在android中,使用一般的DexClassLoader方式加载一个安装的APK包或者jar包可以在程序运行的过程中实现动态插件的功能。但遗憾的是这只适用于代码的动态加载,而对于一些诸如SDK的产品,由于其是准备接入到一个独立的应用当中的,其不能拥有(或要求不能)属于自己的res目录,也就不能使用一般的方法使用getResources()方法获取Resources对象后再使用资源ID去获取对应的资源。这不但局限了开发过程,还加大了这类产品的开发过程难度。
本文旨在记录解决动态加载资源过程中的所见所想。
首先需要明白为什么此类产品不能访问res的资源,它们真的是不能使用res的资源吗?肯定不是的,说明白一点,在应用运行时,SDK产品与接入的产品都是可以共享同一个资源入口Resources对象的,而我们一般使用的R.layout.main_activity访问布局资源,图片资源等,经过编译后其实都是化为一串整形的ID的,以此为前提,如果我们可以固定我们需要访问的资源的ID,然后在代码中直接写入一个整形值,同样是可以访问到res的资源的。但这样一来需要接入产品的配合,二来可以需要固定资源的ID值,虽然可以使用public.xml等协助,但经测试后效果都不是太理想。
由于动态加载进来的资源不是存在于应用内,而是在运行时加载进来的,当前应用的资源入口对象中并没有包含资源的路径,如果我们要访问独立的资源包,则需要针对资源包创建资源入口。代码1
2
3
4
5
6
7
8
9
10
11private void createResource() {
try {
AssetManager assetManager =AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager, resApkInfo.getPath());
Resources superRes = GlobalVars.context.getApplicationContext().getResources();
apkResource = new Resources(assetManager, superRes.getDisplayMetrics(), superRes.getConfiguration());
} catch (Exception e) {
e.printStackTrace();
}
}
代码先获取到一个新的AssetManager实例,因为创建Resources对象时需要。AssetManager有一个方法addAssetPath,其定义自下1
2
3
4
5
6
7
8
9
10/**
* Add an additional set of assets to the asset manager. This can be
* either a directory or ZIP file. Not for use by applications. Returns
* the cookie of the added asset, or 0 on failure.
* {@hide}
*/
public final int addAssetPath(String path) {
int res = addAssetPathNative(path);
return res;
}
目的就是将一个额外的包(zip或目录)添加到asset manager中,这里添加的就是APK的位置,然后创建好后,用其去创建一个Resources对象,就可以使用这个对象用ID的整形值访问独立资源包中的资源了。
由于包未安装,不能使用getIdentifier方法获取,故可以分析独立资源包中的R.java文件来获取对应资源名和ID值。
使用这种办法比较简单,但实际上也存在不少问题:
1. 独立包和应用内的资源可能存在冲突;
2. 对于layout等内存在引用资源的系统在解析时会找不到资源
第一个问题解决起来也比较简单粗暴,私心想着怎么样去修改独立资源包中的ID值。需要说明的是资源名与ID值的映射关系不是由R.java唯一决定的,说实在的R.java只是为了应用好编译才衍生出来的,生成的过程是由AAPT工具完成的(具体的过程可以参考:http://blog.csdn.net/luoshengyang/article/details/8744683因为此前已经研究过AAPT的源码,与其去花大力气绕圈去解决冲突,倒不如直接修改aapt的源码来得快。按罗大大所述,资源ID是一个4字节32位的整形值,其组成为0xPPTTNNNN,其中PP为packageID,系统只有两个,一个为0x01,表示系统应用,一个为0x7f,表示普通应用,对于一个应用来说,在这两者间的packageId都是可以接受的(我曾度过选了一个大于0x7f的值,在验证的时候发现其变成了负值而失败了,因为int都是有符号整数);TT是类型的ID,诸如layout,drawable,但这个没有一一对应的关系,是按其写入的顺序去决定的,下面会讲到其生成的规则;NNNN才是一个资源的ID,按顺序生成的。
决定修改生成的ID,做法也想过几种:
1. 修改packageId
2. 修改typeId
3. 修改资源ID
2和3其实本质都是一样的,他们的数值并不是一定的,可能每一次生成都不一样,因为它们都是按解析时的顺序写到文件中去的,我曾经试着修改过它们(通过数值增加等),让资源ID或类型ID尽量不与使用正常aapt生成的资源冲突,但结果都不是那么好,故我最后是采用第一种方法的。
修改packageId的方法比较简单,既然0x7f已经定义好的,那0x7f应该是写在代码中的,于是我直接用find和xargs grep找127,果然在ResourceTable中命中一个:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23sp<ResourceTable::Package> ResourceTable::getPackage(const String16& package)
{
sp<Package> p = mPackages.valueFor(package);
if (p == NULL) {
if (mIsAppPackage) {
if (mHaveAppPackage) {
fprintf(stderr, "Adding multiple application package resources; only one is allowed.\n"
"Use -x to create extended resources.\n");
return NULL;
}
mHaveAppPackage = true;
p = new Package(package, 127);
} else {
p = new Package(package, mNextPackageId);
}
//printf("*** NEW PACKAGE: \"%s\" id=%d\n",
// String8(package).string(), p->getAssignedId());
mPackages.add(package, p);
mOrderedPackages.add(p);
mNextPackageId++;
}
return p;
}
容易看出,这是想生成一个Package对象,其packageId就是127,至于此对象用来干嘛可以参考上边链接文章。
于是只要把这个值修改成我们想要的值就可以了,此处我使用了0x5a。修改,保存,然后重新编译。
使用aapt生成R.java的命令如下:1
aapt package -f -m -J ${R存放目录} -S ${res目录} -I android.jar -M AndroidManifest.xml
生成的R.java的的packageId都全部变为0x5a了,至此修改完成。
满心欢喜地跑去测试,使用上边的方法的确已经可以拿到资源包中的资源,且与正在运行的应用的资源ID已经不冲突了,但又存在另一个问题,就是上边所述的对于layout等内存在引用资源的系统在解析时会找不到资源问题。
使用上述方法创建一个layout的示例如下:1
2
3
4
5
6
7
8
9
10public View createLayout(String type, String name) {
if (apkResource != null) {
int id = getId(type, name);//获取资源ID
XmlResourceParser parser = apkResource.getLayout(id);
LayoutInflater factory = LayoutInflater.from(context);
View view = factory.inflate(parser, null);
return view;
}
return null;
}
使用apkResource.getLayout(id)的确是可以获取到对应的xml布局的,但是在解析布局的时候发生了问题,报错“No known package when getting name for resource number”,找不到对应ID的资源,查看了一下,ID值是正确的,但就是找不到资源。报这个错误的是ResourceTypes::getResourceName方法里。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16const ssize_t p = getResourcePackageIndex(resID);
const int t = Res_GETTYPE(resID);
const int e = Res_GETENTRY(resID);
if (p < 0) {
if (Res_GETPACKAGE(resID)+1 == 0) {//if packageId=0x7f
ALOGW("No package identifier when getting name for resource number 0x%08x", resID);
} else {
ALOGW("No known package when getting name for resource number 0x%08x", resID);
}
return false;
}
if (t < 0) {
ALOGW("No type identifier when getting name for resource number 0x%08x", resID);
return false;
}
其中resID为我们请求的资源ID(32位那个),getResourcePackageIndex方法如下,Res_GETPACKAGE是通过将id右移24位来获取到packageId的宏,mPackageMap是用来标识当前应用里面有哪些packageId的,大小为256,每一个都表示一个packageId,并存放在对应的位置下;如果不存在 ,则返回0。
1 | inline ssize_t ResTable::getResourcePackageIndex(uint32_t resID) const |
如果getResourcePackageIndex的返回<0表示不合法的,资源有误。
查看解析的过程可以知道,这是因为解析布局内的资源引用时应用使用的还是应用自己原来的Resources对象,正是如此getResourcePackageIndex在解释我们生成的资源ID时获取到的就是<0,且因为packageId!=0x7f,则会打出错误“No known package when getting name for resource number“,所以它解析到引用资源时,查找的还是自己原来的资源目录,而没有去找独立资源库里的资源。这就坑了,这部分代码是属于系统里的,我改不了,那怎么办呢?
考虑到既然应用使用了应用本身的resources对象去查找资源,而我又不能修改系统实现,就只能查着法子去把应用内resources对象替换成我们自己生成的。从application.getResources()这个方法入手,知道getResources()里拿到的就是应用的resources对象,结果发现Application.java中是没这方法的,肯定是在父类ContextWrapper里,果然,最后找到对应的方法:
1 |
|
这个mBase对象是在创建ContextWrapper时传进来的,它事实上是一个ContextImpl类型的对象,getResources()方法最后获取到的就是ContextImpl对象内的mResources成员变量。
分析到这里就差不多了,既然知道mResources成员是在分析布局时使用的,那就可以反射将我们创建的resource对象将其替换。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15public void replaceSystemResource() {
final Application application = GlobalVars.application;
if (application != null) {
Context baseContext = application.getBaseContext();//ContextImpl->ApplicationContext
Field f;
try {
f = baseContext.getClass().getDeclaredField("mResources");
f.setAccessible(true);
oldResource = (Resources) f.get(baseContext);
f.set(baseContext, myResources);
} catch (Exception e) {
e.printStackTrace();
}
}
}
然后使用上述的办法去获取layout就不会再有找不到资源的问题了。
但是,上面的方法也同样存在着问题,因为我们创建的resources对象没有当前应用的资源路径,所以替换后又会出现找不到当前应用资源的问题。解决办法之一是替换后马上获取资源包的资源,完成后马上替换回来。从使用效果来看是没什么问题的,但对于我来说这总觉得像是一个定时炸弹,可惜还没找到别的好办法。
使用以上方法就能动态加载到非应用内的资源了。
==========================================================================
以下是在分析源码过程中的一些记录点,与本文解决问题无太大关系,仅防以后翻看忘记。
目录:frameworks/base/tools/aapt
执行package操作的是Command.cpp下的doPackage方法,其调用了Resources::buildResources方法用来编译资源,然后针对具体的资源又调用了Resrouces::makeFileResources方法来编译(生成ID也在此方法内)。
写出R.java的方法在Resources::writeSymbolClassk,写出一行ID的是:1
2
3fprintf(fp, id_format,
getIndentSpace(indent),
flattenSymbol(name8).string(), (int)sym.int32Val);
其中,sym.int32Val的int32Val是在AaptAssets.h中的AaptSymbols的addSymbol方法中,最后,找到了ResourcesTable.cpp中的一个方法:
1 | status_t ResourceTable::addSymbols(const sp<AaptSymbols>& outSymbols) { |
这里可以通过getResId看资源id的生成规则。
==========================================================================
更新于2015.7.3
上文通过替换Application中的resource对象来实现加载未安装APK中的资源,但实际使用上可能存在各种风险。
比方说如果在替换前对系统的resource中进行了什么操作(如添加资源路径等),如果此时进行了替换,那极有可能在替换后会出现信息丢失或者不同步的现象;同理,如果在替换后也被相同的手法替换了,那我们自己的逻辑也有问题。
进行系统resource替换的出发点是加载layout时如果直接使用自定义的resource,在使用inflate布局时会出现错误,那么能否想想办法优化加载布局时的做法,使得不需要作系统替换也能达到相同的目的。
加载一个布局时,可以使用1
2
3XmlResourceParser parser = apkResource.getLayout(id);//加载布局
LayoutInflater factory = LayoutInflater.from(context);
view = factory.inflate(parser, null);
其中context为ApplicationContext或普通的activityContext都可。在上述例子中使用的是ApplicationContext,apkResource是使用createResource()方法创建出来的,由于加载布局需要两方面的资源:
1. 能够读取独立资源APK的resource对象;
2. 能够加载APK中的类的classloader对象;
对于第二点需要说明一下,由于使用inflate加载布局的时候,由于layout里每个独立标签都对应一个类,加载的类都是用baseContext中的classloader来加载的,除了像LinearLayout,Button,TextView等系统控件可以直接加载外,自定义的类使用上级的加载器是无法找到的,所以必须要使用自定义的classloader。
从以上代码可知在创建LayoutInflater时需要使用一个上下文,我们替换resource是在baseContext中进行的,那假如说我们能自定义一个独立的context,只在加载资源的时候使用,而不进行替换,那替换所产生的风险理应就消除了,创建context的代码是使用Context中的createPackageContext来创建的。
createPackageContext的方法源码如下: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
public Context createPackageContext(String packageName, int flags)
throws NameNotFoundException {
return createPackageContextAsUser(packageName, flags,
mUser != null ? mUser : Process.myUserHandle());
}
public Context createPackageContextAsUser(String packageName, int flags, UserHandle user)
throws NameNotFoundException {
if (packageName.equals("system") || packageName.equals("android")) {
final ContextImpl context = new ContextImpl(mMainThread.getSystemContext());
context.mRestricted = (flags & CONTEXT_RESTRICTED) == CONTEXT_RESTRICTED;
context.init(mPackageInfo, null, mMainThread, mResources, mBasePackageName, user);
return context;
}
LoadedApk pi =
mMainThread.getPackageInfo(packageName, mResources.getCompatibilityInfo(), flags,
user.getIdentifier());
if (pi != null) {
ContextImpl c = new ContextImpl();
c.mRestricted = (flags & CONTEXT_RESTRICTED) == CONTEXT_RESTRICTED;
c.init(pi, null, mMainThread, mResources, mBasePackageName, user);
if (c.mResources != null) {
return c;
}
}
// Should be a better exception.
throw new PackageManager.NameNotFoundException(
"Application package " + packageName + " not found");
}
容易发现,createPackageContext是通过创建指定packageName的LoadedApk及使用原baseContext中的activithThread,resource等参数创建出一个新的ContextImpl的,创建出来的context与baseContext是两个对象,目前看来是已经可以将自定义的context与系统的context进行隔离,但此时还未能使用这个context来加载到APK中的资源,因为我们需要的两个条件还没有实现(resource与classloader),由于自定义的context与baseContext已经没关系了,所以我们可以放心地对这个对象进行反射处理,完整代码如下:
1 | private Context createContext() { |
使用应用这个自定义的context构建新的LayoutInflater对象,就能使用它来加载布局了。