Flutter 开发工程师面试指南

注意:Flutter/Dart 持续演进,线程模型、渲染后端等答案应说明版本和平台语境。

目录

  1. 能力模型与面试方法
  2. Dart 语言与运行时
  3. Flutter Framework 与三棵树
  4. 布局、绘制、合成与渲染
  5. 生命周期、Context、Key 与路由
  6. 状态管理与应用架构
  7. 网络、缓存、数据层与离线能力
  8. Platform Channel、FFI 与插件
  9. 性能优化与 DevTools
  10. 稳定性、安全与可观测性
  11. 测试、工程化与发布
  12. 大型项目架构与团队治理
  13. 真实项目与线上故障题
  14. 系统设计题
  15. 编码与调试题
  16. 领导力与行为面试
  17. 评分表与录用建议
  18. 候选人准备清单与 STAR 模板

1. 能力模型与面试方法

1.1 能力矩阵

维度 合格资深 优秀资深
Dart 掌握类型、异步、泛型、空安全 理解 Event Loop、Isolate、编译与内存边界
Framework 理解 Widget/Element/RenderObject 能沿更新、布局、绘制、合成链路定位问题
架构 能组织清晰的数据流与模块边界 能支撑多人、多业务、多端长期演进
性能 会用 DevTools 定位常见问题 建立基线、监控、回归与治理闭环
稳定性 能处理异常、崩溃和网络失败 能做分级、灰度、止损、复盘和防复发
原生能力 会使用 Channel 和常见插件 能开发插件并排查跨端生命周期/线程问题
测试工程 能写 Unit/Widget/Integration Test 能治理 flaky case、流水线和发布门禁
领导力 能评审代码、指导成员 能推动跨团队决策并承担业务结果

能力可分为:L1 知道、L2 理解、L3 应用、L4 治理、L5 引领。资深岗位通常要求核心领域达到 L3,至少一个专项达到 L4。

1.2 90 分钟建议流程

环节 时间 重点
项目与职责确认 10 分钟 规模、角色、复杂度
Dart/Flutter 原理 20 分钟 基础深度与心智模型
架构和工程实践 20 分钟 设计、边界、落地
性能或故障深挖 15 分钟 证据、工具、闭环
系统设计 15 分钟 澄清、权衡、演进
编码或领导力 10 分钟 代码质量、协作影响

1.3 通用评分与真实性验证

单题按四级评分:

  • 0 分:不知道或有明显事实错误。
  • 1 分:知道术语/API,无法解释原理。
  • 2 分:能解释原理、边界和常见实践。
  • 3 分:有真实案例、数据、权衡和防复发措施。

项目题连续追问:

  1. DAU(日活跃用户数)、页面/模块数量、团队规模、目标平台是什么?
  2. 原始指标和用户影响是什么?
  3. 用什么日志、Trace、Profiler 或实验定位?
  4. 放弃过什么方案,为什么?
  5. 如何灰度、回滚和验证?
  6. P50/P90/P99(第 50/90/99 百分位数)、崩溃率、内存、包体积改善多少?
  7. 如何通过测试、监控、Lint 或流程防复发?

参考回答

  1. 项目规模:先统一统计口径,再说明 DAU/峰值、核心页面与模块数、目标平台、团队规模及个人职责。数据可以给区间,但应注明周期,避免只说“项目很大”。
  2. 基线与影响:用“设备和版本范围—原始指标—用户或业务影响”描述,例如“中低端 Android 的信息流 P90 帧耗时为 28 ms,快速滑动退出率上升 3%”。技术指标必须能对应体验。
  3. 定位证据:根据症状选择工具并形成证据链。崩溃看聚合堆栈与上下文日志,卡顿看 Timeline 的 UI/Raster 帧,网络慢看 Trace,内存看 Heap Snapshot;再用二分开关、对照实验或最小复现验证因果。
  4. 方案取舍:说明候选方案、预期收益、复杂度和风险,以及放弃依据。例如并非给所有列表项增加 RepaintBoundary,因为实测 Layer 和显存成本大于少量绘制收益。
  5. 灰度与回滚:使用远程开关、设备/版本分层和小流量灰度;预设崩溃率、卡顿率等止损阈值,异常时关闭新链路或回滚。验证时同时观察目标指标与内存、崩溃等护栏指标。
  6. 结果数据:报告相同统计口径下的“基线 → 结果”、P50/P90/P99 和副作用,例如“P90 首帧从 1.8 秒降至 1.3 秒,P99 未恶化,峰值内存增加 4 MB”。平均值不能代表长尾用户。
  7. 防复发:按根因选择回归测试、性能基线、线上告警、Lint、发布门禁或故障手册。竞态不一定适合 Lint,更适合状态机测试、可控异步测试和线上监控。

风险信号

  • 只有“优化很多”“体验明显提升”,没有数据。
  • 只列库名,无法说明数据流和生命周期。
  • 无法区分个人贡献与团队成果。
  • 没有失败方案、代价、灰度或回滚意识。

2. Dart 语言与运行时

2.1 知识清单

  • 类型推断、泛型、类型边界、sound null safety。
  • finalconstlate、不可变对象。
  • class、mixin、extension、sealed class、pattern。
  • Closure、集合控制流、异常与 Zone。
  • Future、Stream、Event Loop、microtask、Isolate。
  • JIT/AOT、Debug/Profile/Release、GC 基本模型。

2.2 finalconst 与不可变对象

参考答案

  • final 变量只赋值一次,值可在运行时确定。
  • const 是编译期常量,其对象图也必须满足常量要求;相同常量可能 canonicalize。
  • final List 只是引用不可重指,列表内容仍可能修改。
  • const Widget 可减少对象创建并帮助框架识别相同配置,但不保证 build() 永不执行。

追问const 如何影响 Element 更新?为什么 final DateTime.now() 合法?不可变状态对并发和测试有什么价值?

追问答案

  • const 与 Element:相同常量表达式可能得到同一 Widget 实例,框架在更新子节点时可快速确认配置未变并复用 Element,减少对象创建和部分更新工作。但依赖的 InheritedWidget 变化时仍可能执行 build()const 是低成本优化,不是控制重建范围的万能方案。
  • final DateTime.now()final 只要求变量赋值一次,值可以在运行时产生;DateTime.now() 因而合法。const 要求编译期可确定,而“当前时间”只有运行时才知道,所以不能写成 const DateTime.now()
  • 不可变状态的价值:每次变化产生新快照,旧值不会被隐式修改,数据流更容易推理、比较和回放;Reducer 测试也可直接验证“旧状态+事件=新状态”。Isolate 本就不共享普通对象堆,因此不可变能降低业务竞态和消息语义复杂度,但不能代替取消、顺序控制等并发设计。

风险信号:认为 final 等于深度不可变,或认为加 const 就能解决所有 rebuild。

2.3 空安全与 late

参考答案

TT? 明确区分非空和可空类型,flow analysis 根据控制流做类型提升。late 把初始化检查推迟到运行时,读取未初始化变量会失败;late final 适合生命周期稍后初始化且只写一次的字段。异步数据更适合用 loading/success/error 状态建模,而不是大量使用 !

追问:哪些写法会阻止类型提升?何时选择可空字段而不是 late?异步初始化如何避免竞态?

追问答案

  • 类型提升失败:当分析器无法证明检查后值仍不变时,通常不会提升,例如变量可能被重新赋值、闭包可能写入它,或 getter 每次读取都可能返回不同值。字段提升能力随 Dart 版本演进;最稳妥的通用做法是先把值保存到局部 final 变量再判断。
  • T? 还是 latenull 是合法业务状态时用 T?,例如用户可能没有头像;对象保证在首次读取前完成初始化时才用 late final,例如在 initState 创建 Controller。初始化时序不可靠时,late 只会把编译期问题推迟成运行时异常。
  • 异步初始化竞态:既要管理生命周期,也要校验结果是否仍然有效。mounted 只能防止销毁后更新,不能防止用户 A 的迟到响应覆盖用户 B;应配合取消令牌、递增的 generation/requestIdswitchMap,提交结果前再次核对当前参数。

2.4 Event Loop、Future 与 microtask

参考答案

每个 Isolate 有独立 Event Loop。通常先清空 Microtask Queue,再处理 Event Queue 中的一个事件。Timer、I/O、平台消息通常作为事件处理;连续创建 microtask 可能饿死事件队列,影响输入与帧调度。

1
2
3
4
5
6
7
void main() {
print('A');
Future(() => print('event'));
scheduleMicrotask(() => print('microtask'));
print('B');
}
// 常见顺序:A、B、microtask、event

async 函数在第一个 await 前同步执行;await 让出执行权,后续通过异步延续恢复。未 await 的 Future 可能让错误逃逸当前 try/catch

追问:如何处理重复提交、过期响应和组件销毁后的更新?为什么不能靠猜测复杂异步顺序?

追问答案

  • 三类问题分别治理:重复提交用按钮互斥并配合服务端幂等键;过期响应用取消、请求版本号或“只保留最新任务”的操作符;组件销毁时取消 Timer、Subscription 和请求,并在不可取消回调提交 UI 前检查 mounted
  • 不能猜顺序:代码按 A、B 发出请求,不代表 A 一定先返回;I/O、调度和平台消息都会改变完成顺序。复杂流程应使用显式状态机、序列号、结构化日志以及 fake async/可控 Future 测试,而不是依赖偶然观察到的顺序。

2.5 Future 与 Stream 如何选择?

参考答案

  • Future<T> 表达一次成功或失败。
  • Stream<T> 表达一系列随时间到达的事件。
  • 单订阅流适合明确消费方;广播流可有多个监听者,但晚订阅者通常收不到历史事件。
  • 设计时应明确取消、错误、完成、暂停和资源释放语义。

追问async*yieldyield* 的作用?WebSocket 和搜索联想如何避免泄漏与竞态?

