腾讯系热修复-Tinker使用及原理

简介

Tinker是适用于Android的修补程序库,它支持dex,库和资源更新,而无需重新安装apk。更新完成后重新启动即可
Tinker github官方地址

添加依赖

1.app的build.gradle

buildscript {
    dependencies {
        classpath ('com.tencent.tinker:tinker-patch-gradle-plugin:1.9.1')
    }
}

2.在module的gradle中

dependencies {
    //optional, help to generate the final application
    provided('com.tencent.tinker:tinker-android-anno:1.9.1')
    //tinker's main Android lib
    compile('com.tencent.tinker:tinker-android-lib:1.9.1')
}

Gradle版本大于2.3的这么写
dependencies {
    implementation "com.android.support:multidex:1.0.1"
    //tinker的核心库
    implementation("com.tencent.tinker:tinker-android-lib:1.9.1") { changing = true }
    //可选,用于生成application类
    annotationProcessor("com.tencent.tinker:tinker-android-anno:1.9.1") { changing = true }
    compileOnly("com.tencent.tinker:tinker-android-anno:1.9.1") { changing = true }
}
...

开启multiDex

defaultConfig {
        ...
        multiDexEnabled true
}
...

最后别忘了添加apply

apply plugin: 'com.tencent.tinker.patch'

Tinker的配置及任务

1. Tinker文档中推荐将jumboMode设置为true

android {
    dexOptions {
        // 支持大工程模式
        jumboMode = true
    }
    ...
}

2 配置Tinker与任务

将下面的配置全部复制粘贴到app的gradle文件(app/build.gradle)末尾,内容很多,但现在只需要看懂bakPath与ext括号内的东东就好了。

// Tinker配置与任务
def bakPath = file("${buildDir}/bakApk/")
ext {
    // 是否使用Tinker(当你的项目处于开发调试阶段时,可以改为false)
    tinkerEnabled = true
    // 基础包文件路径(名字这里写死为old-app.apk。用于比较新旧app以生成补丁包,不管是debug还是release编译)
    tinkerOldApkPath = "${bakPath}/old-app.apk"
    // 基础包的mapping.txt文件路径(用于辅助混淆补丁包的生成,一般在生成release版app时会使用到混淆,所以这个mapping.txt文件一般只是用于release安装包补丁的生成)
    tinkerApplyMappingPath = "${bakPath}/old-app-mapping.txt"
    // 基础包的R.txt文件路径(如果你的安装包中资源文件有改动,则需要使用该R.txt文件来辅助生成补丁包)
    tinkerApplyResourcePath = "${bakPath}/old-app-R.txt"
    //only use for build all flavor, if not, just ignore this field
    tinkerBuildFlavorDirectory = "${bakPath}/flavor"
}

def getOldApkPath() {
    return hasProperty("OLD_APK") ? OLD_APK : ext.tinkerOldApkPath
}

def getApplyMappingPath() {
    return hasProperty("APPLY_MAPPING") ? APPLY_MAPPING : ext.tinkerApplyMappingPath
}

def getApplyResourceMappingPath() {
    return hasProperty("APPLY_RESOURCE") ? APPLY_RESOURCE : ext.tinkerApplyResourcePath
}

def getTinkerIdValue() {
    return hasProperty("TINKER_ID") ? TINKER_ID : android.defaultConfig.versionName
}

def buildWithTinker() {
    return hasProperty("TINKER_ENABLE") ? TINKER_ENABLE : ext.tinkerEnabled
}

def getTinkerBuildFlavorDirectory() {
    return ext.tinkerBuildFlavorDirectory
}

