VirtualAPK分析[资源篇]

1,765 阅读9分钟
原文链接: www.zybuluo.com
[关闭] @dodola 2017-07-15 17:45 字数 10172 阅读 230

VirtualAPK分析[资源篇]

Android 插件化[资源处理篇]


一. 概述

VitrualAPK 是滴滴开源的一款插件化框架
VirtualAPK 地址:github.com/didi/Virtua…

前两天鸿洋分析了插件的四大组件启动流程,这里针对一些具体的点分析一下。 整体会分为如下几个方面分析:

  1. 资源篇
  2. Context+ClassLoader 相关
  3. 四大组件相关
  4. 插件打包脚本分析

本篇先从资源下手分析,VirtualAPK的插件资源加载分为两种方式:
一种是插件存在一份独立的 Resources 自己使用,一种是COMBINE_RESOURCES模式,将插件的资源全部添加到宿主的Resource里

首先我们要先看一下系统是如何加载资源的。

Android 资源加载

此处简单探讨一下Android系统里资源加载查找的过程,这是插件加载资源的理论基础。

Resources对象的生成
从下向上一直可以追溯到生成Resources对象的地方

  1. class ContextImpl extends Context {
  2. //...
  3. private ContextImpl(ContextImpl container, ActivityThread mainThread,
  4. LoadedApk packageInfo, IBinder activityToken, UserHandle user, boolean restricted,
  5. Display display, Configuration overrideConfiguration) {
  6. //....
  7. Resources resources = packageInfo.getResources(mainThread);
  8. //....
  9. }
  10. //...
  11. }

这里不去关注packageInfo是如何生成的,直接跟踪到下面去.

  1. public final class LoadedApk {
  2. private final String mResDir;
  3. public LoadedApk(ActivityThread activityThread, ApplicationInfo aInfo,
  4. CompatibilityInfo compatInfo, ClassLoader baseLoader,
  5. boolean securityViolation, boolean includeCode, boolean registerPackage) {
  6. final int myUid = Process.myUid();
  7. aInfo = adjustNativeLibraryPaths(aInfo);
  8. mActivityThread = activityThread;
  9. mApplicationInfo = aInfo;
  10. mPackageName = aInfo.packageName;
  11. mAppDir = aInfo.sourceDir;
  12. mResDir = aInfo.uid == myUid ? aInfo.sourceDir : aInfo.publicSourceDir;
  13. // 注意一下这个sourceDir,这个是我们宿主的APK包在手机中的路径,宿主的资源通过此地址加载。
  14. // 该值的生成涉及到PMS,暂时不进行分析。
  15. // Full path to the base APK for this application.
  16. //....
  17. }
  18. //....
  19. public Resources getResources(ActivityThread mainThread) {
  20. if (mResources == null) {
  21. mResources = mainThread.getTopLevelResources(mResDir, mSplitResDirs, mOverlayDirs,
  22. mApplicationInfo.sharedLibraryFiles, Display.DEFAULT_DISPLAY, null, this);
  23. }
  24. return mResources;
  25. }
  26. //....
  27. }

进入到ActivityThread.getTopLevelResources()的逻辑中

  1. public final class ActivityThread {
  2. Resources getTopLevelResources(String resDir, CompatibilityInfo compInfo) {
  3. //我们暂时只关注下面这一段代码
  4. AssetManager assets = new AssetManager();
  5. if (assets.addAssetPath(resDir) == 0) { //此处将上面的mResDir,也就是宿主的APK在手机中的路径当做资源包添加到AssetManager里,则Resources对象可以通过AssetManager查找资源,此处见(老罗博客:Android应用程序资源的查找过程分析)
  6. return null;
  7. }
  8. // 创建Resources对象,此处依赖AssetManager类来实现资源查找功能。
  9. r = new Resources(assets, metrics, getConfiguration(), compInfo);
  10. }
  11. }

从上面的代码中我们知道了我们常用的Resources是如何生成的了,那么理论上插件也就按照如此方式生成一个Resources对象给自己用就可以了。从VirtualAPK的代码里看一下对资源的处理

VirtualAPK 资源加载