追问答案

  • 生成异步流async* 声明一个异步事件序列,yield 发出单个事件,yield* 转发另一个 Stream,并在其结束后继续执行。取消和错误会沿流传播,持有外部资源时应在 try/finally 中释放。
  • WebSocket 生命周期:保存并取消订阅,页面或会话结束时关闭连接;重连使用上限、指数退避和抖动,并避免前后台切换产生重复连接。
  • 搜索联想时效性:先 debounce,再取消旧请求或使用 switchMap,同时保留查询版本校验,防止无法真正取消的迟到响应覆盖新结果。

2.6 Isolate 什么时候使用?

参考答案

Isolate 独享内存和 Event Loop,通过消息通信,不共享可变状态。UI Isolate 上的 CPU 密集任务会阻塞帧生产。一次性计算优先考虑 Isolate.run();长期 Worker 可用 Isolate.spawn()ReceivePortSendPort

适合大 JSON 解析、图片处理、压缩、加密和复杂计算;不适合很短的任务,也不必为普通异步 I/O 创建 Isolate。需评估启动、序列化和大对象传输成本。

追问

  • TransferableTypedData 解决什么问题?
  • Worker 如何处理超时、取消、错误回传和退出?
  • 为什么 Isolate 不是通用线程池?

追问答案

  • TransferableTypedDatafromList 创建包装对象时会复制输入字节;该包装对象跨 Isolate 发送时,可在常数时间内转移其 materialize() 权限(即取出内部字节缓冲区的权利),适合图片、音视频帧等大块二进制数据。发送后,发送侧包装对象不能再 materialize(),但创建它时传入的原始 TypedData 不会因此失效。
  • 长期 Worker 治理:消息携带任务 ID;超时后发送协作式取消并丢弃迟到结果;业务错误用结构化结果返回,未捕获错误和退出通过 error/exit port 感知;结束时关闭 ReceivePortkill 只能作为任务无法协作退出时的兜底。
  • 不是通用线程池:每个 Isolate 有独立堆和 Event Loop,启动、序列化和消息传输都有成本,也不能直接共享可变对象。可以建立固定 Worker 池处理足够重的纯计算,但大量细粒度任务通常得不偿失。

2.7 JIT、AOT 与构建模式

参考答案

  • Debug 常用 JIT 支持 Hot Reload,但性能不能代表线上。
  • Release 使用 AOT 和优化后的运行配置。
  • Profile 接近 Release 且保留分析能力,是性能测量主要模式。
  • Web 与原生平台的编译运行模型不同,不能混为一谈。

判断点:候选人若用 Debug 数据证明性能收益,应追问测量有效性。


3. Flutter Framework 与三棵树

3.1 Flutter 架构

参考答案

  • Framework:Dart 编写,包含 Widgets、Rendering、Animation、Gesture、Material/Cupertino。
  • Engine:主要由 C++ 实现,负责 Dart Runtime、文本、光栅化、合成等,通过 dart:ui 暴露能力。
  • Embedder:对接窗口、输入、平台生命周期并将 Engine 嵌入具体平台。

现代版本应结合平台说明渲染后端;Impeller 已广泛使用,不能无条件回答“Flutter 始终使用 Skia”。

3.2 Widget、Element、RenderObject 的职责

参考答案

  • Widget 是不可变配置,描述“想要什么”。
  • Element 是 Widget 在树中的实例化位置,跨帧持久存在,连接 Widget 与底层对象。
  • RenderObject 负责布局、绘制、命中测试等。

父节点更新时,框架依据 runtimeTypekey 判断能否复用 Element;可复用则更新配置,否则替换子树。Widget 廉价且可频繁创建,Element/RenderObject 才承载更持久的生命周期与状态。

追问

  1. StatefulWidget 的状态保存在哪里?
  2. setState() 后为何不是整棵应用全部重绘?
  3. StatelessWidget 是否一定比 StatefulWidget 更快?

追问答案

  1. State 保存位置:状态在 State 对象中,由对应的 StatefulElement 持有,而不是放在不可变的 StatefulWidget 中。新旧 Widget 的 runtimeTypekey 匹配时,Element 与原 State 可以继续复用。
  2. 不会全应用重绘setState() 只把关联 Element 标记为 dirty;下一帧从该位置重建。Widget.canUpdate 只用 runtimeTypekey 判断能否复用 Element,并不比较字段值;复用后仍会按需执行 update/build。只有新旧 Widget 是同一实例等情况才能直接短路部分更新,后续是否 relayout/repaint 则取决于约束、渲染属性和边界是否变化。
  3. 两者没有绝对快慢StatefulWidget 只是多了 State 生命周期,真实成本更多取决于重建范围、布局和绘制复杂度。把局部状态放在正确位置,反而能减少祖先的大范围更新。

优秀信号:区分 rebuild、relayout、repaint,理解 dirty 标记和边界。

3.3 setState() 到屏幕变化的链路

参考答案

setState() 同步执行回调并将关联 Element 标记为需要重建。下一帧构建阶段执行 build(),根据新旧 Widget 更新 Element 子树;必要时触发布局、绘制和合成,最终由 Engine 光栅化呈现。重建不等于重新布局或重绘整个页面。

风险信号:认为 setState() 立即同步绘制屏幕,或每次都重建整个 App。

3.4 InheritedWidget 的依赖机制

参考答案

后代通过 dependOnInheritedWidgetOfExactType 建立依赖;InheritedWidget 更新且 updateShouldNotify 返回 true 时,依赖它的 Element 会收到更新并重建。context.read 类操作通常只读取,watch/select 才建立不同粒度的订阅,具体语义取决于库。

追问:为什么在错误生命周期调用会出问题?如何降低大范围重建?select 的收益和代价是什么?

追问答案

  • 生命周期限制:建立 Inherited 依赖要求 Element 已进入可追踪依赖的活动阶段。initState 只执行一次,无法正确响应依赖变化,应在 didChangeDependenciesbuild 中订阅;dispose 时树已不稳定,也不应再向上查找。
  • 缩小更新范围:缩小状态作用域,在真正消费数据的叶子节点订阅,并拆分 Provider/InheritedWidget 与 Widget;稳定不可变状态、const 子树和 selector 都可辅助。是否需要优化仍应由 Timeline 证明,不能把所有卡顿都归因于 rebuild。
  • select 的取舍:它只订阅状态投影,可减少无关重建;代价是每次更新都要执行选择与相等比较,并要求结果稳定。每次创建新集合可能仍持续重建,原地修改可变集合又可能漏掉更新。

4. 布局、绘制、合成与渲染

4.1 Flutter 布局规则

参考答案

可概括为:约束向下传递,尺寸向上传递,父节点决定位置。父 RenderObject 给子节点 Constraints,子节点在约束内选择 Size,父节点再决定子节点偏移。

典型问题

  • Column 中的垂直 ListView 获得无界高度。
  • Row 中长文本未通过 Expanded/Flexible 获得可用宽度。
  • Unbounded width/height 不是“Flutter 坏了”,而是约束关系不成立。

4.2 一帧经历哪些阶段?

参考答案

常见框架阶段包括动画、build、layout、paint、compositing/scene build,随后由 Engine 光栅化并提交 GPU。不同版本和平台的线程实现会变化,不应机械背诵固定线程数量。自 Flutter 3.29 起,iOS/Android 的 UI 与平台线程模型已有合并变化,回答应以所用版本为准。

追问

  • UI 时间与 Raster 时间分别高说明什么?
  • VSync 与 60/90/120 Hz 帧预算是什么?
  • 为什么 Profile 模式和真机更可信?

追问答案

  • UI 时间高:通常是 Dart/Framework 工作过重,应检查同步计算、频繁 build 和复杂 layout。Raster 时间高通常指向大图、阴影、模糊、离屏层、裁剪或 Layer 合成;两者都高时要分别采样,不能只看 FPS。
  • 帧预算:60/90/120 Hz 的理论截止时间约为 16.67/11.11/8.33 ms。UI 与 Raster 可以流水并行,因此不是简单把二者相加;任一关键阶段错过显示截止时间都可能掉帧。
  • 测量环境:Profile 的编译和运行特征更接近 Release,又保留分析能力;真机才能反映真实 CPU/GPU、内存、温控和系统调度。最终仍要用代表性设备分层及线上指标验证。

4.3 RepaintBoundary 的作用与边界

参考答案

RepaintBoundary 可以隔离绘制脏区域,并可能形成独立合成层,使静态区域避免随频繁动画重绘。但过度使用会增加 Layer、内存和合成成本。应通过 Repaint Rainbow、Timeline 和 Layer 观察验证,而非全局添加。

4.4 saveLayer、Opacity、Clip 为什么可能昂贵?

参考答案

某些效果需要离屏缓冲,增加 GPU 目标切换和内存带宽;Opacity 可能引入额外合成,复杂裁剪和阴影也可能扩大成本。具体是否昂贵取决于实现、范围、缓存和渲染后端。优先选择无需离屏层的等价实现,并用工具测量。

4.5 手势命中与竞技场

参考答案

命中测试确定指针事件经过的 RenderObject 路径;多个 GestureRecognizer 可进入 Gesture Arena,等待移动距离、方向、时间等信息后竞争胜负。事件分发、命中测试和手势识别是不同层次。

追问:嵌套滚动冲突如何定位?HitTestBehavior 的三种行为?为何 IgnorePointerAbsorbPointer 不同?

追问答案

  • 嵌套滚动冲突:先确认命中路径和参与竞技场的 recognizer,再看滚动方向、触发阈值、ScrollPhysics 与父子控制器。统一滚动轴时优先用 CustomScrollView/Sliver,而不是把多个独立 Scrollable 强行嵌套。
  • HitTestBehaviordeferToChild 仅在子节点命中时自身才参与;opaque 占据自身范围并阻止后方同位置目标命中;translucent 即使自身视觉透明也参与命中,同时允许后方目标进入命中结果。它控制命中测试,不直接决定手势竞技场最终胜者。
  • IgnorePointerAbsorbPointer:前者让自身和子树不参与指针命中,事件可落到后方;后者自身命中并截住事件,子树和后方都收不到。选择取决于是需要“穿透”还是“遮挡”。

5. 生命周期、Context、Key 与路由

5.1 State 生命周期

参考答案

