RePlugin插件化实践

RePlugin的开源地址:https://github.com/Qihoo360/RePlugin
官方介绍:https://github.com/Qihoo360/RePlugin/blob/dev/README_CN.md
实现Demo:https://github.com/Jarrywell/RePluginDemo

背景

今年(2018年)的Google I/O大会上不仅发布了开发者热捧的Jetpack组件,还发布了另一个大家不太注意却是重量级的功能——Android App Bundle,它主要功能是让App可以以功能模块为粒度来发布迭代版本,用户在手机上首次使用时仅安装一个基本的apk即可,其他功能模块可按需去动态下载。以此方式来缩减初始安装apk的大小和缩短下载安装时间。只不过这个功能需要与Google Play配合使用,导致国内开发者大都直接忽略了该功能。

这里提到Android App Bundle主要是因为该功能跟去年在国内流行的插件化开发很是接近,容易让人联想到这可能是官方提供的一个插件化方案(插件化要转正了?)。而且插件化在沉淀了一段时间后,大都相对比较成熟了(实现上还是存在差异),甚至是一直为大家所诟病的稳定性(需要Hook系统的类,适配艰难)难题也已解决:一些框架已经实现了只需要Hook一个点(仅仅Hook ClassLoader)的方案了(RePlugin),甚至还有宣称完全不需要Hook的方案(Phantom)出现。

刚好最近公司项目也在做apk大小的优化,需求点主要是由于接入了越来越多的第三方功能性的sdk导致项目臃肿不堪。因此想到使用插件化的方案来让一些附加功能模块实现动态加载,因此来补补插件化的课,总结一下实践中碰到的问题。

插件化的应用场景

迭代中使用插件化开发模式时,可使得项目具备如下几个优势:

  • 项目可进行模块化的拆分。使得宿主和插件之间更低的耦合度,以便实现模块化开发(当然组件化方案也是为了实现这种低耦合度的,只不过它关注的是编译期的模块化,而插件化关注的是运行时的模块化)。
  • 提高开发效率。插件可单独开发、编译、调试(特别是项目体积较大整体编译需要花费一分钟以上的时间时效果比较明显)。
  • 实现热修复功能。插件可单独发布,使得线上BUG的解决可以达到”热修复”的效果(有些需要重启进程才可以)。
  • 减小Apk的体积。用户在安装Apk时可以只下载安装一个基本的apk(一些功能模块在插件中),后续再按需下载插件。

注:其实插件化的这几种优势,Android App Bundle也是实现了这几个功能而已,可以说功能上很接近了。

目前插件化的基本实现原理

插件化的核心功能是App能在运行时动态的去加载和运行插件内容。但由于宿主和插件是完全分离的两个Apk(分开编译),那怎样实现让一个Apk(宿主)去加载另一个Apk(插件)的内容呢?怎样让插件的代码(包括四大组件)运行起来呢?这就是插件化方案需要实现关键点。综合来看,若要让插件的特性和原生App尽可能保持一致的话,大致需要实现以下几点才能达到目的:

  • 插件class的加载
    目前常见的几个框架都使用了DexClassLoader来加载插件,因为DexClassLoader带有一个optimizedDirectory目录参数(这个路径参数是用来保存解压的dex文件),导致它天生可以加载外部的JarApkdex。其实最开始使用DexClassLoader来加载其他class的方案仍然还是谷歌提供的思路(使用Multidex加载apk中的非主dex,来解决方法数超过65535的问题),可以看出这里的思路就是借鉴了Multidex的经验来实现的。
    另外,class加载的结果一般存在两种形式:一种是将插件的class合并到宿主中(宿主和插件共用一个ClassLoader),另一种是插件使用单独的ClassLoader(宿主和插件不共用),这两种形式各有各的优势和劣势,后面再细说。另外,宿主还要能加载到插件中的class(是实现插件的基本要求),目前常见的有下面四种形式:
    1、就是上面提到的直接把插件中的类合并到宿主的ClassLoader中。(VirtualAPK的实现)
    2、Hook住宿主ApplicationmPackageInfo中负责类加载的PathClassLoader,将其替换为我们自定义的类加载器(RePluginClassLoader),其实它只是一个代理,最终的加载动作会路由到对应插件的ClassLoader中去加载。(RePlugin的方式)
    3、利用类加载器的双亲委派机制(在加载一个类时首先将其交给父加载器去加载,父加载器没有找到类时才自己去加载)的特性:改变宿主App的ClassLoader委派链,将一个自定义的ClassLoader(MoClassLoader)嵌入到parent中,使得委派链由PathClassLoader->BootCalssLoader变为 PathClassLoader->MoClassLoader->BootCalssLoader(需要hook一下ClassLoaderparent成员变量),这样通过宿主PathClassLoader去加载的类会最终交给MoClassLoader去加载了,MoClassLoader也是相当与一个路由的作用。(MoPlugin的实现)
    4、不处理宿主与插件ClassLoader的关联,调用方手动指定ClassLoader去加载。(Phantom的实现)

  • 插件资源的加载
    即对插件Apk中 Resources对象的实例化,后续对插件资源的访问都需要经由这个Resources对象,这里的实现也有多种方式:有的是通过反射调用AssetManager.addAssetPath()将插件的目录添加到插件资源对应的AssetManager中,然后以这个AssetManager手动创建一个新的Resources对象(VirtualAPK的实现方式,需要Hook)、有的使用开放的API PackageManager.getPackageArchiveInfo()获取PackageInfo,然后通过PackageManager.getResourcesForApplication(PackageInfo.applicationInfo)来让PMS帮助创建Resources对象(RePlugin的实现方式,不需要Hook)。
    最终的Resources资源也存在两种形式:一种是和宿主合并,另一种是插件独立的Resources,也各有优缺点,这里可以参见RePlugin对资源合并和不合并带来优缺点的说明:详细连接。

  • 运行插件中的四大组件及生命周期
    这一步是插件化中的难点,而且要实现这一步上面两步是必要的基础(必须能加载到对应的类和资源嘛),然而Android系统规定要运行的四大组件必须在Manifests文件中明确注册才能够运行起来(AMS会对注册进行校验,启动未注册的组件会抛出异常),因此如何让动态加载的插件在Manifets中注册成为了拦路虎。
    目前插件化框架基本上都使用了以占坑的方式预先在宿主的Manifests中埋入四大组件坑位,在加载时将插件中的四大组件与坑位进行映射的方式来解决AMS对插件中四大组件的校验问题(也有直接启动坑位组件实现的--Phantom方案)。
    当然最终四大组件的运行实现也各不相同,其中尤以Activity的实现最为复杂(它涉及到的属性较多),有的是Hook系统的IActivityManager或者Instrumentation来实现(VirtualAPK的实现)、有的是模拟系统AMS启动Activity的方式来实现(先找坑位、再启动对应进程、最后在该进程中启动插件Activity,RePlugin的实现)、有的是直接启动坑位然后在坑位的生命周期中处理插件的生命周期(Phantom的实现)。

  • 实现多进程(宿主和插件可能运行在不同的进程中,有跨进程交互的需求)
    由于android多进程的实现方式是通过在Manifests中注册四大组件时显式指定android:process=xx来实现的,而插件中的四大组件只能通过预埋在宿主中的坑位来映射加载,这就给坑位的多进程预埋提出了更复杂的要求(插件中运行在哪个进程与坑位对应起来,还要考虑启动模式的组合),因此大多数框架都不支持四大组件的多进程坑位(尤其是Activity的多进程坑位)。目前看到的只有RePlguin比较完美的实现了多进程(它是模拟了AMS启动app的流程,在启动组件前,先使用PluginProcessMain启动映射的进程,参见上一步说明)。

  • 插件与宿主的交互,包括插件中普通类的调用、资源的使用
    虽然宿主和插件之间的形态是低耦合的,但从产品的角度来看,模块与模块之间当然应该存在调用关系(不然怎么联系起来呢,这里指的是类之间的调用而不仅仅是四大组件的启动),因此插件与宿主之间、插件与插件的仍然会有一些必要的耦合。
    另外,这里相互调用的便利程度就取决于前面步骤中插件的类和资源是否有与宿主合并了,因为要是合并了的话,类和资源都在一个ClassLoaderResources中,这样调用者便可以直接访问了,只不过合并会导致一些类和资源的冲突问题,因此有些框架并没有选择合并的方式;如果不合并的话,在调用类或资源之前,就必须先去获取插件对应的ClassLoaderResources对象才能继续调用,这里便会增加使用的难度,特别是插件中使用了第三方的sdk时问题会更加严重(这里的实现一般会使用动态编译去替换或者重写ActivitygetResource()等函数实现)。

  • 插件中so库的调用
    安装插件(一般是指将插件解压到特定的目录)时会将插件Apk进行解压并释放文件。但如果涉及到so库话,那该释放哪种ABI类型的so库呢?这里涉及宿主进程是32位还是64位的判断问题,因此插件化框架一般都是读取宿主的ABI属性来考虑插件的ABI属性。导致这里会存在插件so库可能与宿主不同ABI的so库混用的可能(比如,宿主放的是64位的,而插件放了32位的,则会出现混用的可能),最终导致so的加载失败。

