likes
comments
collection
share

Android 插件化原理浅析

作者站长头像
站长
· 阅读数 37
Android 插件化原理浅析

插件化是用于动态加载并运行代码的技术,通常可用于精简APK体积、动态更新功能、进行热修复等

本文源码基于API 33,目录如下

  • 插件化的目标
  • 插件化的理论基础
    • 组件检查
    • 类检索
    • 资源检索
  • 插件化的手段
    • 注册组件
    • 加载类
    • 加载资源
  • 其它组件的插件化
    • Service
    • BroadcastReceiver
    • ContentProvider
  • 参考资料

插件化的目标

插件化技术为APP运行时提供了灵活的加载和运行机制,可以在运行时动态执行通过网络下发的代码,使原生具备了近似于H5的灵活性。插件化的动态技术,通常服务于以下目标:

  • 缩减APK体积:通过将二级模块做成在线下载的插件,可以有效缩减应用APK体积,按需加载
  • 破除65535方法数限制:继MultiDex以外的另一种方案
  • 动态修复:发现线上问题时通过更新补丁进行修复,控制风险,防止问题进一步扩大
  • 在线升级:在线插件可以实时展示最新版本,升级效率高于APK自身版本升级
  • 提升编译效率:宿主和插件可以并行编译

插件化的理论基础

通常我们讲插件化,讨论的是Activity的插件化实现。

Android组件管理机制

在用Context.startActivity(Intent)拉起新页面时,如果目标Activity没有在AndroidManifest.xml文件中注册,会抛出如下异常:

android.content.ActivityNotFoundException: Unable to find explicit activity class {pro.lilei.plugin/pro.lilei.plugin.PluginActivity}; have you declared this activity in your AndroidManifest.xml?

API 33的源码里,这段检查代码位于Instrumentation.java

android.app.Instrumentation.java

public static void checkStartActivityResult(int res, Object intent) {
    if (!ActivityManager.isStartResultFatalError(res)) {
        return;
    }
    switch (res) {
        case ActivityManager.START_INTENT_NOT_RESOLVED:
        case ActivityManager.START_CLASS_NOT_FOUND:
            if (intent instanceof Intent && ((Intent)intent).getComponent() != null)
                throw new ActivityNotFoundException(
                        "Unable to find explicit activity class "
                        + ((Intent)intent).getComponent().toShortString()
                        + "; have you declared this activity in your AndroidManifest.xml"
                        + ", or does your intent not match its declared <intent-filter>?");
            throw new ActivityNotFoundException(
                    "No Activity found to handle " + intent);
        case ...
    }
}

由上述代码可知,在检验结果码的过程中,START_INTENT_NOT_RESOLVEDSTART_CLASS_NOT_FOUND两个结果码都会导致无法创建Activity对象。后者属于类加载机制的原因,此处主要分析前者。

所检查的结果码来自于Instrumentation.javaexecStartActivity(),其内部调用AMS来启动Activity。

public ActivityResult execStartActivity(...) {
    ...
    intent.migrateExtraStreamToClipData(who);
    intent.prepareToLeaveProcess(who);
    int result = ActivityTaskManager.getService().startActivity(whoThread,
            who.getOpPackageName(), who.getAttributionTag(), intent,
            intent.resolveTypeIfNeeded(who.getContentResolver()), token,
            target != null ? target.mEmbeddedID : null, requestCode, 0, null, options);
    checkStartActivityResult(result, intent);
    ...
}

调用由AMS转发给ActivityTaskManagerService,调用链如下

  • ActivityTaskManagerServicer: startActivity -> startActivityAsUser -> startActivityAsUser
  • ActivityStarter: execute -> executeRequest

在这个过程中,系统会在AndroidManifest.xml文件中注册的组件中,查找可以响应当前Intent的那个,查找的规则既包括类名精准匹配,也包括<intent-filter>标签的匹配。对于插件而言,其Activity显然是没有经过注册的,因此在这一步会出错,返回START_INTENT_NOT_RESOLVED解析失败。

类加载机制

