☰
Flutter混合开发:通道契约、引擎复用与页面栈治理实战
2026/10/26 3:27:53 网站建设 项目流程

1. 先把“混合”的边界划清楚:Flutter 在既有工程里的真实位置

Flutter 原生混合开发这件事,真正难的部分从来不是"能不能跑起来"。跑起来只需要半天,难的是半年后你回头看这套东西,还能不能维护、能不能定位问题、能不能在不惊动原生团队的前提下迭代页面。我见过太多项目在技术选型会上把混合开发描述成"新页面用 Flutter 写,老页面不动",听起来像零成本增量,落地之后才发现成本全在那些没被写进 PPT 的地方:引擎的生命周期谁管、两套导航栈谁说了算、内存峰值涨了多少、返回键由谁拦截、崩溃栈里一半是 C++ 符号没法看。

所以在动手写第一行 Dart 之前,我更愿意先花时间把边界画清楚:哪些页面进 Flutter,哪些坚决不进;Flutter 模块在工程里以一个什么形态存在;原生侧要暴露哪些能力给 Dart;这些能力是同步还是异步、是一次性还是持续流。这四个问题回答不清楚,后面所有章节里的坑你都会一个不落地踩一遍。

1.1 add-to-app 的两种接入形态和它们各自付的代价

混合接入本质上只有两条路。第一条是源码依赖:把 Flutter 模块当成一个普通的子工程,原生工程通过构建脚本在编译期把它拉进来,Dart 代码和原生代码放在同一个仓库里,一起提测、一起发版。第二条是产物依赖:Flutter 模块单独构建成 Android 的 AAR 或者 iOS 的 framework,原生工程按版本号引用,两边可以独立发版。

源码依赖的好处是改起来痛快,Dart 侧改一行、原生侧改一行,一次编译全都能验证,调试链路是通的。代价是构建时间会明显变长,原生团队每次拉代码都得等一次 Flutter 构建;而且一旦 Flutter 侧引入了不兼容的依赖升级,原生团队的构建会直接红掉。

产物依赖的好处是隔离干净,两边各管各的发版节奏,原生工程不需要装 Flutter SDK。代价是调试链路被切断,Dart 代码改动必须重新出产物,热重载基本用不上;而且产物有三个变体(debug、profile、release),版本管理一旦混乱,出现"我本地是好的,打包就白屏"这种事几乎是必然的。

我自己的经验是:项目早期用源码依赖,进入维护期再切产物依赖。早期迭代频繁,构建慢一点无所谓,调试顺畅比什么都重要;等页面稳定了、改动频率降下来,再切产物依赖换取隔离性和构建速度。中途切换的成本主要在 CI 脚本,代码本身不用动。

1.2 FlutterActivity 与 FlutterViewController 背后发生了什么

很多人把FlutterActivity当成一个普通页面来理解,这是后面一系列问题的源头。它不是一个页面,它是一个引擎的宿主容器。真正干活的是FlutterEngine,它里面跑着 Dart VM、装着你的 Dart 代码快照、维护着 Flutter 自己的渲染树和 Navigator 栈。FlutterActivity干的事是把引擎渲染出来的纹理贴到屏幕上、把触摸事件转发进去、把宿主的生命周期同步给引擎。

Android 侧的FlutterActivity默认会在onCreate里创建一个新的引擎(除非你显式指定缓存引擎),这意味着每次跳转到一个 Flutter 页面,如果不管控,就会新建一个引擎。引擎的创建成本是几百毫秒级别的,同时内存占用也是实打实的。iOS 侧的FlutterViewController同理,默认构造会拉起一个新的引擎。

这个认知直接决定了两件事的做法。第一,引擎必须被当作有生命周期的资源来管,而不是页面的附属品。第二,页面和引擎不是一对一的关系,一个引擎可以承载多个页面,前提是你的导航逻辑设计得住。

1.3 选型对照:什么时候值得引入 Flutter 模块