典型顺序为:构造 State → initStatedidChangeDependenciesbuild;父组件提供新配置时调用 didUpdateWidget;临时移出树可能调用 deactivate;永久移除调用 disposereassemble 主要与开发期 Hot Reload 有关。

实践边界:

  • initState 中初始化 Controller、订阅或启动一次性任务。
  • 依赖 InheritedWidget 的初始化放在 didChangeDependencies
  • didUpdateWidget 比较新旧配置并调整订阅。
  • dispose 释放 Controller、Subscription、Timer、Observer。
  • 异步回调更新 UI 前考虑 mounted,但更重要的是取消无效任务。

追问:为什么不应让 initState 本身为 async?切换用户 ID 时如何重建订阅?deactivatedispose 有何区别?

追问答案

  • initState 不应声明为 asyncvoid initState() async 会成为 async-void;框架仍按同步生命周期方法调用它,不会等待异步工作结束,也无法把异步完成纳入生命周期顺序。应保持 initState 同步,在其中启动单独的异步方法,并处理取消、异常、mounted 和过期结果。
  • 切换订阅:在 didUpdateWidget 比较 oldWidget.userId 与新值;变化时先取消旧订阅,再按新 ID 创建订阅。cancel() 返回的 Future 表示上游异步清理完成;若切换还包含无法立即终止的异步加载,则用 generation(请求代次号)丢弃旧任务的迟到结果。
  • deactivatedisposedeactivate 表示临时从当前位置移除,Element 仍可能在同一帧被重挂载;dispose 表示永久卸载,State 不会再回到树中。最终资源释放应放在 dispose,临时树操作才考虑 deactivate

5.2 BuildContext 是什么?

参考答案

BuildContext 本质是 Element 的接口,代表当前 Widget 在 Element 树中的位置。查找 Theme、Navigator、Provider 等都依赖这个位置,因此同一段代码使用不同 Context 可能得到不同结果。跨异步间隙使用 Context 前需确认组件仍 mounted,且应避免长期保存 Context。

经典场景:刚创建 ScaffoldNavigator 或 Provider 后,用其上方 Context 查找会失败;可用 Builder、拆分子 Widget 或正确作用域解决。

5.3 Key 如何选择?

参考答案

  • 无 Key:同级位置和类型决定复用。
  • ValueKey:用稳定业务 ID 标识同级项。
  • ObjectKey:仅按对象身份标识,内部使用 identical,不会调用对象的值相等比较。
  • UniqueKey:强制每次成为不同身份,不适合稳定列表。
  • GlobalKey:可跨位置识别 Element、访问 State/Context,也支持子树重挂载,但成本和耦合更高。

追问:可排序列表为何需要 Key?Key 应放在包装层还是内容层?GlobalKey 为什么不能作为默认通信方案?

追问答案

  • 排序需要 Key:无 Key 时框架主要按同级位置和类型复用 Element,排序后旧 State 可能落到另一条业务数据上。稳定 ValueKey(item.id) 让状态跟随实体,而不是跟随数组下标。
  • 放置层级:Key 应放在参与兄弟比较的最外层列表项。如果兄弟节点是 Padding,Key 就应放在 Padding 或外层 KeyedSubtree,只放到内部内容层来不及影响第一层匹配。
  • 避免滥用 GlobalKey:它有全局唯一性、子树重挂载和更高维护成本,也会让组件通过 Context/State 彼此耦合。普通通信优先回调、状态提升或状态容器;只有确需跨父节点保持 State、访问表单等少数场景再使用。

5.4 路由和深链设计

资深答案应覆盖:

  • 声明式路由与命令式 Navigator 的取舍。
  • URI 到业务页面的解析、鉴权拦截、参数校验和兜底。
  • Web URL 同步、浏览器前进后退、嵌套路由。
  • 多 Tab 独立导航栈和状态恢复。
  • 未登录跳登录后恢复原意图。
  • 深链版本兼容、来源校验和埋点。

风险信号:只会 Navigator.push,未考虑回退栈、冷启动、鉴权和非法参数。


6. 状态管理与应用架构

6.1 如何选择状态管理方案?

参考答案

先判断状态:

  1. 生命周期:局部、页面、流程、全局、持久化。
  2. 来源:用户输入、服务端、设备、派生状态。
  3. 更新模式:事件驱动、响应式流、简单可变状态。
  4. 约束:可测试性、团队熟悉度、调试能力、代码生成、迁移成本。

Provider、Riverpod、Bloc/Cubit、Redux、GetX 等只是实现手段。资深工程师应解释依赖方向、状态所有权、订阅粒度和副作用位置,而不是宣称某库“最好”。

6.2 如何区分 UI State、Domain State 和 Server State?

  • UI State:选中项、动画、输入焦点,通常靠近 Widget。
  • Domain State:业务流程和规则,应独立于具体 UI。
  • Server State:远端数据的缓存、刷新、过期和同步状态。

把所有状态放进全局容器会造成生命周期过长、耦合和重建扩大;把所有状态都留在页面又会导致复用、测试和跨页流程困难。

6.3 单向数据流有什么价值?

事件触发业务处理,产生新状态,UI 根据状态渲染。它使状态变化可追踪、可测试和可重放,并减少多个视图直接互改数据。代价是样板代码和简单场景的复杂化,因此应按业务复杂度采用。

6.4 副作用放在哪里?

网络请求、持久化、导航、Toast、埋点都属于副作用。应让纯状态转换与副作用分离,明确触发条件、幂等、取消和错误处理。导航/提示类一次性事件不能简单永久存进可重放状态,否则旋转、恢复或重订阅可能重复消费。

6.5 Clean Architecture 是否必要?

参考答案

核心不是目录层数,而是依赖方向和变化隔离。常见边界:

  • Presentation:渲染、交互和页面状态。
  • Domain:实体、用例、业务规则。
  • Data:Repository 实现、远端/本地数据源、DTO 映射。

小项目无需机械创建大量 interface/use case;大型项目则需通过边界控制业务、平台和基础设施耦合。抽象应服务真实替换点与测试需求。

优秀信号:能说明哪些层被合并过、为何合并,以及何时再拆分。

6.6 依赖注入与 Service Locator

构造注入依赖显式、便于测试;Service Locator 使用方便但可能隐藏依赖和生命周期。无论采用何种容器,都要定义 singleton、lazy singleton、factory、页面作用域的释放规则,并避免测试之间残留全局状态。


7. 网络、缓存、数据层与离线能力

7.1 网络层应具备什么能力?

  • 统一 Base URL、Header、序列化和错误模型。
  • Token 注入与安全刷新,避免并发刷新风暴。
  • 超时、取消、重试、退避与幂等。
  • 网络日志脱敏、Trace ID、耗时分段和指标。
  • TLS、安全存储、证书策略按风险场景设计。
  • DTO 与 Domain Model 解耦,兼容字段缺失和接口演进。

追问:401 同时发生 20 次如何只刷新一次 Token?POST 能否自动重试?页面退出后如何取消请求?

追问答案

  • 并发 401:使用 single-flight(并发请求共享同一个在途任务)。第一个 401 创建并保存刷新 Token 的共享 Future,其余请求等待同一个任务;成功后原子更新 Token,再重放原请求。刷新接口不能再次进入刷新拦截,每个原请求最多自动重放一次,退出登录或账号切换还需用 session generation(会话代次号)隔离旧结果。
  • POST 重试:不能只按 HTTP 方法判断。请求可能已被服务端执行而响应丢失,盲目重试会重复下单或扣款。只有携带 idempotency key(幂等键)、服务端支持去重,或能确认业务尚未执行时才可自动重试;支付等场景通常先查询状态。
  • 页面退出取消:页面或 ViewModel 持有取消句柄并在 dispose 中取消,Repository 将信号传递到 HTTP 客户端。只检查 mounted 不能停止网络、解析和缓存副作用;共享请求还要用独立订阅或引用计数,避免一个页面退出取消其他消费者的数据源。

7.2 统一错误模型如何设计?

至少区分:

  • 网络不可达、DNS、连接/读取超时。
  • HTTP 状态错误。
  • 业务错误码。
  • 数据解析或契约错误。
  • 认证过期、权限不足。
  • 用户取消与未知错误。

错误模型服务于恢复策略,而不只是统一 Toast。可恢复错误提供重试,认证错误进入续期或登录,契约错误应告警并保留脱敏上下文。

7.3 缓存策略如何选?

常见策略包括 Cache First、Network First、Stale-While-Revalidate 和仅离线缓存。需要明确:

  • 缓存 Key、TTL、容量与淘汰。
  • 用户/租户隔离和登出清理。
  • Schema 版本与迁移。
  • 数据新鲜度展示。
  • 多端修改冲突与一致性要求。
  • 敏感数据是否允许落盘及加密方式。

7.4 如何设计离线写入与同步?

本地先生成稳定操作 ID,写入 Outbox 并更新乐观 UI;网络恢复后按顺序或依赖关系同步。服务端需支持幂等,客户端记录重试次数和失败原因。冲突策略可为服务端优先、客户端优先、字段合并或用户决策,必须由业务语义决定。

优秀信号:考虑 App 被杀、时钟不一致、重复提交、部分成功和迁移失败。

7.5 大 JSON 为什么卡顿?如何处理?

先在 Profile 模式用 CPU Profiler 确认解析和对象创建占用。可减少字段/层级、分页或流式处理;CPU 解析确实阻塞 UI 时再迁移至 Isolate。还需评估数据跨 Isolate 传输和模型构建成本,不能只把 jsonDecode 包进 Worker 就宣告完成。


8. Platform Channel、FFI 与插件

8.1 MethodChannel、EventChannel、BasicMessageChannel

  • MethodChannel:类似异步 RPC,请求方法并返回一次结果。
  • EventChannel:原生到 Dart 的连续事件流。
  • BasicMessageChannel:双向消息,适合自定义协议。

Platform Channel 是异步消息机制;StandardMessageCodec 支持 JSON-like 基础类型及 TypedData。复杂接口可用 Pigeon 生成类型安全代码,减少字符串方法名和手工转换错误。

