Android 热更新技术实现方案-Tinker

介绍

Tinker是微信官方的Android热补丁解决方案,它支持动态下发代码、So库以及资源,让应用能够在不需要重新安装的情况下实现更新。Tinker 官网地址:https://github.com/Tencent/tinker

为什么要用Tinker

市面上的热修复工具很多,比较流行的无非有阿里的AndFix、美团的Robust以及QZone的超级补丁方案。但它们都存在无法解决的问题。他们具体的对比如下:

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

通过以上对比和实际情况,选择了Tinker。

a. AndFix作为native解决方案,首先面临的是稳定性与兼容性问题,更重要的是它无法实现类替换,它是需要大量额外的开发成本的;

b. Robust兼容性与成功率较高,但是它与AndFix一样,无法新增变量与类只能用做的bugFix方案;

c. Qzone方案可以做到发布产品功能,但是它主要问题是插桩带来Dalvik的性能问题,以及为了解决Art下内存地址问题而导致补丁包急速增大的。

特别是在Android N之后,由于混合编译的inline策略修改,对于市面上的各种方案都不太容易解决。而Tinker热补丁方案不仅支持类、So以及资源的替换,它还是2.X-8.X(1.9.0以上支持8.X)的全平台支持。利用Tinker我们不仅可以用做bugfix,甚至可以替代功能的发布。Tinker已运行在微信的数亿Android设备上。

限制

由于原理与系统限制,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以及桌面图标。

实现原理

Tinker进行热修复的流程为:

  • 新dex与旧dex通过dex差分算法生成差异包 patch.dex
  • 将patch dex下发到客户端,客户端将patch dex与旧dex合成为新的全量dex
  • 将合成后的全量dex 插入到dex elements前面,完成修复

其流程图如下:

如何接入

  1. 在build.gradle 文件中添加 tinker-patch-gradle-plugin 的依赖
buildscript {
   
    dependencies {
   
        classpath ('com.tencent.tinker:tinker-patch-gradle-plugin:1.9.1')
    }
}
  1. 在App的Gradle 文件 app/build.gradle 中添加依赖
api("com.tencent.tinker:tinker-android-lib:${
     TINKER_VERSION}") {
    changing = true }

// Maven local cannot handle transist dependencies.
implementation("com.tencent.tinker:tinker-android-loader:${
     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 }
compileOnly("com.tencent.tinker:tinker-android-anno-support:${
     TINKER_VERSION}") {
    changing = true }
  1. 开启multiDexEnabled
android{
   
    defaultConfig {
   
        multiDexEnabled true
        multiDexKeepProguard file("tinker_multidexkeep.pro")
        buildConfigField "String","MESSAGE","\"I am the base apk\""
        buildConfigField "String","TINKER_ID","\"${
     getTinkerIdValue()}\""
        buildConfigField "String","PLATFORM","\"all\""
    }
}

def getTinkerIdValue(){
   
    return hasProperty("TINKER_ID")?TINKER_ID : gitSha()
}
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'")
    }
}
  1. 在gradle.properties文件中添加以下内容
TINKER_VERSION=1.9.14.18
GRADLE_3=true
TINKER_ID=1.0
TINKER_ENABLE=true
  1. 在app/build.gradle 中添加以下内容
sourceSets {
   
    main {
   
        jniLibs.srcDirs = ['libs']
    }
}

packagingOptions {
   
    exclude "/META-INF/**"
}
//签名文件
signingConfigs {
   
    //debug
    debug {
   
        //签名文件位置
        storeFile file("C:\\Users\\Administrator\\Desktop\\公司资料\\签名包\\wl_shenling")
        //密码
        storePassword '123456'
        //别名
        keyAlias 'sl'
        //别名对应的密码
        keyPassword '123456'
    }
    //release
    release {
   
        storeFile file("C:\\Users\\Administrator\\Desktop\\公司资料\\签名包\\wl_shenling")
        storePassword '123456'
        keyAlias 'sl'
        keyPassword '123456'
    }
}
buildTypes {
   
    release {
   
        minifyEnabled true
        proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        signingConfig signingConfigs.release
    }
    debug {
   
        minifyEnabled true
        proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        signingConfig signingConfigs.release
    }
}
  1. 在app/proguard-rules.pro 文件中添加以下内容
# JDK目标版本1.8
#-target 1.8
# 不做收缩(删除注释、未被引用代码)
-dontshrink
# 不做优化(变更代码实现逻辑)
-dontoptimize
# 不用大小写混合类名机制
-dontusemixedcaseclassnames
# 不去忽略非公共的库类
-dontskipnonpubliclibraryclasses
-dontskipnonpubliclibraryclassmembers
# 确定统一的混淆类的成员名称来增加混淆
-useuniqueclassmembernames
# 优化时允许访问并修改有修饰符的类和类的成员
-allowaccessmodification
-keepdirectories
-ignorewarnings
  1. 在app 目录下新建tinker_multidexkeep.pro 文件,并在文件中添加以下内容
