#0 初识插件化

1,374 阅读4分钟

恍惚间从毕业到工作都一年整了,依然是咸鱼一条。想着先了解了解 Android 插件化相关的内容,也是为以后深入了解 Framework 作一个铺垫。同时将探索的历程写出来,以激励自己能够好好探索下去,别半途而废...

目标

这一篇文章的目标是编写出一个简单的应用,能够实现从宿主应用跳转到插件的Activity。

1. 建立项目

使用 Android Studio - File - New - New Porject 创建好宿主项目之后,再在此项目中 new 一个 module,这就是我们的插件项目了。

2. plugin module 配置

由于我是以 Android libary 方式创建的 Module,所以为了生成 apk 包,所以需要修改 module 构建脚本。

apply plugin: 'com.android.application'

3. “插件”打包与“发布”

在插件中新建名ToastTest的java文件,添加如下内容:

public class ToastTest {

    private final Context context;

    public ToastTest(Context context) {
        this.context = context;
    }

    public void call() {
        Toast.makeText(context, "call method", Toast.LENGTH_SHORT).show();
    }

    public String getData() {
        return "Haha";
    }
}

然后就可以打包apk了,使用gradle执行编译命令

 ./gradlew plugin:assemble

成功之后,可以前往 plugin/build 目录下查看生成的 apk 文件。

这就是我们生成的 “插件包”了。我们可以先将其 push 到设备的 sdcard 上。

 adb push ./plugin/build/outputs/apk/debug/plugin-debug.apk sdcard/

4."宿主"调用插件中的方法

众所周知哈,java 提供了 ClassLoader 来加载来自文件等其他的字节码文件,而 Android 中也提供了 DexClassLoader 用于动态加载 dex。 于是咱们创建一个新的 DexClassLoader,用于加载 sdcard/plugin.apk 中的字节码文件。

  buttonLoadClass.setOnClickListener {
      val file = File("sdcard/plugin-debug.apk")
  
      Log.i("MainActivity", "file exists: ${file.exists()}")
  
      val cache = File(cacheDir, "tmp").apply { mkdirs() }
      val loader = DexClassLoader(
          file.path, cache.path, null, classLoader
      )
      val clazz = loader.loadClass("com.example.plugin.ToastTest")
      val constructor = clazz.getConstructor(Context::class.java)
      val method = clazz.getMethod("call")
  
      val methodGetData = clazz.getMethod("getData")
  
      val instance = constructor.newInstance(this)
  
      Log.i("MainActivity", "data :   ${methodGetData.invoke(instance)}")
      //调用插件中的方法,弹出 toast
      method.invoke(instance)

}

5. Activity跳转

经过以上步骤,已经实现动态调用插件中的方法,并弹出了一个 Toast。于是顺势再尝试实现 Activity 的跳转吧。

首先在 plugin 项目中,创建一个名为 PluginActivity 的 Activity。

然后我们来尝试启动它...

在步骤4中,我们通过 DexClassLoader 来加载插件中的字节码文件,从而实现调用插件中的方法。但是在 startActivity 时候,我们又该怎么样 new 出插件中的 Activity 实例呢?

查找了相关资料,找到了这么一种实现方式。

通过 hack Instrumentation 类,在 Instrumentation#newActivity() 方法中使用 DexClassLoader 来加载插件中的 Activity 类。

详情参见 fashare2015.github.io/2018/01/24/…

另外我个人想出了一个更简单的方法。由于 classLoader 加载类时,优先使用 parent 加载,所以我们可以尝试将当前的 classLoader 的 parent 换成插件 DexClassLoader。

文字写这么多,读起来会有点绕。用代码实现的话,差不多逻辑如下:

val pluginClassLoader = DexClassLoader(/*...*/, parent = classLoader.parent);
classLoader.parent = pluginClassLoader

当然,由于 ClassLoader 在 jdk 中是由 private final 修饰的, 我们还需要使用反射来修改它的值。

    buttonToPlugin.setOnClickListener {

        val file = File("sdcard/plugin-debug.apk")

        Log.i("MainActivity", "file exists: ${file.exists()}")

        val cache = File(cacheDir, "tmp").apply { mkdirs() }
        val loader = DexClassLoader(
            file.path, cache.path, null, classLoader.parent
        )
        
        classLoader.modifyFiled("parent", loader)

        val pluginCls = classLoader.loadClass("com.example.plugin.PluginActivity")

        Log.i("MainActivity", "class : $pluginCls")

        val intent = Intent().apply {
            setClassName(this@MainActivity, "com.example.plugin.PluginActivity")
        }

        startActivity(intent)

    }
    
    private fun Any.modifyFiled(filedName: String, value: Any?) {
        val field = this.javaClass.findFiled(filedName)
        if (!field.isAccessible) {
            field.isAccessible = true
        }
        field.set(this, value)
    }

    private fun Class<*>.findFiled(filedName: String): Field {
        return try {
            getDeclaredField(filedName)
        } catch (e: Exception) {
            superclass.findFiled(filedName)
        }
    }

注意事项

  1. 插件中的 activity 需要在宿主 app 中声明,不然 startActivity 会抛出异常。
 <activity android:name="com.example.plugin.PluginActivity"/>
  1. 无法使用 R 文件,即资源文件暂不可用。
  2. PluginActivity 需继承自 Activity 而非 AppCompatActivity,原因同2。
  3. 跳转并成功打开 PluginActivity,可能会报以下异常
    Caused by: java.lang.IllegalAccessError: Class ref in pre-verified class resolved to unexpected implementation
    
    这是由于某些类被重复加载导致的。

至此跳转效果,如下图GIF所示:

插件中资源的使用

我们都知道:Android 中资源文件是由 AssetManager 来管理的,AssetManager 一般有设置了两个 path(Android系统自带的资源和应用的资源)。所以如果我们添加插件的 apk 地址到 AssetManager 中,那么我们不就能访问到插件apk中资源文件了吗?经过验证,是可以的,但是需要注意的点有点小多。

  1. 宿主apk 和 插件 apk 的 AssetManager 最好隔离开,不然会有资源冲突的问题。

  2. AssetManager 隔离时,需要hock改写 Theme context#baseContext 等一些未暴露的 api。

听说腾讯开源的 Shadow 是零反射实现的插件化,有时间再拜读一下他们的源码,看看大佬们是如何解决插件资源使用这一问题的。


文章就暂时写到这里吧,还有不少其他的方式来实现插件化,而且业界也有很多的开源项目,等有机会熟读之后再重作总结吧。

参考

huangtianyu.gitee.io/2017/12/27/…

fashare2015.github.io/2018/01/24/…

github.com/alibaba/atl…