8.2 如何设计健壮的 Channel?

  • 方法名、参数和错误码版本化。
  • Dart/Android/iOS 三端语义一致。
  • 明确主线程要求和耗时任务调度。
  • 处理 Engine、Activity/ViewController、StreamHandler 生命周期。
  • 热重启、重复注册、取消监听不泄漏。
  • 大数据避免频繁通过 Channel 复制;可让 Channel 只传元数据、受控文件路径/句柄,或按平台采用共享内存、原生缓冲区、FFI,并明确权限、所有权、释放和失败清理。

风险信号:仅能写 demo,未考虑生命周期、线程、错误、版本和测试。

8.3 Pigeon 的价值和边界

Pigeon 从接口定义生成 Dart 与宿主端代码,提供类型安全和一致的编解码约定,适合稳定的结构化接口。它不消除跨线程、生命周期、兼容和业务错误设计问题;接口变更仍需版本治理。

8.4 FFI 与 Platform Channel 如何选择?

  • 调系统 SDK/UI/生命周期:通常用插件和 Platform Channel。
  • 调用 C ABI、高频计算或现有原生库:考虑 dart:ffi
  • FFI 需管理指针、内存所有权、线程安全、ABI 和动态库打包。
  • 不能在 Dart 中直接调用任意 Java/Kotlin/Swift API 而不做桥接。

8.5 插件开发需要考虑什么?

  • Federated Plugin:app-facing interface、platform interface、各平台实现。
  • 多 Engine、Add-to-App、后台执行和 Activity 重建。
  • 权限请求与拒绝后恢复。
  • 平台最低版本、架构产物、隐私声明。
  • Mock platform interface、宿主端单测、示例 App 和集成验证。

9. 性能优化与 DevTools

9.1 正确的性能治理闭环

测量 → 定位 → 假设 → 最小修复 → 对照验证 → 自动回归 → 线上监控

必须说明设备、构建模式、数据规模、刷新率和统计口径。只列 constRepaintBoundaryListView.builder 不构成资深答案。

9.2 如何定位掉帧?

  1. 在 Profile 模式、代表性真机复现。
  2. 查看 Performance Timeline,区分 UI 和 Raster 瓶颈。
  3. UI 高:看 build/layout、同步 CPU、序列化、日志。
  4. Raster 高:看 shader、图片、模糊阴影、裁剪、离屏层和过度绘制。
  5. 用 CPU Profiler、Widget rebuild、Repaint Rainbow、Layer 工具验证。
  6. 固定场景重复采样,对比 P90/P99,而非只看平均值。

9.3 如何优化长列表?

  • 使用懒构建列表/Sliver,不一次创建全部子项。
  • 提供稳定 Key,控制 item 状态所有权。
  • 避免 item 中大范围监听和高成本布局。
  • 尺寸固定时使用 itemExtentprototypeItem 等提示。
  • 图片按显示尺寸解码,做缓存、占位和失败处理。
  • 分页防重、取消过期请求并控制预取。
  • 结合业务决定保活范围,避免无限 KeepAlive。

9.4 如何治理启动耗时?

拆分口径:

  • OS 启动到 Flutter Engine/首帧。
  • 首帧到可交互。
  • 可交互到关键数据可用。

措施:

  • 缩短首帧前同步初始化。
  • 非关键 SDK 延迟并行初始化。
  • 优化首屏资源、字体、图片和本地迁移。
  • 原生侧和 Dart 侧统一 Trace。
  • 设计冷启动、温启动和热启动基线。

不能通过展示静态启动图掩盖不可交互时间。

9.5 内存泄漏如何定位?

常见来源:未取消 Stream/Timer、Controller/Observer 未释放、闭包持有大对象、全局缓存无上限、GlobalKey/Context 长期持有、图片缓存和原生资源未释放。

使用 Memory 页观察 heap、external/native memory(Dart 堆外/原生内存)、GC、对象数量和 retaining path(引用保留路径);通过多次进入退出页面做差分快照。单次内存上涨不等于泄漏,需证明对象应释放却仍被引用。

9.6 图片性能

  • 解码尺寸应接近显示尺寸,避免小控件解码超大图。
  • 区分磁盘缓存、内存缓存、压缩文件大小与解码后内存。
  • 列表控制并发下载、预取和失败重试。
  • 大图查看器考虑分片/缩放能力。
  • 监控缓存命中、失败率、解码耗时和内存峰值。

9.7 包体积治理

使用 App Size 工具和构建产物分析:

  • 按 ABI/架构拆分,检查 native 库和符号。
  • 清理无用资源、字体字形和重复依赖。
  • 评估图片格式、代码生成和插件引入成本。
  • Android/iOS 分别分析,不能只看安装包压缩大小。
  • 在 CI 建立体积基线和增量预算。

9.8 高频性能题评分

回答 评价
“多用 const” 初级,只给技巧
能区分 UI/Raster 并使用 Timeline 合格
有固定基准、分位数、前后数据 优秀
建立 CI 回归与线上指标治理 资深/专家

10. 稳定性、安全与可观测性

10.1 Flutter 异常如何分层捕获?

参考答案

  • Framework 回调中的错误可由 FlutterError.onError 处理。
  • 未被 Flutter 框架捕获的异步/平台分发错误可通过 PlatformDispatcher.instance.onError 补充处理。
  • Zone 可捕获其作用域内的未处理异步错误。
  • Isolate、原生崩溃和系统 ANR/OOM 需要各自通道。

捕获不等于吞掉。应保留原始 StackTrace、版本、设备、路由、业务阶段和脱敏 breadcrumbs,并区分 fatal/non-fatal。Debug 环境应保持错误可见。

10.2 稳定性指标如何定义?

  • Crash-free users/sessions。
  • Dart fatal、原生 crash、ANR、OOM。
  • 页面打开成功率、白屏率、关键链路成功率。
  • 网络失败率、接口 P95/P99、解析失败率。
  • 卡顿帧/慢帧比例、启动分位数、内存峰值。
  • 版本、系统、机型、渠道和实验组维度。

只统计 crash 数量会受活跃量影响,需使用比例和影响用户数。

10.3 线上事故处理流程

  1. 确认告警真实性和影响范围。
  2. 按版本、平台、机型、地域、用户群切片。
  3. 先止损:关闭开关、回滚、降级、暂停发布。
  4. 保留日志、Trace、Dump、请求 ID 等证据。
  5. 最小复现,提出假设并验证。
  6. 修复后灰度,观察领先指标和护栏指标。
  7. 复盘根因、扩大因素、发现缺口和防复发事项。

10.4 移动端安全重点

  • Token/密钥不硬编码,不把敏感数据明文存普通偏好。
  • 使用平台安全存储,但理解越狱/Root 设备无法绝对可信。
  • 日志、埋点、Crash 上报做隐私脱敏。
  • WebView 限制来源、JS Bridge 能力、文件访问和跳转协议。
  • 深链、Channel、剪贴板、通知内容都视为不可信输入。
  • TLS 校验保持正确;证书绑定需评估轮换和失效风险。
  • 权限最小化,并说明使用目的和拒绝后的降级。

风险信号:把 Base64、代码混淆或 HTTPS 单独视作完整安全方案。

10.5 WebView 安全题

问题:业务允许加载活动页并调用原生支付能力,如何设计?

参考要点

  • 域名 allowlist(允许名单),禁止任意 URL 获得 Bridge。
  • Bridge 方法最小化并做参数、来源、用户状态校验。
  • 高风险操作需要原生确认和服务端签名/订单校验。
  • 限制 javascript:、文件协议、混合内容和任意跳转。
  • 建立页面版本、审计日志、紧急禁用开关。

10.6 灰度与远程开关

远程配置要有默认值、Schema、缓存、过期、拉取失败策略和审计。灰度按稳定标识分桶,避免用户每次重启换组。高风险开关应有 kill switch(紧急停用开关),但不能让配置系统成为绕过审核下发可执行代码的机制。


11. 测试、工程化与发布

11.1 Flutter 测试分层

类型 主要目标 特点
Unit Test 纯逻辑、Use Case、Repository 策略 快、隔离、数量多
Widget Test Widget 渲染、交互、状态 快于真机,可控制环境
Golden Test 视觉回归 对字体、平台、像素环境敏感
Integration Test 关键用户链路和平台集成 慢、成本高,但真实度高
宿主端测试 Android/iOS 插件实现 覆盖原生线程与生命周期

合理策略是用大量 Unit/Widget Test 覆盖逻辑和组件,再用少量但有代表性的 Integration Test 覆盖登录、支付、深链、权限或插件等关键跨层链路。数量应由业务风险决定,而不是用“足量”或单一覆盖率代替标准。

11.2 如何写稳定的 Widget Test?

  • 通过语义、Key 或文本定位,避免依赖脆弱树结构。
  • 控制时钟、网络、随机数和外部依赖。
  • pump() 推进一帧;pumpAndSettle() 等待无调度帧,但无限动画会超时。
  • 测行为和输出,不绑定内部实现。
  • 覆盖 loading、empty、error、success 和重试。

11.3 Golden Test 的边界

Golden 适合设计系统、核心组件和稳定页面,不适合替代交互测试。需要固定 Flutter 版本、字体、像素比、平台和渲染环境;评审差异时不能无脑更新基线。动态时间、网络图片和动画必须可控。

11.4 Mock、Fake 与真实依赖如何选?

  • 纯逻辑用 Fake/Mock 提高速度和确定性。
  • 数据库迁移、序列化契约、插件宿主实现需有真实集成验证。
  • Mock 应验证关键交互,不应把内部调用顺序全部固化。
  • 契约测试可降低客户端与服务端 schema 漂移风险。

11.5 如何治理 flaky test?

记录失败率、重试后通过率和责任归属;治理真实原因,包括异步等待、共享状态、网络、动画、顺序依赖和环境差异。重试可用于收集证据,不应作为永久掩盖。隔离测试数据并输出截图、日志和时间线。

11.6 推荐 CI 流水线

  1. 格式检查和静态分析。
  2. Unit/Widget Test,可并行和分片。
  3. 关键 Golden Test。
  4. Android/iOS 构建及签名配置检查。
  5. 关键 Integration Test 真机/模拟器矩阵。
  6. 包体积、依赖漏洞、许可证和性能预算。
  7. 产物签名、校验、归档和可追溯发布。