由于项目选用的插件化框架是RePlugin(考察了现在仍在更新的几个插件化方案与项目切合度做出的决定,主要原因是它仅Hook了一个点、并且支持多进程),因此下面的介绍均以RePlugin为示例,并附带与其他框架的比较说明

简单示例说明

在接入RePlugin时会发现它总共提供了4个库分宿主和插件项目要分别接入,下面先说明一下这几个库的功能,来大致了解它们在其中分别做了什么事情:

  • replugin-host-gradle: 宿主接入的gradle插件,主要任务是在编译期间根据在build.gradle中配置的repluginHostConfig信息在Manifests中生成坑位组件信息;动态生成RePluginHostConfig的配置类;扫描项目assets中放置的内置插件生成builtin.json
    注:该gradle插件没有考虑到用户会自定义Build Variant的情况或者在一个单独的module中接入插件的情况,从接入过程来看这个情况还是比较普遍的,如果要适配这中情况只能将源码下下来自己修改下。
  • replugin-host-lib:宿主接入的一个java sdk,插件化核心功能都在这里实现,负责框架的初始化、加载、启动和管理插件等。
  • replugin-plugin-gradle:插件工程接入的gradle插件,主要功能是使用javassist在编译期间动态去替换插件中的继承基类,如修改Activity的继承、Provider的重定向等。
  • replugin-plugin-lib:插件工程接入的java sdk,功能主要是通过反射调用宿主工程中replugin-host-lib的相关类(如RePluginRePluginInternal提供的接口,内部实现都是反射),以提供“双向通信”的能力。

具体的接入细节步骤这里就不做过多介绍了,毕竟这不是一篇入门教程,且官方wiki已经有非常详细、明确的说明了,或者也可以参考我的Demo工程。这里主要是想记录下实际接入过程中的使用的一些感想和阅读源码时的一些理解。下面的内容都是假设宿主工程和插件工程都已经配置好跑起来了的前提下介绍的。我们先来看一个简单的启动插件Activity的示例:

/**
 * 通过RePlugin提供的接口createIntent()创建一个指向插件中的activity的intent
 * 内部实现就是创建了个ComponentName,只不过它的包名被插件名给替代了
 */
final String pluginName = "plugin1";
final String activityName = "com.test.android.plugin1.activity.InnerActivity";
Intent intent = RePlugin.createIntent(pluginName, activityName);

/**
 * 使用RePlugin提供的接口startActivity()来启动插件activity
 * 若在指定的插件中没有找到对应的activity,则会回调
 * 到接口RePluginCallbacks.onPluginNotExistsForActivity()
 */
RePlugin.startActivity(MainActivity.this, intent);

