Tinker Android热补丁

前言

Tinker简介

Tinker是微信官方的Android热补丁解决方案,它支持动态下发代码、So库以及资源,让应用能够在不需要重新安装的情况下实现更新。当然,你也可以使用Tinker来更新你的插件。

Tinker已知问题

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

2. Tinker接入指南

2.1. Gradle接入

gradle是推荐的接入方式,在gradle插件tinker-patch-gradle-plugin中官方帮助完成proguard、multiDex以及Manifest处理等工作。
在项目的build.gradle中,添加tinker-patch-gradle-plugin的依赖:

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

然后在app的gradle文件app/build.gradle,我们需要添加tinker的库依赖以及apply tinker的gradle插件.

dependencies {
    //可选,用于生成application类 
    provided('com.tencent.tinker:tinker-android-anno:1.7.7')
    //tinker的核心库
    compile('com.tencent.tinker:tinker-android-lib:1.7.7') 
}
...
...
//apply tinker插件
apply plugin: 'com.tencent.tinker.patch'

tinker参数配置

同样是在项目的app的gradle文件app/build.gradle文件里,在文件的末尾加上如下相关配置:

/**Tinker 相关配置----------------开始-----------------------------------*/
def bakPath = file("${buildDir}/bakApk/")
def gitSha() {
    try {
        String gitRev = '11.2.3.4'
        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'")
    }
}
ext {
    //for some reason, you may want to ignore tinkerBuild, such as instant run debug build?
    //Tinker是否可用
    tinkerEnabled = true
    //for normal build
    //old apk file to build patch apk 旧包名
    tinkerOldApkPath = "${bakPath}/app-debug-0711-15-50-35.apk"
    //proguard mapping file to build patch apk  旧包混淆文件目录
    tinkerApplyMappingPath = "${bakPath}/app-debug-0711-15-50-35-mapping.txt"
    //resource R.txt to build patch apk, must input if there is resource changed 旧包R文件
    tinkerApplyResourcePath = "${bakPath}/app-debug-0711-15-50-35-R.txt"
    //only use for build all flavor, if not, just ignore this field  多渠道
    tinkerBuildFlavorDirectory = "${bakPath}/app-0711-15-50-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
}
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 {
        /**
         * 基准apk包路径,也就是旧包路径
         * */
        oldApk = getOldApkPath()
        /**
         * 如果出现以下的情况,并且ignoreWarning为false,我们将中断编译。因为这些情况可能会导致编译出来的patch包
         * 带来风险:
         * 1. minSdkVersion小于14,但是dexMode的值为"raw";
         * 2. 新编译的安装包出现新增的四大组件(Activity, BroadcastReceiver...);
         * 3. 定义在dex.loader用于加载补丁的类不在main dex中;
         * 4. 定义在dex.loader用于加载补丁的类出现修改;
         * 5. resources.arsc改变,但没有使用applyResourceMapping编译。
         * */
        ignoreWarning = false
        /**
         * 在运行过程中,我们需要验证基准apk包与补丁包的签名是否一致,我们是否需要为你签名
         * */
        useSign = true

        buildConfig {
            /**
             * 可选参数;在编译新的apk时候,我们希望通过保持旧apk的proguard混淆方式,从而减少补丁包的大小。这个只
             * 是推荐的,但设置applyMapping会影响任何的assemble编译。
             * */
            applyMapping = getApplyMappingPath()
            /**
             * 可选参数;在编译新的apk时候,我们希望通过旧apk的R.txt文件保持ResId的分配,这样不仅可以减少补丁包的
             * 大小,同时也避免由于ResId改变导致remote view异常。
             * */
            applyResourceMapping = getApplyResourceMappingPath()
            /**
             * 在运行过程中,我们需要验证基准apk包的tinkerId是否等于补丁包的tinkerId。这个是决定补丁包能运行在哪些
             * 基准包上面,一般来说我们可以使用git版本号、versionName等等。
             * */
            tinkerId = getTinkerIdValue()
        }
        dex {
            /**
             * 只能是'raw'或者'jar'。
             * 对于'raw'模式,我们将会保持输入dex的格式。
             * 对于'jar'模式,我们将会把输入dex重新压缩封装到jar。如果你的minSdkVersion小于14,你必须选择‘jar’模式
             * ,而且它更省存储空间,但是验证md5时比'raw'模式耗时()
             * */
            dexMode = "jar"
            /**
             * 是否提前生成dex,而非合成的方式。这套方案即回退成Qzone的方案,对于需要使用加固或者多flavor打包(建
             * 议使用其他方式生成渠道包)的用户可使用。但是这套方案需要插桩,会造成Dalvik下性能损耗以及Art补丁包可
             * 能过大的问题,务必谨慎使用。另外一方面,这种方案在Android N之后可能会产生问题,建议过滤N之后的用户。
             */
            //usePreGeneratedPatchDex = false
            /**
             * 需要处理dex路径,支持*、?通配符,必须使用'/'分割。路径是相对安装包的,例如/assets/...
             */
            pattern = ["classes*.dex",
                       "assets/secondary-dex-?.jar"]
            /**
             *     这一项非常重要,它定义了哪些类在加载补丁包的时候会用到。这些类是通过Tinker无法修改的类,也是一定要放在main dex的类。
             这里需要定义的类有:
             1. 你自己定义的Application类;
             2. Tinker库中用于加载补丁包的部分类,即com.tencent.tinker.loader.*;
             3. 如果你自定义了TinkerLoader,需要将它以及它引用的所有类也加入loader中;
             4. 其他一些你不希望被更改的类,例如Sample中的BaseBuildInfo类。这里需要注意的是,这些类的直接引用类也
             需要加入到loader中。或者你需要将这个类变成非preverify。
             */
            loader = ["com.tencent.tinker.loader.*",
                      //warning, you must change it with your application
                      //TODO 换成自己的Application
                      "com.troy.tinkertrydemo.MyApplication",
            ]
        }
        lib {
            /**
             * 需要处理lib路径,支持*、?通配符,必须使用'/'分割。与dex.pattern一致, 路径是相对安装包的,例如/assets/...
             */
            pattern = ["lib/armeabi/*.so"]
        }
        res {
            /**
             * 需要处理res路径,支持*、?通配符,必须使用'/'分割。与dex.pattern一致, 路径是相对安装包的,例如/assets/...,
             * 务必注意的是,只有满足pattern的资源才会放到合成后的资源包。
             */
            pattern = ["res/*", "assets/*", "resources.arsc", "AndroidManifest.xml"]
            /**
             * 支持*、?通配符,必须使用'/'分割。若满足ignoreChange的pattern,在编译时会忽略该文件的新增、删除与修改。
             * 最极端的情况,ignoreChange与上面的pattern一致,即会完全忽略所有资源的修改。
             */
            ignoreChange = ["assets/sample_meta.txt"]
            /**
             * 对于修改的资源,如果大于largeModSize,我们将使用bsdiff算法。这可以降低补丁包的大小,但是会增加合成
             * 时的复杂度。默认大小为100kb
             */
            largeModSize = 100
        }
        packageConfig {
            /**
             * configField("key", "value"), 默认我们自动从基准安装包与新安装包的Manifest中读取tinkerId,并自动
             * 写入configField。在这里,你可以定义其他的信息,在运行时可以通过TinkerLoadResult.getPackageConfigByName得到相应的数值。但是建议直接通过修改代码来实现,例如BuildConfig。
             */
            configField("patchMessage", "tinker is sample to use")
        }
        sevenZip {
            /**
             * 例如"com.tencent.mm:SevenZip:1.1.10",将自动根据机器属性获得对应的7za运行文件,推荐使用
             */
            zipArtifact = "com.tencent.mm:SevenZip:1.1.10"
        }
        /**
         *  文件名                              描述
         *  patch_unsigned.apk                  没有签名的补丁包
         *  patch_signed.apk                  签名后的补丁包
         *  patch_signed_7zip.apk              签名后并使用7zip压缩的补丁包,也是我们通常使用的补丁包。但正式发布的时候,最好不要以.apk结尾,防止被运营商挟持。
         *  log.txt                              在编译补丁包过程的控制台日志
         *  dex_log.txt                          在编译补丁包过程关于dex的日志
         *  so_log.txt                          在编译补丁包过程关于lib的日志
         *  tinker_result                      最终在补丁包的内容,包括diff的dex、lib以及assets下面的meta文件
         *  resources_out.zip                  最终在手机上合成的全量资源apk,你可以在这里查看是否有文件遗漏
         *  resources_out_7z.zip              根据7zip最终在手机上合成的全量资源apk
         *  tempPatchedDexes                  在Dalvik与Art平台,最终在手机上合成的完整Dex,我们可以在这里查看dex合成的产物。
         *
         *
         * */

        /**
         * 获得所有渠道集合,并判断数量
         */
        List flavors = new ArrayList<>();
        project.android.productFlavors.each {flavor ->
            flavors.add(flavor.name)
        }
        boolean hasFlavors = flavors.size() > 0
        /**
         * bak apk and mapping
         *  创建Task并执行文件操作
         */
        android.applicationVariants.all { variant ->
            /**
             * task type, you want to bak
             */
            def taskName = variant.name
            def date = new Date().format("MMdd-HH-mm-ss")

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

                    }
                }
            }
        }
    }
}
/**Tinker 相关配置----------------结束-----------------------------------*/

