IBigerBiger的成长之路

插件化实现(二)插件资源获取

前面一篇文章有说到插件化的准备工作,也就是插件管理器的相关实现,那么这一篇就真正开启插件化相关的学习了,对于插件化资源的获取其实可以满足一些特殊的要求,比如换肤相关,通过获取不同插件Apk中的资源文件,宿主Apk则可以使用这些资源,从而达到换肤的需求。

在文章开始前首先介绍下类加载器相关的知识,因为这是整个插件化的根基。

一.类加载器

1.简介

类加载器用来加载Java类到Java虚拟机中。一般来说,Java 虚拟机使用Java类的方式如下:Java 源程序在经过Java编译器编译之后就被转换成 Java 字节代码。类加载器负责读取Java字节代码,并转换成 java.lang.Class类的一个实例。

Android的Dalvik/ART虚拟机如同标准JAVA的JVM虚拟机一样,在运行程序时首先需要将对应的类加载到内存中。因此,我们可以利用这一点,在程序运行时手动加载Class,从而达到代码动态加载可执行文件的目的。Android的Dalvik/ART虚拟机虽然与标准Java的JVM虚拟机不一样,ClassLoader具体的加载细节不一样,但是工作机制是类似的,也就是说在Android中同样可以采用类似的动态加载插件的功能。

在Android中,ClassLoader是一个抽象类,实际开发过程中,我们一般是使用其具体的子类DexClassLoader、PathClassLoader这些类加载器来加载类的,它们的不同之处是:

  • DexClassLoader可以加载jar/apk/dex,可以从SD卡中加载未安装的apk;
  • PathClassLoader只能加载系统中已经安装过的apk;

那么我们简单看下在Anroid上DexClassLoader的加载流程

2.加载流程

(1).加载Dex文件

我们来看DexClassLoader的源码(使用的是4.4版本查看)

1
2
3
4
5
6
public class DexClassLoader extends BaseDexClassLoader {
public DexClassLoader(String dexPath, String optimizedDirectory,
String libraryPath, ClassLoader parent) {
super(dexPath, new File(optimizedDirectory), libraryPath, parent);
}
}

它的实现其实是它的父类BaseDexClassLoader

1
2
3
4
5
6
public BaseDexClassLoader(String dexPath, File optimizedDirectory,
String libraryPath, ClassLoader parent) {
super(parent);
this.originalPath = dexPath;
this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
}

这里创建了一个DexPathList实例,我们看一下创建过程

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
50
51
52
53
54
55
public DexPathList(ClassLoader definingContext, String dexPath,
String libraryPath, File optimizedDirectory) {
……
this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory);
}
private static Element[] makeDexElements(ArrayList<File> files,
File optimizedDirectory) {
ArrayList<Element> elements = new ArrayList<Element>();
for (File file : files) {
ZipFile zip = null;
DexFile dex = null;
String name = file.getName();
if (name.endsWith(DEX_SUFFIX)) {
dex = loadDexFile(file, optimizedDirectory);
} else if (name.endsWith(APK_SUFFIX) || name.endsWith(JAR_SUFFIX)
|| name.endsWith(ZIP_SUFFIX)) {
zip = new ZipFile(file);
}
……
if ((zip != null) || (dex != null)) {
elements.add(new Element(file, zip, dex));
}
}
return elements.toArray(new Element[elements.size()]);
}
private static DexFile loadDexFile(File file, File optimizedDirectory)
throws IOException {
if (optimizedDirectory == null) {
return new DexFile(file);
} else {
String optimizedPath = optimizedPathFor(file, optimizedDirectory);
return DexFile.loadDex(file.getPath(), optimizedPath, 0);
}
}
/**
* Converts a dex/jar file path and an output directory to an
* output file path for an associated optimized dex file.
*/
private static String optimizedPathFor(File path,
File optimizedDirectory) {
String fileName = path.getName();
if (!fileName.endsWith(DEX_SUFFIX)) {
int lastDot = fileName.lastIndexOf(".");
if (lastDot < 0) {
fileName += DEX_SUFFIX;
} else {
StringBuilder sb = new StringBuilder(lastDot + 4);
sb.append(fileName, 0, lastDot);
sb.append(DEX_SUFFIX);
fileName = sb.toString();
}
}
File result = new File(optimizedDirectory, fileName);
return result.getPath();
}