常用命令:

1
2
3
4
5
6
dart format --output=none --set-exit-if-changed .
flutter analyze
flutter test
flutter test integration_test
flutter build appbundle --release
flutter build ipa --release

11.7 环境与配置治理

  • Dev/Staging/Prod 配置可通过 flavor、编译参数和不同宿主配置管理。
  • 秘密不能因为使用 --dart-define 就变安全;客户端内的值最终可被提取。
  • 保证同一源码和锁定依赖可重现构建。
  • 签名材料放安全系统,采用最小权限与审计。
  • 发布产物必须能追溯 commit、依赖、配置和流水线。

11.8 依赖和版本治理

  • 审核维护活跃度、许可证、平台代码和传递依赖。
  • 谨慎使用宽泛版本范围,应用项目提交 lockfile。
  • 升级 Flutter/Dart 先跑兼容矩阵和关键链路。
  • 核心能力避免绑定无法维护的插件,必要时 fork 需明确回归上游策略。
  • 定期清理过期 override 和临时补丁。

12. 大型项目架构与团队治理

12.1 模块化如何划分?

优先按业务能力切分 Feature,而不是只按技术层形成超大 widgets/services/models 目录。公共层仅放稳定、真正共享的能力。每个模块明确:

  • 对外 API 和内部实现。
  • 依赖方向与禁止依赖。
  • 路由、资源和状态所有权。
  • 测试策略与负责人。
  • 版本和迁移方式。

风险信号:把文件拆成 package 就称为模块化,却存在循环依赖和任意跨层访问。

12.2 Monorepo 与多仓库

Monorepo 便于原子修改、统一工具和依赖协同,但构建、权限和工具规模更复杂;多仓库隔离清晰,跨仓变更和版本协调成本更高。选择取决于团队边界、发布节奏、代码共享和工具能力。

12.3 多团队如何治理公共组件?

  • 设计 Token 和语义化 API,而非复制页面样式。
  • 组件有 owner、版本、变更日志和弃用周期。
  • 示例、Golden、无障碍和多语言测试。
  • 重大变更提供迁移工具或可执行指南。
  • 收集使用反馈,避免公共库成为无限参数集合。

12.4 如何管理架构决策?

通过轻量 ADR(Architecture Decision Record,架构决策记录)记录背景、约束、候选方案、决策、后果和复审条件。决策应可被新证据推翻。资深工程师既要防止无标准,也要防止架构委员会阻塞交付。

12.5 Flutter SDK 升级策略

  1. 盘点 breaking changes、插件和原生工具链。
  2. 建立升级分支和编译/测试基线。
  3. 先解决 deprecation,再升级关键依赖。
  4. 对启动、帧率、包体积、渲染和平台链路回归。
  5. 分阶段合并与灰度,准备回滚。
  6. 更新模板、CI、文档和团队规范。

12.6 Add-to-App 的架构挑战

  • FlutterEngine 预热与缓存,首开速度和内存的权衡。
  • Native 与 Flutter 路由栈、返回手势和生命周期协调。
  • 多 Engine 的插件兼容和资源成本。
  • 登录态、主题、语言、埋点上下文同步。
  • 混合页面问题归属、监控关联和发布节奏。

12.7 国际化、无障碍和多端适配

  • 文案使用资源键,处理复数、性别、日期、数字和 RTL(从右向左书写)布局。
  • 动态字体、文本缩放、最小触控区域、语义标签和焦点顺序。
  • 响应式设计基于可用空间和输入方式,而非只看设备型号。
  • 键鼠、触控、返回键、窗口缩放和平台惯例分别验证。

12.8 Code Review 看什么?

正确性、边界、生命周期、异步竞态、可测试性、性能、安全和可观测性;不把个人风格当成阻塞项。可自动化的格式与规则交给工具,人工评审聚焦设计和风险。重大变更要求测试证据、迁移和回滚说明。

13. 真实项目与线上故障题

13.1 首屏列表滑动严重卡顿

题目

一个信息流页面包含图片、富文本、视频预览和曝光埋点。中低端 Android 机滑动掉帧,如何排查?

优秀回答路径

  1. 固定版本、设备、数据和操作路径,在 Profile 模式建立基线。
  2. Timeline 区分 UI/Raster;观察掉帧是否与新 item 进入、图片解码或曝光回调一致。
  3. UI 侧检查同步解析、item build、intrinsic layout、重复订阅、日志和埋点。
  4. Raster 侧检查超大图、模糊、裁剪、Opacity、saveLayer 和重绘范围。
  5. 分别做最小实验,例如关闭视频/图片/埋点验证因果。
  6. 采用懒构建、尺寸提示、图片降采样、预取限流、重绘隔离等针对性修复。
  7. 记录 P90/P99 帧耗时和慢帧率,并建立滚动性能回归。

追问

  • 若 UI/Raster 都高怎么办?
  • 曝光埋点如何避免每帧做重活?
  • 120 Hz 设备的预算有何变化?

追问答案

  • UI/Raster 都高:分别采样两条链路,并用最小开关实验拆因果。例如先关闭同步解析与埋点观察 UI,再替换图片、阴影或视频观察 Raster;两者可能由同一数据规模同时放大,也可能是两个独立瓶颈,不能靠一个“万能优化”处理。
  • 曝光埋点:可见性计算只产生轻量事件,去重、批量聚合、序列化和网络发送移出逐帧回调;为同一曝光周期设计幂等 ID,并限频、背压。不要在每次滚动通知中同步做 JSON 编码和 I/O。
  • 120 Hz 预算:理论单帧截止时间从 60 Hz 的 16.67 ms 缩短到约 8.33 ms。应按设备刷新率统计慢帧,避免拿固定 16 ms 阈值评价所有设备;同时关注持续吞吐、温控与功耗,而非只看一次流畅滑动。

评分

  • 1 分:直接说加 constRepaintBoundary
  • 2 分:会用 Timeline 区分瓶颈。
  • 3 分:能设计实验、量化验证并防回归。

13.2 页面反复进入后内存持续上涨

题目

图片编辑页每次进入退出后内存增加约 30 MB,最终 OOM。

参考路径

  • 区分 Dart heap、external/native、GPU/图片内存。
  • 重复执行进入—操作—退出—GC,观察是否回落。
  • 做 heap snapshot diff,查看编辑模型、Controller、图片对象 retaining path。
  • 检查 Stream、Timer、Observer、Ticker、原生纹理和插件资源释放。
  • 验证修复后多轮操作的内存平台期(稳定区间),而不是只看某一时刻。

强追问:Dart 对象已释放但内存仍不降有哪些可能?图片缓存是否等于泄漏?原生纹理如何建立释放证据?

追问答案

  • 对象释放但进程内存不降:Dart VM 或系统分配器可能保留已申请内存供后续复用,GC 也不保证立即归还 OS;此外还有 external typed data、图片解码缓冲、GPU 纹理、原生库缓存和内存碎片。应同时观察对象数量、Dart heap、external/native/GPU 指标及多轮操作后的内存平台期。
  • 图片缓存不等于泄漏:有容量上限、可淘汰且在内存压力下可回收的缓存是性能策略;只有条目无界增长、Key 失控或生命周期结束后仍不可释放,才是泄漏或缓存治理缺陷。验证时应清缓存或制造内存压力做对照,而不是只看 RSS(进程常驻内存)。
  • 原生纹理释放证据:为纹理建立跨端唯一 ID,记录 create/register/use/unregister/dispose 的时间线;页面退出后检查原生对象计数、GPU/Native 内存和插件回调,并在重复进入退出、前后台和 Engine 销毁场景下验证计数回到稳定值。Dart 包装对象消失不能单独证明底层纹理已释放。

13.3 Token 刷新引发请求风暴

题目

Token 过期时多个接口同时 401,每个请求都刷新 Token,部分请求再次失败。

优秀方案

  • 使用 single-flight:同一时刻只有一个刷新 Future。
  • 后续请求等待该 Future,成功后用新 Token 重放。
  • 请求标记只允许自动重放一次,防止死循环。
  • 刷新失败统一清理会话并触发一次登录流程。
  • 仅对幂等或明确可安全重试的请求自动重放。
  • 处理用户主动退出与刷新响应竞态,隔离账号世代。

13.4 新版本发布后白屏率上升

处理顺序

  1. 立即暂停放量,判断是否可远程降级或回滚。
  2. 按系统、机型、渠道、冷/热启动、路由切片。
  3. 关联 Flutter fatal、原生 crash、首帧和接口指标。
  4. 检查初始化、数据库迁移、资源、路由和插件注册变化。
  5. 用受影响设备/环境复现,保留启动 Trace。
  6. 修复并小流量灰度,同时观察白屏率与业务成功率。

风险答案:先让用户清缓存或重装,没有止损和证据链。

13.5 数据库迁移导致部分用户启动失败

考察点

  • 迁移必须按 schema 版本顺序、事务化、可重复验证。
  • 发布前使用真实历史数据库快照覆盖多版本升级路径。
  • 大迁移避免阻塞首帧,评估分批和后台方案。
  • 失败时保留数据还是重建取决于业务价值,不能默认删库。
  • 监控迁移耗时、失败率和剩余空间。

13.6 Platform Channel 偶发无回调

定位路径

  • 确认 Dart 是否发出、原生 handler 是否注册、结果是否恰好返回一次。
  • 检查 Activity/Engine 切换、插件 detach、后台恢复和线程。
  • 为每次调用加入 request ID、阶段日志、超时和取消。
  • 若有异步系统回调,确认对象未在生命周期切换后丢失。
  • 检查多 Engine 是否注册到错误 Messenger。

13.7 iOS 正常、Android 低端机崩溃

先分类 Java/Kotlin crash、native crash、OOM 或 ANR;按 ABI、OS、GPU、内存档位切片。检查大图解码、native 库架构、插件线程、后台限制和厂商差异。不要因为 Flutter 代码跨平台就假设运行环境一致。

13.8 搜索结果偶尔回退到旧关键词

