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工程。
-
当然是添加依赖了,在项目的build.gradle中添加如下依赖,即引用tinker的patch插件
buildscript { dependencies { classpath ('com.tencent.tinker:tinker-patch-gradle-plugin:1.7.11') } }
-
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后会报错,如下:
不慌,往下看
-
添加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的集成。虽然说工作量不大,但是从中也遇到了一些问题,有些已经找到原因,有些缺不知为何。后期会在去研究。