大家好,我是拭心,这篇文章是一个好友 Divin 的投稿,介绍 SDK 热更新的一种实现思路,希望对你有所启发。
目前市面上成熟的商业热更新方案不少,有腾讯Bugly的Tinker封装,有阿里云的Sophix,也有游戏垂直行业的卓盟乐变。这些成熟方案,都有一个适用范围,即对App、对游戏整包进行热更新。前两者是和包名绑定在一起的,所以只适用于App热更新;而卓盟乐变则专注于游戏行业,可支持多渠道包热更新。其实最好的还是Sophix,可惜没有开源,虽有公开原理,但是公开资料里也透露了探索与开发周期长达9个月。
在社区,比较流行的热更新有Tinker
、QZone
、AndFix(HotFix)
、Sophix
、Robust
、Dexposed
、Nuwa
、Amigo
,同商业热更新方案一样,也是适用于App整包热更新。在这些方案里,影响力最大的是微信的Tinker
方案,13048个Star,拥有完善的文档,整个框架注重高可用性,最重要的是官方持续维护,在2018年12月,merge
7次。相比之下,其他有在Github上开源的框架,star
数都是7000以下,上次更新时间都在1年前,甚至2年前。
SDK热更新,这是一个极少被关注的问题,Google、百度上相关的文章一篇都没有。我们首先进行思考,SDK热更新
同App热更新
有什么不同?,SDK热更新
要做什么?
•App热更新,输入的是一个基准包和一个新版包,输出的是差分包(或补丁),将这个差分包(或补丁)下载到客户端,客户端加载后生效。•SDK热更新,输入的是一个基准SDK和一个新版SDK,输出的也是差分包(或补丁),不同的是,SDK会被集成到不同的游戏包中,这个游戏包也会被分成各式各样的渠道包,我们要将这个差分包(或补丁)下载到所有游戏、所有渠道包,并加载生效。
比如,我们可以进行这样标识:所有在com.divin.
包名之下的java类,所有assets/divin/
文件夹之下的Assets文件,所有以divin_
开头的Res文件,所有/res/values/
文件,所有以divin_
开头的so文件。
包括build(计算差分)、patch(合并差分)、load(加载差分)。
十分感谢微信Tinker
的开源,对外开放了完整的热更新过程,站在伟人肩上,下面的SDK热更新,都是基于Tinker
开源库进行的修改。
阿里系(AndFix,Hotfix)走的底层替换方案,好处在于实时生效,腾讯系(Tinker)走的是类加载方案,好处在于高兼容性。阿里百川系(Sophix)就有点机智了,两种方案都有使用,还进行了一定的升级,优先走底层替换方案,底层替换方案走不下去了就走类加载方案。
AndFix(HotFix)的底层替换方案已过时,Sophix的无视底层具体结构的底层替换方案较新。感兴趣的同学可以深入了解下,追寻极致的代码热替换[1]。
Tinker的类加载方案,需要重启应用后让Classloader去加载新类。因为Android上无法对一个类进行卸载,不重启,则无法加载新类。
这里也是有两个流派,一个流派是参考Instant Run通过addAssetPath加载新的资源包到AssetsManager,然后再替换Resource中的AssetsManager;一个流派是构造新的R文件资源地址以0x66开头的资源包,再通过addAssetPath加载新的资源包到AssetsManager,因为新的R文件资源地址以0x66开头,新的Java代码里,也引用0x66开头的资源,这样就可以新旧资源不干扰且都能生效。
Tinker属于第一个流派[2],Sophix属于第二个流派[3]。
非常遗憾的是,在我们基于Tinker实现SDK资源更新(即指定资源更新)时,只知道第一个流派,并不知道第二个流派(那篇文章没细读,印象不深)。所以后文中所提到的SDK资源更新(指定资源更新),其实是自己摸索出来的,可以理解成流派二的拼多多版,实现了资源新增、更改,但暂未支持R文件直接引用。
说到这里,是真感谢这世界上有数组这玩意。so文件的热更新,也是把补丁so库的路径插入到nativeLibraryDirectories数组的最前面。
Tinker已开源,Tencent/tinker[4],同时有详细的使用Wiki,Tinker使用Wiki[5]。
Tinker的整个热更新过程,可以理解成四个步骤。
集成Tinker分两大块,一块是Application改造,一块是定制化功能。第一块较为简单,使用Annotation Processor在编译时生成新Application;第二块非常复杂。
build有两种模式,一种是供Android Studio开发使用的Gradle模式,一种是使用Java实现的命令行模式。二者最底层,其实都是使用的tinker-patch-lib,一个用Java实现的核心库。
Tinker的源码分为这么几大块:
顾名思义,这是一个demo,庞大!庞大!庞大!从未见过一个第三方SDK,暴露了如此多的api,可以定制如此多的功能!难怪Sophix在其官方文档中对热更新方案做横向对比时,把自己描述为“傻瓜式接入”,把Amigo描述为“一般”,却把Tinker描述为“复杂”。其实微信官方也有描述,Tinker为了实现“高可用”的目标,在接入成本上做了妥协。热补丁并不简单,在使用之前请务必先仔细阅读XXXX。总的来说,感谢腾讯baba。
demo里,示例了:
①如何控制热更新的请求过滤、合并过程、加载过程、合并后的后续处理、升级热更新模块本身的代码。
②如何改造Application。
③Gradle集成模式的42个参考配置。 42个参考配置!42个参考配置!42个参考配置!
这里让大家放心的是,复杂的是Tinker的定制化开发,而不是给到cp的SDK。我们可以对外隐藏这些定制化开发的细节。
这是热更新过程中build步骤的源码,有三个子模块,tinker-patch-lilb是核心代码,tinker-patch-cli是命令行模式的源码,tinker-patch-gradle-plugin是Gradle模式的源码。
这是热更新过程中patch和load步骤的源码,随Apk、游戏运行在客户端。也有Application改造时用到的Annotation Processor库的源码。
tinker-build所用到的基础库。
tinker-build所用到的第三方库。
我们回顾热更新的4个步骤,第二个步骤是build(计算差分),输入的是一个基准包和一个新版包,输出的是差分包(或补丁)。如果在这个核心算法的里,增加一项功能,只比对SDK的代码,不比对游戏的代码,是不是就可行了呢?
这种思路,有一点点站在业务层反推实现方案的嫌疑。但最后实践检验,还真可以这样。
我们回顾demo中的一项功能,升级热更新模块本身的代码,那Tinker如何去实现这一个功能的呢?Tinker通过一个配置表来配置热更新模块本身的代码。
<issue id="dex">
<loader value="com.tencent.tinker.loader.*"/>
<loader value="tinker.sample.android.SampleApplication"/>
<loader value="tinker.sample.android.app.GameClass"/>
</issue>
这里的配置是支持Pattern的。
把游戏的代码也当热更新模块本身的代码配置,是否OK?
结果是不OK。能够build,但是不能patch、load。
网上所有的博客,其实都有提到Tinker自研了一套dex diff、patch的算法,可以高效地比对出差分包,并在客户端patch出目标dex包。难道是Tinker这一套算法不支持这样地添加非热更新模块代码?
这时候我们回过头理解这一套dex diff、patch算法,也许你都还用不上深入理解,看到上面的几行字,说不定就能发现玄妙。有兴趣可以把视野停在此处思考一下。
•占•.•.•.•.•.•位•.•.•.•.•.•符
Tinker的dex diff、patch算法,说到底,就是一个可逆的过程,先计算两个包的区别特征,再通过一个包以及区别特征,来推出另一个包。这套算法是从dex的方法和指令维度进行全量合成。
用简单的公式来表示:
服务端diff: New.dex - base.dex = patch.file
客户端patch: base.dex + patch.file = New.dex
在上面的尝试中,客户端patch所用到的base.dex,已经不是服务端diff所用到的base.dex了。前者是游戏包的dex,后者是SDK的dex。
摆在我们面前的选择只有两个,一个是理解并修改这套算法,另一个是,另辟蹊径。但前者,显然不是3、5天调研时间能完成的。
柳暗花明又一村~
调试源码时,发现了这玩意:
@Override
public void onAllPatchesEnd() throws Exception {
if (!hasDexChanged) {
Logger.d("No dexes were changed, nothing needs to be done next.");
return;
}
if (config.mIsProtectedApp) {
generateChangedClassesDexFile();
} else {
generatePatchInfoFile();
}
addTestDex();
}
超想用抖音的BGM描述一下内心的心情,“这是什么造型,挺别致哦~”
在开发者配置isProtectedApp
的true或false时,其实Tinker走了两套不一样的差分算法。false时,走Tinker自研的差分算法;true时,走常规的差分算法。
这套差分算法是基于Class类的,可以被客户端patch、load的。
接着,就是对配置表loader配置的复刻了,这里思路比较清晰,增加一个isSDKMode配置,如果为true则走SDK模式,不去读loader配置,而去读loader配置的复刻字段sdkPackage,用来填写需要更新的SDK代码。我们SDK是com.divin.*。
<issue id="dex">
<loader value="com.tencent.tinker.loader.*"/>
<loader value="tinker.sample.android.SampleApplication"/>
<loader value="tinker.sample.android.app.GameClass"/>
<isSDKMode value="true"/>
<sdkPackage value="com.divin.*"/>
</issue>
搞定!
我们先说一下不同资源,在Apk包中的目录结构。
解压缩Apk包后,根目录下有assets和res文件夹。如果你用这个Apk包的目录结构和Android工程源码的目录结构做对比,assets中的内容是一一对应的,Apk包的res文件夹也能Android工程源码的res中资源一一对应起来,但是会少了Android工程源码的res/values文件夹下的文件。
这些res/values文件去哪儿了呢?
resources.arsc
所以,指定资源文件热更新要分两大块,一块是不能一一对应上的res/values文件,一块是能一一对应上的assets文件和res文件。
重述一下,Android工程源码中,不能一一对应上的res/values文件,到Apk文件目录的resources.arsc文件中去了。
我们回顾Tinker更新步骤,第2步build,通过diff算法生成差分包,第3步patch,通过patch算法生成新的res资源包,第4步load,加载新的res资源包。
用SDK的resources.arsc生成差分包,再用游戏的旧resources.arsc计算新的resources.arsc?
这样,又面临我们做指定代码热更新时面临的问题。摆在我们面前的选择只有两个,一个是理解并修改这套算法,另一个是,另辟蹊径。
What?? 逼我们上梁山??
这里面临两个问题:
•我们无法计算出新的resources.arsc文件。•就算计算出来了也没用,因为resources.arsc不仅有SDK的资源,还有游戏的资源。使用SDK的resources.arsc文件,必然会让游戏因找不到资源而崩溃!
车到山前必有路,逐个击破!
第一个问题。 其实Res资源也是有两种算法,一种是Tinker自研的diff、patch算法,一种是不计算差分,完整下载,完整加载。具体到每一个资源,到底走哪种算法,其实是根据资源的大小做的判断,默认是100kb以下的完整下载、完整加载,100kb以上的走自研的diff、patch算法。
那我们就强行走第二种算法,这里要做的事情有二件:
•控制差分的判断逻辑,强行走第二种算法。•修改patch时的CSC、md5完整性判断逻辑。(TODO:预研时,我是直接去掉了,实际业务中,需要增加新的完整性判断逻辑)
第二个问题。我们细读Tinker的资源load流程,它生效的原理是Instant Run那一套流派一。
流派一原理简述如下:
•
先获取默认的AssetManager,通过反射获取其构造方法
•
通过AssertManager的addAssetPath函数,加入外部的资源路径
•
将Resources的mAssets的字段设为前面的AssertManager
这一套,所实现的效果,就是用addAssetPath用新的Res资源包替换原来的Res资源包。慢着,add,Asset,Path,添加资源目录,能不能添加多个呢?
看Android源码找找希望吧。
/**
* @deprecated Use {@link #setApkAssets(ApkAssets[], boolean)}
* @hide
*/
@Deprecated
@UnsupportedAppUsage
public int addAssetPath(String path) {
return addAssetPathInternal(path, false /*overlay*/, false /*appAsLib*/);
}
private int addAssetPathInternal(String path, boolean overlay, boolean appAsLib) {
Preconditions.checkNotNull(path, "path");
synchronized (this) {
ensureOpenLocked();
final int count = mApkAssets.length;
// See if we already have it loaded.
for (int i = 0; i < count; i++) {
if (mApkAssets[i].getAssetPath().equals(path)) {
return i + 1;
}
}
final ApkAssets assets;
try {
if (overlay) {
// TODO(b/70343104): This hardcoded path will be removed once
// addAssetPathInternal is deleted.
final String idmapPath = "/data/resource-cache/"
+ path.substring(1).replace('/', '@')
+ "@idmap";
assets = ApkAssets.loadOverlayFromPath(idmapPath, false /*system*/);
} else {
assets = ApkAssets.loadFromPath(path, false /*system*/, appAsLib);
}
} catch (IOException e) {
return 0;
}
mApkAssets = Arrays.copyOf(mApkAssets, count + 1);
mApkAssets[count] = assets;
nativeSetApkAssets(mObject, mApkAssets, true);
invalidateCachesLocked(-1);
return count + 1;
}
}
BGM再来一次,“这是什么造型,挺别致哦~”
mApkAssets,伟大的数组!
获取新的AssetsManager,先添加热更新的新Res资源,再添加游戏原本的旧Res资源。这样,会先去第一个Res中找资源,第一个Res中找不到再去第二个Res中找。
所以,这里是能实现对SDK资源的新增、修改,但是不能删去资源,同时也不支持R文件直接引用,因为R文件的地址是常量,在Apk编译时,这些常量会跟着引用R文件的业务Class走。如果想保持R文件的地址不变,可以修改APT编译器,也能通过Apktool来做,当然还有上面提到的资源热更新流派二。
这里实现起来,其实和代码热更新有些相似。Tinker默认有这样的配置表:
<issue id="resource">
<!--what resource in apk are expected to deal with tinkerPatch-->
<!--it support * or ? pattern.-->
<!--you must include all your resources in apk here-->
<!--otherwise, they won't repack in the new apk resources-->
<pattern value="res/*"/>
<pattern value="assets/*"/>
<pattern value="resources.arsc"/>
<pattern value="AndroidManifest.xml"/>
<!--ignore add, delete or modify resource change-->
<!--Warning, we can only use for files no relative with resources.arsc, such as assets files-->
<!--it support * or ? pattern.-->
<!--Such as I want assets/meta.txt use the base.apk version whatever it is change ir not.-->
<ignoreChange value="assets/sample_meta.txt"/>
<!--ignore any warning caused by add, delete or modify changes on resources specified by this pattern.-->
<ignoreChangeWarning value="" />
<!--default 100kb-->
<!--for modify resource, if it is larger than 'largeModSize'-->
<!--we would like to use bsdiff algorithm to reduce patch file size-->
<largeModSize value="10000000"/>
</issue>
增加一个isSDKMode配置,如果为true则走SDK模式,不去读ignoreChange配置,而去读ignoreChange配置的复刻字段sdkResPath,
<issue id="resource">
<!--what resource in apk are expected to deal with tinkerPatch-->
<!--it support * or ? pattern.-->
<!--you must include all your resources in apk here-->
<!--otherwise, they won't repack in the new apk resources-->
<pattern value="res/*"/>
<pattern value="assets/*"/>
<pattern value="resources.arsc"/>
<pattern value="AndroidManifest.xml"/>
<!--ignore add, delete or modify resource change-->
<!--Warning, we can only use for files no relative with resources.arsc, such as assets files-->
<!--it support * or ? pattern.-->
<!--Such as I want assets/meta.txt use the base.apk version whatever it is change ir not.-->
<isSDKMode value="true">
<sdkResPath value="assets/only_use_to_test_tinker_resource.txt"/>
<sdkResPath value="assets/divin/*"/>
<sdkResPath value="res/*/divin_*"/>
<sdkResPath value="resources.arsc"/>
<sdkResPath value="AndroidManifest.xml"/>
<ignoreChange value="assets/sample_meta.txt"/>
<!--ignore any warning caused by add, delete or modify changes on resources specified by this pattern.-->
<ignoreChangeWarning value="" />
<!--default 100kb-->
<!--for modify resource, if it is larger than 'largeModSize'-->
<!--we would like to use bsdiff algorithm to reduce patch file size-->
<largeModSize value="10000000"/>
</issue>
至于差分算法,倒是没有什么问题。不论是Tinker自研的diff、patch算法,还是完整下载、完整加载,都可行,毕竟要更新的文件都是SDK独有的,游戏并没有共用。当然啦,使用Tinker自研的diff、patch算法肯定是最好的,毕竟可以减小差分包大小。
TODO
•代码: 所有在com.divin.
包名之下的java类•assets: 所有assets/divin/
文件夹之下的文件•普通Res: 所有以divin_
开头的文件•/res/values/: 所有文件, 但是只能实现增加/更改values,不能实现删除values.•so库: 以divin_
开头的so文件
•无法更新AndroidManifest•在部分三星android-21的机型上无法生效•资源替换不支持远程View, 如应用icon.•不支持SDK直接R文件引用资源
dependencies {
// tinker-android-lib(本地module) 为必须依赖
// anno为可选依赖,用于使用AnnotationProcessor生成Application
//implementation("com.tencent.tinker:tinker-android-lib:${TINKER_VERSION}") { changing = true }
implementation project(':tinker-android::tinker-android-lib')
annotationProcessor("com.tencent.tinker:tinker-android-anno:${TINKER_VERSION}") { changing = true }
compileOnly("com.tencent.tinker:tinker-android-anno:${TINKER_VERSION}") { changing = true }
}
参考SampleApplicationLike.java
改造Application.
TinkerLogic.patch(Context context)
[1]
追寻极致的代码热替换: https://yq.aliyun.com/articles/74598?spm=a2c4e.11153940.blogcont103527.9.4838625aQYwZRa[2]
第一个流派: https://www.cnblogs.com/yyangblog/p/6252490.html[3]
第二个流派: https://yq.aliyun.com/articles/96378?spm=a2c4e.11153940.blogcont103527.11.4838625aQYwZRa[4]
Tencent/tinker: https://github.com/Tencent/tinker[5]
Tinker使用Wiki: https://github.com/Tencent/tinker/wiki