怎么用 八股讲不顺 → 基础环节会挂;讲得顺 → 只是不被这块淘汰,不等于能过。注意本题库不含算法,而算法是大厂应届的硬门槛。场景题难度对标社招两三年,已超出应届考察标准,它的价值是让你知道正确的思考框架——照原文背会被追问穿,要用自己真做过的经历去填充表达。

面试题库(电商 / 短视频 / 社区 / 本地生活 / 旅游 / 企业服务 多业务场景)

一、渲染机制

  1. Widget、Element、RenderObject 三棵树分别是什么,为什么需要三棵?★★★

    Widget 是配置描述,不可变、极轻量,只是告诉框架「我想要什么样的界面」,每次 build 都会重新创建一批。RenderObject 负责真正的布局和绘制,创建成本高,持有布局结果和绘制逻辑。Element 是两者之间的桥梁,代表 Widget 在树上某个位置的实例,它持有状态、管理生命周期,并且是唯一长期存活的那一层。

    为什么需要中间这层?核心是性能。Widget 每次 setState 都全部重建,如果直接对应 RenderObject,就等于每一帧都在销毁重建所有渲染对象,成本无法承受。Element 树的作用是做新旧 Widget 的比对:新的 Widget 来了,Element 判断能不能复用自己对应的 RenderObject,如果能,就只更新 RenderObject 上变化的属性,树结构完全不动。

    这样就形成了一个分工:Widget 层可以随便重建(因为它便宜),Element 层承担 diff 和状态保存,RenderObject 层保持稳定只做增量更新。State 对象挂在 Element 上,这也解释了为什么 Widget 是不可变的却能保存状态——状态压根不在 Widget 里。

    顺带说 BuildContext 其实就是 Element 本身,Element 类实现了 BuildContext 接口。所以 context 能往上查找祖先节点、能拿到 RenderObject 的尺寸,因为它就是树上的那个节点。理解这一点,很多「为什么 context 在这里不能用」的问题就清楚了。

  2. Element 复用的判断条件是什么,什么时候必须加 Key?★★★

    复用判断在 Widget.canUpdate,条件是新旧 Widget 的 runtimeType 相同、并且 key 相同(都为 null 也算相同)。满足就复用 Element 和 RenderObject,只更新配置;不满足就销毁旧的、创建新的,State 也跟着丢掉。

    关键问题出在列表上。框架在同一层级里是按索引顺序比对的,如果不加 Key,把列表第一项删掉,原来第二项跑到了索引 0 的位置,框架发现类型相同就复用了原来第 0 个 Element,于是State 留在了原位置——表现出来就是:删掉第一个 item,结果消失的内容是对的,但某个 item 的勾选状态、输入框内容、动画进度串到别的 item 上去了。

    所以规则是:列表里的元素是有状态的(StatefulWidget),并且列表会发生增删或重排时,必须加 Key,而且要用能唯一标识数据的业务 ID 做 ValueKey,不能用索引(用索引等于没加,索引本身就会变)。如果列表项是无状态的,不加也没问题。

    Key 分两类。LocalKeyValueKeyObjectKeyUniqueKey)只在同一父节点下的兄弟之间比较。GlobalKey全局唯一的,能力更强——可以跨树位置保持 State(把一个 Widget 从树的一处移到另一处而不丢状态),还能通过它直接访问其他 Widget 的 State 和 RenderObject。但 GlobalKey 代价高:它要维护一张全局注册表,而且跨位置移动会触发额外的重建。所以只在真正需要时用,比如拿 Form 的状态做校验、获取某个组件的尺寸位置。不要用 GlobalKey 当作组件间通信的手段,那说明状态管理设计有问题。

  3. Flutter 的布局约束是怎么传递的,为什么会报 unbounded height?★★★

    一句话概括布局规则:约束往下传,尺寸往上返,位置由父节点决定。父节点给子节点一个 BoxConstraints(最大最小宽高),子节点在这个范围内决定自己的尺寸并返回给父节点,然后父节点决定把它放在哪。整个过程一次深度遍历完成,这是 Flutter 布局性能好的原因——不像 Web 那样可能多次回流。

    约束分两种:紧约束(最大等于最小,尺寸被父节点定死)和松约束(最小为 0,子节点可以自己决定)。理解一个组件的行为,关键就是看它给子节点传的是什么约束。

    unbounded 的意思是某个方向的最大约束是无限。典型场景是 Column 里嵌 ListView:Column 在主轴方向给子节点的是无限高约束(因为它想让子节点先说自己要多高),而 ListView 默认想占满父节点给的全部高度,于是它拿到无限高,就没法确定自己该多高了,直接报错。同类问题还有 SingleChildScrollView 里放 ListView、Row 里放横向滚动列表。

    解法看意图。想让 ListView 占满剩余空间用 ExpandedFlexible,它会把无限约束转成紧约束。想让 ListView 按内容高度撑开用 shrinkWrap: true,但要注意这会让它提前计算所有子项的高度,失去懒加载优势,列表长了会卡,所以只适合项数很少的情况。给固定高度用 SizedBox。真正的长列表混合布局应该用 CustomScrollView 加各种 Sliver,这才是正确解法。

    还有个相关知识点:LayoutBuilder 能拿到父节点传下来的约束,用来做响应式布局。但它会导致布局分两个阶段,用多了影响性能,而且不能在需要 intrinsic 尺寸的地方用。

  4. 渲染流水线有哪几个阶段,RepaintBoundary 起什么作用?★★★

    一帧的流程是:动画回调 → build(构建)→ layout(布局)→ paint(绘制)→ composite(合成)→ 光栅化上屏。build 生成 Widget 树的变更,layout 确定每个 RenderObject 的尺寸位置,paint 把绘制指令记录到 Layer 上,composite 把 Layer 树合成,最后交给 GPU 光栅化。

    关键是脏标记加边界的机制。某个节点需要重新布局时调 markNeedsLayout,这个标记会向上冒泡,直到遇到一个 relayout boundary 才停下。所谓 relayout boundary 是指「这个节点的尺寸不受子节点影响」的节点,比如父节点给的是紧约束时,子节点怎么变都不影响自己的尺寸,那就没必要往上传。这样布局的重算范围被限制在局部。

    RepaintBoundary 是绘制层面的同类机制:它让子树拥有独立的 Layer,子树重绘时不会影响外部,外部重绘也不影响它。典型用法是给频繁重绘的区域包一层,比如一个不停转的动画、一个视频播放区域,这样它每帧重绘时,周围静态的内容不用跟着一起重绘。

    不能滥用。每个 RepaintBoundary 都要额外分配一块图层内存,还增加合成阶段的开销。加得太多反而更慢。判断该不该加,用 DevTools 的 debugRepaintRainbowEnabled,开启后每次重绘的区域会闪色框,能直观看出哪些区域在不必要地重绘。

    补充一个实践点:ListView 默认已经给每个 item 加了 RepaintBoundary(addRepaintBoundaries 默认 true),所以列表项不用自己再包。反过来如果 item 特别简单,关掉它反而更快。

  5. setState 做了什么,为什么它可能很贵?★★★

    setState 本身只做两件事:执行你传进去的回调改状态,然后调用 Element.markNeedsBuild 把当前 Element 标记为脏,并注册到下一帧的重建列表里。它不是同步立即重建,而是等下一帧统一处理,所以连续调用多次只会重建一次。

    它「贵」的地方在于重建的范围是整个 build 方法。如果你在一个大页面的顶层 State 里调 setState,那整个页面的 Widget 树都会重新构建一遍——即使实际变化的只是角落里一个数字。虽然 Element 层会做 diff、大部分 RenderObject 能复用,但Widget 对象的创建和 diff 本身就有成本,树很大时这个成本很可观,尤其是 build 方法里还包含了复杂的条件判断或者数据计算。

    所以优化的思路是缩小重建范围。几个具体手段:把变化的部分拆成独立的小 StatefulWidget,setState 只影响那一小块;用 const 构造 Widget,const 实例在 canUpdate 时是同一个对象,可以直接跳过;用 ValueListenableBuilderSelector 这类只重建局部的组件;把不变的子树提取成变量,避免在 build 里重复创建。

    还有几个常见错误值得提:在 build 里调 setState 会导致无限循环;在 dispose 之后调 setState 会报错,异步回调里要先判断 mountedinitState 里调 setState 没必要也可能报错,直接赋值就行。异步请求返回后 setState 忘记判断 mounted,是实际项目里最常见的报错来源之一。

  6. Impeller 是什么,它解决了 Skia 的什么问题?★★★

    Impeller 是 Flutter 自研的渲染引擎,用来替代原来的 Skia。它现在已经是 Android、iOS 的默认渲染器,桌面端(Windows、Linux、macOS)在 Flutter 3.47 也转为默认,Skia 只在少数不支持 Vulkan 的老设备上作为回退。

    它解决的核心问题是着色器编译卡顿。Skia 的着色器是运行时按需编译的,当界面上出现一个新的绘制组合(某种渐变、某种模糊、某个新的动画效果),就要现场编译对应的着色器,这个过程可能耗时几十甚至上百毫秒,直接导致掉帧。表现出来就是首次进入某个页面、首次播放某个动画时明显卡一下,第二次就流畅了。以前的缓解手段是「着色器预热」,先跑一遍把用到的着色器录下来打进包里,但这个方案很脆弱,录不全或者版本一变就失效。

    Impeller 的做法是把着色器集合固定下来并在编译期预先编译,运行时不再需要现场编译,从根上消除了这类卡顿。同时它针对现代图形 API 重新设计,iOS 上用 Metal、Android 上用 Vulkan,能更好地利用 GPU 特性,并且渲染时间的可预测性更好——这一点对动画流畅度比「平均更快」更重要。

    实际影响:新项目不用再折腾着色器预热了。要注意的是历史上 Impeller 刚切换默认时,一些自定义绘制、特殊的 BackdropFilter 用法、以及部分第三方渲染插件出现过表现差异或性能回退,遇到渲染异常可以用 --no-enable-impeller 临时回退验证,但这不是长期方案。

  7. Sliver 布局协议和普通 Box 协议有什么区别,为什么长列表必须用它?★★★

    两套协议传递的东西完全不同。Box 协议父节点传 BoxConstraints(最大最小宽高),子节点返回一个 Size。Sliver 协议父节点传 SliverConstraints,子节点返回 SliverGeometry

    SliverConstraints 里最关键的几个字段是滚动相关的:scrollOffset 表示我已经被滚出去多少了,remainingPaintExtent 表示视口里还剩多少可见空间给我,remainingCacheExtent 是含预加载区域的剩余量。返回的 SliverGeometry 里,scrollExtent 是「我总共要占多长的滚动距离」,paintExtent 是「我当前实际画了多长」。

    这两个值可以不相等,这就是懒加载的根基。Box 协议下一个节点必须知道自己完整的尺寸才能返回,一千条数据就得全部布局出来才知道总高。而 Sliver 可以说「我总共占十万像素的滚动距离,但我现在只画了屏幕内这 800 像素」——只对可见区域做布局和绘制,剩下的仅用一个数字声明存在。所以 ListView.builder 底层就是 SliverListSliverChildBuilderDelegate,只处理可见范围加 cacheExtent 那一圈。

    由此也能解释为什么混合布局必须用 CustomScrollView 而不是 Column 里套几个 ListViewCustomScrollView 里所有 Sliver 共享同一个 ScrollableViewport,也就是一个滚动上下文,它们能感知彼此的滚动位置,所以 SliverAppBar 才能正确吸顶、SliverList 才能正确懒加载。而 Column 里的每个 ListView 都是独立的滚动上下文,一是手势归属会打架,二是为了在 Column 里确定高度往往被迫 shrinkWrap,直接把懒加载优势废掉了。

    实践上知道这套协议的意义在于:遇到「头部折叠加多个列表加吸顶标签」这种需求,第一反应就该是拆成若干 Sliver 塞进一个 CustomScrollView,而不是嵌套滚动组件再想办法压住冲突。