从我们写下的.java类文件代码,到最终运行在虚拟机中的程序,这中间经历了多个环节。我们知道,Java是一门运行在虚拟机中的语言,首先,.java文件会被javac命令编译成.class文件,它先被加载到内存中,然后在内存中经历函数调用,最后从内存中卸载,这是一个类完整的生命周期过程,包含以下阶段:

  1. 加载
  2. 链接(包含验证、准备、解析三个阶段)
  3. 初始化
  4. 使用
  5. 卸载

Android 插件化原理浅析

本章节重点分析其加载原理。

Java中的类加载

负责进行加载类的工具称为类加载器,Java中的类加载器分为系统类加载器和自定义类加载器两种。

系统类加载器

  • 引导类加载器Bootstrap ClassLoader):用c/c++语言实现,用于加载JDK中的核心类,主要加载$JAVA_HOME/jre/lib目录下的类,例如rt.jarresources.jar等jar包。
  • 扩展类加载器Extensions ClassLoader):用Java语言实现的ExtClassLoader,其作用是加载Java的扩展类,位于$JAVA_HOME/jre/lib/ext目录下
  • 应用程序类加载器Application ClassLoader):用Java语言实现的AppClassLoader,是距离开发者最近的类加载器,可以通过ClassLoader.getSystemClassLoader方法获取到它,用于加载应用程序classpath目录和系统环境变量java.class.path指定目录下的类

可以在代码中通过class.getClassLoader()获取到当前的类加载器,类加载器之间存在委托关系,因此可以遍历取出其双亲(Parent)加载器,代码如下。

public class ClassLoaderDemo {
    public static void main(String[] args) {
        ClassLoader cl = ClassLoaderDemo.class.getClassLoader();
        while (cl != null) {
            System.out.println("ClassLoader: " + cl);
            cl = cl.getParent();
        }
    }
}

控制台输出结果如下,可见当前生效的类加载器及其委托关系。由于ExtClassLoader的双亲加载器是BootstrapClassLoader,其由c/c++实现,故在Java代码中无法获得它的引用。

AppClassLoader@vi9g71ng
ExtClassLoader@jb0b8ang

注意在上文中我用了“委托”而不是“继承”,这是因为加载器内部采用的是委托机制,委托顺序如下图:

Android 插件化原理浅析

  • 左侧是JDK中实现的类加载器,通过parent属性形成父子关系。应用中自定义的类加载器的parent都是AppClassLoader
  • 右侧是JDK中的类加载器实现类。通过类继承的机制形成体系。未来我们就可以通过继承相关的类实现自定义类加载器

自定义类加载器

在一些特殊需求场景下需要自定义类加载器,例如从网络上获取到一个加密后的.class文件,此时通过自定义类加载器,将字节流解密后,再创建运行时的类对象。

双亲委托模式

是Java类加载机制最核心的知识,描述了在加载器层级结构中如何正确地找到目标类加载器的过程。文字描述如下:

  1. JVM产生加载某个类的需求
  2. 首先检索本级缓存,判断这个类是否已经加载过,如果加载过的话则直接返回加载过的对象
  3. 如果没有加载过,则看当前的类加载器是否存在parent(双亲),如果存在,则交由双亲检索
  4. 不断向上追查祖先,直到最古老的祖先,然后由该祖先进行查找,同样会查找当前该层级的缓存
  5. 如果祖先查找不到,则交回儿子加载器查找
  6. 直到返回当前加载器,触发其findClass进行查找

其实这个过程与Android UI中的事件分发处理机制相似度达到99%,都是将事件(加载、触摸)层层向内/向上传递,一直传递到尽头后尝试进行消费,如果消费成功则终止,消费不成功则反向传回,逐级消费。

一图胜千言。

Android 插件化原理浅析

双亲委派模式的优点

  • 效率:加载过一次的类会进行缓存,提升下次使用的速度
  • 安全:所有加载任务都会层层传递给系统的类加载器,Java、Android的核心类都是由系统加载的,防止攻击者篡改这些核心类
  • 解耦:不同层级的加载器负责不同范围的类的加载,职责清晰,易于理解和维护

判断两个类相同

需要同时满足:

  • 类名相同
  • 完整包名相同
  • 加载器是同一个

Android中的类加载

有了上文的基础,直接用结构图表示,其中BootstrapClassLoaderPathClassLoader是必不可少的,其它类加载器由使用者自行定义。

