浅析Android插件化

前言

在Android 9.0的jar包中,可以发现很多函数添加了@hide,这表示google显示了对@hide api的反射调用,上有政策,下有对策,我天朝人民的智慧是无穷尽的,具体的方案可以参考一种绕过Android P对非SDK接口限制的简单方法。按理说只要在Android 9.0的手机反射调用了@hide的api都不会work。但是我在华为的p20上测试,还是可以使用的,说明华为对google的做了一些“定制化”。

划重点

本文将从下面几个方面去讲述插件化的实现原理

  1. 简单梳理一下Activity的启动流程
  2. 了解反射和动态代理
  3. Android类的加载机制,主要了解PathClassLoader和DexClassLoader
  4. hook的两个方案
  5. 资源加载

demo

Activity启动流程

image

启动流程:

  1. 点击桌面APP图标,Launcher进程采用Binder IPC(IActivityManager)向system_server(AMS)进程发起startActivity请求
  2. system_server(AMS)进程接受到请求后,检查该进程是否存在,如果不存在则想zygote进程发送创建进程的请求
  3. zygote进程fork出新的子进程,即APP进程
  4. APP进程调用ActivityThread中的main函数,然后通过Binder IPC(IActivityManager)向system_server(AMS)进程发起attachApplication请求
  5. system_server(AMS)进程接受到请求后,先通过Binder IPC(IApplicationThread)向APP发送bindApplication通知,创建Application。发送创建Activity的消息需要分版本
    1. Android 9.0(28)以下,通过Binder IPC(IActivityManager)向APP进程发送scheduleLaunchActivity请求
    2. Andriod 9.0(28),通过Binder IPC(IActivityManager)向APP进程发送scheduleTransaction请求
  6. APP进程接受到请求后
    1. 小于28的版本,通过mH(handler)想App进程发送scheduleLaunchActivity请求
    2. 等于28的版本,通过scheduleTransaction,调用ActivityThread的handleLaunchActivity
  7. 主线程在接收到消息后,开始创建Activity
  8. 到此,APP便正式启动,开始进入Activity生命周期,执行完onCreate/onStart/onResume方法,UI渲染完成后便可以看到APP主界面了。

反射和动态代理

反射就是动态获取信息和动态代用对象的方法,具体的介绍可以看这篇文章反射、动态代理和注解,这里就不赘述了

classloader

  • PathClassLoader只能加载已经安装到Android系统中的apk(/data/app目录),是Android默认使用类加载器
  • DexClassLoader可以加载任意目录下的dex/jar/apk/zip文件,比PathClassLoader要更加灵活,是实现热修复的重点

基于API28的源码

/**
 * Provides a simple {@link ClassLoader} implementation that operates on a list
 * of files and directories in the local file system, but does not attempt to
 * load classes from the network. Android uses this class for its system class
 * loader and for its application class loader(s).
 */
public class PathClassLoader extends BaseDexClassLoader {
    
    public PathClassLoader(String dexPath, ClassLoader parent) {
        super(dexPath, null, null, parent);
    }

    public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent){
        super(dexPath, null, librarySearchPath, parent);
   }
}

/**
 * A class loader that loads classes from {@code .jar} and {@code .apk} files
 * containing a {@code classes.dex} entry. This can be used to execute code not
 * installed as part of an application.
*/
public class DexClassLoader extends BaseDexClassLoader {
    public DexClassLoader(String dexPath, String optimizedDirectory,
                          String librarySearchPath, ClassLoader parent) {
        super(dexPath, null, librarySearchPath, parent);
    }
}

从注释上可以看出PathClassLoader是用于系统类和应用程序类的加载,DexClassLoader可以用来加载任意目录的dex。集体实现还要看BaseDexClassLader的构造方法

public class BaseDexClassLoader extends ClassLoader {
    private final DexPathList pathList;

    public BaseDexClassLoader(String dexPath, File optimizedDirectory,
                              String librarySearchPath, ClassLoader parent) {
        this(dexPath, optimizedDirectory, librarySearchPath, parent, false);
    }

    public BaseDexClassLoader(String dexPath, File optimizedDirectory,
                              String librarySearchPath, ClassLoader parent, boolean isTrusted) {
        super(parent);
        this.pathList = new DexPathList(this, dexPath, librarySearchPath, null, isTrusted);
        ...
    }
    
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        ...
        Class c = pathList.findClass(name, suppressedExceptions);
        ...
        return c;
    }
    
    @Override
    protected URL findResource(String name) {
        return pathList.findResource(name);
    }
    
    @Override
    public String findLibrary(String name) {
        return pathList.findLibrary(name);
    }
}