二、Dart 运行机制

  1. Dart 的事件循环是怎么工作的,microtask 和 event queue 有什么区别?★★★

    Dart 是单线程加事件循环的模型。一个 Isolate 里有两个队列:microtask 队列event 队列。事件循环的规则是:先把 microtask 队列全部清空,然后才处理 event 队列里的一个事件,处理完再回头检查 microtask 队列。

    也就是说 microtask 优先级更高,而且是插队执行。microtask 来自 scheduleMicrotask 和一部分 Future 的内部实现;event 队列装的是 IO 完成、定时器到期、用户手势、以及 Future 的回调。

    实际影响很直接:如果在 microtask 里不断产生新的 microtask,event 队列就永远得不到执行,界面会完全卡死。所以业务代码里基本不该用 scheduleMicrotask

    另一个常考的点是执行顺序。Future(() {}) 是把任务扔进 event 队列,而 Future.value() 或者 Future.sync 后面的 then 会走 microtask。所以下面这段的输出顺序是 1、4、3、2:

    print(1);
    Future(() => print(2));                    // event 队列
    Future.microtask(() => print(3));          // microtask 队列
    print(4);

    还有一个要点是 await 之后的代码相当于 then 里的回调,会被放进队列而不是继续同步执行。这解释了很多「为什么我 await 之后拿到的状态变了」的问题——因为 await 期间让出了执行权,别的代码跑过了。

    最后强调:单线程意味着任何耗时的同步计算都会卡住 UI,因为没有别的线程能接手。这也是为什么下一题的 Isolate 那么重要。

  2. Isolate 为什么不共享内存,什么时候需要用它?★★★

    每个 Isolate 有独立的内存堆和独立的事件循环,之间不共享任何内存,只能通过 SendPort 发消息通信,消息内容会被拷贝过去。

    这么设计的好处是彻底没有数据竞争,不需要锁、不需要考虑内存可见性,也就不会有死锁。而且因为堆是独立的,GC 可以各自独立进行,一个 Isolate 做垃圾回收不会暂停其他 Isolate,这对保持 UI 流畅很有价值。代价就是通信要序列化拷贝,传大数据有开销。

    需要用它的场景是CPU 密集的计算:JSON 解析大数据、图片处理、加解密、复杂的列表排序或过滤。判断标准很简单——如果一段同步代码执行超过十几毫秒,就会让当帧超时掉帧,应该扔到 Isolate 里。

    注意网络请求和文件 IO 不需要 Isolate,因为它们本来就是异步的,底层由引擎的线程池处理,不会阻塞 Dart 线程。把 IO 放进 Isolate 是常见的误用,纯属浪费。

    用法上,简单的一次性任务用 Isolate.run(Dart 2.19 之后,比老的 compute 更简洁),它自动处理创建和销毁。需要长期通信就自己 Isolate.spawnReceivePort 建立双向端口。要注意传给 Isolate 的函数必须是顶层函数或静态方法,不能是捕获了外部变量的闭包(因为闭包的上下文没法拷贝)。

    大数据传输可以用 TransferableTypedData零拷贝转移(所有权转移而不是复制),这在处理图片字节流时能省下明显的开销。另外要清楚创建 Isolate 本身有成本(几毫秒和一块内存),高频小任务频繁开销反而不划算,这种情况应该复用一个常驻 Isolate。

  3. async/await 的本质是什么,Future 里的代码什么时候开始执行?★★★

    async/await 是语法糖,编译器把它转换成状态机加回调的形式,本质上等价于 then 链,但可读性好得多。它不创建新线程,全程还是在同一个 Isolate 的单线程上跑,只是在 await 的地方让出执行权,等结果就绪后从队列里被调度回来继续。

    关于执行时机有几个容易搞错的点。async 方法体不是立刻异步的:调用一个 async 函数,函数体会同步执行,直到遇到第一个 await 才返回一个未完成的 Future 给调用方。所以 async 函数开头的代码是同步跑的,这经常被误解。

    Future(() { ... }) 构造出来的任务是放进 event 队列下一轮才执行,而 Future.value(x) 是立刻完成的 Future,它的 then 走 microtask。Future.sync(() {...})同步执行函数体,只有返回值被包成 Future。

    实践中几个坑。一是忘记 await,代码看起来没问题但实际没等结果就往下走了,这类 bug 很隐蔽,开启 unawaited_futures 这条 lint 规则能提示。二是循环里串行 await,几十个请求一个个等,总耗时是累加的,应该用 Future.wait 并发发起。三是异常处理,async 函数里抛的异常会变成 Future 的错误状态,如果没有人 catch,就成了未捕获的异步异常,可能悄悄丢掉,所以要么 try-catch 要么加 catchError

    还有一个和 UI 相关的:await 之后一定要检查 mounted,因为等待期间用户可能已经离开这个页面,Widget 被 dispose 了,这时候 setState 或者用 context 都会报错。

