Flutter 202609面试总结

根据202608左右的纯Flutter岗位的面试记录和总结

一、核心结论

  1. Widget 是不可变配置;Element 是树中的持久实例;RenderObject 负责 layout、paint、hit test、semantics。
  2. rebuild、layout、paint、raster 是不同阶段,前者发生不代表后者必然发生。
  3. setState() 同步修改 State,并将对应 Element 标记为 dirty;Widget 不会被标脏。
  4. 布局口诀:约束向下传递,尺寸向上传递,父节点决定位置。
  5. Element 复用条件:新旧 Widget 的 runtimeTypekey 都相同。
  6. mounted 只能保证 State/Context 尚未卸载,不能解决请求乱序或导航栈变化。
  7. 不要在 build() 中创建 Future 或 Stream,否则 rebuild 可能重复启动异步任务。
  8. Isolate 默认不共享可变内存;普通网络等待是异步 I/O,通常不需要 Isolate。
  9. RepaintBoundary 是重绘边界,不等于一定离屏绘制或一定缓存。
  10. ListView.builder 的核心是懒构建,不是缓存全部 item。
  11. Get.put 立即创建;Get.lazyPut 首次查找时创建;Get.create 每次查找都新建。
  12. debounce 是停止变化后执行;interval 是持续变化时每个时间窗口最多执行一次。
  13. final 是只能赋值一次;const 是编译期常量并具有深层不可变性。
  14. implements 不复用实现;mixin 通过 with 横向复用实现。

二、Event Loop 与 Microtask

1
2
3
执行同步代码或一个 Event
→ 清空 Microtask Queue
→ 处理下一个 Event

“每个 Event 前先清空 Microtask Queue”在结果上基本正确;更严谨地说,当前同步任务或 Event 结束后形成 microtask checkpoint,Microtask 清空后才进入下一个 Event。

如果 Microtask 持续产生新的 Microtask,Timer、I/O 回调等 Event 可能长期无法执行。


三、三棵树与更新流程

Widget、Element、RenderObject

  • Widget:轻量、不可变的 UI 配置。
  • Element:树中的持久实例;实现 BuildContext;管理父子关系、State 生命周期和 rebuild。
  • RenderObject:负责布局、绘制指令、命中测试和语义,不负责光栅化。

Widget 是不可变配置;Element 负责关联 Widget、State 和 RenderObject,并协调更新;RenderObject 负责 layout、paint、hit test 和 semantics。光栅化由 Engine 的 Raster Thread 完成。

setState 链路

1
2
3
4
5
6
7
setState 同步执行回调
→ State 改变
→ Element.markNeedsBuild()
→ 下一帧 build
→ 必要时 layout
→ 必要时 paint
→ 必要时 raster

build() 是构建阶段,不是绘制阶段。在其中随意 setState() 可能违反构建约束或导致重复构建。

Element 复用

1
2
oldWidget.runtimeType == newWidget.runtimeType &&
oldWidget.key == newWidget.key

位置是父 Element 协调子节点时的匹配基础,但不是 Widget.canUpdate 的直接判断项。


四、布局约束

1
2
3
父节点向下传递 BoxConstraints
→ 子节点在约束内计算尺寸并向上返回
→ 父节点根据尺寸决定子节点位置

Column + ListView

Column 沿主轴布局非 Flex 子节点时通常给出无界高度;ListView 作为滚动视口需要有限高度来确定可视区域,因此无法完成布局。

不是“父组件满足不了子组件”,而是:

父组件没有提供子组件完成布局所需的有限约束。

1
2
3
4
5
6
Column(
children: [
const Header(),
Expanded(child: ListView(...)),
],
)

shrinkWrap: true 可按内容计算高度,但长列表成本较高。


五、Key、const 与 RepaintBoundary

Key

  • ValueKey:按值的 == 比较,常用业务 ID。
  • ObjectKey:按对象身份 identical 比较。
  • UniqueKey:每个实例不同;每次 build 新建会阻止复用。
  • GlobalKey:全局唯一,可访问 State/Context,也可跨父节点移动同一子树。

