移动架构02-Tinker热修复的分析与使用

移动架构02-Tinker热修复

Tinker是目前开源框架中兼容性最好的热修复框架。

Tinker的Github地址:https://github.com/Tencent/tinker

一、为什么使用Tinker

1.总体比较

当前市面的热补丁方案有很多,其中比较出名的有微信的Tinker、阿里的AndFix、美团的Robust以及QZone的超级补丁方案。那么为什么使用Tinker呢?我们先来做下简单对比。

Tinker QZone AndFix Robust
类替换 yes yes no
So替换 yes no no
资源替换 yes yes no
全平台支持 yes yes yes
即时生效 no no yes
性能损耗 较小 较大 较小
补丁包大小 较小 较大 一般
开发透明 yes yes no
复杂度 较低 较低 复杂
gradle支持 yes no no
Rom体积 较大 较小 较小
成功率 较高 较高 一般

总的来说:

  1. AndFix是native层的修复方案,优点是即时生效,缺点是只能修复方法、并且成功率不高;
  2. Robust也native层的修复方案,优点是即时生效,并且成功率很高,缺点也是只能修复方法;
  3. Qzone(非开源)是Java层的修复方案,优点是可以修复类和资源,缺点是补丁包大、性能差、不能即时生效;
  4. Tinker也是Java层的修复方案,优点是可以修复类、so和资源,补丁包小,缺点是Rom体积大、不能即时生效;

2.实现对比

热修复是基于hook技术实现的,它可以动态修改内存中的代码,但是不能修改在SD卡中的dex文件。

AndFix

AndFix采用native hook的方式,这套方案直接使用dalvik_replaceMethod替换class中方法的实现。由于它并没有整体替换class, 而field在class中的相对地址在class加载时已确定,所以AndFix无法支持新增或者删除filed的情况(通过替换initclinit只可以修改field的数值)。

QZone

QZone方案并没有开源,但在github上的HotFix采用了相同的方式。这个方案使用classloader的方式,将修复好的类生成dex文件,将这个dex文件插入到系统的dex数组的前面,当系统加载这个类时,优先从dex数组的签名查找,找到后就加载到内存中,从而实现类的替换。

但是直接使用classloader的方式,被修复的类和它的引用类不处于同一个dex中,会带来unexpected DEX problem

因为,apk在安装时,虚拟机(dexopt)会把apk中的classes.dex优化成odex文件。优化时,如果启动参数verify为true,就会执行dvmVerifyClass进行类的校验,如果一个类的直接引用类和calzz都在同一个dex中的话,那么这个类就会被打上CLASS_ISPREVERIFIED。当加载这个类时,由于它有CLASS_ISPREVERIFIED标记,会先进行dex校验,验证它的引用类和它是否处于同一个dex中,如果不处于同一个dex就会unexpected DEX problem

为了解决unexpected DEX problem,QZone使用插桩的方式,在每一个类中加入如下代码,防止他们被打上CLASS_ISPREVERIFIED:

if (ClassVerifier.PREVENT_VERIFY) {
    System.out.println(AntilazyLoad.class);//AntilazyLoad处于一个独立的dex中
}

可是,Android系统做类校验是非常有意义的,在Dalvik与Art中使用插桩式都会产生一些问题。

  • Dalvik; 在dexopt过程,若class verify通过会写入pre-verify标志,在经过optimize之后再写入odex文件。这里的optimize主要包括inline以及quick指令优化等。
  • Art; Art采用了新的方式,插桩对代码的执行效率并没有什么影响。但是若补丁中的类出现修改类变量或者方法,可能会导致出现内存地址错乱的问题。为了解决这个问题我们需要将修改了变量、方法以及接口的类的父类以及调用这个类的所有类都加入到补丁包中。这可能会带来补丁包大小的急剧增加。

Tinker