这是典型异步竞态。输入 A 后请求较慢,输入 B 后请求较快,B 先返回、A 后返回覆盖结果。可 debounce 降低频率,用序号/当前 query 丢弃过期响应,或取消旧请求。还要处理页面销毁和错误状态。

13.9 推送点击偶发打开错误页面

检查通知 payload 版本、路由参数、登录态、冷启动初始化顺序和重复消费。将“接收通知”“解析意图”“满足前置条件”“导航”拆成可观测状态机,使用 notification ID 幂等,未知 payload 安全降级。

13.10 包体积突然增加 20 MB

用 App Size 分析和前一版本 diff,按 Dart AOT、assets、native library、resources 分类。重点检查新增插件的多架构二进制、重复字体、未压缩资源和调试符号。修复后建立每平台/ABI 的增量预算。

13.11 一次完整项目复盘题

问题:讲一个你主导的最复杂 Flutter 项目。

连续追问

  1. 业务目标与规模是什么?
  2. 你拥有的决策权和交付边界?
  3. 最大技术风险是什么,如何提前识别?
  4. 方案候选与取舍依据?
  5. 哪次判断错了,造成什么后果?
  6. 最终数据结果和长期维护成本?
  7. 如果重做,哪些保持不变,哪些推翻?

高分回答结构

  1. 目标与规模:说明用户、核心链路、平台、流量/数据量及成功标准,不用“复杂”代替事实。
  2. 职责边界:区分自己决定、参与评审和团队共同完成的部分;同时说明不能决定的外部约束。
  3. 风险识别:从原型、历史数据、容量估算、依赖成熟度和故障演练中提前发现风险,并说明风险未发生不等于识别没有价值。
  4. 方案取舍:列出至少两个真实候选方案,用性能、交付、可维护性、迁移和回滚成本比较,而不是事后只描述胜出方案。
  5. 错误判断:明确自己的假设哪里错、早期信号为何被忽略、如何止损及机制如何改变;不能把失败全部归因于其他团队。
  6. 结果与成本:同时报告用户/业务指标、技术分位数、稳定性和长期维护成本,说明观察窗口与数据口径。
  7. 重做选择:保留被证据验证的原则,推翻过早抽象、错误边界或高运营成本设计,并解释新证据,而不是简单说“会做得更好”。

优秀信号:承认不确定性和失败,能把技术指标连接到用户/业务结果。

13.12 故障复盘模板

1
2
3
4
5
6
7
8
事件:一句话说明用户可见影响
时间线:发现、响应、止损、修复、恢复
影响:用户数、时长、业务损失、平台/版本
根因:直接技术原因
扩大因素:为什么影响被放大
发现缺口:为何没有更早发现
处置:临时止损与永久修复
防复发:测试、监控、流程、负责人和完成条件

14. 系统设计题

14.1 通用作答框架

  1. 澄清用户、核心链路、平台、规模和非功能目标。
  2. 定义模块边界和依赖方向。
  3. 画出数据流、状态流和副作用。
  4. 设计网络、缓存、离线、同步与冲突。
  5. 处理错误、降级、恢复、幂等和安全。
  6. 定义性能、稳定性和业务指标。
  7. 说明测试、发布、灰度和回滚。
  8. 给出演进路线,避免一次性过度设计。

14.2 设计一个支持离线的即时通讯模块

需求澄清

  • 单聊/群聊、消息类型、已读回执、撤回、搜索。
  • 弱网/离线、消息量级、多设备一致性。
  • 加密、合规、推送和附件限制。

参考设计

  • 本地数据库是 UI 的主要读取源。
  • WebSocket/长连接接收增量,REST 补历史和校准。
  • 发送消息先生成 client message ID,写 Outbox 并展示 sending。
  • 服务端按幂等 ID 去重,返回 server sequence 和时间。
  • 每会话用游标/序列同步,缺口触发补拉。
  • 状态机:sending/sent/delivered/read/failed。
  • 附件独立上传,支持压缩、进度、取消和断点。
  • 推送只作为唤醒/提示,回到服务端拉权威数据。

深入追问

  • 本地临时消息如何与服务端消息合并?
  • 多设备撤回、乱序、重复和时钟漂移怎么办?
  • 数据库如何分页且保持列表锚点?
  • 密钥和消息内容如何保护?

深入追问答案

  • 临时消息合并:本地发送时生成全局唯一的 clientMessageId;服务端按该 ID 幂等并返回 serverMessageId/sequence。数据库在一个事务中更新原记录的服务端字段和状态,不新增第二条消息;重连补拉时也按客户端 ID、服务端 ID 和会话序列去重。
  • 撤回、乱序、重复与时钟漂移:排序和同步以服务端 sequence/逻辑版本为准,客户端时间只用于近似展示;撤回建模为带目标消息和版本的服务端事件,多设备按游标补拉。重复事件幂等应用,发现 sequence 缺口就补拉,不能依赖 WebSocket 到达顺序。
  • 分页与锚点:数据库用 (conversationId, serverSequence) 建联合索引,采用游标分页而非大 offset。加载更早消息前记录锚点消息 ID 与其视口偏移,插入后恢复相对位置;新消息到达时只有用户位于底部才自动跟随。
  • 内容保护:传输使用 TLS,静态数据按风险使用平台安全存储保护密钥并加密数据库/附件;日志、通知预览、备份和截图也纳入隐私边界。若要求端到端加密,还需设计设备密钥、会话密钥轮换、多设备验证和丢失恢复,不能只说“AES 加密”。

评分点

项目 分值
需求与约束澄清 15
数据模型和状态机 20
离线、幂等、同步 25
性能和存储 15
安全与可观测性 10
测试、灰度、演进 15

14.3 设计大型电商 Flutter App

应覆盖:

  • 首页、搜索、商品、购物车、订单按 Feature 模块化。
  • 用户、实验、设计系统、网络、监控作为平台能力。
  • 商品详情的聚合接口、缓存和降级。
  • 购物车本地乐观更新与服务端价格/库存最终校验。
  • 下单链路防重、支付回调幂等、恢复未完成订单。
  • 多环境、动态配置、灰度、埋点一致性。
  • 大促时图片、接口、降级和流量保护。

关键判断:客户端不能把展示价格或本地购物车当成最终交易权威。

14.4 设计动态化首页

服务端下发受限 Schema,客户端维护组件注册表、版本兼容和默认降级。Schema 必须校验大小、深度、组件 allowlist 和参数。预置组件而非下发任意 Dart 代码。建立预览、审计、灰度、缓存、过期和 kill switch。

追问:未知组件怎么办?配置导致崩溃如何回滚?埋点如何保证跨模板一致?

追问答案

  • 未知组件:解析阶段按组件类型和最低客户端版本校验;未知类型渲染安全占位或跳过局部模块,并上报模板版本、组件类型和客户端版本。不能让一个未知节点导致整页白屏,也不能静默掩盖长期契约不兼容。
  • 配置回滚:配置必须版本化、签名/校验并保留最近可用版本。先停止服务端放量或触发 kill switch,客户端校验失败/连续崩溃时回退内置默认或上一稳定缓存;按版本灰度恢复并监控页面成功率。
  • 埋点一致性:在 Schema 中定义稳定的语义事件和组件实例 ID,由统一渲染层自动补充模板、位置、实验和页面上下文;业务组件只提交受约束的参数。用 Schema 校验、契约测试和数仓质量监控防止同一事件跨模板含义漂移。

14.5 设计多 Tab 独立导航应用

每个 Tab 可维护独立 Navigator 和栈状态;根路由处理登录、深链和全局弹窗。明确 Android Back、重复点 Tab 回顶、内存回收和状态恢复。不要用一个全局 Navigator 随意承载所有流程。

14.6 设计 Flutter 插件平台

  • platform interface 定义稳定契约。
  • Android/iOS/Web/Desktop 分包实现。
  • 插件能力发现、版本兼容和错误模型。
  • 自动化模板、Lint、宿主测试和示例验证。
  • 生命周期、权限、线程和多 Engine 规范。
  • 发布、弃用、安全审计和 owner 制度。

14.7 设计移动端可观测平台

统一事件模型关联 session、trace、route、version 和匿名用户标识。覆盖 Crash、ANR、性能、网络、日志和业务成功率。客户端做采样、限流、批量、压缩、离线缓存、优先级和隐私脱敏;服务端支持聚合、告警、版本对比和发布关联。

14.8 系统设计常见扣分点

  • 未澄清需求就开始选 Provider/Bloc。
  • 只画分层,没有数据流和故障路径。
  • 把所有一致性问题交给“重试”。
  • 没有容量、性能、监控、灰度和回滚。
  • 为假想需求设计大量抽象,却未解决核心链路。

15. 编码与调试题

15.1 实现防抖搜索并避免旧响应覆盖

要求

  • 输入停止 300 ms 后发起查询。
  • 新输入取消旧计时器。
  • 过期响应不得覆盖新结果。
  • 组件销毁后不更新 UI。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class _SearchPageState extends State<SearchPage> {
Timer? _debounce;
int _requestVersion = 0;
List<Item> _items = const [];

void onQueryChanged(String query) {
_debounce?.cancel();
final version = ++_requestVersion;

_debounce = Timer(const Duration(milliseconds: 300), () async {
final result = await repository.search(query);
if (!mounted || version != _requestVersion) return;
setState(() => _items = result);
});
}

@override
void dispose() {
_debounce?.cancel();
_requestVersion++;
super.dispose();
}
}

追问

  • Repository 支持取消时如何真正中止网络请求?
  • 空字符串、异常、Loading 和重试如何建模?
  • 如何用 fake clock 测试而不等待真实 300 ms?

追问答案

  • 真正取消请求:让 Repository 接受 CancelToken 或自定义取消信号,新输入和 dispose() 都取消旧句柄,并把信号传到 HTTP 客户端。取消仍可能与完成竞态,所以请求版本校验必须保留;取消异常应静默处理,不展示成搜索失败。
  • 状态建模:使用 idle/loading/data/empty/error 等互斥状态,而不是多个可能冲突的布尔值。空查询立即取消并回到 idle;请求发出时进入 loading;成功按结果进入 data/empty;错误和重试都绑定当时 query,并在提交前确认仍是当前查询。
  • Fake clock 测试:纯 Dart 可用 fake_async,先推进 299 ms 断言未请求,再推进 1 ms 断言只请求一次;连续输入应只保留最后一次。Widget Test 可用 tester.pump(Duration(...)),并用可控 Completer 让新响应先于旧响应完成,验证旧结果不会覆盖。