if (buildWithTinker()) {
    //apply tinker插件
    apply plugin: 'com.tencent.tinker.patch'

    // 全局信息相关的配置项
    tinkerPatch {
        tinkerEnable = buildWithTinker()// 是否打开tinker的功能。
        oldApk = getOldApkPath()        // 基准apk包的路径,必须输入,否则会报错。
        ignoreWarning = false           // 是否忽略有风险的补丁包。这里选择不忽略,当补丁包风险时会中断编译。
        useSign = true                  // 在运行过程中,我们需要验证基准apk包与补丁包的签名是否一致,我们是否需要为你签名。
        // 编译相关的配置项
        buildConfig {
            applyMapping = getApplyMappingPath()
            // 可选参数;在编译新的apk时候,我们希望通过保持旧apk的proguard混淆方式,从而减少补丁包的大小。这个只是推荐设置,不设置applyMapping也不会影响任何的assemble编译。
            applyResourceMapping = getApplyResourceMappingPath()
            // 可选参数;在编译新的apk时候,我们希望通过旧apk的R.txt文件保持ResId的分配,这样不仅可以减少补丁包的大小,同时也避免由于ResId改变导致remote view异常。
            tinkerId = getTinkerIdValue()
            // 在运行过程中,我们需要验证基准apk包的tinkerId是否等于补丁包的tinkerId。这个是决定补丁包能运行在哪些基准包上面,一般来说我们可以使用git版本号、versionName等等。
            keepDexApply = false
            // 如果我们有多个dex,编译补丁时可能会由于类的移动导致变更增多。若打开keepDexApply模式,补丁包将根据基准包的类分布来编译。
            isProtectedApp = false // 是否使用加固模式,仅仅将变更的类合成补丁。注意,这种模式仅仅可以用于加固应用中。
            supportHotplugComponent = false // 是否支持新增非export的Activity(1.9.0版本开始才有的新功能)
        }
        // dex相关的配置项
        dex {
            dexMode = "jar"
// 只能是'raw'或者'jar'。 对于'raw'模式,我们将会保持输入dex的格式。对于'jar'模式,我们将会把输入dex重新压缩封装到jar。如果你的minSdkVersion小于14,你必须选择‘jar’模式,而且它更省存储空间,但是验证md5时比'raw'模式耗时。默认我们并不会去校验md5,一般情况下选择jar模式即可。
            pattern = ["classes*.dex",
                       "assets/secondary-dex-?.jar"]
            // 需要处理dex路径,支持*、?通配符,必须使用'/'分割。路径是相对安装包的,例如assets/...
            loader = [
                    // 定义哪些类在加载补丁包的时候会用到。这些类是通过Tinker无法修改的类,也是一定要放在main dex的类。
                    // 如果你自定义了TinkerLoader,需要将它以及它引用的所有类也加入loader中;
                    // 其他一些你不希望被更改的类,例如Sample中的BaseBuildInfo类。这里需要注意的是,这些类的直接引用类也需要加入到loader中。或者你需要将这个类变成非preverify。
            ]
        }
        //  lib相关的配置项
        lib {
            pattern = ["lib/*/*.so","src/main/jniLibs/*/*.so"]
            // 需要处理lib路径,支持*、?通配符,必须使用'/'分割。与dex.pattern一致, 路径是相对安装包的,例如assets/...
        }
        // res相关的配置项
        res {
            pattern = ["res/*", "assets/*", "resources.arsc", "AndroidManifest.xml"]
            // 需要处理res路径,支持*、?通配符,必须使用'/'分割。与dex.pattern一致, 路径是相对安装包的,例如assets/...,务必注意的是,只有满足pattern的资源才会放到合成后的资源包。
            ignoreChange = [
                    // 支持*、?通配符,必须使用'/'分割。若满足ignoreChange的pattern,在编译时会忽略该文件的新增、删除与修改。 最极端的情况,ignoreChange与上面的pattern一致,即会完全忽略所有资源的修改。
                    "assets/sample_meta.txt"
            ]
            largeModSize = 100
            // 对于修改的资源,如果大于largeModSize,我们将使用bsdiff算法。这可以降低补丁包的大小,但是会增加合成时的复杂度。默认大小为100kb
        }
        // 用于生成补丁包中的'package_meta.txt'文件
        packageConfig {
            // configField("key", "value"), 默认我们自动从基准安装包与新安装包的Manifest中读取tinkerId,并自动写入configField。
            // 在这里,你可以定义其他的信息,在运行时可以通过TinkerLoadResult.getPackageConfigByName得到相应的数值。
            // 但是建议直接通过修改代码来实现,例如BuildConfig。
            configField("platform", "all")
            configField("patchVersion", "1.0")
//            configField("patchMessage", "tinker is sample to use")
        }
        // 7zip路径配置项,执行前提是useSign为true
        sevenZip {
            zipArtifact = "com.tencent.mm:SevenZip:1.1.10"
        }
    }
    List flavors = new ArrayList<>();
    project.android.productFlavors.each { flavor ->
        flavors.add(flavor.name)
    }
    boolean hasFlavors = flavors.size() > 0
    def date = new Date().format("MMdd-HH-mm-ss")

    /**
     * bak apk and mapping
     */
    android.applicationVariants.all { variant ->
        /**
         * task type, you want to bak
         */
        def taskName = variant.name

        tasks.all {
            if ("assemble${taskName.capitalize()}".equalsIgnoreCase(it.name)) {

                it.doLast {
                    copy {
                        def fileNamePrefix = "${project.name}-${variant.baseName}"
                        def newFileNamePrefix = hasFlavors ? "${fileNamePrefix}" : "${fileNamePrefix}-${date}"

                        def destPath = hasFlavors ? file("${bakPath}/${project.name}-${date}/${variant.flavorName}") : bakPath
                        from variant.outputs.first().outputFile
                        into destPath
                        rename { String fileName ->
                            fileName.replace("${fileNamePrefix}.apk", "${newFileNamePrefix}.apk")
                        }

                        from "${buildDir}/outputs/mapping/${variant.dirName}/mapping.txt"
                        into destPath
                        rename { String fileName ->
                            fileName.replace("mapping.txt", "${newFileNamePrefix}-mapping.txt")
                        }

                        from "${buildDir}/intermediates/symbols/${variant.dirName}/R.txt"
                        into destPath
                        rename { String fileName ->
                            fileName.replace("R.txt", "${newFileNamePrefix}-R.txt")
                        }
                    }
                }
            }
        }
    }
    project.afterEvaluate {
        //sample use for build all flavor for one time
        if (hasFlavors) {
            task(tinkerPatchAllFlavorRelease) {
                group = 'tinker'
                def originOldPath = getTinkerBuildFlavorDirectory()
                for (String flavor : flavors) {
                    def tinkerTask = tasks.getByName("tinkerPatch${flavor.capitalize()}Release")
                    dependsOn tinkerTask
                    def preAssembleTask = tasks.getByName("process${flavor.capitalize()}ReleaseManifest")
                    preAssembleTask.doFirst {
                        String flavorName = preAssembleTask.name.substring(7, 8).toLowerCase() + preAssembleTask.name.substring(8, preAssembleTask.name.length() - 15)
                        project.tinkerPatch.oldApk = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-release.apk"
                        project.tinkerPatch.buildConfig.applyMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-release-mapping.txt"
                        project.tinkerPatch.buildConfig.applyResourceMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-release-R.txt"

                    }

                }
            }

            task(tinkerPatchAllFlavorDebug) {
                group = 'tinker'
                def originOldPath = getTinkerBuildFlavorDirectory()
                for (String flavor : flavors) {
                    def tinkerTask = tasks.getByName("tinkerPatch${flavor.capitalize()}Debug")
                    dependsOn tinkerTask
                    def preAssembleTask = tasks.getByName("process${flavor.capitalize()}DebugManifest")
                    preAssembleTask.doFirst {
                        String flavorName = preAssembleTask.name.substring(7, 8).toLowerCase() + preAssembleTask.name.substring(8, preAssembleTask.name.length() - 13)
                        project.tinkerPatch.oldApk = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-debug.apk"
                        project.tinkerPatch.buildConfig.applyMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-debug-mapping.txt"
                        project.tinkerPatch.buildConfig.applyResourceMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-debug-R.txt"
                    }

                }
            }
        }
    }
}