可以看出在构造方法中创建了一个DexPathList对象赋值给了pathList字段,然后findxxx()方法都是从DexPathList中查找。BaseDexClassLoader的构造函数包含四个参数:

  • dexPath:包含类和资源的jar/apk文件列表,由File.pathSeparator分割
  • optimizedDirectory:由于dex文件被包含在APK或者Jar文件中,因此在装载目标类之前需要先从APK或者Jar文件中解压出dex文件,该参数就是制定解压出的dex文件存放路径。这也是对apk中dex根据平台进行ODEX优化过程,字API26开始无效
  • librarySearchPath:指目标类中所使用的c/c++库存放的路径,可以为null
  • parent:父ClassLoader引用

接下来我们查看DexPathList的构造方法和findxxx()方法


final class DexPathList {
    private Element[] dexElements;
    
    DexPathList(ClassLoader definingContext, String dexPath,
            String librarySearchPath, File optimizedDirectory, boolean isTrusted) {
        ...
        // 加载dexPath路径下的dex和resource
        this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
                                           suppressedExceptions, definingContext, isTrusted);
        ...
    }
    
        private static Element[] makeDexElements(List<File> files, File optimizedDirectory,
            List<IOException> suppressedExceptions, ClassLoader loader, boolean isTrusted) {
      Element[] elements = new Element[files.size()];
      int elementsPos = 0;
      for (File file : files) {
          if (file.isDirectory()) {
              elements[elementsPos++] = new Element(file);
          } else if (file.isFile()) {
              String name = file.getName();

              DexFile dex = null;
              if (name.endsWith(DEX_SUFFIX)) {
                  // Raw dex file (not inside a zip/jar).
                  try {
                      dex = loadDexFile(file, optimizedDirectory, loader, elements);
                      if (dex != null) {
                          elements[elementsPos++] = new Element(dex, null);
                      }
                  } catch (IOException suppressed) {
                      System.logE("Unable to load dex file: " + file, suppressed);
                      suppressedExceptions.add(suppressed);
                  }
              } else {
                  try {
                      dex = loadDexFile(file, optimizedDirectory, loader, elements);
                  } catch (IOException suppressed) {
                      suppressedExceptions.add(suppressed);
                  }

                  if (dex == null) {
                      elements[elementsPos++] = new Element(file);
                  } else {
                      elements[elementsPos++] = new Element(dex, file);
                  }
              }
          } else {
              System.logW("ClassLoader referenced unknown path: " + file);
          }
      }
      return elements;
    }
}

构造方法中调用makeDexElements()方法获取到了Elements[]数组赋值给了dexElements变量

 public Class<?> findClass(String name, List<Throwable> suppressed) {
        for (Element element : dexElements) {
            Class<?> clazz = element.findClass(name, definingContext, suppressed);
            if (clazz != null) {
                return clazz;
            }
        }

        if (dexElementsSuppressedExceptions != null) {
            suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
        }
        return null;
    }

findClass()方法则是变量在构造方法初始化好的Element[]数组中从前往后遍历找到我们需要的类。
这里我们总结一下类加载的过程:

  • PathClassLoaderDexClassLoader调用了父类BaseDexClassLoader的构造方法。
  • BaseDexClassLoader在构造方法中创建了DexPathList对象并赋值给pathList字段,加载类的findxxx()方法都是调用DexPathList类的findxxx()方法来实现类的加载
  • DexPathList在构造方法中调用makeDexElements()方法创建了Elements[]数组赋值给dexElements字段,findClass()方法就是从前往后遍历Element[]数组找到我们要的class

插件化

终于进入到正题了,插件化顾名思义,就是将一个APK拆成多个,在需要的时候进行网络下载,动态加载到内存中。目前有两种比较好的插件化方案:

  • Hook Instrumentation方案
  • Hook AMS(ActivityManagerService)和ActivityThread中的mH(handler)

选择:这里我们选择第一个方案,第二个方案缺陷比较多,首先是Android 9.0的启动流程做了比较大的改动,hook mH已经失效,而且AMS的hook在Android 8.0和小于8.0也有区别。

选择好了方案我们先梳理一下整个hook的流程:

  1. 将插件工程打包为APK,然后通过adb push命令发送到宿主手机目录下(模拟下载流程)
  2. 利用ClassLoader加载插件APK中的类(Android 9.0PathClassLoader也可以加载任意目录下的APK)
  3. 将APK加载出来的DexPathList中的Emement数据加入到原生的classloader对象中
  4. hook InstrumentationProxy,在发起execStartActivity时换成占位的Activity,在newActivity的时候换为APk中的Activity

打包apk后面再说,这里先说下面的几步操作

利用ClassLoader加载apk