我们可以从optimizedPathFor方法看到,optimizedDirectory是用来缓存我们需要加载的dex文件的,并创建一个DexFile对象,如果它为null,那么会直接使用dex文件原有的路径来创建DexFile对象。(optimizedDirectory必须是一个内部存储路径),最后把这个DexFile对象存储在Element对象里面。

(2).加载类

上面流程其中创建了一个DexFile实例,用来保存dex文件,我们猜想这个实例就是用来加载类的

Android中,ClassLoader用loadClass方法来加载我们需要的类,我们看一下ClassLoader的loadClass方法

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
public Class<?> loadClass(String className) throws ClassNotFoundException {
return loadClass(className, false);
}
protected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException {
Class<?> clazz = findLoadedClass(className);
if (clazz == null) {
ClassNotFoundException suppressed = null;
try {
clazz = parent.loadClass(className, false);
} catch (ClassNotFoundException e) {
suppressed = e;
}
if (clazz == null) {
try {
clazz = findClass(className);
} catch (ClassNotFoundException e) {
e.addSuppressed(suppressed);
throw e;
}
}
}
return clazz;
}

loadClass方法调用了findClass方法,而BaseDexClassLoader重载了这个方法

1
2
3
4
5
6
7
8
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
Class clazz = pathList.findClass(name);
if (clazz == null) {
throw new ClassNotFoundException(name);
}
return clazz;
}

可以看到这里调用了DexPathList的findClass方法,如下

1
2
3
4
5
6
7
8
9
10
11
12
public Class findClass(String name) {
for (Element element : dexElements) {
DexFile dex = element.dexFile;
if (dex != null) {
Class clazz = dex.loadClassBinaryName(name, definingContext);
if (clazz != null) {
return clazz;
}
}
}
return null;
}

这里遍历所有加载过的dex文件,再调用loadClassBinaryName方法一个个尝试能不能加载想要的类

至此,ClassLoader的创建和加载类的过程的完成了。

二.插件资源获取

1.普通资源的获取

首先我们看一下平时我们是如何在代码中去获取资源文件的?

1
2
3
textView.setText(getResources().getString(R.string.xxx));
imageView.setImageResource(getResources().getDrawable(R.drawable.xxx));
...

当然除了这些还有别的,就不一一列举出来了,那么这里主要都是通过Resources来获取对应的文件id,而这个id则是存在R.java文件中,这个文件是系统帮我们创建的,是对应资源文件的静态int对象,所以直接通过这个id也是可以获取到对象的

如下代码其实与上面的代码效果是一致的

1
2
3
textView.setText(getResources().getString(0x7f040029));
imageView.setImageResource(getResources().getDrawable(0x7f040030));
...

所以插件中的资源我们通过文件id来获取,而文件id则是通过R文件来获取对应文件名的id,而R文件我们就可以通过ClassLoader的loadClass方法来获取R文件

我们以获取Drawable为例:

我们看一下R中Drawable所在的位置,如下


图1 Drawable所在位置

可以看到Drawable其实是R文件里面的一个内部类,所以想要获取图片对应的Id,首先需要获取这个内部类,通过这个内部类,获取其中的图片变量就可以获取到Id了

下面我们看一下代码实现获取Id

1
2
3
4
5
6
File optimizedDirectoryFile = context.getDir("dex", Context.MODE_PRIVATE);
DexClassLoader dexClassLoader = new DexClassLoader(plugin.getPath(), optimizedDirectoryFile.getPath(), null, ClassLoader.getSystemClassLoader());
Class<?> clazz = dexClassLoader.loadClass(packageName + ".R$drawable";
Field field = clazz.getDeclaredField(resId);
return field.getInt(int.class);

这里的plugin是插件Apk,packageName是获取对应插件的包名,resId则是获取资源的名称

这里首先是用插件Apk生成一个ClassLoader,通过上面分析可以知道,这时候插件Apk的dex文件就获取到了,接下来通过ClassLoader的loadClass方法获取R文件中drawable的内部类,最后通过这个内部类对象获取到对应图片的Id了。

那么获取到这个图片的Id后是否就直接扔到Resources方法里面就可以使用了呢?

答案是否定,为什么?我们看一下Resources这个类的构造方法

1
2
3
public Resources(AssetManager assets, DisplayMetrics metrics, Configuration config) {
this(assets, metrics, config, CompatibilityInfo.DEFAULT_COMPATIBILITY_INFO);
}

这里面有一个参数AssetManager,即管理资产类,这个类为访问当前应用程序的资产文件提供了入口,所以如果我们按以往的方式使用Resources的话,其实是没有我们插件中资源文件的入口的,所以前面我们获取了正确的资源文件Id,也是获取不到资源文件的。

AeesetManager类里面有个方法是addAssetPath,通过这个方法可以将一个路径添加为AeesetManager的资源文件入口,但是这个addAssetPath方法,我们调用不了的,所以通过反射的方式去调用,将插件Apk的路径添加进去,并生成一个新的Resources,如下

1
2
3
4
5
6
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager, plugin.getPath());
Resources superRes = context.getResources();
Resources mResources = new Resources(assetManager, superRes.getDisplayMetrics(),
superRes.getConfiguration());

