基于Tinker的SDK全局热更新方案(全网唯一)

一、背景

App热更新

目前市面上成熟的商业热更新方案不少,有腾讯Bugly的Tinker封装,有阿里云的Sophix,也有游戏垂直行业的卓盟乐变。这些成熟方案,都有一个适用范围,即对App、对游戏整包进行热更新。前两者是和包名绑定在一起的,所以只适用于App热更新;而卓盟乐变则专注于游戏行业,可支持多渠道包热更新。其实最好的还是Sophix,可惜没有开源,虽有公开原理,但是公开资料里也透露了探索与开发周期长达9个月。

在社区,比较流行的热更新有TinkerQZoneAndFix(HotFix)SophixRobustDexposedNuwaAmigo,同商业热更新方案一样,也是适用于App整包热更新。在这些方案里,影响力最大的是微信的Tinker方案,13048个Star,拥有完善的文档,整个框架注重高可用性,最重要的是官方持续维护,在2018年12月,merge7次。相比之下,其他有在Github上开源的框架,star数都是7000以下,上次更新时间都在1年前,甚至2年前。

SDK热更新

SDK热更新,这是一个极少被关注的问题,Google、百度上相关的文章一篇都没有。我们首先进行思考,SDK热更新App热更新有什么不同?,SDK热更新要做什么?

SDK热更新同App热更新有什么不同?

  1. App热更新,输入的是一个基准包和一个新版包,输出的是差分包(或补丁),将这个差分包(或补丁)下载到客户端,客户端加载后生效。
  2. SDK热更新,输入的是一个基准SDK和一个新版SDK,输出的也是差分包(或补丁),不同的是,SDK会被集成到不同的游戏包中,这个游戏包也会被分成各式各样的渠道包,我们要将这个差分包(或补丁)下载到所有游戏、所有渠道包,并加载生效

SDK热更新要做什么?

1. 对SDK的代码、资源进行标识,我们要进行热更新的对象,就是这些代码、资源。

比如,我们可以进行这样标识:所有在com.divin.包名之下的java类,所有assets/divin/文件夹之下的Assets文件,所有以divin_开头的Res文件,所有/res/values/文件,所有以divin_开头的so文件。

2. 在热更新的整个流程,对上述代码、资源进行特别操作。

包括build(计算差分)、patch(合并差分)、load(加载差分)。

十分感谢微信Tinker的开源,对外开放了完整的热更新过程,站在伟人肩上,下面的SDK热更新,都是基于Tinker开源库进行的修改。

热更新重点

1. dex热更新,即Java代码热更新。

阿里系(AndFix,Hotfix)走的底层替换方案,好处在于实时生效,腾讯系(Tinker)走的是类加载方案,好处在于高兼容性。阿里百川系(Sophix)就有点机智了,两种方案都有使用,还进行了一定的升级,优先走底层替换方案,底层替换方案走不下去了就走类加载方案。

AndFix(HotFix)的底层替换方案已过时,Sophix的无视底层具体结构的底层替换方案较新。感兴趣的同学可以深入了解下,追寻极致的代码热替换

Tinker的类加载方案,需要重启应用后让Classloader去加载新类。因为Android上无法对一个类进行卸载,不重启,则无法加载新类。

2. 资源文件热更新。

这里也是有两个流派,一个流派是参考Instant Run通过addAssetPath加载新的资源包到AssetsManager,然后再替换Resource中的AssetsManager;一个流派是构造新的R文件资源地址以0x66开头的资源包,再通过addAssetPath加载新的资源包到AssetsManager,因为新的R文件资源地址以0x66开头,新的Java代码里,也引用0x66开头的资源,这样就可以新旧资源不干扰且都能生效。

Tinker属于第一个流派,Sophix属于第二个流派

非常遗憾的是,在我们基于Tinker实现SDK资源更新(即指定资源更新)时,只知道第一个流派,并不知道第二个流派(那篇文章没细读,印象不深)。所以后文中所提到的SDK资源更新(指定资源更新),其实是自己摸索出来的,可以理解成流派二的拼多多版,实现了资源新增、更改,但暂未支持R文件直接引用。

3. so文件热更新。

说到这里,是真感谢这世界上有数组这玩意。so文件的热更新,也是把补丁so库的路径插入到nativeLibraryDirectories数组的最前面。

二、Tinker

开源

Tinker已开源,Tencent/tinker,同时有详细的使用Wiki,Tinker使用Wiki

热更新过程

Tinker的整个热更新过程,可以理解成四个步骤。

1. Tinker集成

集成Tinker分两大块,一块是Application改造,一块是定制化功能。第一块较为简单,使用Annotation Processor在编译时生成新Application;第二块非常复杂。

2. build(计算差分)

build有两种模式,一种是供Android Studio开发使用的Gradle模式,一种是使用Java实现的命令行模式。二者最底层,其实都是使用的tinker-patch-lib,一个用Java实现的核心库。

3. patch(合并差分)

4. load(加载差分)

源码结构

Tinker的源码分为这么几大块:

