Android 插件化开发:如何在插件中加载和使用 R 资源
在 Android 插件化架构中,主应用与插件(APK)共享同一个进程。通常情况下,res 目录下的每一个资源都会在 R.java 中生成一个对应的 Integer 类型的 ID。APP 启动时会把 R.java 注册到当前的上下文环境,我们在代码里以 R 文件的方式使用资源时正是通过使用这些 ID 访问 res 资源。
然而,插件的 R.java 并没有注册到当前的上下文环境,所以插件的 res 资源也就无法通过标准的 ID 方式直接使用了。
如何使用插件中的 R 资源
一种解决方式是插件里需要用到的新资源都通过纯 Java 代码的方式创建(包括 XML 布局、动画、点九图等),这种方式虽然有效但过于繁琐。
更通用的解决方案是修改 Context 的资源获取逻辑。记得我们平时怎么使用 res 资源的吗?就是调用 getResources().getXXX(resId)。让我们看看 getResources() 方法的实现链路。
1. 分析 Resources 获取链路
首先查看自定义或代理 Context 中的 getResources() 方法:
@Override
public Resources getResources() {
if (mResources != null) {
return mResources;
}
if (mOverrideConfiguration == null) {
mResources = super.getResources();
return mResources;
} else {
Context resc = createConfigurationContext(mOverrideConfiguration);
mResources = resc.getResources();
return mResources;
}
}
看起来是通过 mResources 实例获取资源。继续追踪 mResources 的初始化,发现它调用了父类 ContextThemeWrapper 里的 getResources() 方法:
Context mBase;
public ContextWrapper(Context base) {
mBase = base;
}
@Override
public Resources getResources() {
return mBase.getResources();
}
这又调用了 Context 的 getResources() 方法。由于 Context 是个抽象类,实际工作由 ContextImpl 完成。查看 ContextImpl 的实现:
@Override
public Resources getResources() {
return mResources;
}
这里并没有 mResources 的创建过程。它是 ContextImpl 的成员变量,通常在构造方法中创建:
resources = mResourcesManager.getTopLevelResources(packageInfo.getResDir(),
packageInfo.getSplitResDirs(), packageInfo.getOverlayDirs(),
packageInfo.getApplicationInfo().sharedLibraryFiles, displayId,
overrideConfiguration, compatInfo);
mResources = resources;
关键逻辑在于 ResourcesManager 的 getTopLevelResources 方法:
Resources getTopLevelResources(String resDir, String[] splitResDirs,
String[] overlayDirs, String[] libDirs, int displayId,
Configuration overrideConfiguration, CompatibilityInfo compatInfo) {
Resources r;
AssetManager assets = new AssetManager();
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.");
}
}
}
}
DisplayMetrics dm = getDisplayMetricsLocked(displayId);
Configuration config;
r = new Resources(assets, dm, config, compatInfo);
return r;
}
这是关键所在。通过这些代码从一个 APK 文件加载 res 资源并创建 Resources 实例。具体过程是:获取一个 AssetManager 实例,使用其 addAssetPath 方法加载 APK(里的资源),再使用 DisplayMetrics、Configuration、CompatibilityInfo 实例一起创建我们想要的 Resources 实例。
2. 加载插件 APK 资源的核心代码
基于上述分析,我们可以通过以下代码加载插件 APK 里的 res 资源:
try {
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager, mDexPath);
mAssetManager = assetManager;
} catch (Exception e) {
e.printStackTrace();
}
Resources superRes = super.getResources();
mResources = new Resources(mAssetManager, superRes.getDisplayMetrics(),
superRes.getConfiguration());
注意: 有的人担心从插件 APK 加载进来的 res 资源的 ID 可能与主项目里现有的资源 ID 冲突。其实这种方式加载进来的 res 资源并不是融入到主项目里面来。主项目里的 res 资源是保存在 ContextImpl 里面的 Resources 实例,整个项目共有;而新加进来的 res 资源是保存在新创建的 Resources 实例的。也就是说,ProxyActivity 其实有两套 res 资源,并不是把新的 res 资源和原有的 res 资源合并了(所以不怕 R.id 重复)。对两个 res 资源的访问都需要用对应的 Resources 实例,这也是开发时要处理的问题。(实际上系统会加载一套 framework-res.apk 资源,里面存放系统默认 Theme 等资源)
3. 为什么需要使用反射调用 addAssetPath
这里你可能注意到了我们采用了反射的方法调用 AssetManager 的 addAssetPath 方法,而在上面 ResourcesManager 中调用 AssetManager 的 addAssetPath 方法是直接调用的,不用反射。而且看看 SDK 里 AssetManager 的 addAssetPath 方法的源码,发现它也是 public 类型的,外部可以直接调用,为什么还要用反射呢?
public final int addAssetPath(String path) {
synchronized (this) {
int res = addAssetPathNative(path);
makeStringBlocks(mStringBlocks);
return res;
}
}
这里有个误区,SDK 的源码只是给我们参考用的,APP 实际上运行的代码逻辑在 android.jar 里面(位于 android-sdk\platforms\android-XX)。反编译 android.jar 并找到 ResourcesManager 类就可以发现这些接口都是对应用层隐藏的。
例如 AssetManager 在 android.jar 中的 Stub 实现如下:
public final class AssetManager {
AssetManager() {
throw new RuntimeException("Stub!");
}
public void close() {
throw new RuntimeException("Stub!");
}
public final InputStream open(String fileName) throws IOException {
throw new RuntimeException("Stub!");
}
}
因此,必须通过反射绕过编译期的限制,才能在实际运行时调用该方法。
加载插件中的 Layout 资源
我们使用 LayoutInflater 对象,一般使用方法如下:
View view = LayoutInflater.from(context).inflate(R.layout.main_fragment, null);
其中,R.layout.main_fragment 我们可以通过上述方法获取其 ID,那么关键的一步就是如何生成一个 context?直接传入当前的 context 是不行的,因为该 context 的 mResources 指向的是主应用的资源。
解决方案有两个:
- 创建一个自己的
ContextImpl,Override 其方法。
- 通过反射,直接替换当前 context 的
mResources 私有成员变量。
通常采用第二种方案,即在 Activity 的 attachBaseContext 方法中,对 Context 的 mResources 进行替换,这样我们就可以加载离线 APK 中的布局了。
@Override
protected void attachBaseContext(Context context) {
replaceContextResources(context);
super.attachBaseContext(context);
}
public void replaceContextResources(Context context) {
try {
Field field = context.getClass().getDeclaredField("mResources");
field.setAccessible(true);
field.set(context, mBundleResources);
System.out.println("debug:replaceResources succ");
} catch (Exception e) {
System.out.println("debug:replaceResources error");
e.printStackTrace();
}
}
注意事项与优化建议
- 内存泄漏风险:手动创建的
Resources 和 AssetManager 实例需要妥善管理生命周期。当插件卸载或 Context 销毁时,应确保释放相关资源,避免内存泄漏。
- Theme 适配:如果插件使用了特定的 Theme,需要确保
Configuration 中包含正确的 Theme 信息,否则 UI 可能显示异常。
- 多版本兼容:不同 Android 版本的
ContextImpl 结构可能存在差异,反射获取字段名时需做兼容性判断。
- 资源冲突:虽然两套 Resources 独立,但如果插件和主应用引用了相同的资源 ID 且逻辑依赖强关联,仍需注意业务逻辑的一致性。
通过以上步骤,即可成功在插件化架构中加载并使用插件中的 R 资源,实现主应用与插件资源的隔离与复用。