Android 插件化原理浅析

资源加载机制

在讨论资源时,通常指的是assets目录、res目录下的drawablelayoutcolorstring等。

例:设置ImageView的资源

以设置ImageView的src为例,有两种方法,分别是在布局文件中直接指明android:src="@drawable/some_image.png",和在Java代码中调用ImageView.setImageResource()

在布局文件中声明

其函数调用链为:

  1. ImageView: constructor
  2. TypedArray: getDrawable
  3. Resources: getDrawable
  4. ResourcesImpl: loadDrawableForCookie

通过setImageResource()设置

  1. ImageView: setImageResource -> resolveUri
  2. Context: getDrawable
  3. Resources: getDrawable
  4. ResourcesImpl: loadDrawableForCookie

上述两个方式最终都会调用到ResourceImpl.loadDrawableForCookie

private Drawable loadDrawableForCookie(...) {
    ...
    if (file.endsWith(".xml")) {
        final String typeName = getResourceTypeName(id);
        if (typeName != null && typeName.equals("color")) {
            dr = loadColorOrXmlDrawable(wrapper, value, id, density, file);
        } else {
            dr = loadXmlDrawable(wrapper, value, id, density, file);
        }
    } else {
        // 通过AssetsManager加载系统资源流文件
        final InputStream is = mAssets.openNonAsset(
                value.assetCookie, file, AssetManager.ACCESS_STREAMING);
        final AssetInputStream ais = (AssetInputStream) is;
        dr = decodeImageDrawable(ais, wrapper, value);
    }
    ...
}

通过上述分析可知,获取资源的方法调用链是:

  • Context->Resources->AssetManager->NativeAssetManager

Android 插件化原理浅析

这个流程中的关键概念阐释如下:

  • Resources: 由Context.getResources返回,通过它可以获取APP各项资源,是供我们直接调用的接口。当APP启动时随着Context实例而创建
  • ResourcesImpl: 由ResourcesManager创建,它是Resources的具体实现,与Resources绑定,持有一个AssetsManager对象
  • AssetsManager: 关键类,它在初始化时读取了当前APP的资源表resource.arsc文件,并调用addAssetPath(String path)将资源文件通过路径进行加载。在APP进程初始化时就伴随着AssetsManager的创建

AssetsManager读取资源顺序

AssetManager的初始化代码位于Native层的AssetManager.cpp中,初始化时分别执行以下任务:

  1. 加载系统的framework-res.apk,导入系统资源,如以android.开头的color等
  2. 传入apk文件时,查找其内部的resources.arsc进行加载
  3. 将解析到的资源添加到mResources进行维护,它是位于Native层的资源表

这样,通过Context.getResources就可以拿到上文中在Native层维护的mResources资源表,进而通过Key查找相应资源。

在以上理论基础上,进行插件化的实践。

插件化的实践

在应用插件化思想的过程中,需要解决以下三个难点:

  • 类文件注入,把插件dex插入到ClassLoader类加载器中
  • Activity组件的注册,从而将目标页面的生命周期与系统绑定
  • 资源注入,让宿主、插件的资源实现互相访问

加载类

自定义PluginClassLoader

新建一个PluginClassLoader,用来加载插件中的类。

public class PluginClassLoader extends BaseDexClassLoader {
    public DexClassLoader(
        String dexPath,
        String optimizedDirectory,
        String librarySearchPath,
        ClassLoader parent) {
            // ...
        }
}

构造函数中的4个参数说明如下:

  • dexPath: 需要加载的dex/jar/apk路径,对于assets目录下的插件包,需要将其复制到应用目录下
  • optimizedDirectory: ART虚拟机对dex优化后生成odex的存放位置
  • librarySearchPath: so文件位置
  • parent: 双亲类加载器

将PluginClassLoader实例化后,调用其loadClass(clazzName)函数就可以加载插件中的类。

private fun extractPlugin() {
    val inputStream = assetts.open("plugin.apk")
    File(filesDir.absolutePath, "plugin.apk").writeBytes(inputStream.readBytes())
}

