【0155】【热修复与插件化-3】 热修复Tinker详解

1.Tinker的基本介绍

【本章内容提要】

1.1 Tinker的基本介绍

1.2 Tinker的核心原理

2.使用Tinker完成bug的修复

2.1 Tinker的集成

【说明-区别】

provided是指编译的时候依赖这个jar包,但是最终打包的时候不打进去

compile所指编译内容可以是本地的,也可以是远程(jCenter)中的。如果是本地的包,最好以aar形式给出。编译完成后jar包会被打包进入apk中。

2.2 封装TinkerManager

【说明】隔离,封装TinkerManager,app所有的交互只与TinkerManager有关,放置框架的改变导致app的代码大量的改变;

【Tinker的初始化】

【加载类和上下文的获取】

2.3 ApplicationLike的继承 

【分包】分包是根据自己项目的情况是否添加;

 

【解释】代理Application:因为要用ApplicationLike是一个代理真实的Application,可以监听Application当中的一系列的生命周期中发生的事件;

【在清单中声明Application】需要先Build,生成对应的Application,然后在清单中声明;

 

2.4 新旧apk的生成

 

【添加TinkerID】标记补丁是否可以安装到该应用的apk中;Tinker会判断补丁文件的TinkerID和app的TinkerID是否是一致的;

【增加新的代码生成新的apk】布局中增加了一个按钮

 

3. 生成补丁文件

【说明】Tinker具有两种生成patch文件的方式,一种是使用命令行,一种是使用配置;

3.1 命令行生成方式

【xml配置文件中需要修改的地方】

 

 

【生成patch】

3.命令行接入

命令行工具tinker-patch-cli.jar提供了基准包与新安装包做差异,生成补丁包的功能。具体的命令参数如下:

java -jar tinker-patch-cli.jar -old old.apk -new new.apk -config tinker_config.xml -out output_path

参数与gradle基本一致,新增的sign参数,我们需要输入签名路径与签名信息。

输出文件详解

在tinkerPatch输出目录build/outputs/tinkerPatch中,我们关心的文件有:

文件名描述
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,你可以在这里查看是否有文件遗漏
tempPatchedDexes 在Dalvik与Art平台,最终在手机上合成的完整Dex,我们可以在这里查看dex合成的产物。

每次编译结束,我们都应该查看相关日志,清楚最终在补丁包中的文件。尤其是dex的补丁文件,即使是1k的dex补丁文件,也会带来合成时的时间损耗以及合成完整dex文件ROM空间体积这两部分影响!

【push】push补丁文件到自定义的文件的目下;

 4. Gradle中配置生成patch文件

4.1 gradle参数详解

【说明】内容来源于Tiker官方的文档;参数不是全部都需要,没有配置的就是默认值;

我们将原apk包称为基准apk包,tinkerPatch直接使用基准apk包与新编译出来的apk包做差异,得到最终的补丁包。gradle配置的参数详细解释如下:

参数默认值描述
tinkerPatch   全局信息相关的配置项
tinkerEnable true 是否打开tinker的功能。
oldApk null 基准apk包的路径,必须输入,否则会报错。
newApk null 选填,用于编译补丁apk路径。如果路径合法,即不再编译新的安装包,使用oldApk与newApk直接编译。
outputFolder null 选填,设置编译输出路径。默认在build/outputs/tinkerPatch  
ignoreWarning false 如果出现以下的情况,并且ignoreWarning为false,我们将中断编译。因为这些情况可能会导致编译出来的patch包带来风险:
1. minSdkVersion小于14,但是dexMode的值为"raw";
2. 新编译的安装包出现新增的四大组件(Activity, BroadcastReceiver...);
3. 定义在dex.loader用于加载补丁的类不在main dex中;
4. 定义在dex.loader用于加载补丁的类出现修改;
5. resources.arsc改变,但没有使用applyResourceMapping编译。
useSign true 在运行过程中,我们需要验证基准apk包与补丁包的签名是否一致,我们是否需要为你签名。
buildConfig   编译相关的配置项
applyMapping null 可选参数;在编译新的apk时候,我们希望通过保持旧apk的proguard混淆方式,从而减少补丁包的大小。这个只是推荐设置,不设置applyMapping也不会影响任何的assemble编译
applyResourceMapping null 可选参数;在编译新的apk时候,我们希望通过旧apk的R.txt文件保持ResId的分配,这样不仅可以减少补丁包的大小,同时也避免由于ResId改变导致remote view异常
tinkerId null 在运行过程中,我们需要验证基准apk包的tinkerId是否等于补丁包的tinkerId。这个是决定补丁包能运行在哪些基准包上面,一般来说我们可以使用git版本号、versionName等等。
keepDexApply false 如果我们有多个dex,编译补丁时可能会由于类的移动导致变更增多。若打开keepDexApply模式,补丁包将根据基准包的类分布来编译。
isProtectedApp false 是否使用加固模式,仅仅将变更的类合成补丁。注意,这种模式仅仅可以用于加固应用中。
supportHotplugComponent(added 1.9.0) false 是否支持新增非export的Activity
dex   dex相关的配置项
dexMode jar 只能是'raw'或者'jar'。 
对于'raw'模式,我们将会保持输入dex的格式。
对于'jar'模式,我们将会把输入dex重新压缩封装到jar。如果你的minSdkVersion小于14,你必须选择‘jar’模式,而且它更省存储空间,但是验证md5时比'raw'模式耗时。默认我们并不会去校验md5,一般情况下选择jar模式即可。
pattern [] 需要处理dex路径,支持*、?通配符,必须使用'/'分割。路径是相对安装包的,例如assets/...
loader [] 这一项非常重要,它定义了哪些类在加载补丁包的时候会用到。这些类是通过Tinker无法修改的类,也是一定要放在main dex的类。
这里需要定义的类有:
1. 你自己定义的Application类;
2. Tinker库中用于加载补丁包的部分类,即com.tencent.tinker.loader.*; 
3. 如果你自定义了TinkerLoader,需要将它以及它引用的所有类也加入loader中;
4. 其他一些你不希望被更改的类,例如Sample中的BaseBuildInfo类。这里需要注意的是,这些类的直接引用类也需要加入到loader中。或者你需要将这个类变成非preverify。
5. 使用1.7.6版本之后的gradle版本,参数1、2会自动填写。若使用newApk或者命令行版本编译,1、2依然需要手动填写
lib   lib相关的配置项
pattern [] 需要处理lib路径,支持*、?通配符,必须使用'/'分割。与dex.pattern一致, 路径是相对安装包的,例如assets/...
res   res相关的配置项
pattern [] 需要处理res路径,支持*、?通配符,必须使用'/'分割。与dex.pattern一致, 路径是相对安装包的,例如assets/...,务必注意的是,只有满足pattern的资源才会放到合成后的资源包。
ignoreChange [] 支持*、?通配符,必须使用'/'分割。若满足ignoreChange的pattern,在编译时会忽略该文件的新增、删除与修改。 最极端的情况,ignoreChange与上面的pattern一致,即会完全忽略所有资源的修改。
largeModSize 100 对于修改的资源,如果大于largeModSize,我们将使用bsdiff算法。这可以降低补丁包的大小,但是会增加合成时的复杂度。默认大小为100kb
packageConfig   用于生成补丁包中的'package_meta.txt'文件
configField TINKER_ID, NEW_TINKER_ID configField("key", "value"), 默认我们自动从基准安装包与新安装包的Manifest中读取tinkerId,并自动写入configField。在这里,你可以定义其他的信息,在运行时可以通过TinkerLoadResult.getPackageConfigByName得到相应的数值。但是建议直接通过修改代码来实现,例如BuildConfig。
sevenZip   7zip路径配置项,执行前提是useSign为true
zipArtifact null 例如"com.tencent.mm:SevenZip:1.1.10",将自动根据机器属性获得对应的7za运行文件,推荐使用。
path 7za 系统中的7za路径,例如"/usr/local/bin/7za"。path设置会覆盖zipArtifact,若都不设置,将直接使用7za去尝试。

4.2 与命令行相比需要改动的地方

【说明】共4处地方有改动

 4.3 必须的Gradle参数配置

