Tinker 热补丁接入过程中的坑!!!
===============
Tinker 介绍
官方接入说明
gradle 接入
gradle是推荐的接入方式,在gradle插件tinker-patch-gradle-plugin中我们帮你完成proguard、multiDex以及Manifest处理等工作。
添加gradle依赖
在项目的根目录build.gradle中,添加tinker-patch-gradle-plugin的依赖
引入tinker 核心库
然后在baseUI-lib文件的build.gradle,我们需要添加tinker的库依赖以及apply tinker的gradle插件.
在APP/build.gradle 下面添加tinker 的配置文件
keep_in_main_dex.txt 文件内容就是指定你要放置到主DEX 中的类
-keep public class * implements com.tencent.tinker.loader.app.ApplicationLifeCycle {
*;
}
-keep public class * extends com.tencent.tinker.loader.TinkerLoader {
*;
}
-keep public class * extends com.tencent.tinker.loader.app.TinkerApplication {
*
}
-keep class com.tencent.tinker.loader.** {
*;
}
-keep class com.anzogame.corelib.GameApplication {
*;
}
加入tinker EXT提供配置文件
tinkerEnabled: tinker 的开关
tinkerOldApkPath : 生补丁包的基础apk,线上包版本
tinkerApplyMappingPath : 线上包混淆文件生成mapping 文件
tinkerApplyResourcePath : 线上包的资源id 文件
以上版本是我们每一个正式版本需要保留的生成热补丁的基础信息
tinkerBuildFlavorDirectory : 多渠道用到,我们不用,下面说到我们怎么处理多渠道打包
加入 TinkerPatch 配置
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 = false
/**
* 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
/**
* 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 = "1.2"
}
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"
/**
* optional,default 'false'
* if usePreGeneratedPatchDex is true, tinker framework will generate auxiliary class
* and insert auxiliary instruction when compiling base package using
* assemble{Debug/Release} task to prevent class pre-verified issue in dvm.
* Besides, a real dex file contains necessary class will be generated and packed into
* patch package instead of any patch info files.
*
* Use this mode if you have to use any dex encryption solutions.
*
* Notice: If you change this value, please trigger clean task
* and regenerate base package.
*/
usePreGeneratedPatchDex = false
/**
* necessary,default '[]'
* what dexes in apk are expected to deal with tinkerPatch
* it support * or ? pattern.
*/
pattern = ["classes*.dex",
"assets/secondary-dex-?.jar"]
/**
* 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 = ["com.tencent.tinker.loader.*",
//warning, you must change it with your application
"com.anzogame.corelib.GameApplication",
"com.anzogame.corelib.BuildConfig.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/armeabi/*.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 = ["*.png"]
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 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")
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"
}
}
}
}
}
}
详细参数参看文档 传送门
配置就到这里,然后执行assembleDebug,安装apk,千万不要 RUN ,RUN方式生成是补丁打补丁的,编译不通过 彩蛋....(Too many classes in --main-dex-list, main dex capacity exceeded)
为什么会这样子呢 ? 我们已经采用了GOOGLE 的方案多DEX ,从报错上来看应该是主DEX 的类太多了,超过了限制,但是这个哪些类放到主DEX 不是我们决定的啊,很操蛋, 那么我们来看系统是如何分包的.
在项目中,可以直接运行 gradle 的 task 。
collect{flavor}{buildType}MultiDexComponents Task 。这个 task 是获取 AndroidManifest.xml 中 Application 、Activity 、Service 、 Receiver 、 Provider 等相关类,以及 Annotation ,之后将内容写到 build/intermediates/multi-dex/{flavor}/{buildType}/maindexlist.txt 文件中去。
packageAll{flavor}DebugClassesForMultiDex Task 。该 task 是将所有类打包成 jar 文件存在 build/intermediates/multi-dex/{flavor}/debug/allclasses.jar 。 当 BuildType 为 Release 的时候,执行的是 proguard{flavor}Release Task,该 task 将 proguard 混淆后的类打包成 jar 文件存在 build/intermediates/classes-proguard/{flavor}/release/classes.jar
shrink{flavor}{buildType}MultiDexComponents Task 。该 task 会根据 maindexlist.txt 生成 componentClasses.jar ,该 jar 包里面就只有 maindexlist.txt 里面的类,该 jar 包的位置在 build/intermediates/multi-dex/{flavor}/{buildType}/componentClasses.jar
create{flavor}{buildType}MainDexClassList Task 。该 task 会根据生成的 componentClasses.jar 去找这里面的所有的 class 中直接依赖的 class ,然后将内容写到 build/intermediates/multi-dex/{flavor}/{buildType}/maindexlist.txt 中。最终这个文件里面列出来的类都会被分配到第一个 dex 里面。
通过上面的流程我们可以得出 ,我们主DEX 中的类取决于build/intermediates/multi-dex/{flavor}/{buildType}/maindexlist.txt 中的内容 ,那么我们在执行MultiDexComponents task 时候做些拦截,把Activity 从主DEX中移除,这里面的移除不是全部移除,如果Activity中包含有子类,那么我们的移除是无效,还是会被放入到主DEX,另外,如果你 Application 、Service 、 Receiver 、 Provider 中的直接引用类还是会被放到第一个主DEX中。
当我们采用多DEX 的时候,应用启动的首先回加载主DEX ,其他的 dex 需要我们在应用启动后进行动态加载安装, 通过MultiDex.install(getApplication());加载其他DEX. 这样的方法虽然可行,我们把 很多 Activity 放到其他的DEX 中了 ,可是生成包发现DEX 还是有9.2M 这种方式如果随着代码增加还是有可能导致主DEX 超限的问题,这时候我们可以采用dexnkife 通过配置形式把Application 中相关的类和一些必要的首页类放置到主DEX 中,可以准确控制哪些放入到主DEX,这样也可以解决问题。
那么Google 官方Multidex是如何加载的呢?
Google 官方支持 Multidex 的 jar 包是 android-support-multidex.jar,该 jar 包从 build tools 21.1 开始支持。这个 jar 加载 apk 中的从 dex 流程如下:
[图片上传失败...(image-14a17f-1530665215929)]
此处主要的工作就是从 apk 中提取出所有的从 dex(classes2.dex,classes3.dex,…),然后通过反射依次安装加载从 dex 并合并 DexPathList 的 Element 数组。
为什么API 21 以上就没有主DEX 过大的问题呢?
- 这是为了5.0以上系统在安装过程中的art阶段就将所有的classes(..N).dex合并到一个单独的oat文件(5.0以下只能苦逼的启动时加载 对于Art相关知识,可以参考老罗的系列文章 传送门
DEX类分包的规则
我们开启多DEX支持一般是指定了multiDexEnabled,系统其实它利用的是Android sdk build tool中的mainDexClasses脚本,这在版本21以上才会有。使用方法非常很简单:
mainDexClasses [--output
这里只是简单的得到所有入口类(即rules中的Instrumentation、application、Activity、Annotation等等)的直接引入类。何为直接引用类?在init过程,会在校验阶段去resolve它各个方法、变量引用到的类,这些类统称为某个类的直接引用类。举个栗子:
public class MainActivity extends Activity {
protected void onCreate(Bundle savedInstanceState) {
DirectReferenceClass test = new DirectReferenceClass();
}
}
public class DirectReferenceClass {
public DirectReferenceClass() {
InDirectReferenceClass test = new InDirectReferenceClass();
}
}
public class InDirectReferenceClass {
public InDirectReferenceClass() {
}
}
上面有MainActivity、DirectReferenceClass、InDirectReferenceClass三个类,其中DirectReferenceClass是MainActivity的直接引用类,InDirectReferenceClass是DirectReferenceClass的直接引用类。而InDirectReferenceClass是MainActivity的间接引用类(即直接引用类的所有直接引用类)。
对于5.0以下的系统,我们需要在启动时手动加载其他的dex。而我们并没有要求得到所有的间接引用类,这是因为我们在attachBaseContext的时候,已将其他dex加载。
事实上,若我们在attachBaseContext中调用Multidex.install,我们只需引入Application的直接引用类即可,mainDexClasses将Activity、ContentProvider、Service等的直接引用类也引入,主要是满足需要在非attachBaseContent加载多dex的需求。另一方面,若存在以下代码,将出现NoClassDefFoundError错误。
public class HelloMultiDexApplication extends Application {
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
DirectReferenceClass test = new DirectReferenceClass();
MultiDex.install(this);
}
}
这是因为在实际运行过程中,DirectReferenceClass需要的InDirectReferenceClass并不一定在主dex。解决方法是手动将该类放于dx的-main-dex-list参数中:
afterEvaluate {
tasks.matching {
it.name.startsWith('dex')
}.each { dx ->
if (dx.additionalParameters == null) {
dx.additionalParameters = []
}
dx.additionalParameters += '--multi-dex'
dx.additionalParameters += "--main-dex-list=$projectDir/".toString()
}
}
LinearAlloc 是什么
- LinearAlloc 主要用来管理 Dalvik 中 class 加载时的内存,就是让 App 在执行时减少系统内存的占用。在 App 的安装过程中,系统会运行一个名为 dexopt 的程序为该应用在当前机型中运行做准备。dexopt 使用 LinearAlloc 来存储应用的方法信息。App 在执行前会将 class 读进 LinearAlloc 这个 buffer 中,这个 LinearAlloc 在 Android 2.3 之前是 4M 或 5M ,到 4.0 之后变为 8M 或 16M。因为 5M 实在是太小了,可能还没有 65536 就已经超过 5M 了,什么意思呢,就是只有一个包的情况下也有可能出现 INSTALL_FAILED_DEXOPT ,原因就在于 LinearAlloc。
解决 LinearAlloc
DEXOPT && DEX2OAT 是什么?
dexopt
当 Android 系统安装一个应用的时候,有一步是对 Dex 进行优化,这个过程有一个专门的工具来处理,叫 DexOpt。DexOpt 是在第一次加载 Dex 文件的时候执行的,将 dex 的依赖库文件和一些辅助数据打包成 odex 文件,即 Optimised Dex,存放在 cache/dalvik_cache 目录下。保存格式为 apk路径 @ apk名 @ classes.dex 。执行 ODEX 的效率会比直接执行 Dex 文件的效率要高很多。
更多可查看 Dalvik Optimization and Verification With dexopt 。
dex2oat
Android Runtime 的 dex2oat 是将 dex 文件编译成 oat 文件。而 oat 文件是 elf 文件,是可以在本地执行的文件,而 Android Runtime 替换掉了虚拟机读取的字节码转而用本地可执行代码,这就被叫做 AOT(ahead-of-time)。dex2oat 对所有 apk 进行编译并保存在 dalvik-cache 目录里。PackageManagerService 会持续扫描安装目录,如果有新的 App 安装则马上调用 dex2oat 进行编译。
更多可查看 Android运行时ART简要介绍和学习计划 。
Application Not Responding
因为第一次运行(包括清除数据之后)的时候需要 dexopt ,然而 dexopt 是一个比较耗时的操作,同时 MultiDex.install() 操作是在 Application.attachBaseContext() 中进行的,占用的是UI线程。那么问题来了,当我的第二个包、第三个包很大的时候,程序就阻塞在 MultiDex.install() 这个地方了,一旦超过规定时间,那就 ANR 了。那怎么办?放子线程?如果 Application 有一些初始化操作,到初始化操作的地方的时候都还没有完成 install + dexopt 的话,那又会 NoClassDefFoundError 了吗?同时 ClassLoader 放在哪个线程都让主线程挂起。
微信/手Q加载方案
对于微信来说,我们一共有111052个方法。以线性内存3355444(限制5m,给系统预留部分)、方法数64K为限制,即当满足任意一个条件时,将拆分dex。由此微信将得到一个主dex,两个子dex,若微信采用Android方案,在首次启动时将长期无响应(没有出现黑屏时因为默认皮肤的原因),这对处女座的我来说是无法接受的。应该如何去做?微信与手Q的方案是类似的,将首次加载放于地球中,并用线程去加载(但是5.0之前加载dex时还是会挂起主线程)。
Dex形式
暂时我们还是放于assets下,以assets/secondary-program-dex-jars/secondary-N.dex.jar命名。为什么不以classes(..N).dex?这是因为一来觉得以Android的推广速度,5.0用户增长应该是遥遥无期的,二来加载Dex的代码,传进去的是zip,在加载前我们需要验证MD5,确保所加载的Dex没有被篡改(Android官方没有验证,主要是只有root才能更改吧)。
/**
- Makes an array of dex/resource path elements, one per element of
- the given array.
*/
private static Element[] makeDexElements(ArrayListfiles, File optimizedDirectory,
ArrayListsuppressedExceptions) {
事实上,应该传进去的是dex也是应该可以的,这块在下一个版本将采用classes(..N).dex。但是如果我们使用了线程加载,并且弹出提示界面,对用户来说并不是无法接受。
Dex类分包的规则
分包规则即将所有Application、ContentProvider以及所有export的Activity、Service、Receiver的间接依赖集都必须放在主dex。对于微信现在来说,这部分大约有41306个方法,每次通过扫描AndroidMifest计算耗时大约为20s不到。怎么计算?可以参考buck或者mainDexClasses的做法。
public MainDexListBuilder(String rootJar, String pathString) throws IOException {
path = new Path(pathString);
ClassReferenceListBuilder mainListBuilder=new ClassReferenceListBuilder(path);
加载Dex的方式
加载逻辑这边主要判断是否已经dexopt,若已经dexopt,即放在attachBaseContext加载,反之放于地球中用线程加载。怎么判断?其实很低级,因为在微信中,若判断revision改变,即将dex以及dexopt目录清空。只需简单判断两个目录dex名称、数量是否与配置文件的一致。
(name md5 校验是否加载成功类)
secondary-1.dex.jar 63e5240eac9bdb5101fc35bd40a98679 secondary.dex01.Canary
secondary-2.dex.jar e7d2a4a181f579784a4286193feaf457 secondary.dex02.Canary
总的来说,这种方案用户体验较好,缺点在于太过复杂,每次都需重新扫描依赖集,而且使用的是比较大的间接依赖集(要真正运行到,直接依赖集是不行的)。当前微信必要的依赖集已经41306个方法,说不定哪一天就爆了。
FaceBook加载方案
那是否存在一种加载方式它的依赖集很小,但却不会像官方方案一样造成明显的卡顿?逆过不少app,发现facebook的思路还是挺不错的,下面作一个简单的说明:
Dex形式
微信与facebook的dex形式是完全一致的,这是因为我们也是使用facebook开源工具buck编译的。但是我们做了一个自动生成buck脚本的工作,即开发人员无须关心buck脚本如何编写。
Dex类分包的规则
facebook将加载Dex的逻辑放于单独的nodex进程,这是一个非常简单、轻量级的进程。它没有任何的ContentProvider,只有有限的几个Activity、Service。
所以依赖集为Application、NodexSplashActivity的间接依赖集即可,而且这部分逻辑应该相对稳定,我们无须做动态扫描。这就实现了一个非常轻量级的依赖集方案。
加载Dex的方式
加载dex逻辑也非常简单,由于NodexSplashActivity的intent-filter指定为Main与LAUNCHER。首先拉起nodex进程,然后初始化NodexSplashActivityActivity,若此时Dex已经初始化过,即直接跳转到主页面。
这种方式好处在于依赖集非常简单,同时首次加载Dex时也不会卡死。但是它的缺点也很明显,即每次启动主进程时,都需先启动nodex进程。尽管nodex进程逻辑非常简单,这也需100ms以上。若微信对启动时间非常敏感,很难会去采用这个方案。
美团加载方案
传送门
在 gradle 生成 dex 文件的这步中,自定义一个 task 来干预 dex 的生产过程,从而产生多个 dex 。
tasks.whenTaskAdded { task ->
if (task.name.startsWith('proguard') && (task.name.endsWith('Debug') || task.name.endsWith('Release'))) {
task.doLast {
makeDexFileAfterProguardJar();
}
task.doFirst {
delete "${project.buildDir}/intermediates/classes-proguard";
String flavor = task.name.substring('proguard'.length(), task.name.lastIndexOf(task.name.endsWith('Debug') ? "Debug" : "Release"));
generateMainIndexKeepList(flavor.toLowerCase());
}
} else if (task.name.startsWith('zipalign') && (task.name.endsWith('Debug') || task.name.endsWith('Release'))) {
task.doFirst {
ensureMultiDexInApk();
}
}
}
把 Service、Receiver、Provider 涉及到的代码都放到主 dex 中,而把 Activity 涉及到的代码进行了一定的拆分,把首页 Activity、Laucher Activity 、欢迎页的 Activity 、城市列表页 Activity 等所依赖的 class 放到了主 dex 中,把二级、三级页面的 Activity 以及业务频道的代码放到了第二个 dex 中,为了减少人工分析 class 的依赖所带了的不可维护性和高风险性,美团编写了一个能够自动分析 class 依赖的脚本, 从而能够保证�主 dex 包含 class 以及他们所依赖的所有 class 都在其内,这样这个脚本就会在打包之前自动分析出启动到主 dex 所涉及的所有代码,保证主 dex 运行正常。
加载 dex 的方式
通过分析 Activity 的启动过程,发现 Activity 是由 ActivityThread 通过 Instrumentation 来启动的,那么是否可以在 Instrumentation 中做一定的手脚呢?通过分析代码 ActivityThread 和 Instrumentation 发现,Instrumentation 有关 Activity 启动相关的方法大概有:execStartActivity、 newActivity 等等,这样就可以在这些方法中添加代码逻辑进行判断这个 class 是否加载了,如果加载则直接启动这个 Activity,如果没有加载完成则启动一个等待的 Activity 显示给用户,然后在这个 Activity 中等待后台第二个 dex 加载完成,完成后自动跳转到用户实际要跳转的 Activity;这样在代码充分解耦合,以及每个业务代码能够做到颗粒化的前提下,就做到第二个 dex 的按需加载了。
美团的这种方式对主 dex 的要求非常高,因为第二个 dex 是等到需要的时候再去加载。重写Instrumentation 的 execStartActivity 方法,hook 跳转 Activity 的总入口做判断,如果当前第二个 dex 还没有加载完成,就弹一个 loading Activity等待加载完成。
引入dexnkife 核心库
dexnkife 项目地址: DexKnifePlugin.
dexnkife 帮助我们划分类到主DEX ,使DEX 划分通过配置形式来完成
使用 # 进行注释, 当行起始加上 #, 这行配置被禁用.
# 全局过滤, 如果没设置 -filter-suggest 并不会应用到 建议的maindexlist.
# 如果你想要某个包路径在maindex中,则使用 -keep 选项,即使他已经在分包的路径中.
-keep android.support.v4.view.**
# 这条配置可以指定这个包下类在第二dex中.
android.support.v?.**
# 使用.class后缀,代表单个类.
-keep android.support.v7.app.AppCompatDialogFragment.class
# 不包含Android gradle 插件自动生成的miandex列表.
-donot-use-suggest
# 将 全局过滤配置应用到 建议的maindexlist中, 但 -donot-use-suggest 要关闭.
-filter-suggest
# 不进行dex分包, 直到 dex 的id数量超过 65536.
-auto-maindex
# dex 扩展参数, 例如 --set-max-idx-number=50000
# 如果出现 DexException: Too many classes in --main-dex-list, main dex capacity exceeded,则需要调大数值
-dex-param --set-max-idx-number=50000
# 显示miandex的日志.
-log-mainlist
# 如果你只想过滤 建议的maindexlist, 使用 -suggest-split 和 -suggest-keep.
# 如果同时启用 -filter-suggest, 全局过滤会合并到它们中.
-suggest-split **.MainActivity2.class
-suggest-keep android.support.multidex.**
搞定分DEX ,继续集成Tinker
生成基础版本
首先执行gradle 任务中的assableRelease,release 环境下打包会输出到/home/anzogame/release/包名/版本/release/,同时会在release同级目录下生成bakApk/app-1209-15-34-25/_test/ 生成apk ,mapping ,R.txt 文件,该文件就是基础版本
生成补丁包
在build.grale 中将tinkerOldApkPath,tinkerApplyMappingPath,tinkerApplyResourcePath 替换生新生成apk,maping和R.txt 文件,
然后修改自己代码,注意要修改一行CoreApplicationLike 中的代码 ,可以加一个日志,因为1.7.5的tinker 有一个bug ,最好修改一下,然后执行
gradle 任务中的,tinkerPatchRelease 生成补丁文件,最后生成的补丁文件
输出文件详解
然后将生成补丁patch_signed_7zip.apk 重名 patch_signed_7zip.so 通过后台CMS 传到SERVER ,客户端安装基础版本,启动查看补丁是否安装成功。
/Tinker.DexDiffPatchInternal: success recover dex file: /data/user/0/com.anzogame.lol/tinker/patch-3ce34da5/dex/classes.dex.jar, use time: 4118
12-09 16:21:34.900 12198-12228/? I/Tinker.DexDiffPatchInternal: try Extracting /data/user/0/com.anzogame.lol/tinker/patch-3ce34da5/dex/classes2.dex.jar
12-09 16:21:36.416 12198-12228/? I/Tinker.DexDiffPatchInternal: isExtractionSuccessful: true
12-09 16:21:36.526 12198-12228/? I/Tinker.DexDiffPatchInternal: try Extracting /data/user/0/com.anzogame.lol/tinker/patch-3ce34da5/dex/classes3.dex.jar
12-09 16:21:36.902 12198-12228/? I/Tinker.DexDiffPatchInternal: isExtractionSuccessful: true
12-09 16:21:36.932 12198-12228/? I/Tinker.DexDiffPatchInternal: try Extracting /data/user/0/com.anzogame.lol/tinker/patch-3ce34da5/dex/classes4.dex.jar
12-09 16:21:36.976 12198-12228/? I/Tinker.DexDiffPatchInternal: isExtractionSuccessful: true
12-09 16:21:36.982 12198-12228/? I/Tinker.DexDiffPatchInternal: try Extracting /data/user/0/com.anzogame.lol/tinker/patch-3ce34da5/dex/test.dex.jar
12-09 16:21:36.985 12198-12228/? I/Tinker.DexDiffPatchInternal: isExtractionSuccessful: true
12-09 16:21:49.737 12198-12228/? I/Tinker.DexDiffPatchInternal: recover dex result:true, cost:19018, isUpgradePatch:true
12-09 16:21:49.739 12198-12228/? W/Tinker.BsDiffPatchInternal: patch recover, library is not contained
12-09 16:21:49.746 12198-12228/? I/Tinker.ResDiffPatchInternal: res dir: /data/user/0/com.anzogame.lol/tinker/patch-3ce34da5/res/, meta: resArscMd5:91dfea65855052958dc4cd3abd408550
arscBaseCrc:2718315645
pattern:res/.*
pattern:resources\.arsc
pattern:assets/.*
addedSet:assets/only_use_to_test_tinker_resource.txt
modifiedSet:res/layout/activity_patch.xml
12-09 16:21:49.797 12198-12228/? I/Tinker.ResDiffPatchInternal: no large modify resources, just return
12-09 16:21:53.353 12198-12228/? I/Tinker.ResDiffPatchInternal: final new resource file:/data/user/0/com.anzogame.lol/tinker/patch-3ce34da5/res/resources.apk, entry count:7596, size:39259384
12-09 16:21:53.353 12198-12228/? I/Tinker.ResDiffPatchInternal: recover resource result:true, cost:3613, isNewPatch:true
12-09 16:21:53.366 12198-12228/? W/Tinker.UpgradePatch: UpgradePatch tryPatch: done, it is ok
12-09 16:21:53.366 12198-12228/? I/Tinker.DefaultPatchReporter: patchReporter: patch all result path:/storage/emulated/0/AnZoLOL/patch/patch_signed_7zip.so, success:true, cost:22752, isUpgrade:true
12-09 16:21:53.367 12198-12228/? I/Tinker.PatchFileUtil: safeDeleteFile, try to delete path: /data/user/0/com.anzogame.lol/tinker/patch.retry
12-09 16:21:53.368 12198-12228/? I/Tinker.PatchFileUtil: safeDeleteFile, try to delete path: /data/user/0/com.anzogame.lol/tinker/temp.apk
看到如下日志,表示补丁合成成功,杀进程,重启应用,查看日志
TinkerLoader: tryLoadPatchFiles:isEnabledForResource:true
12-09 16:24:00.771 12420-12420/? D/Tinker.TinkerInternals: same fingerprint:Xiaomi/gemini/gemini:6.0.1/MXB48T/V8.1.1.0.MAACNDI:user/release-keys
12-09 16:24:00.775 12420-12420/? W/Tinker.TinkerLoader: tinker safe mode preferName:tinker_own_config_com.anzogame.lol count:0
12-09 16:24:00.780 12420-12420/? W/Tinker.TinkerLoader: after tinker safe mode count:1
12-09 16:24:00.780 12420-12420/? I/Tinker.TinkerDexLoader: classloader: dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/com.anzogame.lol-1/base.apk"],nativeLibraryDirectories=[/data/app/com.anzogame.lol-1/lib/arm, /data/app/com.anzogame.lol-1/base.apk!/lib/armeabi-v7a, /vendor/lib, /system/lib]]]
12-09 16:24:00.806 12420-12420/? W/Tinker.ClassLoaderAdder: checkDexInstall result:true
12-09 16:24:00.807 12420-12420/? I/Tinker.TinkerDexLoader: after loaded classloader: dalvik.system.PathClassLoader[DexPathList[[zip file "/data/user/0/com.anzogame.lol/tinker/patch-3ce34da5/dex/classes.dex.jar", zip file "/data/user/0/com.anzogame.lol/tinker/patch-3ce34da5/dex/classes2.dex.jar", zip file "/data/user/0/com.anzogame.lol/tinker/patch-3ce34da5/dex/classes3.dex.jar", zip file "/data/user/0/com.anzogame.lol/tinker/patch-3ce34da5/dex/classes4.dex.jar", zip file "/data/user/0/com.anzogame.lol/tinker/patch-3ce34da5/dex/test.dex.jar", zip file "/data/app/com.anzogame.lol-1/base.apk"],nativeLibraryDirectories=[/data/app/com.anzogame.lol-1/lib/arm, /data/app/com.anzogame.lol-1/base.apk!/lib/armeabi-v7a, /vendor/lib, /system/lib]]]
12-09 16:24:00.812 12420-12420/? E/Tinker.ResourcePatcher: checkResUpdate success, found test resource assets file only_use_to_test_tinker_resource.txt
12-09 16:24:00.813 12420-12420/? I/Tinker.ResourceLoader: monkeyPatchExistingResources resource file:/data/user/0/com.anzogame.lol/tinker/patch-3ce34da5/res/resources.apk, use time: 6
12-09 16:24:00.813 12420-12420/? I/Tinker.TinkerLoader: tryLoadPatchFiles: load end, ok!
12-09 16:24:00.845 12420-12420/? D/Tinker.DefaultAppLike: onBaseContextAttached:
12-09 16:24:00.853 12420-12420/? I/Tinker.SamplePatchListener: application maxMemory:256
12-09 16:24:00.858 12420-12420/? W/Tinker.Tinker: tinker patch directory: /data/user/0/com.anzogame.lol/tinker
12-09 16:24:00.863 12420-12420/? I/Tinker.TinkerLoadResult: parseTinkerResult loadCode:0
12-09 16:24:00.863 12420-12420/? I/Tinker.TinkerLoadResult: parseTinkerResult oldVersion:, newVersion:3ce34da54e4e3ec20c9f8868a8970db0, current:3ce34da54e4e3ec20c9f8868a8970db0
12-09 16:24:00.863 12420-12420/? I/Tinker.TinkerLoadResult: oh yeah, tinker load all success
12-09 16:24:00.863 12420-12420/? I/Tinker.DefaultLoadReporter: patch version change from to 3ce34da54e4e3ec20c9f8868a8970db0
12-09 16:24:00.863 12420-12420/? I/Tinker.DefaultLoadReporter: try kill all other process
12-09 16:24:00.865 12420-12420/? I/Tinker.DefaultLoadReporter: patch load result, path:/data/user/0/com.anzogame.lol/tinker, code:0, cost:81
oh yeah, tinker load all success
补丁合成加载成功。
tinker 多渠道打包怎么处理?
tinker 本身是支持flavor 打包的:
加入上面的配置,执行assembleRelease task, 会在app/build/bakApk/目录下面生成所有flavor 中的渠道包.
接着修改代码和资源文件,执行tinkerPatchAllFlavorRelease 生成所有渠道的补丁包
然在后在手机上执行通渠道的补丁升级,可以正常升级,如果你用tencent渠道的包升级test 渠道的补丁包,就会失败,什么原因呢?查看tinker文档
额。。。。 实际使用中不可能对不同渠道进行补丁包的管理,多个渠道需要使用一个补丁包,那么我们就需要对我们现在有的打包方式进行修改,tinker 的建议方式原理和美团的快速打包方案类似,那么我们来看下美团的打包方案。
美团打包方案
传送门1
传送门2
第一步:
修改我们的多渠道flavors 打包方式去掉所有渠道,只剩下一个test渠道做为基础渠道,然后在启动APP的时候动态设置渠道值,例如可以用友盟提供的方式AnalyticsConfig.setChannel(ChannelUtil.getChannel(getCurrentActivity()));动态设置渠道值
获取渠道代码
第二步:
替换我们之前的获取渠道名称代码 AndroidApiUtils.getUmengChannel(Context context) , 改为
友盟提供的方式AnalyticsConfig.getChannel(getApplicationContext()) ,因为我们以前的代码是直接读取的
meta 中的与友盟渠道号
,查看友盟的代码,AnalyticsConfig.getChannel 是在渠道号不为空的情况下才会去读取meta 中的,我们打包方式是不会对AndroidManifest.xml 中的渠道号做替换的,只是内存中的channel 替换。
第三步:
打包生成apk ,在把生成APK 放到脚本同级的目录下面,进入目录执行python MultiChannelBuildTool.py
生成apk, 不到一分钟,所有渠道的APK 已经生成好了,channel.txt 是所有渠道的渠道列表。
下面是打包脚本:
按照美团的打包方式生成基础APK 多渠道APK 补丁包,经验证补丁可以正常运行。
以后发版本打包的流程, 执行gradle clean assembleRelease -PDEV_PACKET=false 任务,release 目录下面生成基础版本的APK ,然后在当前目录下面实行python MultiChannelBuildTool.py ,同时在release 同级目录下面会生成 bakApk/.../ 备份的apk ,mapping.txt, R.txt 文件。
Tinker 常用API
传送门
Tinker 自定义扩展
[传送门](file:///Users/zhulizhi/Downloads/Tinker-%E8%87%AA%E5%AE%9A%E4%B9%89%E6%89%A9%E5%B1%95.html)
Tinker 常见问题
传送门
热补丁流程错误码定义:
1,下载失败上报
2,接口请求错误上报
3,MD5文件校验失败错误上报
5,文件格式校验错误上报
6,补丁合成失败上报
7,补丁合成成功上报
热补丁相关疑问?
tinker 热补丁和DroidPlug插件有什么区别?
tinker 是热更新工具 目前补丁不支持新增四大组件
DroidPlug 核心思想是hook 系统流程,占坑实现插件。
tinker 的资源是怎么修复的?
tinker 的DEX是怎么修复的?
tinker 的so是怎么修复的?
Tinker 相关文章
微信Tinker的一切都在这里,包括源码(一)
Android_N混合编译与对热补丁影响解析
微信Android热补丁实践演进之路