根据上一章节的叙述,我们知道类加载的时候,首先会到父类的classLoader去寻找,找不到才会到当前的classLoader去加载类,而在这里系统是用PathClassLoader去加载类,这里我们就需要将apk中Element加入到PathClassLoader的DexPathList中的Element数组中,看下面代码

@Throws(Exception::class)
    private fun inject(context: Context, origin: ClassLoader, pluginPath: String) {
        val optimizeFile = context.getFileStreamPath("plugin") // /data/data/$packageName/files/plugin
        if (!optimizeFile.exists()) {
            optimizeFile.mkdirs()
        }
        val pluginClassLoader = DexClassLoader(pluginPath, optimizeFile.absolutePath, null, origin)
        val pluginDexPathList = FieldUtil.getField(
            Class.forName(CLASS_BASE_DES_CLASSLOADER), pluginClassLoader,
            FIELD_PATH_LIST
        )
        val pluginElements = FieldUtil.getField(
            Class.forName(CLASS_DEX_PATH_LIST),
            pluginDexPathList,
            FIELD_DES_ELEMENTS
        ) // 拿到插件中的Elements

        val originDexPathList = FieldUtil.getField(
            Class.forName(CLASS_BASE_DES_CLASSLOADER), origin,
            FIELD_PATH_LIST
        )
        val originElements =
            FieldUtil.getField(
                Class.forName(CLASS_DEX_PATH_LIST),
                originDexPathList,
                FIELD_DES_ELEMENTS
            )

        val array = combineArray(originElements, pluginElements) // 合并数组
        FieldUtil.setField(
            Class.forName(CLASS_DEX_PATH_LIST),
            originDexPathList,
            FIELD_DES_ELEMENTS,
            array
        )// 设置回pathClassLoader
        Log.i(TAG, "plugin success to load")
    }


    fun combineArray(pathElements: Any, dexElements: Any): Any {
        val componentType = pathElements.javaClass.componentType
        val i = Array.getLength(pathElements)
        val j = Array.getLength(dexElements)
        val k = i + j
        val result = Array.newInstance(componentType, k)
        System.arraycopy(dexElements, 0, result, 0, j)
        System.arraycopy(pathElements, 0, result, j, i)
        return result
    }

这样我们就将使用DexClassLoader加载的APK,成功的放到系统PathClassLoader的加载列表中,接下来我们就需要想办法绕过系统检查,启动activity

Hook Instrumentation

我们如果绕过检查呢?通过上面的分析的启动流程会发现,在Instrumentation#execStartActivity中,会有个checkStartActivityResult的方法去检查错误,因此,我们可以复写这个方法,让启动参数能通过系统的检查。首先,我们需要检查启动的Intent能不能匹配到,匹配不到的话,将ClassName修改为我们预先在AndroidManifest中配置的占坑Activity,并且把当前的这个ClassName放到当前的intent的extra中,以便后面做恢复。

 public ActivityResult execStartActivity(
            Context who, IBinder contextThread, IBinder token, Activity target,
            Intent intent, int requestCode, Bundle options) {

        List<ResolveInfo> resolveInfo = null;
        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
            resolveInfo = mPackageManager.queryIntentActivities(intent, PackageManager.MATCH_ALL);
        } else {
            resolveInfo = mPackageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY);
        }
        //判断启动的插件Activity是否在AndroidManifest.xml中注册过
        if (null == resolveInfo || resolveInfo.size() == 0) {
            //保存目标插件
            intent.putExtra(REQUEST_TARGET_INTENT_NAME, intent.getComponent().getClassName());
            //设置为占坑Activity
            intent.setClassName(who, PlaceHolderActivity.class.getName());
            Log.i("liyachao", PlaceHolderActivity.class.getName());
        }

        try {
            Method execStartActivity = Instrumentation.class.getDeclaredMethod("execStartActivity",
                    Context.class, IBinder.class, IBinder.class, Activity.class,
                    Intent.class, int.class, Bundle.class);
            return (ActivityResult) execStartActivity.invoke(mInstrumentation, who, contextThread, token, target, intent, requestCode, options);
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (InvocationTargetException e) {
            e.printStackTrace();
        }
        return null;
    }

绕过了检查,现在需要解决的问题是还原,我们知道,系统启动Activity最后会调到ActivityThread里,在这里,会通过Instrumentation#newActivity方法去反射构造一个Activity对象,因此我们只需要在这里还原即可。


 public Activity newActivity(ClassLoader cl, String className, Intent intent) throws InstantiationException,
            IllegalAccessException, ClassNotFoundException {
        String intentName = intent.getStringExtra(REQUEST_TARGET_INTENT_NAME);
        if (!TextUtils.isEmpty(intentName)) {
            return super.newActivity(cl, intentName, intent);
        }
        return super.newActivity(cl, className, intent);
    }