三、生命周期与状态管理

  1. StatefulWidget 的生命周期有哪些,didUpdateWidgetdidChangeDependencies 什么时候触发?★★

    顺序是 createStateinitStatedidChangeDependenciesbuild,之后每次父级重建走 didUpdateWidgetbuild,最后 deactivatedispose

    initState 只执行一次,用来创建 controller、订阅 Stream、发起首次请求。注意这里不能调 Theme.ofMediaQuery.of 这类依赖 InheritedWidget 的方法,因为依赖关系还没建立。

    didChangeDependencies 在 initState 之后立刻执行一次,之后每当依赖的 InheritedWidget 发生变化时都会再执行。所以「需要根据主题或屏幕尺寸做的初始化」应该放这里,而不是 initState。

    didUpdateWidget 是最容易被忽略、也最容易出 bug 的一个。它在父级重建并传入了新的 Widget 配置时触发,参数是 oldWidget。关键点在于:这时候 State 对象被复用了,但 widget 已经换成新的了。如果你在 initState 里根据 widget.userId 发了请求,父级把 userId 改了之后 initState 不会再执行,页面就一直显示旧用户的数据。正确做法是在 didUpdateWidget 里比对新旧值,变了才重新请求或者重建 controller:

    @override
    void didUpdateWidget(MyWidget oldWidget) {
      super.didUpdateWidget(oldWidget);
      if (oldWidget.userId != widget.userId) {
        _loadData();           // 只在真的变了的时候才重做
      }
    }

    所以有个配套习惯:凡是 initState 里用到了 widget.xxx 的逻辑,就要想一下 didUpdateWidget 里要不要同步一遍

    deactivate 是从树上被移除时调用,但它可能被重新插入(比如带 GlobalKey 的 Widget 换了位置),所以清理工作不能放这里。dispose 才是真正销毁,controller 和订阅都在这里释放。

  2. BuildContext 到底能做什么,为什么 await 之后不能直接用它?★★

    context 就是 Element 本身,代表 Widget 在树上的那个位置。所以它的全部能力都是基于位置的:往上查找祖先(Theme.ofNavigator.ofProvider.of),以及拿到自己对应的 RenderObject(获取尺寸和坐标)。理解「它是位置而不是对象引用」,下面几个坑就都通了。

    async 之后不能直接用,因为等待期间用户可能已经返回、页面被销毁,这个 Element 已经从树上摘掉了。此时再拿它去 Navigator.of(context).pop() 或者 showDialog,要么报错要么操作到一棵失效的树。所以 await 之后必须先判断:

    final data = await api.fetch();
    if (!mounted) return;              // 关键的一行
    Navigator.of(context).pop(data);

    Dart 3 之后 BuildContext 自己也有 mounted,在非 State 的地方可以用 context.mounted。lint 规则 use_build_context_synchronously 打开能自动提示这类问题,强烈建议开着。

    第二个坑是拿到的祖先不是你想的那个。最典型的是 Scaffold.of(context) 在同一个 build 方法里取不到刚写的 Scaffold——因为你手上的 context 位于 Scaffold 之上,而查找只能往上走。解法是用 Builder 包一层,制造一个位置更深的 context。

    第三个坑是 dialog 的 context 和页面的 context 是两个不同的位置。在 dialog 里想关掉 dialog 应该用 dialog builder 给的 context,用页面的 context 去 pop 会把整个页面弹掉。这个错误特别常见,表现是「点确认按钮结果整页退回去了」。

    最后一个是前面说过的 initState 里不能查 InheritedWidget,要放到 didChangeDependencies

  3. InheritedWidget 的原理是什么,为什么它的查找是 O(1)?★★★

    InheritedWidget 是 Flutter 内置的数据向下共享机制,ThemeMediaQueryProvider 全都基于它。用法是子孙节点通过 dependOnInheritedWidgetOfExactType 拿到它,同时自动建立依赖关系,数据变化时只有这些依赖它的节点会重建。

    查找之所以是 O(1) 而不是逐层往上遍历,关键在于每个 Element 都持有一张祖先 InheritedWidget 的哈希表_inheritedWidgets)。这张表在 Element 挂载时从父节点继承过来,如果自己是 InheritedWidget 就往里加一项。因为大多数节点只是把父节点的表原样引用,所以维护成本很低。查找时直接按类型在哈希表里取,一步到位。

    这个设计很值得说,它体现了用空间换时间加共享不变数据的思路:如果每次查找都往上走,深层节点访问 Theme 就是 O(树高),而界面嵌套几十层很常见。

    更新机制上,updateShouldNotify 决定要不要通知依赖者。返回 false 就一个都不通知,这是个重要的优化点——Provider 的 Selector 就是靠精细控制这个来减少重建。

    两个实践要点:一是 context.watchcontext.read 的区别,watch 会建立依赖(数据变了会重建),read 只取一次值不建立依赖,在事件回调里应该用 read,在 build 里用 watch,用错了会导致「点按钮后整个页面重建」或者「数据变了界面不刷新」。二是不能在 initState 里用 dependOnInheritedWidgetOfExactType,因为那时候依赖关系还没建立好,需要的话放到 didChangeDependencies 里。

  4. Provider、Riverpod、Bloc 的核心差异是什么,怎么选?★★

    Provider 是对 InheritedWidget 的封装,把「往树上放数据」和「取数据」变得简单。它的特点是依赖 Widget 树——数据的作用域由它在树上的位置决定,取数据必须有 context。这带来的问题是:树外面(比如一个纯 Dart 的 service 层)拿不到状态,而且容易遇到「找不到 Provider」这类运行时错误。

    Riverpod 是同一个作者对 Provider 的重新设计,核心改变是脱离 Widget 树:Provider 定义成全局变量,取值不需要 context。好处是编译期安全(不会有找不到的运行时错误)、可以在任何地方读写状态、天然支持组合和依赖注入、测试也更容易(直接覆盖 provider)。代价是概念比较多,上手曲线陡一些。

    Bloc 强调事件驱动和单向数据流:UI 发出 Event,Bloc 处理后输出 State,UI 根据 State 渲染。它的价值在于状态变更过程是显式且可追溯的,每一次变化都有对应的事件,天生适合做日志、时间旅行调试、以及复杂的状态机逻辑。代价是样板代码多,一个简单的开关也要定义 Event 和 State 类。

    我的选择逻辑是按项目复杂度和团队规模来定,而不是追新。小项目、状态简单,ValueNotifierValueListenableBuilder 就够了,压根不用引入框架。中等规模用 Riverpod,平衡最好。大型项目多人协作、业务流程复杂,Bloc 的强约束反而是优点——它让所有人写出结构一致的代码,新人接手也容易看懂数据流向。

    要避免的是在一个项目里混用多套方案,这比选了个不那么完美的方案糟糕得多。

  5. const 构造器为什么能提升性能,怎么做局部刷新?★★

    const 构造的 Widget 在编译期就创建好并被规范化(canonicalize),相同参数的 const Widget 在整个程序里是同一个实例。这带来两个好处:一是不用每次 build 都重新分配对象,减少 GC 压力;二是 diff 的时候,新旧 Widget 是同一个对象,框架可以直接判定无变化跳过整棵子树,连递归比对都省了。

    所以尽量给不含变量的 Widget 加 const,尤其是列表项里的图标、分割线、静态文本这些。开启 prefer_const_constructors lint 规则让工具自动提示。

    局部刷新的手段按精细程度排:

    拆分 Widget 是最基础也最有效的。把会变的部分单独抽成一个 StatefulWidget,setState 的影响范围就自动缩小到那一块。很多性能问题只要做好组件拆分就解决了,不需要任何框架。

    ValueListenableBuilder 配合 ValueNotifier,只有 builder 里的那点内容会重建,不需要 setState,也不引入第三方库,做单个值的响应式更新特别合适。

    Selector(Provider)或者 select,从一个大的状态对象里只挑出关心的字段,只有这个字段变了才重建。这解决了「Model 里改了任意字段导致所有监听者都重建」的问题。

    还有个常被忽略的技巧:把不变的子树提取成 final 变量或者作为参数传入。因为传进来的 Widget 实例在父节点重建时是同一个对象,diff 直接跳过。Builder 组件也可以用来隔离重建范围。

