宿主如何加载插件资源
开发中宿主程序调起未安装的插件apk,一个很大的问题就是资源如何访问,这些资源文件的ID都映射在gen文件夹下的R.java中,而插件中凡是以R开头的资源都不能访问。究其原因是因为宿主程序中并没有插件的资源,所以通过R来加载插件的资源是行不通的,程序会抛出异常:无法找到某某id所对应的资源
如何处理插件资源与宿主资源的处突
插件化资源问题要做到的效果是,如果我们要获取的资源在插件中找得到,则加载优先加载插件的,如果找不到,则到宿主资源中找。这样能做到动态更新的效果。
如何确保插件和宿主使用到的是被修改过的资源。
anim、array、attr、bool、color、dimen、drawable、id、integer、layout、menu、mipmap、raw、string、style、xml、styleable
package com.sangfor.pocket;
public final class R {
public static final class id{
public static final int Extra_layout=0x7f0f12dc;
...
}
public static final class string {
public static final int M=0x7f080019;
...
}
...
}
getResources().getXXX(resid)
其中getResources()
获取的就是当前应用的全局Resource对象,然而,插件无论是apk还是so格式的,插件的R.java并没有注册到当前主app的上下文环境
那么我们getResources()
所获得全局Resource对象的getXXX(resid)
自然就找不到对应的资源路径
这也就是插件的res资源也就无法在主app中通过id使用了.
如果在主app中使用hook newActivity创建的Activity对象,是无法访问到插件res的。这是由于每个apk只能访问自己的res,所以这时候,虽然这个Activity确实是插件中的Activity,但是实际上是加载在宿主里的resource,所以也就是有个隔离,因此必须替换resource
那么我们到底应该如何在主App中使用插件apk的资源呢?
具体的解决思路有以下几种:
Resource全局唯一性
- mResources通过一系列的缓存或者成员实例引用,实现了全局唯一。
- 如果通过打补丁的方式,会全局生效(small替换了resourcemanager中瓜缓存的resource的assetmanager,实现全局替换)。
- 但是在插件化方案中,为了避免id冲突,有的使用的是替换法,那就是所有有缓存和成员实例的地方都要替换为我们自己的resource;
要想获得资源文件必须得到一个Resource对象,想要获得插件的资源文件,必须得到一个插件的Resource对象,好在android.content.res.AssetManager.java中包含一个私有方法addAssetPath。
只需要将apk的路径作为参数传入,就可以获得对应的AssetsManager对象,从而创建一个Resources对象,然后就可以从Resource对象中访问apk中的资源了
通过以上方法却是可以在宿主中获取到插件的资源文件,只是宿需要用到相关资源的时候需跟插件约定好对应名称,以防出现找不到的情况
一个apk里面其context的个数为application+Activity+service的总和,因为他们都是继承context的,然而context只是一个抽象类,其真正的实现类是ContextImpl,拿Activity来说,在Activity的启动流程中,会在ActivityThread的performLaunchActivity()方法中调用Activity的attach方法把ContextImp实例传给Activity(即赋值给Activity内的成员变量mBase)
ContextImpl内有一个Resources的成员变量mResources,代表的是应用的资源,我们平时在调用getResources()方法获取到的是该Resources
Resources内部的一个重要成员是AssetManager(mAssets),其指向的是apk的资源路径,资源的获取最终都是通过它来得到的。
这里需要注意的是AssetManager并不是Resources独立持有的,也就是说系统在获取资源的时候不一定是通过Resources获取的,有时候是直接通过AssetManager来获取
- Resource实例存储
- Resource的实例保存在ContextImpl中,每次构建ContextImpl时,会从LoadedApk中拿到对应的Resource
- Loadedapk中的getResource会有成员实例mResource,命中直接返回,没命中委托Activitythread—ResourceManager生成
- ResourceMamager里有map缓存,命中直接返回,没命中则先新建Assetmanager,再构建Resource实例
全局唯一性
我们平时怎么使用res资源的吗,主要就是依赖于getResources()
的Resources对象, 也就是getResources().getXXX(resid)
getResources
调用的是android.view.ContextThemeWrapper
的方法 @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;
}
}
ContextWrapper#getResources()
方法:android.content.ContextWrapper
Context mBase;
public ContextWrapper(Context base) {
mBase = base;
}
@Override
public Resources getResources() {
return mBase.getResources();
}
android.content.Context
public abstract Resources getResources();
@Override
public Resources getResources() {
return mResources;
}
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对象,那么自然就可以借助该对象获取插件中的资源了
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实例,这也是开发时要处理的问题。(其实应该有3套,Android系统会加载一套framework-res.apk资源,里面存放系统默认Theme等资源)
这里你可能注意到了我们采用了反射的方法调用AssetManager的“addAssetPath”方法,而在上面ResourcesManager中调用AssetManager的“addAssetPath”方法是直接调用的
而且看看SDK里AssetManager的“addAssetPath”方法的源码(这里也能看到具体APK资源的提取过程是在Native里完成的),发现它也是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类就可以发现这些接口都是对应用层隐藏的
private static AssetManager createAssetManager(String apkPath) {
try {
AssetManager assetManager = AssetManager.class.newInstance();
AssetManager.class.getDeclaredMethod("addAssetPath", String.class).invoke(
assetManager, apkPath);
return assetManager;
} catch (Throwable th) {
th.printStackTrace();
}
return null;
}
public static Resources getBundleResource(Context context, String apkPath){
AssetManager assetManager = createAssetManager(apkPath);
return new Resources(assetManager, context.getResources().getDisplayMetrics(), context.getResources().getConfiguration());
}
我们使用插件的Resources对象,获取资源时,传递的ID必须是离线apk中R文件对应的资源的ID
resources#getIdentifier()
Resources resources = BundlerResourceLoader.getBundleResource(getApplicationContext());
imageView = (ImageView)findViewById(R.id.image_view_iv);
if(resources != null){
String str = resources.getString(resources.getIdentifier("test_str", "string", "net.mobctrl.normal.apk"));
String strById = resources.getString(0x7f050001);//注意,id参照Bundle apk中的R文件
System.out.println("debug:"+str);
Toast.makeText(getApplicationContext(),strById, Toast.LENGTH_SHORT).show();
Drawable drawable = resources.getDrawable(0x7f020000);//注意,id参照Bundle apk中的R文件
imageView.setImageDrawable(drawable);
}
public static Resources getBundleResource(Context context){
AssetsManager.copyAllAssetsApk(context);//将apk拷贝到系统data/目录下
File dir = context.getDir(AssetsManager.APK_DIR, Context.MODE_PRIVATE);
String apkPath = dir.getAbsolutePath()+"/BundleApk.apk";
System.out.println("debug:apkPath = "+apkPath+",exists="+(new File(apkPath).exists()));
AssetManager assetManager = createAssetManager(apkPath);
return new Resources(assetManager, context.getResources().getDisplayMetrics(), context.getResources().getConfiguration());
}
- nuptboyzhb/AndroidPluginFramework-- github
述代码是加载离线apk中的字符串和Drawable资源,那么layout资源呢?
我们使用LayoutInflate对象,一般使用方法如下:
View view = LayoutInflater.from(context).inflate(R.layout.main_fragment, null);
总结起来就是,传入当前上下文环境和资源id给LayoutInflater,LayoutInflater会借用上下文环境中的Resource去查找该id的资源并加载
其中,R.layout.main_fragment我们可以通过getIdentifier获取插件中的id,那么关键的一步就是如何生成一个context?
直接传入当前的context是不行的,因为这是两个不同的上下文对象,当前app的context中是找不到这个插件layout的id的
解决方案有2个:
我们在Activity的attachBaseContext方法中,对Context的mResources进行替换,就可以把当前的Context对象传入LayoutInflater函数中
但是需要注意的是,一旦替换,则当前Acitivity获取的Resouces都是基于插件资源的。
@Override
protected void attachBaseContext(Context context) {
replaceContextResources(context);
super.attachBaseContext(context);
}
/**
* 使用反射的方式,使用Bundle的Resource对象,替换Context的mResources对象
* @param context
*/
public void replaceContextResources(Context context){
try {
Field field = context.getClass().getDeclaredField("mResources");
field.setAccessible(true);
field.set(context, mBundleResources);
System.out.println("debug:repalceResources succ");
} catch (Exception e) {
System.out.println("debug:repalceResources error");
e.printStackTrace();
}
}
简书H3c:Android插件化开发 第四篇 [加载插件Activity]中,提供了一种 替换Application中Resources的方式
public class HostApplication extends Application {
private Resources mAppResources = null;
private Resources mOldResources = null;
@Override
public void onCreate() {
super.onCreate();
mOldResources = super.getResources();
AssetsMultiDexLoader.install(this);// 加载assets中的apk
installResource();
}
@Override
public Resources getResources() {
if(mAppResources == null){
return mOldResources;
}
return this.mAppResources;
}
private void installResource() {
if (mAppResources == null) {
mAppResources = BundlerResourceLoader.getAppResource(this);// 加载assets中的资源对象
}
}
@Override
public AssetManager getAssets() {
if (this.mAppResources == null) {
return super.getAssets();
}
return this.mAppResources.getAssets();
}
}
public class BundleActivity extends BaseActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.bundle_layout);
findViewById(R.id.text_view).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
Toast.makeText(getApplicationContext(), "Hello", Toast.LENGTH_LONG).show();
}
});
}
@Override
public Resources getResources() {
return getApplication().getResources();
}
}
AssetManager的addAssetPath()方法调用native层AssetManager对象的addAssetPath()方法,通过查看c++代码可以知道,该方法可以被调用多次,每次调用都会把对应资源添加起来,而后来添加的在使用资源是会被首先搜索到
可以怎么理解,C++层的AssetManager有一个存放资源的栈,每次调用addAssetPath()方法都会把资源对象压如栈,而在读取搜索资源时是从栈顶开始搜索,找不到就往下查
这样我们就可以通过修改 getBundleResource(Context context, String apkPath)
方法实现:
public static Resources getBundleResource(Context context){
String plugDexPath = Environment.getExternalStorageState() + "/myplug/plug1.apk";
String appDexPath = mContext.getApplicationContext().getPackageResourcePath();
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPathMethod = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPathMethod.setAccessible(true);
addAssetPathMethod.invoke(assetManager, appDexPath);//【先入栈】
addAssetPathMethod.invoke(assetManager, plugDexPath);//【后入栈】
Resources superRes = mContext.getResources();
return new Resources(assetManager, superRes.getDisplayMetrics(), superRes.getConfiguration());
}
appDexPath为宿主apk路径,plugDexPath为插件apk路径,superRes为宿主资源,resources为融合插件与宿主的资源
当我们添加了一个资源(如在String.xml里添加了一个String),则系统会为我们在R里面为该资源生成一个int型的id与之对应,使用的时候是根据该id找到对应的资源。
**资源id是按照资源名称的字典顺序来递增的。**拿String来说。
this is a
this is b
//则会在R里面生成相应的id,按照资源名称的字典顺序来递增
public static final int za=0x7f06004
public static final int za=0x7f06005
基于上面的观察,我们会发现一个问题:
宿主资源情况为:存在
za(id=0x7f060004)
zb(id=0x7f060005)
插件资源情况为:存在
za(id=0x7f060004)
zab(id=0x7f060005)
ab(0x7f060006)
这时候在宿主里获取资源zb,则根据上面所说,无论是插件R中的id数值
还是resources#getIdentifier()
, 最终都是会根据id=0x7f060005去查找资源
但是由于,插件的plugDexPath处于栈顶,获取到的就是插件中的zab,而不是宿主中的zb
然而实际上,此处zab和zb并不是我们想去替换的资源
要解决资源冲突,目前有很多插件化框架都提出了自己的解决方案:
需要注意的是
在上边的使用中,我们都是显式的使用插件中的资源,主动的调用getBundleResource(Context context, String apkPath).getxxx(resId)
来获取相关资源
也就是说,当我们需要加载插件中的资源时,替换掉当前Context的ContextImpl中的Resource对象
那么一些系统的隐式调用,怎么去保证能去插件中寻找资源呢?
这就需要运用到Resource全局置换。 目前来看,做法也分为以下两种:
Small框架就是采用该方式
我们看获取到Resources后如何找到对应id的资源,在Resources中定位到getString(int id)方法:
@NonNull
public String getString(@StringRes int id) throws NotFoundException {
final CharSequence res = getText(id);
if (res != null) {
return res.toString();
}
throw new NotFoundException("String resource ID #0x"
+ Integer.toHexString(id));
}
public CharSequence getText(@StringRes int id) throws NotFoundException {
CharSequence res = mAssets.getResourceText(id);
if (res != null) {
return res;
}
throw new NotFoundException("String resource ID #0x"
+ Integer.toHexString(id));
}
最终寻找资源的调用是有AssetManager来执行的,这个AssetManager是 ResourceManager#getTopLevelResources()
创建Resources时来的
然而,需要注意的是:getTopLevelResources()
中具备缓存逻辑
Resources getTopLevelResources(String resDir, String[] splitResDirs,
String[] overlayDirs, String[] libDirs, int displayId,
Configuration overrideConfiguration, CompatibilityInfo compatInfo) {
ResourcesKey key = new ResourcesKey(resDir, displayId, overrideConfiguration, scale, token);
Resources r;
WeakReference wr = mActiveResources.get(key);
r = wr != null ? wr.get() : null;
if (r != null && r.getAssets().isUpToDate()) {
return r;
}
AssetManager assets = new AssetManager();
if (resDir != null) {
if (assets.addAssetPath(resDir) == 0) {
return null;
}
}
r = new Resources(assets, dm, config, compatInfo, token);
WeakReference wr = mActiveResources.get(key);
Resources existing = wr != null ? wr.get() : null;
if (existing != null && existing.getAssets().isUpToDate()) {
r.getAssets().close();
return existing;
}
mActiveResources.put(key, new WeakReference(r));
return r;
}
也就是说,由于ResourceManager是一个单例类,并且持有了当前App的Resource缓存,那么我们直接在App启动时手动替换掉ResourceManager中的Resource缓存,就可以在当前App中添加插件的资源,并且全局有效
Small框架的资源加载流程在ApkBundleLauncher中完成,setup流程获取到所有插件so的信息,在postSetUp中获取所有插件包的资源路径,通过反射调用AssetManager的addAssetPaths方法,构造一个包含宿主包资源、系统资源和插件包资源的AssetManger。最后还是通过反射,使用包含所有资源的AssetManager替换掉ResourcesManager中Resources的AssetManger,最终达到加载插件中资源的目的
其实关注Small的源码中ReflectAccelerator.ensureCacheResources,这个方法想要的达到的作用是当每次启动Activity时遍历系统缓存的ResourceImpl,将它的AssetManager替换成包含插件资源的AssetManager。当然这个机制只在SDK>=24时生效
public static void ensureCacheResources() {
if (Build.VERSION.SDK_INT < 24) return;
if (sResourceImpls == null || sMergedResourcesImpl == null) return;
Set> resourceKeys = sResourceImpls.keySet();
for (Object resourceKey : resourceKeys) {
WeakReference resourceImpl = (WeakReference)sResourceImpls.get(resourceKey);
if (resourceImpl != null && resourceImpl.get() != sMergedResourcesImpl) {
// Sometimes? the weak reference for the key was released by what
// we can not find the cache resources we had merged before.
// And the system will recreate a new one which only build with host resources.
// So we needs to restore the cache. Fix #429.
// FIXME: we'd better to find the way to KEEP the weak reference.
sResourceImpls.put(resourceKey, new WeakReference
由于隐式调用都是依赖于Context对象内的Resource实例,我们可以在Context对象被创建后且还未使用时把它里面的Resources(mResources)替换为具备分发功能的Resource
现在问题就转化为:
我们已知,整个应用的Context数目等于Application+Activity+Service的数目,Context会在这几个类创建对象的时候创建并添加进去。
而这些行为都是在ActivityTHread和Instrumentation里做的,我们以Activity为例,看下如何使用hook来替换Resource实例
ActivityThread在接收到LAUNCH_ACTIVITY消息以后,在 performLaunchActivity方法中:
Context实例的创建
Activity实例的创建
Activity绑定Context
ActivityThread在LAUNCH_ACTIVITY消息中,完成了Activity生命周期中的三个回调,分别是onCreate onStart onRestoreInstanceStat
Instrumentation#callActivityOnCreate()
方法 //ActivityThread
private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) {
//1. Context实例的创建
ContextImpl appContext = createBaseContextForActivity(r);
...
//2. Activity实例的创建
java.lang.ClassLoader cl = appContext.getClassLoader();
activity = mInstrumentation.newActivity(
cl, component.getClassName(), r.intent);
StrictMode.incrementExpectedActivityCount(activity.getClass());
r.intent.setExtrasClassLoader(cl);
r.intent.prepareToEnterProcess();
if (r.state != null) {
r.state.setClassLoader(cl);
}
...
//3. Activity绑定Context
appContext.setOuterContext(activity);
activity.attach(appContext, this, getInstrumentation(), r.token,
r.ident, app, r.intent, r.activityInfo, title, r.parent,
r.embeddedID, r.lastNonConfigurationInstances, config,
r.referrer, r.voiceInteractor, window, r.configCallback);
//4. Activity的onCreate()方法的回调
activity.mCalled = false;
if (r.isPersistable()) {
mInstrumentation.callActivityOnCreate(activity, r.state, r.persistentState);
} else {
mInstrumentation.callActivityOnCreate(activity, r.state);
}
...
}
private ContextImpl createBaseContextForActivity(ActivityClientRecord r) {
}
//Instrumentation
public Activity newActivity(ClassLoader cl, String className,
Intent intent)
throws InstantiationException, IllegalAccessException,
ClassNotFoundException {
return (Activity)cl.loadClass(className).newInstance();
}
替换掉Activity里Context里的Resources最好要早,基于上面的观察,我们可以在调用Instrumentation的callActivityOnCreate()方法时把Resources替换掉
我们如何控制callActivityOnCreate()方法的执行,这里又得使用hook的思想了,即把ActivityThread里面的Instrumentation对象(mInstrumentation)给替换掉,同样得使用反射
//ActivityThread
public static ActivityThread currentActivityThread() {
return sCurrentActivityThread;
}
//实现过程
public static void hookResource(Context context){
try{
//反射获取 ActivityThread
Class> activityThreadClass = Class.forName("android.app.ActivityThread");
Method currentActivityThreadMethod = activityThreadClass.getDeclaredMethod("currentActivityThread");
currentActivityThreadMethod.setAccessible(true);
Object oldActivityThread = currentActivityThreadMethod.invoke(null);
//反射获取Instrumentation
Field mInstrumentationFiled = activityThreadClass.getDeclaredField("mInstrumentation");
mInstrumentationFiled.setAccessible(true);
Instrumentation oldInstrumentation= (Instrumentation) mInstrumentationFiled.get(oldActivityThread);
//替换Instrumentation
Instrumentation proxy = new MyInstrumentation(oldInstrumentation, context);
mInstrumentationFiled.set(oldActivityThread, proxy);
}catch(Exception e){
}
}
//替换 Context.Resouce
public class MyInstrumentation extends Instrumentation{
@override
public void callActivityOnCreate(Activity activity, Bundle icicle) {
chageResource(activity, icicle);
super.callActivityOnCreate(activity, icicle);
}
private void chageResource(Activity activity, Bundle icicle){
Filed mBaseFiled = Activity.class.getSuperclass().getSuperclass().getDeclaredField("mBase");
mBaseFiled.setAccessible(true);
Context mbase = (Context) mBaseFiled.get(activity);
Class> contextImplClass = Class.forName("android.app.ContextImpl");
Filed mResourcesFiled = contextImplClass.getDeclaredField("mResources");
mResourcesFiled.setAccessible(true);
//自己的
Resources myResources = MyPluginsUtils.getBundleResource(mbase);
//反射注入
mResourcesFiled.set(mbase, myResources);
}
}
如果想要做到插件化,需要了解Android资源文件的打包过程aapt,这样可以为每一个插件进行编号,然后按照规则生成R文件
按照下述规则生成对应的插件apk,插件的R文件按照如下规则:
在运行时,我们可以写一个ResourceManager类(相当于一个分发类),它继承自Resource对象,然后所有的Activity,都将其context的mResource成员变量修改为ResourceManager类,然后Override其方法
在加载资源时,ResourceManager类会根据不同的id的前缀,查找对应插件的Resource或者主app资源id