配置参数参考GitHub:
官方github

自定义Application类

为了使真正的Application实现可以在补丁包中修改,需要把Appliction类的所有逻辑移动到ApplicationLike代理类中。
即创建一个SampleApplication类,继承DefaultApplicationLike

@DefaultLifeCycle(application = "com.example.qiongyu_jiang.tinkerdemo.MyApplication",
        flags = ShareConstants.TINKER_ENABLE_ALL,
        loadVerifyFlag = false)

public class SampleApplication extends DefaultApplicationLike{

    public SampleApplication(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)
    @Override
    public void onBaseContextAttached(Context base) {
        super.onBaseContextAttached(base);
        /************************Action*************************/
        TinkerInstaller.install(this);
        /************************Action*************************/
    }
    @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
    public void registerActivityLifecycleCallbacks(Application.ActivityLifecycleCallbacks callback) {
        getApplication().registerActivityLifecycleCallbacks(callback);
    }
}

其中DefaultLifeCycle中的Application为我们真正的Application,flags表示需要支持的修复的范围,然后将AndroidManifest.xml文件中的Application的name改为我们的Application。
DefaultApplicationLike不是继承自Application,需要用注解来设置项目中真正的Application,Tinker插件会自动生成真正的Application。


        
            
                

                
            
        
    

