先用自己的话说一遍「30 秒版」,别背。面试官往下挖,再看追问那一句。快捷键 / 搜索。

1

架构和三棵树

▼

30 秒版

Flutter 自己画,不用系统按钮那些,所以两端长得一样。上面写 Dart,底下 C++ 引擎在画像素,再往下才对接安卓 iOS。

Widget 就是一张说明书,扔了再建也没事。真正活着的是 Element,状态在它身上。布局绘制是 RenderObject。所以界面在刷,不代表底下全拆了。

追问

为什么要拆三棵?

说明书便宜,可以一直换。状态得有个地方住,绘制又重,不能跟着换。所以拆开了。

一帧怎么走?

先动画,再 build 脏的那些,再量尺寸,再画,最后引擎上屏。哪边超了十六毫秒都会卡。

Widget 为啥不能改?

改不动才能放心重建。状态放 State 上,不放 Widget 上。

setState 刷多大?

从你调用的那个往下刷。底下是 const 的,它会跳过。

BuildContext 是啥?

就是 Element 自己。拿 Theme、找 Navigator,都是顺着它往上摸。

跟 RN 比?

RN 套系统控件。Flutter 自己画。要嵌系统控件,才走 PlatformView。

2

State 生命周期

▼

30 秒版

Widget 是说明书,每次 build 都可能是新的。State 才是那个还活着的人:记着计数、输入框、动画、接口有没有回来。它挂在 Element 上,说明书换了,人还在。

它就干几件事。进来 initState 里把控制器、监听建好。build 里拿当前数据和 widget 传来的参数,画出界面。变了调 setState,告诉框架这块脏了,下一帧再画。走的时候 dispose,把监听和控制器放掉。

你点按钮,setState 先把数字改掉,比如 0 变成 1。它不会立刻重画屏幕。

它只是跟框架说:这个组件脏了,记下来。术语叫 markNeedsBuild,被记下来的那个活节点叫 Element。

等屏幕下一次刷新(Vsync),框架才从「脏了的这块」往下重新 build。类型一样、Key 一样就复用老节点,这步叫 canUpdate。然后量尺寸 layout,再画出来 paint。

底下写了 const 的不重跑。所以是局部刷新,不是整页拆掉重来。

没状态就 Stateless。有状态才上 Stateful。initState 里别拿 Theme,接口回来先看 mounted。

追问

State 做了什么?

保住可变的东西,别让 Widget 一重建就丢。还负责建、画、刷、拆:initState 建,build 画,setState 刷,dispose 拆。框架找的是 Element 上这个 State,不是那张一次性的说明书。

setState 过程?

先改数,再跟框架说这块脏了(markNeedsBuild / Element)。等下一次屏幕刷新(Vsync)才从这块往下 build。类型和 Key 对上就复用(canUpdate),再量尺寸(layout)、画出来(paint)。const 的不重跑。局部刷,不是整页拆。

initState 能 Theme.of 吗?

别。那会儿还没挂上依赖,放 build 里拿。

didChangeDependencies?

进来调一次。上面 InheritedWidget 变了还会再调。

didUpdateWidget?

爸爸换了新说明书进来,对比一下,把外面传进来的值同步掉。

setState 报错?

人已经走了,接口才回来。先看 mounted。

前后台?

听 App 生命周期。回前台、进后台那些。

列表状态乱了?

没加 Key,按位置复用,状态跟错人了。

3

Key

▼

30 秒版

列表会插会删,item 又有自己的状态,不加 Key 就会跟错人。我一般用 id 当 ValueKey。

用 index 当 Key 等于没加。GlobalKey 能拿到 State,但贵,表单校验才用。

追问

index 当 Key?

插一条,后面编号全变,等于白加。

没状态也要加?

一般不用。没状态跟错也看不出来。

UniqueKey 写在 build 里?

每刷一次都是新身份,动画会从头播。

GlobalKey 为啥贵?

要记一张全局表,还不能同时挂两处。

Key 加哪?

加在这一层孩子最外面。包了 Padding,Key 要在 Padding 上。

4

状态管理

▼

30 秒版

说白了就是数据变了谁跟着刷。本页我用 setState,跨页才上框架。

soundcore 就我一个人,GetX 写得快。KAI 行情太碎,我用 Riverpod,界面 watch,按钮 read,一个币一份 family,价格格子只盯价格。单测能单独跑,不用起一整页。

我踩过的:watch 和 read 用反了会乱刷;family 的 key 不能每次 new;登录态和长连接别 autoDispose,不然一切走 WebSocket 就断。

追问

怎么证明用过 Riverpod?

就讲 KAI 行情怎么接的。别背名词表。

为啥不用 GetX?

一个人赶工可以。行情这种流,全局 Controller 容易整页刷。

底层怎么通知的?

谁用了就记下来,变了只喊这些人。

watch / read / select?

看着刷用 watch,点一下用 read,只盯一个字段用 select。

5

异步和 Isolate

▼

30 秒版

Dart 其实就一条线在转。await 不会新开线程,只是先去干别的,回来再接着跑。

所以网络一般不卡界面。卡的是算得重的,大 JSON、压图,我丢 Isolate。小活别起 Isolate,光启动它就够慢。

追问

await 卡线程吗?

不卡。它先让开,线还在转别的事。

网络为啥不卡?

真正收数据的是底下的线程池,收完再通知 Dart。

Isolate 跟线程?

内存不共用,只能传消息。麻烦一点,但不会抢着改同一份数据。

Future 和 Stream?

一个是等一次,一个是一直来。用完记得关。

FutureBuilder 坑?

future 写在 build 里,刷一下就重新请求。提前创建好。

可能让你写顺序

