Android插件化探索(二)资源加载

前情提要

在探索资源加载方式之前,我们先来看看上一篇中没细讲的东西。还没看过的建议先看上一篇Android插件化探索(一)类加载器DexClassLoader

PathClassLoader和DexClassLoader的区别

DexClassLoader的源码如下:

1
2
3
4
5
6
7
public class DexClassLoader extends BaseDexClassLoader {
//支持从任何地方的apk/jar/dex中读取
public DexClassLoader(String dexPath, String optimizedDirectory,
String libraryPath, ClassLoader parent)
{

super(dexPath, new File(optimizedDirectory), libraryPath, parent);
}
}

PathClassLoader的源码如下,没有指定optimizedDirectory所以只能加载已安装的APK,因为已安装的APK会将dex解压到了/data/dalvik-cache/目录下,PathClassLoader会到这里去找。

1
2
3
4
5
6
7
8
9
public class PathClassLoader extends BaseDexClassLoader {
public PathClassLoader(String dexPath, ClassLoader parent) {
super(dexPath, null, null, parent);
}
public PathClassLoader(String dexPath, String libraryPath,
ClassLoader parent)
{

super(dexPath, null, libraryPath, parent);
}
}

但是本人本着不作不死的性格,修改Plugin类如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private void useDexClassLoader(String path){

//创建类加载器,把dex加载到虚拟机中
PathClassLoader calssLoader = new PathClassLoader(path, null,
this.getClass().getClassLoader());

//利用反射调用插件包内的类的方法
try {
Class<?> clazz = calssLoader.loadClass("com.maplejaw.hotplugin.PluginClass");
Comm obj = (Comm)clazz.newInstance();
Integer ret = obj.function( 12,21);
Log.d("JG", "返回的调用结果: " + ret);

} catch (Exception e) {
e.printStackTrace();
}
}

在4.4和5.0上分别做了试验从SD卡上加载dex,发现提示略有差别。
Android 4.4:直接提示找不到该类

1
java.lang.ClassNotFoundException: Didn't find class "com.maplejaw.hotplugin.PluginClass" on path: DexPathList[[zip file "/storage/sdcard/2.apk"],nativeLibraryDirectories=[/vendor/lib, /system/lib]]

Android 5.0:可以发现在加载该类之前,系统尝试将dex写到/data/dalvik-cache/下,由于权限问题而失败。

1
2
3
05-25 04:07:02.291 31699-31699/com.maplejaw.hotfix E/dalvikvm: Dex cache directory isn't writable: /data/dalvik-cache
05-25 04:07:02.291 31699-31699/com.maplejaw.hotfix I/dalvikvm: Unable to open or create cache for /storage/sdcard/2.apk (/data/dalvik-cache/storage@sdcard@2.apk@classes.dex)
05-25 04:07:02.291 31699-31699/com.maplejaw.hotfix W/System.err: java.lang.ClassNotFoundException: Didn't find class "com.maplejaw.hotplugin.PluginClass" on path: DexPathList[[zip file "/storage/sdcard/2.apk"],nativeLibraryDirectories=[/vendor/lib, /system/lib]]

但是!!!笔者此时默默的掏出了大红米(Android 5.0)测试了一番。居然发现可以调用正常,也就是说,MIUI成功将dex写到了/data/dalvik-cache/下,所以如果你手持MIUI发现PathClassLoader可以加载外部dex时,务必冷静,用模拟器试试。

双亲委托

什么叫双亲委托?
为了更好的保证 JAVA 平台的安全。在此模型下,当一个装载器被请求加载某个类时,先委托自己的 parent 去装载,如果 parent 能装载,则返回这个类对应的 Class 对象,否则,递归委托给父类的父类装载。当所有父类装载器都装载失败时,才由当前装载器装载。在此模型下,用户自定义的类装载器,不可能装载应该由父亲装载的可靠类,从而防止不可靠甚至恶意的代码代替本应该由父亲装载器装载的可靠代码。