15.2 修复这段生命周期代码

1
2
3
4
5
6
7
8
9
10
11
class _PageState extends State<Page> {
late final StreamSubscription<Data> subscription;

@override
void initState() {
super.initState();
subscription = widget.source.listen((data) {
setState(() {});
});
}
}

应发现

  • 未在 dispose 中取消订阅。
  • didUpdateWidget 未处理 source 变化。
  • subscription 声明为 final,无法在数据源变化时重新赋值。
  • 回调没有保存/使用 data,题目可要求补齐状态。
  • 错误与完成语义未定义。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
class _PageState extends State<Page> {
late StreamSubscription<Data> _subscription;
Data? _latestData;
Object? _streamError;
bool _streamDone = false;

@override
void initState() {
super.initState();
_listen(widget.source);
}

void _listen(Stream<Data> source) {
_subscription = source.listen(
_onData,
onError: _onError,
onDone: _onDone,
);
}

void _onData(Data data) {
if (!mounted) return;
setState(() {
_latestData = data;
_streamError = null;
});
}

void _onError(Object error, StackTrace stackTrace) {
if (!mounted) return;
setState(() => _streamError = error);
}

void _onDone() {
if (!mounted) return;
setState(() => _streamDone = true);
}

@override
void didUpdateWidget(covariant Page oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.source, widget.source)) {
_subscription.cancel();
_latestData = null;
_streamError = null;
_streamDone = false;
_listen(widget.source);
}
}

@override
void dispose() {
_subscription.cancel();
super.dispose();
}
}

示例选择“展示当前数据、显示最后一次错误、记录流已结束,并在切换数据源时清空旧状态”的策略;真实项目可按业务改为保留旧数据、终止页面或允许重试。面试时也允许候选人指出 cancel() 返回 Future,但 dispose() 不能 await,并进一步讨论资源层的关闭策略。

15.3 找出列表状态错位原因

1
2
3
4
5
ListView(
children: users
.map((user) => UserCard(user: user))
.toList(),
)

UserCard 有内部状态且 users 重排时,状态可能按位置复用到错误用户。应使用稳定业务 ID:

1
2
3
4
UserCard(
key: ValueKey(user.id),
user: user,
)

追问:如果外层还有 Padding,Key 放在哪里?删除、插入和跨父节点移动分别如何处理?

追问答案

  • 外层包装:Key 必须放到同级比较的最外层节点;若兄弟是 Padding,就把 ValueKey(user.id) 放在 Padding 或包一层 KeyedSubtree。只给内部 UserCard Key,第一层无 Key 的 Padding 仍会按位置复用。
  • 删除与插入:稳定且同级唯一的 LocalKey 让未删除项的 Element/State 跟随业务实体移动;删除项最终 dispose,新 Key 创建新 State。数组下标会随插入改变,随机 Key 又会强制全部重建,都不合适。
  • 跨父节点移动:LocalKey 只在同一父节点的兄弟范围匹配。GlobalKey 可支持同一帧跨父节点重挂载,但成本和耦合高;普通业务状态更适合提升到 ViewModel/领域状态,避免依赖 Widget State 跨树保存。

15.4 实现并发安全的 Token 刷新

伪代码应体现 single-flight:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Future<String>? _refreshing;

Future<String> refreshOnce() {
final inFlight = _refreshing;
if (inFlight != null) return inFlight;

final future = authApi.refreshToken();
_refreshing = future;
return future.whenComplete(() {
if (identical(_refreshing, future)) {
_refreshing = null;
}
});
}

评分重点不是语法,而是并发等待、失败清理、只重试一次、退出竞态和非幂等请求策略。

15.5 自定义 RenderObject 题

题目:在父节点提供有界宽高约束的前提下,实现一个只允许单个 child、自身占满允许空间,并把 child 放在右下角的 RenderObject。

候选人应说明:

  • Widget/Element/RenderObject 对应类型。
  • performLayout 中可令 size = constraints.biggest,用 constraints.loosen() 布局 child,并把 BoxParentData.offset 设为 Offset(size.width - child.size.width, size.height - child.size.height)
  • paint 使用 offset 绘制 child。
  • 属性变化时选择 markNeedsLayoutmarkNeedsPaint
  • 需要时处理 hit test 和语义。

不要求完整背出源码,但要有正确管线意识。若去掉“有界约束”和“占满空间”的前提,自身尺寸策略并不唯一,候选人应先澄清是扩展到最大尺寸还是按 child 收缩。

15.6 调试题:setState() called after dispose

应检查 Timer、Future、Stream、Animation、Channel callback。仅添加 if (!mounted) return 能避免异常,但更优方案是取消无意义工作、让请求与页面生命周期绑定,并防止资源泄漏和副作用继续发生。

15.7 调试题:Vertical viewport was given unbounded height

候选人应从约束链解释:纵向可滚动组件位于给出无界垂直约束的父布局中。根据意图选择 Expanded、明确尺寸,或合并为一个 CustomScrollView/Sliver;不应习惯性 shrinkWrap: true,因为它可能增加布局成本。

15.8 编码题评分表

项目 权重
正确性和边界 30%
生命周期与资源释放 20%
异步竞态和错误语义 15%
可读性与 API 设计 15%
可测试性 10%
性能与安全意识 10%

16. 领导力与行为面试

16.1 推动过什么跨团队技术改进?

追问

  • 问题为何值得做,谁不同意?
  • 如何用数据而不是职位推动?
  • 试点范围、迁移成本和回滚是什么?
  • adoption(采用率)、故障率或交付效率改善多少?

高分回答框架(经历型问题应替换为候选人的真实案例)

  • 为何值得做、谁不同意:把问题转换为故障、重复维护、交付延迟、升级阻塞或合规风险。准确复述反对方承担的迁移成本、排期和性能顾虑,而不是把不同意见描述成“不配合”。
  • 用数据推动:建立缺陷率、维护工时、交付周期等基线,用原型或小范围试点验证收益;同时公开迁移成本和适用边界,并与受影响团队共同约定“达到什么指标继续、什么情况停止”。
  • 试点、迁移与回滚:选择有代表性但爆炸半径可控的真实链路;把代码修改、测试、培训、双版本维护和发布风险都计入迁移成本。通过旧版兼容、版本锁定、远程开关或模块级恢复提供回滚。
  • 结果指标:adoption(采用率)应是“已迁移目标数/符合条件总数”;故障按会话或调用量归一化;效率观察交付周期中位数和 P90;同时报告回滚、维护工单和升级耗时。比较前后必须使用相同口径,并说明观察窗口和同期干扰因素。

优秀答案体现影响力、利益相关方管理和持续运营,而不只是写了一个工具。

16.2 如何处理架构意见冲突?

先统一目标和约束,区分事实、假设和偏好;用 ADR、原型、测量或小范围试点消除不确定性。可逆决策快速试验,不可逆决策提高证据门槛。决策后共同执行,并约定复审条件。

16.3 如何培养中初级 Flutter 工程师?

  • 用渐进任务扩大所有权,而非只分琐碎工作。
  • Code Review 解释原则和替代方案。
  • Pair debugging 展示定位过程。
  • 建立可访问的示例、规范和故障案例。
  • 用交付质量、独立决策和影响范围衡量成长。

16.4 如何处理技术债?

把技术债转换为可观察成本:故障、研发耗时、性能损失、升级阻塞或安全风险。按风险和收益排序,与业务迭代结合偿还;对大型债务先建立隔离边界和迁移路径。避免“全部重写”或“永远以后再说”。

16.5 讲一次失败

优秀答案应说明自己的错误判断、早期信号为何被忽略、如何止损和改变机制。只把失败归因于产品、服务端或新人是风险信号。

16.6 如何承担线上值班?

明确告警分级、值班手册、升级路径、访问权限和应急开关。事故中保持单一指挥和时间线,优先恢复服务;事后做无责但有行动项的复盘。治理目标是减少重复事故,而非追责个体。


17. 评分表与录用建议

17.1 综合评分表(100 分)

维度 权重 关键证据
Dart 与异步并发 10 Event Loop、Stream、Isolate、竞态
Framework 与渲染 15 三棵树、约束、帧管线、生命周期
架构与状态管理 15 边界、数据流、副作用、演进
性能与内存 15 工具、证据、分位数、回归
稳定性与安全 10 监控、止损、复盘、隐私
原生与跨平台 10 Channel/FFI、插件、平台生命周期
测试与工程化 10 测试策略、CI、依赖和发布
系统设计 10 澄清、权衡、故障路径、演进
领导力和沟通 5 推动、培养、决策、责任

17.2 分数锚点

  • 85–100:强烈推荐
    多个领域达到治理级;能用真实指标、权衡和机制证明影响。
  • 75–84:推荐
    基础扎实,独立负责复杂模块,有至少一个明显强项。
  • 65–74:谨慎推荐/看岗位匹配
    有交付能力,但原理、复杂度或领导力存在缺口。
  • 50–64:不推荐资深岗位
    偏 API 使用,缺少系统设计和真实治理经验。
  • 低于 50:不推荐
    核心概念错误,无法独立处理复杂项目。

17.3 一票否决或重点风险

  • 编造项目数据或前后陈述明显矛盾。
  • 对用户数据、安全或发布风险缺乏基本敬畏。
  • 无法接受证据,与事实冲突时仍坚持个人偏好。
  • 把全部事故归责他人,不承担自己的决策。
  • 核心异步、生命周期和资源释放知识存在系统性缺陷。

17.4 面试记录模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
候选人:
岗位/级别:

项目真实性与个人职责:
最强领域及证据:
主要短板及证据:

Dart/并发:__/10
Framework/渲染:__/15
架构/状态:__/15
性能/内存:__/15
稳定性/安全:__/10
原生/跨平台:__/10
测试/工程化:__/10
系统设计:__/10
领导力:__/5

