1. Flutter 是什么?和 React Native、Weex 等有什么本质区别?
- Flutter 是 Google 的跨平台 UI 框架,使用 Dart 编写,通过自绘引擎直接绘制 UI(不依赖系统原生控件)。早期引擎以 Skia 为主,新一代 Impeller 已在 iOS/Android 等逐步成为默认。
- 与 RN/Weex 对比:
| 对比项 | Flutter | React Native / Weex |
|---|---|---|
| 渲染 | 自绘(Skia/Impeller 画像素) | JS 描述 → 映射为原生 View |
| 一致性 | 各平台 UI 一致 | 不同平台原生控件可能有差异 |
| 通信 | 无 JS-Native Bridge | 依赖 Bridge(RN 新架构为 JSI+Fabric) |
| 性能 | 动画/滚动易 60fps | 受 Bridge/原生能力影响 |
| 包体积 | 自带引擎,包偏大 | 依赖系统,包相对小 |
2. 为什么 Flutter 选择 Dart 而不是 JavaScript?
- AOT 编译:可编译为原生 ARM/x64,发布包完全 AOT,启动和运行性能好。
- JIT + 开发体验:开发时支持 JIT,配合 Hot Reload 快速刷新。
- 单线程模型清晰:async/await、Isolate 明确,无 JS 多线程历史包袱。
- 可控:Dart 由 Google 主导,与 Flutter 迭代一致;JS 受 ECMA/引擎生态影响。
- 类型安全:强类型利于大型项目和工具链(重构、补全、静态分析)。
小结:性能(AOT/JIT)+ 单线程清晰 + 可控 + 类型安全。
3. 说说 Flutter 的架构分层(从上到下)
| 层级 | 语言 | 主要内容 |
|---|---|---|
| Framework 层 | Dart | 我们写的代码:Widget、渲染、动画、手势、Material/Cupertino 组件 |
| Engine 层 | C/C++ | 渲染引擎(Skia/Impeller)、Dart 运行时、文本排版、Dart VM |
| Embedder 层 | 平台原生 | 对接各平台:窗口、输入、线程、平台插件 |
应用只直接接触 Framework;Engine 和 Embedder 由 Flutter SDK 与各平台工程提供。
架构图(自上而下):
sequenceDiagram
participant App as 应用 / Widget
participant F as Framework 层 (Dart)
participant E as Engine 层 (C/C++)
participant Em as Embedder 层 (平台)
App->>F: 调用
F->>E: 渲染 / 运行时
E->>Em: 绘制 / 系统 API
Em->>Em: iOS · Android · Web · Desktop
数据/调用方向:约束与配置从 Framework 往下传;事件从 Embedder 往上 → Engine → Framework → Widget;渲染为 Framework 生成 Layer/Scene → Engine 绘制 → Embedder 交给平台显示。
小结:你的 App → Framework → Engine → Embedder → 操作系统/GPU。
4. Impeller 和 Skia 有什么区别?当前 Flutter 渲染引擎的现状?
- Skia:Flutter 早期使用的 2D 渲染引擎(也是 Chrome、Android 等在用),成熟稳定,但在部分场景下存在运行时着色器编译导致的卡顿(shader jank)。
- Impeller:Flutter 的新一代渲染引擎,主要改进包括:
- 预编译着色器(AOT):在引擎构建时编译,避免首帧或新效果时的编译卡顿。
- 更适配现代图形 API:iOS/macOS 用 Metal,Android 用 Vulkan(无 Vulkan 时回退 OpenGL)。
- 显式资源管理与并发优化,便于预测性能。
- 当前现状:
- iOS:Impeller 已是默认,不可切回 Skia。
- Android:API 29+ 且支持 Vulkan 的设备默认 Impeller。
- Web:仍以 Skia(CanvasKit/skwasm)为主。
- macOS/Windows/Linux:Impeller 在推进或实验阶段。
- 小结:Flutter 正在从 Skia 向 Impeller 迁移,减轻 jank、更好利用各平台 GPU。
5. Flutter 和原生比,你觉得优缺点是什么?
- 优点:一套代码多端、UI 一致、热重载效率高、自绘性能可控、Dart 类型安全。
- 缺点:包体积偏大、强依赖自绘引擎(Skia/Impeller)、深度原生能力要写 Channel、生态和成熟度不如原生;复杂混合栈要额外方案。
- 适用:中高 UI 一致性要求的 App、快速迭代的 To C/内部工具;强依赖最新原生特性的场景要评估。
6. 什么是 Widget?Widget 和 Element、RenderObject 的关系?
Widget 是 Flutter 里对 UI 的不可变配置描述(“我要一个什么样的界面”),是声明式的配置数据,不是最终画在屏幕上的东西。常见例子:Text('hello')、Container(color: Colors.blue) 都是 Widget。
三者的角色:
| 概念 | 是什么 | 主要职责 |
|---|---|---|
| Widget | 不可变的配置/描述 | 描述“要什么 UI”(数据) |
| Element | Widget 在树上的“实例 + 管家” | 对应一个 Widget,管生命周期、子节点、更新时对比 Widget 决定复用还是重建 |
| RenderObject | 真正参与布局和绘制的对象 | layout(算尺寸位置)、paint(画到屏幕) |
一句话:Widget 是配置,Element 是管家,RenderObject 是干活的。关系:Widget → Element →(可能有)RenderObject。
谁有 RenderObject?
- 通常没有:Container、Padding、Center 等组合型,由子节点来画。
- 有:Text、Image、Row、Column 等,对应 RenderParagraph、RenderFlex 等。
树结构关系图(Widget → Element → RenderObject 对应):
sequenceDiagram
participant W as Widget 树
participant E as Element 树
participant R as RenderObject 树
W->>E: 每个 Widget 对应一个 Element
E->>E: 管生命周期、子节点、对比复用
E->>R: 部分 Element 对应 RenderObject(如 Text/Row)
Note over W,R: Container 等组合型无 RenderObject,由子节点绘制
更新时流程:
- setState / 父 rebuild → 触发 build,生成新 Widget 树。
- Element 对比新旧 Widget(同位置 + Key 一致则复用)。
- 复用的 Element 关联的 RenderObject 做 markNeedsLayout / paint。
- 下一帧 layout + paint 上屏。
7. StatelessWidget 和 StatefulWidget 的区别?使用场景?
| 对比项 | StatelessWidget | StatefulWidget |
|---|---|---|
| 状态 | 无内部可变状态 | 有 State 保存可变状态 |
| build 依赖 | 仅构造函数参数 | 参数 + State 内数据 |
| 更新方式 | 父重建则重建 | setState() 触发重建 |
| 典型场景 | 纯展示、静态内容、无交互 | 有交互、数据变化、表单、动画 |
选择:能不用状态就不用,优先 StatelessWidget;需要状态再用 StatefulWidget 或状态管理方案。
8. StatefulWidget 的 State 生命周期(常用方法顺序)?
| 顺序 | 方法 | 调用时机 |
|---|---|---|
| 1 | createState() | StatefulWidget 被创建时,创建对应 State |
| 2 | initState() | State 第一次插入树时,只调一次;做初始化;不能在这里 setState |
| 3 | didChangeDependencies() | initState 之后立即调一次;之后依赖的 InheritedWidget 变化时再调 |
| 4 | build() | 构建 UI,可多次调用(setState、父 rebuild、依赖更新等) |
| — | setState() | 你主动调用,触发下一帧 build() |
| 5 | didUpdateWidget(oldWidget) | 父组件 rebuild 并传入新的 StatefulWidget 时;可对比 oldWidget 做逻辑 |
| 6 | deactivate() | State 从树上暂时移除时(如路由替换,可能还会再插入) |
| 7 | dispose() | State 永久移除时,只调一次;必须在这里 dispose Controller、取消订阅 |
生命周期流程图:
sequenceDiagram
participant S as State
S->>S: createState()
S->>S: initState()
S->>S: didChangeDependencies()
S->>S: build()
Note over S: setState/依赖变化 → 再 didChangeDependencies → build
Note over S: 父传入新 Widget → didUpdateWidget() → build
Note over S: 从树移除 → deactivate() → dispose()
只调一次:createState、initState、dispose。可能多次:didChangeDependencies、build、didUpdateWidget、deactivate。
常见考点:initState 里不能 setState(此时还未挂载完成);异步回调里 setState 前要 mounted 判断;dispose 里必须释放 Controller、StreamSubscription、Timer、removeObserver,否则易内存泄漏。
9. 什么是 Key?LocalKey 和 GlobalKey 区别?什么时候必须用 Key?
- Key:在 Widget 树中标识 Widget,用于 Element 复用。同类型同父节点下,Key 相同则复用 Element,否则可能复用错导致状态错乱。
- LocalKey:在同一父节点下唯一即可(如 ValueKey、ObjectKey、UniqueKey)。
- GlobalKey:全局唯一,可用来跨组件访问 State 或 Element(如获取 State 调方法、获取 RenderBox 做坐标)。
必须用 Key 的典型场景:列表增删改(尤其是带状态的列表项)、有状态的子 Widget 顺序/数量会变时,给列表项设 ValueKey(item.id)(或 ObjectKey、UniqueKey)等,避免 Flutter 按位置复用错 Element。
10. Flutter 的布局模型是怎样的?约束是怎么向下传递的?
- 约束驱动:父组件给子组件 BoxConstraints(min/max width/height),子组件在约束内选一个尺寸,再决定子子的约束。
- 自顶向下传约束,自底向上返尺寸;父再根据子的尺寸来摆放(如 Row 根据子宽度排布)。
- 常见:紧约束(min=max)必须填满;松约束(如 max 为无限)子可自己选尺寸。
约束与尺寸流向:
sequenceDiagram
participant P as 父 RenderObject
participant C as 子 RenderObject
P->>C: 传递 BoxConstraints (min/max 宽高)
C->>P: 返回 Size (选定的宽高)
P->>P: 根据子 Size 摆放 → 布局结果
11. 常用布局 Widget 有哪些?Expanded 和 Flexible 区别?
- 单子:Container、Center、Padding、Align、SizedBox、AspectRatio 等。
- 多子:Row、Column、Stack(层叠)、ListView、GridView、Wrap(流式)。
- Expanded:必须是 Row/Column/Flex 的子组件,flex 默认为 1,在主轴方向占满剩余空间(强制填满)。
- Flexible:可设置 flex 和 Fit(tight/loose),Flexible(fit: FlexFit.loose) 只占需要的空间,不强制填满;Expanded 等价于 Flexible(fit: FlexFit.tight)。
12. 如何实现一个 0.5 高度的分割线?为什么不能直接写 height: 0.5?
为什么不能直接 height: 0.5?
布局会做像素取整,0.5 可能被当成 0 或 1;且部分设备上 0.5 物理像素也不一定可见。
常见做法:
- Transform 或 CustomPaint:画一条 0.5 逻辑像素的线。
- Divider + Transform.scale:在 y 方向缩小。
- SizedBox(height: 1) + Container + Transform 压成 0.5。
- CustomPaint:画 1px 线,用颜色透明度或线宽控制视觉细线。
考点:理解约束与取整 + 会用 Transform / CustomPaint 做细线。
13. 除了 setState,你还用过哪些状态管理?类型与对比?
一、状态管理工具的类型
- 作用范围:本地/组件级(setState) vs 全局/应用级(Provider、Riverpod、Bloc、GetX、Redux)。
- 更新方式:命令式(主动调用更新) vs 响应式(数据变化自动重建)。
- 架构风格:InheritedWidget 系(Provider、Riverpod)、事件/单向流(Bloc、Redux)、一体化(GetX)。
二、不同状态管理工具对比图
(多维对比表,便于横向看差异)
| 方案 | 作用范围 | 更新方式 | 典型 API / 用法 | 适用场景 |
|---|---|---|---|---|
| setState | 组件内 | 命令式 | setState(() => _x = v) | 单组件、逻辑简单 |
| Provider | 子树/全局 | 响应式 | Provider + context.watch/read + notifyListeners() | 中小项目、入门 |
| Riverpod | 全局 | 响应式 | Provider 定义 + ref.watch/read,不依赖 context | 中大型、可测试 |
| Bloc/Cubit | 子树/全局 | 命令式(emit) | BlocProvider + BlocBuilder + emit(state) | 复杂状态机、易测试 |
| GetX | 全局 | 响应式(Obx)/命令式(GetBuilder) | GetxController、.obs、Obx、Get.put | 小项目、快速开发 |
| Redux | 全局 | 命令式(dispatch) | Store + dispatch(action) + reducer | 超大型、多端一致 |
三、选型建议
入门用 Provider(第 13、40 题);中大型用 Riverpod(第 22 题);复杂状态机用 Bloc(第 15 题);小项目用 GetX(第 54~58 题);多端一致用 Redux。按团队规模、可测试性、是否要强约束选。
14. Provider 大致原理?如何把数据“提供给”子树?
基于 InheritedWidget:树上方用 Provider<T> 包一层,子节点 context.watch<T>() / context.read<T>() 取 T。watch 会随 notifyListeners() 重建,read 只取一次不重建。ChangeNotifier 提供 notifyListeners;MultiProvider 组合多 Provider;Consumer/Selector 缩小监听范围。底层原理见第 90 题。
15. 说说 Bloc 的 Bloc/Cubit + Event + State 模型
一、三个核心概念
- State(状态):当前页/模块的数据与界面状态(如列表数据、加载中/成功/失败、选中的 tab)。State 是不可变的,每次变化都是新对象,Bloc/Cubit 通过 emit(newState) 发出新状态。
- Event(事件):用户操作或系统触发的**“发生了什么事”(如点击刷新、下拉、登录、错误重试)。只有 Bloc 有 Event 层;Cubit 没有 Event,外部直接调 Cubit 的方法**。
- Bloc / Cubit:业务逻辑所在,根据“输入”(Event 或方法参数)和当前 State 算出下一 State 并 emit,UI 只监听 State 建界面,不写业务逻辑。
二、Bloc 和 Cubit 的区别
| 对比项 | Cubit | Bloc |
|---|---|---|
| 输入 | 直接调方法(如 cubit.loadData()) | 发 Event(如 bloc.add(LoadDataEvent())) |
| 内部 | 方法里根据参数 + 当前 state 算新 state,emit(state) | 在 on<Event> 或 mapEventToState 里根据 event + 当前 state 算新 state,emit(state) |
| 事件来源 | 不区分“谁触发的”,只看方法参数 | 每个 Event 是独立类型,可追溯“点了什么、发了什么事件” |
| 适用 | 状态机简单、触发方式少 | 事件多、需要区分来源、方便日志/调试/测试 |
三、数据流(单向)
Cubit: UI/业务 调用方法 → Cubit 内计算 → emit(newState) → BlocBuilder 拿到 state 重建 UI
Bloc: UI/业务 add(Event) → Bloc 的 on<Event> 里计算 → emit(newState) → BlocBuilder 拿到 state 重建 UI
- 原则:UI 只负责发 Event / 调方法和根据 State 渲染;不在 UI 里写“加载中改哪个变量、失败弹什么”等逻辑,都放在 Bloc/Cubit 里,通过 State 表达出来。
- 易测试:给定「当前 State + Event(或方法参数)」可以断言「下一 State」是什么,业务逻辑单测简单。
四、Cubit 用法小结
- 继承 Cubit<State>,构造函数里 super(initialState)。
- 在方法里做异步/同步逻辑,需要更新界面时 emit(newState);不能重复 emit 同一实例(State 要不可变、用新对象)。
- UI 里 BlocProvider 提供 Cubit,BlocBuilder<Cubit, State> 根据 state 建 Widget;需要“只监听、不建 UI”时用 BlocListener(如 state 变失败时弹 Toast)。
五、Bloc 用法小结
- 继承 Bloc<Event, State>,构造函数里 super(initialState),用 on<XxxEvent>((event, emit) => ...) 注册处理函数。
- 在 on<Event> 里根据 event 和 state 算新 state,调 emit(newState);可 await 异步操作再 emit。
- UI 里 BlocProvider 提供 Bloc,BlocBuilder 根据 state 建 UI,BlocListener 只听 state 做副作用;需要“先听再建”可用 BlocConsumer。
- BlocProvider 可包在路由或页面根;RepositoryProvider 常和 Bloc 一起用,在 Bloc 里通过 context.read<Repo>() 拿仓库做请求。
六、UI 组件对照
| 组件 | 作用 |
|---|---|
| BlocProvider | 把 Bloc/Cubit 提供给子树,子节点用 context.read<Bloc>() 取 |
| BlocBuilder | 监听 State,state 变化时重建 builder 返回的 Widget;可设 buildWhen 控制何时重建 |
| BlocListener | 只监听 State,不建 UI,在 listener 里做副作用(Snackbar、导航、弹窗) |
| BlocConsumer | BlocBuilder + BlocListener 合一,先 listener 再 builder |
| RepositoryProvider | 提供 Repository 等依赖,Bloc 里 context.read<Repo>() 取用 |
16. context.watch 和 context.read 区别?
- watch:监听变化,当 Provider 通知更新时,当前 Widget 会重建;适合在 build 里拿数据并展示。
- read:只取一次当前值,不建立监听、不因该数据变化而重建;适合在事件回调里取 store 调方法,避免在 build 里用 read 导致更新不生效。
- 总结:展示用 watch,事件里用 read。
17. GetX 详细介绍?Obx 和 GetBuilder 区别?
一、GetX 是什么(一句话)
GetX 是一套轻量一体化库:把状态管理、路由、依赖注入(以及 snackbar/dialog、国际化等)放在一起,API 简单、少写样板代码,适合快速开发;代价是对 BuildContext 依赖少、易“全局化”,大项目需要团队规范以防难维护、难测试。
二、核心能力与常用 API
| 能力 | 常用 API / 概念 |
|---|---|
| 状态管理 | GetxController、.obs 响应式变量、Obx、GetBuilder、update() |
| 路由 | GetMaterialApp、Get.to() / Get.off() / Get.offAll()、Get.back()、Get.arguments |
| 依赖注入 | Get.put()、Get.lazyPut()、Get.find(),不依赖 BuildContext |
| 其他 | Get.snackbar、Get.dialog、Get.bottomSheet、Get.updateLocale 等 |
三、状态管理:从「注册」到「使用」的流程
- 写 GetxController:子类里放状态和业务方法;状态可以是普通变量 + GetBuilder + update(),或 .obs 响应式变量 + Obx。
- 注册:在合适位置 Get.put(MyController())(立即创建)或 Get.lazyPut(() => MyController())(首次 find 时创建),详见第 19 题。
- 在页面里用:Get.find<MyController>() 取实例,或用 GetView<MyController> 的页面自动带 controller;用 Obx(() => Widget) 包住依赖 .obs 的 UI,或用 GetBuilder 包住依赖 update() 的 UI。
- GetxController:在其中维护状态和业务;onInit / onReady / onClose 对应初始化和销毁(对应关系见第 18 题)。
- .obs:把变量变成响应式(如
0.obs→ RxInt),改 .value 或扩展方法(如 count++)后,用到它的 Obx 会重建。 - Obx:只重建其回调里「读到的 .obs」对应的那部分 UI,依赖 GetX 内部依赖收集。
- GetBuilder:不依赖 .obs,在 Controller 里调 update() 或 update(id) 时,对应 GetBuilder(同 id)才重建;适合不想用 .obs、或要精确控制刷新范围时。
Obx 与 GetBuilder 对比
| 对比项 | Obx | GetBuilder |
|---|---|---|
| 依赖 | .obs 响应式变量 | GetxController 的 update() |
| 更新方式 | 自动(.obs 变化即重建) | 手动调用 update() |
| 刷新范围 | 依赖追踪到的变量所在 Obx | 可指定 id,只更新同 id 的 GetBuilder |
| 适用 | 响应式、写法简洁 | 按需刷新、精确控制、不想用 .obs |
四、路由与依赖注入
- 路由:GetMaterialApp + Get.to/off/offAll/back,传参 Get.to(Page(), arguments: data),目标页 Get.arguments。对照 Navigator 见第 21 题。
- 依赖注入:Get.put 立即创建,Get.lazyPut 首次 find 时创建,Get.find<T>() 取实例;pop 时未设 permanent 的会回收。细节见第 19 题。
五、优缺点与注意点
- 优点:API 简单、状态/路由/依赖注入一体、不依赖 context 也能路由和取 Controller,适合小项目或原型。
- 缺点:全局风格重、易滥用,测试和重构成本高;和 Flutter 惯用的「树 + context」不一致,大团队需规范。
- 注意:Controller 适时 Get.delete 或依赖路由回收,避免泄漏;Obx 里只做「读 .obs + 返回 Widget」,不要写重逻辑或异步。
18. GetxController 生命周期?与 State 的 initState/dispose 对应?
一、生命周期顺序(时间线)
Controller 从被 Get.put 创建到被回收,顺序是:
- 创建:Get.put(MyController()) 或首次 Get.find(若用 lazyPut)时实例化。
- onInit():紧接着调用,只执行一次;适合初始化数据、订阅 Stream、发请求等,此时 Widget 尚未 build。
- onReady():在 onInit 之后、下一帧回调;此时当前页已 build 完,适合依赖「已经挂上树的 UI」的逻辑(如弹窗、SnackBar、定位到某个 Key)。
- 使用期:页面通过 Get.find / GetView 使用,Obx / GetBuilder 随状态更新。
- onClose():Controller 被 Get.delete 或随路由 pop 自动回收之前调用;必须在这里取消订阅、关闭 Stream、dispose 子 Controller 等,避免内存泄漏。
二、与 StatefulWidget 的 State 对应
| GetxController | StatefulWidget 的 State | 说明 |
|---|---|---|
| onInit() | initState() | 创建后立即调用,只一次 |
| onReady() | 首帧后(如 addPostFrameCallback) | 界面已 build 完 |
| onClose() | dispose() | 回收前做清理 |
三、注意点
- Get.put 默认在当前路由 pop 且该 Controller 只被该页引用时,会自动 Get.delete,从而触发 onClose。
- Get.put(Controller(), permanent: true) 不会随路由回收,需在合适时机手动 Get.delete,否则易泄漏。
19. Get.put 和 Get.lazyPut 区别?什么时候用哪个?
一、区别概览
| 对比项 | Get.put | Get.lazyPut |
|---|---|---|
| 创建时机 | 调用时立即实例化并放入容器 | 只注册工厂,首次 Get.find<T>() 时才创建实例 |
| 回收 | 默认随当前路由 pop 自动 Get.delete(permanent: true 则不会) | 同样可在 pop 时回收,取决于绑定关系 |
| 典型场景 | 页面一进来就要用的 Controller、希望随路由同生共死 | 不一定用得到、想减轻启动/依赖、避免循环依赖 |
二、Get.put
- 调用 Get.put(MyController()) 时,马上执行构造函数并放入 GetX 容器,之后 Get.find<MyController>() 直接取到同一实例。
- 未设 permanent: true 时,当前路由 pop 且该 Controller 只被该页引用,会自动 delete 并触发 onClose。
- 适用:该页一定用这个 Controller(如详情页的 DetailController),且希望随页面销毁一起回收。
三、Get.lazyPut
- Get.lazyPut(() => MyController()) 只保存一个「创建函数」,第一次某处 Get.find<MyController>() 时才执行该函数并放入容器,之后 find 拿到同一实例。
- 若从未有人 find,Controller 不会创建,也不会执行 onInit/onClose。
- 适用:不是每个入口都会用到的 Controller(如设置页才用 SettingsController);或依赖多、希望延迟初始化、避免循环依赖。
小结:必用且随页销毁用 Get.put;按需用、减启动、解依赖用 Get.lazyPut。put 的要注意 permanent 和适时 Get.delete,避免泄漏。
20. GetX 的 .obs 响应式原理?Obx 如何监听变化?
一、.obs 是什么
- 0.obs、''.obs、false.obs 等会把值包成 GetX 的 Rx 类型(如 RxInt、RxString、RxBool),内部是一个「可监听」的包装,value 改变时会通知所有订阅方。
- 修改方式:必须通过 .value 或 GetX 提供的扩展方法(如 count++、name.value = 'x')修改,这样 GetX 才能拦截并触发通知;直接改普通变量不会触发 UI 更新。
二、Obx 如何监听(数据流)
- build 时:Obx(() => Text('${count.value}')) 执行回调,GetX 在回调执行过程中记录该 Obx 访问了哪些 .obs(如 count),建立「这个 Obx 依赖 count」的关系。
- .obs 变化时:当 count.value 被修改,GetX 通知「依赖 count 的 Obx」需要重建。
- 下一帧:只重建这些 Obx 包裹的子树,其它 Widget 不受影响。
本质:依赖收集(build 时谁读了谁)+ 订阅 .obs 的 value 变化 + 按需重建,和「谁用到了谁,谁变就更新谁」一致。
三、注意点
- Obx 回调里只应读 .obs 并返回 Widget,不要写复杂计算或异步;否则依赖收集和重建范围会难以预期。
- 只读到的 .obs 才会被记为依赖:Obx 内没用到某个 .obs,改那个 .obs 不会触发该 Obx 重建;在 Obx 外改 .obs、Obx 内用到了,会正常触发重建。
21. GetX 路由(Get.to / Get.off)和原生 Navigator 的区别?
一、API 对照
| GetX | 原生 Navigator | 说明 |
|---|---|---|
| Get.to(Page()) | Navigator.push | 压栈,可返回 |
| Get.off(Page()) | Navigator.pushReplacement | 当前页被替换,不可返回当前页 |
| Get.offAll(Page()) | pushAndRemoveUntil 清栈再 push | 清空栈再进新页 |
| Get.back() | Navigator.pop | 出栈 |
| Get.to(Page(), arguments: x) / Get.arguments | ModalRoute.settings.arguments | 传参 / 目标页取参 |
二、典型用法
- 根用 GetMaterialApp(内部仍是 Navigator),用 Get.to(NextPage()) 跳转,Get.back(result) 返回;传参用 Get.to(Page(), arguments: data),在目标页 Get.arguments 取。
- Get.off 替换当前页(如登录成功后进首页且不能回到登录页);Get.offAll 清空栈再进新页(如登出后回到登录)。
三、和原生 Navigator 的主要区别
- 不依赖 BuildContext:Get.to / Get.off / Get.back 可在 Controller、Service、任意地方调用,不需要 context;原生要用 Navigator.of(context).push/pop,必须能拿到 context。
- 根组件:GetX 用 GetMaterialApp,底层仍是 Navigator,Get 封装了路由 API 和 Get.arguments 等。
- 与 Controller 回收:Get.put 的 Controller 未设 permanent: true 时,常在该路由 pop 时随 Get 的绑定被 Get.delete 并触发 onClose;原生 Navigator 无这层绑定,需在 State.dispose 里自己释放。
小结:需要在无 context 处做路由(如全局逻辑、弹窗回调)时,Get 路由较方便;若项目统一用 GoRouter / Navigator 2.0,可以只用 GetX 的状态 + 依赖注入,不用 Get 路由。
22. Riverpod 的 Provider 类型有哪些?ref.watch 和 ref.read?
一、Provider 类型概览
Riverpod 用不同的 Provider 类型表示「数据形态」和「是否可变」,在 Widget 或 Notifier 里通过 ref.watch / ref.read 使用。
| 类型 | 含义 | 典型用法 | 何时用 |
|---|---|---|---|
| Provider | 不可变值,创建后一般不改 | 配置、单例服务、常量 | 只读、全局一份的数据 |
| StateProvider | 简单可变状态(如 int、bool) | ref.watch(stateProvider) 读,ref.read(stateProvider.notifier).state++ 改 | 计数器、开关等简单状态 |
| FutureProvider | 异步单次(Future) | ref.watch(futureProvider) 得 AsyncValue,处理 loading/data/error | 单次请求、初始化加载 |
| StreamProvider | 流(Stream) | ref.watch(streamProvider) 得 AsyncValue | 实时推送、WebSocket、订阅 |
| StateNotifierProvider | 复杂状态 + 业务逻辑(StateNotifier) | ref.watch(provider) 取 state,ref.read(provider.notifier) 调方法改状态 | 多字段、多方法的业务状态 |
| NotifierProvider(2.x) | 同上,Riverpod 2.x 推荐写法 | Notifier 子类 + ref,替代 StateNotifierProvider | 新项目推荐用 NotifierProvider |
- Provider:
final configProvider = Provider((ref) => AppConfig());,只读。 - StateProvider:
final countProvider = StateProvider<int>((ref) => 0);,通过 .notifier 取 Notifier 再改 .state。 - FutureProvider:
final userProvider = FutureProvider<User>((ref) => api.getUser());,watch 得到 AsyncValue<User>(data/loading/error)。 - StreamProvider:
final messagesProvider = StreamProvider<List<Message>>((ref) => stream);,同样得到 AsyncValue。 - StateNotifierProvider:状态由 StateNotifier 管理,ref.read(xxx.notifier) 调方法,内部 state = newState 触发依赖该 Provider 的 Widget 重建。
二、ref.watch 和 ref.read 的区别
| 对比项 | ref.watch(provider) | ref.read(provider) |
|---|---|---|
| 使用位置 | 主要在 build 里(或依赖 ref 的 Notifier/Provider 体内) | 事件回调里(如 onPressed、initState 里不推荐,可用 ref.read) |
| 是否建立监听 | 会;当前 Widget/Provider 依赖该 provider,其值一变就重建/重新计算 | 不会;只读当前值一次,不订阅变化 |
| 典型场景 | 用数据驱动 UI(显示 state、根据 AsyncValue 显示 loading/data/error) | 用户操作时取一次值或调 notifier 方法(如提交表单、翻页加载) |
- ref.watch:在 build 里用,表示「依赖这个 provider,它的值变了我就重建」。例如
ref.watch(counterProvider)显示计数;不要在事件回调里用 watch,否则容易重复监听或不符合「只读一次」的语义。 - ref.read:在 onPressed、onTap 等回调里用,只取当前值或 ref.read(provider.notifier) 调方法,不建立监听,不会因为该 provider 变化而重建当前 Widget。
四、ref.listen(顺带)
- ref.listen(provider, (prev, next) { ... }):监听 provider 变化并执行副作用(如弹 SnackBar、导航),不会让当前 Widget 为显示该值而重建;适合「值变了我要做一件事,但 UI 不直接依赖这个值」的场景。
小结:Provider 类型按「不可变 / 简单可变 / 异步 / 流 / 复杂状态」选;ref.watch 在 build 里用、建立依赖且会重建;ref.read 在事件里用、只读或调 notifier,不监听。
23. 如何做 Flutter 的依赖注入?
- 目的:不在类内部 new 依赖,由外部/容器注入,解耦、单测可替换、统一管理生命周期。Flutter 里常需 Repository/ApiClient/ViewModel 等全局或子树共享。
- 常见做法
| 方式 | 如何「注入」 | 如何「取」 | 特点 / 适用 |
|---|---|---|---|
| Provider / Riverpod | 在树顶或某层用 Provider / ProviderScope 包一层,create 里建实例 | 子节点 context.read<T>() / ref.read(provider) | 依赖挂在树上,和 Widget 树绑定;Riverpod 不依赖 context,可测试性好 |
| get_it | 在 main 或 init 里 GetIt.I.registerSingleton / registerFactory | 任意处 GetIt.I<T>() 取 | 全局单例/工厂,不依赖 BuildContext,适合纯逻辑层、测试时 registerSingleton 换成 Mock |
| Bloc | 用 RepositoryProvider、BlocProvider 在树顶或某层提供 Repository / Bloc | 子节点 context.read<Repository>()、BlocProvider.of | 和 Bloc 架构配套,Repository 给 Bloc 用 |
| GetX | Get.put(Controller()) / Get.lazyPut(() => Controller()) | Get.find<Controller>() | 自带容器,不依赖 context;详见第 54、56 题 |
- 选型:树上绑定用 Provider/Riverpod/Bloc;全局与树无关用 get_it;一体化用 GetX。常见:UI 层 Provider/Riverpod/GetX,逻辑层 get_it 取 Repository;单测时 get_it 换 Mock。
24. ValueListenableBuilder、ListenableBuilder 和 setState、Obx 的区别?适用场景?
一、各是什么、重建范围
| 方式 | 监听/依赖 | 重建范围 |
|---|---|---|
| setState | 无,手动 setState() | 整个 State 的 build,整页重建 |
| Obx | GetX 的 .obs 变量 | 该 Obx 包裹的子树,依赖收集到的 .obs 变才重建 |
| ValueListenableBuilder | ValueListenable<T>(如 ValueNotifier<T>) | 仅 builder 返回的子树,value 变才重建 |
| ListenableBuilder | Listenable(如 AnimationController、ChangeNotifier) | 仅 builder 返回的子树,listen 触发时重建 |
二、适用场景
- setState:简单页、状态少,整页重建成本可接受。
- Obx:项目用 GetX,希望自动依赖、局部重建。
- ValueListenableBuilder:单值或简单模型(如 ValueNotifier<int>),要局部刷新、不用 GetX 时用。
- ListenableBuilder:多个字段或 Listenable(如 ChangeNotifier、Animation),要局部刷新;动画场景常用 AnimatedBuilder(本质也是听 Listenable)。
三、小结
- setState 整页重建;Obx 依赖 .obs 局部重建;ValueListenableBuilder / ListenableBuilder 依赖 ValueNotifier / Listenable 只重建 builder 子树。要局部刷新且不用 GetX 时,用 ValueListenableBuilder 或 ListenableBuilder。
25. Flutter 路由有哪几种方式?Navigator 1.0 和 2.0 区别?
| 对比项 | Navigator 1.0 | Navigator 2.0 |
|---|---|---|
| 方式 | 命令式(push/pop) | 声明式(路由由应用状态驱动) |
| API | Navigator.push / pop | Router + RouteInformationParser + RouterDelegate |
| 栈管理 | 直接操作栈 | 通过配置/状态生成路由栈 |
| 适用 | 简单页面栈 | Web 深链接、多 tab、复杂栈、URL 同步 |
| 封装 | 无 | 常用 GoRouter(配置化、redirect、深链接) |
26. 如何做页面间传参?如何接收返回结果?
传参
- 构造函数传参:push 时
new Page(args)。 - 或 ModalRoute.of(context).settings.arguments 在目标页取。
- 命名路由在 routes 里配置,通过 arguments 传参,目标页用 ModalRoute.settings.arguments 取。
返回结果
await Navigator.push(...) 得到目标页 Navigator.pop(context, result) 的 result;或通过状态管理/EventBus 传回。
27. Hero 动画是什么?使用注意?
一、Hero 是什么
- Hero 是 Flutter 里的共享元素转场:在两个页面上各放一个 Hero,tag 相同,路由切换时 Flutter 会把「源页的 Hero 子节点」在视觉上飞过去接到「目标页的 Hero 子节点」位置,中间自动做位置、尺寸的过渡动画,常用于列表头像点进详情大图、卡片展开等。
二、怎么用
- 源页(如列表):用 Hero(tag: 'xxx', child: Widget) 包住要「飞」的那块(如小头像)。
- 目标页(如详情):用同一个 tag 再包一层 Hero(tag: 'xxx', child: Widget),这里是「落地」后的样子(如大图)。
- 路由用 Navigator.push 正常跳转即可,Flutter 会根据 tag 匹配两个 Hero,自动播过渡动画;返回时动画反向。
三、使用注意
| 注意点 | 说明 |
|---|---|
| tag 唯一 | 同一路由栈内,同一时刻不能有两个相同 tag 的 Hero 同时存在;列表里每个 item 的 Hero 要用不同 tag(如带 id:'avatar-${item.id}'),否则匹配错乱或报错。 |
| 两边 child 尽量一致 | 两边的 child 类型、宽高比不要差太多,否则过渡时会有明显「形变」或跳动;例如一边是圆形头像、一边是大方图,可以接受,但一边是文字一边是图就容易怪。 |
| 包一层 Material | 若 Hero 的 child 是图片、透明背景等,在部分平台可能被裁剪或出现黑边,用 Material 或 Material(type: MaterialType.transparency) 包一层 child 可减轻问题。 |
| flightShuttleBuilder | 想自定义「飞行过程中」显示的 Widget,可设 Hero.flightShuttleBuilder,默认是源 child 在飞。 |
四、小结:相同 tag 在两页各包一块 UI 即共享元素转场;列表每项用不同 tag(如 'avatar-${id}'),两边 child 尽量一致,必要时 Material 包一层防裁剪。
28. 如何做路由鉴权 / 登录拦截?(如 GoRouter redirect)
一、思路简述
-
鉴权/登录拦截:在用户访问需要登录的页面时,若未登录则先跳到登录页,登录成功后再跳回原目标页(或首页)。
-
常见做法:GoRouter 的 redirect 里根据当前路径 + 登录态决定重定向到哪;Navigator 1.0 可在 push 前或 onGenerateRoute 里判断。
-
GoRouter:redirect 里同步读登录态(Provider/Riverpod、get_it),未登录访问鉴权路径时 return '/login?from=...',登录成功 context.go(from ?? '/');登录态不能用 async,用 refreshListenable 触发重新 redirect。
-
Navigator 1.0:push 前判断登录态,未登录则 push 登录页,登录页回调里 pop 再 push 目标页;或在 onGenerateRoute 里按 settings.name + 登录态返回 MaterialPage。
29. PopScope 和 WillPopScope 区别?如何拦截返回键?
- 拦截返回:用户点返回键/左滑/AppBar 返回时默认 Navigator.pop;拦截 = pop 前先判断(如表单未保存弹确认、Web 再按一次退出、混合栈交给原生),确认后再 Navigator.pop。
- PopScope(推荐):canPop: false 不自动 pop,onPopInvokedWithResult 里做逻辑(弹窗、通知原生等),需 pop 时自己调 Navigator.pop。WillPopScope(onWillPop 返回 true/false)已废弃。
- 对比
| 对比项 | WillPopScope | PopScope |
|---|---|---|
| 状态 | 已废弃(Flutter 3.12+) | 推荐替代 |
| 拦截方式 | onWillPop 返回 true/false,false 即不 pop | canPop: false 表示不自动 pop;onPopInvokedWithResult 在用户点返回时被调用 |
| “拒绝”返回 | 回调里 return false 即可,当前页不 pop | 设 canPop: false,在 onPopInvokedWithResult 里弹 Dialog 等,用户确认后再 Navigator.pop(context) |
| 拿到“用户点了返回” | onWillPop 被调时就是用户点了返回 | onPopInvokedWithResult 被调时就是用户点了返回,可在这里做二次确认 |
- 场景:表单未保存 → canPop: false + 弹确认;Web 壳子 → 再按一次退出(回调里记时间);混合栈 → 回调里通知原生由原生决定。
30. Flutter 与原生混合开发时,页面路由如何管理?(大厂高频)
一、目标:混合 = 原生页 + Flutter 页互相跳转。要栈统一(返回关谁)、返回键/手势一致、传参清晰。
二、常见做法:原生维护主栈,Flutter 作为一屏或多屏嵌入;FlutterBoost、add-to-app 等:原生打开/关闭 Flutter 容器时带 arguments,Flutter 通过 Channel 或方案 API 通知原生 finish/pop、回传结果。
三、要点小结(表格)
| 要点 | 说明 |
|---|---|
| 栈统一 | 原生维护主栈,Flutter 作为一屏或多屏嵌入;返回键由原生或方案统一处理,并通知 Flutter 是否 pop |
| 传参 原生→Flutter | 启动 Flutter 容器时带 arguments(如 Intent.extra、Route 的 initialRoute/arguments) |
| 传参 Flutter→原生 | Platform Channel 或方案提供的 API(如 FlutterBoost 的 close 带 result) |
| 单引擎/多引擎 | 一般单引擎复用,多 Flutter 页共用一个引擎,减少内存;多引擎适合隔离业务 |
| 生命周期 | Flutter 页的可见性、前后台要与原生一致,便于暂停动画、释放资源 |
31. Dart 是单线程的,如何做“多线程”?Isolate 和 Future 区别?
一、Isolate 是什么
- Isolate 是 Dart 里的独立“线程”:每个 Isolate 有自己的内存堆和独立的事件循环,不共享内存,因此没有传统多线程的锁、竞态问题。
- 主 Isolate:跑 UI、build/layout/paint、你写的绝大部分 Dart 代码;子 Isolate 通过 Isolate.spawn 或 Flutter 的 compute() 创建,在后台跑耗时计算。
- 通信方式:Isolate 之间只能通过 SendPort / ReceivePort 传消息(序列化数据),不能直接访问对方变量;传参和返回值必须可序列化(基本类型、List/Map、简单对象等)。
二、API 要点
- ReceivePort:当前 Isolate 收消息,sendPort 给对方用于回传。
- Isolate.spawn(entryPoint, message):entryPoint 为顶层或静态函数,message 可带 SendPort;子 Isolate 只能通过 port 收发数据,不能访问主 Isolate 变量或 Flutter 插件/UI。
- Isolate.exit(result):子 Isolate 退出时可带返回值,主 Isolate 在 ReceivePort.listen 里收到。
三、Isolate 与 Future 对比
| 对比项 | Future / async-await | Isolate |
|---|---|---|
| 执行位置 | 同一 isolate(主线程) | 独立 isolate,独立内存与事件循环 |
| 数据共享 | 共享内存,直接访问变量 | 不共享内存,通过 SendPort/ReceivePort 传消息 |
| 适用 | IO、轻量异步、不阻塞 UI | CPU 密集(加解密、大 JSON、图像处理) |
| 典型用法 | async/await、Future.then | Isolate.spawn、compute() |
- 计算密集应放到 Isolate 或 compute(),避免卡 UI;IO、网络等用 async/await 即可。更细的 Isolate 用法与 compute 区别见第 34 题。
32. async/await 和 Future 的关系?FutureBuilder 使用注意?
- async/await 是 Future 的语法糖:async 函数返回 Future,await 等待 Future 完成并取结果,不阻塞 UI 线程。
- FutureBuilder:根据 Future 的 connectionState 和 data/error 构建不同 UI。注意:同一 Future 不要重复传给 FutureBuilder 导致多次触发;Future 创建时就会执行,别在 build 里每次 new 会重复请求的 Future。
33. Stream 和 Future 区别?StreamBuilder 使用场景?
| 对比项 | Future | Stream |
|---|---|---|
| 数据次数 | 一次,一个值(或异常) | 多次,连续数据流 |
| 典型场景 | 单次请求、一次性异步 | WebSocket、蓝牙、搜索建议、传感器 |
| UI 组件 | FutureBuilder | StreamBuilder |
| 取消 | 无内置取消(可封装) | listen 返回 Subscription,需 cancel |
- StreamBuilder:根据 Stream 的 connectionState 和 data/snapshot 构建 UI;适合实时推送、聊天消息。注意:订阅要在 dispose 里 cancel,否则易内存泄漏。Stream 单订阅与广播区别见第 36 题。
34. compute() 和 Isolate 的关系?什么时候用 compute?
一、Isolate 简要回顾
- Isolate 概念、API(spawn、SendPort/ReceivePort)、与 Future 对比见第 31 题。此处仅需知:每个 Isolate 独立堆与事件循环,之间靠消息传递,传参与返回值需可序列化。
二、compute() 和 Isolate 的关系(对比要清晰)
- compute() 是 Flutter 提供的封装:内部就是用 Isolate.spawn 起一个新 Isolate,把传入的顶层/静态函数和一个参数发过去执行,结果通过 SendPort 回传,compute 转成 Future<T> 返回;执行完后该 Isolate 自动结束并回收。
- 关系一句话:compute = 对「单次、一进一出」的 Isolate 用法的封装,你不用自己写 ReceivePort、spawn、listen、Isolate.kill,只写一个静态/顶层函数和 compute(fn, arg) 即可。
compute() 与 手写 Isolate 对比
| 对比项 | compute() | 手写 Isolate.spawn |
|---|---|---|
| 谁起 Isolate | compute 内部起一个 | 你自己 Isolate.spawn |
| 通信方式 | 一个入参、一个返回值,函数执行完一次就结束 | 自己 ReceivePort + SendPort,可多次 send/listen,长期驻留 |
| 生命周期 | 函数执行完 Isolate 自动回收 | 需自己 Isolate.kill 或让 isolate 自己 exit |
| API 形式 | compute(fn, message) → Future<result>,async/await 即可 | 要写 port.listen、spawn、传 sendPort 等 |
| 适用 | 单次耗时计算(解析、编解码、复杂运算) | 多次收发、长驻后台、复杂流水线 |
三、什么时候用 compute、什么时候用裸 Isolate
| 场景 | 用谁 | 说明 |
|---|---|---|
| 单次 CPU 密集(大 JSON 解析、加解密、图片编解码、复杂计算) | compute(fn, data) | 一进一出,写起来简单,Isolate 自动回收 |
| 需要长期驻留、多次收发(如后台持续处理、复杂流水线、长连接) | Isolate.spawn + 自己维护 ReceivePort | compute 只跑一次就结束,无法「常驻 + 多次通信」 |
| 只做 IO、轻量异步(网络请求、读文件) | async/await 在主 Isolate | IO 不会占满 CPU,不需要新 Isolate |
四、使用注意
- compute 的 fn 必须是顶层或静态函数(或静态方法),不能传匿名函数或带闭包,否则无法在子 Isolate 里反序列化执行。
- 子 Isolate 里不能调 Flutter 插件、dart:ui、BuildContext 等依赖主 isolate 的 API,只能做纯 Dart 计算或 dart:io(如 File、HttpClient)。
- 传参和返回值不宜过大,否则序列化/反序列化也耗时;大二进制可考虑 TransferableTypedData。
五、小结:compute = 单次 Isolate 封装,一进一出、自动回收;单次 CPU 密集用 compute,多次通信/长驻用手写 Isolate,纯 IO 用 async/await。对比见上表。
35. Dart 事件循环里微任务和事件队列与 Flutter 帧的关系?
一、两个队列
- 微任务队列:scheduleMicrotask、Future.then 等,先被清空。
- 事件队列:Timer、I/O、手势、渲染帧任务等,每轮只取一个执行。
二、每轮顺序:① 清空微任务 → ② 取一个事件执行 → 下一轮。若微任务多或当前事件耗时长,后面的任务(含渲染帧)会迟迟轮不到。
三、Flutter 的「帧」在事件队列里
- 引擎在每帧 VSync 前(或按帧率)向事件队列里投递一个 「渲染帧」任务。
- 只有当事件循环取到这个渲染帧任务并执行时,才会跑 build → layout → paint → 提交 GPU。
- 因此:渲染帧和 Timer、I/O、手势一样,都是事件队列里的一个任务;它不会「插队」,必须等前面的微任务和前面的事件都处理完(或让出)才会轮到。
四、和 jank 的关系
| 情况 | 结果 |
|---|---|
| 微任务太多 / 执行太久 | 本帧要执行的「渲染帧」任务还在事件队列里排队,等微任务清完才能被取到 → 渲染延迟 → 本帧超时 → jank。 |
| 前面的事件(如某个 Timer、手势回调)执行太久 | 同上,渲染帧任务迟迟轮不到 → jank。 |
| 在 build 或某回调里同步写大量逻辑 | 相当于把「当前事件」拉长,当前事件可能就是渲染帧本身或前面的任务,会拖慢本帧或下一帧。 |
结论:要把耗时逻辑放到 Isolate 或至少延后到下一帧(如 scheduleFrameCallback),保证「渲染帧任务」被取到时能尽快执行完 build/layout/paint。
五、流程图
sequenceDiagram
participant Micro as 微任务队列
participant Event as 事件队列
participant Run as 执行
Run->>Micro: ① 清空微任务 (scheduleMicrotask / Future.then)
Micro->>Event: ② 取一个任务
Event->>Run: 渲染帧 / Timer / I/O / 手势
Note over Run: 若是渲染帧 → build→layout→paint→提交 GPU
Note over Run: 否则 → 执行对应回调,再下一轮循环
小结:单线程下先清空微任务,再处理一个事件;渲染帧是事件队列里的一种任务,被取到才执行 build/layout/paint。微任务或前面事件耗时长 → 渲染帧迟迟轮不到 → jank。
36. Stream 单订阅(single)和广播(broadcast)区别?使用场景?
- 单订阅(single):一个 Stream 只能被 listen 一次,多次 listen 会报错;适合“单消费者、按序消费”的场景,如单次网络响应流、单次文件读取流。StreamController() 默认创建单订阅 Stream。
- 广播(broadcast):可被多个 listener 同时监听;适合“多消费者、事件广播”的场景,如 WebSocket 消息、全局事件总线、多个 UI 同时监听同一数据流。用 StreamController.broadcast() 或 stream.asBroadcastStream()。
- 区别:单订阅可支持 pause/resume、背压;广播不保证每个 listener 都收到“历史”数据,且不支持 pause。若需要多 listener 且要共享同一数据源,用 broadcast;若只有一个消费者且要严格按序,用 single。Stream 与 Future 区别、StreamBuilder 使用见第 33 题。面试可答:单订阅 = 单消费者、可暂停;广播 = 多消费者、不保证历史、不能 pause。
37. 在 Flutter 里发网络请求后更新 UI,要注意什么?
- 用 async/await 或 Future.then 在请求完成后 setState 或通知状态管理更新。
- 一定要在 setState 前判断 mounted:异步回来时 Widget 可能已 dispose,否则会报错。
- 处理 加载中、错误、空数据 等状态,避免未处理异常导致白屏或崩溃。
- 可配合 FutureBuilder 或状态管理统一管理请求状态(loading/success/error)。
38. 说说 Flutter 的 build 时机和如何减少不必要的 build
- build 在:initState 之后、didUpdateWidget、父 build、InheritedWidget 依赖更新、setState 等时机被调用。
- 减少 build:
- const 构造函数:相同配置复用同一实例,减少重建。
- 把状态下沉:只把会变的 state 放在子树顶层,用 StatefulWidget 包最小范围。
- RepaintBoundary:隔离重绘区域,避免整棵子树重绘。
- 状态管理里用 select/watch 精细依赖,只监听用到的字段(如 Riverpod 的 select)。
39. ListView 大量数据怎么优化?ListView.builder 和 ListView 区别?
- ListView:一次性构建所有子 Widget,只适合少量数据。
- ListView.builder:懒加载,只构建可见(加缓存)的 item,适合长列表。
- 进一步优化:itemExtent 固定高度减少布局计算;cacheExtent 控制预加载范围;避免在 item 里做重操作;图片用 cached_network_image 等缓存。
40. 什么是 Flutter 的 jank?如何排查和优化?
- Jank:掉帧、卡顿,通常是单帧耗时超过约 16ms(60fps),用户感觉卡。成因(UI 线程慢 vs GPU 线程慢)见第 45 题。
- 排查:Performance Overlay(右上角显示 GPU/UI 帧耗时)、DevTools Timeline 看哪一帧超时、profile 模式 跑真机。
- 优化:减少 build 范围、避免在 build 里做重计算、大列表用 builder、耗时计算放 Isolate、图片压缩与缓存、减少 saveLayer 等昂贵操作。
41. 为什么 build 里不能做耗时操作?应该放哪?
为什么不能?
build 在每帧或每次重建时都会调用。在 build 里做重计算、同步 IO、大 JSON 解析等会阻塞 UI 线程,导致掉帧(jank)、卡顿。
应该放哪?
| 场景 | 放哪里 |
|---|---|
| 一次性初始化 | initState |
| 异步请求 | Future / async,结果回来后再 setState |
| CPU 密集 | compute() 或 Isolate,算完再通知 UI |
| 定时 / 轮询 | Timer 或 Stream,在回调里更新状态 |
原则:build 只做轻量的「根据状态返回 Widget 树」,不做事务逻辑、不阻塞。
42. saveLayer 为什么贵?如何避免或减少?
为什么贵?
saveLayer 会为子树分配离屏缓冲,先画到缓冲再合成,涉及多遍绘制、GPU 内存与带宽,是 Flutter 里较贵的操作之一。
常见触发:Opacity(非 0/1)、ColorFilter、ShaderMask、BackdropFilter(模糊)、部分 Transform 等。
如何避免或减少?
- Opacity:能不用就不用,子节点直接设透明色。
- BackdropFilter:用 ClipRect 包小区域,限制范围。
- RepaintBoundary:包住会触发 saveLayer 的子树,避免影响整屏。
- 设计:减少全局半透明、模糊层数。
43. vsync 是什么?为什么 Ticker 需要它?刷新流程是怎样的?
- vsync:显示器每帧扫描完成后发出的同步信号(如 60Hz ≈ 16.67ms 一次),表示可以准备下一帧。Flutter 按此节拍做 build/layout/paint 并提交,与屏幕刷新对齐。
- Ticker 为什么需要 vsync:Ticker 在每帧回调里 tick 一次,动画与渲染同频;页面不可见时不再注册回调,动画自动暂停、省电。用 Timer 需自己管生命周期且易与帧不同步。
刷新流程(一帧):VSync 触发 → UI 线程(Ticker.tick、build → layout → paint)→ 产出 Layer Tree → 提交给 GPU 线程 → 合成并写帧缓冲 → 下次 vsync 上屏。GPU 线程的 Compositor 可通过 Throttle 回压 UI 线程,避免掉帧。
44. Flutter 渲染管线大致几步?Layout、Paint、Layer、Compositing 是什么?
一、渲染管线大致几步(顺序)
一帧内,从「需要刷新」到「上屏」可以简化为:
- Build:根据 Widget 树 更新 Element 树,挂载/更新 RenderObject(对应「要画什么」)。
- Layout:RenderObject 做布局,算出每个节点的尺寸和位置。
- Paint:RenderObject 做绘制,产出 Layer,多个 Layer 组成 Layer 树。
- Compositing:把 Layer 树 转成 GPU 能执行的纹理与绘制指令,确定合成顺序。
- 提交 GPU → 上屏:GPU 执行绘制、写帧缓冲,等 VSync 后显示到屏幕。
二、Layout、Paint、Layer、Compositing 各是什么
| 概念 | 是什么 | 谁在做 | 产出/作用 |
|---|---|---|---|
| Layout(布局) | 根据约束算出每个 RenderObject 的大小和位置 | RenderObject.performLayout() | 自顶向下传约束,自底向上回传尺寸,得到布局好的 RenderObject 树 |
| Paint(绘制) | 把当前 RenderObject 画出来 | RenderObject.paint(Canvas) | 用 Canvas 画,产出 Layer(如 PictureLayer、TransformLayer、OpacityLayer) |
| Layer 树 | 描述「谁画什么、顺序、变换」的一棵树 | 各 RenderObject.paint 时挂到 Layer 上 | 供合成与裁剪用;Compositing 的输入 |
| Compositing(合成) | 把 Layer 树 变成 GPU 可执行的指令 | 引擎(Skia/Impeller)在 GPU 线程 | 确定 z-order、透明度混合等,生成纹理与绘制命令,写入帧缓冲 |
三、管线流程图
sequenceDiagram
participant Build as Build (Widget→Element)
participant Layout as Layout (performLayout)
participant Paint as Paint (Layer 树)
participant Comp as Compositing (GPU 指令)
participant VSync as VSync 上屏
Build->>Layout: 布局
Layout->>Paint: 绘制
Paint->>Comp: 合成
Comp->>VSync: 提交
四、小结:Build → Layout → Paint → Layer 树 → Compositing → 上屏。各步含义见上表。从 VSync 触发到上屏的流程图见第 43 题;UI/GPU 线程与 jank 见第 45 题。
45. UI 线程和 GPU 线程如何协作?为什么会导致 jank?
一、两线程分工(管线步骤见第 44 题)
| 线程 | 主要工作 |
|---|---|
| UI 线程(Dart) | build → layout → paint,生成 Layer 树并提交给 GPU |
| GPU 线程(C++) | 接收 Layer 树,转 GPU 指令、合成、写帧缓冲,VSync 后上屏 |
二、一帧内协作
VSync 触发本帧 → UI 线程完成 build/layout/paint 并提交 Layer 树 → GPU 线程做 Compositing、写帧缓冲 → 下一 VSync 上屏。要在 60fps 下不丢帧,UI 和 GPU 的活都要在约 16.67ms 内完成。
三、为什么会导致 jank(掉帧)
- jank = 某一帧没能在预期时间(如下一个 VSync)前准备好并上屏,用户会感到卡顿、掉帧。
| 原因 | 说明 |
|---|---|
| UI 线程太慢 | build / layout / paint 任一步耗时过长(如 build 里逻辑重、layout 过于复杂、paint 里大量绘制),或主 isolate 在做耗时计算(大 JSON、复杂运算),导致本帧内迟迟交不出 Layer 树,GPU 等不到数据或收到时已错过本帧上屏时机 → jank。 |
| GPU 线程太慢 | Layer 树过大、过复杂(如 saveLayer 多、半透明层多、过度绘制),GPU 转指令和合成的时间超过一帧预算 → 即使 UI 按时提交,GPU 也来不及在本帧内画完 → jank。 |
四、协作流程图
sequenceDiagram
participant VSync
participant UI as UI 线程 (Dart)
participant GPU as GPU 线程 (C++)
VSync->>UI: 触发新一帧
UI->>UI: build → layout → paint
UI->>GPU: 提交 Layer 树
GPU->>GPU: Skia/Impeller 转 GPU 指令
GPU->>GPU: 合成、写帧缓冲
Note over UI,GPU: 若任一步 >16.67ms → jank
VSync->>VSync: 下一帧显示
- 优化:UI 线程用 const、缩小 build 范围、耗时放 Isolate;GPU 用 RepaintBoundary、少 saveLayer、减半透明。
- 小结:UI 线程 build→layout→paint 并提交 Layer 树,GPU 线程合成、上屏;任一线程超时即 jank。UI 慢多为 build/主线程耗时,GPU 慢多为 saveLayer/过度绘制。
46. 为什么说 Layer 树?和 RenderObject 树的关系?
- Layer 树:合成与上屏按「层」组织(位图/变换/透明度),Paint 产出 → Compositing 消费,便于光栅化、缓存(RepaintBoundary)、saveLayer 等。
- 与 RenderObject 树:RenderObject 树负责 layout + paint(谁在算、谁在画);paint 时产出 Layer 组成 Layer 树(画的结果如何分层交给 GPU)。一个 RenderObject 可对应 0/1/多个 Layer(RepaintBoundary 成层、saveLayer 多一层)。
47. 什么是 RepaintBoundary?什么时候用?
- 是什么:在 Widget 树上画一条边界,边界内子树变成单独一层(Layer);边界内变只重画边界内,边界外变可复用边界内缓存,从而缩小重绘范围。用多了多占层和内存,不能滥用。
- 什么时候用
| 场景 | 用法 |
|---|---|
| 列表里每个 item 很复杂 | 给每个 item 包一层 RepaintBoundary,滚动时只重画可见/变化的那几个 item,其它 item 不跟着整列表重画。 |
| 某一块有小动画(如一个图标在转) | 只给这一块包 RepaintBoundary,动画每帧只重画这一小块,不会带动整页重绘。 |
| DevTools 里发现某块重绘特别频繁 | 用性能视图找到对应 Widget,在它外面包一层 RepaintBoundary,把重绘范围锁在这一块。 |
- 别乱用:不要给每个小 Widget 都包;只在列表 item 很重、局部动画、或 DevTools 确认某块重绘过重时加。
48. 如何用 DevTools 做性能分析?Timeline、Memory 怎么用?
Timeline
- 看每帧耗时,build / layout / paint 各占多少。
- 找出哪一帧超过 16ms、是 build 慢还是 paint 慢。
- 配合 Profile 模式(
flutter run --profile)和真机。
Memory
- 拍堆快照(heap snapshot),看对象数量、Retained 大小。
- 对比操作前后快照,找未释放的 Controller、Subscription、大对象。
- 看是否有持续增长的曲线(泄漏)。
其他
- CPU 火焰图:看哪段代码占 CPU 高。
- Widget inspector:看树结构和 rebuild 范围。
常用流程:先 Timeline 定位是 build 还是 paint 卡 → 再针对性优化,或看 Memory 查泄漏。
49. 启动速度和包体积有哪些常见优化手段?
启动优化
- 减少 main() 到首帧的同步工作(少在 main 里重计算、少同步 IO)。
- 延迟初始化非首屏必需的库或服务。
- 首屏用简单 Widget,复杂内容异步加载。
- Release 模式用 AOT,比 Debug 快很多。
- Native 侧:Splash/占位、引擎预加载。
包体积优化
- 使用 --split-debug-info、--obfuscate。
- 去掉未用资源(图片、字体),按需引入第三方库。
- Android:打 App Bundle(aab),让商店按架构分包。
- 分析:
flutter build apk --analyze-size或 DevTools 的 size 工具看大块占用。
小结:启动看「首帧时间」和 main 到首绘路径;包体积看资源与 native 库、是否混淆与 split。从 main() 到首帧的详细流程见第 50 题。
50. Flutter 应用从 main() 到首帧显示的启动流程?(大厂/原理)
一、流程分阶段
| 阶段 | 做什么 |
|---|---|
| 1. main() | 执行 runApp(MyApp()) 前会绑定 WidgetsBinding、SchedulerBinding 等;runApp 把根 Widget 挂到引擎,触发首次构建。 |
| 2. 构建 Element / RenderObject 树 | 从根向下 mount(创建 Element、RenderObject),再 layout(算尺寸位置),再 paint(产出 Layer 树)。 |
| 3. 合成与上屏 | Engine 把 Layer 树 compositing 成 GPU 指令,Embedder 与平台窗口、VSync 对接,把首帧画到屏幕。 |
| 4. 之后 | 进入 事件循环:输入、动画、Timer、微任务等驱动后续帧与交互。 |
二、一句话串起来
- main() → runApp 挂根 Widget → mount / layout / paint 得到 Layer 树 → Compositing 转 GPU 指令 → Embedder + VSync 首帧上屏 → 进入事件循环。
三、可优化点
- 减少 main() 到首帧的同步工作(少在 main 里重计算、少同步 IO)、延迟初始化非首屏库、首屏用简单 Widget;详见第 49 题启动优化。
51. AnimationController 和 Tween 分别做什么?
- AnimationController:控制动画的时间线(start/stop/repeat/reverse),产出 0.0~1.0 的线性进度,需要 vsync(TickerProvider)防止不可见时继续跑。
- Tween:定义值域映射,如
Tween<double>(begin: 0, end: 1)或ColorTween,通过 animate(animation) 得到 Animation<T>,把 0~1 映射到具体类型。 - 组合:Controller 驱动 → Animation 0~1 → Tween.animate 得到 Animation<T> → 在 addListener 或 AnimatedBuilder 里用 value 更新 UI。
52. 隐式动画和显式动画区别?举例
| 对比项 | 隐式动画 | 显式动画 |
|---|---|---|
| 用法 | 改目标值,框架自动插值 | 自己持有一个 AnimationController + Tween |
| 典型组件 | AnimatedContainer、AnimatedOpacity、AnimatedPositioned | AnimationController + Tween + addListener / AnimatedBuilder |
| 控制力 | 简单,适合单属性过渡 | 强,可做组合、循环、复杂曲线 |
| 举例 | 按钮点击后容器变宽 → AnimatedContainer | 页面转场、复杂曲线 → Controller + Tween |
53. AnimatedBuilder 和 setState 做动画的区别?
- setState:在 Animation.addListener 里调 setState,整棵 State 的 build 都会重建,范围大,易造成多余 rebuild。
- AnimatedBuilder:只重建 builder 返回的那棵子树,重建范围小,性能更好;且不需要自己 addListener + setState,代码更清晰。
- 做动画时优先用 AnimatedBuilder(或 ListenableBuilder)把重建限制在最小子树。
54. 如何和原生(Android/iOS)通信?Platform Channel 是什么?三种 Channel 分别适用什么场景?
Platform Channel 是什么
Flutter(Dart)与原生(Java/Kotlin、Swift/ObjC)通过信道交换消息。Flutter 侧 MethodChannel(name).invokeMethod('xxx', args);原生侧在对应 Channel 的 handler 里处理并 reply。
三种 Channel 对比(字节/阿里常考):
| Channel | 适用场景 | 说明 |
|---|---|---|
| MethodChannel | 方法调用,Dart 调原生并异步拿一次结果 | 最常用;调原生 API、获取设备信息、一次请求一次响应 |
| EventChannel | 原生持续向 Dart 推数据(传感器、蓝牙、广播) | Dart 侧 listen;适合“原生主动推送、多次数据” |
| BasicMessageChannel | 二进制/字符串消息,双向收发,无固定“方法名+参数” | 自定义协议、简单键值或二进制流;使用频率低于 MethodChannel |
- 选型:调方法拿结果用 MethodChannel;原生持续推送用 EventChannel;自定义消息格式用 BasicMessageChannel。开发 Flutter 插件及 FFI 与 Platform Channel 选型见第 56 题。
55. 混合栈(Flutter 与原生页面互相跳转)要注意什么?
- 需统一管理原生容器(Activity/ViewController)与 Flutter Engine,避免多引擎或页面栈错乱。
- 常见方案:FlutterBoost、官方 add-to-app,由原生主导路由,Flutter 作为单页或多页嵌入。
- 注意:引擎复用、生命周期与内存、返回键/手势与原生一致、传参方式统一。混合栈下的页面路由如何管理见第 30 题。
56. 如何开发一个 Flutter 插件(Plugin)?FFI 和 Platform Channel 怎么选?
一、插件是什么:Dart 不能直接调 Java/Swift 或系统 API;插件 = 加原生代码(android/、ios/),通过 Platform Channel 或 FFI 让 Dart 与原生互通。
二、如何开发一个插件(用 Platform Channel 时)
- 创建插件工程:
flutter create --template=plugin --org com.xxx 插件名,会生成带 android/、ios/、lib/ 的工程;pubspec.yaml 里会声明 plugin 和 platforms(android、ios 等)。 - 原生端:在 android/ 里用 MethodChannel 注册一个「通道名」,在 setMethodCallHandler 里根据 call.method(方法名)和 call.arguments(参数)做逻辑,结果通过 result.success(...) 或 result.error(...) 回给 Dart。ios/ 里同样用 FlutterMethodChannel 设 setMethodCallHandler。
- Dart 端:在插件库里 MethodChannel('通道名').invokeMethod('方法名', 参数),返回 Future,拿到原生端 result.success 传回来的值。
- 发布:写清 platforms、README、示例;发到 pub.dev 或私有仓库,别人 dependencies 里引用即可。
三、Platform Channel 和 FFI 各是什么
| 方式 | 是什么 | 通俗理解 |
|---|---|---|
| Platform Channel | Flutter 的「通道」:Dart 发消息(方法名 + 参数)给原生,原生执行完再回传结果;参数与返回值需序列化。三种 Channel(Method/Event/BasicMessage)适用场景见第 54 题。 | 适合调 Java/Kotlin、Swift/ObjC、系统 API、原生 UI。 |
| FFI(dart:ffi) | Dart 通过 dart:ffi 直接调 C/C++ 动态库(.so、.dylib、.dll),不经过通道、不序列化。 | 适合高性能、已有 C 库、底层 C API;不适合调 Java/Swift 界面或服务。 |
四、怎么选(FFI 和 Platform Channel)
| 场景 | 更合适的方式 |
|---|---|
| 调 Android/iOS 系统服务(相机、蓝牙、定位、通知、存储) | Platform Channel,原生端用 Java/Kotlin、Swift/ObjC 调系统 API。 |
| 调 现成原生 SDK(如某厂商的 Java/Swift SDK) | Platform Channel,在原生端封装 SDK,Dart 只调 Channel。 |
| 需要原生 UI(如系统选择器、地图原生控件) | Platform Channel,原生端建 View/ViewController,通过 Channel 和 Flutter 通信。 |
| 高性能、算密集(图像处理、编解码、大量运算)且已有 C/C++ 库 | FFI,Dart 直接调 C 库,无序列化、延迟低。 |
| 调 系统底层 C API、或要复用 C 库 | FFI。 |
五、小结:插件 = 原生 + Channel/FFI 互通;开发用 flutter create --template=plugin,Dart invokeMethod、原生 setMethodCallHandler。选型:系统/原生 SDK/UI → Platform Channel;高性能/C 库 → FFI。三种 Channel 见第 54 题。
57. 打包 Android / iOS 的流程与注意点?
Android
- 命令:flutter build apk(通用)或 flutter build appbundle(上架推荐)。
- 配置:android/app/build.gradle 里 versionCode、versionName、signingConfigs。
- 注意:minSdkVersion、targetSdkVersion、混淆(ProGuard/R8)。
iOS
- 命令:flutter build ios;Xcode 打开 ios/Runner.xcworkspace。
- 配置:签名、Bundle ID、证书与描述文件。
- 注意:Info.plist 权限说明、iOS 最低版本。
通用
flutter clean 后重打;Release 用 --release;渠道/环境用 --dart-define 或 flavor。各平台构建产物路径与用途见第 58 题。
58. Flutter 构建会生成哪些产物?各平台输出路径与用途?
| 平台 | 命令 | 产物路径 | 产物/用途 |
|---|---|---|---|
| Android | flutter build apk --release | build/app/outputs/flutter-apk/app-release.apk | 通用 APK,可直接安装;测试、内部分发 |
| Android | flutter build apk --split-per-abi | 同目录下多 APK(arm64-v8a、armeabi-v7a、x86_64 等) | 按架构分包,单包体积更小 |
| Android | flutter build appbundle --release | build/app/outputs/bundle/release/app-release.aab | AAB,上传 Google Play;商店按设备生成优化 APK |
| iOS | flutter build ios(需 Xcode 打包) | 在 Xcode 中 Archive 后导出 .ipa | IPA,上传 App Store / TestFlight |
| Web | flutter build web | build/web/(含 main.dart.js、assets、index.html 等) | 静态资源,部署到 Web 服务器;可选 --web-renderer canvaskit/html、--wasm |
| Windows | flutter build windows | build/windows/runner/Release/(exe + 依赖) | Windows 桌面可执行包 |
| macOS | flutter build macos | build/macos/Build/Products/Release/(.app) | macOS 应用包 |
| Linux | flutter build linux | build/linux/x64/release/bundle/ | Linux 可执行与库 |
注意:Android/iOS 需配置签名;Web 的 build/web 整目录部署;桌面端产物含引擎与 Dart 运行时,需整体分发。打包命令、签名与注意点见第 57 题。
59. Flutter Web 和 桌面端开发要注意什么?
- Web:渲染选 CanvasKit(一致性好、包大)或 HTML(包小、SEO 友好);SEO 需路由与 URL 同步(如 GoRouter);注意 CORS、Cookie/Storage,敏感存储见第 82 题。
- 桌面:窗口/快捷键用 window_manager 或平台通道;文件用 file_picker、path_provider;flutter build windows/macos/linux 产物含引擎与运行时,需整体分发。
60. Hot Reload 和 Hot Restart 区别?Hot Reload 不能生效的情况?
- Hot Reload:保留应用状态,只重新执行 build,改动 UI 和部分逻辑很快可见。
- Hot Restart:重启整个应用,状态清空,main() 重新跑。
- Hot Reload 可能不生效:改了 initState、全局变量、枚举、静态字段、main()、某些 mixin/扩展等,需要 Hot Restart 或完整重启。Hot Reload 大致原理见第 62 题。
61. Flutter 的 build 模式:Debug、Profile、Release 区别?
| 模式 | 编译 | 特点 | 用途 |
|---|---|---|---|
| Debug | JIT | 断言、Service Extension、调试信息;包大、慢 | 日常开发、Hot Reload |
| Profile | AOT | 接近 Release 性能,保留 profiling | 性能分析(Timeline、真机 profile) |
| Release | AOT | 关闭调试、优化体积与速度 | 上架、正式环境 |
命令:flutter run 默认 Debug;flutter run --profile / flutter build apk --release。
62. Hot Reload 的大致原理是什么?
- 是什么:不重启应用,把改动代码注入 Dart VM,触发 rebuild,保留 State(表单、滚动等)。
- 流程:IDE 发增量 kernel → VM 加载/替换代码 → Flutter 标记脏 Element → 下一帧 rebuild 受影响子树,State 尽量保留。
- 限制:initState、main()、全局/静态/枚举等改动常需 Hot Restart 或完整重启。
63. Flutter 有哪几种测试?unit / widget / integration 区别?
| 类型 | 测什么 | 工具/方式 | 特点 |
|---|---|---|---|
| 单元测试(unit) | Dart 逻辑、函数、状态转换 | test 包,flutter test | 快、不依赖 Flutter,适合业务与状态管理 |
| 组件测试(widget) | 单个 Widget 渲染与交互 | flutter_test:testWidgets、pumpWidget、find、tap | 可测 UI 与用户操作,中等速度 |
| 集成测试(integration) | 完整 App 流程 | integration_test,真机/模拟器 | 慢、适合冒烟与关键路径 |
比例建议:单元 > 组件 > 集成,金字塔结构。
64. 如何测试有状态组件的用户操作?
核心思路:在 testWidgets 里挂载带状态的 Widget → 用 find 定位控件 → 用 tester 模拟点击/输入 → pump 推进一帧让 setState 生效 → 用 expect 断言 UI 或状态。
一、基本步骤
| 步骤 | 做法 | 说明 |
|---|---|---|
| 1. 挂载 | tester.pumpWidget(YourWidget()) | 有状态组件用 pumpWidget 挂到测试环境,State 会创建并执行 initState/build |
| 2. 定位 | find.byType(ElevatedButton)、find.text('确定')、find.byKey(Key('ok')) | 按类型、文字、Key 等找到要操作的 Widget |
| 3. 操作 | tester.tap(find.byType(ElevatedButton))、tester.enterText(find.byType(TextField), 'hello') | 模拟点击、输入、长按等,只触发回调,不会立刻重绘 |
| 4. 推进帧 | tester.pump() 或 tester.pumpAndSettle() | 有状态组件在 setState 后要下一帧才重绘;pump() 推一帧,pumpAndSettle() 推到动画/定时器稳定 |
| 5. 断言 | expect(find.text('结果'), findsOneWidget)、expect(find.byType(Text), findsNWidgets(2)) | 断言界面是否出现预期 Widget 或数量 |
二、为什么要 pump?
- 用户操作会触发
setState,但 setState 只是标记 dirty,真正 rebuild 发生在下一帧。测试里没有真实的 60fps 循环,必须手动 pump 一次(或多次)才会执行 build。 - pump():推进 1 帧(默认 0ms),适合「点一下按钮,立刻看文字变化」。
- pumpAndSettle():反复 pump 直到没有动画/定时器再请求帧(有超时),适合「点完有动画、Dialog 弹出」等。
三、依赖 Provider / InheritedWidget 时:被测用到了 context.watch、Provider.of 时,挂载时外层包 Provider<MyState>.value(value: myFakeState, child: MyPage()) 或 MultiProvider。
四、常见注意点
- 异步:操作后有 Future 时用 pump() 推帧或 runAsync() 再 pumpAndSettle(),必要时 mock。Key:多同类型时用 find.byKey 精确命中。mounted:异步回调时注意 pump 顺序,避免 setState after dispose。
小结:pumpWidget → find → tap/enterText → pump/pumpAndSettle → expect;依赖 InheritedWidget 时外层包 Provider。
65. pub 包管理:版本约束、plugin 与 package 区别?
一、版本约束
在 pubspec.yaml 的 dependencies 里写依赖名和版本规则,pub get 时会按规则解析出可用的版本。
| 写法 | 含义 | 示例 |
|---|---|---|
| ^1.2.3(caret) | 兼容 1.2.3 ≤ 版本 < 2.0.0,即同主版本内兼容 | 常用,允许自动升级到 1.x 最新 |
| 1.2.3(无 ^) | 仅允许 1.2.3 这一版 | 锁定版本,不自动升级 |
| >=1.2.3 <2.0.0 | 自定义区间 | 需要精确控制时用 |
| path: ../xxx | 依赖本地路径的包,不从 pub 拉 | 本地开发、Monorepo |
| git: url ref: branch/tag | 依赖 Git 仓库的某分支/标签 | 未发 pub 或用私有仓 |
| dependency_overrides | 强制所有依赖里对某包使用的版本 | 解决冲突时慎用,可能破坏兼容 |
二、plugin 与 package 对比
- Package:纯 Dart 的库,没有 Android/iOS 等平台原生代码,只提供 Dart API,可在 Dart/Flutter 项目里用,不涉及 MethodChannel、原生编译。
- Plugin:在 Package 的基础上,额外带平台代码(如 android/、ios/ 目录),通过 Platform Channel(MethodChannel/EventChannel 等)与原生通信,需要跑 flutter pub get 后参与各平台构建;pub.dev 上会标成 Plugin。
| 对比项 | Package | Plugin |
|---|---|---|
| 是否含平台代码 | 否,仅 Dart | 是,含 Android/iOS 等原生实现 |
| 是否与原生通信 | 否 | 是,通过 MethodChannel 等 |
| pub.dev 标签 | Package | Plugin(并可能同时是 Package) |
| 典型用途 | 工具函数、状态管理、纯 Dart 逻辑、UI 组件(仅 Dart/Flutter) | 调系统 API、相机、蓝牙、原生 SDK、平台能力 |
| 创建方式 | flutter create --template=package | flutter create --template=plugin;pubspec.yaml 里需声明 plugin 与 platforms |
三、小结
- 版本约束:^1.2.3 表示 1.x 兼容;path/git 依赖本地或仓库;dependency_overrides 慎用。
- Plugin 与 Package:Package = 纯 Dart 库;Plugin = 带平台代码、能与原生通信的包;选型看是否需要调原生,需要则用 Plugin,否则用 Package。
66. Flutter 在 CI/CD 里一般怎么做?(flutter test、build、缓存)
一、常用流水线步骤
| 步骤 | 命令/做法 |
|---|---|
| 依赖 | flutter pub get;可缓存 .dart_tool、pubspec.lock 加速下次。 |
| 静态/分析 | flutter analyze;可选 dart format --set-exit-if-changed 检查格式。 |
| 测试 | flutter test 跑单元与 widget 测试;集成测试用 integration_test 或 flutter_driver 在真机/模拟器跑(CI 需装 SDK、模拟器或真机)。 |
| 构建 | Android:flutter build apk 或 flutter build appbundle;iOS:需 macOS,flutter build ios 后 Xcode 归档;Web:flutter build web。 |
| 缓存 | 缓存 Flutter SDK、pub cache(如 ~/.pub-cache)、Gradle/Maven(Android)、CocoaPods(iOS),可明显缩短流水线时间。 |
二、注意
- iOS 构建需要 Apple 证书与描述文件,CI 上常用 fastlane 或 CI 提供的签名;渠道/环境用 --dart-define 或 flavor 区分。
67. SafeArea 是做什么的?
- SafeArea:给子 Widget 加安全区域内边距,避开刘海、状态栏、底部指示条、圆角等,避免内容被遮挡或裁切。
- 常用于页面根布局,包一层 SafeArea 再放主体内容;也可用 SafeArea 的 left/top/right/bottom 单独控制哪边加 padding。
68. MediaQuery 常用属性?适配不同屏幕怎么做?
一、MediaQuery 常用属性
通过 MediaQuery.of(context) 取当前媒体信息,常用属性如下:
| 属性 | 含义 | 典型用法 |
|---|---|---|
| size | 当前逻辑像素下的可用区域宽高(Size,宽×高) | 按比例布局、判断横竖屏、大屏/平板分栏 |
| padding | 被系统 UI 占用的内边距(刘海、状态栏、导航条、安全区等) | 与 SafeArea 类似,自己做 padding 时用 |
| viewPadding | 系统 UI 的“稳定”内边距(不随键盘等变化) | 需要不受键盘影响的 safe 区域时用 |
| viewInsets | 被遮挡的 insets(如键盘弹出时底部被挡) | 键盘弹起时上推输入框、避免被挡 |
| devicePixelRatio | 逻辑像素 : 物理像素 比例(如 2.0、3.0) | 需要物理尺寸、1:1 绘制时换算 |
| textScaleFactor | 系统字体缩放倍数(无障碍大字号) | 文字布局要尊重用户字号设置 |
| orientation | portrait / landscape | 横竖屏布局切换、不同布局 |
| platformBrightness | Brightness.light / dark | 跟随系统深色模式、状态栏图标颜色 |
注意:padding 在键盘弹起时可能被压缩;viewPadding 不变,viewInsets 反映键盘等遮挡,做“键盘避让”时常用 viewInsets.bottom。
二、适配不同屏幕的常见做法
- 避免写死尺寸:少用固定 width/height(px),多用 Flex + Expanded/Flexible、比例(如
size.width * 0.8)、LayoutBuilder 拿约束再算子组件宽高,这样不同屏幕下可伸缩。 - 安全区与刘海:用 SafeArea 或 MediaQuery.padding 给内容加边距,避开刘海、状态栏、底部指示条。
- 尊重系统字号:布局和断行要考虑 textScaleFactor,避免“大字号”下文字溢出或重叠;必要时用 MediaQuery.textScalerOf(context) 或 FittedBox。
- 横竖屏 / 大屏:用 MediaQuery.orientation、size 或 LayoutBuilder 判断横竖屏或宽度区间,做不同布局(如竖屏单栏、横屏/平板双栏);也可用 Breakpoint(如 responsive_framework)或自定义宽度阈值做断点。
- 逻辑像素 vs 物理像素:UI 布局用 逻辑像素(size、padding 等);需要“真实物理大小”或与原生对齐时,用 devicePixelRatio 换算(物理 = 逻辑 × devicePixelRatio)。
小结:MediaQuery 提供 size、padding/viewPadding/viewInsets、devicePixelRatio、textScaleFactor、orientation、platformBrightness;适配用弹性布局 + 比例 + LayoutBuilder/断点,安全区用 SafeArea 或 padding,横竖屏用 orientation/size 分支。
69. ThemeData 如何做亮色/暗色主题切换?
一、ThemeData 与 MaterialApp 的关系
- ThemeData:描述一套主题(颜色、字体、组件样式等)。Flutter 不强制「亮/暗」两套,但通常配合 MaterialApp 的 theme(亮色)和 darkTheme(暗色)使用。
- MaterialApp 三个关键参数:
- theme:亮色主题,类型
ThemeData。 - darkTheme:暗色主题,类型
ThemeData。 - themeMode:当前用哪一套,类型
ThemeMode。
- theme:亮色主题,类型
二、themeMode 的三种取值
| themeMode | 含义 |
|---|---|
| ThemeMode.system | 跟随系统(深色模式开关),系统亮则用 theme,系统暗则用 darkTheme |
| ThemeMode.light | 强制亮色,始终用 theme |
| ThemeMode.dark | 强制暗色,始终用 darkTheme |
不设 themeMode 时默认是 ThemeMode.system。
三、如何做「运行时」亮/暗切换
思路:用状态保存当前是 light 还是 dark(或 system),把该状态传给 MaterialApp(themeMode: ...);用户点切换时更新状态并触发 rebuild。
- 用 StatefulWidget:
ThemeMode _mode = ThemeMode.system,切换时setState(() => _mode = ThemeMode.dark),根用MaterialApp(themeMode: _mode, theme: ..., darkTheme: ...)。 - 用 Provider(或其它状态管理):存
ThemeMode,根MaterialApp(themeMode: context.watch<ThemeNotifier>().mode, ...),切换时在 Notifier 里改 mode 并notifyListeners()。
这样无需重启 App,整棵 Widget 树会按新的 theme/darkTheme 重建,Theme.of(context) 会拿到对应那套 ThemeData。
四、ThemeData 里常配什么?子组件怎么取?
- colorScheme:主色、背景色、错误色等,推荐优先用 ColorScheme.fromSeed 或 colorScheme: ThemeData.light().colorScheme 再改。
- textTheme:标题、正文、按钮等文字样式。
- appBarTheme:AppBar 背景、标题样式等。
- useMaterial3:是否用 Material 3 风格(true/false)。
子组件里用 Theme.of(context) 取当前生效的 ThemeData,用 Theme.of(context).colorScheme、Theme.of(context).textTheme 等,这样在切换 themeMode 后会自动拿到亮色或暗色那套。
小结:MaterialApp 的 theme/darkTheme 两套,themeMode(system/light/dark)决定用哪套。运行时切换 = 状态存 themeMode 传给 MaterialApp,切换时 setState/notifyListeners;子组件 Theme.of(context) 取当前主题。
70. 图片加载与缓存注意点?
网络图
用 cached_network_image 等带缓存库,避免重复请求;设 placeholder、errorWidget;大图考虑 fit、缩略图。
本地图
Image.asset 会打包进应用;大图可 decode 后缩放到合适尺寸再显示,避免内存过大。
内存
列表大量图片时用 ListView.builder + 缓存库的缓存策略,避免 OOM;长列表优化见第 39 题。
71. Flutter 常用本地存储方案?
| 方案 | 类型 | 适用场景 |
|---|---|---|
| shared_preferences | 键值对 | 简单配置、开关等(敏感数据如 token/密码见第 82 题) |
| sqflite | SQLite 关系型 | 复杂查询、大量结构化数据 |
| hive | NoSQL、纯 Dart | 中等数据、对象存储、高性能 |
| path_provider + File | 文件 | 缓存、下载、大文件 |
| MMKV | 高性能键值 | 频繁读写、低延迟 |
选型:轻量配置用 shared_preferences;表结构复杂用 sqflite;要高性能、少依赖用 hive。
72. 如何做下拉刷新、上拉加载?
- 下拉刷新:RefreshIndicator 包 ListView.builder,onRefresh 返回 Future,里头发第一页请求(或 dispatch 刷新事件),请求完成后列表恢复。
- 上拉加载:ScrollController.addListener 里当 position.pixels >= position.maxScrollExtent - 阈值 时加载下一页;或 itemCount = list.length + 1,最后一项根据状态显示 loading / 没有更多。要防重复(loading 标志)、维护分页参数与「没有更多」状态,dispose 里 scrollController.dispose()。
- 完整实现(防重复、Footer 状态、代码示例)见第 98 题;ScrollController 用法见第 78 题。
73. WidgetsBindingObserver 是什么?常用场景?
- WidgetsBindingObserver:监听应用生命周期(前后台、生命周期事件)的接口,在 didChangeAppLifecycleState 里拿到 AppLifecycleState(resumed / inactive / paused / detached 等)。
- 常用场景:切到后台时暂停动画/音视频、暂停轮询;回到前台时刷新数据、恢复;在 State 的 initState 里 WidgetsBinding.instance.addObserver(this)、dispose 里 removeObserver。
74. 说说 Overlay 和 OverlayEntry?
- Overlay = 浮在 Navigator 页面上方的浮层栈,不占路由;OverlayEntry = 一条浮层,builder 建内容,insert 显示、remove 移除。MaterialApp 已配 Overlay,通常不用自建。适合 Toast、Loading、弹窗、悬浮按钮等。
- API
| 操作 | 写法 |
|---|---|
| 拿到当前 Overlay | Overlay.of(context) 或 Navigator.of(context).overlay |
| 插入一条浮层 | Overlay.of(context).insert(entry);可 insert(entry, above: xxx) 控制叠放顺序 |
| 移除一条浮层 | entry.remove() |
| 只刷新这条浮层 | entry.markNeedsBuild(),只重建该 entry 的 builder,不整棵子树重建 |
75. GestureDetector 和 Listener 区别?
| 对比项 | GestureDetector | Listener |
|---|---|---|
| 层级 | 手势层(语义化) | 原始指针事件层 |
| 输入 | 点击、长按、拖拽、缩放等手势 | onPointerDown / Move / Up 等,带 pointer 编号 |
| 冲突 | 内置竞技场裁决手势冲突 | 无,需自己处理 |
| 适用 | 常见交互(按钮、滑动、缩放) | 画板、自定义滑动、精细控制、GestureDetector 无法表达的行为 |
需要“原始事件 + 手势”时,可 Listener 包 GestureDetector 或反过来,注意命中与传递顺序。
76. 如何实现自定义 Sliver?
- Sliver 是什么:CustomScrollView 里的 Viewport 由多个 Sliver 组成,每一节根据 SliverConstraints(scrollOffset、视口剩余等)返回 SliverGeometry(scrollExtent、paintExtent 等),在 performLayout/paint 里完成布局与绘制。自定义 Sliver = 按这套协议实现一节可滚动区域。
常见做法:
| 方式 | 场景 | 说明 |
|---|---|---|
| SliverToBoxAdapter | 一截固定内容(头部、Banner) | 包一个 child,高度由 child 决定,不懒加载 |
| SliverList / SliverGrid | 一节列表/网格 | 用 SliverChildBuilderDelegate 按需 build,懒加载 |
| SliverPersistentHeader 等 | 吸顶、占满剩余等 | 直接用系统 Sliver 或看源码仿写 |
| 自定义 RenderSliver | 视差、自定义吸顶、不规则高度 | 实现 RenderSliver 子类,自己算 SliverGeometry |
不写 RenderObject 时:SliverToBoxAdapter(child: ...) 加一截固定内容;SliverList(delegate: SliverChildBuilderDelegate(...)) 加一节列表。自定义一节列表可写自己的 SliverChildDelegate 或包一层 SliverList。
完全自定义(RenderSliver):子类实现 performLayout(根据 SliverConstraints 算 geometry,对子节点 layout)、paint(用 SliverPaintContext 画子节点);Widget 层用 SliverWithSingleChildRenderObjectWidget 包一层,createRenderObject 返回该 RenderSliver。SliverGeometry 常用字段:scrollExtent(本节总长度)、paintExtent(可见需绘制长度)、layoutExtent、maxScrollObstruction(吸顶时压住前一段的长度)。
77. Form 与 TextFormField 如何做校验?FormState 怎么用?
- Form:包一组可校验的 TextFormField,设 key: GlobalKey<FormState>,用 key.currentState 拿到 FormState。
- FormState:validate() 依次调所有子项 validator,全部通过返回 true,否则显示错误并返回 false;save() 依次调各子项 onSaved 取到值;reset() 清空校验状态。
- TextFormField:validator(value) 返回 String?,null 通过、非 null 为错误提示;onSaved(value) 在 FormState.save() 时被调,可赋给变量;autovalidateMode 控制何时校验(disabled / onUserInteraction / always)。
- 提交流程:先 formKey.currentState?.validate(),为 true 再 formKey.currentState!.save(),在 onSaved 里拿到值后发请求。
78. ScrollController 和 ScrollPhysics 分别做什么?监听滚动怎么实现?
一、ScrollController 是什么、做什么
- ScrollController 用来绑定一个可滚动组件(ListView、GridView、CustomScrollView、SingleChildScrollView 等),通过 controller 参数传入。
- 主要作用有两类:
- 控制滚动位置:jumpTo(offset) 瞬间跳到某位置,animateTo(offset) 动画滚到某位置;可配合 initialScrollOffset 设置初始滚动位置。
- 监听滚动:通过 controller.offset、controller.positions 拿到当前滚动偏移和 ScrollPosition,用 addListener 在滚动过程中做逻辑(如吸顶栏显隐、上拉加载更多)。
二、ScrollController 常用 API
| API | 作用 |
|---|---|
| ScrollController(initialScrollOffset: 0) | 创建时可设初始滚动偏移 |
| controller.offset | 当前滚动偏移(像素),只读 |
| controller.positions | 当前关联的 ScrollPosition 集合(通常一个) |
| controller.jumpTo(offset) | 无动画跳到 offset |
| controller.animateTo(offset, duration: ..., curve: ...) | 动画滚到 offset |
| controller.addListener(callback) | 滚动时回调(offset 变化会触发) |
| controller.dispose() | 用完后在 State.dispose 里调用,避免泄漏 |
三、ScrollPhysics 是什么、做什么
- ScrollPhysics 决定滚动手感:到顶/到底时是否回弹、是否有惯性、是否可 overscroll 等。传给可滚动组件的 physics 参数。
- 常见几种:
| 类型 | 效果 |
|---|---|
| BouncingScrollPhysics | 到边界有回弹(类似 iOS),可 overscroll |
| ClampingScrollPhysics | 到边界硬停,无回弹(类似 Android 默认) |
| NeverScrollableScrollPhysics | 禁止滚动,常用于嵌套时禁止内层滚动 |
| AlwaysScrollableScrollPhysics | 即使内容不足一屏也可拖拽(配合 shrinkWrap 等) |
不传 physics 时,MaterialApp 下一般会根据平台选默认(如 iOS 用 Bouncing,Android 用 Clamping)。
- 监听滚动:① ScrollController.addListener:绑到 ListView 等,回调里用 offset、position.pixels、maxScrollExtent 做「超过某值显示顶栏」「接近底部加载更多」等;dispose 里 removeListener、dispose()。② NotificationListener<ScrollNotification>:包住可滚动组件,onNotification 里用 metrics.pixels 等,不依赖 controller,适合父组件只关心子是否滚动的场景。
小结:ScrollController 负责绑定、jumpTo/animateTo、监听;ScrollPhysics 管手感(Bouncing/Clamping 等);用毕 dispose 里 removeListener、dispose()。
79. dio 常用能力?拦截器、取消请求、超时怎么用?
一、dio 是什么
- dio 是 Flutter 里常用的 HTTP 客户端,支持请求/响应拦截、取消、超时、FormData 上传/下载等,比 http 包功能更全,适合业务项目。
二、常用能力概览
| 能力 | 用法 |
|---|---|
| 请求方法 | dio.get(url)、dio.post(url, data: map)、dio.put、dio.delete;queryParameters 传 query,data 传 body |
| 全局配置 | BaseOptions:baseUrl、connectTimeout、receiveTimeout(毫秒)、headers、contentType 等;Dio(BaseOptions(...)) 创建实例后,请求可写相对路径 |
| 上传 | FormData.fromMap({'file': MultipartFile.fromFile(path)}),dio.post(url, data: formData) |
| 下载/进度 | dio.download(url, savePath);onSendProgress、onReceiveProgress 回调 (sent, total) 可做进度条 |
三、拦截器怎么用
- 用 InterceptorsWrapper 定义 onRequest、onResponse、onError,通过 dio.interceptors.add(InterceptorsWrapper(...)) 添加;执行顺序:请求前 onRequest → 发请求 → 收到响应后 onResponse,出错则走 onError。
- onRequest(options, handler):可修改 options(如 options.headers['Authorization'] = 'Bearer $token'),然后 handler.next(options) 继续,或 handler.reject 终止。
- onResponse(response, handler):可对 response.data 统一解包(如取
data.data)、改格式,再 handler.next(response)。 - onError(err, handler):可统一弹窗、打日志、按状态码重试,再 handler.next(err) 或 handler.resolve 改成成功。
四、取消请求怎么用
- 创建 CancelToken,请求时传入:dio.get(url, cancelToken: token);需要取消时调 token.cancel('reason'),该 token 关联的未完成请求会被取消(会抛 DioException,type == DioExceptionType.cancel)。
- 典型用法:页面里持有一个 CancelToken,在 dispose 里 token.cancel(),避免页面销毁后回调里还在 setState;或搜索框防抖时取消上一次请求。
五、超时怎么用
- 连接超时:connectTimeout(从发请求到建立连接的最长时间,毫秒)。
- 接收超时:receiveTimeout(从建立连接到收完响应的最长时间,毫秒)。
- 在 BaseOptions 里统一设:
BaseOptions(connectTimeout: Duration(seconds: 5), receiveTimeout: Duration(seconds: 10)),也可单次请求时传 Options(connectTimeout: ..., receiveTimeout: ...)。超时后会抛 DioException,type == DioExceptionType.connectionTimeout 或 receiveTimeout。
小结:dio 常用 get/post、BaseOptions、FormData、进度;拦截器 InterceptorsWrapper(onRequest/onResponse/onError)做 token/解包/错误处理;取消 CancelToken + cancel();超时 connectTimeout、receiveTimeout。
80. Semantics 是什么?如何做无障碍(TalkBack/VoiceOver)?
- 背景:无障碍让视障等用户通过读屏使用 App;TalkBack(Android)/ VoiceOver(iOS)会朗读焦点处的语义。Semantics 给 UI 贴语义(标签、角色、hint),形成 Semantics 树供读屏;自定义/纯图标需自包 Semantics。
- 常用写法
| 需求 | 做法 |
|---|---|
| 给无文字的图标/区域加说明 | 用 Semantics 包住,设 label(主要读的内容)、hint(操作提示,如「双击提交」) |
| 标明可点击 | Semantics(button: true, ...) 或 label 里带「按钮」等,读屏会读成可点击 |
| 多个子节点合并成一句 | 用 MergeSemantics 包住,读屏会把子节点语义合并成一条读出来,避免一句句拆开读 |
| 装饰性内容不想被读 | 用 ExcludeSemantics 包住,这块就不会出现在语义树里,读屏会跳过 |
| 自定义控件 | 在根 Widget 外包 Semantics,设 label、value(如进度)、hint、button/link 等 |
- 其他:对比度、可点区域 ≥44px、textScaleFactor 放大;大厂/政府常要求 WCAG。
81. Flutter 里如何做状态恢复(State Restoration)?场景是什么?
一、是什么
- 切后台后进程可能被回收或页面被移除,再打开时若不做处理,滚动位置、表单输入等会丢失。状态恢复 = 回收/移除前把状态存起来,再次显示时读回并恢复 UI。
二、典型场景
| 场景 | 说明 |
|---|---|
| 后台被系统杀进程 | Android 省电、内存紧张时杀后台进程;用户再点图标进来时 App 重新启动,若没做恢复,所有页面状态都是初始值。 |
| 页面被暂时移除 | 如 Navigator 里某页被 pop 掉又 push 回来、或系统为省内存把不可见页回收,再显示时需要恢复该页的滚动、输入等。 |
| 长表单、多步骤流程 | 用户填到一半切走,回来希望接着填,而不是从头开始。 |
三、Flutter 里怎么做(思路与 API)
- 建一个「恢复作用域」:在根用 MaterialApp(restorationScopeId: 'app') 或包一层 RestorationScope,表示「整棵子树参与状态恢复」,系统会在适当时机把存起来的数据按 restorationScopeId 和子节点的 restorationId 灌回来。
- 在需要恢复的 State 里:混入 RestorationMixin,用 RestorableProperty(如 RestorableInt、RestorableString、RestorableBool)存要恢复的字段;在 restoreState 里把这类属性注册到 RestorationBucket,这样系统会负责「保存时序列化、恢复时反序列化并写回」。
- 恢复时机:系统在「应用从后台恢复」或「页面重新被插入」等时机,把之前保存的 RestorationBucket 数据灌回对应 State,RestorableProperty 会更新值并触发 UI 更新(如 notifyListeners)。
- 滚动位置:ListView、ScrollView 等若设置了 PageStorageKey,滚动位置会参与 PageStorage 的保存与恢复,可与 State Restoration 配合使用。
四、常用 API 小结
| API | 作用 |
|---|---|
| MaterialApp.restorationScopeId | 根恢复作用域 ID,整 App 的恢复数据会按这个 ID 存/取 |
| RestorationMixin | 混入 State,提供 restoreState、RestorationBucket 等,用于注册要恢复的数据 |
| RestorableInt / RestorableString / RestorableBool 等 | 可序列化、可被恢复的「属性」,值变化时会被记录,恢复时写回 |
| restoreState | 在 State 里重写,把 RestorableProperty 注册到 RestorationBucket |
| PageStorageKey | 给 ScrollView 等设 key,滚动位置会通过 PageStorage 参与保存/恢复 |
- 注意:数据要可序列化;混合栈/复杂路由需与原生或路由方案配合。根设 restorationScopeId,State 用 RestorationMixin + RestorableProperty 在 restoreState 里注册;滚动用 PageStorageKey。
82. 敏感数据(token、密码)在 Flutter 里怎么存?和 SharedPreferences 的区别?
一、SharedPreferences 为什么不能存敏感数据
- SharedPreferences 数据写在本地文件里,一般是明文或简单编码,root 或备份可读,不适合存 token、密码、密钥等敏感信息。
二、敏感数据怎么存
- 用 flutter_secure_storage(或平台通道调原生安全存储):
- Android:背后用 Keychain / Keystore,密钥由系统保护。
- iOS:背后用 Keychain,数据由系统加密、与设备绑定。
- 跨平台:flutter_secure_storage 统一 API,适合存 token、密码、密钥。
三、和 SharedPreferences 对比
| 对比项 | SharedPreferences | flutter_secure_storage |
|---|---|---|
| 存储方式 | 本地文件,明文或简单编码 | 系统安全存储(Keychain/Keystore),加密 |
| 适用 | 普通配置、非敏感缓存(如主题、语言) | token、密码、密钥等敏感数据 |
| 安全性 | 易被 root/备份读取 | 由系统保护,相对安全 |
四、小结
- 敏感数据(token、密码)用 flutter_secure_storage 或平台原生安全存储;非敏感配置、缓存用 SharedPreferences 即可。
83. Dart 空安全是什么??、!、late 怎么用?
- 空安全:类型默认非空,只有显式声明
?才可存 null,减少运行时空指针。 ?:可空类型,如String?;调用前需判空或使用?.、??。!:断言非空,对可空类型使用x!表示“我保证这里不是 null”,否则抛异常,慎用。late:延迟初始化,首次访问前必须赋值;用于依赖注入、在 initState 里赋值的成员等。
84. Mixin 是什么?和继承的区别?
- Mixin:用
mixin定义、with混入,把一批方法/属性“混”进类里,可多混入,解决 Dart 单继承限制。 - 和继承区别:继承是“是一个”,单继承;Mixin 是“带有某种能力”,可多个。Flutter 里常见 SingleTickerProviderStateMixin、AutomaticKeepAliveClientMixin 等。与 extends、implements 的完整区别及声明顺序见第 85 题。
85. mixin、extends、implements 在 Dart 里的区别与使用顺序?(大厂基础)
一、三者是什么
| 关键字 | 含义 |
|---|---|
| extends | 单继承:子类继承一个父类,获得其实现(方法、字段),可 override;Dart 只能 extends 一个类。 |
| implements | 实现接口:类承诺实现某类型声明的所有成员(抽象方法、getter/setter),不继承任何实现;可 implements 多个接口。 |
| mixin(with) | 混入:把 mixin 的方法/字段实现「混进」当前类,不占继承位,用来在单继承下复用多份能力;可 with 多个 mixin。 |
二、使用顺序(语法规定)
- extends 只能一个,且写在最前;接着 with 多个 mixin;最后 implements 多个接口。
- 正确写法:
class A extends B with M1, M2 implements I1, I2 { }
三、注意点
- mixin 不能有有参构造函数(无参可省略);mixin 可用 on X, Y 限制「只能被 with 在 X、Y 的子类上」。
- implements 只「承诺实现」,不带来实现;with 带来实现;extends 带来实现且占唯一的继承位。
86. const 构造函数有什么作用?什么时候用?
- 作用:const 构造出来的实例是编译时常量,相同参数在同一程序里是同一实例,可减少对象创建、利于 Flutter 在 diff 时复用,减少 rebuild 成本。
- 什么时候用:Widget 配置不变时尽量加 const(如
const Text('xx')、const SizedBox());构造函数里所有字段都 final 且为编译时常量时才能声明 const 构造函数。
87. final 和 const 区别?
- final:运行时常量,只能赋一次值,可在运行时确定(如
final x = getValue())。 - const:编译时常量,值必须在编译期确定;const 变量隐式 final。
- const 构造函数:创建的是编译时常量对象。
88. 级联操作符 .. 是做什么的?
..:对同一对象连续调用方法或赋值,返回的是对象本身而不是方法返回值。- 例:
controller..addListener(...)..forward()等价于先controller.addListener(...)再controller.forward(),整体表达式的值是 controller。
89. BuildContext 是什么?能做什么、不能做什么?
是什么
Widget 在树中的位置上下文,用来查找祖先 InheritedWidget、获取路由/导航、Overlay、尺寸与位置等。
能做的
context.watch<T>()、context.read<T>()、Theme.of(context)、MediaQuery.of(context)、Navigator.of(context).push() 等。
不能 / 慎用
- initState 里用 context 查 InheritedWidget(Theme、Provider 等),可能还不是最终依赖;详见第 95 题。
- context 在 dispose 后不可用,异步回调里用前要 mounted 判断。
90. InheritedWidget 原理?Provider 如何基于它实现?
InheritedWidget
- 在树上层存数据;子节点通过 context.dependOnInheritedWidgetOfExactType<T>() 获取并注册依赖。
- 当该 InheritedWidget 更新时,依赖它的子节点会 didChangeDependencies 并重建。
Provider
- 本质:InheritedWidget + ChangeNotifier。
- Provider.of / context.watch 内部调 dependOnInheritedWidgetOfExactType。
- notifyListeners() 时 Provider 重建,依赖的子树就 rebuild。
91. Dart 里值传递和引用传递?库私有(_)是什么?
- 传递方式:Dart 里传参是引用(reference)的拷贝;没有 C++ 那种“引用传递”,修改形参指向不会改变实参指向;修改形参指向的对象内容会反映到实参(同一对象)。表象上:
| 表现 | 说明 |
|---|---|
| 简单类型(int、bool 等) | 不可变,看起来像“值拷贝”,函数内改不影响外部 |
| 对象类型 | 传引用拷贝,函数内改对象内容会影响外部;重新赋值(换对象)不影响外部 |
- 库私有:以下划线 _ 开头的顶级成员(变量、函数、类、mixin)是库私有,只在当前 .dart 文件(库) 内可见。面试可答:传参是“引用拷贝”;_ 开头即库内私有。
92. Dart 的 extension 扩展方法是什么?有什么用?
一、是什么
- extension 可以给已有类(包括 Dart SDK、第三方库)无侵入地增加方法或 getter,不需要继承、也不用改原类代码。
- 同文件或 import 后,该类型的实例就能像调用普通方法一样调用扩展方法。
二、有什么用
| 用途 | 说明 |
|---|---|
| 语义化 API | 如对 int 扩展 duration getter,写 5.duration 代替 Duration(seconds: 5)。 |
| 工具方法 | 对 String、List 等扩展 format、parse、校验方法,不污染原类。 |
| Flutter 里 | GetX 的 .obs、.value 等通过 extension 挂在类型上;部分包对 BuildContext 扩展 theme、size 等 getter。 |
三、注意
- 扩展是静态解析的,不能多态;不同 extension 里同名方法用扩展名区分:MyExt(value).myMethod()。
93. 错误处理:FlutterError.onError 和 Zone?
| 方式 | 捕获范围 | 典型用法 |
|---|---|---|
| FlutterError.onError | Flutter 框架层错误(build 里抛异常、布局错误等) | 设置后在该回调里上报、打日志、弹窗;可配合 FlutterError.presentError 决定是否再抛给系统 |
| runZonedGuarded(Zone) | 异步未捕获异常(Future、async 里未 try-catch 的异常) | runZonedGuarded(() => runApp(MyApp()), (e, s) { ... }),在回调里上报;与 FlutterError.onError 同时使用可覆盖同步 + 异步,统一上报 |
| PlatformDispatcher.instance.onError | 异步错误 | 与 Zone 二选一或配合使用 |
小结:同步 Flutter 错误用 FlutterError.onError;异步未捕获用 runZonedGuarded 或 PlatformDispatcher.instance.onError;生产环境常两者都设,统一上报。
94. 如何做 Flutter 的国际化(多语言)?
- 做什么:按当前 Locale 显示不同文案、数字/日期格式(如中文「确定」、英文「OK」)。
- 官方方案:
- 依赖:pubspec 里加
flutter_localizations、intl。 - MaterialApp 三件套:
localizationsDelegates(系统 + 自己 App 的 delegate,如AppLocalizations.delegate)、supportedLocales(如[Locale('zh'), Locale('en')])、locale(当前语言,不设则跟系统)。 - 文案:写 Arb 文件(
app_zh.arb、app_en.arb等),用 flutter gen-l10n 生成AppLocalizations,代码里AppLocalizations.of(context)!.xxx。
- 依赖:pubspec 里加
- 运行时切换:用状态存当前
Locale并传给 MaterialApp 的locale,切换时更新状态触发重建即可;GetX 可用Get.updateLocale(Locale('en'))。 - 注意:RTL 语言由 MaterialApp 根据 locale 处理;数字、日期用 intl 的
NumberFormat、DateFormat按 locale 格式化。
95. initState 里拿不到 context 依赖(如 Theme)怎么办?(字节/阿里)
一、原因
- initState 执行时,State 刚挂上树,context 还未完成对 InheritedWidget 的依赖注册,此时 Theme.of(context)、MediaQuery.of(context)、context.read<T>() 等可能拿不到或拿不准(如拿到的是上层某处的旧值),且不会在 Theme 等变化时自动 rebuild。
- 所以:initState 里不要用 context 去依赖 InheritedWidget(Theme、MediaQuery、Provider 等)。
二、做法
| 需求 | 做法 |
|---|---|
| 用到 Theme/MediaQuery 等做逻辑 | 放到 didChangeDependencies() 里:首次在 initState 之后调,之后 Theme 等变化也会调;或放到 build() 里直接 Theme.of(context)。 |
| 根据主题设「初始值」并 setState | 在 didChangeDependencies 里 if (依赖已就绪) 取一次 Theme 等,setState 赋给成员变量,避免在 initState 里取。 |
| 必须在「首帧渲染之后」再执行 | 用 WidgetsBinding.instance.addPostFrameCallback((_) { ... }),在首帧 paint 完成后的回调里再 Theme.of(context) 或 context.read。 |
三、小结
- initState 里不用 context 依赖 Theme/MediaQuery/Provider;需要时放到 didChangeDependencies 或 build,或首帧后用 WidgetsBinding.instance.addPostFrameCallback。
96. Flutter 里常见内存泄漏怎么避免?如何排查?
一、什么是内存泄漏(简要)
- 页面或对象已经不用了(如页面 pop 掉),但仍有引用指向它,导致 GC 无法回收,占用的内存一直不释放,反复进出页面或操作后可能 OOM 或卡顿。
二、常见原因与对应避免方式
| 类型 | 为什么容易漏 | 怎么避免 |
|---|---|---|
| Controller 未 dispose | ScrollController、AnimationController、TextEditingController 内部会注册监听、持有 context/Element,不 dispose 会一直挂着 | 在 State.dispose() 里统一 controller.dispose();Controller 在 State 里创建、在 dispose 里释放 |
| StreamSubscription 未 cancel | 对 Stream 调 listen 会返回 StreamSubscription,不 cancel 则回调会一直存在,若回调里用到了已销毁的 State 就会泄漏或报错 | 把 subscription 存起来,在 dispose 里 subscription.cancel() |
| Timer 未 cancel | Timer.periodic、Timer 不 cancel 会一直跑,回调里若引用 Widget/State 会泄漏 | 在 dispose 里 timer.cancel();或使用 Ticker、AnimationController 等与生命周期绑定的方式 |
| Listener / Observer 未移除 | WidgetsBinding.instance.addObserver(this)、ScrollController.addListener 等,不 remove 会一直收到回调 | dispose 里 removeObserver(this)、controller.removeListener(...) |
| GlobalKey 长期持有 State | GlobalKey 会持有对应 State 的引用,若 Key 是全局/静态的,State 所在 Widget 树拆掉后 State 仍被 Key 引用,无法回收 | 尽量不用 GlobalKey 存 State;若用,在不用时置空或确保 Key 作用域与页面一致 |
| GetX / Bloc 等未释放 | Get.put 的 Controller 未 Get.delete、Bloc 未 close,会一直活在内存里 | 随页面销毁时 Get.delete、bloc.close(),或使用 Get.lazyPut + 路由绑定自动回收 |
- 习惯:State 里创建的 Controller/Subscription/Timer 都在 dispose() 里释放(先 removeListener/cancel 再 dispose);异步回调里 setState 前 if (!mounted) return;Stream 的 listen 放在 initState、dispose 里 cancel。
- 排查:DevTools → Memory 拍快照,多次「进页→返回」后对比实例数量;Profile 模式跑;快照里搜 Controller/Subscription/State 看 Retaining path 找持有者。
97. CustomPainter 适用场景?和 CustomWidget 区别?
一、CustomPainter 是什么
- CustomPainter 是一个抽象类,实现 paint(Canvas canvas, Size size) 和可选的 shouldRepaint。把它传给 CustomPaint 的 painter 参数,Flutter 在布局完成后会调用 paint,你在里面用 Canvas 直接画线、形、文字、图片、路径等,不通过子 Widget 组合,完全自绘。
- CustomPaint 需要尺寸:要么设 size,要么包在 SizedBox / 有约束的父组件里(否则会尽可能大),foregroundPainter 可再叠一层绘制。
二、适用场景
| 场景 | 说明 |
|---|---|
| 不规则图形、路径 | 折线、曲线、多边形、贝塞尔等,用 Path + Canvas.drawPath 比用多个 Widget 拼更简单、性能更好。 |
| 图表 | 柱状图、折线图、饼图等,大量线段/形状,用 Canvas 一次画完,避免成百上千个 Widget。 |
| 签名板、画板 | 用户触摸轨迹用 Path 记录,在 paint 里 drawPath,配合 GestureDetector 收集点。 |
| 动效背景、粒子、渐变 | 每帧重画,shouldRepaint 返回 true,在 paint 里根据时间或状态画;用 RepaintBoundary 包住可减少重绘范围。 |
| 大量重复简单图形 | 如网格、点阵、纹理,用 Canvas 批量画比建大量 Widget 更轻量。 |
三、和「CustomWidget」(用 Widget 组合)的区别
- 这里的 CustomWidget 泛指:用 StatelessWidget / StatefulWidget 组合已有 Widget(Container、Text、Row、CustomPaint 等)做成的自定义组件,布局和渲染都交给子 Widget 树。
- 对比如下:
| 对比项 | CustomPainter(自绘) | CustomWidget(组合子 Widget) |
|---|---|---|
| 实现方式 | 实现 paint(Canvas, Size),用 Canvas 画 | 在 build 里 return 一堆子 Widget |
| 层级 | 更底层,直接操作 Canvas | 依赖现有 Widget,树更深 |
| 适用 | 不规则图形、图表、签名、大量图形、每帧动效 | 常规 UI:按钮、列表、表单、卡片组合 |
| 性能 | 一个 CustomPaint 一层绘制,适合「一笔画完」的复杂图 | 子 Widget 多时会有更多 layout/paint 节点;简单组合没问题 |
| 布局 | 自己不做布局,尺寸由 CustomPaint 的 size 或父约束决定 | 由子 Widget 的 layout 决定 |
| 手势 | CustomPainter 不处理手势,需在外层包 GestureDetector 等 | 子 Widget 可自带或包 GestureDetector |
四、小结
- CustomPainter 用在 CustomPaint 里,用 Canvas 自绘,适合不规则图形、图表、签名板、动效、大量图形;和用 Widget 组合的 CustomWidget 相比,更底层、适合「画」出来的 UI,组合 Widget 适合常规排版和交互。需要手势时在 CustomPaint 外包 GestureDetector。
98. 你做过的最复杂的 Flutter 功能是什么?遇到过什么坑?
答:(示例思路,需结合自己项目)
复杂功能举例
长列表 + 多类型 + 下拉刷新/上拉加载 + 本地缓存;或复杂表单 + 多步骤 + 校验 + 草稿。
常见坑
- 列表滑动与下拉刷新冲突。
- 键盘弹起导致布局错乱。
- iOS/Android 表现不一致。
- 混合栈返回键、内存泄漏(Controller 未关闭)、多 isolate 通信等。
解决思路
- 选对组件:RefreshIndicator + ListView.builder;MediaQuery 与 SafeArea;Platform 适配。
- 混合栈:用 FlutterBoost 等规范路由。
- 内存:dispose 里取消订阅;用 compute / isolate 规范通信。
99. 手写/口述:实现下拉刷新 + 上拉加载更多的列表,要考虑哪些点?(腾讯/美团场景题)
- 下拉刷新:RefreshIndicator 包 ListView.builder,onRefresh 返回 Future,里头发第一页请求(page=1、清空列表再请求),await 完再结束否则动画会提前收。
- 上拉加载:ScrollController.addListener 里判断 position.pixels >= maxScrollExtent - 阈值(如 200) 且 !_loading && !_noMore 时调 _loadMore();维护 page、_noMore,返回条数 < pageSize 时设 _noMore 并底部显示「没有更多了」。
- 防重复:bool _loading,请求前 if (_loading) return 并置 true,try/finally 里置 false;dispose 里 scrollController.removeListener、scrollController.dispose()。
- 列表与 Footer:itemCount: list.length + 1,最后一项按状态显示加载中 / 失败重试 / 没有更多;失败可 retryCount 或 Footer 点「重试」再调 _loadMore。
- 优化:RepaintBoundary 包 item、itemExtent 固定高度、图片缓存;大厂会问 Footer 状态机(idle / loading / error / noMore)。
100. 手写/口述:登录页(手机号格式化、60 秒验证码倒计时、表单校验)核心思路?(大厂场景题)
- 手机号:inputFormatters 用 FilteringTextInputFormatter.digitsOnly + LengthLimitingTextInputFormatter(11);需 3-4-4 空格或脱敏时,onChanged 里处理或自定义 TextInputFormatter,脱敏用 substring 拼前 3 + "****" + 后 4 展示。
- 60 秒倒计时:Timer.periodic(Duration(seconds: 1)) 每秒 setState 减一,到 0 时 cancel;按钮 onPressed 在 countdown > 0 时置 null,dispose 里 _timer?.cancel() 防泄漏。
- 表单校验:Form + GlobalKey<FormState> 包各 TextFormField,validator 里手机号 1 开头 11 位、验证码 6 位、密码长度等,返回 null 通过。提交前 formKey.currentState?.validate() 通过再发请求。详见第 76 题。
- 防重复提交:bool _isSubmitting,提交里先判断再置 true,await 请求后 finally 置 false;按钮 onPressed 在 _isSubmitting 时传 null。