Tinker在编译时通过新旧两个Dex生成差异path.dex。在运行时,将差异patch.dex重新跟原始安装包的旧Dex还原为新的Dex。这个过程可能比较耗费时间与内存,所以我们是单独放在一个后台进程:patch中。为了补丁包尽量的小,微信自研了DexDiff算法,它深度利用Dex的格式来减少差异的大小。它的粒度是Dex格式的每一项,可以充分利用原本Dex的信息,而BsDiff的粒度是文件,AndFix/QZone的粒度为class。

二、Tinker怎么使用

1.添加gradle依赖

在gradle.properties中配置tinker的版本和ID

TINKER_VERSION = 1.9.1
TINKER_ID = 100

在项目的build.gradle中,添加tinker-patch-gradle-plugin的依赖

buildscript {
    dependencies {
         //添加tinker热修复插件
        classpath "com.tencent.tinker:tinker-patch-gradle-plugin:${TINKER_VERSION}"
    }
}

增加tinker的gradle文件:tinker.gradle

/*----------------------------------------配置tinker插件----------------------------------------------*/
apply plugin: 'com.tencent.tinker.patch'
//获取提交git的版本号,作为tinkerid
def gitSha() {
    try {
        String gitRev = 'git rev-parse --short HEAD'.execute(null, project.rootDir).text.trim()
        if (gitRev == null) {
            throw new GradleException("can't get git rev, you should add git to system path or just input test value, such as 'testTinkerId'")
        }
        return gitRev
    } catch (Exception e) {
        throw new GradleException("can't get git rev, you should add git to system path or just input test value, such as 'testTinkerId'")
    }
}

def bakPath = file("${buildDir}/bakApk/")

/**
 * you can use assembleRelease to build you base apk
 * use tinkerPatchRelease -POLD_APK=  -PAPPLY_MAPPING=  -PAPPLY_RESOURCE= to build patch
 * add apk from the build/bakApk
 */
ext {
    //for some reason, you may want to ignore tinkerBuild, such as instant run debug build?
    tinkerEnabled = true

    //for normal build
    //old apk file to build patch apk
    tinkerOldApkPath = "${bakPath}/app-debug-0430-23-10-22.apk"
    //proguard mapping file to build patch apk
    tinkerApplyMappingPath = "${bakPath}/app-debug-1018-17-32-47-mapping.txt"
    //resource R.txt to build patch apk, must input if there is resource changed
    tinkerApplyResourcePath = "${bakPath}/app-debug-0430-23-10-22-R.txt"

    //only use for build all flavor, if not, just ignore this field
    tinkerBuildFlavorDirectory = "${bakPath}/app-1018-17-32-47"
}


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 : gitSha()
}


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

def getTinkerBuildFlavorDirectory() {
    return ext.tinkerBuildFlavorDirectory
}

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

    tinkerPatch {
        oldApk = getOldApkPath()
        ignoreWarning = false
        useSign = true
        tinkerEnable = buildWithTinker()
        buildConfig {
            applyMapping = getApplyMappingPath()
            applyResourceMapping = getApplyResourceMappingPath()
            tinkerId = getTinkerIdValue()
            keepDexApply = false
            isProtectedApp = false
            supportHotplugComponent = false
        }

        dex {
            dexMode = "jar"
            pattern = ["classes*.dex", "assets/secondary-dex-?.jar"]
            loader = [ "tinker.sample.android.app.BaseBuildInfo"]
        }

        lib {
            /**
             * optional,default '[]'
             * what library in apk are expected to deal with tinkerPatch
             * it support * or ? pattern.
             * for library in assets, we would just recover them in the patch directory
             * you can get them in TinkerLoadResult with Tinker
             */
            pattern = ["lib/*/*.so"]
        }

        res {
            pattern = ["res/*", "assets/*", "resources.arsc", "AndroidManifest.xml"]
            ignoreChange = ["assets/sample_meta.txt"]
            largeModSize = 100
        }

        packageConfig {
            configField("patchMessage", "tinker is sample to use")
            configField("platform", "all")
            configField("patchVersion", "1.0")
        }

        sevenZip {
            zipArtifact = "com.tencent.mm:SevenZip:1.1.10"
        }
    }

    List<String> 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")

    android.applicationVariants.all { variant ->

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

                }
            }
        }
    }
}