相信大部分插件框架给的第一个示例都是这样去启动一个插件中的Activity来展示。不过通过这里的简单示例我们能看出几个要点:

  • 这里的startActivity()并没有使用原生的Activity.startActivity()或者Context.startActivity()(VirtualAPK能直接调用)而是调用了RePlugin自己封装的接口。这里这样实现的主要是因为RePlugin为了做到唯一Hook点而没有像大部分框架的实现方式那样去Hook系统的跳转接口,所以只能退一步让开发者去调用额外的接口去启动插件了,虽然这里增加了学习成本,但大体的接口用法和原生是一致的(差别只是在创建ComponentName时使用插件名而不是平常的包名),而且还支持action的隐式启动。
  • 上述示例中展示的是在宿主中启动插件中的Activity,我们可以手动调用RePlugin封装的startActivity()接口。但如果是在插件中启动其他Activity(包括在其他插件中和其他进程中的Activity)呢?(RePlugin的宗旨是插件的开发要像原生开发一样)或者是一个插件中接入了第三放sdk,然后sdk内部有启动Activity的需求,我们没法主动去调RePlugin的接口,该怎么适配这种情况呢?RePlugin主要做了两种情况的适配:
    第一种情况:如果插件是通过Activity.startActivity()启动其他Activity的,前面有提到过插件工程需要接入replugin-plugin-gradle插件,他会在编译期间去替换Activity的继承关系为PluginActivity,它重写了方法startActivity()来实现即便调用原生的方法也会给你转向到RePlugin的方法:
PluginActivity

@Override
public void startActivity(Intent intent) {
    if (RePluginInternal.startActivity(this, intent)) {
        return;
    }
    super.startActivity(intent);
}

@Override
public void startActivityForResult(Intent intent, int requestCode) {
    if (RePluginInternal.startActivityForResult(this, intent, requestCode)) {
        return;
    }
    super.startActivityForResult(intent, requestCode);
}

RePluginInternalstartActivity() 方法通过反射最终还是调用到了RePlugin.startActivity()的实现方法。
第二种情况:如果是通过调用Context.startActivity()来启动其他Activity的呢?这里的适配主要是在PluginContext中实现重写startActivity(),具体实现跟PluginActivity重写方法大体是一样的。为什么适配PluginContext的方法就能替换原生的方法呢?因为插件中的Context实例要么是通过Activity.getContext()获取,要么是通过getApplicationContext()获取的,只要这两处地方拿到的ContextPluginContext就可以实现了,分别看下源码,PluginActivity中替换Context的代码是在attachBaseContext()中,这个方法会在ActivityonCreate()执行之前就会调用:

PluginActivity
@Override
protected void attachBaseContext(Context newBase) {
    /**
     * 这里是通过反射到宿主中的Factory2.createActivityContext()去
     * 查询获取插件在加载是构造的PluginContext对象
     */
    newBase = RePluginInternal.createActivityContext(this, newBase);
    super.attachBaseContext(newBase);
}

ApplicationContext的替换是在加载对应插件时通过PluginApplicationClient的方法替换的:

PluginApplicationClient
private void callAppLocked() {
    //...
    /**
     * 创建插件对应的Application实例
     */
    mApplicationClient = PluginApplicationClient.getOrCreate(
            mInfo.getName(), mLoader.mClassLoader, mLoader.mComponents, 
    mLoader.mPluginObj.mInfo);

    /**
     * 模拟AMS启动app时,会先调用Application的attachBaseContext()和onCreate()方法
     */
    if (mApplicationClient != null) {
        /**
         * 注意这里传进去的的Context是在加载插件时创建的PluginContext
         */
        mApplicationClient.callAttachBaseContext(mLoader.mPkgContext);
        mApplicationClient.callOnCreate();
    }
}

其中mLoader.mPkgContext就是PluginContext。以上两种情况的实现就做到了不需要Hook系统的方法就能实现启动插件中Activity了。

  • 还有就是RePlugin的自定义方法startActivity()要做到坑位与插件Activity的映射启动,这里涉及到的东西比较多,比如:插件的加载、进程id的分配、坑位的分配等,这里先不展开讲了,后续再作说明。

为什么RePlugin需要这个唯一Hook点

简单说来,它主要是处理四大组件(以Activity为例)的坑位替换问题:用已经在Manifests中注册了的坑位Activity去骗过AMS的校验,然后在启动流程后续阶段具体实例化对应Activity时去替换为坑位的组件(Instrumentation.newActivity()去实例化Activity)。我们看下Activity的启动流程中校验Manifests注册和创建Activity的两步:
第一步、校验Manifests的注册:

Activity
public void startActivityForResult(@RequiresPermission Intent intent, int requestCode,
    @Nullable Bundle options) {
    if (mParent == null) {
        //...
        /**
         * 通过Instrumentation.execStartActivity()启动Activity
         */
        Instrumentation.ActivityResult ar =
            mInstrumentation.execStartActivity(
                this, mMainThread.getApplicationThread(), mToken, this,
                intent, requestCode, options);
        //...
    } else {
       //...
    }
}
Instrumentation
public ActivityResult execStartActivity(
    Context who, IBinder contextThread, IBinder token, String target,
    Intent intent, int requestCode, Bundle options) {
    //...
    try {
        intent.migrateExtraStreamToClipData();
        intent.prepareToLeaveProcess(who);

        /**
         * 调用AMS去启动对应的Activity, 并返回启动结果result
         */
        int result = ActivityManager.getService()
            .startActivity(whoThread, who.getBasePackageName(), intent,
                intent.resolveTypeIfNeeded(who.getContentResolver()),
                token, target, requestCode, 0, null, options);

        /**
         * 这里通过result检测出错的结果
         */
        checkStartActivityResult(result, intent);
    } catch (RemoteException e) {
        throw new RuntimeException("Failure from system", e);
    }
    return null;
}