private fun init() {
    extractPlugin()
    pluginPath = File(filesDir.absolutePath, "pluginapk").absolutePath
    nativeLibDir = File(filesDir, "pluginlib").absolutePath
    dexOutPath = File(filesDir, "dexout").absolutePath
    pluginClassLoader = PluginClassLoader(pluginPath, nativeLibDir, dexOutPath, this::class.java.classloader)
}

通过反射生成Activity对象

val loadClass = pluginClassLoader.loadClass(activityName)
loadClass.getMethod("test", null).invoke(loadClass)

注册组件

通过上一章节自定义的PluginClassLoader,我们可以创建插件APK中的Activity对象并调用其方法,然而,这只是把它当成普通Java对象来使用,对于Android系统而言,我们当然希望AMS能够识别我们所创建的Activity实例,自动调用其生命周期相应方法。因此,需要将上一步创建的Activity与AMS的生命周期逻辑绑定。

要实现上述目的,通常有两种方法:

  • Manifest中预先插桩
    • 优点:实现简单,对Android各个版本兼容性好
    • 缺点:代码侵入量大,需要借助中间类来中转双向(宿主-插件之间)方法调用;插桩Activity数量不好控制
  • Hook系统启动Activity时的检查过程
    • 优点:无缝接入,不需要中转Activity
    • 缺点:Android不同版本的检查逻辑不同,有兼容性风险

这里我们选用插桩的方式进行实现,首先在宿主中声明一个StubActivity,它主要有2个职责:

  • AndroidManifest.xml文件中注册自身,从而可以被AMS生命周期识别并调用到
  • onCreate时从Intent中接收传递来的参数对象PluginInfo(name, apkPath, activityName),以便通过PluginClassLoader进行自定义创建

相对应的,如果要把插件Activity的各项系统调用转发回系统,也需要借助一个插件Activity的基类PluginBaseActivity,它的职责是:

  • 将插件Activity的各个系统调用转发给StubActivity,使诸如setContentView等系统接口调用对插桩类生效。因为在系统眼中此时前台的应当是插桩类StubActivity

StubActivity.java

public class StubActivity extends AppCompatActivity {
    private PluginBaseActivity mPluginBaseActivity;
    
    @Override
    protected void onCreate(final Bundle savedInstanceState) {
        String pluginActivityName = getIntent().getString("activityName", "");
        mPluginBaseActivity = PluginLoader.loadActivity(pluginActivityName, this); // 加载插件APK并通过自定义ClassLoader创建插件Activity实例
        if (mPluginBaseActivity == null) {
            super.onCreate(savedInstanceState);
            return;
        }
        mPluginBaseActivity.onCreate(); // 其它生命周期调用同理
    }
}

@Override
protected void onResume() {
    if (mPluginBaseActivity == null) {
        super.onResume();
        return;
    }
    mPluginBaseActivity.onResume();
}

PluginBaseActivity.java,插件Activity应当是它的子类

public class PluginBaseActivity {
    private StubActivity mStubActivity;

    public StubActivity(StubActivity stubActivity) {
        mStubActivity = stubActivity;
    }

    @Override
    public <T extends View> T findViewById(int id) {
        return mStubActivity.findViewById(id);
    }
    // ... 其它各项系统调用同理
}

字节码替换工具:Shadow

Shadow是腾讯开源的一款插件化框架,其实现思路就是StubActivity插桩,它提供了字节码替换功能,可以在编译阶段自动将插件中的Activity继承关系改为PluginBaseActivity。其实现原理是借助Gradle的Transform Api,在字节码生成后、dex文件创建前,对生成的字节码进行修改。这样一来,插件开发者只需要按照传统方式开发Activity代码,无须手动继承PluginBaseActivity

自动修改前,SamplePluginActivity.kt

class SamplePluginActivity : Activity() {}

自动修改后,SamplePluginActivity.kt

class SamplePluginActivity : PluginBaseActivity() {}

加载资源

可访问插件资源的Resources对象

通过上文分析,我们知道APP运行时,通过Context.getResources获取到一个全局的Resources对象,其内部封装了ResourcesImpl进行资源查找,ResourcesImpl则与AssetsManager绑定,后者会关联到文件系统中具体的APK。