注意:“Android:name=”.MyApplication””应该studio 会报红,不过正常编译能通过,可以忽略。
需要添加读写磁盘的文件:



加载补丁

加载补丁的代码是这个:

TinkerInstaller.onReceiveUpgradePatch(this.getApplication(), Environment.getExternalStorageDirectory().getAbsolutePath() + "/patch_signed_7zip.apk");

补丁文件在被加载之后会被删除。

具体操作与测试

具体操作

编译打补丁前的APK

在项目右侧的gradle操作栏中选择assembleDebug


Tinker Android热补丁_第1张图片
这里写图片描述

当然也可以采用命令行的方式

./gradlew assembleRelease

在app/build/outputs/apk下生成app-debug.apk,在bakApk文件夹下生成相关备份


Tinker Android热补丁_第2张图片
这里写图片描述

如果选择的生成方式是release,同时也会生成一个mapping.txt文件,debug是没有的。
随后在终端进入apk所在目录下,将apk安装到手机上:

adb install -r app-debug-0711-11-24-18.apk

这样就成功的把打补丁前的app安装到手机上。下面是demo的样子:


Tinker Android热补丁_第3张图片
这里写图片描述

生成patchAPK

在build.gradle里修改部分文件,使其与刚刚生成的文件名一致,这里说明旧的apk是什么。