四、动画与手势

  1. AnimationController 是怎么驱动动画的,为什么需要 vsync★★★

    动画的本质是每一帧改一次值,然后触发重建或重绘。驱动这个「每一帧」的东西是 Ticker,它注册到 SchedulerBinding 上,跟屏幕的 vsync 垂直同步信号对齐,每帧回调一次并带上距离动画开始的时长。AnimationController 拿到这个时长,按 duration 算出当前进度(0 到 1),通知所有监听者。

    vsync 参数要的就是一个 TickerProvider,一般用 SingleTickerProviderStateMixin(一个 controller)或 TickerProviderStateMixin(多个)混入 State。

    为什么必须绑到 State 上?因为这样 Ticker 的生命周期就和 Widget 绑定了:页面被切到后台或者不可见时,框架会把 Ticker 静音(muted),动画自动停下来,不会在用户看不到的地方白烧 CPU 和电量。如果不绑,一个跑在后台的无限循环动画会一直占着每帧的时间。所以 vsync 不是形式参数,它是性能和续航的保障机制

    值的组织是这样:AnimationController 本身是 Animation<double>,范围 0 到 1;用 Tween 映射到实际范围(颜色、尺寸、偏移);用 CurvedAnimation 套上缓动曲线。

    隐式动画和显式动画怎么选AnimatedContainerAnimatedOpacityTweenAnimationBuilder 这类隐式动画只要改目标值,框架自动补间,代码最少,适合简单的状态过渡。需要精确控制播放、暂停、反向、循环,或者动画由手势驱动,就得用 controller 加显式动画。

    最后一个必须知道的性能点:不要在 addListener 里调 setState。那意味着每帧都重建整个 build 方法,一秒六十次,很容易掉帧。正确做法是用 AnimatedBuilder 或者各种 XxxTransitionFadeTransitionSlideTransition),它们只重建 builder 内部那一小块,外面的 Widget 完全不动。这是动画性能问题里最高频的一个原因。当然 controller 也必须在 dispose 里释放。

  2. 一次点击是怎么找到响应者的,多个手势同时想响应时谁赢?★★★

    分两个阶段:命中测试找出候选者,手势竞技场裁决赢家。

    命中测试(HitTest)RenderView 开始,拿着触摸点坐标递归往下问每个 RenderObject「这个点在你范围内吗」。父节点会先问子节点,命中的节点被依次加进 HitTestResult,所以最终这条链的头部是最深、视觉上最上层的节点,尾部是根节点。之后事件沿这条链分发。

    这里有个实用知识点:GestureDetectorbehavior 决定空白区域算不算命中。deferToChild(默认,有子节点被命中才算)、opaque(整个区域都算命中)、translucent(自己命中同时让下面的也能收到)。给一个只有文字的可点击区域加不上点击响应,通常就是因为空白部分没被命中,改成 opaque 就好了。

    手势竞技场(GestureArena)解决的是「多个识别器都对这一次触摸感兴趣」。链上每个 GestureRecognizer 都会通过 addPointer 报名进入竞技场,然后各自根据后续的移动事件判断要不要争。结束方式有两种:某个识别器主动宣布胜出,或者其他都主动放弃,最后只剩一个自动获胜。竞技场只能有一个赢家,这是理解所有手势冲突的关键。

    典型的裁决过程:手指按下时 Tap 和 Drag 都报名,Tap 会先等着,一旦移动距离超过阈值,Tap 主动放弃,Drag 胜出;如果手指没怎么动就抬起,Tap 胜出。这也解释了一个体验现象——可滑动区域里的按钮点击会有一点点延迟感,因为要先确认你不是想滑动。

    另外两个常用的东西:Listener 是更底层的原始指针事件,不参与竞技场,所以它能拿到全部事件不会被别人抢走;IgnorePointer 让子树完全不参与命中测试(点击穿透过去),AbsorbPointer 是自己吞掉事件但不让子树收到。

