Android动态加载插件资源

最近在看app的换肤功能。简单的来说就是动态读取插件apk中的资源,需要进行换肤的控件所用到的资源在主apk和插件apk中各维护了一份,且资源名称相同。
插件听起来高大上,但其实就是一个apk文件。所以我们所要做的,就是怎么样能让插件中的资源加载进本地,并且读取到。

Resource的创建

在app内部加载资源使用的是context.getResources(),context中getResources()方法是一个抽象方法,具体的实现在ContextImpl类中。

Resources resources = packageInfo.getResources(mainThread);

参数packageInfo指向的是一个LoadedApk对象,这个LoadedApk对象描述的是当前正在启动的Activity组所属的Apk。

进入到LoadedApk的getResources(mainThread)方法

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, this);
}
return mResources;
}

LoadedApk类的成员函数getResources首先检查其成员变量mResources的值是否等于null。如果不等于的话,那么就会将它所指向的一个Resources对象返回给调用者,否则的话,就会调用参数mainThread的成员函数getTopLevelResources来获得这个Resources对象,然后再返回给调用者。 mainThread指向一个ActivityThread对象。

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
public final class ActivityThread {
......
final HashMap<ResourcesKey, WeakReference<Resources> > mActiveResources
= new HashMap<ResourcesKey, WeakReference<Resources> >();
Resources getTopLevelResources(String resDir, CompatibilityInfo compInfo) {
ResourcesKey key = new ResourcesKey(resDir, compInfo.applicationScale);
Resources r;
synchronized (mPackages) {
......
WeakReference<Resources> wr = mActiveResources.get(key);
r = wr != null ? wr.get() : null;
......
if (r != null && r.getAssets().isUpToDate()) {
......
return r;
}
}
......
AssetManager assets = new AssetManager();
if (assets.addAssetPath(resDir) == 0) {
return null;
}
......
r = new Resources(assets, metrics, getConfiguration(), compInfo);
......
synchronized (mPackages) {
WeakReference<Resources> wr = mActiveResources.get(key);
Resources existing = wr != null ? wr.get() : null;
if (existing != null && existing.getAssets().isUpToDate()) {
// Someone else already created the resources while we were
// unlocked; go ahead and use theirs.
r.getAssets().close();
return existing;
}
// XXX need to remove entries when weak references go away
mActiveResources.put(key, new WeakReference<Resources>(r));
return r;
}
}
}

在其中创建了AssertManager对象,assets.addAssetPath(resDir)这句话的意思是把资源目录里的资源都加载到AssetManager对象中 如果我们把一个未安装的apk的路径传给这个方法,那么apk中的资源就被加载到AssetManager对象里面了。但它是一个隐藏方法,需要反射调用。有了AssertManager对象就能创建Resources对象了。

AssetManager介绍

Provides access to an application’s raw asset files; see Resources for the way most applications will want to retrieve their resource data. This class presents a lower-level API that allows you to open and read raw files that have been bundled with the application as a simple stream of bytes.

AssetManager提供了应用的原始资源,通过它可以让应用程序检索他们的资源数据。
在ResourcesImpl类中存在AssetManager的引用mAsset.举个例子看下Resources怎么通过AssetManager加载数据.看下Resources的getString()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
public String getString(@StringRes int id) throws NotFoundException {
return getText(id).toString();
}
public CharSequence getText(@StringRes int id) throws NotFoundException {
CharSequence res = mResourcesImpl.getAssets().getResourceText(id);
if (res != null) {
return res;
}
throw new NotFoundException("String resource ID #0x"
+ Integer.toHexString(id));
}

Resources将资源id传给了AssetManager的getResourceText方法。从AssetManager中返回了资源数据。有兴趣大家可以深入研究一下,这里不做过多介绍。 接下来我们写一个小demo看看更换皮肤包的简单实现。

Demo

首先如果只加载本地皮肤包(带有皮肤资源的apk)的时候,我们将皮肤包放入assets文件夹内,再在初始化的时候加载进sdcard中。如果要下载皮肤包,则直接下载进sdcard指定目录中。我们现在只做本地皮肤包的更换。