通过我们获取的新的Resources与上面获取到的资源文件Id就可以正确的获取到插件中的资源文件了,

当然除了上述的图片资源以外呢?其实还有很多可以获取的资源,具体我们看R文件的内部类,如下


图2 R文件内部类

通过这些内部类,我们可以获取到更多的资源文件

我做了简单的封装如下

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
50
51
52
53
54
55
56
57
private static int getPluginResId(RESOURCES_TYPE type,Context context,File file, String packageName,String resId){
try {
File optimizedDirectoryFile = context.getDir("dex", Context.MODE_PRIVATE);
DexClassLoader dexClassLoader = new DexClassLoader(file.getPath(), optimizedDirectoryFile.getPath(), null, ClassLoader.getSystemClassLoader());
Class<?> clazz = dexClassLoader.loadClass(packageName + getResString(type));
Field field = clazz.getDeclaredField(resId);
return field.getInt(int.class);
} catch (Exception e) {
e.printStackTrace();
}
return 0;
}
private static Resources getPluginResources(Context context,File file) throws Exception {
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager, file.getPath());
Resources superRes = context.getResources();
Resources mResources = new Resources(assetManager, superRes.getDisplayMetrics(),
superRes.getConfiguration());
return mResources;
}
private static String getResString(RESOURCES_TYPE type){
switch (type){
case TYPE_ANIM:
return ".R$anim";
case TYPE_ATTR:
return ".R$attr";
case TYPE_BOOL:
return ".R$bool";
case TYPE_COLOR:
return ".R$color";
case TYPE_DIMEN:
return ".R$dimen";
case TYPE_DRAWABLE:
return ".R$drawable";
case TYPE_INTEGER:
return ".R$integer";
case TYPE_LAYOUT:
return ".R$layot";
case TYPE_MENU:
return ".R$menu";
case TYPE_MIPMAP:
return ".R$mipmap";
case TYPE_STRING:
return ".R$string";
case TYPE_STYLE:
return ".R$style";
case TYPE_STYLEABLE:
return ".R$styleable";
default:
return "";
}
}

以一个图片的调用如下,其他资源相关调用,类比就好了

1
2
3
4
5
6
public static Drawable getMipMap(Context context, String packageName,String mipmapId) throws Exception {
File targetFile = PluginInstallManager.getTargetPluginFile(packageName);
int resId = getPluginResId(RESOURCES_TYPE.TYPE_MIPMAP, context, targetFile, packageName, mipmapId);
Resources mResources = getPluginResources(context,targetFile);
return mResources.getDrawable(resId);
}
2.特殊资源的获取

一些资源的使用可以通过上述的方式来完成,但是有些资源的使用则不能用仅仅使用上述的方式,我们来举两个例子说明

(1).设置menu文件

设置一个Activity的menu方式如下

1
2
3
4
5
6
7
@Override
public boolean onCreateOptionsMenu(Menu menu) {
// Inflate the menu; this adds items to the action bar if it is present.
getMenuInflater().inflate(R.menu.menu_main, menu);
return true;
}

我们看一下MenuInflater的inflate方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void inflate(@MenuRes int menuRes, Menu menu) {
XmlResourceParser parser = null;
try {
parser = mContext.getResources().getLayout(menuRes);
AttributeSet attrs = Xml.asAttributeSet(parser);
parseMenu(parser, attrs, menu);
} catch (XmlPullParserException e) {
throw new InflateException("Error inflating menu XML", e);
} catch (IOException e) {
throw new InflateException("Error inflating menu XML", e);
} finally {
if (parser != null) parser.close();
}
}

可以看到这里通过Resourece的getLayout方法来生成对应的menu的XmlResourceParser对象,然后通过MenuInflater的parseMenu方法来设置。