在VirtualAPK里插件所有相关的内容都被封装到LoadedPlugin里,插件的加载行为一般都在这个类的构造方法的实现里,我们这里只关注与资源相关部分的代码

  1. LoadedPlugin(PluginManager pluginManager, Context context, File apk) throws PackageParser.PackageParserException {
  2. //需要注意context是宿主的Context
  3. //apk 指的是插件的路径
  4. this.mResources = createResources(context, apk);
  5. this.mAssets = this.mResources.getAssets();
  6. }
  7. private static AssetManager createAssetManager(Context context, File apk) {
  8. try {
  9. //这里参照系统的方式生成AssetManager,并通过反射将插件的apk路径添加到AssetManager里
  10. //这里只适用于资源独立的情况,如果需要调用宿主资源,则需要插入到宿主的AssetManager里
  11. AssetManager am = AssetManager.class.newInstance();
  12. ReflectUtil.invoke(AssetManager.class, am, "addAssetPath", apk.getAbsolutePath());
  13. return am;
  14. } catch (Exception e) {
  15. e.printStackTrace();
  16. return null;
  17. }
  18. }
  19. @WorkerThread
  20. private static Resources createResources(Context context, File apk) {
  21. if (Constants.COMBINE_RESOURCES) {
  22. //如果插件资源合并到宿主里面去的情况,插件可以访问宿主的资源
  23. Resources resources = new ResourcesManager().createResources(context, apk.getAbsolutePath());
  24. ResourcesManager.hookResources(context, resources);
  25. return resources;
  26. } else {
  27. //插件使用独立的Resources,不与宿主有关系,无法访问到宿主的资源
  28. Resources hostResources = context.getResources();
  29. AssetManager assetManager = createAssetManager(context, apk);
  30. return new Resources(assetManager, hostResources.getDisplayMetrics(), hostResources.getConfiguration());
  31. }
  32. }

如果将宿主和插件隔离,我们只需要生成一个独立的Resources对象给插件使用,如果要调用宿主资源则需要将宿主的APK和插件的APK一起添加到同一个AssetManager里。进入到ResourcesManager的逻辑里

  1. public static synchronized Resources createResources(Context hostContext, String apk) {
  2. // hostContext 为宿主的Context
  3. Resources hostResources = hostContext.getResources();
  4. //获取到宿主的Resources对象
  5. Resources newResources = null;
  6. AssetManager assetManager;
  7. try {
  8. //-----begin---
  9. //这块的代码涉及到的内容比较多,详情见①
  10. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
  11. assetManager = AssetManager.class.newInstance();
  12. ReflectUtil.invoke(AssetManager.class, assetManager, "addAssetPath", hostContext.getApplicationInfo().sourceDir);
  13. } else {
  14. assetManager = hostResources.getAssets();
  15. }
  16. //------end----
  17. //------begin---
  18. ReflectUtil.invoke(AssetManager.class, assetManager, "addAssetPath", apk);
  19. List<LoadedPlugin> pluginList = PluginManager.getInstance(hostContext).getAllLoadedPlugins();
  20. for (LoadedPlugin plugin : pluginList) {
  21. ReflectUtil.invoke(AssetManager.class, assetManager, "addAssetPath", plugin.getLocation());
  22. }
  23. //------end----
  24. //-----begin-----
  25. //此处针对机型的兼容代码是可以避开的,详情见③
  26. if (isMiUi(hostResources)) {
  27. newResources = MiUiResourcesCompat.createResources(hostResources, assetManager);
  28. } else if (isVivo(hostResources)) {
  29. newResources = VivoResourcesCompat.createResources(hostContext, hostResources, assetManager);
  30. } else if (isNubia(hostResources)) {
  31. newResources = NubiaResourcesCompat.createResources(hostResources, assetManager);
  32. } else if (isNotRawResources(hostResources)) {
  33. newResources = AdaptationResourcesCompat.createResources(hostResources, assetManager);
  34. } else {
  35. // is raw android resources
  36. newResources = new Resources(assetManager, hostResources.getDisplayMetrics(), hostResources.getConfiguration());
  37. }
  38. //-----end-----
  39. } catch (Exception e) {
  40. e.printStackTrace();
  41. }
  42. return newResources;
  43. }
  44. public static void hookResources(Context base, Resources resources) {
  45. if (Build.VERSION.SDK_INT >= 24) {
  46. return;
  47. }
  48. try {
  49. ReflectUtil.setField(base.getClass(), base, "mResources", resources);
  50. Object loadedApk = ReflectUtil.getPackageInfo(base);
  51. ReflectUtil.setField(loadedApk.getClass(), loadedApk, "mResources", resources);
  52. Object activityThread = ReflectUtil.getActivityThread(base);
  53. Object resManager = ReflectUtil.getField(activityThread.getClass(), activityThread, "mResourcesManager");
  54. Map<Object, WeakReference<Resources>> map = (Map<Object, WeakReference<Resources>>) ReflectUtil.getField(resManager.getClass(), resManager, "mActiveResources");
  55. Object key = map.keySet().iterator().next();
  56. map.put(key, new WeakReference<>(resources));
  57. } catch (Exception e) {
  58. e.printStackTrace();
  59. }
  60. }

