背景
前一段时间,做了一个需求,需要动态加载一个so,还有一个classes.dex,还有一些资源。看上去是一个还行的需求,原理就是通过 classloader 进行动态加载,知易行难,真正做起来,还是遇到了下面的这些坑。
问题
0x01类冲突
什么是类冲突呢?就是说我们的代码中可能有两个一模一样的类,包名,类名都一模一样。有人可能会问,怎么会有这种情况呢?因为模块走的动态加载,没有走统一编译,这种问题就会变得无法避免。难免有人脑子想到一起,就产生了重复的类了。
众所周知,java是通过classloader进行类加载的,类加载机制就是著名的双亲委派,不太了解的同学,我简单描述一下就是:如果有一家三代,就先去爷爷那里找有没有这个类,如果没有就去爸爸那里找,爸爸找不到就从儿子这里找,儿子找不到就 ClassNotFoundException 了。 所以,当我们进行动态加载的时候,一般都是使用 DexClassLoader (关于如何动态加载,这里不多说,网上文章很多),这个DexClassLoader会把参数里面的路径下的dex文件加载起来,那么你的类就可以通过这个 classloader 进行加载了。
这个时候,问题就来了,如果有重名的类,已经加载过了,那么,你肯定就加载不到你自己的类了,这样加载到的类就不是你想要的那个类,错误就产生了。如何避免呢?先看如下代码:
public class CustomClassLoader extends DexClassLoader {
private ClassLoader mParentClassLoader;
public CustomClassLoader(String dexPath, String optimizedDirectory, String libraryPath, ClassLoader parent) {
super(dexPath, optimizedDirectory, libraryPath, new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
return null;
}
});
mParentClassLoader = parent;
}
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
Class<?> clazz = null;
try {
clazz = super.loadClass(name);
} catch (Exception ex) {
// ignore
}
if (clazz != null) {
return clazz;
}
if (mParentClassLoader == null) {
return null;
}
return mParentClassLoader.loadClass(name);
}
//....
}
可以看到,我们自定义了一个CustomClassLoader继承自DexClassLoader,有两个重点:
- super() 调用的第三个参数传了一个重写了 loadClass 的方法,里面直接返回null,这个参数是父classloader的一起,这里就是把爸爸设置成一个什么都没有的 classloader。如果不设置,在安卓6.0以下都会报一个错误,父classloader不能为null的错误。
- loadClass() 方法,先调用super.loadClass() 方法,出异常再调用传递进来的真正的爸爸classloader加载。
通过这样一个逻辑,就能保证先加载自己的类,再去加载爷爷和爸爸那里的类了。这样即使内存里面已经有了这个类,通过这个加载逻辑也能加载成功自己的类了。不过这样就违背了java的双亲委派机制,不过这也是没有办法的事情,java自己也违背过,哈哈哈。
0x02 资源加载不起来
我们的classes.dex 和资源文件不是同一个apk,也就是说他们不是一起进行打包的,这就带来了另外一个问题,两边分开进行打包,资源id对不上。要解决这个问题,就要把我们的资源apk路径加载到系统寻找资源的路径上面来,关键方法如下:
public static boolean addResource(Context context, String apkDir) {
if (TextUtils.isEmpty(apkDir)) {
return false;
}
try {
Method m = getAddAssetPathMethod();
Log.e("getAddAssetPathMethod m = " + m);
if (m != null) {
int ret = (int) m.invoke(context.getAssets(), apkDir);
Log.e("invoke ret = " + ret);
return ret > 0;
}
} catch (Exception e) {
Log.d("invoke method error ! ", e.toString());
}
return false;
}
private static Method getAddAssetPathMethod() {
Method m = null;
Class c = AssetManager.class;
if (Build.VERSION.SDK_INT >= 24) {
try {
m = c.getDeclaredMethod("addAssetPathAsSharedLibrary", String.class);
m.setAccessible(true);
} catch (NoSuchMethodException e) {
e.printStackTrace();
}
return m;
}
try {
m = c.getDeclaredMethod("addAssetPath", String.class);
m.setAccessible(true);
} catch (NoSuchMethodException e) {
e.printStackTrace();
}
return m;
}
然后自己构造一个 ContextThemeWrapper类,进行资源的查找。大致实现如下:
public class ResourcesContext extends ContextThemeWrapper {
private final ClassLoader mNewClassLoader;
Resources mNewResources;
public ResourcesContext(Context base, int themeres, ClassLoader cl, Resources r) {
super(base, themeres);
mNewResources = r;
mNewClassLoader = cl;
}
@Override
public Resources getResources() {
if (mNewResources != null) {
return mNewResources;
}
return super.getResources();
}
}
通过传递进来的 mNewResources 进行资源的查找。最终使用这个类进行资源的查找,通过context去查找资源的方法如下:
resourceContext.getString(R.xxx);
必须通过这个resourceContext进行资源的查找。
这样我们就解决了资源查找的问题,还有一个问题,就是资源id错乱对不上的问题。这个解决比较简单,就是把所有的id在初始化的时候统一进行一次重新赋值,让dex中的id都被赋值为资源apk中的id值。
0x03 资源错乱
在demo中运行良好,兴高采烈去客户端进行集成。一集成完毕,就发现app莫名奇妙的崩溃,很多资源找不到, 而且基本是什么资源都会崩溃。找了很久问题的根源,发现是资源id冲突。看来只能在我们自己编译资源apk的时候,进行资源id的修改了。那么aapt这个工具就闪亮登场了。在build.gradle中的android节点加入:
aaptOptions {
additionalParameters "--package-id", "0x66","--allow-reserved-package-id"
}
buildToolsVersion '28.0.3'
0x66 是自己定义的id,这样我们生成的资源就都是0x66开头的了,而系统默认都是 0x7f开头。注意此工具必须在高版本的gradle中才能使用。
总结
动态加载过程中,资源问题是最令人头痛的一个地方,好在也会有各种各样的办法去修复他。欢迎大家一起交流。