【Gradle配置的说明】app开发过程中的是不需要使用Tinker的配置的,因此,Gradle的Tinker的声明,使用一个语句块,方便管理;可以直接定义一个变量进行开关;

【定义配置的开关】

 

【oldAPK的路径的获取】

 

【警告的配置】此处设置为只要是存在警告就中断生成patch文件;

【签名文件和使能Tinker配置】

 

【buildConfig的配置】

【参数】【keepDexApply】默认false:如果我们有多个dex,编译补丁时可能会由于类的移动导致变更增多。若打开keepDexApply模式,补丁包将根据基准包的类分布来编译。

 

 [函数的完成]

【dex参数的指定】

[dexMode]

只能是'raw'或者'jar'。 
对于'raw'模式,我们将会保持输入dex的格式。
对于'jar'模式,我们将会把输入dex重新压缩封装到jar。如果你的minSdkVersion小于14,你必须选择‘jar’模式,而且它更省存储空间,

但是验证md5时比'raw'模式耗时。默认我们并不会去校验md5,一般情况下选择jar模式即可。

 [pattern]

需要处理dex路径,支持*、?通配符,必须使用'/'分割。路径是相对安装包的,例如assets/...

[loader]

这一项非常重要,它定义了哪些类在加载补丁包的时候会用到。这些类是通过Tinker无法修改的类,也是一定要放在main dex的类。
这里需要定义的类有:
1. 你自己定义的Application类
2. Tinker库中用于加载补丁包的部分类,即com.tencent.tinker.loader.*; 
3. 如果你自定义了TinkerLoader,需要将它以及它引用的所有类也加入loader中;
4. 其他一些你不希望被更改的类,例如Sample中的BaseBuildInfo类。这里需要注意的是,这些类的直接引用类也需要加入到loader中。或者你需要将这个类变成非preverify。
5. 使用1.7.6版本之后的gradle版本,参数1、2会自动填写。若使用newApk或者命令行版本编译,1、2依然需要手动填写

【指定lib和res资源文件】

[ignoreChange]指定不受影响的资源的路径; 

支持*、?通配符,必须使用'/'分割。若满足ignoreChange的pattern,在编译时会忽略该文件的新增、删除与修改。 最极端的情况,ignoreChange与上面的pattern一致,即会完全忽略所有资源的修改。

[largeModeSize]

对于修改的资源,如果大于largeModSize,我们将使用bsdiff算法。这可以降低补丁包的大小,但是会增加合成时的复杂度。默认大小为100kb

4.4 非必须的Gradle参数配置

【7zip压缩工具的配置】一般的开发者电脑上不一定安装了7zip工具,没有配置;

 4.5 指定复制文件和拷贝路径的脚本

【说明】在编译生成之后的apk文件的路径在之前的代码中没有配置(即下面的代码没有定义),在此处配置;并且不用手动拷贝,此处完成拷贝;

 1    =====官方文档的语句=======判断是否具多渠道===========
