Tinker接入实现和原理分析
最近研究了下Tinker的热修复实现,相对于其他的热修复方法,微信的热修复有着不可比拟的用户基数优势,这里也不用多说,搬来一张微信的吹逼图。
微信吹逼.ing
这里给一下微信的官方接入地址,以及Tinker的开源地址
Tinker -- 微信Android热补丁方案
Tecent/Tinker
好,下面开始我们的热修复接入。
一 : 导入开源地址
在整个项目的最外层gradle配置
classpath 'com.tencent.tinker:tinker-patch-gradle-plugin:1.9.14.3'
然后在我们的子项目中配置
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
implementation 'com.tencent.tinker:tinker-android-lib:1.9.14.3'
implementation "com.android.support:multidex:1.0.3"
然后Gradle同步刷新一下,我们的Tinker就下载成功,配置multidex主要是为了分包,上面只是最基本的下载,Gradle还需要配置,下面给个简单的示例:
apply plugin: 'com.android.application'
def oldApkName = "输入你的基准apk名称"
android {
compileSdkVersion 29
buildToolsVersion "29.0.2"
defaultConfig {
applicationId "cn.simple.example"
minSdkVersion 21
targetSdkVersion 29
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
multiDexEnabled true
buildConfigField "String", "MESSAGE", "\"I am the base apk\""
buildConfigField "String", "TINKER_ID", "\"${getTinkerIdValue()}\""
buildConfigField "String", "PLATFORM", "\"all\""
}
signingConfigs {
release {
storeFile file("./keystore/example.jks")
storePassword 'example'
keyAlias = 'example'
keyPassword 'example'
}
}
buildTypes {
release {
minifyEnabled true //是否开启混淆
signingConfig signingConfigs.release
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
shrinkResources true // 是否去除无效的资源文件
}
}
dataBinding {
enabled = true
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'com.tencent.tinker:tinker-android-lib:1.9.14.3'
implementation "com.android.support:multidex:1.0.3"
}
def gitSha() {
try {
String gitRev = 'git rev-parse --short HEAD'.execute(null, project.rootDir).text.trim()
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'")
}
}
def bakPath = file("${buildDir}/bakApk/")
ext {
tinkerEnabled = true
tinkerOldApkPath = "${bakPath}/app-release-1213-15-16-23.apk"
tinkerApplyMappingPath = "${bakPath}/app-release-1213-15-01-48-mapping.txt"
tinkerApplyResourcePath = "${bakPath}/app-release-1213-15-01-48-R.txt"
tinkerBuildFlavorDirectory = "${bakPath}/app-2019-12-13-14"
}
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") ? Boolean.parseBoolean(TINKER_ENABLE) : ext.tinkerEnabled
}
def getTinkerBuildFlavorDirectory() {
return ext.tinkerBuildFlavorDirectory
}
if (buildWithTinker()) {
apply plugin: 'com.tencent.tinker.patch'
tinkerPatch {
oldApk = "${bakPath}/${oldApkName}"
ignoreWarning = false
useSign = true
tinkerEnable = buildWithTinker()
buildConfig {
applyMapping = getApplyMappingPath()
applyResourceMapping = getApplyResourceMappingPath()
tinkerId = getTinkerIdValue()
keepDexApply = false
isProtectedApp = false
supportHotplugComponent = false
}
dex {
dexMode = "jar"
pattern = ["classes*.dex",
"assets/secondary-dex-?.jar"]
loader = [
//use sample, let BaseBuildInfo unchangeable with tinker
"tinker.sample.android.app.BaseBuildInfo"
]
}
lib {
pattern = ["lib/*/*.so"]
}
res {
pattern = ["res/*", "assets/*", "resources.arsc", "AndroidManifest.xml"]
ignoreChange = ["assets/sample_meta.txt"]
largeModSize = 100
}
packageConfig {
configField("patchMessage", "tinker is sample to use")
configField("platform", "all")
configField("patchVersion", "1.0")
}
sevenZip {
zipArtifact = "com.tencent.mm:SevenZip:1.1.10"
}
}
List<String> flavors = new ArrayList<>();
project.android.productFlavors.each { flavor ->
flavors.add(flavor.name)
}
boolean hasFlavors = flavors.size() > 0
def date = new Date().format("MMdd-HH-mm-ss")
android.applicationVariants.all { variant ->
def taskName = variant.name
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
if (variant.metaClass.hasProperty(variant, 'packageApplicationProvider')) {
def packageAndroidArtifact = variant.packageApplicationProvider.get()
if (packageAndroidArtifact != null) {
try {
from new File(packageAndroidArtifact.outputDirectory.getAsFile().get(), variant.outputs.first().apkData.outputFileName)
} catch (Exception e) {
from new File(packageAndroidArtifact.outputDirectory, variant.outputs.first().apkData.outputFileName)
}
} else {
from variant.outputs.first().mainOutputFile.outputFile
}
} else {
from variant.outputs.first().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"
from "${buildDir}/intermediates/symbol_list/${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"
}
}
}
}
}
}
task sortPublicTxt() {
doLast {
File originalFile = project.file("public.txt")
File sortedFile = project.file("public_sort.txt")
List<String> sortedLines = new ArrayList<>()
originalFile.eachLine {
sortedLines.add(it)
}
Collections.sort(sortedLines)
sortedFile.delete()
sortedLines.each {
sortedFile.append("${it}\n")
}
}
}
内容看起来很多,不懂Gradle的同学估计已经懵了,不过不要紧,这里面我们使用是可以全部复制到我们的项目里面的,除了最上面的
def oldApkName = "输入你的基准apk名称"
只有这一行我们打补丁包的时候才需要更改一下,如果想要了解每个参数的具体作用,可以参考
Tinker 接入指南
二 : 项目配置
项目配置涉及到多个类,这里提供一下我写好的git地址,直接将tinker包下面的类全部复制出来即可
点我就对了
里面包含一个PatchActivity和SampleResultService,都需要在Mainfest里面配置一下,并且把PatchActivity设置为启动类,PatchActivity实际就是一个示例,SampleResultService是用来处理补丁过程的服务。
全部复制出来过后,我们再找到我们项目的Application,没有的话新建一个就可以了,需要继承TinkerApplication,这个类也是Tinker库里面的,不过需要传一些参数,下面给个示例:
public class App extends TinkerApplication {
public App() {
super(ShareConstants.TINKER_ENABLE_ALL,
"填入合理的包名地址.TinkerApplicationLike",
"com.tencent.tinker.loader.TinkerLoader",
false);
}
@Override
public void onCreate() {
super.onCreate();
}
}
TinkerApplicationLike这个类就在我刚才提供的tinker包里面,我们把"填入合理的包名地址"替换成我们放TinkerApplicationLike类的具体位置就可以了,我这里提供的热修复方法不包括修复Application,因为程序启动时会加载默认的Application类,这导致我们补丁包是无法对它做修改,如果有这方面需求,请走这边
Tinker 自定义扩展
里面也介绍类我们上面App可以传入的参数解释,可以参考一下。
三 : 生成补丁和使用
终于来到最后一步了,首先知道我们的补丁的生成原理是通过基准包和新包的差异来生成的,所以我们需要先生成一个基准包,其实就是一般的apk罢了,只是为了在补丁操作中区分,我们选择assembleRelease生成我们的基准包,assembleRelease在这里
Gradle示意图.png
Gradle就在android studio的最右侧,字是横着的,找一下,然后点击assembleRelease开始运行,等运行结束我们到项目的app/build/bakApk/包下面就能看到形如:app-release-1213-16-04-54.apk 的apk,这样的apk就是我们的基准包了,这也是我们平时上线给用户用的包,但是如果出现问题了,那么这个时候就可以以这个基准包来生成补丁了,比如这个基准包给用户使用过程中,出现问题了,那么我们就需要修改我们的代码了,比如我们现在修改PatchActivity类里面id是textView的内容,设置为我们想要的内容,表示这次补丁修改的地方,然后我们把刚才生成的基准包名称复制到我们的Gradle中,替换"输入你的基准apk名称"即可,然后Gradle刷新一下,再找到我们的TinkerPatchRelease,如下图
TinkerPatchRelease
点击运行,然后我们到项目的app/build/outputs/apk/tinkerPatch包下几句能找到:patch_signed_7zip.apk 的补丁包,这个补丁包是很小的,然后怎么让我们刚才改的内容在基准包上面生效呢。
首先我们需要安装基准包,然后复制这个补丁包到我们手机文件夹的根目录下,放置,要注意的是名称不能更改,如果变了,需要改回patch_signed_7zip.apk,然后打开我们的基准包,首先需要授存储权限,然后发现id为textview的文本显示的还是之前的,点击第一个按钮,加载我们的补丁包,但是是不会立即生效的,如果我们看到吐司形如:
patch success, please restart process
就表示我们的补丁加载成功,然后我们退出程序,重新进来,发现我们id为textview的文本显示的内容就已经被更改了,如果同学不想把补丁包放在跟目录下,更改一下PatchActivity类中加载补丁的代码路径就可以了,包的名称修改也是如此,到此Tinker的热修复流程我们就完成了。
四 : 原理分析
那么有的同学可能很好奇,为什么要重新启动才能生效呢,这是因为Tinker的实现补丁原理是替换dexElements,正如我们之前说的Application通常修复不了,当然还有其他接入方式,Tinker会在加载我们的需要修复的类之前就加载我们修复过后的内容,导致错误的逻辑不再执行。
事实上我们可以通过这个原理实现我们自己的热修复,提供下模仿热修复的类:
public class FixDexUtil {
private static final String DEX_SUFFIX = ".dex";
private static final String APK_SUFFIX = ".apk";
private static final String JAR_SUFFIX = ".jar";
private static final String ZIP_SUFFIX = ".zip";
public static final String DEX_DIR = "odex";
private static final String OPTIMIZE_DEX_DIR = "optimize_dex";
private static HashSet<File> loadedDex = new HashSet<>();
static {
loadedDex.clear();
}
/**
* 加载补丁,使用默认目录:data/data/包名/files/odex
*
* @param context
*/
public static void loadFixedDex(Context context) {
loadFixedDex(context, null);
}
/**
* 加载补丁
*
* @param context 上下文
* @param patchFilesDir 补丁所在目录
*/
public static void loadFixedDex(Context context, File patchFilesDir) {
// dex合并之前的dex
doDexInject(context, loadedDex);
}
/**
*@author bthvi
*@time 2018/6/25 0025 15:51
*@desc 验证是否需要热修复
*/
public static boolean isGoingToFix(@NonNull Context context) {
boolean canFix = false;
File externalStorageDirectory = Environment.getExternalStorageDirectory();
// 遍历所有的修复dex , 因为可能是多个dex修复包
File fileDir = externalStorageDirectory != null ?
new File(externalStorageDirectory,"007"):
new File(context.getFilesDir(), DEX_DIR);// data/data/包名/files/odex(这个可以任意位置)
File[] listFiles = fileDir.listFiles();
if (listFiles != null){
for (File file : listFiles) {
if (file.getName().startsWith("clss") &&
(file.getName().endsWith(DEX_SUFFIX)
|| file.getName().endsWith(APK_SUFFIX)
|| file.getName().endsWith(JAR_SUFFIX)
|| file.getName().endsWith(ZIP_SUFFIX))) {
loadedDex.add(file);// 存入集合
//有目标dex文件, 需要修复
canFix = true;
}
}
}
return canFix;
}
private static void doDexInject(Context appContext, HashSet<File> loadedDex) {
String optimizeDir = appContext.getFilesDir().getAbsolutePath() +
File.separator + OPTIMIZE_DEX_DIR;
// data/data/包名/files/optimize_dex(这个必须是自己程序下的目录)
File fopt = new File(optimizeDir);
if (!fopt.exists()) {
fopt.mkdirs();
}
try {
// 1.加载应用程序dex的Loader
PathClassLoader pathLoader = (PathClassLoader) appContext.getClassLoader();
for (File dex : loadedDex) {
// 2.加载指定的修复的dex文件的Loader
DexClassLoader dexLoader = new DexClassLoader(
dex.getAbsolutePath(),// 修复好的dex(补丁)所在目录
fopt.getAbsolutePath(),// 存放dex的解压目录(用于jar、zip、apk格式的补丁)
null,// 加载dex时需要的库
pathLoader// 父类加载器
);
// 3.开始合并
// 合并的目标是Element[],重新赋值它的值即可
/**
* BaseDexClassLoader中有 变量: DexPathList pathList
* DexPathList中有 变量 Element[] dexElements
* 依次反射即可
*/
//3.1 准备好pathList的引用
Object dexPathList = getPathList(dexLoader);
Object pathPathList = getPathList(pathLoader);
//3.2 从pathList中反射出element集合
Object leftDexElements = getDexElements(dexPathList);
Object rightDexElements = getDexElements(pathPathList);
//3.3 合并两个dex数组
Object dexElements = combineArray(leftDexElements, rightDexElements);
// 重写给PathList里面的Element[] dexElements;赋值
Object pathList = getPathList(pathLoader);// 一定要重新获取,不要用pathPathList,会报错
setField(pathList, pathList.getClass(), "dexElements", dexElements);
}
Toast.makeText(appContext, "修复完成", Toast.LENGTH_SHORT).show();
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 反射给对象中的属性重新赋值
*/
private static void setField(Object obj, Class<?> cl, String field, Object value) throws NoSuchFieldException, IllegalAccessException {
Field declaredField = cl.getDeclaredField(field);
declaredField.setAccessible(true);
declaredField.set(obj, value);
}
/**
* 反射得到对象中的属性值
*/
private static Object getField(Object obj, Class<?> cl, String field) throws NoSuchFieldException, IllegalAccessException {
Field localField = cl.getDeclaredField(field);
localField.setAccessible(true);
return localField.get(obj);
}
/**
* 反射得到类加载器中的pathList对象
*/
private static Object getPathList(Object baseDexClassLoader) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
return getField(baseDexClassLoader, Class.forName("dalvik.system.BaseDexClassLoader"), "pathList");
}
/**
* 反射得到pathList中的dexElements
*/
private static Object getDexElements(Object pathList) throws NoSuchFieldException, IllegalAccessException {
return getField(pathList, pathList.getClass(), "dexElements");
}
/**
* 数组合并
*/
private static Object combineArray(Object arrayLhs, Object arrayRhs) {
Class<?> clazz = arrayLhs.getClass().getComponentType();
int i = Array.getLength(arrayLhs);// 得到左数组长度(补丁数组)
int j = Array.getLength(arrayRhs);// 得到原dex数组长度
int k = i + j;// 得到总数组长度(补丁数组+原dex数组)
Object result = Array.newInstance(clazz, k);// 创建一个类型为clazz,长度为k的新数组
System.arraycopy(arrayLhs, 0, result, 0, i);
System.arraycopy(arrayRhs, 0, result, i, j);
return result;
}
}
主要作用就是合并dexElements,再提供一下调用方法
File externalStorageDirectory = Environment.getExternalStorageDirectory();
// 遍历所有的修复dex , 因为可能是多个dex修复包
File fileDir = externalStorageDirectory != null ?
new File(externalStorageDirectory, "patch") :
new File(getFilesDir(), FixDexUtil.DEX_DIR);// data/user/0/包名/files/odex(这个可以任意位置)
if (!fileDir.exists()) {
fileDir.mkdirs();
}
if (FixDexUtil.isGoingToFix(this)) {
FixDexUtil.loadFixedDex(this, Environment.getExternalStorageDirectory());
}
我们可以把这个放到Application或者加载页中即可,我们生成补丁包的位置就放在根目录下的patch包内即可,修改上面代码也可以更改。然后我们打包就可以生成基准包,补丁包怎么生成呢,首先我们需要确认我们需要修改的类,然后修改好之后,连同项目的包名复制出来,被修复的类也按各自的包名放在里面,不需要修改的类就不用放进来了,然后在整体的包外面调用java命令行
dx --dex --output= 目标输入目录/classes.dex [刚才拷贝的需要修复bug的所有类所在包名的目录]
把"目标输入目录"替换成我们需要生成补丁的电脑地址,class.dex就是我们会生成的补丁,[刚才拷贝的需要修复bug的所有类所在包名的目录]就是我们刚才修复的所有bug类的所在包名,运行命令后,不过意外,我们就能得到class.dex这个补丁了,我们先运行我们的基准包,然后把这个补丁复制到我们的手机根目录下的patch包中,然后运行我们合并补丁的代码,就是上面提供的方法,然后重新打开包,发现我们的bug消失了~