Android 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)

+qq群:853967238。获取以上高清技术思维图,以及相关技术的免费视频学习资料。

猜你喜欢

转载自blog.csdn.net/weixin_43887839/article/details/87718013