五、性能与内存

  1. Flutter 卡顿怎么排查,UI 线程和 Raster 线程超时分别说明什么?★★★

    先明确一帧的预算:60 帧是 16.6 毫秒,120 帧只有 8.3 毫秒。DevTools 的 Performance 面板会把每一帧拆成两段耗时,这两段指向完全不同的问题,先分清再动手。

    UI 线程超时(图上是蓝色)说明 Dart 代码本身太慢:build 方法里做了复杂计算、一次 setState 触发了巨大的子树重建、在 build 里同步解析 JSON、或者布局层级太深。排查用 Timeline 的火焰图看具体是哪个方法耗时,也可以在代码里插 Timeline.startSync 自己打点。

    Raster 线程超时(绿色)说明绘制和光栅化太重:图片太大需要缩放、用了 saveLayer 的操作(OpacityClipRRectShaderMask 这些都会触发)、复杂的阴影和模糊效果、或者图层太多合成压力大。这里的优化方向完全不同:换用更轻的实现,比如用 Containercolor 代替 Opacity、用带圆角的 decoration 代替 ClipRRect、静态复杂图形考虑预先转成图片。

    几个实用工具开关:debugProfileBuildsEnabled 看每个 Widget 的 build 耗时,debugRepaintRainbowEnabled 看哪些区域在重绘,debugPaintLayerBordersEnabled 看图层划分。性能测试必须用 profile 模式,debug 模式下有大量断言和额外检查,数据完全不能反映真实情况,这一点经常有人搞错。

    另外要区分持续卡顿偶发掉帧。持续卡顿一般是渲染或计算问题;偶发的大幅掉帧要怀疑 GC(Memory 面板能看到 GC 事件)、图片解码、或者首次加载着色器(不过 Impeller 之后这个基本没了)。

  2. Flutter 里内存泄漏的常见原因有哪些?★★★

    几乎都是该 dispose 的没 dispose,也就是 State 销毁了但它创建的资源还挂在别的地方被引用着。

    AnimationController 没 dispose 最常见,它注册在 Ticker 上会被 SchedulerBinding 持有,不释放的话不仅泄漏内存,动画还会一直在后台跑消耗 CPU。StreamSubscription 忘记 cancel,Stream 会一直持有回调闭包,而闭包捕获了 State,整个页面都释放不掉。Timer 特别是 Timer.periodic,不 cancel 就永远在跑。TextEditingControllerScrollControllerFocusNode 这些也都要 dispose。

    还有几类不那么明显的。全局单例或静态变量持有 context 或 State,比如把 context 存进一个全局的工具类里,页面关了也释放不掉,而且 context 一失效就会引发各种奇怪报错。事件总线注册了没注销,跟 Stream 是同一个问题。图片缓存ImageCache 默认上限是 1000 张或 100MB,大量大图会把它撑满。闭包意外捕获,一个长生命周期的回调里引用了 State 的成员,就把整个 State 拖住了。

    排查手段:DevTools 的 Memory 面板,反复进出同一个页面然后看某个类的实例数是否在增长,这是判断泄漏最直接的方法。Snapshotreachable path 能看到对象被谁引用着。另外 Flutter 有个 leak_tracker 工具(新版本 DevTools 集成了泄漏检测),能自动报告没有被 dispose 的可释放对象。

    预防上有个很实用的习惯:initState 里创建资源的同一时刻,就把 dispose 里的清理代码写上,不要等后面补。另外 lint 规则里有 cancel_subscriptionsclose_sinks,打开能拦住一部分。

  3. 长列表怎么优化,图片内存怎么控制?★★★

    长列表第一原则是必须用 ListView.builder 而不是 ListView(children: [...])。后者会一次性构建所有子项,一千条数据就是一千个 Widget 全部创建,内存和首屏时间都撑不住。builder 是按需构建,只创建可见区域附近的项。

    进一步的优化点。itemExtent 或者 prototypeItem,如果每项高度固定,告诉框架这个高度,它就不需要逐项测量来计算滚动位置,滚动性能提升明显,跳转到指定位置也变成 O(1)。控制 cacheExtent,它决定视口外预构建多少像素的内容,调大滚动更顺滑但内存占用高,默认值一般够用。item 内部结构尽量扁平,避免深层嵌套和不必要的 Stack避免在 itemBuilder 里做计算,数据要在进列表前处理好。

    复杂的混合布局(顶部横幅加吸顶标签加多个列表)不要用嵌套滚动加 shrinkWrap,那会失去懒加载。正确做法是 CustomScrollViewSliverAppBarSliverListSliverPersistentHeader 组合,整个页面只有一个滚动上下文,性能和体验都对。

    图片是内存的头号消耗者,关键认识是一张图占的内存跟文件大小无关,只跟解码后的像素数有关:宽乘高乘 4 字节。一张 4000×3000 的照片解码后是 48MB,哪怕文件只有 2MB。所以列表里放几张大图就能把内存打爆。

    控制手段最重要的一条是cacheWidthcacheHeight 指定解码尺寸,让图片按显示需要的尺寸解码,而不是原图尺寸。显示区域 100 逻辑像素宽,就没必要解码成 4000 像素。ResizeImage 也是同样作用。其次是服务端下发合适尺寸的图,这比客户端处理更彻底。然后是管理 ImageCache,可以调整 maximumSizemaximumSizeBytes,在内存告警时调 clear。最后是用 cached_network_image 这类库做磁盘缓存,避免重复下载和解码。

