-
Notifications
You must be signed in to change notification settings - Fork 213
替换修复
###替换classLoader
Amigo 先行构造一个AmigoClassLoader
对象,这个AmigoClassLoader
是一个继承于PathClassLoader
的类,把补丁包的 Apk 路径作为参数来构造AmigoClassLoader
对象,之后通过反射替换掉 LoadedApk 的 ClassLoader。这一步是 Amigo 的关键所在。
###替换Dex
之前提到,每个 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。
###替换原有 Application
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 替换回来。