场景特征推荐形态主要理由
新增一块独立业务,与老页面交互少独立 Flutter 容器页边界清晰,回退成本最低
需要频繁改 UI、多端一致要求高源码依赖 + 共享引擎迭代快,样式统一
只是三五个静态页面不建议引入引擎内存开销大于收益
强依赖原生控件(相机预览、地图、WebView)谨慎评估 PlatformView 成本视图合成会带来性能损耗
团队无 Dart 经验且只有一两个人维护不建议引入出问题没人能定位

这张表里最容易被忽略的是最后一行。技术方案的成本从来不只是运行时成本,还有人员成本。混合工程的排查链路比纯原生长得多,一个崩溃可能涉及 Dart、平台通道、原生、引擎四层,如果团队里没有一个人能把这条链路走通,出了问题就只能靠猜。

2. 平台通道:把原生能力做成一份能长期维护的契约

通道(Channel)是混合开发的主动脉,也是绝大多数"诡异 bug"的诞生地。我见过的通道问题,九成不是 API 用错了,而是约定没定清楚:通道名随手起、错误码各写各的、返回值类型在两端理解不一致、回调时机没有明确说明。这些问题在单次调用时都不会暴露,一定要等到并发、弱网、页面快速切换的时候才集中爆发。

我现在的做法是把通道当成一份对外 API 来设计:统一前缀、统一错误码、统一返回结构、在 Dart 侧包一层强类型的封装类,原生侧的实现类只对外暴露业务语义。这样即使后面换人接手,看 Dart 侧的封装类就知道这个通道能干什么、会返回什么、会抛什么错。

2.1 MethodChannel 的线程模型是第一个雷区

MethodChannel在 Android 上的默认行为是:原生侧的 handler 在平台主线程被调用。也就是说,你在 handler 里做任何耗时操作,都会直接卡住 UI。更麻烦的是结果的回复——在较早的实现里,result.success()必须在主线程调用,如果你在子线程算完直接回,行为是不确定的,有时候能过,有时候抛异常。

现在 Android 侧支持给通道指定TaskQueue,通过setTaskQueue让 handler 在后台线程执行,回复也可以在任意线程完成。这是个很实用的能力,但要注意:一旦切到后台线程,你访问任何 UI 相关的对象都必须自己切回主线程,包括Activity、View、FragmentManager。

iOS 侧更简单粗暴,通道的 handler 默认就在主线程(platform thread)上执行,没有后台队列的概念。所以如果你在 iOS 的 handler 里同步读一个大文件,界面会直接卡住。正确做法是丢到自己的并发队列,算完之后DispatchQueue.main.async回主线程再调result。

一个典型的 Dart 侧封装长这样:

