前言
Flutter 从某个版本开始,官方就已经支持了Android的动态下发,iOS由于苹果的限制,目前没有特别好的实现方式。现在master分支上最新的代码已经支持Android动态下发了,本篇文章基于v1.3.13。关于动态下发的相关代码可以见如下几个类:
官方方案现状
官方的动态下发其整体的思路就是替换Flutter的产物文件,并且与bsdiff进行结合减小产物的大小。
但是目前官方实现的这种方式过于鸡肋,基本不能直接拿来用,比如:
- url下载的格式固定,无法与现有的灰度系统进行结合。
- 自定义能力基本为零,不能干预patch的下载流程,安装流程等等。
- 如果有patch的话,每次启动都会去下载patch文件,导致重复下载,浪费数据流量。
- 只能基于dynamicRelease的buildType进行构建,且只能使用flutter命令构建,无法使用gradle进行构建。
- 对混合应用的patch构建支持基本为零。
- 只能支持Flutter相关产物,对于原生的产物不允许发生变化,如classes.dex等。这是必然的,除非加以改正,否则必然只能支持Flutter的产物。
- 缺乏patch签名校验,带来安全问题。
所以官方必须修改其方案,至少要以接口的形式暴露一些自定义的流程,如patch的url获取,patch的下载,patch文件的校验流程,patch的安装流程,可以适当进行扩展。否则现有的方案基本不可能直接进行使用。
官方方案更新流程
在我们调用 FlutterMain.startInitialization(context) 的时候,会去判断我们是否支持动态下发。
1 | Bundle metaData = null; |
它会从AndroidManifest.xml中读取metaData键为DynamicPatching的值,如果为true,就会新建一个ResourceUpdater对象,负责patch的下载,校验等等,后续只要该对象不为null,都会进入动态下发的流程,否则走apk释放流程。
如果下载模式是ON_RESTART或者ON_RESUME,就会执行下载。
这里需要注意如果下载模式是ON_RESUME时,则执行onResume回调时,也会执行下载。
1 | public static void onResume(Context context) { |
这里还有一个问题,就是无论patch是否下载成功了,每次执行startUpdateDownloadOnce的时候,都会重新去下载一遍,也就是说下载模式是ON_RESTART的时候,每次冷启动都会去下载一次patch,下载模式是ON_RESUME的时候,每次冷启动以及onResume回调的时候,都会去下载一次patch。这里至少要做一个文件重复的判断,如果要下载的文件md5与本地以及存在的文件md5一致,则不去重复下载。
此时如果安装模式是立即安装的话,即IMMEDIATE模式,则会阻塞等待下载完成,否则下次重新启动的时候安装。
也就是说要想使用官方的这种方式,首先需要在AndroidManifest.xml中声明 DynamicPatching=true
1 | <application> |
其中下载模式和安装模式也是静态声明在AndroidManifest.xml中,如果不声明,默认的安装模式为ON_NEXT_RESTART,即下次安装,默认的下载模式为ON_RESTART,即下次重启下载。对应在AndroidManifest.xml中的键名为PatchDownloadMode和PatchInstallMode,这也直接导致了无法动态变更某个patch的下载模式和安装模式,因为它是静态写死在AndroidManifest.xml文件中的。
这里需要明确一个问题,我们为什么希望能根据单个patch改变下载和安装模式呢?因为下发patch意味着bug,而有些致命的bug我们希望能及时得到更新,也就是立即安装,相当于启动的时候阻塞去下载安装,而对于一些一般的的问题,可能没那么紧急,只需要下次重启安装即可。
而下载的url则更是奇葩,内部写死url拼接规则,请问哪个公司的下载url是按照这个格式的?这就直接导致了无法与现有灰度系统和cdn进行结合使用。
1 | private String buildUpdateDownloadURL() { |
从代码中可以看出,下载的url前缀来自AndroidManifest.xml中键为PatchServerURL的metaData,最终的url为 PatchServerURL后面拼接versionCode.zip即为最终的下载url。这个操作可谓骚得无法拯救,简直智障。
具体的详细流程有兴趣可以查看代码,这里简要概括一下主流程。
- 初始化的时候判断是否配置了DynamicPatching=true,如果为true,则进入动态更新执行流程
- 执行patch的下载操作,其url为 PatchServerURL + “/“ + versionCode.zip,下载的临时文件为patch.zip.download,下载完成后,重命名为patch.zip.install
- 校验patch.zip.install文件中manifest.json中相关值,主要为buildNumber是否匹配,基线crc32校验是否匹配,其中crc32主要计算 isolate_snapshot_data, isolate_snapshot_instr, flutter_assets/isolate_snapshot_data 这三个文件,校验成功后,将patch.zip.install文件重名为patch.zip文件。
- 校验时间戳,此时的时间戳如果patch.zip文件存在,则会把patchNumber和patch.zip文件最后修改的时间一起加入进行计算,其整体格式为 res_timestamp-\$versionCode-\$lastInstallTime-\$patchNumber-\$patchFile.lastModifiedTime,如果过期了,则会进入patch重新释放的流程
- 如果patch.zip文件存在,则进入释放流程,释放的时候就按文件释放,如果文件名以.bzdiff40结尾,则先执行bspatch合成文件再释放,否则直接释放。此时patch.zip中只会包含改变的文件,对于没有改变的文件则从apk中进行释放。
- 从安装的apk文件中释放剩余文件,如果已经存在,则不释放。
- 重新创建最新的时间戳文件,表示最新文件释放成功。
- 进入so加载流程,如果释放的产物中包含so,则从释放的产物中进行加载,否则从apk安装时是否的so进行加载。
总的来看,如果要复用官方的这套流程,我们至少需要解决以下几个问题
- patch下载的url可自定义
- patch的下载模式和安装模式可根据patch文件的粒度动态变化
- patch防重复下载机制,节约用户流量
- patch签名校验机制,保障安全性
官方方案patch构建
以上是客户端的patch安装流程,那么官方的方案如何构建patch呢?
在构建patch前,我们必须先产生一个基线文件。值得注意的是,官方的方案,只能基于dynamicRelease的buildType进行构建,所以我们必须在app中加入该buildType
1 | buildTypes { |
构建基线文件
1 | flutter build apk --release --dynamic |
将构建产生的apk文件复制出来,假如此时的apk的versionCode为1,则我们将其拷贝到flutter工程目录下的与.android同级目录的.baseline/1.apk,如图所示

修改flutter的代码,然后执行patch的构建,此时只能修改flutter代码,不能修改嵌入层的代码,如Java,并且会基于.baseline/\$versonCode.apk为基线文件,当然也可以通过参数传入基线文件,但是为了方便,这里我们直接使用默认的路径规则,修改完成后,执行如下命令构建
1 | flutter build apk --release --dynamic --patch |
看到如下输出,就表示构建成功了,构建成功的patch位于flutter工程目录下public/\$versionCode.zip

产生的patch文件大致如下所示,包含了manifest.json文件,里面含有buildNumber,patchNumber,以及基线相关文件的crc32值,还有对应的改变的文件,如果是某些特定的文件,会使用bsdiff算法进行差量,产生.bzdiff40文件。

这里需要思考两个问题
- 我们能否直接使用gradle命令构建出patch文件?
- 原生与Flutter混合开发的应用能否构建出patch文件?
对于以上两个问题,答案都是否定的(当然也有可能是我没有找到正确的方式)。为什么呢?
对于第一个问题,为什么不能使用gradle命令直接构建出patch文件呢?因为flutter的patch文件是基于apk进行产生的,其生成的patch的代码逻辑位于 /path/to/flutter/packages/flutter_tools/lib/src/android/gradle.dart,该文件的执行入口正是必须使用flutter命令才会进入,直接使用gradle命令走的是另一个分支的代码,并不会进入生成patch文件的流程。gradle.dart生成patch的代码逻辑大致如下
1 | final AndroidApk package = AndroidApk.fromApk(apkFile); |
具体流程其实很简单,就是比较特定文件,将改变的文件打入patch包,部分特殊文件使用bsdiff进行差量减小包大小,最后生成manifest.json文件,产生patch包。
从以上流程我们也可以看出,整个patch包虽然有crc32的基线校验,但是并没有对patch包进行签名,所以这里存在一个风险点,容易被不法分子利用。
对于第二个问题 原生与Flutter混合开发的应用能否构建出patch文件,其实这个问题的答案是基于第一个问题的,混合开发的应用,一般打包不会直接基于flutter命令打包,而是先基于flutter命令打包出相关flutter产物,然后和涉及到的Android代码一起打包成aar文件,发布到远程maven仓库,然后在原生应用侧通过gradle远程依赖进行构建。
所以问题很明确,最终混合开发的应用打包肯定是基于gradle命令,而非flutter命令,但是由于目前patch只能基于flutter命令构建,所以混合开发的应用目前无法构建patch。
这两个问题也就变成了我们需要解决的问题,即需要提供一套流程,使用gradle命令进行打包,从而支持混合开发的应用。
给Flutter打Call
基于Gradle的patch构建
从Dart代码可以看出,flutter的patch文件构建其实很简单,只要对比差异文件,特定文件生成bsdiff差分文件,根据基线包生成manifest.json文件,打包成zip文件即可,因此基于gradle的patch构建其实变得很简单,我们只要仿造dart代码,基于gradle实现一套patch构建流程即可,只要输入基线apk和新的apk,就能产生一个patch文件,这正是我们想要达到的,不需要强依赖于Flutter环境。
Flutter的patch有部分文件是基于bsdiff的,这部分代码我们可以直接复用tencent的tinker中的BSDiff类,见 BSDiff.java
但是需要注意的是,bsdiff control block中几个字段的数据结构长度问题,查看了一些实现,有些地方这几个字段占用4个字节,有些地方这几个字段占用8个字节,而tinker中的实现这几个字段占用的是4个字节,但是Flutter中的实现,这几个字段占用的是8个字节,所以如果直接把Tinker中的类拿来使用的话,会导致合成的文件的时候文件非法,我们需要做两个修改。
第一个是Magic Bytes的修改,将其从MicroMsg修改为BZDIFF40,代码如下
1 | private static final byte[] MAGIC_BYTES = "BZDIFF40".getBytes(); |
第二个就是将上面说到的几个字段从4字节修改为8字节,找到如下代码
1 | // Write control block entry (3 x int) |
将其修改为
1 | // Write control block entry (3 x int) |
修改完成后就可以直接使用了。
后面我们只需要仿造Dart代码,用groovy重写一下即可,具体代码大致如下:
1 | newApk.entries().each {ZipEntry newFile -> |
这样虽然可以生成了patch,但是还不够完美,我们对生成的patch进行签名
1 | SigningConfig signingConfig = project.tasks.findByName("package${variant.name.capitalize()}").signingConfig |
执行如下gradle命令即可完成patch的构建并对其进行签名
1 | gradlew assembleReleaseFlutterPatch -PbaselineApk=/path/to/baseline.apk |
基线apk也可以传入maven坐标
1 | gradlew assembleReleaseFlutterPatch -PbaselineApk=x:y:z |
至此,我们基本完成了基于Gradle的Flutter Patch的构建。
这里我已经将其抽成了一个可用的插件,有兴趣可以见
基于Javassist实现定制化需求
前面我们说到,官方的Patch流程,不支持url的自定义,patch文件的下载模式与安装模式的按patch文件粒度的自定义,不支持Patch文件的签名校验,不支持patch的防重复下载等等问题,那么我们有没有办法复用官方的现有逻辑,但又能解决这些问题呢,我们基于Javassist在编译期对其进行字节码修改,从而达到自定义的能力,其实现大致如下:
1 |
|
从代码中我们可以看到,我们在编译期用gradle注册一个transform,扫描类,当扫描到 io/flutter/view/ResourceUpdater.class 时,修改其中的三个方法到我们配置的自定义类中,将buildUpdateDownloadURL重定向到自定义类中的获取下载url的方法,将getDownloadMode重定向到自定义类中的获取下载模式的方法,将getInstallMode重定向到自定义类中的获取安装模式的方法。
这里注意一个问题,基于Javassist的字节码修改,我们需要有完整的调用链,因此compile classpath中的类我们必须全部插入到ClassPool类中,如下
1 | public static void updateClassPath(ClassPool classPool, Project project, String variantName) { |
在自定义的类中,我们实现简单的逻辑,对patch的md5进行校验,如果重复,则返回下载的url为null,让其进入异常流程,从而防止重复下载,对于签名校验,我们没有好的方法注入,因此可以在三个自定义方法中分别都进行签名校验,如果校验不通过,则直接删除patch文件,然后再删除时间戳文件,从而让flutter文件重新释放,这里我实现了一个简单的示例,如下
1 | @Keep |
详细代码见 https://github.com/lizhangqu/plugin-flutter-patch
插件化场景的Flutter插件下发
前面我们已经说到,Flutter是不支持非Flutter代码的修改的,因此对于Android代码及资源的修改它就无能为力了。因此除了官方的Patch下发通道之外,如果App已经是插件化架构的,那么完全可以开辟出基于插件化的插件下发通道来更新Flutter插件,前提是Flutter的所有代码已经是单独的一个bundle了。
不过Flutter代码中有一个关键的地方,导致了插件下发通道会失效。前面我们分析了Flutter的时间戳文件的格式,在没有patch的情况下,它的格式如下
1 | res_timestamp-$versionCode-$appLastInstallTime |
可以看到,时间戳是和app的versionCode和安装时间强关联的,这时候即使我们下发了一个完整的Flutter插件,其实Flutter相关的产物文件也无法能到释放,因为versionCode和安装时间都没有发生变化,flutter会认为当前释放的文件已经是最新的。
基于此,我们必须让Flutter的产物文件释放到一个和插件版本强关联的目录。
现有的Flutter产物会释放到如下目录
1 | /data/data/$pkgName/app_flutter |
假如我们插件的安装目录为
1 | /data/data/$pkgName/app_plugins/$bundlePkgName/$bundleVersion |
那我们需要将现有释放的目录修改为
1 | /data/data/$pkgName/app_plugins/$bundlePkgName/$bundleVersion/app_flutter |
从而让其产物目录和插件化的插件版本强关联,这样以来,一旦下发了新版本的插件,Flutter的产物也就可以得到释放。
我们可以和修改flutter的下载url,下载模式,安装模式的方式类似,通过编译期改字节码的方式重定向产物目录到另外的一个目录。
幸运的是,现有Flutter的释放目录,已经全部收敛到一个类中,如下
1 | public final class PathUtils { |
我们只需要修改getDataDirectory函数到自定义的函数中即可,参考代码如下
1 | @Override |
至此,插件化场景下的Flutter插件下发通道也完全搞定。
总结
Flutter 动态下发目前虽然官方已经支持了,但从代码中可以看出,目前的方式是不成熟的,考虑到现状,我们基于字节码修改技术,给Flutter打了几个小小的补丁,从而让其更适应我们的业务场景,而官方的Patch构建方式对gradle的支持也不是很完善,我们则仿造现有的Dart代码,重写了一套基于Gradle构建的Patch构建方式解决这个问题。由此看来Flutter需要走的路还很长。