其中,有几点配置在这里说明一下,方便理解后续的操作(当tinkerEnabled = true的情况下):

  1. app的生成目录是:主Module(一般是名为app)/build/bakApk文件夹。
  2. 补丁包的生成路径:主Module(一般是名为app)/build/outputs/apk/tinkerPatch/debug/patch_signed_7zip.apk。
  3. 基础包的名字:old-app.apk,放于bakApk文件夹下。
  4. 基础包的mapping.txt和R.txt文件一般在编译release签名的apk时才会用到。
  5. 在用到mapping.txt文件时,需要重命名为old-app-mapping.txt,放于bakApk文件夹下。
  6. 在用到R.txt文件时,需要重命名为old-app-R.txt,放于bakApk文件夹下。

Tinker准备工作

1. 添加一些类

1.这里面有一些我找到的文件,需要加到工程中,当然这些工具类也可以自己写

image.png

这些类的作用大概如下
SampleUncaughtExceptionHandler:Tinker的全局异常捕获器。
MyLogImp:Tinker的日志输出实现类。
SampleLoadReporter:加载补丁时的一些回调。
SamplePatchListener:过滤Tinker收到的补丁包的修复、升级请求。
SamplePatchReporter:修复或者升级补丁时的一些回调。
SampleTinkerReport:修复结果(成功、冲突、失败等)。
SampleResultService::patch补丁合成进程将合成结果返回给主进程的类。
TinkerManager:Tinker管理器(安装、初始化Tinker)。
TinkerUtils:拓展补丁条件判定、锁屏或后台时应用重启功能的工具类。
对于这些自定义类及错误码的详细说明,请参考:「Tinker官方Wiki:可选的自定义类」