总分:__/100
建议:强烈推荐 / 推荐 / 谨慎 / 不推荐
级别判断:
入职风险:
建议后续追问:

18. 候选人准备清单与 STAR 模板

18.1 必备知识检查

  • 能解释 Widget、Element、RenderObject 及更新条件。
  • 能从约束解释常见布局异常。
  • 能说明 Event Loop、Future、Stream、Isolate。
  • 能区分 rebuild、relayout、repaint 和 raster。
  • 能说明状态所有权、作用域、依赖和副作用。
  • 能设计 Token 刷新、缓存、离线和错误模型。
  • 能解释 Channel、插件生命周期和 FFI 边界。
  • 能用 DevTools 给出一次有数据的性能案例。
  • 能讲清一次线上事故及防复发措施。
  • 能设计 Unit/Widget/Integration/Golden 测试组合。
  • 能说明 CI、灰度、回滚和版本治理。
  • 能完成一个大型 App 的模块与数据流设计。

18.2 每个项目至少准备的数据

  • 用户/流量规模、目标平台、团队人数、项目周期。
  • 页面/模块/包数量和关键技术约束。
  • 启动 P50/P90、慢帧率、崩溃率、ANR、内存峰值。
  • 包体积、测试数量/覆盖、构建耗时、发布频率。
  • 方案前基线、方案后结果、数据采集方式。

数据必须真实且可解释。无法披露绝对值时可使用脱敏后的比例和数量级。

18.3 STAR+R 项目回答模板

  • S(Situation):业务背景、用户规模和约束。
  • T(Task):你的职责、目标和成功标准。
  • A(Action):定位证据、候选方案、决策和实施。
  • R(Result):量化结果及业务影响。
  • R(Reflection):失败、代价、防复发和下一步。

推荐表达:

1
2
3
4
5
6
7
项目服务于……,当时在……设备/版本上出现……
我的职责是……,成功标准定义为……
我先通过……建立基线,发现主要瓶颈是……
比较了 A/B/C,放弃 A 是因为……,最终选择 B……
灰度过程中用……作为护栏,保留……回滚方案。
最终 P90 从……降至……,崩溃率从……降至……
这次方案的代价是……,后来通过……避免复发。

18.4 性能案例模板

1
2
3
4
5
6
7
8
9
场景和设备:
Profile/Release 与采样方法:
优化前 P50/P90/P99:
Timeline 中 UI/Raster 特征:
根因证据:
实验与被否决方案:
最终改动:
优化后数据:
线上监控与回归门禁:

18.5 故障案例模板

1
2
3
4
5
6
7
8
9
用户影响与持续时间:
发现渠道:
第一止损动作:
时间线与关键证据:
直接根因:
扩大因素:
永久修复:
测试/监控/流程防复发:
个人反思:

18.6 面试前最后检查

  1. 不背诵绝对化结论,主动说明版本、平台和业务边界。
  2. 每个核心结论准备一个反例或不适用场景。
  3. 所有“优化”准备前后数据和测量方法。
  4. 所有“主导”准备决策、协调和个人贡献证据。
  5. 遇到未知问题先澄清和建立分析路径,不要猜 API。

附录:面试官快速题库

基础热身

  1. const Widget 对更新流程有什么实际影响?
  2. State 保存在哪里,Widget 为什么可频繁创建?
  3. BuildContext 为什么与树的位置有关?
  4. Future 与 Stream 的错误如何传播?
  5. 什么任务值得放入 Isolate?

参考答案

  1. const Widget:相同常量表达式可复用规范化实例,减少对象创建,并让更新流程更容易快速确认配置未变;但 Element 仍由 runtimeType + key 决定是否复用,Inherited 依赖变化也仍可能触发 build()。它是局部优化,不是阻止重建的开关。
  2. State 的位置:State 由 StatefulElement 持有,Widget 只是当前帧的不可变配置。只要新旧 Widget 的类型和 Key 可匹配,Element 与 State 就能跨多次 Widget 创建继续存在。
  3. Context 与位置BuildContext 是 Element 的接口,代表树中的一个位置。向上查找 Theme、Navigator、Provider 得到什么,取决于该位置有哪些祖先,所以刚创建的新作用域不能用它上方的 Context 查询。
  4. 异步错误传播:Future 以“成功值或错误+StackTrace”完成,可用 await/try-catchcatchError 处理;未等待的错误可能成为未处理异步错误。Stream 把错误作为独立事件发送,监听者用 onErrorawait for 外的 try-catch 处理;单个错误是否终止流由生产者决定,不能默认“有错就结束”。
  5. Isolate 适用任务:经过 Profile 证明会阻塞 UI 的 CPU 密集工作,例如大 JSON 映射、图片处理、压缩或加密,且计算收益要高于启动和消息传输成本。普通网络等待不是理由,短小任务迁移后可能更慢。

原理深挖

  1. setState() 后到屏幕变化经历什么?
  2. 如何判断一次卡顿发生在 UI 还是 Raster?
  3. Key 如何参与 Element 复用?
  4. 为什么 shrinkWrap: true 可能有成本?
  5. Platform Channel 在多 Engine 中有什么风险?

参考答案

  1. setState() 链路:同步修改状态并标记对应 Element dirty;下一帧执行 build,比较新旧 Widget 并更新 Element/RenderObject;需要时再 layout、paint、构建 scene,最后由 Engine raster 并提交 GPU。重建不等于整页重新布局或绘制。
  2. 区分 UI/Raster 卡顿:在 Profile 真机的 Timeline 中分别看 UI 与 Raster 帧耗时。UI 高通常检查同步 Dart 计算、build/layout;Raster 高检查大图、阴影、模糊、裁剪、离屏层和过度绘制。用 CPU Profiler、Layer 工具及最小开关实验确认根因。
  3. Key 与复用:框架在同一父节点下结合位置、runtimeTypekey 匹配新旧 Widget。稳定 LocalKey 让 Element/State 随业务实体移动;Key 必须同级唯一,并放在实际参与兄弟比较的最外层节点。
  4. shrinkWrap 成本:滚动组件需要根据内容估算自身尺寸,可能测量更多子项,并在滚动范围或内容变化时重复布局,削弱懒构建优势。少量内容可用;长列表应优先给出明确约束或统一成 Sliver。
  5. 多 Engine 风险:每个 Engine 有自己的 Messenger、Dart Isolate 和插件生命周期。插件若把 Activity、回调或 Channel 存成进程级单例,可能注册到错误 Engine、重复回调、相互覆盖或泄漏;实现必须按 Engine 实例 attach/detach,并验证插件是否真正支持多 Engine。

项目实践

  1. 讲一次你用证据推翻最初性能假设的经历。
  2. 讲一次数据库或缓存迁移事故。
  3. 讲一次 Flutter SDK 大版本升级。
  4. 如何让 20 人团队保持架构边界?
  5. 如何证明一个公共组件平台成功?

高分回答框架

  1. 推翻性能假设:先说最初假设及其依据,再给 Timeline/Profiler 或对照实验如何证伪,说明转向的新根因、最小修复、P90/P99 前后数据和回归门禁。高分点是尊重证据,不是“第一次就猜对”。
  2. 迁移事故:按用户影响、止损、证据、直接根因、扩大因素和防复发回答。重点说明历史 Schema 样本覆盖、事务与幂等、磁盘空间、失败回退,以及为什么测试和监控没有更早发现;不能默认用删库解决。
  3. SDK 大版本升级:盘点 breaking changes、插件和原生工具链,建立编译/测试/性能基线;先在分支和小流量业务验证,比较启动、帧率、渲染、包体积及平台链路,准备版本回退,并更新 CI、模板与团队规范。
  4. 20 人架构治理:按 Feature 划分 owner 和公开 API,用依赖规则、package 边界和 CI 检查阻止违规;ADR 记录重要决策,模板和示例降低遵守成本。边界应可演进,例外要有期限与复审,不能只靠口头约定或负责人审批。
  5. 组件平台成功标准:不仅看组件数量,还看目标团队 adoption、交付周期中位数/P90、重复缺陷率、无障碍与视觉回归、升级耗时、维护工单和版本碎片。收益需与迁移、运行性能和平台维护成本一起衡量。

终局判断

  1. 如果只能改善一个工程指标,你选什么,为什么?
  2. 你做过最有争议的技术决策是什么?
  3. 哪个线上问题改变了你的开发方式?
  4. 你如何判断某项抽象是必要设计还是过度设计?
  5. 入职后三个月,你会如何评估现有 Flutter 项目?

高分回答框架

  1. 只改善一个指标:先根据当前最大用户风险选择,而不是固定答案。若崩溃阻断核心链路,优先 crash-free;若稳定性已达标但首屏流失严重,优先可交互时间。说明指标口径、护栏指标和为何它能带来业务结果。
  2. 有争议的决策:交代冲突目标、反对意见、证据和可逆性;说明如何试点、设停止条件并承担结果。优秀答案既能坚持原则,也能承认后来哪些判断需要修正。
  3. 改变开发方式的事故:用真实事故说明原先缺失的心智模型,例如只修异常却未取消资源,或只看平均值忽略长尾;重点是事故后新增了什么测试、监控、门禁或设计检查,并证明未重复发生。
  4. 判断抽象是否必要:看是否已有多个真实变化源、稳定共同语义、明确替换/测试边界,以及抽象是否降低总认知和迁移成本。只有一个实现、依赖假想需求或调用者需要理解大量内部细节,通常是过度设计;可先重复少量代码,等模式稳定再抽取。
  5. 三个月评估路径:第一个月理解业务、用户和发布链路,建立构建、测试、架构和线上指标基线;第二个月沿关键链路审查数据流、性能、稳定性、原生边界和团队痛点,并完成一个小型真实改进;第三个月按影响/成本排序路线图,与团队共同确定 owner、指标、灰度和回滚。先建立信任和证据,不应一入职就推动重写。

最终判断不应来自某一道“标准答案”,而应来自候选人能否持续给出正确心智模型、真实证据、合理权衡和可落地的治理闭环。