// tinker multidex keep patterns:
-keep public class * implements com.tencent.tinker.entry.ApplicationLifeCycle {
    <init>(...);
    void onBaseContextAttached(android.content.Context);
}

-keep public class com.tencent.tinker.entry.ApplicationLifeCycle {
    *;
}

-keep public class * extends com.tencent.tinker.loader.TinkerLoader {
    <init>(...);
}

-keep public class * extends android.app.Application {
     <init>();
     void attachBaseContext(android.content.Context);
}


// 这里是你应用本身的Application
-keep class com.xy.app.SampleApplication {
    <init>(...);
}

-keep class com.tencent.tinker.loader.** {
    <init>(...);
}

-keep class android.support.test.internal** { *; }
-keep class org.junit.** { *; }
  1. 配置tinker 相关内容
def bakPath = file("${
     buildDir}/bakApk/")
ext {
   
    // 是否启用tinker
    tinkerEnabled = true


    //要生成的补丁所对应的apk
    tinkerOldApkPath = "${
     bakPath}/app-release-1210-11-36-35.apk"
    //上面apk 对应的mapping 文件
    tinkerApplyMappingPath = "${
     bakPath}/app-release-1210-11-36-35-mapping.txt"
    //其对应的R 文件
    tinkerApplyResourcePath = "${
     bakPath}/app-release-1210-11-36-35-R.txt"

    //only use for build all flavor, if not, just ignore this field
    //仅用于构建所有风格,否则忽略此字段
    tinkerBuildFlavorDirectory = "${
     bakPath}/app-1210-11-36-35"
}