然后在app的build.gradle中,我们需要添加tinker的库依赖以及应用tinker的gradle文件.

apply from: 'tinker.gradle'
android {
    defaultConfig {
        ...
        multiDexEnabled true
    }

//以下是签名信息,测试环境可以省略
//    signingConfigs {
//        release {
//            try {
//                storeFile file("./keystore/release.keystore")
//                storePassword "testres"
//                keyAlias "testres"
//                keyPassword "testres"
//            } catch (ex) {
//                throw new InvalidUserDataException(ex.toString())
//            }
//        }
//
//        debug {
//            storeFile file("./keystore/debug.keystore")
//        }
//    }
//    buildTypes {
//        release {
//            minifyEnabled true
//            signingConfig signingConfigs.release
//            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
//        }
//        debug {
//            debuggable true
//            minifyEnabled false
//            signingConfig signingConfigs.debug
//        }
//    }

//支持jni的热修复
//    sourceSets {
//        main {
//            jniLibs.srcDirs = ['libs']
//        }
//    }
}

dependencies {
    ...
    //gradle 3.0.0的一定要使用如下的依赖
    implementation("com.tencent.tinker:tinker-android-lib:${TINKER_VERSION}") { changing = true }
    annotationProcessor("com.tencent.tinker:tinker-android-anno:${TINKER_VERSION}") { changing = true }
    compileOnly("com.tencent.tinker:tinker-android-anno:${TINKER_VERSION}") { changing = true }
    //Android5.0以下需要添加multidex库.如果其他的库包含了multidex库,就不需要额外添加了。
    implementation "com.android.support:multidex:1.0.1"
}

2.生成差异包

  1. 使用AS右上角的Gradle-app-Tasks-build-assembleDebug编译,会在app-build-bakApk中生成apk文件和R文件,将它安装到手机上;
  2. 将tinker.gradle中的tinkerOldApkPath和tinkerApplyResourcePath,改成在app-build-bakApk中新生成的apk文件和R文件路径,然后修改项目的代码或者资源;
  3. 使用AS右上角的Gradle-app-Tasks-tinker-tinkerPatchDebug编译,会在app-build-outputs-apk-tinkerPatch-debug下生成patch_signed_7zip.apk,即为补丁包。将patch_signed_7zip.apk通过adb命令放入SD的根目录下
  4. 点击LOAD PATCH按钮,并重启App,热修复就完成了。

3.Release的使用方法

Tinker的使用方式如下,以gradle接入的release包为例:

  1. 每次编译或发包将安装包与mapping文件备份;
  2. 若有补丁包的需要,按自身需要修改你的代码、库文件等;
  3. 将备份的基准安装包与mapping文件输入到tinkerPatch的配置中;
  4. 运行tinkerPatchRelease,即可自动编译最新的安装包,并与输入基准包作差异,得到最终的补丁包。

三、Tinker的已知问题

由于原理与系统限制,Tinker有以下已知问题:

  1. Tinker不支持修改AndroidManifest.xml,Tinker不支持新增四大组件(1.9.0支持新增非export的Activity);
  2. 由于Google Play的开发者条款限制,不建议在GP渠道动态更新代码;
  3. 在Android N上,补丁对应用启动时间有轻微的影响;
  4. 不支持部分三星android-21机型,加载补丁时会主动抛出"TinkerRuntimeException:checkDexInstall failed"
  5. 对于资源替换,不支持修改remoteView。例如transition动画,notification icon以及桌面图标。

demo已上传gitee,喜欢的同学可以下载,顺便点个赞!

上一篇:移动架构01-常见设计模式与AOP编程

下一篇:移动架构03-深入解析面向对象数据库框架

猜你喜欢

转载自blog.csdn.net/dshdhsd/article/details/80154832