这个方法的menu文件Id和Resourece我们都不好进行修改,所以我们直接模拟这套流程,生成对应的插件中的menu的XmlResourceParser对象,然后调用parseMenu方法设置就好了,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void inflateMenu(Context context, String packageName,String menuId,Menu menu){
try {
XmlResourceParser parser = getMenu(context,packageName,menuId);
AttributeSet attrs = Xml.asAttributeSet(parser);
Class<?> menuInflaterClass = Class.forName("android.view.MenuInflater");
MenuInflater menuInflater = new MenuInflater(context);
Method parseMenuMethod = menuInflaterClass.getDeclaredMethod("parseMenu",XmlPullParser.class, AttributeSet.class,Menu.class);
parseMenuMethod.setAccessible(true);
parseMenuMethod.invoke(menuInflater,parser,attrs,menu);
} catch (Exception e) {
e.printStackTrace();
}
}

通过这样调用就可以正确的使用插件中的menu作为我们Activity的menu了

(2).设置TextView等控件的Style样式

TextView与继承的子View用代码设置Style样式方式都是使用setTextAppearance方法如下

1
textView.setTextAppearance(R.style.xxx);

我们接下来看一下textView的setTextAppearance方法的源代码,如下

1
2
3
4
5
6
7
8
9
10
11
public void setTextAppearance(Context context, @StyleRes int resId) {
final TypedArray ta = context.obtainStyledAttributes(resId, R.styleable.TextAppearance);
final int textColorHighlight = ta.getColor(
R.styleable.TextAppearance_textColorHighlight, 0);
if (textColorHighlight != 0) {
setHighlightColor(textColorHighlight);
}
...
ta.recycle();
}

其实我们只用关心这里的TypedArray的获取就好了,后面相关的属性设置其实和我们没有什么关系,这里是调用了Context的obtainStyledAttributes,经过调用,最后调用了Context中Resourece对象的内部类Theme的obtainStyledAttributes方法,如下

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
public TypedArray obtainStyledAttributes(@StyleRes int resid, @StyleableRes int[] attrs)
throws NotFoundException {
final int len = attrs.length;
final TypedArray array = TypedArray.obtain(Resources.this, len);
array.mTheme = this;
if (false) {
int[] data = array.mData;
System.out.println("**********************************************************");
System.out.println("**********************************************************");
System.out.println("**********************************************************");
System.out.println("Attributes:");
String s = " Attrs:";
int i;
for (i=0; i<attrs.length; i++) {
s = s + " 0x" + Integer.toHexString(attrs[i]);
}
System.out.println(s);
s = " Found:";
TypedValue value = new TypedValue();
for (i=0; i<attrs.length; i++) {
int d = i*AssetManager.STYLE_NUM_ENTRIES;
value.type = data[d+AssetManager.STYLE_TYPE];
value.data = data[d+AssetManager.STYLE_DATA];
value.assetCookie = data[d+AssetManager.STYLE_ASSET_COOKIE];
value.resourceId = data[d+AssetManager.STYLE_RESOURCE_ID];
s = s + " 0x" + Integer.toHexString(attrs[i])
+ "=" + value;
}
System.out.println(s);
}
AssetManager.applyStyle(mTheme, 0, resid, 0, attrs, array.mData, array.mIndices);
return array;
}

这里最后调用了AssetManager的applyStyle,而这里是调用的C层的代码,我们可以看到这里TypedArray的最后相关处理其实是通过mTheme这个对象来处理的。

所以我们Hook掉Context中的Theme对象,为我们插件对应的Theme对象,这个Theme对象是通过Resourece来生成的,所以代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void setTextAppearance(Context context, String packageName,String styleId, TextView textView){
try {
File targetFile = PluginInstallManager.getTargetPluginFile(packageName);
int resId = getPluginResId(RESOURCES_TYPE.TYPE_STYLE, context, targetFile, packageName, styleId);
Field mThemeField = context.getClass().getDeclaredField("mTheme");
mThemeField.setAccessible(true);
Resources resources = getPluginResources(context, targetFile);
mThemeField.set(context, resources.newTheme());
textView.setTextAppearance(context,resId);
} catch (Exception e) {
e.printStackTrace();
}
}

所以获取插件中的资源对应的Id,将TextView对象中的Context变量的mTheme变量Hook掉就可以使用了。

当然对于Android中其实还有其他地方也要使用这种hook的方式去使用插件中的资源文件,那么就不做过多的介绍了,原则上都是使用插件对应的资源Id与Resourece类即可。

写在后面的话

对于插件中资源的获取就介绍到这里了,下一篇就是对于插件中四大控件的使用了,peace~~~