在JVM中预定义了的三种类型类加载器:

  • 启动(Bootstrap)类加载器:是用本地代码实现的类装入器,它负责将 /lib下面的类库加载到内存中(比如rt.jar)。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。
  • 标准扩展(Extension)类加载器:是由 Sun 的 ExtClassLoader(sun.misc.Launcher\$ExtClassLoader)实现的。它负责将< Java_Runtime_Home >/lib/ext或者由系统变量 java.ext.dir指定位置中的类库加载到内存中。开发者可以直接使用标准扩展类加载器。
  • 系统(System)类加载器:是由 Sun 的 AppClassLoader(sun.misc.Launcher\$AppClassLoader)实现的。它负责将系统类路径(CLASSPATH)中指定的类库加载到内存中。开发者可以直接使用系统类加载器。

委派机制:自定义的ClassLoader->AppClassLoader->ExtClassLoader->BootstrapClassLoader

那么Android中的委派机制是怎么样的呢?

  • BootClassLoader: 加载系统类库
  • PathClassLoader: 加载已安装apk中的dex中的类
  • DexClassLoader: 加载外部和内部apk中的类

委派机制:DexClassLoader->PathClassLoader->BootClassLoader。

我们可以打印出其委派机制:

1
2
3
4
5
6
7
8
9
10
11
ClassLoader classLoader=new DexClassLoader(apkPath,getApplicationInfo().dataDir,libPath,getClassLoader());
try {
Class<?> clazz = calssLoader.loadClass("com.maplejaw.hotplugin.PluginClass");
Comm obj = (Comm)clazz.newInstance();
Integer ret = obj.function( 12,21);

while(classLoader != null){
Log.d("JG", "类加载器:"+classLoader);
classLoader = classLoader.getParent();
}
Log.d("JG", "返回的调用结果: " + ret);

结果如下:

1
2
3
4
5
6
7
8
9
05-25 09:09:11.330 9100-9100/com.maplejaw.hotfix D/JG: 初始化PluginClass

05-25 09:09:11.330 9100-9100/com.maplejaw.hotfix D/JG: 类加载器:dalvik.system.DexClassLoader[DexPathList[[zip file "/data/app/com.maplejaw.hotplugin-2/base.apk"],nativeLibraryDirectories=[/data/app/com.maplejaw.hotplugin-2/lib/x86_64, /vendor/lib64, /system/lib64]]]

05-25 09:09:11.330 9100-9100/com.maplejaw.hotfix D/JG: 类加载器:dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/com.maplejaw.hotfix-2/base.apk"],nativeLibraryDirectories=[/data/app/com.maplejaw.hotfix-2/lib/x86_64, /vendor/lib64, /system/lib64]]]

05-25 09:09:11.330 9100-9100/com.maplejaw.hotfix D/JG: 类加载器:java.lang.BootClassLoader@7cd98df

05-25 09:09:11.330 9100-9100/com.maplejaw.hotfix D/JG: 返回的调用结果: 33

看到这里,应该可以明白上一篇中提到的java.lang.IllegalAccessError: Class ref in pre-verified class resolved to unexpected implementation中因为同一加载器加载不同dex中相同的类引发的错误了吧。

资源加载

在上一篇中,提到了通过资源名字,然后获取id,最后获取资源的思路。先来回顾一下。

getResourcesForApplication

1
2
3
4
5
6
//首先,通过包名获取该包名的Resources对象
Resources res= pm.getResourcesForApplication(packageName);
//根据约定好的名字,去取资源id;
int id=res.getIdentifier("a","drawable",packageName);//根据名字取id
//根据资源id,取出资源
Drawable drawable=res.getDrawable(id)

这种方式有个特点,就是得清楚每一个资源的名字。但也从侧面提现了,这种方式不够灵活。那么,有没有一种方法,可以简化这一过程呢?

根据这种方式的特点,尝试着写了几个测试例子。

不就是要资源吗,直接在插件中把资源提供给宿主不就行了。修改PluginClass如下:

1
2
3
public Drawable getImage(Context context){
return context.getResources().getDrawable(R.drawable.a);
}

宿主核心代码修改如下。

1
2
3
4
Class<?> clazz = classLoader.loadClass(packageName+".PluginClass");
Comm obj = (Comm) clazz.newInstance();
Drawable drawable=obj.getImage(this)
mImageView.setImageDrawable(drawable);

运行测试,没有报错,反而读到了宿主APK下的一个图片,应该是资源id和宿主中的id重复了。于是多拷了几个图片到插件drawable目录,仍然什么都读不到。于是仔细回头看了看代码。发现了这一句context.getResources(),可以看出拿到的是宿主APK的Resources对象,怀着好奇心,顺藤摸瓜找到了源码。在ContextImpl的源码中,发现如下:
可以从字面意思看出一个包一个Resources。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

private ContextImpl(ContextImpl container, ActivityThread mainThread,
LoadedApk packageInfo, ...)
{

//...
//省略了部分源码
Resources resources = packageInfo.getResources(mainThread);
mResources = resources;
//...
//省略了部分源码
}


public Resources getResources() {
return mResources;
}

既然一个包一个Resources,那我们换种思路,就用插件包的Resources去加载资源。修改PluginClass如下:

1
2
3
 public Drawable getImage(Resources res){
return res.getDrawable(R.drawable.a);
}

然后修改宿主的核心代码,测试通过。

1
2
3
4
Class<?> clazz = classLoader.loadClass(packageName+".PluginClass");
Comm obj = (Comm) clazz.newInstance();
Resources res=pm.getResourcesForApplication(packageName);
Drawable drawable=obj.getDrawable(res)

可以看出,这种方法比第一种方法要灵活不少,不需要知道每张图片的名字,只需插件实现相关接口即可。

AssetManager

但是,有没有别的方式了?答案当然是肯定的,Android里有一个类叫AssetManager!在介绍AssetManager之前我们来看看getResource的源码。我们知道,调用getResource,会调用LoadedApk的getResources。

1
2
3
4
5
6
7
8
9
private ContextImpl(ContextImpl container, ActivityThread mainThread,
LoadedApk packageInfo, ...)
{

//...
//省略了部分源码
Resources resources = packageInfo.getResources(mainThread);
mResources = resources;
//...
//省略了部分源码
}

那么就来看看LoadedApk的getResources源码。

1
2
3
4
5
6
7
public Resources getResources(ActivityThread mainThread) {
if (mResources == null) {
mResources = mainThread.getTopLevelResources(mResDir, mSplitResDirs, mOverlayDirs,
mApplicationInfo.sharedLibraryFiles, Display.DEFAULT_DISPLAY, null, this);
}
return mResources;
}

可以看出,LoadedApk会调用ActivityThread去加载。最终在ResourcesManager中找到了真身。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
Resources getTopLevelResources(String resDir, String[] splitResDirs,
String[] overlayDirs, String[] libDirs, int displayId,
Configuration overrideConfiguration, CompatibilityInfo compatInfo)
{


Resources r;

//...
//省略了部分源码
AssetManager assets = new AssetManager();

if (resDir != null) {
if (assets.addAssetPath(resDir) == 0) {
return null;
}
}

if (splitResDirs != null) {
for (String splitResDir : splitResDirs) {
if (assets.addAssetPath(splitResDir) == 0) {
return null;
}
}
}

if (overlayDirs != null) {
for (String idmapPath : overlayDirs) {
assets.addOverlayPath(idmapPath);
}
}

if (libDirs != null) {
for (String libDir : libDirs) {
if (libDir.endsWith(".apk")) {

if (assets.addAssetPath(libDir) == 0) {
Log.w(TAG, "Asset path '" + libDir +
"' does not exist or contains no resources.");
}
}
}
}

//...
//省略了部分源码
r = new Resources(assets, dm, config, compatInfo);

return r;
}
}