2. 清单文件中添加服务

android:name="com.lqr.tinker.service.SampleResultService"
android:exported="false"/>

编写Application代理

Tinker表示,Application无法动态修复,所以有两种选择:

  1. 使用「继承TinkerApplication + DefaultApplicationLike」。
  2. 使用「DefaultLifeCycle注解 + DefaultApplicationLike」。
    我们在这里使用第二种方式
@SuppressWarnings("unused")
@DefaultLifeCycle(application = "com.lqr.tinker.MyApplication",// application类名。只能用字符串,这个MyApplication文件是不存在的,但可以在AndroidManifest.xml的application标签上使用(name)
        flags = ShareConstants.TINKER_ENABLE_ALL,// tinkerFlags
        loaderClass = "com.tencent.tinker.loader.TinkerLoader",//loaderClassName, 我们这里使用默认即可!(可不写)
        loadVerifyFlag = false)//tinkerLoadVerifyFlag
public class TinkerApplicationLike extends DefaultApplicationLike {

    private Application mApplication;
    private Context mContext;
    private Tinker mTinker;

    // 固定写法
    public TinkerApplicationLike(Application application, int tinkerFlags, boolean tinkerLoadVerifyFlag, long applicationStartElapsedTime, long applicationStartMillisTime, Intent tinkerResultIntent) {
        super(application, tinkerFlags, tinkerLoadVerifyFlag, applicationStartElapsedTime, applicationStartMillisTime, tinkerResultIntent);
    }

    // 固定写法
    @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
    public void registerActivityLifecycleCallbacks(Application.ActivityLifecycleCallbacks callback) {
        getApplication().registerActivityLifecycleCallbacks(callback);
    }

    @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
    @Override
    public void onBaseContextAttached(Context base) {
        super.onBaseContextAttached(base);
        mApplication = getApplication();
        mContext = getApplication();
        initTinker(base);
        // 可以将之前自定义的Application中onCreate()方法所执行的操作搬到这里...
    }