列表无 Key 时默认按位置和类型匹配。插入、删除、换序可能使旧 State 对应到错误数据,造成输入框、勾选、动画、播放状态错位。

GlobalKey 会增加耦合、全局注册和子树移动成本。父子通信优先回调或 Controller,跨层状态优先状态管理。

const

相同参数的 const 构造会被规范化为同一实例。父组件 rebuild 时:

1
identical(oldWidget, newWidget) == true

框架可跳过对子 Element 的更新。但 const 子组件仍可能因自身 setState() 或 InheritedWidget 变化而 rebuild;父级约束或绘制环境变化时也仍可能 layout、paint。

RepaintBoundary

它创建绘制边界和独立 Layer,使 markNeedsPaint() 的脏标记传播到最近边界为止。适合高频变化区域与复杂静态区域相邻的场景。

它不保证离屏渲染或 Raster Cache。过多使用会增加 Layer、内存、显存和合成成本。

const 减少 Widget/Element 更新;RepaintBoundary 隔离 paint。


六、InheritedWidget 与生命周期

InheritedWidget

1
context.dependOnInheritedWidgetOfExactType<MyInheritedWidget>()

该调用找到最近的 InheritedElement,并将当前 Element 注册为 dependent。updateShouldNotify(oldWidget) 只判断是否通知依赖者,不负责建立依赖。

返回 true 后,仅通知已注册的 dependent,触发 didChangeDependencies() 并标记 rebuild。

StatefulWidget 生命周期

1
2
3
4
首次:createState → initState → didChangeDependencies → build
更新:didUpdateWidget → build
依赖变化:didChangeDependencies → build
移除:deactivate → 可能重新插入,或 dispose
  • initState():每个 State 一次,不能声明为 async
  • didChangeDependencies():首次也会执行。
  • didUpdateWidget():Element 复用但 Widget 配置变化时执行。
  • deactivate():暂时离树,不代表销毁。
  • dispose():释放 Controller、FocusNode、Stream、Observer 等资源。

七、异步、mounted 与 BuildContext

BuildContext 本质上是 Element 暴露的接口,表示 Widget 在 Element Tree 中的位置。

1
2
final result = await request();
if (!context.mounted) return;

mounted 只保证 Element 尚未卸载,不能保证:

  • 返回的是最新请求;
  • 当前路由仍在顶部;
  • 页面参数或业务状态未变化。

请求乱序:

1
2
3
A 先请求,B 后请求
B 先返回并显示新数据
A 后返回并覆盖 B

处理方式:请求序号、取消旧请求、返回时校验参数。

异步后 pop 还要检查路由:

1
2
3
if (!context.mounted) return;
if (ModalRoute.of(context)?.isCurrent != true) return;
Navigator.of(context).pop();

八、FutureBuilder 与 StreamBuilder

不要在 build() 中创建 Future,因为 rebuild 会产生新 Future 并可能重复请求。

1
2
3
4
5
6
7
late Future<Data> future;

@override
void initState() {
super.initState();
future = fetchData();
}

FutureBuilder 切换 Future 不会取消旧 Future 的底层网络任务。

  • Future:一次完成结果。
  • Stream:多个 data、error、done 事件。

StreamBuilder 内部管理订阅:Widget 移除时取消;Stream 实例变化时取消旧订阅并订阅新实例。取消订阅能否停止数据源取决于 Stream 实现。

不要在 build() 中创建 Stream,否则可能反复取消、重新订阅或重启底层任务。


九、Isolate

每个 Isolate 有独立 Heap、Event Loop、Microtask Queue 和全局状态。Isolate 不等于固定的一条系统线程,Runtime 会负责调度。

Isolate 默认不共享可变内存,通过 SendPortReceivePort 传递消息;大块二进制可使用 TransferableTypedData

适合 CPU 密集任务:大型 JSON 解析、图片处理、加密、压缩、大量计算。

不要用“超过 100ms”作为固定标准。60Hz 一帧约 16.67ms,应结合 Profile 数据和创建、消息传递成本判断。

普通网络等待是异步 I/O,不需要 Isolate;响应后的大型解析可能需要。