class DeviceInfoBridge { static const MethodChannel _channel = MethodChannel('app/bridge/device_info'); /// 明确说明:该方法在原生侧可能耗时,调用方不要在主 isolate 里同步等待 static Future<Map<String, dynamic>?> fetchSummary() async { try { final result = await _channel.invokeMethod<Map>('fetchSummary'); if (result == null) return null; return Map<String, dynamic>.from(result); } on PlatformException catch (e) { // 错误码由原生侧约定,不要在这里吞掉异常 throw BridgeException(e.code, e.message ?? ''); } } }

Kotlin 侧对应实现,注意线程切换的位置:

class DeviceInfoHandler( private val context: Context, private val executor: ExecutorService ) : MethodChannel.MethodCallHandler { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { if (call.method != "fetchSummary") { result.notImplemented() return } // 计算丢到后台,回复切回主线程 executor.execute { val payload = runCatching { collectSummary(context) } Handler(Looper.getMainLooper()).post { payload.onSuccess { result.success(it) } .onFailure { result.error("E_COLLECT", it.message, null) } } } } }

这段代码里有两个刻意的设计。第一,executor是外部注入的,不是内部新建的,方便统一管理线程池的生命周期,页面销毁时能关闭。第二,异常被包成约定的错误码E_COLLECT,而不是把异常信息直接塞进 message 里——错误码是给 Dart 侧做分支判断用的,message 是给人看的,两者不能混。

2.2 序列化边界上最容易出错的几类值

StandardMethodCodec支持的是一组固定类型:null、bool、num、String、Uint8List、List、Map。注意这里的num是个坑——Dart 里的int在 Android 侧可能被映射成Integer也可能被映射成Long,取决于数值大小;而在 Dart 侧强转as int时,如果原生传过来的是个Double(比如1.0),会直接抛类型错误。

我踩过最隐蔽的一次是金额字段。原生侧从数据库读出来是BigDecimal,为了省事直接toDouble()传了过去,Dart 侧写的是json['amount'] as int,运行时炸掉。更隐蔽的是,某些情况下它又"能跑"——因为那个值恰好是整数,平台层自动转成了整型。于是同一个字段,金额大的时候崩,金额小的时候正常,测试全过,线上偶发。

类型风险表现规避手段
int / double 混淆偶发类型转换异常约定金额统一用字符串或分为单位的 int
Long 精度丢失大数变成负数或截断超过 2^53 的整数用字符串传
自定义对象直接传会报 unsupported一律转成 Map,并在 Dart 侧做显式解析
可空性Dart 端空安全编译不过原生侧明确"永远不返回 null"或"可能为 null"
二进制数据用 List 传效率极低用 Uint8List 走零拷贝路径

还有一条经验:跨通道传的数据,字段名和类型应该有一份共享的约定文档,哪怕只是一张表格贴在 Wiki 上。我见过两次因为原生侧改了字段名但 Dart 侧没跟,结果功能静默失效——不报错,只是拿不到值,返回了 default。

2.3 EventChannel 管连续数据,别用它传一次性结果

EventChannel是给持续数据流准备的:传感器、定位、下载进度、蓝牙状态。它的语义是"我订阅了,你持续推给我,我取消订阅你就停"。用在一次性请求上会带来两个问题:一是订阅和取消的时机难以把握,二是同一通道被多个页面同时监听时会互相干扰。

一个典型的错误写法是:页面 A 订阅了某个EventChannel,页面 B 也订阅同一个,然后页面 A 销毁时取消了订阅,页面 B 就再也收不到数据了。原因是底层onListen和onCancel的配对是靠引用计数的,如果原生侧没有正确实现计数,第一个取消就会把流关掉。

class LocationStream { static const EventChannel _channel = EventChannel('app/stream/location'); static Stream<Map<String, dynamic>> watch() { return _channel .receiveBroadcastStream() .map((event) => Map<String, dynamic>.from(event as Map)) .handleError((Object e) { // 不要把错误吞掉,交给上层决定是否重试 throw e; }); } }

原生侧的实现必须老老实实计数:

class LocationStreamHandler(private val context: Context) : EventChannel.StreamHandler { private var sink: EventChannel.EventSink? = null private var listenerCount = 0 override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { listenerCount++ sink = events if (listenerCount == 1) startLocationUpdates() } override fun onCancel(arguments: Any?) { listenerCount-- if (listenerCount <= 0) { listenerCount = 0 stopLocationUpdates() sink = null } } }

还有一个细节:EventSink的调用也必须在主线程。如果在后台线程回调sink.success(),Android 侧会抛异常,iOS 侧则可能造成 UI 无响应。这一点文档里写得不算显眼,但踩一次就记住了。

2.4 一次“金额莫名其妙变成 0”的排查链路

我完整记录过一次排查,因为它太典型了,值得原样复现。

现象:订单详情页在 Flutter 侧展示的实付金额偶尔是 0,刷新一次又正常。只在低端机上出现,且和网络快慢有关。

第一步,确认数据源。在 Dart 侧把通道返回的原始 Map 打印出来,发现字段存在,值是0,不是 null,也不是缺失。说明通道确实返回了 0。

第二步,确认原生侧。在 Kotlin 的 handler 里打印同一个字段,值是正确的一千多。说明问题出在通道传输或者 Dart 侧解析。

第三步,检查类型。原生侧传的是Double,Dart 侧解析用的是(map['amount'] as num).toInt()。到这里看起来没问题。

第四步,发现真正的原因。原生侧的那个值是异步算出来的,handler 里用了result.success()在后台线程直接回复。在低端机上,主线程繁忙时这个回复被延迟,而页面侧有一个"超时兜底"逻辑,200 毫秒没收到就展示默认值 0。所以不是数据错了,是时序错了。

修复:把回复切回主线程,同时去掉那个 200 毫秒的兜底逻辑,改成 loading 态。兜底超时这种设计在通道调用里非常危险,因为它把"慢"和"没有"混为一谈了。

这次之后我给自己定了一条规矩:任何通道调用都不设超时兜底,要么等到结果,要么由原生侧明确返回失败。如果真的需要用户体验上的保护,用 loading 骨架屏,不要用默认值。

3. 页面栈才是混合开发真正的深水区

通道的问题再麻烦,本质上是局部问题,改一个通道不影响全局。页面栈的问题不一样,它是全局的、结构性的,一旦设计错了,后面每加一个页面都要在错误的架构上打补丁。

根源在于:原生有自己的导航栈(Android 的FragmentManager/Activity栈,iOS 的UINavigationController),Flutter 也有自己的导航栈(Navigator+Route)。两套栈各自认为自己是老大,页面之间的跳转、返回、传参、状态恢复全都会打架。

3.1 两套导航栈打架时的三种治理思路

思路一:Flutter 只管单页。每个 Flutter 页面在原生侧对应一个容器(FlutterActivity或FlutterFragment),页面之间的跳转全部走原生路由。Flutter 内部的Navigator.push被禁止,或者被拦截后转成原生跳转。这种方案最保守,原生完全掌控局面,回退成本最低。缺点是 Flutter 页面的转场动画、手势返回都得在原生侧重做,体验上会有割裂感。

思路二:Flutter 管一段连续流程。比如下单流程有五步,这五步全在 Flutter 里,原生只负责"进入下单"和"下单完成返回"两次交互。Flutter 内部用Navigator自由跳转,只在边界上和原生同步。这种方案在实践中用得最多,因为它把"栈打架"的范围压缩到了可控的边界上。

思路三:共享栈。原生栈里插入一个 Flutter 容器,Flutter 容器内部再模拟出一套页面栈,返回键先交给 Flutter 处理,Flutter 处理不了再交给原生。这条路体验最统一,但实现成本最高,需要处理页面快照、状态恢复、手势冲突、内存回收等一堆细节。业界有开源的混合栈方案在做这件事,思路基本都是"用一个引擎承载多个虚拟页面,原生侧维护一个页面容器栈"。

我的建议是:没有明确的统一体验诉求,就别碰思路三。思路二能覆盖八成以上的业务场景,剩下的两成用思路一兜底。

3.2 引擎复用:省的是内存,花的是复杂度

引擎的开销有多大,值得单独说。一个刚启动的引擎,内存占用在几十 MB 这个量级,具体数字取决于 Flutter 版本、是否开启 Impeller、加载了多少资源。如果每次进 Flutter 页面都新建引擎,用户来回切五个页面,内存就能翻倍,低端机上直接触发系统回收。

Android 侧的标准做法是FlutterEngineCache:应用启动时预创建一个引擎放进去,后面所有FlutterActivity都从这个缓存取。

// Application.onCreate 中预创建 val engine = FlutterEngine(this).apply { dartExecutor.executeDartEntrypoint( DartExecutor.DartEntrypoint.createDefault() ) } FlutterEngineCache.getInstance().put("main_engine", engine) // 跳转时复用 startActivity( FlutterActivity .withCachedEngine("main_engine") .build(this) )

iOS 侧对应的是持有一个FlutterEngine实例,创建FlutterViewController(engine: engine, nibName: nil, bundle: nil)。iOS 上还有FlutterEngineGroup,它能让你创建多个引擎但共享底层的快照和资源,多引擎场景下的内存开销会小很多。

复用带来的复杂度在于:引擎是有状态的。它的Navigator栈、全局单例、State对象都会跨页面存活。用户从页面 A 退出,再进页面 B,如果 A 里的状态没清干净,B 就可能看到脏数据。我遇到过一次,用户退出登录后又进来,看到的还是上一个账号的购物车——因为购物车的数据缓存在一个 Dart 侧的单例里,引擎没销毁所以单例没重置。

处理办法:在引擎层面定义一个"会话重置"的入口,登录态变化时主动清空所有全局状态。不要指望引擎销毁来清理,因为引擎本来就不该频繁销毁。

3.3 返回键、生命周期与状态恢复的联动

Android 的物理返回键是混合开发里最容易被忽略的交互。默认情况下,FlutterActivity会拦截返回键并转发给 Flutter 的Navigator,Flutter 处理不了再回传给原生。听起来很顺,但实际用起来经常出问题。

最常见的是双栈同时响应。用户在一个 Flutter 页面上按返回,Flutter 的Navigator弹了一个页面,同时原生的FragmentManager也 pop 了一次,结果用户连退两页。

排查这类问题,我的做法是在三个位置都打日志:原生的onBackPressed、Flutter 的didPopRoute、Navigator的pop回调。看日志顺序就知道是谁先动的手。

class AppShell extends StatefulWidget { const AppShell({super.key}); @override State<AppShell> createState() => _AppShellState(); } class _AppShellState extends State<AppShell> with WidgetsBindingObserver { @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); } @override Future<bool> didPopRoute() async { final navigator = Navigator.of(context); if (navigator.canPop()) { navigator.pop(); return true; // 明确告诉原生:我处理了 } return false; // 交还给原生 } @override void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } }

生命周期方面还有一个隐蔽的坑:引擎的生命周期和容器的生命周期不同步。缓存的引擎在页面销毁后依然存活,它的AppLifecycleState不会因为容器销毁而变成paused。如果 Dart 侧有轮询或者定时器,页面都关了它还在跑,白白耗电。解决办法是在容器销毁时通过通道显式通知 Dart 侧暂停,或者在 Dart 侧用WidgetsBindingObserver结合页面可见性自己判断。

3.4 一次白屏十分钟的现场排查记录

现象:某个 Flutter 页面在部分机型上白屏,没有任何报错,日志也是干净的。

第一步,排除渲染。在 Dart 侧main()里打日志,日志正常输出,说明 Dart 代码起来了,引擎是活的。问题在渲染或者视图层级。

第二步,排除 Flutter 侧异常。用FlutterError.onError兜住所有异常,没有捕获到。用debugPaintSizeEnabled打开布局边界,发现在 debug 包上是正常的。关键线索:只有 release 包白屏。

第三步,怀疑引擎变体混用。检查产物目录,发现原生工程里引用的 Flutter framework 有两个来源:一个是早期手工拷贝进去的 debug 版本,一个是构建脚本产出的 release 版本,链接顺序不确定。release 包在某些机型上链接到了 debug 引擎,而 debug 引擎没有 AOT 快照,找不到 Dart 代码,于是白屏。

第四步,验证。清掉所有历史残留产物,重新走一次完整构建,白屏消失。

根治手段:把 Flutter 产物目录从版本库里彻底移出去,写进.gitignore,构建时由脚本统一生成。同时在 CI 加一步检查,扫描原生工程的依赖配置里有没有指向本地产物路径的引用,有就报错。这一类问题靠人工 review 是防不住的,必须靠工具卡住。

4. 原生视图嵌入:PlatformView 该用在哪儿、不该用在哪儿

PlatformView是混合开发里性能争议最大的部分。它的作用是让 Flutter 页面里能嵌一个原生控件——地图、相机预览、原生播放器、第三方 SDK 的 UI。听起来很美好,实际用起来会发现滚动卡顿、触摸延迟、内存上涨。

4.1 视图合成模式下为什么触摸和滚动会“慢半拍”

要理解这个,得先知道 Flutter 的渲染路径。Flutter 自己画所有东西,把结果交给系统的图形层呈现。引入PlatformView之后,原生控件的绘制结果需要和 Flutter 的画布合到一起,这个合成过程在不同的平台和不同的实现模式上代价不一样。

Android 上早先用的是虚拟显示的方式:原生控件画到一个离屏的画布上,再把这张图当纹理贴进 Flutter 的图层树。好处是性能相对好,坏处是触摸事件需要从 Flutter 侧反向映射回原生控件,键盘输入、无障碍、手势识别的行为都会打折扣。

后来有了混合合成模式:原生控件直接放在 Flutter 视图的上层,由系统负责合成。触摸不需要映射,行为更接近原生,但代价是每次 Flutter 重绘,上层原生视图的位置都要重新同步,滚动列表里大量使用时会明显掉帧。

iOS 上的实现路径不同,但结论类似:PlatformView在列表里滚动会明显比其他内容更耗。

4.2 判断标准:这三个条件不满足就别用 PlatformView

我给自己定的判断标准有三条,全满足才用:

  • 原生控件的功能无法用 Flutter 复刻。地图、相机预览、某些硬件相关的 SDK 属于这一类。如果只是一个样式特殊的列表、一个自定义的图表,用 Flutter 画,不要用原生控件。
  • 这个控件不需要高频出现在可滚动列表里。整页一个地图可以,列表里每一行嵌一个地图就不行。
  • 这个控件的交互方式足够简单。如果原生控件自己带复杂手势(比如地图的双指缩放、旋转),和 Flutter 的滚动手势容易冲突,处理起来非常痛苦。

反过来,以下几个场景我强烈建议不要用PlatformView:

场景更好的替代
展示一个原生风格的按钮或输入框用 Flutter 组件复刻样式
列表项里嵌视频缩略图用 Flutter 的图片组件加载首帧
内嵌 WebView 展示富文本优先考虑富文本渲染方案,实在要用再上 PlatformView
弹窗、Toast 之类的轻提示用 Flutter 的 Overlay 实现

4.3 必须用时的几条降损手段

如果确实绕不开,有几个手段能明显降低损耗。

控制存活范围。PlatformView一旦创建,视图层级就一直存在。在页面切换时主动销毁它,回来时重建,比一直挂着更划算。Flutter 侧可以通过条件渲染控制,配合AutomaticKeepAliveClientMixin的反向使用。

避免在滚动容器里频繁创建销毁。列表里如果有PlatformView,一定要固定它的数量和位置,不要让它跟着ListView.builder动态创建。我见过一个实现,列表滚一屏创建三个地图实例,滚回去再创建三个,内存直接飙到几百 MB。

限制原生视图的刷新频率。如果原生控件内部有动画或者高频刷新,它会持续触发 Flutter 的合成。能在原生侧暂停的就暂停,页面不可见时停掉所有动画。

用 profile 模式量,不要用 debug 模式判断。debug 模式下PlatformView的性能表现和 release 差距巨大,用 debug 得出的结论没有任何参考价值。测量口径统一用 profile 包,看 raster 线程和 UI 线程的耗时分布。

5. 构建、调试与发版环节的隐性成本

前面几章讲的都是运行时的东西,但真正常常让人加班到半夜的,是构建和发版环节。这部分内容很少有人系统整理,因为每家工程结构都不一样,但踩的坑高度重合。

5.1 目录结构与依赖边界怎么划

我倾向于这样的结构:

repo/ app-native/ # 原生主工程 feature-flutter/ # Flutter 模块(独立可运行的壳 + 业务代码) shared-contract/ # 通道契约文档与错误码常量

feature-flutter必须保持能独立运行。这一点非常重要:只有能独立跑,Flutter 开发者才能不依赖原生工程做 UI 开发、才能写 widget 测试、才能快速验证。为了做到这一点,壳工程里要提供通道的 mock 实现,把原生能力用假数据替代。

依赖方向上,原生工程依赖 Flutter 模块,Flutter 模块不能反向依赖原生工程的任何代码。这个约束要靠构建配置卡住,不能靠自觉。一旦出现反向依赖,编译顺序就会变成循环,构建系统会给你一堆看不懂的报错。

5.2 热重载在混合工程里的可用边界

热重载在混合工程里是有条件可用的,条件挺苛刻:

  • 必须是 debug 包,且 Flutter 引擎处于可调试状态
  • 需要执行flutter attach,让调试器连上已经在运行的 App
  • 初次连接时可能需要触发一次页面跳转,让引擎完成初始化

常见的失败情况:flutter attach卡在 "Waiting for a connection",原因是设备上跑的是 release 包,或者端口被占用,或者 App 已经启动了引擎但调试服务没有开。我的经验是,先确保 App 是通过flutter run或者带调试参数的构建装上去的,再 attach,成功率最高。

另外要注意:热重载只能改 Dart 代码。改了通道名、改了原生侧 handler、改了资源文件,都必须完整重新构建。这一点在混合工程里尤其容易忘,因为你在一个仓库里同时改两边,改完顺手按了个热重载,发现没生效,然后开始怀疑人生。

5.3 包体与启动耗时的实测口径

包体方面,Flutter 会给 Android 增加几 MB 的 so 库和资源,给 iOS 增加十几 MB 的 framework(取决于是否开启位码、是否裁剪)。优化手段有限,主要是三条:分离调试符号、按架构分包、开启图标裁剪。

flutter build apk --release \ --obfuscate \ --split-debug-info=build/symbols \ --tree-shake-icons

注意--obfuscate之后崩溃栈会被混淆,必须保留 symbols 目录,否则线上崩溃无法还原。我见过一次线上崩溃,symbols 被 CI 当成临时产物清理了,那批崩溃到现在都没法定位。

启动耗时的测量,口径要统一。我一般测三个数:冷启动到首帧、进入 Flutter 页面到 Flutter 首帧、页面可交互时间。第一个数用系统工具测,后两个数在 Dart 侧打点。

void main() { final sw = Stopwatch()..start(); runApp(const App()); WidgetsBinding.instance.addPostFrameCallback((_) { // 首帧渲染完成 debugPrint('flutter_first_frame=${sw.elapsedMilliseconds}ms'); }); }

如果 Flutter 首帧时间明显偏长,先看是不是每次都在新建引擎。缓存引擎能省掉绝大部分启动时间,这是投入产出比最高的一项优化。

6. 上线前我会逐条过的检查清单

这套东西用了几年,我把它固化成了一张清单,每次发版前过一遍,能挡掉大部分低级事故。

通道层:所有通道名是否带统一前缀、错误码是否在两端一致、所有耗时操作是否切了线程、result是否只在主线程调用、是否有通道没做notImplemented兜底。最后这条容易漏,原生侧没实现的 method 如果直接抛异常,Dart 侧会收到一个不太友好的错误,很难看出是"没实现"。

引擎层:是否使用了缓存引擎、引擎的销毁时机是否明确、登录态变化时是否重置了全局状态、Dart 侧的定时器和监听是否随页面销毁而停止。

导航层:返回键的归属是否明确、Flutter 内部跳转是否会误触发原生 pop、页面传参是否有类型约定、深度链接进来时两套栈是否能正确对齐。

构建层:产物目录是否已从版本库移除、debug 和 release 产物是否可能混用、symbols 是否被正确归档、CI 是否有产物来源检查。

性能层:PlatformView的使用位置是否都在非滚动区域、是否有列表项嵌了原生控件、profile 包下的帧耗时是否达标、内存曲线在反复进出 Flutter 页面后是否平稳。

最后再分享一个我觉得最值钱的经验:混合工程里,能少一个通道就少一个通道。通道越多,两端的契约就越多,出问题的面就越大。有些团队喜欢把原生能力拆得很细,一个功能一个通道,结果通道数量到几十个,维护成本高得离谱。更好的做法是按业务域收口,一个域一个通道,通道内部用 method 名区分,这样契约数量可控,改起来也方便。

踩过的坑多到可以写一本书,但真正让我少走弯路的,是把"这东西半年后还要不要维护"当成第一优先级来考虑。能跑起来只是起点,能长期跑下去才是本事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询