    private void initTinker(Context base) {
        // tinker需要你开启MultiDex
        MultiDex.install(base);

        TinkerManager.setTinkerApplicationLike(this);
        // 设置全局异常捕获
        TinkerManager.initFastCrashProtect();
        //开启升级重试功能(在安装Tinker之前设置)
        TinkerManager.setUpgradeRetryEnable(true);
        //设置Tinker日志输出类
        TinkerInstaller.setLogIml(new MyLogImp());
        //安装Tinker(在加载完multiDex之后,否则你需要将com.tencent.tinker.**手动放到main dex中)
        TinkerManager.installTinker(this);
        mTinker = Tinker.with(getApplication());
    }

}

在Application中添加TinkerApplicationLike

public class TinkerApplicationLike extends DefaultApplicationLike {
    ...
    @Override
    public void onCreate() {
        super.onCreate();
        // 将之前自定义的Application中onCreate()方法所执行的操作搬到这里...
    }

    @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
    @Override
    public void onBaseContextAttached(Context base) {
        super.onBaseContextAttached(base);
        mApplication = getApplication();
        mContext = getApplication();
        initTinker(base);
        // 或搬到这里...
    }
}

使用TinkerApplication

通过上面的注释,MyApplication会自动生成,我使用了自定义的Application使用方法如下

public class MyApplicatoin extends TinkerApplication {

   public MyApplicatoin(){
       super(ShareConstants.TINKER_ENABLE_ALL,TinkerApplicationLike.class.getName(),TinkerLoader.class.getName(),false);
   }
   
}

Tinker的使用

1.编译基础包
在Terminal中使用命令行./gradlew assembleDebug之后再module的build目录下会有个bakApk目录,在这目录中就会生成基础包
其实在配置完Tinker的配置后,make项目,就看这个目录下已经存在基础包了


image.png

如果这个apk文件是release签名且是要放到应用市场上的,那么你必须将apk与R.txt(如果有使用混淆的话,还会有一个mapping.txt)这几个文件保存好,切记。
2.制作补丁包
将要修复的基础包apk重命名为old-app.apk
点击tinkerPatchDebug


image.png

在output文件夹中生成分叉包


image.png

在这里我遇到了一个问题
在编译的时候补丁包的时候总是不成功报
com.tencent.tinker.build.util.TinkerPatchException:
解决方法如下
image.png

3.将生成的补丁包patch_signed_7zip.apk放置指定目录下
执行TinkerInstaller.onReceiveUpgradePatch方法进行安装
之后成功修复

以上步骤可以修复java、so 、资源文件

加载SO注意的地方

image.png

上图是Tinker官网Wiki的文档部分截图,从红线部分可以知道,因为部分手机判断abi并不准确(可能因为Android碎片化比较严重吧),Tinker没有区分abi,自然也不会在app启动时,自动加载对应的so库,这需要开发者自己判断。
需要调用如下方法

public void load_library_hack(View view) {
    String CPU_ABI = android.os.Build.CPU_ABI;
    // 将tinker library中的 CPU_ABI架构的so 注册到系统的library path中。
    TinkerLoadLibrary.installNavitveLibraryABI(this, CPU_ABI);
}

常用API

1、请求打补丁
TinkerInstaller.onReceiveUpgradePatch(context, 补丁包的本地路径);
2、卸载补丁
Tinker.with(getApplicationContext()).cleanPatch();// 卸载所有的补丁
Tinker.with(getApplicationContext()).cleanPatchByVersion(版本号)// 卸载指定版本的补丁
复制代码
3、杀死应用的其他进程
ShareTinkerInternals.killAllOtherProcess(getApplicationContext());
复制代码
4、Hack方式修复so
TinkerLoadLibrary.installNavitveLibraryABI(this, abi);
abi:cpu架构类型

5、非Hack方式修复so
TinkerLoadLibrary.loadLibraryFromTinker(getApplicationContext(), "lib/" + abi, so库的模块名); // 加载任意abi库
TinkerLoadLibrary.loadArmLibrary(getApplicationContext(), so库的模块名); // 只适用于加载armeabi库
TinkerLoadLibrary.loadArmV7Library(getApplicationContext(), so库的模块名); // 只适用于加载armeabi-v7a库
复制代码