先进行初始化的操作:

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
//先定义全局的名称和存储路径
private static final String APK_NAME = "sample.apk";
private static final String APK_DIR = Environment.
getExternalStorageDirectory() + File.separator + APK_NAME;
public void init(Context context) {
File pluginFile = new File(APK_DIR);
if (pluginFile.exists()) {
pluginFile.delete();
}
InputStream is = null;
FileOutputStream fos = null;
try {
is = context.getAssets().open(APK_NAME);
fos = new FileOutputStream(APK_DIR);
int bytes;
byte[] byteArr = new byte[1024 * 4];
while ((bytes = is.read(byteArr, 0, 1024 * 4)) != -1) {
fos.write(byteArr, 0, bytes);
}
PackageManager mPm = context.getPackageManager();
PackageInfo mInfo = mPm.getPackageArchiveInfo(APK_DIR, PackageManager.GET_ACTIVITIES);
mSkinPackageName = mInfo.packageName;
AssetManager assetManager = AssetManager.class.newInstance();
Method method = assetManager.getClass().getMethod("addAssetPath", String.class);
method.invoke(assetManager, pluginFile.getAbsolutePath());
mSuperResources = context.getResources();
mResources = new Resources(assetManager, mSuperResources.getDisplayMetrics(),
mSuperResources.getConfiguration());
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
if (is != null) {
is.close();
}
if (fos != null) {
fos.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}

读取assets下的sample.apk,将其放进sdcard中。通过反射创建AssetManager,并调用addAssetPath方法,将apk的路径传入AssetManager中。再new 一个Resources对象,传入上步生成的AssetManager对象,这时就拿到了皮肤包apk的Resources对象。初始化完成。

接下来在布局中放入一个TextView,动态替换TextView控件用到的资源。

1
2
3
4
5
6
7
8
9
10
<TextView
android:id="@+id/tv_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textSize="20sp"
android:layout_centerHorizontal="true"
android:textColor="@color/colorPrimaryDark"
android:background="@mipmap/ic_launcher"
/>

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
btnLoad = (Button) findViewById(R.id.btn_load);
tvName = (TextView) findViewById(R.id.tv_name);
btnLoad.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
tvName.setBackground(ResourceManager.getInstance()
.loadMipmapResource(R.mipmap.ic_launcher));
tvName.setText(ResourceManager.getInstance()
.loadStringResource(R.string.app_name));
tvName.setTextColor(ResourceManager.getInstance()
.loadColorResource(R.color.colorPrimaryDark));
}
});

ok,这时候我们看下ResourceManager.getInstance().loadMipmapResource(R.mipmap.ic_launcher)的实现;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public Drawable loadMipmapResource(int resId){
return mResources.getDrawable(findTrueResId(resId,"mipmap"));
}
/**
* 找到插件app中rescource的真正id
* @param resId 主app中的资源id
*/
private int findTrueResId(int resId,String defType){
String entryName = mSuperResources.getResourceEntryName(resId);
Log.e(TAG, "entryName " + entryName);
String resourceName = mSuperResources.getResourceName(resId);
Log.e(TAG, "resourceName " + resourceName);
int trueResId = mResources.getIdentifier(entryName, defType,mSkinPackageName);
Log.e(TAG, "trueResId " + trueResId);
return trueResId;
}

上面的代码,mSuperResources是当前apk的Resources对象,通过getResourceEntryName(resId),拿到resId对应的名称.
还有一个方法,getResourceName,这个和getResourceEntryName的区别在于,getResourceName拿到的全名包括包名,getResourceEntryName拿到的是简短名称.这里我们使用getResourceEntryName方法。
调用皮肤包的Resources对象的getIdentifier方法,会返回资源在皮肤包中的真实id,将真实id拿到后,就可以调用getDrawable(trueId)来加载资源了。来看下打印出来的日志。

xyz.ibat.pluginsloader E/DONG: entryName ic_launcher
xyz.ibat.pluginsloader E/DONG: resourceName xyz.ibat.pluginsloader:mipmap/ic_launcher
xyz.ibat.pluginsloader E/DONG: trueResId 2130903047

加载string和color和上述方法原理相同。我们来看下最终效果。

换肤前:

换肤前
换肤前

换肤后:

换肤后

参考资料

Android应用程序资源管理器(Asset Manager)的创建过程分析
Android源码分析-资源加载机制