public static void checkStartActivityResult(int res, Object intent) {
    //..
    switch (res) {
        case ActivityManager.START_CLASS_NOT_FOUND:
            /**
             * 这里提示没有在Manifest中注册
             */
            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?");
            throw new ActivityNotFoundException(
                "No Activity found to handle " + intent);
       //...
}

ActivityManager.getService().startActivity()的返回值result就是AMS的校验结果,在下面函数checkStartActivityResult()中通过抛异常的方式反馈错误结果。因此,只要保证走到这一步之前传递的Activity都是坑位Activity即可正常跑通。因此到该阶段为止,插件框架中传递的都是坑位信息。接下来看看startActivity()的后需阶段。

第二步、创建Activity

ActivityThread
private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) {
    //...
    ComponentName component = r.intent.getComponent();

    //...
    ContextImpl appContext = createBaseContextForActivity(r);
    Activity activity = null;
    try {
        /**
        * 在RePlugin中,这里获取的ClassLoader已经被替换成了RePluginClassLoader
        * newActivity()通过ClassLoader和ClassName构建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);
        }
    } catch (Exception e) {
        if (!mInstrumentation.onException(activity, e)) {
            throw new RuntimeException(
                "Unable to instantiate activity " + component
                    + ": " + e.toString(), e);
        }
    }
    //...
    return activity;
}

其中mInstrumentation.newActivity()传递了一个ClassLoader,这个ClassLoader就是宿主App中的PathClassLoader,如果我们把它早早的替换成RePluginClassLoader,那下面的Activity加载最终就会走到我们自定义的RePluginClassLoader中去了:

Instrumentation
public Activity newActivity(ClassLoader cl, String className, Intent intent)
    throws InstantiationException, IllegalAccessException, ClassNotFoundException {
    return (Activity)cl.loadClass(className).newInstance();
}

于是在这里我们便可以在RePluginClassLoader.loadClass()中通过某种映射关系替换掉坑位Activity实例化一个我们插件中的Activity。具体实现见如下类:
attachBaseContext()中Hook宿主的PathClassLoader

//RePlugin
public static void attachBaseContext(Application app, RePluginConfig config) {
    //...
    PMF.init(app);
    //...
}

//PMF
public static final void init(Application application) {
    //...
    PatchClassLoaderUtils.patch(application);
}

//PatchClassLoaderUtils
public static boolean patch(Application application) {
    // 获取Application的BaseContext (来自ContextWrapper)
    Context oBase = application.getBaseContext();

    Object oPackageInfo = ReflectUtils.readField(oBase, "mPackageInfo");

    // 获取mPackageInfo.mClassLoader
    ClassLoader oClassLoader = (ClassLoader) ReflectUtils.readField(oPackageInfo, "mClassLoader");

    // 外界可自定义ClassLoader的实现,但一定要基于RePluginClassLoader类
    ClassLoader cl = RePlugin.getConfig().getCallbacks().createClassLoader(oClassLoader.getParent(), oClassLoader);

    // 将新的ClassLoader写入mPackageInfo.mClassLoader
    ReflectUtils.writeField(oPackageInfo, "mClassLoader", cl);

    Thread.currentThread().setContextClassLoader(cl);
}

注:另一种方式就是利用ClassLoader的双亲委派模型将宿主的类加载器由PathClassLoader->BootCalssLoader 变为 PathClassLoader->MyClassLoader->BootCalssLoader。所有需要通过PathClassLoader加载的类都让其父加载器MyClassLoader去加载也能达到目的。(MoPlugin的实现)

然后在后面去加载类时便能路由到RePluginClassLoader中处理,具体加载细节可以参看代码中的注释:

//RePluginClassLoader
//这里className是要替换的类
protected Class loadClass(String className, boolean resolve) throws ClassNotFoundException {
    Class c = null;
    /**
     * 这里最终调用PmBase.loadClass()去加载插件类
     */
    c = PMF.loadClass(className, resolve);
    if (c != null) {
        return c;
    }
    //...
    return super.loadClass(className, resolve);
}


//PmBase
final Class loadClass(String className, boolean resolve) {

    /**
     * 通过坑位Activity找映射的插件Activity
     */
    if (mContainerActivities.contains(className)) {
        Class c = mClient.resolveActivityClass(className);
        if (c != null) {
            return c;
        }
        //....
    }

    /**
     * 通过坑位Service找映射的插件Service
     */
    if (mContainerServices.contains(className)) {
        Class c = loadServiceClass(className);
        if (c != null) {
            return c;
        }
        //...
    }

    /**
     * 通过坑位provider找映射的插件provider
     */
    if (mContainerProviders.contains(className)) {
        Class c = loadProviderClass(className);
        if (c != null) {
            return c;
        }
        //...
    }

    /**
     * 通过动态注册的类映射插件的类
     */
    DynamicClass dc = mDynamicClasses.get(className);
    if (dc != null) {
        final Context context = RePluginInternal.getAppContext();
        PluginDesc desc = PluginDesc.get(dc.plugin);
        //...
        Plugin p = loadAppPlugin(dc.plugin);
        if (p != null) {
            try {
                Class cls = p.getClassLoader().loadClass(dc.className);
                //...
                return cls;
            } catch (Throwable e) {
            }
        }
        return dc.defClass;
    }
}

因此如果插件实现不需要进行坑位替换和映射的话,那么也可以不去做这个点的Hook操作,比如前面提到的那个Phantom框架就没有Hook这里。

上面PmBase.loadClass()函数中还有一个比较重要的注意点——DynamicClass,它定义的是一个普通类(非四大组件)的映射关系,应用场景是不能手动通过插件ClassLoader去加载类的场景(这里也是大部分框架没有考虑到的地方),比如:插件中的自定义ViewFragment等需要在宿主的xml中使用。使用方法如下:

/**
 * 定位到插件中要注册类的位置(插件名+类名)创建一个ComponentName
 */
ComponentName target = RePlugin.createComponentName("plugin1", 
    "com.test.android.plugin1.fragment.Plugin1Demo1Fragment");
/**
 * 调用registerHookingClass()函数将需要替换的类与插件中的类做一个映射,后面如果再来找目标类时
 * 则会去对应插件中去找
 */
RePlugin.registerHookingClass("com.test.android.host.Plugin1Fragment", target, null);

/**
 * 这样在xml中就能直接写目标类了,比如这里的一个定义在xml中的fragment
 */

以上代码的说明其功能均是为了给插件的类与宿主做映射用的。还有就是上面提到了组件坑位的映射和替换,那具体RePlugin是怎么映射的呢?
其主要实现在PluginContainers类中实现,由于这里实现最为复杂,使用文字描述简化其过程:

  1. 请求分配坑位。
  2. 调度到常驻进程并通过组件的android:process=xx匹配到映射进程,常驻进程此时再拉起一个Provider来启动对应新进程,并返回一个插件进程的Binder
  3. 插件进程启动时从常驻进程加载登记表(坑和目标activity表)。
  4. 插件进程从登记表中匹配坑位组件。
  5. 请求者发起startActivity()请求,参数为坑位组件。

Service、Provider的处理

这两个组件由于属性较少(一般只涉及到多进程属性android:process=xxx)且生命周期比较简单,因此RePlugin对这两个组件的实现采用了直接构建对应插件ServiceProvider)实例然后手动调用其生命周期函数。当然为了适应Android对app进程的管理(参考LMK策略),RePlugin也还是会在对应的进程中运行一个坑位的Service,避免进程被系统误杀。接下来我们来看看startService()的启动流程:


MainActivity
//demo启动一个插件Service
Intent intent = RePlugin.createIntent("plugin1", "com.test.android.plugin1.service.Plugin1Service1");
PluginServiceClient.startService(MainActivity.this, intent);

上面的调用最终会通过binder执行到Service的管理类PluginServiceServer中的startServiceLocked(),然后在其中会手动构建Service对象并执行其生命周期,最后启动对应进程的坑位Service防止系统误杀:

//PluginServiceServer
ComponentName startServiceLocked(Intent intent, Messenger client) {
    intent = cloneIntentLocked(intent);
    ComponentName cn = intent.getComponent();

    /**
     * 这里构造出插件Service实例
     */
    final ServiceRecord sr = retrieveServiceLocked(intent);

    /**
     * 这里最终调到installServiceLocked(),其中会手动调用Service的attachBaseContext(),onCreate()生命周期
     * 具体参见下面注释说明
     */
    if (!installServiceIfNeededLocked(sr)) {
        return null;
    }

    /**
     * 从binder线程post到ui线程,去执行Service的onStartCommand操作
     */
    Message message = mHandler.obtainMessage(WHAT_ON_START_COMMAND);
    Bundle data = new Bundle();
    data.putParcelable("intent", intent);
    message.setData(data);
    message.obj = sr;
    mHandler.sendMessage(message);

    return cn;
}

private boolean installServiceLocked(ServiceRecord sr) {
    // 通过ServiceInfo创建Service对象
    Context plgc = Factory.queryPluginContext(sr.plugin);

    ClassLoader cl = plgc.getClassLoader();


    // 构建Service对象
    Service s;
    try {
        s = (Service) cl.loadClass(sr.serviceInfo.name).newInstance();
    } catch (Throwable e) {

    }

    // 只复写Context,别的都不做
    try {
        /**
         * 手动调用Service的attachBaseContext()
         */
        attachBaseContextLocked(s, plgc);
    } catch (Throwable e) {
    }

    /**
     * 手动调用Service的onCreate()
     */
    s.onCreate();
    sr.service = s;

    // 开启“坑位”服务,防止进程被杀
    ComponentName pitCN = getPitComponentName();
    sr.pitComponentName = pitCN;
    startPitService(pitCN);
    return true;
}

Provider的处理也很简单,仅仅是通过替换操作的Uri参数,让其命中对应坑位进程的Provider,然后在对应坑位进程的函数从Uri解析出对应插件的Provider并手动执行最终的操作:

MainActivity
测试Provider的demo
final String authorities = "com.android.test.host.demo.plugin1.TEST_PROVIDER";
Uri uri = Uri.parse("content://" + authorities + "/" + "test");

ContentValues cv = new ContentValues();
cv.put("name", "plugin1 demo");
cv.put("address", "beijing");

/**
 * 宿主操作插件中的provider时context必须要传插件中的context
 */
Context pluginContext = RePlugin.fetchContext("plugin1");
final Uri result = PluginProviderClient.insert(pluginContext, uri, cv);
DLog.d(TAG, "provider insert result: " + result);

此时会调用到

//PluginProviderClient
public static Uri insert(Context c, Uri uri, ContentValues values) {
    Uri turi = toCalledUri(c, uri); //转换为目标的uri
    /**
     * 这里使用转换后的uri将会跳转到对应进程坑位的Provider
     */
    return c.getContentResolver().insert(turi, values);
}

//转换逻辑
public static Uri toCalledUri(Context context, String plugin, Uri uri, int process) {
    /**
     * 根据process映射到对应进程的的坑位Provider
     */
    String au;
    if (process == IPluginManager.PROCESS_PERSIST) {
        au = PluginPitProviderPersist.AUTHORITY;
    } else if (PluginProcessHost.isCustomPluginProcess(process)) {
        au = PluginProcessHost.PROCESS_AUTHORITY_MAP.get(process);
    } else {
        au = PluginPitProviderUI.AUTHORITY;
    }

    /**
     * 转换为replugin格式的uri
     */
    // from => content://                                                  com.qihoo360.contacts.abc/people?id=9
    // to   => content://com.qihoo360.mobilesafe.Plugin.NP.UIP/plugin_name/com.qihoo360.contacts.abc/people?id=9
    String newUri = String.format("content://%s/%s/%s", au, plugin, uri.toString().replace("content://", ""));
    return Uri.parse(newUri);
}

最终会执行对应坑位Providerinsert()函数:

//PluginPitProviderBase
public Uri insert(Uri uri, ContentValues values) {
    PluginProviderHelper.PluginUri pu = mHelper.toPluginUri(uri);
    if (pu == null) {
        return null;
    }
    /**
     * 通过PluginUri手动构建运行时的ContentProvider
     */
    ContentProvider cp = mHelper.getProvider(pu);
    if (cp == null) {
        return null;
    }
    /**
     * 手动调用其insert函数
     */
    return cp.insert(pu.transferredUri, values);
}

广播的处理

广播的处理则更为简单,就是将插件中Manifests中注册的静态广播变成在加载插件时手动注册的动态广播即可,下面的调用在加载插件时触发:

//Loader
final boolean loadDex(ClassLoader parent, int load) {

    /**
     * 这里加载插件出插件的四大组件信息
     */
    mComponents = new ComponentList(mPackageInfo, mPath, mPluginObj.mInfo);

    // 动态注册插件中声明的 receiver
    regReceivers();
}

private void regReceivers() throws android.os.RemoteException {
    if (mPluginHost != null) {
        mPluginHost.regReceiver(plugin, ManifestParser.INS.getReceiverFilterMap(plugin));
    }
}


