那天晚上我盯着小米后台的数据,看了很久。
批注高亮错位的修复包过审了,挺快,一天就过了。但我点开后台看了一眼更新数据,心里凉了一截。
大部分用户根本没更新。
我盯着那个数字盯了好一会儿,突然有点恍惚。
往前推几个月,我们公司的产品「角端」还只跑在网页端。那时候我对发版这件事是没有焦虑的。用户报 bug,我当天改完,凌晨推一下,第二天起来打开页面拿到的就是修好的版本。整个闭环不超过 24 小时,干净利落。
后来我们决定做 App。Flutter 写的,从设计到开发上线,前端就我一个人。
上线第一周,收到了一个批注高亮错位的反馈。
我看了一眼,问题不复杂,高亮定位逻辑有个边界没处理好,当晚就改完了。
然后我开始打包、提交商店。
小米审核挺给面子,一天就过了。但过审之后,我看了一眼后台,那个一直跑在心里的爽劲儿就停了。
一个阅读 App,用户打开就是为了看书。谁会天天去检查有没有新版本?而且这个 bug 也不致命,不影响阅读,用户甚至意识不到这是个 bug,只是觉得「这个高亮怎么有点歪」。
我也不可能为了一个高亮错位弹一个强制更新弹窗,逼用户重新下载 85 MB 的整包。就为了治一个高亮歪了?这个体验本身的伤害,比 bug 还大。
就这么一个小问题,我一晚上就修完了,但一周之后还在线上飘着。
这件事最让我难受的,不是 bug 没修干净,是我修干净了用户却看不到。
代码改完了,审核也过了。但用户更不更新,不是我能决定的事。
在网页端的时候,修复是我一个人能闭环的事。到了 App 端,修复变成了我、商店、用户三方的协作。我一个人撑整个 App 端的开发,没有专门的发版节奏,也没有运营团队去推更新率。这个流程对我来说不是有点麻烦,是根本跑不通。
那段时间我开始认真琢磨一件事,我需要热更新。
不是「要是有就好了」那种程度,是「不解决我没法继续」的程度。
如果你也是一个人或者两三个人在维护 Flutter App,大概率会撞到同一件事。bug 修完了,但用户还在旧版本里。这种事一两次能忍,时间长了真的很折磨人。
决定要做之后,第一步当然是先看市面上有什么现成的。
CodePush 我第一时间就排掉了。
做过 React Native 的人对 CodePush 都不陌生,微软维护,几乎是 RN 项目的标配。但它的原理是替换 JS Bundle,而 Flutter 的 release 产物是 AOT 机器码,完全不是一个体系。直接用是别想了,能借的只有思路。
然后我认真研究了 Shorebird。
Shorebird 的技术思路很明确。通过修改 Dart VM 让 Flutter 能以混合模式运行 AOT 代码和补丁代码,补丁差分下发,客户端合并后下次启动生效。架构很专业,团队也很靠谱,挑不出什么毛病。
但我最终没用,理由也都很现实。
补丁分发要走 Shorebird 自己的云服务,国内网络环境不太可控。自托管方案当时还不够成熟,文档也没跟上。每个月一笔固定开支对独立开发者来说也是个心理负担,倒不是付不起,是觉得不踏实。最关键的是,我的数据、我的补丁、我的分发链路全要架在别人的基础设施上,这一关我心里始终过不去。
先说清楚,我不是说 Shorebird 不好。它可能是目前 Flutter 热更新里最正经、最完整的方案之一。如果你的团队在海外、有预算、能接受云服务,Shorebird 大概率就是最优解。
只是对我的场景,国内用户、小团队、希望自己控制整条链路,它不太合适。
国内社区的方案我也翻了一圈。腾讯的 Tinker 不支持 Flutter。一些开源项目要么停更,要么停在 demo 阶段,README 写得很漂亮,issue 区里却挤着不少生产环境跑不通的反馈。
这些项目背后都是有人花时间花心思的,每一个都不容易。但从结果上看,它们没法让我直接拿来用。
兜了一圈,又回到了起点。
绕回来那天我开始想另一个问题。
Flutter Android 的编译产物,到底长什么样?
不是从架构图上看,是真的把 release APK 解压了,一个文件一个文件翻。
翻完之后我发现,Flutter 在 Android 上的 AOT 产物其实非常清晰,核心就是一个文件,
text
libapp.so
Dart 代码编译之后的机器码,最终会以 libapp.so 的形式打进 APK 里。
那一刻我冒出一个特别朴素的想法。
如果我能在客户端把这个 libapp.so 替换成新版本,下次启动让 Flutter 加载新的 so,是不是就能完成一次 Dart 层修复?
听着像不像废话?我当时也觉得这个想法过于朴素了,肯定早被人想过。
一查,果然。
2019 年腾讯团队就用 Tinker 做过 libapp.so 的差分下发,京东云也写过非常详细的技术探索。原理上,这条路是跑得通的。
但这些方案有几个共同的问题。
它们要么依赖 Tinker 这种 Android 原生热修复框架,要么需要改 Flutter Engine 源码,要么更像一篇技术验证文章,而不是 Flutter 开发者能直接接进去的插件。生产兜底也不全,签名验证、版本绑定、启动失败回滚这些,要么没有,要么得自己再补一遍。
坦率的讲,路是通的,只是还没被打包成一个 Flutter 开发者能直接 pub add 用起来的东西。
flutter_patcher 想做的就是这一件事。
一个不依赖 Tinker、不改 Flutter Engine、也不绑定云服务的 Flutter Android 热更新插件,专门用来在 Android 上替换 libapp.so,让 Dart 层修复在下次冷启动生效。
这里也先讲清楚,它不是 Web 那种运行中立即替换,也不是 debug 下的 Hot Reload。它的生效时机是下次冷启动。
但对线上 bug 修复来说,这一步已经足够有价值了。
整套方案我给自己定了一条原则:实现链路要足够简单,复杂度要控制在个人项目可长期维护的范围内。
所以项目接入时,不需要额外部署一套热更新平台。通常只需要一个补丁检查 HTTP 接口,以及一个用于分发补丁文件的 CDN。
flutter_patcher 没有实现自定义虚拟机,也没有修改 Flutter Engine,更没有绑定任何第三方云服务。它只聚焦一件事:在 Android 侧替换 libapp.so,并围绕这件事补齐校验、分发、加载和回滚链路。
整体流程可以拆成四步:
- 生成补丁
- 分发补丁
- 客户端应用补丁
- 启动失败自动回滚
先说补丁生成。
这一步通常放在 CI 中完成。每次构建新版本时,从 release APK 中按 ABI 提取新的 libapp.so,然后生成对应的 manifest。
manifest 里记录的信息包括:补丁版本、目标 APK 的 versionCode、ABI、libapp.so 的 MD5、补丁下载地址,以及可选的 Ed25519 签名。
当前版本主要支持 full patch,也就是直接下发完整的 libapp.so。
这不是体积最优的方案,但实现和验证路径更短:客户端不需要执行差分合并,文件校验也更直接,更适合作为第一版生产链路。差分补丁已经在计划中,后续会基于 bsdiff 继续推进。
如果开启签名,服务端会对补丁 MD5 生成 Ed25519 签名。私钥只保留在 CI 或服务端,客户端只内置公钥。客户端下载补丁后,除了校验文件完整性,也可以确认补丁来自可信来源,降低补丁被篡改或替换的风险。
分发部分相对简单。
业务侧只需要提供一个 HTTP 接口,让客户端在启动时查询当前版本是否存在可用补丁。有补丁时返回 manifest,没有补丁时返回空结果。补丁文件本身可以直接放在 CDN 上。
这套方式可以复用现有业务后端,不需要单独部署热更新平台。
我选择自托管,也是因为补丁分发本身并不复杂。对于这类能力,把 manifest 生成、补丁存储和下发策略控制在自己手里,会更容易排查问题和控制风险。
回到客户端。
客户端拿到 manifest 后,会按下面的顺序处理:
- 检查补丁是否匹配当前 APK 的
versionCode - 检查补丁是否在本地黑名单中
- 下载对应 ABI 的
libapp.so - 如果 manifest 提供了 MD5,则校验 MD5
- 如果 manifest 同时提供了签名,则校验 Ed25519 签名
- 校验通过后,将补丁写入应用沙箱
- 记录补丁 meta 信息
- 下次冷启动时加载新的
libapp.so
整个过程对用户无感知,不会弹出确认框,也不需要用户手动操作。
这里有一个比较重要的约束:补丁和宿主 APK 的 versionCode 是强绑定的。
也就是说,一份补丁只会作用于它声明支持的 APK 版本。用户升级 APK 后,旧补丁会因为版本不匹配而不再加载。
这个限制是为了避免宿主 APK 已经变化,但客户端仍然加载旧版本补丁,从而引入不确定行为。
热更新链路里,补丁下发只是其中一部分。更关键的是:补丁加载失败后,客户端能不能自动恢复。
如果一个有问题的补丁在启动阶段导致崩溃,而下一次启动仍然继续加载同一个补丁,应用就会进入重复崩溃状态。用户只能通过清除数据、卸载重装,或者等待应用商店版本更新来恢复。
所以 flutter_patcher 内置了启动熔断逻辑。
App 启动时会记录当前补丁的启动状态。如果检测到某个补丁导致启动失败,下一次启动会自动跳过该补丁,回退到 APK 内置的 libapp.so,并将这份补丁加入本地黑名单。
进入黑名单的补丁不会再次加载。
这部分实现并不复杂,但对生产环境很重要。它保证了最坏情况下,用户最多经历一次异常启动;再次打开 App 时,会自动回到 APK 内置版本。
再看补丁体积。
下面这组数据是 flutter_patcher 在角端实际跑出来的:
| 项目 | 大小 |
|---|---|
| 完整 APK(release) | 85.49 MB |
| arm64-v8a libapp.so | 11.06 MB |
| 热修补丁 libapp.so | 11.00 MB |
走应用商店更新,用户需要重新下载约 85 MB 的安装包,还要经过审核和用户主动更新。走 flutter_patcher,则是后台下载约 11 MB 的补丁文件,并在下次冷启动时生效。
11 MB 仍然不算小,因为当前下发的是完整 libapp.so,不是差分补丁。但这一版优先解决的是修复链路是否可控、可验证、可回滚。体积优化会在差分补丁阶段继续处理。
回到开头那个批注高亮错位 bug。
传统流程是这样的,改完代码、打包、提交审核、等审核通过、等用户看到更新、等用户愿意更新。每一步都不在开发者手里。
接入 flutter_patcher 之后,流程变成,
text
修复代码
↓
CI 生成补丁
↓
上传 CDN
↓
用户下次冷启动生效
关键不在于流程少了几步,是修复这件事重新回到了我自己手里。
再讲讲它能做什么、不能做什么。
flutter_patcher 能做的事情其实很窄,就是替换 Android 上的 libapp.so。所以它适合修 Dart 层的逻辑问题,比如某个页面状态展示错了、某个边界判断写歪了、某个交互细节不对。就是那种你自己看了都觉得不至于让用户重装一整个 APK 的小毛病。
至于 libapp.so 之外的东西,它就帮不上忙了。Android 原生代码改不了。Flutter Engine 动不了。插件的 native 侧、资源文件、Manifest、权限配置,统统不在它的射程里。iOS 目前也没支持。
还有一点别忘了,各家应用商店对动态下发代码的态度并不一致。真正接进去之前,最好按自己上架的渠道再确认一遍政策。这块我就不替你下结论了,每家平台的尺度自己拿捏。
Android平台有了这个方案,那 iOS 呢?
iOS 我还没做,原因也直接。
iOS 的 AOT 产物结构和 Android 不一样,App Store 对动态代码加载的审核也比 Android 严格得多。这块不是简单把 Android 方案移植过去就能解决的,需要单独研究。
如果你的核心需求是 iOS 热更新,flutter_patcher 现在帮不上忙。我自己也在慢慢看,但不想给一个我自己还没跑通的承诺。
如果有人在 iOS 侧踩过坑,非常欢迎来交流,我也是想听听你的思路。
最后讲讲还没做完的几件事。
差分补丁。现在走的是 full 模式,每次让客户端下载完整的 libapp.so,胜在简单,校验也省心。但 11 MB 这个体积我心里清楚还能再压。bsdiff 已经排进计划里了。
灰度。我现在推荐的做法是把灰度逻辑放在业务侧的补丁检查接口里,按用户 ID、设备、版本号、渠道这些条件判断要不要下发。能跑,但不省事。后面想在插件侧也提供一套标准做法。
Flutter 版本兼容。这条链路本身踩在 Flutter Android 启动流程和 libapp.so 加载路径上,每次 Flutter 大版本变动都得重新看一遍。CI 测试矩阵会继续补,目标是把主流 stable 都覆盖到。
这些事我也不知道什么时候能全部搞完。坦率的讲,开源项目最怕画饼,所以这里只列我真的在动的东西。
写这篇之前我犹豫了挺久。
我有时候觉得,写文章这件事比写代码要难得多。代码跑不起来,编译器会直接骂你。文章跑不到读者心里,没人会告诉你。
但我自己被「bug 修完了用户还在旧版本」困扰过,所以我相信肯定还有人在被同一件事烦恼。
flutter_patcher 不是炫技项目,也没打算替代谁。
如果你有预算、能接受云服务、团队在海外,Shorebird 依然是更完整的选择。
但如果你的情况和我类似,
- 国内用户为主
- 小团队或独立开发
- 不想依赖外部云服务
- 希望补丁分发链路自己控制
- 主要想快速修复 Dart 层问题
那 flutter_patcher 可能正好适合你。
项目目前还在早期,但核心链路已经在角端 Android 线上稳定运行了 2 个月,累计下发过 3 次补丁,回滚机制触发过 0 次。
如果你正在维护 Flutter Android App,也被同样的事卡住过,可以从 README 的 Quick Start 跑一个最小 demo。
Pub.dev:pub.dev/packages/fl...
GitHub:github.com/xuelinger23...
后面我会继续拆几个具体实现,
- Flutter Android 启动链路里如何替换 libapp.so
- 崩溃熔断状态机怎么设计
- Ed25519 签名验证链路怎么做
- full patch 和 bsdiff 差分 patch 的取舍
- 灰度发布应该放在插件侧还是业务侧
如果你也在折腾 Flutter 热更新,或者对 libapp.so 替换这条路有自己的想法,欢迎在评论区交流👇