①:此处针对系统版本的区分涉及到资源加载时候的兼容性问题
由于资源做过分区,则在AndroidL后直接将插件包的apk地址addAssetPath之后就可以,但是在Android L之前,addAssetPath只是把补丁包加入到资源路径列表里,但是资源的解析其实是在很早的时候就已经执行完了,问题出现在这部分代码:
AssetManager.cpp

  1. const ResTable* AssetManager::getResTable(bool required) const
  2. {
  3. ResTable* rt = mResources;
  4. if (rt) {
  5. return rt;
  6. }
  7. //....
  8. const size_t N = mAssetPaths.size();
  9. for (size_t i=0; i<N; i++) {
  10. Asset* ass = NULL;
  11. ResTable* sharedRes = NULL;
  12. bool shared = true;
  13. const asset_path& ap = mAssetPaths.itemAt(i);
  14. MY_TRACE_BEGIN(ap.path.string());
  15. Asset* idmap = openIdmapLocked(ap);
  16. ALOGV("Looking for resource asset in '%s'\n", ap.path.string());
  17. if (ap.type != kFileTypeDirectory) {
  18. if (i == 0) {
  19. // The first item is typically the framework resources,
  20. // which we want to avoid parsing every time.
  21. sharedRes = const_cast<AssetManager*>(this)->
  22. mZipSet.getZipResourceTable(ap.path);
  23. }
  24. if (sharedRes == NULL) {
  25. ass = const_cast<AssetManager*>(this)->
  26. mZipSet.getZipResourceTableAsset(ap.path);
  27. if (ass == NULL) {
  28. ALOGV("loading resource table %s\n", ap.path.string());
  29. ass = const_cast<AssetManager*>(this)->
  30. openNonAssetInPathLocked("resources.arsc",
  31. Asset::ACCESS_BUFFER,
  32. ap);
  33. if (ass != NULL && ass != kExcludedAsset) {
  34. ass = const_cast<AssetManager*>(this)->
  35. mZipSet.setZipResourceTableAsset(ap.path, ass);
  36. }
  37. }
  38. if (i == 0 && ass != NULL) {
  39. // If this is the first resource table in the asset
  40. // manager, then we are going to cache it so that we
  41. // can quickly copy it out for others.
  42. ALOGV("Creating shared resources for %s", ap.path.string());
  43. sharedRes = new ResTable();
  44. sharedRes->add(ass, (void*)(i+1), false, idmap);
  45. sharedRes = const_cast<AssetManager*>(this)->
  46. mZipSet.setZipResourceTable(ap.path, sharedRes);
  47. }
  48. }
  49. } else {
  50. ALOGV("loading resource table %s\n", ap.path.string());
  51. Asset* ass = const_cast<AssetManager*>(this)->
  52. openNonAssetInPathLocked("resources.arsc",
  53. Asset::ACCESS_BUFFER,
  54. ap);
  55. shared = false;
  56. }
  57. }

mResources指向的是一个ResTable对象,如果它的值不等于NULL,那么就说明当前应用程序已经解析过它使用的资源包里面的resources.arsc文件,因此,这时候AssetManager类的成员函数getResources就可以直接将该ResTable对象返回给调用者。如果还没有初始化 mResources则按照一定步骤遍历当前应用所使用的每个资源包进而生成 mResources
具体的初始化过程见 老罗的博客

由于有系统资源的存在,mResources 的初始化在很早就初始化了,所以我们就算通过addAssetPath方法将 apk 添加到mAssetPaths里,在查找资源的时候也不会找到这部分的资源,因为在旧的 mResources 里没有这部分的 id。

所以在 Android L 之前是需要想办法构造一个新的AssetManager里的 mResources 才行,这里有两种方案,VirtualAPK 用的是类似 InstantRun 的那种方案,构造一个新的 AssetManager,将宿主和加载过的插件的所有 apk 全都添加一遍,然后再调用 hookResources方法将新的 Resources 替换回原来的,这样会引起两个问题,一个是每次加载新的插件都会重新构造一个 AssetManger 和 Resources,然后重新添加所有资源,这样涉及到很多机型的兼容(因为部分厂商自己修改了 Resources 的类名),一个是需要有一个替换原来Resources的过程,这样就需要涉及到很多地方,从 hookResources的实现里看,替换了四处地方,在尽量少的 hook 原则下这样的情况还是尽量避免的。

另外一种方案在淘宝发布的《深入探索 Android 热修复技术原理》的文档里有说明,这里引用过来介绍一下。

在 AssetManager 里有一个方法叫做destroy方法

AssetManager.java

  1. public final class AssetManager {
  2. //....
  3. private native final void destroy();
  4. //....
  5. }

对应的 native 代码如下

android_util_AssetManager.cpp

  1. static void android_content_AssetManager_destroy(JNIEnv* env, jobject clazz)
  2. {
  3. AssetManager* am = (AssetManager*)
  4. (env->GetIntField(clazz, gAssetManagerOffsets.mObject));
  5. ALOGV("Destroying AssetManager %p for Java object %p\n", am, clazz);
  6. if (am != NULL) {
  7. delete am;
  8. env->SetIntField(clazz, gAssetManagerOffsets.mObject, 0);
  9. }
  10. }

这里直接 delete am ,会导致调用AssetManager 的析构函数

AssetManager.cpp

  1. AssetManager::~AssetManager(void)
  2. {
  3. int count = android_atomic_dec(&gCount);
  4. //ALOGI("Destroying AssetManager in %p #%d\n", this, count);
  5. delete mConfig;
  6. delete mResources;
  7. // don't have a String class yet, so make sure we clean up
  8. delete[] mLocale;
  9. delete[] mVendor;
  10. }

这里delete mResources
现在我们可以通过调用他的init方法,重新初始化

android_util_AssetManager.cpp

  1. static void android_content_AssetManager_init(JNIEnv* env, jobject clazz)
  2. {
  3. AssetManager* am = new AssetManager();
  4. if (am == NULL) {
  5. jniThrowException(env, "java/lang/OutOfMemoryError", "");
  6. return;
  7. }
  8. am->addDefaultAssets();
  9. ALOGV("Created AssetManager %p for Java object %p\n", am, clazz);
  10. env->SetIntField(clazz, gAssetManagerOffsets.mObject, (jint)am);
  11. }

此时在资源查找的时候发现mResources没有初始化就将所有的资源 add 一遍。

插件调用宿主的资源

在宿主开启了COMBINE_RESOURCES模式下,插件是可以调用宿主的资源的,理论上也是可以调用其他插件资源的(需要修改打包),但是不推荐这么做。这个问题可以看一下Replugin踩过的坑
此处需要注意两个问题,如果宿主升级那么需要保证原来插件调用的资源id不能改变,否则宿主升级后,加载的插件还是拿取的旧版本资源id,可能会导致资源找不到和错乱情况,所以宿主要从使用插件起保证每个版本被插件所使用的资源不能变化id。感觉不是很实用。

这里用官方例子做一个简单的例子说明一下如何调用宿主资源。

  1. 首先我们需要将能被插件访问到的资源提取到一个公共模块中去
    image_1bkvuj49vuqnkrbev912ogtu9m.png-38.8kB

    然后修改配置,将comm_res这个模块引用到宿主模块中

    image_1bkvuljlg2k11n8u101k1kcr15ev1g.png-49.8kB

  2. 切回我们的插件模块,将common_res也引用到插件模块中
    image_1bkvunr4cn45t1j1uat1uiq1edk1t.png-12kB

  3. 这样就可以在插件直接引用这个资源了,我直接在一个Activity中引用了这个资源

    1. <ImageView android:layout_width="wrap_content"
    2. android:layout_height="wrap_content"
    3. android:src="@drawable/newsplash"
    4. />
  4. 打包验证,调用插件可以看到这个图片是被引用到了
    image_1bkvuu0h72io175a5iibilson2a.png-90kB

  5. 验证资源是否来自宿主中,首先先看一下插件里是否存在这个图片的资源
    image_1bkvv1qisqhlbbi1k1j1emn14gn2n.png-41.4kB

    可以看到,插件里并没有打包到插件里,下一步看一下此资源的id是否和宿主中的资源id一致

    插件里的id
    image_1bkvv3jngius1pdsugjauc1ept34.png-11.2kB

    宿主里的id
    image_1bkvv69jm6fk3qv16vlive1npb3h.png-181.9kB

    验证通过

  6. 此处验证一下公共资源的增加和变动是否会引起旧版本的插件调用资源错误
    验证方式比较简单,增加几张图片
    image_1bl00t6i61j40klpme1rdfmig4b.png-9kB
    然后重新打包宿主,运行,发现此时运行旧的插件,已经发生错误了
    image_1bl00vfeftutd8u9uu1p01ib54o.png-95.3kB

    查一下宿主的资源id,发现此时已经发生资源变更
    image_1bl00st831ehapiflkahp1itt3u.png-121.1kB

  7. 要解决这个问题,需要注意两个地方,1,旧的资源不能删除,2,需要保持旧版本资源的id不变,具体见Tinker的实现