1. tinker-sample-android

顾名思义,这是一个demo,庞大!庞大!庞大!从未见过一个第三方SDK,暴露了如此多的api,可以定制如此多的功能!难怪Sophix在其官方文档中对热更新方案做横向对比时,把自己描述为“傻瓜式接入”,把Amigo描述为“一般”,却把Tinker描述为“复杂”。其实微信官方也有描述,Tinker为了实现“高可用”的目标,在接入成本上做了妥协。热补丁并不简单,在使用之前请务必先仔细阅读XXXX。总的来说,感谢腾讯baba。

demo里,示例了:

①如何控制热更新的请求过滤、合并过程、加载过程、合并后的后续处理、升级热更新模块本身的代码。

②如何改造Application。

③Gradle集成模式的42个参考配置。 42个参考配置!42个参考配置!42个参考配置!

这里让大家放心的是,复杂的是Tinker的定制化开发,而不是给到cp的SDK。我们可以对外隐藏这些定制化开发的细节。

2. tinker-build

这是热更新过程中build步骤的源码,有三个子模块,tinker-patch-lilb是核心代码,tinker-patch-cli是命令行模式的源码,tinker-patch-gradle-plugin是Gradle模式的源码。

3. tinker-android

这是热更新过程中patch和load步骤的源码,随Apk、游戏运行在客户端。也有Application改造时用到的Annotation Processor库的源码。

4. tinker-commons

tinker-build所用到的基础库。

5. third-party

tinker-build所用到的第三方库。

三、SDK热更新实现

1. 指定代码热更新。

我们回顾热更新的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>

搞定!

2. 指定资源文件热更新。

我们先说一下不同资源,在Apk包中的目录结构。

解压缩Apk包后,根目录下有assets和res文件夹。如果你用这个Apk包的目录结构Android工程源码的目录结构做对比,assets中的内容是一一对应的,Apk包的res文件夹也能Android工程源码的res中资源一一对应起来,但是会少了Android工程源码的res/values文件夹下的文件。

这些res/values文件去哪儿了呢?

resources.arsc

所以,指定资源文件热更新要分两大块,一块是不能一一对应上的res/values文件,一块是能一一对应上的assets文件和res文件。

不能一一对应上的res/values文件

重述一下,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?? 逼我们上梁山??

这里面临两个问题:

  1. 我们无法计算出新的resources.arsc文件。
  2. 就算计算出来了也没用,因为resources.arsc不仅有SDK的资源,还有游戏的资源。使用SDK的resources.arsc文件,必然会让游戏因找不到资源而崩溃!

车到山前必有路,逐个击破!

第一个问题。 其实Res资源也是有两种算法,一种是Tinker自研的diff、patch算法,一种是不计算差分,完整下载,完整加载。具体到每一个资源,到底走哪种算法,其实是根据资源的大小做的判断,默认是100kb以下的完整下载、完整加载,100kb以上的走自研的diff、patch算法。

那我们就强行走第二种算法,这里要做的事情有二件:

  1. 控制差分的判断逻辑,强行走第二种算法。
  2. 修改patch时的CSC、md5完整性判断逻辑。(TODO:预研时,我是直接去掉了,实际业务中,需要增加新的完整性判断逻辑)

第二个问题。我们细读Tinker的资源load流程,它生效的原理是Instant Run那一套流派一

流派一原理简述如下:

  • 先获取默认的AssetManager,通过反射获取其构造方法

  • 通过AssertManager的addAssetPath函数,加入外部的资源路径

  • 将Resources的mAssets的字段设为前面的AssertManager

这一套,所实现的效果,就是用addAssetPath用新的Res资源包替换原来的Res资源包。慢着,addAssetPath,添加资源目录,能不能添加多个呢?

看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来做,当然还有上面提到的资源热更新流派二

能一一对应上的assets文件和res文件。

这里实现起来,其实和代码热更新有些相似。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算法肯定是最好的,毕竟可以减小差分包大小。

3. 指定so文件热更新。

略。

四、效果

SDK热更新范围

  1. 代码: 所有在com.divin.包名之下的java类
  2. assets: 所有assets/divin/文件夹之下的文件
  3. 普通Res: 所有以divin_开头的文件
  4. /res/values/: 所有文件, 但是只能实现增加/更改values,不能实现删除values.
  5. so库: 以divin_开头的so文件

SDK热更新限制

  1. 无法更新AndroidManifest
  2. 在部分三星android-21的机型上无法生效
  3. 资源替换不支持远程View, 如应用icon.
  4. 不支持SDK直接R文件引用资源

集成配置

1. app.gradle

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 }
} 

2. 修改Application

参考SampleApplicationLike.java改造Application.

3. 更新

TinkerLogic.patch(Context context)

↓来来来,点一点小爱心。爱心是动力~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,937评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,503评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,712评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,668评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,677评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,601评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,975评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,637评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,881评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,621评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,710评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,387评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,971评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,947评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,189评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,805评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,449评论 2 342

推荐阅读更多精彩内容