print('1');
Future(() => print('2'));          // event
Future.microtask(() => print('3')); // microtask
Future.value().then((_) => print('4')); // microtask
scheduleMicrotask(() => print('5'));
print('6');
// 输出:1 6 3 4 5 2
6

性能优化

▼

30 秒版

先看再改。Profile 里看是 build 超时,还是画超时。build 太重就拆开、能 const 就 const、列表用 builder。画太重就圈一块单独重绘,少用透明和裁剪,图按显示大小解。

启动我拆开看:原生壳、引擎、第一帧。安卓启动别塞一堆 SDK,iOS 的 AppDelegate 也别干重活。Dart 这边 main 里少等,蓝牙、埋点放到首页出来再做。测启动别用 Debug。

追问

你改过什么卡顿?

讲飞项那次,或者 KAI 行情。别两边都讲。

启动慢看哪?

原生一段,引擎一段,第一帧一段。哪段长改哪段。

安卓 / iOS?

安卓启动别塞 SDK。iOS 别在 AppDelegate 里干重活。启动图接到第一帧,中间别空一下。

拆方法算优化吗?

不算。方法没有自己的 Element,父一刷它还得跑。

到处加隔离重绘?

别。占显存。只圈会动的那一块。

列表白屏?

格子太重,或者原图太大。按显示大小解,提前多准备几条。

7

和原生通信

▼

30 秒版

我平时用得最多的是 MethodChannel,跟原生说一声,等它回。电量、传感器这种一直推的,用 EventChannel。还有个 BasicMessageChannel,两边互发消息,编解码能自己换。底层就一条管子,三种只是用法不一样。

都是异步的,原生那边回消息要回到主线程。要类型安全我会上 Pigeon。调 C 库用 FFI。地图、WebView 才走 PlatformView。往原生里嵌 Flutter,引擎提前热好,别让用户盯着白屏。

追问

为啥三种?

不是三条管子。一个问一句,一个一直推,一个随便发。按用法选。

同步的吗?

不是。Dart 这边等着,原生那边别在回调里干重活。

传什么?

数字、字符串、列表、Map 这些。复杂对象先拆开,或者用 Pigeon。

传很大的数据?

通道扛不住就共享纹理,或者 FFI。

你做过啥?

钱包嵌原生、蓝牙、支付。讲你真正写过的那个。

8

路由

▼

30 秒版

早先就是 push、pop,好懂。后来官方弄了声明式那套,太重,项目里我一般用 go_router。

go 是按地址把栈摆好,push 就是再压一页。没登录就 redirect。返回拦截用 PopScope。我这边用的是 go_router / 命名路由 / GetX。

追问

go 和 push?

go 可能把栈重摆。push 就是多一页。返回手感不一样。

拦截返回?

WillPopScope 不用了,用 PopScope。

传参?

小的跟地址走。大对象传个 id,进去再查。

底部 Tab?

每个 Tab 自己一栈,切走别把状态扔了。

没 context 怎么跳?

挂一个全局 key,或者直接拿 router。

9

布局约束

▼

30 秒版

父告诉子你最高多高、最宽多宽,子报自己要多大,父再摆位置。一遍就完,所以快。

Column 里直接丢 ListView 会炸,因为高度没封顶。包个 Expanded,或者给死高度。Row 里文字溢出去,也是同一回事。

追问

tight / loose?

tight 是必须这么大。loose 是可以更小。SizedBox 是前者,Center 是后者。

Container 宽高不听?

爸爸说死了多大,它只能听爸爸的。

文字溢出?

横着排的时候宽度没封顶。包一下让它有边界。

Expanded 和 Flexible?

Expanded 必须撑满。Flexible 可以留空。

怎么知道爸爸给了多少?

LayoutBuilder。

10

热重载和编译模式

▼

30 秒版

Debug 才能热重载,改界面马上能看,状态还在。改 initState、改全局初值,热重载救不了,得热重启。

线上包是提前编成机器码的,快,但不能热重载。测卡不卡,只用 Profile,Debug 看了不算。

追问

热重载和热重启?

一个换代码留状态,一个重跑,状态没了。原生壳都不用重编。

Debug 为啥又卡又大?

边跑边编,检查也开着。不代表线上。

混淆?

打包时打开就行。崩了用符号表还原。

你用哪一版?

说真实版本。升级常见就是样式变了、老 API 废了。

+

加分闪卡

▼

短问短答

final 和 constfinal 运行期赋一次;const 编译期常量,同值同实例,const Widget 能跳过重建。
空安全 / late类型默认不可空;late 延迟初始化,用前没赋值会抛错,也能做懒加载。
mixin / extends / implementsextends 单继承;implements 只要接口;mixin 横向复用代码,后 with 的覆盖前面的。
extension给已有类型加方法,静态解析,不能加字段。
Dart 3 新特性sealed class 加 switch 穷举、Record、模式匹配解构、class modifiers。
GC分代:新生代复制回收短命对象,老生代标记清除;每个 Isolate 独立堆。
== 和 identical== 可重写比值,identical 比引用;重写 == 必须重写 hashCode。
网络层dio 拦截器做 token、日志、重试,CancelToken,统一错误码;model 用 json_serializable 或 freezed。
屏幕适配逻辑像素天然适配;按稿等比用 screenutil;文字跟随系统缩放别硬锁。
国际化 / 主题arb 加 gen-l10n;ThemeData 加 ThemeExtension 自定义 token。
测试单测纯逻辑;Widget test 用 pumpWidget 加 find;integration_test 真机;mock 用 mocktail。
Widget 树太深?组合优于继承是 Flutter 哲学,层数不是问题,影响性能的是重建范围和布局复杂度。