代码有点长,但是很显然,最终的资源加载交给了AssetManager,assets.addAssetPath(libDir)添加资源目录,然后new了一个Resources对象返回。

那我们现在通过反射来模仿系统的写法。

1
2
3
4
5
6
7
8
9
10
11
protected void loadResources(String dexPath) {
try {
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager, dexPath);
Resources superRes = super.getResources();
mResources = new Resources(assetManager, superRes.getDisplayMetrics(),superRes.getConfiguration());
} catch (Exception e) {
e.printStackTrace();
}
}

然后重写getResources方法:

1
2
3
4
5

@Override
public Resources getResources() {
return mResources == null ? super.getResources() : mResources;
}

核心代码修改如下,

1
2
3
4
5
6
7
//先加载插件资源
loadResources(apkPath);

//核心代码调用
Class<?> clazz = classLoader.loadClass(packageName+".PluginClass");
Comm obj = (Comm) clazz.newInstance();
mImageView.setImageDrawable(obj.getDrawable(getgetResources));

成功将资源加载到宿主APK,测试通过。
当然,为了使通用性更强,不因主题的差异而导致效果不一样,上面的代码一般会这么写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
  //反射加载资源
private AssetManager mAssetManager;
private Resources mResources;
private Resources.Theme mTheme;
protected void loadResources(String dexPath) {
try {
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager, dexPath);
mAssetManager = assetManager;
} catch (Exception e) {
e.printStackTrace();
}
Resources superRes = super.getResources();
mResources = new Resources(mAssetManager, superRes.getDisplayMetrics(),superRes.getConfiguration());
mTheme = mResources.newTheme();
mTheme.setTo(super.getTheme());
}