//常驻进程的PmHostSvc
public void regReceiver(String plugin, Map rcvFilMap) throws RemoteException {
    HashMap> receiverFilterMap = (HashMap>) rcvFilMap;

    // 遍历此插件中所有静态声明的 Receiver
    for (HashMap.Entry> entry : receiverFilterMap.entrySet()) {
        for (IntentFilter filter : filters) {
            int actionCount = filter.countActions();
            while (actionCount >= 1) {
                saveAction(filter.getAction(actionCount - 1), plugin, receiver);
                actionCount--;
            }

            // 注册 Receiver
            mContext.registerReceiver(mReceiverProxy, filter);
        }
    }
}

注:上面的注册动作是在插件加载时进行的。因此,这就意味着必须要是使用过插件中的类或资源后(会触发插件的加载)才能响应插件中的静态广播。RePlugin这么设计也还是符合按需加载的机制,官方也给出了具体原因:链接地址

多进程的支持

通篇看一遍RePlugin源码,可以发现它花了特别大的篇幅来实现四大组件的多进程(基本上涉及到插件的内容都与进程挂勾了),且还可以看到多处跨进程的binder通信(多多少少可以看到类似android中AMS管理四大组件的影子),前面提到四大组件的启动、坑位等问题时特意没过多的涉及多进程(东西太多),所以在这里统一梳理一下。

为什么大部分插件化开源框架都有意避开了多进程的实现?因为实现太过复杂,对坑位的预埋提出了更高的要求(预埋坑位的进程名需要与插件中未知的进程名进行映射),更重要的是还涉及到宿主中有多进程、插件中有多进程以及双方进程间要通信等情况,导致要统一管理信息变的复杂了。于是很多框架为了更轻量级(RePlugin的源码会比VirtualAPK的源码多了好几倍)都没去实现。但RePlugin在wiki中有提到要让app处处都能插件化的愿景,所以它就没法逃避这个问题。

那难点在哪里?
1、坑位配置更加复杂。特别是activity本身就涉及到启动模式、taskAffnity、主题等属性的组合,现在多加入一个android:process=xxx的组合,坑位的数量成指数级增长了。
2、进程名称是插件中Manifests中的组件属性中定义的,需要与对应坑位的进程进行映射(注意:这里要在Activity启动之前完成)。
3、由于坑位和插件内容分布在多个进程中,对坑位和插件的管理涉及到了跨进程,这大大增加了复杂度。

一开始看觉得好像没那么复杂,因为android:process属性已经配在了坑位上了,那我们直接启动对应坑位组件不就运行在对应的进程了吗(原生机制)?但回头一想,其实不然,插件组件启动前必须要先映射坑位,那到底映射哪一个坑位呢(那么多进程),且统一管理这些坑位映射关系还涉及到进程的管理(因为坑位分配涉及到进程分配)这无形中就增加了附加难度(相当与AMS对四大组件的管理)。

RePlugin实现:在app启动时(主进程)会拉起一个常驻进程(类似与系统的ActivityManagerService对应的进程),后续涉及插件的相关机制都去通过binder调用常驻进程,插件信息信息保存在这个常驻进程中,并统一管理和分配(这样才能保持一致性)。然后当需要启动一个插件组件(如Activity)时,先使用进程属性(android:process=xxx)提前匹配将要运行的进程名,然后常驻进程以该进程名为参数启动一个对应进程的Provider(没有实际作用,仅仅是为了拉起一个新进程并返回一个Binder对象),这样便可以在新进程中执行Application的生命周期(attachBaseContext()onCreate())了,而这里是我们在新进程中初始化插件的入口,于是我们便可以在启动插件组件之前先启动新进程了(这是因为常驻进程对多进程进行管理,需要提前建立两个进程的通信通道并同步一些插件信息,一切准备就绪后再启动对应坑位组件)。

先来看看demo中动态生成坑位信息,它在gradle配置中指定了3个进程,对activityproviderservice生成的坑位信息如下(一部分),p0~p2就是需要去映射的进程名:

//activity的多进程坑位,可以看到同一属性的activity有3个进程(p0、p1、p2)对应的坑位





//provider坑位,用于拉活对应进程




//常驻进程provider


//service坑位



代码中涉及到进程管理的类:
PmBase: 每个进程都会实例化这个类,但是内部实现会区分进程走到不同的分支。
PmHostSvc: 仅运行在常驻进程中(Service端),统一管理一切,并通过binder向其他进程(Client端)提供访问接口。
PluginProcessPer:每个进程都有实例(Service端),并将一个binder注册到常驻进程PmHostSvc(Client端),它相当于是插件进程与常驻进程的通信通道。
PluginProcessMain: 没有具体的实例,内部都是静态方法,只是提供其他进程与常驻进程进行交互的接口,最重要的接口就是connectToHostSvc(),将两个进程连接起来并同步一些信息。
PluginProcessHost: 没有具体的实例,内部使用了静态变量保存了一些进程参数的初始值。
ProcessPitProviderPersistProcessPitProviderUIProcessPitProviderP0ProcessPitProviderP1ProcessPitProviderP2:这几个provider就是在启动坑位前先拉起,让对应进程先起来的作用。

我们来看看RePlugin中进程启动时的流程,主要分两种情况:

  • 主进程的启动和常驻进程的启动
    主进程启动是用户进入app时启动的第一个进程属于主动启动,常驻进程的启动则是由主进程(也包括其他非常驻进程)启动后带起来的,是被动启动,下面看下这两个进程启动时的流程:

在Application.attachBaseContext()中调用,主要是初始化PmBase:

//RePlugin
public static void attachBaseContext(Application app, RePluginConfig config) {
    PMF.init(app);
}

上面方法最终调用到PmBaseinit()方法,这里会区分是否是常驻进程启动(服务端)还是非常驻进程(客户端)启动,分别对客户端和服务端进行初始化,其中initForClient()会去拿常驻进程中PmHostSvc(如果常驻进程没有启动则带起)的aidl接口,以该接口建立连接。这里一定是客户端先启动(app的主进程是客户端),因此常驻进程是后启动的:

//PmBase
void init() {
    if (IPC.isPersistentProcess()) {
        // 初始化“Server”所做工作,主要实例化PmHostSvc
        initForServer();
    } else {
        // 连接到Server
        initForClient(); 
    }
}
private final void initForClient() {
    // 1. 先尝试连接
    PluginProcessMain.connectToHostSvc();
}