List<String> flavors = new ArrayList<>(); 2 project.android.productFlavors.each { flavor -> 3 flavors.add(flavor.name) 4 }
5 boolean hasFlavors = flavors.size() > 0
================================================ 6 /** 7 * 复制基准包和其它必须文件到指定目录 8 */ 9 android.applicationVariants.all { variant -> 10 /** 11 * task type, you want to bak 12 */ 13 def taskName = variant.name 14 def date = new Date().format("MMdd-HH-mm-ss") 15 16 tasks.all { 17 if ("assemble${taskName.capitalize()}".equalsIgnoreCase(it.name)) { 18 19 it.doLast { 20 copy { 21 def fileNamePrefix = "${project.name}-${variant.baseName}" 22 def newFileNamePrefix = hasFlavors ? "${fileNamePrefix}" : "${fileNamePrefix}-${date}" 23                //目的路径的定义 24 def destPath = hasFlavors ? file("${bakPath}/${project.name}-${date}/${variant.flavorName}") : bakPath 25 from variant.outputs.outputFile 26 into destPath
                //复制拷贝文件
27 rename { String fileName -> 28 fileName.replace("${fileNamePrefix}.apk", "${newFileNamePrefix}.apk") 29 } 30 //复制拷贝文件 31 from "${buildDir}/outputs/mapping/${variant.dirName}/mapping.txt" 32 into destPath 33 rename { String fileName -> 34 fileName.replace("mapping.txt", "${newFileNamePrefix}-mapping.txt") 35 } 36               //复制拷贝文件 37 from "${buildDir}/intermediates/symbols/${variant.dirName}/R.txt" 38 into destPath 39 rename { String fileName -> 40 fileName.replace("R.txt", "${newFileNamePrefix}-R.txt") 41 } 42 } 43 } 44 } 45 } 46 } 47 48

 

 5.组件化的认识

6.Tinker的组件化

【说明】此处的只是Tinker的单工程的实例化,使用的时候还需要拷贝代码;可以做成多工程的实例,只要添加依赖即可;

6.1 Tinker请求的流程

6.2 Tinker的组件化

【service的功能】

 【说明】app开始之后发送消息;发送请求查看是否具有patch

【处理下载patch的请求】

6.3 发布带有Tinker的apk

【调用】集成到项目中,然后调用的启动服务;

6.4 发生bug之后发布patch

6.5【将patch发布到服务器】

【说明】具有两种方法:【1】使用简单的服务器:bomb或者是百度网盘都可以的,资源上传之后都会生成对应的url地址;【2】使用代理,代理可以是json文件,也可以是patch文件;

6.6 下载patch修复

【说明】在app打开的时候会自动请求patch,在下载后patch之后,应用程序会自动闪退,杀死主进程;这是Tinker的特点;

 在后面的部分会有解决闪退的方法;

7.Tinker的高级功能

【说明】abtest:不同的渠道具有不同的交互和UI,根据不同的埋点数据对比哪种交互和UI更合理;

【本节内容提要】

7.1A/B测试常识

【普及常识】原文地址:https://blog.csdn.net/huver2007/article/details/71213676

有时相同的手机App里展示的页面会和别人的不同,“难道自己的没有自动更新?”很多人都这样想过吧,还有App后缀Beta、Pro字样,其实这些都是公司在进行产品的灰度发布。下面先来看看两个定义:
灰度:把黑色定为基本色,每个灰度对象都是0%(白色)到100%(黑色)的中间值,简而言之,灰度就是不饱和的黑色。
灰度发布:是指在黑与白之间,能够平滑过渡的一种发布方式。AB test就是一种灰度发布方式,让一部分用户继续用A,一部分用户开始用B,如果用户对B没有什么反对意见,那么逐步扩大范围
,把所有用户都迁移到B上面来。灰度发布可以保证整体系统的稳定,在初始灰度的时候就可以发现、调整问题,以保证其影响度。(百度百科) 除了AB test灰度发布另一种思想是,只发布给一小部分用户,如:App在发布之前,可能会针对性的给一小批种子用户发送下载链接,或者到小的应用市场去发布。用小流量发布的方式来检验新版会不会有问题。 为什么要做灰度发布 很多公司在定义一个产品,一个功能的时候,有的说白,有的说黑,但是互联网产品真正的定义者是谁?当然是用户,你说了不算,我说了也不算,用户口碑才是硬道理,因此需要一个灰度周期,让用户决定其生死,定义其黑白。 灰度发布对产品研发的重要性不言而喻,用业内的话说就是顶级的PM也只能跑赢一半的ABtest 像Uber, Airbnb, Wish这些新一代的企业,都是从第一天开始就做AB测试来做产品迭代优化的,所以他们才会不断提升高速发展。 反面例子就是人人网,人人从诞生开始就没脱离过Facebook的影子,很多功能和设计都是直接照搬,不考虑水土服不服,不做测试,直接裸奔上线,完全碰运气的玩法。 如何做A
/B测试? 适合做A/B测试的情况 其实当产品到一定规模的时候,任何改版都应该首先经过AB测试小流量验证,建议日活1000以上就开始做试验。如果用户体量小,就50% 50%对比试验,分层试验可以大幅度增加并行试验数量。
如果用户体量到达百万千万级别,可以用小流量测试各种不同的功能,
比如一个文案的改动,一个icon的优化带来的点击率有没有增加,增加了就继续扩大测试流量,减少了或是继续优化或是直接关掉。需要注意的是,在一些特定的功能点上,比如聊天页面增加一
些真的情侣或者亲人的功能,我们在流量选取上就应该考虑这些目标包不包含此功能针对的特定人群。 A
/B测试流程 如果从来没有用过AB测试,那可以先尝试从一个小改动开始,熟悉AB测试的实施流程,在关键环节的修改上做实验。特别是后端算法变更,像搜索算法、推荐算法、push算法等等,AB测试肯定是
必不可少的。还有一些我们拿捏不定或者争执不下的前端改动,都应该进行AB测试。
关键环节熟练之后,我们可以并行的去尝试更多的地方的修改。 一般AB测试流程 结合A
/B测试的开发流程 最终形成一套以AB测试为核心环节的上线流程: 需求评审-建立试验方案-新功能开发-灰度发布-小流量AB测试-发布成功的功能,关闭失败的 AB测试的周期? 试验的周期一般是7天,覆盖周末和周中的用户行为。对于复杂一些的测试,可以跑2周甚至1个月。还有一个办法,就是看试验结果的置信区间的收敛速度,如果置信区间达到3%-5%已经可以决策了,
就可以停止试验了。正常情况下,我们需要大流量试验来验证大型新功能,
比如新推荐算法,新学习模型,新聊天功能。然后我们可以同时用流量分层的方法做很多很多小试验,比如改UI改文案,看看有什么改变能带来用户转化的提升。同时跑10个以上的试验很正常,
这种并行决策实际上大幅度提高了产品优化效率,而不会延缓迭代。 总结 合理运用灰度发布和A
/B测试,对于PM来讲,是必须要掌握的核心技能之一,我们每天都在研究、体验、设计产品,有时会想当然的觉得这个流程不复杂,这个操作很简单,用户应该上来就会用,
不知不觉间就把我们思维强加给了用户。细节决定成败早已不是空谈,
对于任何可能影响到用户体验的地方,都应该防患于未然,已经被无数优秀的产品证明,用户粘性是最重要的。

7.2 埋点数据常识

【数据埋点】是为了采集业务数据(包括用户数据)做的技术层面的工作。
常见的埋点有“全埋点”和“代码埋点”两种,这两种埋点方式相比而言所采集的数据范围和深度是不一样的。

“代码埋点”采集数据的准确度和深度优于“全埋点”,像服务器、数据库的数据只有“代码埋点”才能准确采集,而“全埋点”只能采集前端数据。设置埋点的意义很重要,
开始分析数据之前必须要采集到数据,而埋点就是为实现数据采集的技术手段,针对数据采集与埋点的方法和介绍你可以看一下 原文【数据采集与埋点】,里面讲了数据采集的原则、
前端埋点技术、后端埋点技术,可以深入了解一下。

7.3 Tinker完成多渠道打包

【说明】【1】多渠道打包是使用友盟的多渠道打包;【2】具有两种方式:命令行的方式和gradle的方式(实际使用此方式);

 

【Tinker脚本的修改】没有修改结束,还需要修改;

 

【获取Tinker的多渠道的路径】

 

【Tinker官方提供的源码】

 1     project.afterEvaluate {
 2         if (hasFlavors) {
 3             task(tinkerPatchAllFlavorRelease) {
 4                 group = 'tinker'
 5                 def originOldPath = getTinkerBuildFlavorDirectory() //拿到最外层的文件夹
 6                 for (String flavor : flavors) {
 7                     def tinkerTask = tasks.getByName("tinkerPatch${flavor.capitalize()}Release")
 8                     dependsOn tinkerTask
 9                     def preAssembleTask = tasks.getByName("process${flavor.capitalize()}ReleaseManifest")
10                     preAssembleTask.doFirst {  //下面的代码主要是完成对不同的文件夹和文件名称的拼凑
11                         String flavorName = preAssembleTask.name.substring(7, 8).toLowerCase() + preAssembleTask.name.substring(8, preAssembleTask.name.length() - 15)
12                         project.tinkerPatch.oldApk = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-release.apk"
13                         project.tinkerPatch.buildConfig.applyMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-release-mapping.txt"
14                         project.tinkerPatch.buildConfig.applyResourceMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-release-R.txt"
15                     }
16                 }
17             }
18         //Debug包的patch文件的生成与上面的代码基本一致
19             task(tinkerPatchAllFlavorDebug) {
20                 group = 'tinker'
21                 def originOldPath = getTinkerBuildFlavorDirectory()
22                 for (String flavor : flavors) {
23                     def tinkerTask = tasks.getByName("tinkerPatch${flavor.capitalize()}Debug")
24                     dependsOn tinkerTask
25                     def preAssembleTask = tasks.getByName("process${flavor.capitalize()}DebugManifest")
26                     preAssembleTask.doFirst {
27                         String flavorName = preAssembleTask.name.substring(7, 8).toLowerCase() + preAssembleTask.name.substring(8, preAssembleTask.name.length() - 13)
28                         project.tinkerPatch.oldApk = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-debug.apk"
29                         project.tinkerPatch.buildConfig.applyMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-debug-mapping.txt"
30                         project.tinkerPatch.buildConfig.applyResourceMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-debug-R.txt"
31                     }
32 
33                 }
34             }
35         }
36     }

 

【执行多渠道打包的命令】

【生成不同渠道下的patch文件】

8.自定义Tinker的行为

8.1 自定义PatchListener

8.2 自定义TinkerResultService行为-解决闪屏问题

 

 9.自定义PatchListener

 9.1 【源码】DefaultTinkerResultService的功能

 1  protected int patchCheck(String path, String patchMd5) { //对patch文件进行校验;是我们可扩展的代码
 2         Tinker manager = Tinker.with(context);
 3         //check SharePreferences also
 4         if (!manager.isTinkerEnabled() || !ShareTinkerInternals.isTinkerEnableWithSharedPreferences(context)) {
 5             return ShareConstants.ERROR_PATCH_DISABLE;
 6         }
 7         File file = new File(path);
 8 
 9         if (!SharePatchFileUtil.isLegalFile(file)) {
10             return ShareConstants.ERROR_PATCH_NOTEXIST;
11         }
12 
13         //patch service can not send request
14         if (manager.isPatchProcess()) {
15             return ShareConstants.ERROR_PATCH_INSERVICE;
16         }
17 
18         //if the patch service is running, pending
19         if (TinkerServiceInternals.isTinkerPatchServiceRunning(context)) {
20             return ShareConstants.ERROR_PATCH_RUNNING;
21         }
22         if (ShareTinkerInternals.isVmJit()) {
23             return ShareConstants.ERROR_PATCH_JIT;
24         }
25 
26         Tinker tinker = Tinker.with(context);
27 
28         if (tinker.isTinkerLoaded()) {
29             TinkerLoadResult tinkerLoadResult = tinker.getTinkerLoadResultIfPresent();
30             if (tinkerLoadResult != null && !tinkerLoadResult.useInterpretMode) {
31                 String currentVersion = tinkerLoadResult.currentVersion;
32                 if (patchMd5.equals(currentVersion)) {
33                     return ShareConstants.ERROR_PATCH_ALREADY_APPLY;
34                 }
35             }
36         }
37 
38         if (!UpgradePatchRetry.getInstance(context).onPatchListenerCheck(patchMd5)) {
39             return ShareConstants.ERROR_PATCH_RETRY_COUNT_LIMIT;
40         }
41 
42         return ShareConstants.ERROR_PATCH_OK;
43     }

 9.2 完成自定义listenr功能

【说明】使用md5校验:防止下载的文件和服务器的文件不一致;

【增加对话框】如果增加对话框,让用户选择是否需要进行选择安装patch,则需要复写下面的方法:onPatchReceived方法;

【对listener校验的使用】

【改造构造方法】

10. 自定义CustomResultService 

10.1 【源码】默认实现的功能

【杀死进程、闪退的源码】

10.2 复写onPatchResult方法

 

11.源码简单阅读

【支持三种资源的修改/增加】核心的思想:【dex】找到对应的区,替换或者增加源码;【res】先处理Manifest文件,然后处理res资源; 

 

12. 使用Tinker注意的问题

 

posted @ 2018-05-17 13:38  OzTaking  阅读(751)  评论(0)    收藏  举报