// 下面内容一般情况下不用改动,直接赋值就行
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
}
if (buildWithTinker()) {
   
    apply plugin: 'com.tencent.tinker.patch'

    tinkerPatch {
   
        /**
         * necessary,default 'null'
         * the old apk path, use to diff with the new apk to build
         * add apk from the build/bakApk
         */
        oldApk = getOldApkPath()
        /**
         * optional,default 'false'
         * there are some cases we may get some warnings
         * if ignoreWarning is true, we would just assert the patch process
         * case 1: minSdkVersion is below 14, but you are using dexMode with raw.
         *         it must be crash when load.
         * case 2: newly added Android Component in AndroidManifest.xml,
         *         it must be crash when load.
         * case 3: loader classes in dex.loader{} are not keep in the main dex,
         *         it must be let tinker not work.
         * case 4: loader classes in dex.loader{} changes,
         *         loader classes is ues to load patch dex. it is useless to change them.
         *         it won't crash, but these changes can't effect. you may ignore it
         * case 5: resources.arsc has changed, but we don't use applyResourceMapping to build
         */
        ignoreWarning = true

        /**
         * optional,default 'true'
         * whether sign the patch file
         * if not, you must do yourself. otherwise it can't check success during the patch loading
         * we will use the sign config with your build type
         */
        useSign = true

        /**
         * optional,default 'true'
         * whether use tinker to build
         */
        tinkerEnable = buildWithTinker()

        /**
         * Warning, applyMapping will affect the normal android build!
         */
        buildConfig {
   
            /**
             * optional,default 'null'
             * if we use tinkerPatch to build the patch apk, you'd better to apply the old
             * apk mapping file if minifyEnabled is enable!
             * Warning:
             * you must be careful that it will affect the normal assemble build!
             */
            applyMapping = getApplyMappingPath()
            /**
             * optional,default 'null'
             * It is nice to keep the resource id from R.txt file to reduce java changes
             */
            applyResourceMapping = getApplyResourceMappingPath()

            /**
             * necessary,default 'null'
             * because we don't want to check the base apk with md5 in the runtime(it is slow)
             * tinkerId is use to identify the unique base apk when the patch is tried to apply.
             * we can use git rev, svn rev or simply versionCode.
             * we will gen the tinkerId in your manifest automatic
             */
            tinkerId = getTinkerIdValue()

            /**
             * if keepDexApply is true, class in which dex refer to the old apk.
             * open this can reduce the dex diff file size.
             */
            keepDexApply = false

            /**
             * optional, default 'false'
             * Whether tinker should treat the base apk as the one being protected by app
             * protection tools.
             * If this attribute is true, the generated patch package will contain a
             * dex including all changed classes instead of any dexdiff patch-info files.
             */
            isProtectedApp = false

            /**
             * optional, default 'false'
             * Whether tinker should support component hotplug (add new component dynamically).
             * If this attribute is true, the component added in new apk will be available after
             * patch is successfully loaded. Otherwise an error would be announced when generating patch
             * on compile-time.
             *
             * <b>Notice that currently this feature is incubating and only support NON-EXPORTED Activity</b>
             */
            supportHotplugComponent = false
        }

        dex {
   
            /**
             * optional,default 'jar'
             * only can be 'raw' or 'jar'. for raw, we would keep its original format
             * for jar, we would repack dexes with zip format.
             * if you want to support below 14, you must use jar
             * or you want to save rom or check quicker, you can use raw mode also
             */
            dexMode = "jar"

            /**
             * necessary,default '[]'
             * what dexes in apk are expected to deal with tinkerPatch
             * it support * or ? pattern.
             */
            pattern = ["classes*.dex"]
            /**
             * necessary,default '[]'
             * Warning, it is very very important, loader classes can't change with patch.
             * thus, they will be removed from patch dexes.
             * you must put the following class into main dex.
             * Simply, you should add your own application {@code tinker.sample.android.SampleApplication}
             * own tinkerLoader, and the classes you use in them
             *
             */
            loader = [
                //use sample, let BaseBuildInfo unchangeable with tinker
                "com.xy.app.BaseBuildInfo"
                //                    "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 {
   
            /**
             * optional,default '[]'
             * 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 = ["res/*", "assets/*", "resources.arsc", "AndroidManifest.xml"]

            /**
             * optional,default '[]'
             * the resource file exclude patterns, ignore add, delete or modify resource change
             * it support * or ? pattern.
             * Warning, we can only use for files no relative with resources.arsc
             */
            //            ignoreChange = ["assets/sample_meta.txt"]

            /**
             * default 100kb
             * for modify resource, if it is larger than 'largeModSize'
             * we would like to use bsdiff algorithm to reduce patch file size
             */
            largeModSize = 100
        }

        packageConfig {
   
            /**
             * optional,default 'TINKER_ID, TINKER_ID_VALUE' 'NEW_TINKER_ID, NEW_TINKER_ID_VALUE'
             * package meta file gen. path is assets/package_meta.txt in patch file
             * you can use securityCheck.getPackageProperties() in your ownPackageCheck method
             * or TinkerLoadResult.getPackageConfigByName
             * we will get the TINKER_ID from the old apk manifest for you automatic,
             * other config files (such as patchMessage below)is not necessary
             */
            configField("patchMessage", "tinker is sample to use")
            /**
             * just a sample case, you can use such as sdkVersion, brand, channel...
             * you can parse it in the SamplePatchListener.
             * Then you can use patch conditional!
             */
            configField("platform", "all")
            /**
             * patch version via packageConfig
             */
            configField("patchVersion", "1.0")
        }
        //or you can add config filed outside, or get meta value from old apk
        //project.tinkerPatch.packageConfig.configField("test1", project.tinkerPatch.packageConfig.getMetaDataFromOldApk("Test"))
        //project.tinkerPatch.packageConfig.configField("test2", "sample")

        /**
         * if you don't use zipArtifact or path, we just use 7za to try
         */
        sevenZip {
   
            /**
             * optional,default '7za'
             * the 7zip artifact path, it will use the right 7za with your platform
             */
            zipArtifact = "com.tencent.mm:SevenZip:1.1.10"
            /**
             * optional,default '7za'
             * you can specify the 7za path yourself, it will overwrite the zipArtifact value
             */
            //        path = "/usr/local/bin/7za"
        }
    }

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

    /**
     * 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

                        if (variant.metaClass.hasProperty(variant, 'packageApplicationProvider')) {
   
                            def packageAndroidArtifact = variant.packageApplicationProvider.get()
                            if (packageAndroidArtifact != null) {
   
                                try {
   
                                    from new File(packageAndroidArtifact.outputDirectory.getAsFile().get(), variant.outputs.first().apkData.outputFileName)
                                } catch (Exception e) {
   
                                    from new File(packageAndroidArtifact.outputDirectory, variant.outputs.first().apkData.outputFileName)
                                }
                            } else {
   
                                from variant.outputs.first().mainOutputFile.outputFile
                            }
                        } else {
   
                            from variant.outputs.first().outputFile
                        }

                        into destPath
                        rename {
    String fileName ->
                            fileName.replace("${
     fileNamePrefix}.apk", "${
     newFileNamePrefix}.apk")
                        }

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

                        from "${
     buildDir}/intermediates/symbols/${
     dirName}/R.txt"
                        from "${
     buildDir}/intermediates/symbol_list/${
     dirName}/R.txt"
                        from "${
     buildDir}/intermediates/runtime_symbol_list/${
     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"
                    }

                }
            }
        }
    }
}


task sortPublicTxt() {
   
    doLast {
   
        File originalFile = project.file("public.txt")
        File sortedFile = project.file("public_sort.txt")
        List<String> sortedLines = new ArrayList<>()
        originalFile.eachLine {
   
            sortedLines.add(it)
        }
        Collections.sort(sortedLines)
        sortedFile.delete()
        sortedLines.each {
   
            sortedFile.append("${
     it}\n")
        }
    }
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

꒰ঌ 安卓开发໒꒱

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值