这里通过手拉起一个运行在常驻进程中的Provider,这样常驻进程就起来了,然后就进入了常驻进程的attachBaseContext()->PmBase.ini()->initForServer()创建PmHostSvc最终返回给client:
Provider是在Manifests中的坑位,注意运行在常驻进程(android:process=':replugin',Demo中指定了常驻进程的名字为replugin):

//常驻进程的provider,注意android:process属性

//PluginProcessMain
static final void connectToHostSvc() {
    IBinder binder = PluginProviderStub.proxyFetchHostBinder(context);
    sPluginHostRemote = IPluginHost.Stub.asInterface(binder);
}

//PluginProviderStub
private static final IBinder proxyFetchHostBinder(Context context, String selection) {
    Uri uri = ProcessPitProviderPersist.URI; //com.android.test.host.demo.loader.p.main
    //PROJECTION_MAIN = = {"main"};
    cursor = context.getContentResolver().query(uri, PROJECTION_MAIN, selection, null, null);
}
//ProcessPitProviderPersist
@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
    sInvoked = true;
    return PluginProviderStub.stubMain(uri, projection, selection, selectionArgs, sortOrder);
}

//PluginProviderStub
public static final Cursor stubMain(Uri uri, String[] projection, String selection, String[] selectionArgs, 
    String sortOrder) {
    
    if (SELECTION_MAIN_BINDER.equals(selection)) {
        return BinderCursor.queryBinder(PMF.sPluginMgr.getHostBinder());
    }
}

final IBinder getHostBinder() {
    return mHostSvc; //PmHostSvc
}

//BinderCursor
public static final Cursor queryBinder(IBinder binder) {
    return new BinderCursor(PluginInfo.QUERY_COLUMNS, binder);
}
  • 启动插件组件时带起插件坑位进程
    这里的插件进程是在启动一个插件组件(声明了进程名)时触发的,我们以上面那一节启动插件中的Activity的示例为例来看下插件进程启动的流程:
    RePlugin提供的启动入口:
//RePlugin
public static boolean startActivity(Context context, Intent intent) {
    ComponentName cn = intent.getComponent();
    String plugin = cn.getPackageName();
    String cls = cn.getClassName();
    return Factory.startActivityWithNoInjectCN(context, intent, plugin, cls, IPluginManager.PROCESS_AUTO);
}

最终调用到PluginLibraryInternalProxy.startActivity()接口,这一步中的loadPluginActivity()是关键,会去触发加载插件、进程启动、坑位映射等核心操作:

//PluginLibraryInternalProxy
public boolean startActivity(Context context, Intent intent, String plugin, String activity, int process, 
    boolean download) {

    /**
     * 这一步去加载插件、启动进程、映射坑位(核心)
     */
    ComponentName cn = mPluginMgr.mLocal.loadPluginActivity(intent, plugin, activity, process);

    // 将Intent指向到“坑位”。这样:
    // from:插件原Intent
    // to:坑位Intent
    intent.setComponent(cn);

    //调用系统接口启动坑位Activity
    context.startActivity(intent);

    return true;
}

下面看他的具体实现,具体步骤参见注释:

//PluginCommImpl
public ComponentName loadPluginActivity(Intent intent, String plugin, String activity, int process) {
    ActivityInfo ai = null;
    String container = null;
    PluginBinderInfo info = new PluginBinderInfo(PluginBinderInfo.ACTIVITY_REQUEST);

    try {
        // 获取 ActivityInfo
        ai = getActivityInfo(plugin, activity, intent);

        // 根据 activity 的 processName,选择进程 ID 标识
        if (ai.processName != null) {
            process = PluginClientHelper.getProcessInt(ai.processName);
        }

        // 容器选择(启动目标进程)
        IPluginClient client = MP.startPluginProcess(plugin, process, info);

        // 远程分配坑位
        container = client.allocActivityContainer(plugin, process, ai.name, intent);

    } catch (Throwable e) {
    }

    return new ComponentName(IPC.getPackageName(), container);
}

然后是调用MP.startPluginProcess()启动进程,最终调用aidl调用到常驻进程的PmHostSvc的接口:

public static final IPluginClient startPluginProcess(String plugin, int process, PluginBinderInfo info) {
    return PluginProcessMain.getPluginHost().startPluginProcess(plugin, process, info);
}

最终内部则是调用PmBase.startPluginProcessLocked()接口去启动进程,其步骤跟启动常驻进程原理是一致的,还是通过启动对应进程的Provider来最终触发新进程Application.attachBaseContext()的执行,便有进入了框架的初始化流程:

//PmBase
final IPluginClient startPluginProcessLocked(String plugin, int process, PluginBinderInfo info) {
    // 启动
    boolean rc = PluginProviderStub.proxyStartPluginProcess(mContext, index);

    return client;
}

//PluginProviderStub
static final boolean proxyStartPluginProcess(Context context, int index) {
    //
    ContentValues values = new ContentValues();
    values.put(KEY_METHOD, METHOD_START_PROCESS);
    values.put(KEY_COOKIE, PMF.sPluginMgr.mLocalCookie);
    Uri uri = context.getContentResolver().insert(ProcessPitProviderBase.buildUri(index), values);

    return true;
}

查看Manifests中的坑位信息如下,注意android:process=':p0'表示的进程名:


就这样启动了一个新进程了!!一直跟过来其实并没有发现什么新技术,还是套用了四大组件的启动+binder就完成的,但是很巧妙。

资源读取

RePlugin中Resources资源在宿主和插件中是独立开来的。因此,宿主读取插件的资源和插件读取宿主的资源都需要先获取对方的Resources对象,然后再从该Resources对象中去获取。RePlugin提供接口:

//RePlugin
public static Resources fetchResources(String pluginName) {
    return Factory.queryPluginResouces(pluginName);
}
//通过插件的Context.getResources()也可以
public static Context fetchContext(String pluginName) {
    return Factory.queryPluginContext(pluginName);
}

由于资源id是在插件中的,因此不能直接通过R.id等直接来引用,Resources提供了一个按资源名称和类型来读取资源的接口getIdentifier(),其定义如下:

/**
 * @param name The name of the desired resource.
 * @param defType Optional default resource type to find, if "type/" is
 *                not included in the name.  Can be null to require an
 *                explicit type.
 * @param defPackage Optional default package to find, if "package:" is
 *                   not included in the name.  Can be null to require an
 *                   explicit package.
 *
 * @return int The associated resource identifier.  Returns 0 if no such
 *         resource was found.  (0 is not a valid resource ID.)
 */
public int getIdentifier(String name, String defType, String defPackage) {
    return mResourcesImpl.getIdentifier(name, defType, defPackage);
}

因此,读取插件中的资源可以使用该接口实现,参数定义参见上述的定义,下面是读取drawable示例:

/**
 * 获取插件的Resources对象(触发插件的加载)
 */
Resources resources = RePlugin.fetchResources("plugin2");
/**
 * 通过resource的getIdentifier()接口获取对应资源的id(参数参考上面的定义)
 */
final int id = resources.getIdentifier("test_plugin2_img", "drawable",
        "com.test.android.plugin2");
if (id != 0) {
    /**
     * 通过id去读取真正的资源文件
     */
    final Drawable drawable = resources.getDrawable(id);
    if (drawable != null) {
        mPluginImageView.setImageDrawable(drawable);
    }
}

读取layout的示例:

Resources resources = RePlugin.fetchResources("plugin2");
id = resources.getIdentifier("layout_test_plugin", "layout",
        "com.test.android.plugin2");
if (id != 0) {
    ViewGroup parent = findViewById(R.id.id_layout_plugin);
    XmlResourceParser parser = resources.getLayout(id);

    /**
     * 通过XmlResourceParser去加载布局,测试结果布局中的资源仍不能加载
     */
    View result = getLayoutInflater().inflate(parser, parent);

    /**
     * 这种方式也不能加载,会去宿主中找
     */
    //View result = getLayoutInflater().inflate(id, parent);
}

so库的支持

查看源码发现RePlugin对so库的支持其实并没有做额外的处理,仅仅是在安装插件(加压插件包)时读取一下宿主的ABI值,然后再根据宿主的ABI去释放插件对应的libs目录文件。具体逻辑都在PluginNativeLibsHelper文件中了,关键函数如下所示(可以参看其中的注释):

//PluginNativeLibsHelper
// 根据Abi来获取需要释放的SO在压缩包中的位置
private static String findSoPathForAbis(Set soPaths, String soName) {

    // 若主程序用的是64位进程,则所属的SO必须只拷贝64位的,否则会出异常。32位也是如此
    // 问:如果用户用的是64位处理器,宿主没有放任何SO,那么插件会如何?
    // 答:宿主在被安装时,系统会标记此为64位App,则之后的SO加载则只认64位的
    // 问:如何让插件支持32位?
    // 答:宿主需被标记为32位才可以。可在宿主App中放入任意32位的SO(如放到libs/armeabi目录下)即可。

    // 获取指令集列表
    boolean is64 = VMRuntimeCompat.is64Bit();
    String[] abis;
    if (is64) {
        abis = BuildCompat.SUPPORTED_64_BIT_ABIS;
    } else {
        abis = BuildCompat.SUPPORTED_32_BIT_ABIS;
    }

    // 开始寻找合适指定指令集的SO路径
    String soPath = findSoPathWithAbiList(soPaths, soName, abis);
    
    return soPath;
}

另外,虽然插件最终能解析对应的libs目录,但也存在宿主和插件中so文件ABI属性不一致的情况,这里官方也给出了详细介绍:插件so库ABI说明。

而宿主的ABI属性的判断条件则比较复杂了,但这里不是插件框架的范畴,放一篇介绍的比较流畅的文章链接:Android的so文件加载机制详解

其他

  • Phantom
    这个方案号称是唯一零Hook的占坑方案,翻看了一遍源码它确实做到了零Hook点(就是相比RePlugin要Hook住app的PathClassLoader,它不需要Hook),RePlugin要Hook住PathClassLoader只是为了在加载插件中的四大组件时去替换为坑位信息(这里还只能在ClassLoader中去做,否则就只能去Hook AMS了),Phantom方案就是看透了这点:干脆就直接启动坑位组件,并将插件中的具体组件信息通过Intent传递到坑位中去构建一个运行时的插件组件(如:Plugin1Activity),然后在坑位组件的生命周期中手动去调用插件组件的生命周期,坑位组件相当于是一个代理(其实在RePlugin中Service组件的实现就是这种方式),其余的实现大体跟RePlugin差不多,相当于是RePlugin的简化版,也仍没有进程的概念,所有组件只能是主进程。

  • MoPlugin
    这是公司内部自研的插件化方案,虽然是插件化的模式,但感觉它更倾向于是简单的热更新实现,它主要功能是将一些对外不变的接口类(包名不能变、函数接口不能去删减,可以增加)进行插件化的改造,使得那些需要升级更新的类(使用一个注解来标明)和资源能从插件中动态的加载。可以看出它倾向于对某些不常变化的东西进行更新(其实它初衷就是用来实现SDK内部插件的框架)。
    实现方式:类的加载前面也有提到过,是利用双亲委派的机制在原有的加载链路中插入一个MoClassLoader来实现;加载后的资源最终合并到宿主的Resources中(这里要合并是因为这些插件细节属于SDK内部逻辑了,不便于暴露给调用方);不需要埋坑位(有固定不变的前提)。

总结

梳理下来发现,其实插件化并没有引入什么高深的新技术,而且实现下来无非就是那么几个点:类的加载、资源的加载,坑位处理等,只是不同的框架有不同的实现而已。
比较复杂的地方是需要你对AMS的工作流程要比较清楚,特别对Hook的关键点,要能抓住其来龙去脉;另外就是需要对apk的安装流程有整体的认识(插件的安装类似apk的安装,即将文件进行释放和解析),后续应多了解这两个流程的实现。

参考文献

https://github.com/Qihoo360/RePlugin
https://github.com/Qihoo360/RePlugin/wiki/%E9%AB%98%E7%BA%A7%E8%AF%9D%E9%A2%98
https://www.jianshu.com/p/74a70dd6adc9
https://blog.csdn.net/yulong0809/article/details/78428247

你可能感兴趣的:(RePlugin插件化实践)