十、帧渲染与性能排查

一帧流程

1
2
3
4
5
6
7
8
9
VSync
→ animation callbacks
→ build
→ layout
→ paint
→ compositing
→ Layer Tree
→ Raster Thread 光栅化
→ GPU/系统显示

每帧不会重新挂载整棵 Element Tree,只处理 dirty 节点。

卡顿分类

UI 卡顿常见原因:

  • 大范围或高频 rebuild;
  • 昂贵 layout;
  • Dart 同步计算、JSON 解析;
  • 动画或事件回调耗时。

Raster 卡顿常见原因:

  • 大图、模糊、阴影、复杂路径;
  • saveLayer、离屏渲染;
  • Shader 编译;
  • 过度绘制。

Opacity、裁剪不是必然昂贵,要看是否产生额外 Layer 或离屏渲染。

DevTools

  • Performance Timeline:区分 UI 与 Raster 阶段耗时。
  • Performance Overlay:实时显示 UI、Raster 两条耗时图。
  • Track Widget Builds:记录 Widget build,定位频繁或耗时构建。
  • Repaint Rainbow:用颜色显示重绘区域,不用于检查 Widget 销毁。

十一、长列表

ListView.builder 按需构建可视区域及 cacheExtent 附近的 item,远离屏幕的 item 通常会被回收。

  • itemExtent:指定固定高度,减少测量和位置计算。
  • prototypeItem:用原型测量统一高度。
  • cacheExtent:预构建视口前后一定距离;过大会增加内存和构建成本。

状态保留:

  • 业务状态,如输入内容、播放位置:优先保存到数据源。
  • 短期保留 State:AutomaticKeepAliveClientMixin

大量视频播放器不宜全部保活。应在离屏时暂停、释放播放器并保存播放位置,重新进入时恢复。


十二、GetX

GetBuilder 与 Obx

  • GetBuilder:调用 update() 手动通知,适合低频或批量刷新。
  • Obx:构建时自动收集其中读取的 Rx 依赖,Rx 变化后自动 rebuild。
  • .obs:把普通值转换为 Rx,不是 .obx
  • update([id]):只刷新对应 id 分组;不传 id 通常通知相关 GetBuilder。

大范围 Obx 会使任意依赖变化重建整个包裹区域,应缩小监听范围。

Controller 注册

  • Get.put:立即创建并注册,后续通常复用。
  • Get.lazyPut:注册工厂,首次 Get.find 时创建,之后复用。
  • Get.create:每次 Get.find 都创建新实例。
  • permanent: true:阻止自动释放,适合应用级服务。
  • fenix: true:实例释放后保留工厂,下次 Get.find 时重新创建。

Bindings

Bindings.dependencies() 与 Flutter 的 didChangeDependencies() 无关。

1
2
3
4
进入路由
→ 执行 Binding.dependencies()
→ 注册 Controller
→ 创建页面

不要在 build()Get.put():构造表达式可能反复执行,且实例所有权、生命周期、释放时机不明确。

Bindings 的价值:集中依赖、支持懒加载、关联路由生命周期、便于 Mock 和测试。

Controller 生命周期

  • onInit():初始化数据、Worker 和订阅。
  • onReady()onInit 后下一帧,适合导航、弹窗等页面就绪操作。
  • onClose():Controller 删除前释放 Worker、Timer、Stream 等。

页面关闭不一定触发 onClose(),取决于注册方式、SmartManagementpermanent 等配置。

Worker

  • ever:每次 Rx 变化都执行。
  • once:第一次变化时执行一次。
  • debounce:停止变化一段时间后执行,适合搜索框。
  • interval:持续变化时每个时间窗口最多执行一次,适合滚动上报、防连点。
1
2
debounce:等你停下来再执行
interval:你不停也会按时间窗口执行

内存泄漏

泄漏不是“持续写数据”,而是对象已不需要却仍被引用,无法被 GC。

常见原因:

  • permanent 滥用;
  • Worker、Timer、StreamSubscription 未释放;
  • Controller、FocusNode 等未释放;
  • 静态变量、单例或闭包长期持有页面对象;
  • tag 错误导致重复注册。