一切准备就绪,我们最后的问题是,如何替换到系统的Instrumentation。通过查看源代码,找到ActivityThread中的private static volatile ActivityThread sCurrentActivityThread;,这是一个静态变量,这就方便我们了


@Throws(Exception::class)
    @JvmStatic
    fun hookActivityThreadInstrumentation(application: Application) {
        val activityThreadClazz = Class.forName("android.app.ActivityThread")
        val activityThreadField = activityThreadClazz.getDeclaredField("sCurrentActivityThread")
        activityThreadField.isAccessible = true
        val activityThread = activityThreadField.get(null)

        val instrumentationField = activityThreadClazz.getDeclaredField("mInstrumentation")
        instrumentationField.isAccessible = true
        val instrumentation = instrumentationField.get(activityThread) as Instrumentation
        val proxy = InstrumentationProxy(instrumentation, application.packageManager)
        instrumentationField.set(activityThread, proxy)
    }

这样,我们就能启动一个没有注册在AndroidManifest文件中的Activity了。

资源的插件化方案

资源的插件化方案,目前大概有两种

  • 合并资源,这样做的缺点是,可能出现资源冲突,解决方案就是重写aapt,来自定义资源生成规则
  • 各个插件构造自己的资源方案

这里我们使用自己构造资源方案,实现起来简单,我们给插件创建一个Resources,然后插件APK中都通过这个Resource去获取资源。这里看下Resources构造方法

 /**
     * Create a new Resources object on top of an existing set of assets in an
     * AssetManager.
     *
     * @deprecated Resources should not be constructed by apps.
     * See {@link android.content.Context#createConfigurationContext(Configuration)}.
     *
     * @param assets Previously created AssetManager.目前创建的AssetManager,用来加载资源,根据插件APK路径创建AssetManager加载资源
     * @param metrics Current display metrics to consider when
     *                selecting/computing resource values.显示配置,直接使用宿主的Resources的配置即可
     * @param config Desired device configuration to consider when。配置项,直接使用宿主的Resources的配置即可
     *               selecting/computing resource values (optional).
     */
    @Deprecated
    public Resources(AssetManager assets, DisplayMetrics metrics, Configuration config) {
        this(null);
        mResourcesImpl = new ResourcesImpl(assets, metrics, config, new DisplayAdjustments());
    }

接下来看AssetManager如何创建


public final class AssetManager implements AutoCloseable {
        public AssetManager() {
        synchronized (this) {
            if (DEBUG_REFS) {
                mNumRefs = 0;
                incRefsLocked(this.hashCode());
            }
            init(false);
            if (localLOGV) Log.v(TAG, "New asset manager: " + this);
            ensureSystemAssets();
        }
    }
    
    /**
     * 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) {//传入需要加载资源的路径
        return  addAssetPathInternal(path, false);
    }
}

直接通过空参构造方法创建,然后调用addAssetPath()去加载对路径的资源。

接下来我们在Application中创建插件的Resources,之所有在这里创建是为了方便插件APK中获取到这个Resources,因为插件APK中的四大组建实际上是在宿主APK中创建的,那么它们拿到Application实际上也是宿主的,所以只需要通过getApplication().getResources()就可以非常方便的拿到插件Resource

 override fun attachBaseContext(base: Context?) {
        super.attachBaseContext(base)
        try {
            val path = FileUtil.initPath("com.knight.plugin")
            val file = File(path)
            var pluginPath = ""
            file.listFiles().forEach {
                if (it.name.endsWith(".apk")) {
                    pluginPath = it.absolutePath
                }
            }
            pluginResource =
                    PluginManager.initPlugin(this, pluginPath)
        } catch (e: Exception) {
            e.printStackTrace()
        }
        KnightPermission.init(this)
    }

    override fun getResources(): Resources {
        return if (pluginResource == null) super.getResources() else pluginResource!!
    }

模拟下载apk

这个过程很简单,在demo中,我将apk放在assets目录下,启动的时候,将apk复制到手机应用的目录下,这样就模拟了下载apk的过程

fun copyData2File(filesDir: File, assets: AssetManager, fileName: String) {
        val file = File(filesDir, fileName)
        if (file.exists()) {
            return
        }
        var outputStream: OutputStream? = null
        var inputStream: InputStream? = null

        try {
            outputStream = FileOutputStream(file)
            inputStream = assets.open(fileName)
            val buffer = ByteArray(1024)
            var len = inputStream.read(buffer)
            while (len != -1) {
                outputStream.write(buffer, 0, len)
                len = inputStream.read(buffer)
            }
            Log.i("liyachao", "copy $fileName success")
        } catch (e: Exception) {
            e.printStackTrace()
        } finally {
            try {
                outputStream?.close()
                inputStream?.close()
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }
    }
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容