因此,资源注入的思路是,对插件APK构建其Resources对象,然后就可以实现在PluginBaseActivity中提供借助这个Resources对象查找插件中的资源。

PluginLoader.java

public Resources getPluginResources() {
    PackageManager packageManager = applicationContext.getPackageManager();
    // 1. 通过APK路径,获取其PackageInfo
    PackageInfo pi = packageManager.getPackageArchiveInfo(
        pluginApkPath,
        PackageManager.GET_ACTIVITIES
        |PackageManager.GET_META_DATA
        |PackageManager.GET_SERVICES
        |PackageManager.GET_PROVIDERS
        |PackageManager.GET_SIGNATURE
    );
    pi.applicationInfo.sourceDir = pluginApkPath;
    pi.applicationInfo.publicSourceDir = pluginApkPath;
    
    // 2.创建插件Resources对象
    Resources pluginResources = packageManager.getResourcesForApplication(pi.applicationInfo);
}

随后将pluginResources与宿主的Resources进行合并,从而让插件的代码也可以访问到宿主当中的资源。

PluginResources.java,用于从插件/宿主中获取资源

public class PluginResources extends Resources {
    private Resources hostResources;
    private Resources pluginResources;

    // 这里传入的是上一步通过插件APK生成的Resources对象
    public PluginResources(Resources hostResources, Resources pluginResources) {
        super(pluginResources.getAssets(), pluginResources.getDisplayMetrics(), pluginResources.getConfiguration());
        this.hostResources = hostResources;
        this.pluginResources = pluginResources;
    }

    // 先从插件中获取,如果获取不到,就从宿主当中找
    @Override
    public String getString(int id, Object... formatArgs) throws NotFoundException {
        try {
            return pluginResources.getString(id, formatArgs);
        } catch (NotFoundException e) {
            return hostResources.getString(id, formatArgs);
        }
    }

    // ...
}

双重查找资源

最后在宿主插桩StubActivity中,使用包装后的PluginResources提供资源查找服务,这样,即使在插件Activity中调用,也可以借助PluginBaseActivity转发给插桩的StubActivity,从而实现资源注入。

StubActivity.java

public class StubActivity extends Activity {
    private Resources mPluginResources;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        // ...
        mPluginResources = new PluginResources(super.getResources(), PluginLoader.getResources(pluginApkPath));
        // ...
    }

    @Override
    public Resources getResources() {
        if (mPluginResources == null) {
            return super.getResources();
        }
        return mPluginResources;
    }
}

其它组件的插件化

除了Activity,再简单讲述下其它3个组件的插件化实现思路,只提供概念上的指引,不做过多讲解。

Service插件化

和Activity的相同点是,先在Manifest文件中插桩StubService,插件中的Service就可以借尸还魂。但是,与Activity有所不同,作为桩子的StubActivity可以提供给多个插件Activity使用,多次去start同一个StubActivity,系统中就会存在多个StubActivity实例。而于Service而言,多次去start同一个StubService,系统中不会增多实例,所以因此需要准备多个StubService,以备插件中存在多个Service的情况。

Android 插件化原理浅析

BroadcastReceiver插件化

与前两者有所不同,BroadcastReceiver分为动态广播与静态广播。

动态广播

不需要与AMS交互,使用ClassLoader方案进行加载即可。

静态广播

附加了IntentFilter信息,无法通过插桩的方式进行预站位,实现思路是通过PackageManager取出Manifest文件中声明的静态广播,将其注册为动态广播。

ContentProvider插件化

与BroadcastReceiver处理方法类似,通过PackageParser解析Manifest文件中注册的ContentProvider,然后调用ActivityThread.installContentProviders函数,把它们注册在宿主APP中。

有两点需要注意:

  • 注册时机:APP安装自己的ContentProvider是在进程启动时候进行,比Application的onCreate还要早,所以我们要在Application的attachBaseContext方法中手动执行上述操作
  • 转发机制:让外界App直接调用插件的App,并不是一件特别好的事情,因为插件的稳定性欠佳。最好是由App的ContentProvider作为中转。因为字符串是ContentProvider的唯一标志,转发机制就特别适用

参考资料

转载自:https://juejin.cn/post/7357301805569441803
评论
请登录