//重写方法
@Override
public AssetManager getAssets() {
return mAssetManager == null ? super.getAssets() : mAssetManager;
}
@Override
public Resources getResources() {
return mResources == null ? super.getResources() : mResources;
}
@Override
public Resources.Theme getTheme() {
return mTheme == null ? super.getTheme() : mTheme;
}

换皮肤原理

换皮肤一般有两种方式。

约定好资源名字

这种方式非常简单,基本不需要修改什么代码。只需两部就来完成。
假如我们有一个图片菜单,在宿主和插件中都叫a.png。

设置菜单图片的代码如下。

1
mImageMenu.setImageDrawable(getResources().getDrawable(R.drawable.a));

  • 重写getResources

    1
    2
    3
    4
    @Override
    public Resources getResources() {
    return mResources == null ? super.getResources() : mResources;
    }
  • 获取Resources对象

    1
    loadResources或getResourcesForApplication

这样在需要加载皮肤的地方loadResources,然后重写加载就能实现换肤功能。

不约定资源名字

这种方式主要通过接口的方式进行调用。让不同的皮肤插件进行调用。

  • 实现插件接口

    1
    2
    3
    public Drawable getImageMenu(Resources res){
    return res.getDrawable(R.drawable.a);
    }
  • 重写getResources

    1
    2
    3
    4
    @Override
    public Resources getResources() {
    return mResources == null ? super.getResources() : mResources;
    }
  • 获取Resources对象

    1
    loadResources或getResourcesForApplication
  • 设置菜单图片

    1
    2
    3
    4
    Class<?> clazz = classLoader.loadClass(packageName+".PluginClass");
    Comm obj = (Comm) clazz.newInstance();

    mImageMenu.setImageDrawable(obj.getImageMenu(getResources()));

    或者如下写法,通过Context来获取资源

    1
    2
    3
    4
    5
    6
    7
     //PluginClass接口类
    public Drawable getImage(Context context){
    return context.getResources().getDrawable(R.drawable.a);
    }

    //宿主类。加载图片。
    mImageMenu.setImageDrawable(obj.getImage(this));

从上面可以看出,约定资源名字这种方法,可以少写好多接口。

最后

由于本人水平有限,如有错误敬请指出。