六、混合开发与工程化

  1. Platform Channel 有哪几种,什么时候该用 FFI?★★★

    三种。MethodChannel 最常用,一次调用一次返回,适合「调个原生方法拿结果」,比如获取设备信息、调起支付。EventChannel 是原生向 Dart 持续推送,基于 Stream,适合传感器数据、电池状态、下载进度这类连续事件。BasicMessageChannel 是双向的自由消息传递,用得少,需要自定义编解码时才用。

    底层都是通过 BinaryMessenger 传二进制数据,用 StandardMessageCodec 把 Dart 对象和原生对象互相转换,支持的类型有限(基本类型、List、Map),复杂对象要自己序列化成 JSON 或者拆成 Map。

    要注意Channel 调用是异步的,而且有序列化开销。所以不适合高频调用——比如每帧都通过 Channel 取一次数据,那个开销会直接吃掉帧预算。这种场景要么把数据批量传,要么改用别的机制。另外要注意原生侧必须在主线程回调,在子线程调 result.success 会出问题(Android 上要切回 UI 线程)。

    FFI 是直接调用 C/C++ 函数,走的是同步调用、零序列化,性能接近原生调用。适合两类场景:一是调用现成的 C/C++ 库,比如 SQLite、FFmpeg、OpenCV、加解密算法库,不用为它写一层 Channel 桥接;二是高性能计算,需要频繁调用或者传大块内存数据的场景,可以直接共享指针操作同一块内存,避免拷贝。

    选择上很清楚:要调平台 SDK 和系统能力(相机、蓝牙、推送)用 Channel,要调 C/C++ 库或者追求极致性能用 FFI。FFI 的代价是要处理内存管理(手动 malloc 和 free,容易泄漏或崩溃),而且要为每个平台编译好动态库,工程配置更麻烦。

  2. 原生 View 怎么嵌到 Flutter 里,有什么坑?★★★

    PlatformView,典型场景是嵌入地图、WebView、视频播放器、原生广告这些自己实现代价太高的组件。

    难点在于Flutter 的界面是自己绘制在一块画布上的,而原生 View 由系统渲染,两套渲染体系要合成到一起。历史上有几种实现方式。早期 Android 用 VirtualDisplay,把原生 View 渲染到一块离屏纹理再交给 Flutter 合成,问题是触摸事件要手动转发、键盘和无障碍功能有各种异常。后来推出 HybridComposition,把原生 View 真正插进视图层级,交互和文本输入都正常了,代价是会打断 Flutter 的图层合成,可能引起性能下降,尤其是在原生 View 上面又叠 Flutter 内容时。iOS 侧的机制类似但细节不同。

    实际会遇到的坑:手势冲突,原生 View 和外层 Flutter 的滚动组件会抢手势,需要用 gestureRecognizers 明确声明谁优先,把 WebView 放进 ListView 里几乎必然遇到这个问题。层级和圆角,给 PlatformView 加圆角、阴影、透明度这些效果经常不生效或者表现异常,因为它不是 Flutter 自己画的。性能,一个页面里放多个 PlatformView 会明显掉帧,长列表里更不能这么干。生命周期,Flutter 页面销毁时要确保原生 View 也被正确释放,不然容易泄漏。

    所以我的实践原则是能不用就不用,优先找纯 Flutter 的实现方案。确实必须用(地图、WebView),就把它单独放一个页面,不要嵌在复杂布局或者列表里,能规避大部分问题。

  3. 已有原生 App 怎么接 Flutter,混合栈要解决什么问题?★★★

    接入方式是把 Flutter 作为一个模块编译成产物(Android 是 aar,iOS 是 framework),原生工程依赖它,然后通过 FlutterActivityFlutterViewController 打开 Flutter 页面。

    核心难题是两套导航栈的协同。原生有自己的页面栈,Flutter 内部也有 Navigator 的路由栈,两边互不知情。如果简单地「每个 Flutter 页面都用一个原生容器承载」,就会创建多个 FlutterEngine,每个引擎都要几十兆内存,还各自持有独立的 Dart 堆,页面间共享状态也很麻烦。反过来如果只用一个引擎,那么原生 A → Flutter B → 原生 C → Flutter D 这种交错跳转时,Flutter 侧的栈状态就会错乱,返回时页面对不上。

    具体表现出来的问题:页面生命周期不同步(Flutter 页面被原生页面覆盖时,它不知道自己不可见了,动画和定时器还在跑)、返回手势和物理返回键行为异常页面栈内存泄漏转场动画不一致

    解决方案主流是Flutter Boost(闲鱼开源)这类混合栈框架,思路是共用一个 FlutterEngine,但把 Flutter 的页面容器和原生页面栈做统一管理:由原生栈作为唯一真相来源,Flutter 侧维护一个与之对应的容器映射,并正确分发 appeardisappear 生命周期。官方后来也提供了 FlutterEngineGroup,可以让多个引擎共享同一份代码和部分内存,把新增引擎的成本降到几兆,这是官方推荐的方向。

    实践建议:如果只是接一两个独立的 Flutter 页面(比如活动页),用官方方案加单引擎最简单。如果要做大规模混合、几十个页面交错跳转,才值得引入 Boost 这类框架,它的接入和维护成本不低,而且和 Flutter 版本升级经常有兼容问题。

  4. 热重载的原理是什么,为什么有些改动必须重启?★★★

    热重载利用的是 Dart VM 的增量编译加代码替换能力:只把改动的代码编译成增量的 kernel 文件推送给运行中的 VM,VM 替换掉对应类的方法实现,然后触发一次整棵树的重建。因为不重启进程、不重置内存状态,所以能保留当前页面和数据,这是它比重启快得多的原因。

    它只能替换方法体,不能改变程序结构和已有对象的内存布局。所以下面这些改动必须重启:

    initState 或构造函数。因为这些代码只在对象创建时执行一次,State 对象已经存在了,替换方法实现不会让它重新执行一遍。这是最常遇到的情况——改了初始化逻辑但界面没反应,其实是热重载生效了,只是那段代码不会再跑。

    改全局变量和静态字段的初始值。它们已经初始化过了,热重载不会重新赋值。

    改类的结构:加减字段、改继承关系、把 StatelessWidget 改成 StatefulWidget、改枚举定义。这些会改变对象的内存布局,已有实例无法适配。

    main 函数,它只在启动时执行。改原生代码(Java/Kotlin/Swift/OC)、改依赖或者 pubspec.yamlassets 的声明,这些压根不在 Dart VM 的管辖范围内,必须完整重新构建。

    还有个实践细节:热重载不会重新执行当前页面的进入逻辑,所以改了页面初始化相关的代码,最快的验证方式是退出这个页面再进来一次,而不是直接重启整个应用。热重启(hot restart)是重置 Dart 状态但不重新编译原生部分,比完整重启快,改结构性代码时用它。