loadArmLibrary()与loadArmV7Library()本质是调用了loadLibraryFromTinker(),有兴趣的可以查看下源码。
对于Tinker所有API的详细说明,请参考:「Tinker官方Wiki:Tinker-API概览」。

Tinker原理

Tinker的原理,类加载方案基于Dex分包方案,什么是Dex分包方案呢?这个得先从65536限制和LinearAlloc限制说起。
65536限制
随着应用功能越来越复杂,代码量不断地增大,引入的库也越来越多,可能会在编译时提示如下异常:

com.android.dex.DexIndexOverflowException: method ID not in [0, 0xffff]: 65536

这说明应用中引用的方法数超过了最大数65536个。产生这一问题的原因就是系统的65536限制,65536限制的主要原因是DVM Bytecode的限制,DVM指令集的方法调用指令invoke-kind索引为16bits,最多能引用 65535个方法。
LinearAlloc限制
在安装时可能会提示INSTALL_FAILED_DEXOPT。产生的原因就是LinearAlloc限制,DVM中的LinearAlloc是一个固定的缓存区,当方法数过多超出了缓存区的大小时会报错。

为了解决65536限制和LinearAlloc限制,从而产生了Dex分包方案。Dex分包方案主要做的是在打包时将应用代码分成多个Dex,将应用启动时必须用到的类和这些类的直接引用类放到主Dex中,其他代码放到次Dex中。当应用启动时先加载主Dex,等到应用启动后再动态的加载次Dex,从而缓解了主Dex的65536限制和LinearAlloc限制。
在ClassLoader的加载过程,其中一个环节就是调用DexPathList的findClass的方法,如下所示。
libcore/dalvik/src/main/java/dalvik/system/DexPathList.java

public Class findClass(String name, List suppressed) {
       for (Element element : dexElements) {//1
           Class clazz = element.findClass(name, definingContext, suppressed);//2
           if (clazz != null) {
               return clazz;
           }
       }
       if (dexElementsSuppressedExceptions != null) {
           suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
       }
       return null;
   }

Element内部封装了DexFile,DexFile用于加载dex文件,因此每个dex文件对应一个Element。
多个Element组成了有序的Element数组dexElements。当要查找类时,会在注释1处遍历Element数组dexElements(相当于遍历dex文件数组),注释2处调用Element的findClass方法,其方法内部会调用DexFile的loadClassBinaryName方法查找类。如果在Element中(dex文件)找到了该类就返回,如果没有找到就接着在下一个Element中进行查找。
根据上面的查找流程,我们将有bug的类Key.class进行修改,再将Key.class打包成包含dex的补丁包Patch.jar,放在Element数组dexElements的第一个元素,这样会首先找到Patch.dex中的Key.class去替换之前存在bug的Key.class,排在数组后面的dex文件中的存在bug的Key.class根据ClassLoader的双亲委托模式就不会被加载,这就是类加载方案,如下图所示。


image.png

类加载方案需要重启App后让ClassLoader重新加载新的类,为什么需要重启呢?这是因为类是无法被卸载的,因此要想重新加载新的类就需要重启App,因此采用类加载方案的热修复框架是不能即时生效的。
虽然很多热修复框架采用了类加载方案,但具体的实现细节和步骤还是有一些区别的,比如QQ空间的超级补丁和Nuwa是按照上面说得将补丁包放在Element数组的第一个元素得到优先加载。微信Tinker将新旧apk做了diff,得到patch.dex,然后将patch.dex与手机中apk的classes.dex做合并,生成新的classes.dex,然后在运行时通过反射将classes.dex放在Element数组的第一个元素。饿了么的Amigo则是将补丁包中每个dex 对应的Element取出来,之后组成新的Element数组,在运行时通过反射用新的Element数组替换掉现有的Element 数组。

采用类加载方案的主要是以腾讯系为主,包括微信的Tinker、QQ空间的超级补丁、手机QQ的QFix、饿了么的Amigo和Nuwa等等。

demo地址

参考刘望舒博客

你可能感兴趣的:(腾讯系热修复-Tinker使用及原理)