如今 hotfix 框架有不少,原理大同小异,基本上是基于qq空间这篇文章 或者微信的方案。惋惜的是微信的 Tinker 以及 QZone 都没有将其具体实现开源出来,只是在文章中分析了现有各个 hotfix 框架的优缺点以及他们的实现方案。Amigo 原理与 Tinker 基本相同,可是在 Tinker 的基础上,进一步实现了 so 文件、资源文件、Activity、BroadcastReceiver 的修复,几乎能够号称全面修复,不愧 Amigo(朋友)这个称号,能在危急时刻送来全面的帮助。java
首先咱们先来看看如何使用这个库。 库地址:https://github.com/eleme/Amigonode
在project 的build.gradle
中android
dependencies { classpath 'me.ele:amigo:0.0.3' }
在module 的build.gradle
中git
apply plugin: 'me.ele.amigo'
就这样轻松的集成了Amigo。github
补丁包生效有两种方式能够选择:数组
稍后生效补丁包微信
若是不想当即生效而是用户第二次打开App 时才打入补丁包,则能够将新的Apk 放到 /data/data/{your pkg}/files/amigo/demo.apk
,第二次打开时就会自动生效。能够经过这个方法app
File hotfixApk = Amigo.getHotfixApk(context);
获取到新的Apk。 同时,你也可使用Amigo 提供的工具类将你的补丁包拷贝到指定的目录当中。框架
FileUtils.copyFile(yourApkFile, amigoApkFile);
当即生效补丁包ide
若是想要补丁包当即生效,调用如下两个方法之一,App 会当即重启,而且打入补丁包。
Amigo.work(context);
Amigo.work(context, apkFile);
若是须要删除掉已经下好的补丁包,能够经过这个方法
Amigo.clear(context);
提示:若是apk 发生了变化,Amigo 会自动清除以前的apk。
在热修复的过程当中会有一些耗时的操做,这些操做会在一个新的进程中的Activity 中执行,因此你能够经过如下方式来自定义这个Activity。
<meta-data android:name="amigo_layout" android:value="{your-layout-name}" /> <meta-data android:name="amigo_theme" android:value="{your-theme-name}" />
Amigo 目前可以支持修复Activity 和BroadcastReceiver。只须要将新的Activity 和BroadcastReceiver 加到新的Apk 包中就能够了。Service 和ContentProvider 将会在将来的版本中支持更新。
集成 Amigo 十分简单,可是明白 Amigo 的实现更加剧要。
在Amigo
这个类中实现了主要的修复工做。咱们一块儿追追看,究竟是怎样的实现。
Amigo.java
... if (demoAPk.exists() && isSignatureRight(this, demoAPk)) { SharedPreferences sp = getSharedPreferences(SP_NAME, MODE_MULTI_PROCESS); String demoApkChecksum = checksum(demoAPk); boolean isFirstRun = !sp.getString(NEW_APK_SIG, "").equals(demoApkChecksum); ...
这段代码中,首先检查是否有补丁包,而且签名正确,若是正确,则经过检验校验和是否与以前的检验和相同,不一样则为检测到新的补丁包。
当这是新的补丁包时,首先第一件事就是释放。ApkReleaser.work(this, layoutId, themeId)
在这个方法中最终会去开启一个 ApkReleaseActivity,而这个 Activity 的layout 和 theme 就是以前从配置中解析出来,在 work 方法中传进来的layoutId 和 themeId。
ApkReleaseActivity.java
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); ... new Thread() { @Override public void run() { super.run(); DexReleaser.releaseDexes(demoAPk.getAbsolutePath(), dexDir.getAbsolutePath()); NativeLibraryHelperCompat.copyNativeBinaries(demoAPk, nativeLibraryDir); dexOptimization(); handler.sendEmptyMessage(WHAT_DEX_OPT_DONE); } }.start(); }
在 ApkReleaseActivity 的 onCreate()
方法中会开启一个线程去进行一系列的释放操做,这些操做十分耗时,目前在不一样的机子上测试,从几秒到二十几秒之间不等,若是就这样黑屏在用户前面未免太不优雅,因此 Amigo 开启了一个新的进程,启动这个 Activity。 在这个线程中,作了三件微小的事情:
拷贝 so 文件到 Amigo 的指定目录下 拷贝 so 文件是经过反射去调用 NativeLibraryHelper
这个类的nativeCopyNativeBinaries()
方法,但这个方法在不一样版本上有不一样的实现。
若是版本号在21如下
NativeLibraryHelper
public static int copyNativeBinariesIfNeededLI(File apkFile, File sharedLibraryDir) { final String cpuAbi = Build.CPU_ABI; final String cpuAbi2 = Build.CPU_ABI2; return nativeCopyNativeBinaries(apkFile.getPath(), sharedLibraryDir.getPath(), cpuAbi, cpuAbi2); }
会去反射调用这个方法,其中系统会自动判断出 primaryAbi 和 secondAbi。
若是版本号在21以上 copyNativeBinariesIfNeededLI(file, file)
这个方法已经被废弃了,须要去反射调用这个方法
**NativeLibraryHelper** ``` public static int copyNativeBinaries(Handle handle, File sharedLibraryDir, String abi) { for (long apkHandle : handle.apkHandles) { int res = nativeCopyNativeBinaries(apkHandle, sharedLibraryDir.getPath(), abi, handle.extractNativeLibs, HAS_NATIVE_BRIDGE); if (res != INSTALL_SUCCEEDED) { return res; } } return INSTALL_SUCCEEDED; } ``` 因此首先得去得到一个`NativeLibraryHelper$Handle`类的实例。以后就是找 primaryAbi。Amigo 先对机器的位数作了判断,若是是64位的机子,就只找64位的 abi,若是是32位的,就只找32位的 abi。而后将 Handle 实例当作参数去调用`NativeLibraryHelper`的`findSupportedAbi`来得到primaryAbi。最后再去调用`copyNativeBinaries`去拷贝 so 文件。
对于 so 文件加载的原理能够参考这篇文章
优化 dex 文件
ApkReleaseActivity.java
private void dexOptimization() { ... for (File dex : validDexes) { new DexClassLoader(dex.getAbsolutePath(), optimizedDir.getAbsolutePath(), null, DexUtils.getPathClassLoader()); Log.e(TAG, "dexOptimization finished-->" + dex); } }
DexClassLoader 没有作什么事情,只是调用了父类构造器,他的父类是 BaseDexClassLoader。在 BaseDexClassLoader 的构造器中又去构造了一个DexPathList 对象。 在DexPathList
类中,有一个 Element 数组
DexPathList
/** list of dex/resource (class path) elements */ private final Element[] dexElements;
Element 就是对 Dex 的封装。因此一个 Element 对应一个 Dex。这个 Element 在后文中会提到。
优化 dex 只须要在构造 DexClassLoader 对象的时候将 dex 的路径传进去,系统会在最后会经过DexFile
的
DexFile.java
native private static int openDexFile(String sourceName, String outputName, int flags) throws IOException;
来这个方法来加载 dex,加载的同时会对其作优化处理。
这三项操做完成以后,通知优化完毕,以后就关闭这个进程,将补丁包的校验和保存下来。这样第一步释放 Apk 就完成了。以后就是重头戏替换修复。
Amigo 先行构造一个AmigoClassLoader
对象,这个AmigoClassLoader
是一个继承于PathClassLoader
的类,把补丁包的 Apk 路径做为参数来构造AmigoClassLoader
对象,以后经过反射替换掉 LoadedApk 的 ClassLoader。这一步是 Amigo 的关键所在。
以前提到,每一个 dex 文件对应于一个PathClassLoader
,其中有一个 Element[],Element 是对于 dex 的封装。
Amigo.java
private void setDexElements(ClassLoader classLoader) throws NoSuchFieldException, IllegalAccessException { Object dexPathList = getPathList(classLoader); File[] listFiles = dexDir.listFiles(); List<File> validDexes = new ArrayList<>(); for (File listFile : listFiles) { if (listFile.getName().endsWith(".dex")) { validDexes.add(listFile); } } File[] dexes = validDexes.toArray(new File[validDexes.size()]); Object originDexElements = readField(dexPathList, "dexElements"); Class<?> localClass = originDexElements.getClass().getComponentType(); int length = dexes.length; Object dexElements = Array.newInstance(localClass, length); for (int k = 0; k < length; k++) { Array.set(dexElements, k, getElementWithDex(dexes[k], optimizedDir)); } writeField(dexPathList, "dexElements", dexElements); }
在替换dex时,Amigo 将补丁包中每一个 dex 对应的 Element 对象拿出来,以后组成新的 Element[],经过反射,将现有的 Element[] 数组替换掉。 在 QZone 的实现方案中,他们是经过将新的 dex 插到 Element[] 数组的第一个位置,这样就会先加载新的 dex ,微信的方案是下发一个 DiffDex,而后在运行时与旧的 dex 合成一个新的 dex。可是 Amigo 是下发一个完整的 dex直接替换掉了原来的 dex。与其余的方案相比,Amigo 由于直接替换原来的 dex ,兼容性更好,可以支持修复的方面也更多。可是这也致使了 Amigo 的补丁包会较大,固然,也能够发一个利用 BsDiff 生成的差分包,在本地合成新的 apk 以后再放到 Amigo 的指定目录下。
Amigo.java
private void setNativeLibraryDirectories(AmigoClassLoader hackClassLoader) throws InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException, NoSuchFieldException { injectSoAtFirst(hackClassLoader, nativeLibraryDir.getAbsolutePath()); nativeLibraryDir.setReadOnly(); File[] libs = nativeLibraryDir.listFiles(); if (libs != null && libs.length > 0) { for (File lib : libs) { lib.setReadOnly(); } } }
so 文件的替换跟 QZone 替换 dex 原理相差很少,也是利用 ClassLoader 加载 library 的时候,将新的 library 加到数组前面,保证先加载的是新的 library。可是这里会有几个小坑。
DexUtils.java
public static void injectSoAtFirst(ClassLoader hackClassLoader, String soPath) throws NoSuchFieldException, IllegalAccessException, NoSuchMethodException, InvocationTargetException, InstantiationException { Object[] baseDexElements = getNativeLibraryDirectories(hackClassLoader); Object newElement; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { Constructor constructor = baseDexElements[0].getClass().getConstructors()[0]; constructor.setAccessible(true); Class<?>[] parameterTypes = constructor.getParameterTypes(); Object[] args = new Object[parameterTypes.length]; for (int i = 0; i < parameterTypes.length; i++) { if (parameterTypes[i] == File.class) { args[i] = new File(soPath); } else if (parameterTypes[i] == boolean.class) { args[i] = true; } } newElement = constructor.newInstance(args); } else { newElement = new File(soPath); } Object newDexElements = Array.newInstance(baseDexElements[0].getClass(), 1); Array.set(newDexElements, 0, newElement); Object allDexElements = combineArray(newDexElements, baseDexElements); Object pathList = getPathList(hackClassLoader); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { writeField(pathList, "nativeLibraryPathElements", allDexElements); } else { writeField(pathList, "nativeLibraryDirectories", allDexElements); } }
注入 so 文件到数组时,会发如今不一样的版本上封装 so 文件的是不一样的类,在版本23如下,是File
DexPathList.java
/** list of native library directory elements */ private final File[] nativeLibraryDirectories;
在23以上倒是改为了Element
DexPathList.java
/** List of native library path elements. */ private final Element[] nativeLibraryPathElements;
所以在23以上,Amigo 经过反射去构造一个 Element 对象。以后就是将 so 文件插到数组的第一个位置就好了。 第二个小坑是nativeLibraryDir要设置成readOnly。
DexPathList.java
public String findNativeLibrary(String name) { maybeInit(); if (isDirectory) { String path = new File(dir, name).getPath(); if (IoUtils.canOpenReadOnly(path)) { return path; } } else if (zipFile != null) { String entryName = new File(dir, name).getPath(); if (isZipEntryExistsAndStored(zipFile, entryName)) { return zip.getPath() + zipSeparator + entryName; } } return null; }
在ClassLoader 去寻找本地库的时候,若是 so 文件没有设置成ReadOnly的话是会不会返回路径的,这样就会报错了。
Amigo.java
... AssetManager assetManager = AssetManager.class.newInstance(); Method addAssetPath = getDeclaredMethod(AssetManager.class, "addAssetPath", String.class); addAssetPath.setAccessible(true); addAssetPath.invoke(assetManager, demoAPk.getAbsolutePath()); setAPKResources(assetManager) ...
想要更新资源文件,只须要更新Resource
中的 AssetManager 字段。AssetManager
提供了一个方法addAssetPath
。将新的资源文件路径加到AssetManager
中就能够了。在不一样的 configuration 下,会对应不一样的 Resource 对象,因此经过 ResourceManager 拿到全部的 configuration 对应的 resource 而后替换其 assetManager。
Amigo.java
... Class acd = classLoader.loadClass("me.ele.amigo.acd"); String applicationName = (String) readStaticField(acd, "n"); Application application = (Application) classLoader.loadClass(applicationName).newInstance(); Method attach = getDeclaredMethod(Application.class, "attach", Context.class); attach.setAccessible(true); attach.invoke(application, getBaseContext()); setAPKApplication(application); application.onCreate(); ...
在编译过程当中,Amigo 的插件将 app 的 application 替换成了 Amigo,而且将原来的 application 的 name 保存在了一个名为acd
的类中,该修复的都修复完了是时候将原来的 application 替换回来了。拿到原有 Application 名字以后先调用 application 的attach(context)
,而后将 application 设回到 loadedApk 中,最后调用oncreate()
,执行原有 Application 中的逻辑。 这以后,一个修复完的 app 就出如今用户面前。优秀的库~
前文提到 Amigo 在编译期利用插件替换了 app 原有的 application,那这一个操做是怎么实现的呢?
AmigoPlugin.groovy
File manifestFile = output.processManifest.manifestOutputFile def manifest = new XmlParser().parse(manifestFile) def androidTag = new Namespace("http://schemas.android.com/apk/res/android", 'android') applicationName = manifest.application[0].attribute(androidTag.name) manifestFile.text = manifestFile.text.replace(applicationName, "me.ele.amigo.Amigo")
首先,Amigo Plugin 将 AndroidManifest.xml 文件中的applicationName 替换成 Amigo。
AmigoPlugin.groovy
Node node = (new XmlParser()).parse(manifestFile) Node appNode = null for (Node n : node.children()) { if (n.name().equals("application")) { appNode = n; break } } Node hackAppNode = new Node(appNode, "activity") hackAppNode.attributes().put("android:name", applicationName) manifestFile.text = XmlUtil.serialize(node)
以后,Amigo Plugin 作了很 hack 的一步,就是在 AndroidManifest.xml 中将原来的 application 作为一个 Activity 。咱们知道 MultiDex 分包的规则中,必定会将 Activity 放到主 dex 中,Amigo Plugin 为了保证原来的 application 被替换后仍然在主 dex 中,就作了这个十分 hack 的一步。机智的少年。
接下来会再去判断是否开启了混淆,若是有混淆的话,查找 mapping 文件,将 applicationName 字段换成混淆后的名字。
下一步会去执行 GenerateCodeTask,在这个 task 中会生成一个 Java 文件,这个文件就是上文提到过得acd.java
,而且将模板中的 appName 替换成applicationName。 而后执行 javaCompile task,编译 Java 代码。 最后还要作一件事,就是修改 maindexlist.txt。被定义在这个文件中的类会被加到主 dex 中,因此 Amigo plugin 在collectMultiDexInfo
方法中扫描加到主 dex 的类,而后再在扫描的结果中加上 acd.class,把这些内容所有加到 maindexlist.txt。到此Amigo plugin 的任务就完成了。 Amigo plugin 的主要目的是在编译期用 amigo 替换掉原来的 application,可是还得保存下来这个 application,由于以后还得在运行时将这个 application 替换回来。
Amigo 几乎实现了全方位的修复,经过替换 ClassLoader,直接全量替换 dex 的思路,保证了兼容性,成功率,可是可能下发的补丁包会比较大。还有一点 Amigo 的精彩之处就是利用 Amigo 替换了 app 原有的 application,这一点保证了 Amigo 连 application 都能修复。之后可能惟一不能修复的就是 Amigo 自身了。
最后咱们比较下目前几个 hotfix 方案:
Amigo | Tinker | nuwa/QZone | AndFix | Dexposed |
---|---|---|---|---|
类替换 | yes | yes | yes | no |
lib替换 | yes | yes | no | no |
资源替换 | yes | yes | yes | no |
全平台支持 | yes | yes | yes | yes |
即时生效 | optional | no | no | yes |
性能损耗 | 无 | 较小 | 较大 | 较小 |
补丁包大小 | 较大 | 较小 | 较大 | 通常 |
开发透明 | yes | yes | yes | no |
复杂度 | 无 | 较低 | 较低 | 复杂 |
gradle支持 | yes | yes | yes | no |
接口文档 | 丰富 | 丰富 | 通常 | 通常 |
占Rom体积 | 较大 | 较大 | 较小 | 较小 |
成功率 | 100% | 较好 | 很高 | 通常 |