ext {
    //for some reason, you may want to ignore tinkerBuild, such as instant run debug build?
    //Tinker是否可用
    tinkerEnabled = true
    //for normal build
    //old apk file to build patch apk 旧包名
    tinkerOldApkPath = "${bakPath}/app-debug-0711-11-24-18.apk"
    //proguard mapping file to build patch apk  旧包混淆文件目录
    tinkerApplyMappingPath = "${bakPath}/app-debug-0711-11-24-18-mapping.txt"
    //resource R.txt to build patch apk, must input if there is resource changed 旧包R文件
    tinkerApplyResourcePath = "${bakPath}/app-debug-0711-11-24-18-R.txt"
    //only use for build all flavor, if not, just ignore this field  多渠道
    tinkerBuildFlavorDirectory = "${bakPath}/app-0711-11-24-18"
}

随后修改原来的项目文件,比如加个按钮。
然后在项目右侧的gradle的tinker目录下选择tinkerPatchDebug,生成补丁包文件。生成后放在outputs目录下。


Tinker Android热补丁_第4张图片
这里写图片描述

将此文件推送到手机的SD卡中

adb push ./app/build/outputs/tinkerPatch/debug/patch_signed_7zip.apk /storage/sdcard0/

重启APP,补丁推送成功。


Tinker Android热补丁_第5张图片
这里写图片描述

测试

测试项 修改代码 增加类 增加布局文件 增加资源文件(图片)
补丁包大小 4kb 4kb 32kb 22kb(图片大小9.42kb)
启动时长(前) 106ms 117ms 144ms 120ms
启动时长(后) 117ms 144ms 120ms 121ms
内存占用(前) 8.43/17.17 mb 7.27/18.33 mb 7.26/18.34 mb 5.09/20.51 mb
内存占用(后) 7.27/18.33 mb 7.26/18.34 mb 5.09/20.51 mb 6.31/19.29 mb

经测试,增加代码,增加方法,增加类,增加布局文件,增加图片,均能修复成功。

测试项 修改代码 增加类 增加布局文件 增加资源文件(图片)
补丁包大小 4kb 4kb 32kb 22kb(图片大小9.42kb)
启动时长(前) 106ms 117ms 144ms 120ms
启动时长(后) 117ms 144ms 120ms 121ms
内存占用(前) 8.43/17.17 mb 7.27/18.33 mb 7.26/18.34 mb 5.09/20.51 mb
内存占用(后) 7.27/18.33 mb 7.26/18.34 mb 5.09/20.51 mb 6.31/19.29 mb

原来项目APK大小:1402KB
加入classpath ('com.tencent.tinker:tinker-patch-gradle-plugin:1.7.7')后:1402KB
加入compile('com.tencent.tinker:tinker-android-lib:1.7.7')后:1550KB
加入provided('com.tencent.tinker:tinker-android-anno:1.7.7')后:1550KB

一些坑

1) tinkerId is not set
tinker采用的是git的版本号来设置tinkerId,项目需要设置git并且设置完了记得找段代码commit一下。
2)ignoreWarning
在studio终端中输入gradlew tinkerPatchDebug来生成pach时可能会报以下异常:
Warning:ignoreWarning is false, but resources.arsc is changed, you should use applyResourceMapping mode to build the new apk, otherwise, it may be crash at some times
com.tencent.tinker.build.util.TinkerPatchException: ignoreWarning is false, but resources.arsc is changed, you should use applyResourceMapping mode to build the new apk, otherwise, it may be crash at some times
将build.gradle中的ignoreWarning = false改为true即可

官方常见问题

参考博客

http://blog.csdn.net/qiyei2009/article/details/57079973
http://blog.csdn.net/happy_horse/article/details/55224356

你可能感兴趣的:(Tinker Android热补丁)