排查方法:DevTools Memory 的 Heap Snapshot、Diff、Retaining Path,并确认 onClose() 是否执行。


十三、Dart 语言

空安全

  • T?:允许为 null。
  • !:空值断言,不是保证不为空;值为 null 时运行期报错。
  • late:延迟初始化的非空变量,不一定是懒加载。
  • required:命名参数必须显式传入,不代表值非空,如 required String? name

风险:

  • 滥用 !:把编译期问题推迟成运行期异常。
  • 未初始化就读取 lateLateInitializationError
  • late final 重复赋值:运行期报错。

泛型协变

Cat extends Animal,Dart 的泛型协变允许:

1
2
List<Cat> cats = [Cat()];
List<Animal> animals = cats;

但实际对象仍是 List<Cat>。执行:

1
animals.add(Dog());

编译期按 List<Animal> 看似允许,运行期发现实际列表要求 Cat,因此抛出类型错误。

final 与 const

  • final:变量只能赋值一次,值可在运行时确定。
  • const:编译期常量,对象深层不可变并可被规范化。
1
2
3
4
5
6
final list = [1];
list.add(2); // 可以,引用没变
// list = [3]; // 不可以,不能重新赋值

const values = [1];
// values.add(2); // 运行时报错

extends、implements、mixin

  • extends:单继承,可复用父类字段和方法实现,适合明确的 is-a 关系。
  • implements:只采用接口契约,不复用实现;需实现全部实例成员,包括方法、getter、setter。
  • mixin:通过 with 横向复用实现,一个类可混入多个 Mixin;可用 on 限制适用父类型。
1
class Bird extends Animal with Flyable, Loggable {}

extension

  • extension:不修改原类,为已有类型补充便捷方法,无需 with
  • mixin:将字段和方法实现混入类,成为实例能力的一部分。
  • 扩展方法采用静态分派,根据表达式的编译期类型选择扩展。
  • 原类存在同名实例方法时,实例方法优先。
  • 扩展方法不进入原类虚方法表,因此不能真正重写实例方法。
  • 接收者为 dynamic 时,不会通过静态扩展查找扩展方法。

十四、第 33 题预习:静态分派与动态分派

静态分派

编译期根据表达式的静态类型确定目标,例如 Dart 扩展方法。

动态分派

运行期根据对象的实际类型选择被重写的方法:

1
2
Animal animal = Cat();
animal.speak(); // 调用 Cat.speak()

变量静态类型是 Animal,实际对象是 Cat。实例方法进入虚方法分派,运行时会调用 Cat 的重写实现,这就是多态。

字段访问通常不具有相同的动态多态语义;Dart 的实例变量会隐式产生 getter/setter。面试回答时应区分:

  • 方法重写:运行时动态分派。
  • 同名字段声明:不是传统意义上的字段重写,避免依赖“字段多态”。
  • 若要多态获取属性,应显式定义并重写 getter。

十五、CHECK ME LIST

  1. 三棵树:Widget 配置、Element 持久实例、RenderObject 布局绘制。
  2. 更新:setState → Element dirty → build → 按需 layout/paint/raster
  3. 布局:Constraints down,Sizes up,Parents set positions。
  4. Key:类型和 key 相同才能复用;列表换序用稳定业务 Key。
  5. 异步:mounted 只防卸载,不防请求乱序和路由变化。
  6. Builder:Future/Stream 在生命周期中创建,不在 build 中创建。
  7. Isolate:CPU 密集才考虑;网络等待本身不需要。
  8. 性能:先用 Profile/Timeline 区分 UI 卡顿与 Raster 卡顿。
  9. GetX:GetBuilder 手动,Obx 响应式;put 立即、lazyPut 首次、create 每次。
  10. Dart:! 是断言;late 是延迟初始化;final 单次赋值;const 编译期深层不可变。
  11. 泛型:List<Cat> 可协变成 List<Animal>,错误写入在运行期检查。
  12. 继承:extends 复用父类,implements 只要契约,mixin 横向复用。