美文网首页优化点Android知识
Tinker学习计划(1)-Tinker的集成

Tinker学习计划(1)-Tinker的集成

作者: 徐正峰 | 来源:发表于2017-07-03 15:43 被阅读0次

    Tinker使用

    前言

    写在前面的话,在上家公司一直在主导组件框架的开发,所以对Android领域组件化,热更新的发展都有所关注。当时做组件化时,也调研了不少资料,那时候也正是腾讯在宣传Tinker的时机,并宣称准备全部开源,其实是一直有所期待的,原因也是因为微信,如果真如腾讯所说,微信的热更新用的是Tinker,按照微信的体量和要求,Tinker应该是神一般的存在,就如阿里的Atlas一样(虽然说现在Atlas也已开源,但是按照阿里的尿性,估计内部也已经早已改朝换代了),总之都是神一般的框架,值得我们花大把的时间去研究和学习。

    玩Dota的人应该清楚,Tinker的来源于英雄Tinker,其大招就是刷新技能和道具,我想这也正是腾讯将其作为热更新框架名字的用意吧,无线刷新App,玩的就是飘逸。

    历史

    在了解Tinker之前,我们先回顾一下热更新的前世今生,个人觉得先从宏观上去了解这些事还是很有必要的,最起码装逼很有必要。热更新主要分为两个流派:Java和Native。

    • Native代表有阿里的Dexposed,AndFix,据说腾讯内部也有一个KKFIx,不太了解。
    • Java主要代表有Qzone的超级补丁、大众点评的nuwa、百度金融的rocooFix, 饿了么的amigo以及美团的robust。

    我们先说Java,万变不离其宗,都离不开Dex替换的原则,不管是拆分dex还是全量dex,说白了就是让classloader先加载新的那段dex代码从而达到改头换面的目的。至于Native,我只知道基本原理用的都是Native Hook的方式。可以参考这篇文章
    微信Android热补丁实践演进之路。至于各大厂商的热更新产品,可以看下图:

    Tinker Qzone AndFix Robust
    类替换 yes yes <font color="Red">no</font> <font color="Red">no</font>
    So替换 yes <font color="Red">no</font> <font color="Red">no</font> <font color="Red">no</font>
    资源替换 yes yes <font color="Red">no</font> <font color="Red">no</font>
    全平台支持 yes yes yes yes
    及时生效 <font color="Red">no</font> <font color="Red">no</font> yes yes
    性能损耗 较小 较大 较小 较小
    补丁包大小 较小 较大 中等 中等
    开发透明 yes yes <font color="Red">no</font> <font color="Red">no</font>
    复杂度 较低 较低 复杂 复杂
    gradle支持 yes <font color="Red">no</font> <font color="Red">no</font> <font color="Red">no</font>
    Rom体积 较大 较小 较小 较小
    成功率 较高 较高 一般 最高
    综上来说:
    • AndFix是Native解决方案,在混合编译出现后遇到最大的问题就是兼容性和稳定性问题,而且无法实现类更换,本身是Native方案,这对开发者来说上手难度也更大
    • Qzone由于插桩的缘故,牺牲了很大的虚拟机性能,而且为了解决后面由于Arrt内存地址错乱只能采用全量替换Dex,也导致补丁包急速增大。
    • AndFix和Robust有一个很大的优点就是能够及时生效,但是Robust的缺点是无法做到类替换,而且无法新增变量和类,导致局限性很小,只能作为bug修复,无法做到功能发布。

    说了这么多感觉貌似只有Tinker是最适合的,最牛逼的,当然不是,我们讲没有万能的方案只有适合的方案,试想如果产品要求我们的热更新只需要作为bug修复的手段,功能发布用组件化方案,而且必须要及时生效,那这时候Tinker就无法满足要求了。所以任何一个方案都不是万能的,我相信微信在搞Tinker的时候也是站在巨人的肩膀上。不过既然我们是要研究Tinker,还是先讲讲Tinker的优点,或者说官方发布的优点,至于具体原理是什么,后面的文章会一一说明。

    • 稳定,这点毋庸置疑,当然我不是说其他厂商的开源框架不够稳定,微信的大体量和高日活决定这款产品要有足够的稳定性,这点也保证了Tinker要有足够的稳定性与兼容性
    • 开发社区活跃,Tinker是在15年6月开始,到现在才2年时间,至少现在GitHub上还算活跃,也希望腾讯能够一直保持下去。
    • patch包小,这个也算Tinker的一个亮点吧。自己实现了Diff算法,保证Patch包足够小。

    Tinker的已知问题:

    • 不支持修改AndroidManifest.xml。这也就决定了Tinker不支持新增四大组件
    • Android N上,补丁对应用启动时间有影响(暂时不知,后面看到了会说明)
    • 在某些三星 android-21手机上 还有兼容性问题
    • 资源替换中,不支持修改remoteView,transition动画,通知栏图标和应用图标

    希望Tinker的开源也会让热更新领域发展的越来越好。

    Tinker的集成

    在还没有阅读Tinker源码之前,我们先来集成一下Tinker,不管怎样,先用起来再说。

    Tinker Github地址:Tinker

    Android Studio clone tinker源码,如下所示


    不得不说,腾讯这次真是业界良性,开源了所有东西,包括插件源码。

    话不多说,首先建立一个Demo工程。

    接入指南

    1. 当然是添加依赖了,在项目的build.gradle中添加如下依赖,即引用tinker的patch插件

       buildscript {
           dependencies {
               classpath ('com.tencent.tinker:tinker-patch-gradle-plugin:1.7.11')
           }
       }
      
    2. APP 的 build.gradle中添加如下依赖

       dependencies {
           //自己的依赖
           ...
           compile('com.tencent.tinker:tinker-android-lib:1.7.11')
           ...
       }
       ...
       compile('com.tencent.tinker:tinker-android-lib:1.7.11')
      

      sync project后会报错,如下:

      不慌,往下看

    3. 添加Tinkid。这里解释一下TinkId,运行过程中,Tinker需要验证基准apk包的tinkerId是否等于补丁包的tinkerId。这个是决定补丁包能运行在哪些基准包上面。说白了就是id匹配的作用,保持基准包和pacth包的兼容。所以这里建议大家的方式,可以看下github demo的APP gradle文件。网上很多人的做法是复制整个文件,然后运行,貌似可行,实际上并未达到学习的效果,我在改文件里标注了所有的gradle的注释,大家可以看下。

       apply plugin: 'com.android.application'
      
       android {
           compileSdkVersion 25
           buildToolsVersion "25.0.3"
           defaultConfig {
               applicationId "com.netease.xu.tinkerdemo"
               minSdkVersion 10
               targetSdkVersion 25
               versionCode 1
               versionName "1.0"
               testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
      
       //设置BuildConfig.java里message字段,没什么特殊的作用,可有可无
       buildConfigField "String", "MESSAGE", "\"I am the base apk\""
       //在BuildConfig.java里设置TinkerID字段,实际上如果代码里没有调用这个,也可以不设置
       buildConfigField "String", "TINKER_ID", "\"${getTinkerIdValue()}\""
       //同上
       buildConfigField "String", "PLATFORM",  "\"all\""
           }
           buildTypes {
               release {
                   minifyEnabled false
                   proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
               }
           }
               }
      
       dependencies {
           compile fileTree(dir: 'libs', include: ['*.jar'])
           androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', {
               exclude group: 'com.android.support', module: 'support-annotations'
           })
           compile 'com.android.support:appcompat-v7:25.3.1'
           compile 'com.android.support.constraint:constraint-layout:1.0.2'
           //tinker的核心库
           compile('com.tencent.tinker:tinker-android-lib:1.7.11')
       }
       
       //定义基准apk构建的位置
       def bakPath = file("${buildDir}/bakApk/")
      
       //额外属性,实际上这些也是可以不用写的,腾讯真是良心,可以支持Gradle脚本自定义,这些值实际上都可以在gradle.properites中自定义
       ext {
           //是否支持tinker构建
           tinkerEnabled = true
      
           //如果需要构建patch包,这里需要天上同一个其基准包地址
           tinkerOldApkPath = "${bakPath}/app-debug-1018-17-32-47.apk"
           //如果需要构建patch包,这里需要天上同一个其基准包的混淆文件
           tinkerApplyMappingPath = "${bakPath}/app-debug-1018-17-32-47-mapping.txt"
           //如果差分包有修改资源文件,则必须需要输入以前基准包的R文件,主要是为了固定id来用的。
           tinkerApplyResourcePath = "${bakPath}/app-debug-1018-17-32-47-R.txt"
       
           //给打渠道包配置的,这里是学习阶段,暂时注释
               //    tinkerBuildFlavorDirectory = "${bakPath}/app-1018-17-32-47"
               }
       
               if (ext.tinkerEnabled) {
       
           //引用patch插件
           apply plugin: 'com.tencent.tinker.patch'
       
           //tinkerPath任务,补丁关键任务,源码也有,后面我会有详细撰述,这里只知道怎么用即可
           //直接使用基准apk包与新编译出来的apk包做差异,得到最终的补丁包
           tinkerPatch {
               oldApk = getTinkerOldApkPath()
               /**
                * 是否忽略警告
                * 值为false将中断编译。因为这些情况可能会导致编译出来的patch包带来风险:
                * 1. minSdkVersion小于14,但是dexMode的值为"raw";  dexmode下面会有介绍
                * 2. 新编译的安装包出现新增的四大组件(Activity, BroadcastReceiver...);
                * 3. 定义在dex.loader用于加载补丁的类不在main dex中;
                * 4. 定义在dex.loader用于加载补丁的类出现修改;
                * 5. resources.arsc改变,但没有使用applyResourceMapping编译。
                */
               ignoreWarning = false
       
               /**
                * 是否签名,保证基准包和补丁包的签名一致,代码里有判断逻辑
                */
               useSign = true
       
               /**
                * 编译相关配置项
                */
               buildConfig {
                   //在编译新的apk时候,通过保持旧apk的proguard混淆方式,从而减少补丁包的大小。这个只是推荐设置,不设置applyMapping也不会影响任何的assemble编译
                   applyMapping = getTinkerApplyMappingPath()
                   //在编译新的apk时候,通过旧apk的R.txt文件保持ResId的分配,这样不仅可以减少补丁包的大小,同时也避免由于ResId改变导致remote view异常。
                   applyResourceMapping = getTinkerApplyResourcePath()
       
                   //tinkerID
                   tinkerId = getTinkerIdValue()
       
                   //如果有多个dex,编译补丁时可能会由于类的移动导致变更增多。若打开keepDexApply模式,补丁包将根据基准包的类分布来编译。
                   keepDexApply = false
               }
       
               dex {
                   //只能是'raw'或者'jar'。
                   //对于'raw'模式,将会保持输入dex的格式。
                   //对于'jar'模式,将会把输入dex重新压缩封装到jar。如果你的minSdkVersion小于14,你必须选择‘jar’模式,而且它更省存储空间,但是验证md5时比'raw'模式耗时。默认我们并不会去校验md5,一般情况下选择jar模式即可。
                   dexMode = "jar"
       
                   //需要处理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。
                    5. 使用1.7.6版本之后版本,参数1、2会自动填写。
                    */
                   loader = [
                           //use sample, let BaseBuildInfo unchangeable with tinker
               //                    "tinker.sample.android.app.BaseBuildInfo"
                   ]
               }
       
               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")
                   /**
                    * 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")
               }
       
               /**
                *7zip路径配置项,执行前提是useSign为true
                */
               sevenZip {
                   /**
                    * 例如"com.tencent.mm:SevenZip:1.1.10",将自动根据机器属性获得对应的7za运行文件,推荐使用
                    */
                   zipArtifact = "com.tencent.mm:SevenZip:1.1.10"
                   /**
                    * 系统中的7za路径,例如"/usr/local/bin/7za"。path设置会覆盖zipArtifact,若都不设置,将直接使用7za去尝试。
                    */
           //        path = "/usr/local/bin/7za"
               }
           }
       
           List<String> flavors = new ArrayList<>();
           project.android.productFlavors.each {flavor ->
               flavors.add(flavor.name)
           }
           boolean hasFlavors = flavors.size() > 0
           /**
            * bak apk and mapping
            * 渠道包相关配置。
            */
           android.applicationVariants.all { variant ->
               /**
                * task type, you want to bak
                */
               def taskName = variant.name
               //def date = new Date().format("MMdd-HH-mm-ss")
               def date = new Date().format("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"
               //                    }
               //
               //                }
               //            }
               //        }
               //    }
               }
               
               def getTinkerApplyResourcePath() {
                   return ext.tinkerApplyResourcePath
               }
               
               def getTinkerApplyMappingPath() {
                   return ext.tinkerApplyMappingPath
               }
               
               def isTinkerEnabled() {
                   return hasProperty("TINKER_ENABLE") ? TINKER_ENABLE : ext.tinkerEnabled
               }
               
               def getTinkerOldApkPath() {
                   return hasProperty("OLD_APK") ? OLD_APK : ext.tinkerOldApkPath
               }
               
               def getTinkerIdValue() {
                   return hasProperty("TINKER_ID") ? TINKER_ID : 1
       }
      

    4.配置完成后,在MainActivity中加入以下代码:

    MainActivity.java
    activity_main.xml

    这些都比较简单的demo,做好这些以后,在命令行里敲入:

         ./gradlew assembleDebug
    

    5.由于我没有开混淆,而且前面gradle里也定义了输出文件的路径,所以在改路径下生成了两个文件:


    基准包和一个R.txt文件,R是APK用到的资源索引
    大家可以看下R.txt文件,实际上里面也包括所有的系统资源,这里就不做撰述

    细心的同学会发现,manifest.xml会多了一行:



    这个1实际上也是我们自己配置的tinkerid。这边我直接写死了,如果是线上项目,很多是用git的提交号来。

    BuildConfig.java中也多了在gradle里配置的字段


    6.这些做完够了么,当然不够,可以构建出APK出来,但是APK里不包含热更新相关的处理逻辑,最起码有load patch包,以及冷启动相关代码逻辑。

    在做这些之前,我们先回顾一下上面的过程,显然Tinker干预了打包的过程,实际上补丁包的生成也是有单独的命令的。从这一点来看,tinker的侵入性还是比较强的。

    我们接着来添加代码已满足需求:

    • load 补丁包: 说白了就是给一个补丁包的地址,然后Tinker去load。这个地址应该是sd卡和files都可以,sd卡最后也会拷贝到files下去加载。所以我在xml里加一个一个button显示的load补丁包。至于补丁包的地址写死吧。 /sdcard/patch.apk 代码如下:


    当运行时会报如下错误:



    显然是由于Tinker没有安装导致的,一般框架也是这个尿性,所以这时候要对Tinker进行初始化,说到这里,发现Tinker的侵入性又+1。不管了,既然是集成,也只能按照它的协议往下做了。

    一般人集成也许第一步就初始化了,我的角度是站在普通人的思路去做这个事,发现问题再去解决问题,这样才能去分析这个框架,更大可能的去理解创造者的思想,当然网上很多人直接复制粘贴,这个就不推荐了。
    • Tinker初始化。在官方Demo的ReadMe中找到:

    也只能是这个尿性了,这不是重点。重点是这个SampleApplicationLike。微信为什么高出这个呢,他应该是一个代理,这个里面做Tinker热跟新相关的处理,后面我们分析源码的时候会讲到。这里不做撰述,照葫芦画瓢即可。实际上内部也是有一个Default的实现的,这里为了学习,就自定义了一个。按部就班的来,最后运行的时候发现报一下错误



    竟然说我的Application类重复了,诡异。继续找原因:
    原因应该是SampleApplicationLike中的注解自动生成了一个Application导致和代码里重写的注解@DefaultCycle导致的。所以我尝试取消这个注解。编译可以通过,但是运行的时候出现以下崩溃



    哎,难道Tinker不支持自定义Application。暂时不管了,删除自己写的Application,添加注解。就可以编译安装了。
    <font color="red">这个设计,有点反人类。侵入性严重。难受...</font>

    7.做完这些事以后,构建出一个基准包(./gradlew assembleDebug),并且安装。由于需要用到sd卡权限,所以

    manefest里要加上sd卡读写权限,省的重来一次。

    运行后,在 app/build/bakApk/ 目录下会生成一个Apk以及相应的R.txt文件,会带有一个数字,如下:



    通过adb 安装这个基准包。然后修改一行代码,这个随便自己。只要能看出来当前修改和上次基准包有区别就可以。我这边就是修改了一下toast弹出的文案。

    8.通过上面的7构建出来基准包后,修改app/build.gralde脚本。



    这个路径很好理解。至于那个Mappingpath。由于我用的是debug包,直接忽略,删除都可以。运行 ./gradlew tinkerPatchDebug 构建补丁包竟然需要一分钟以上。吐槽一下。成功以后,在 app/build/outputs/tikerPath/目录下会生成补丁包,有带签名和不带签名的。


    9.通过adb push导入补丁包到 sd卡。打开应用在关闭屏幕,点击按钮弹出toast。就是新的t文案了。

    结束

    上面完成了Tinker的集成。虽然说工作量不大,但是从中也遇到了一些问题,有些已经找到原因,有些缺不知为何。后期会在去研究。

    相关文章

      网友评论

        本文标题:Tinker学习计划(1)-Tinker的集成

        本文链接:https://www.haomeiwen.com/subject/xjpbcxtx.html