七、工程与选型

  1. Flutter 的 JIT 和 AOT 分别用在什么时候,包体积怎么优化?★★

    debug 模式用 JIT,代码在运行时编译,所以支持热重载和各种调试断言,代价是执行慢、包体积大。release 模式用 AOT,提前编译成机器码,启动快、运行效率高,但不能热重载。profile 模式是 AOT 加上性能追踪工具,做性能测试必须用它,debug 模式的数据毫无参考价值。

    包体积方面,Flutter 应用的基础体积主要来自引擎(几兆到十几兆,各平台不同)加上你的 Dart 代码编译产物和资源。优化手段:

    按 ABI 拆包,Android 上用 --split-per-abi 分别出 arm64 和 armv7 的包,能省一半左右;上 Google Play 用 App Bundle 会自动处理。压缩资源,图片用 WebP 代替 PNG,音视频不要打进包里改成远程加载,检查有没有误打进包的多倍图和废弃资源。去掉调试符号--split-debug-info 把符号表单独输出,既减小体积又保留了解析崩溃堆栈的能力。代码混淆 --obfuscate 也能省一点。

    Tree shaking 是自动的,但有前提:动态用到的东西摇不掉。典型的是图标字体,如果用了 IconData 动态构造图标,整个图标字体就没法裁剪,会白占一两兆。反射和 dart:mirrors 同理(Flutter 里本来也不支持)。

    还有一个 2026 年比较重要的变化:Material 组件正在从 Flutter 核心里拆出去,独立成 material_ui 这样的包。这个改动的意义是不用 Material 的项目不再被迫带上这部分代码,对包体积和引擎精简都有好处,但老项目升级时要注意导入路径和依赖声明的变化。

  2. Flutter 和 React Native 的本质区别是什么,怎么做技术选型?★★

    本质区别在渲染方式。React Native 是桥接原生组件:你写的 <View> 最终对应 Android 的 ViewGroup、iOS 的 UIView,界面由系统渲染。Flutter 是自绘:只向平台要一块画布,所有像素自己画,不使用任何原生控件。

    这个差别决定了各自的优劣。Flutter 的跨端一致性极好,因为渲染完全由自己控制,两端表现几乎像素级相同,不用为平台差异写适配;性能也更可控,没有 JS 和原生之间的通信瓶颈(RN 的旧架构里这个桥是主要性能问题,新架构 Fabric 和 JSI 改善了很多)。代价是组件外观不跟随系统,系统更新了控件样式或者出了新的交互规范,Flutter 需要自己跟进实现;而且要用系统原生控件(比如 WebView、地图)就得走 PlatformView,前面说过那一堆坑。

    RN 的优势是前端生态复用,React 开发者几乎无缝上手,npm 生态可用,动态更新(热更新)方案成熟——这一点在国内很关键,因为 Flutter 的代码是 AOT 编译的,官方不支持热更新。

    选型上我的判断依据:团队背景是首要因素,前端团队为主选 RN 的落地成本明显更低,客户端团队为主 Flutter 更顺;UI 复杂度,需要大量自定义视觉效果、动画、图表的选 Flutter,它的绘制能力和性能优势明显;是否强依赖热更新,如果业务要求频繁改版不走审核,这是 Flutter 的硬伤;是否需要深度使用系统能力,涉及大量原生控件混排的场景 Flutter 会比较吃力。

    另外一个现实因素是招聘和长期维护。选一个团队能持续招到人、社区活跃的方案,比技术指标上略优要重要得多。

  3. Flutter 里怎么做路由管理,声明式路由解决了什么问题?★★

    早期是 Navigator 1.0 的命令式写法,pushpop 直接操作栈。简单直观,但有几个做不到的事:没法直接声明「当前应该是什么栈」,只能一步步操作;深链接和 Web URL 同步困难,用户直接访问一个深层 URL 时,你得手动把中间的页面栈拼出来;浏览器的前进后退按钮没法正确响应。

    Navigator 2.0 引入声明式模型:你提供一个 Pages 列表描述「当前栈应该是什么样」,框架负责算出差异并执行转场。这样路由状态就变成了应用状态的一部分,可以被序列化、被 URL 表达、被前进后退驱动。代价是原生 API 相当繁琐,要实现 RouterDelegateRouteInformationParser 一堆东西,直接用它写业务代码非常痛苦。

    所以实践中基本都用封装库,go_router 是官方推荐的,它把声明式的能力包成了简洁的路径配置写法,支持嵌套路由、路由守卫(做登录拦截)、参数解析、深链接,同时还兼容 Web 的 URL。auto_route 用代码生成,类型安全更好,参数传递不用手写字符串 key。

    实际项目里我的做法:go_router 做集中式路由表,所有路径定义在一个地方,配合常量或者生成的路由名避免硬编码字符串。登录校验、权限控制统一放在 redirect 里,而不是散落在各个页面的 initState 里。需要传复杂对象时传 ID 而不是整个对象,页面自己去取数据,这样深链接和页面恢复才能正常工作。

    还有一个容易忽略的点:路由传参用 extra 传对象在 Web 刷新后会丢,因为它不在 URL 里。移动端不明显,Web 端会出 bug,做多端时要注意。

没有匹配的题目,换个关键词试试。

模块 06 · 共 27 题 · 题目与答案分离,建议先自答再展开