我的Android重构之旅:插件化篇

我的Android重构之旅:架构篇
我的Android重构之旅:框架篇
我的Android重构之旅:插件化篇

随着项目的不断成长,即便项目采用了 MVP 或是 MVVM 这类优秀的架构,也很难跟得上迭代的脚步,当 APP 端功能越来越庞大、繁琐,人员不断加入后,牵一发而动全局的事情时常发生,后续人员如同如履薄冰似的维护项目,为此我们必须考虑团队壮大后的开发模式,提前对业务进行隔离,同时总结出插件化开发的流程,完善 Android 端基础框架。

本文是“我的Android重构之旅”的第三篇,也是让我最为头疼的一篇,在本文中,我将会和大家聊一聊“插件化”的概念,以及我们在“插件化”框架上的选择与碰到的一些问题。

鲁迅如是说道

Plug-in Hello World

插件化是指将 APK 分为宿主和插件的部分,在 APP 运行时,我们可以动态的载入或者替换插件部分。
宿主: 就是当前运行的APP。
插件: 相对于插件化技术来说,就是要加载运行的apk类文件。

插件化分为俩种形态,一种插件与宿主 APP 无交互例如微信与微信小程序,一种插件与宿主极度耦合例如滴滴出行,滴滴出行将用户信息作为独立的模块,需要与其他模块进行数据的交互,由于使用场景不一致,本文只针对插件与宿主有频繁数据交互的情况。

在我们开发的过程中,往往会碰到多人协作进行模块化的开发,我们期望能够独立运行自己的模块而又不受其他人模块的影响,还有一个更为常见的需求,我们在快速的产品迭代过程中,我们往往希望能无缝衔接新的功能至用户手机上,过于频繁的产品迭代或过长的开发周期,这会使得我们在与竟品竞争时失去先机。

Git 提交记录

上图是一款人脸识别产品的迭代记录,由于上线的各个城市都有细微的逻辑差别,导致每次核心业务出现 BUG 同事要一个个 Push 至各各版本,然后通知各个城市的推广商下载,这时候我就在想,能不能把我们的应用做成插件的形式动态下发呢,这样就避免了每次都需要的版本升级,在某次 Push 版本的深夜,我决定不能这样下去了,我一定要用上插件化。

插件化框架的选择

下图是主流的插件化、组件化框架

特性 DynamicLoadApk DynamicAPK Small DroidPlugin VirtualAPK
支持四大组件 只支持Activity 只支持Activity 只支持Activity 全支持 全支持
组件无需在宿主manifest中预注册 ×
插件可以依赖宿主 ×
支持PendingIntent × × ×
Android特性支持 大部分 大部分 大部分 几乎全部 几乎全部
兼容性适配 一般 一般 中等
插件构建 部署aapt Gradle插件 Gradle插件

最终反复推敲决定使用滴滴出行的 VirtualAPK 作为我们的插件化框架,它有以下几个优点:

  • 可与宿主工程通信
  • 兼容性强
  • 使用简单
  • 编译插件方便
  • 经过大规模使用

如果你要加载一个插件,并且这个插件无需和宿主有任何耦合,也无需和宿主进行通信,并且你也不想对这个插件重新打包,那么推荐选择DroidPlugin。

Android 插件化技术的典型应用

插件化原理

VirtualAPK 对插件没有额外的约束,原生的apk即可作为插件。插件工程编译生成 Apk 后,即可通过宿主 App 加载,每个插件apk被加载后,都会在宿主中创建一个单独的 LoadedPlugin 对象。如下图所示,通过这些 LoadedPlugin 对象,VirtualAPK 就可以管理插件并赋予插件新的意义,使其可以像手机中安装过的 App 一样运行。

我们在引入一款框架的时候往往不能只单纯的了解如何使用,应去深入的了解它是如何工作的,特别是插件化这种热门的技术,十分感谢开源项目给了我们一把探寻 Android 世界的金钥匙,下面将和大家简易的分析下 VirtualAPK 的原理。

VirtualAPK 的工作过程

四大组件对于安卓人员都是再熟悉不过了,我们都清楚四大组建都是需要在 AndroidManifest 中注册的,而对于 VirtualAPK 来说是不可能预先知晓名字,提前注册在宿主 Apk 中的,所以现在基本都采用 hack 方案解决,VirtualAPK 大致方案如下:

  • Activity:在宿主 Apk 中提前占坑,然后通过 Hook Activity 的启动过程,“欺上瞒下”启动插件 Apk 中的 Activity,因为 Activity 存在不同的 LaunchMode 以及一些特殊的熟悉,所以需要多个占坑的“李鬼” Activity。
  • Service:通过代理 Service 的方式去分发;主进程和其他进程,VirtualAPK 使用了两个代理Service。
  • BroadcastReceiver:静态转动态。
  • ContentProvider:通过一个代理Provider进行分发。

在本文,我们主要分析 Activity 的占坑过程,如果需要更深入的了解 VirtualAPK 请点我

Activity 流程

我们如果要启用 VirtualAPK 的话,需要先调用pluginManager.loadPlugin(apk),进行加载插件,然后我们继续向下调用

   // 调用 LoadedPlugin 加载插件 Activity 信息
   LoadedPlugin plugin = LoadedPlugin.create(this, this.mContext, apk);
   // 加载插件的 Application
   plugin.invokeApplication();

我们可以发现插件 Activity 的解析是交由LoadedPlugin.create 去完成的,完成之后保存至 mPlugins 这个 Map 当中方便下次调用与解绑插件,我们继续往下探索

        // 拷贝Resources
        this.mResources = createResources(context, apk);
        // 使用DexClassLoader加载插件并与现在的Dex进行合并
        this.mClassLoader = createClassLoader(context, apk, this.mNativeLibDir, context.getClassLoader());
        // 如果已经初始化不解析
        if (pluginManager.getLoadedPlugin(mPackageInfo.packageName) != null) {
            throw new RuntimeException("plugin has already been loaded : " + mPackageInfo.packageName);
        }
        // 解析APK
        this.mPackage = PackageParserCompat.parsePackage(context, apk, PackageParser.PARSE_MUST_BE_APK);
        // 拷贝插件中的So
        tryToCopyNativeLib(apk);
        // 保存插件中的 Activity 参数
        Map<ComponentName, ActivityInfo> activityInfos = new HashMap<ComponentName, ActivityInfo>();
        for (PackageParser.Activity activity : this.mPackage.activities) {
            activityInfos.put(activity.getComponentName(), activity.info);
        }
        this.mActivityInfos = Collections.unmodifiableMap(activityInfos);
        this.mPackageInfo.activities = activityInfos.values().toArray(new ActivityInfo[activityInfos.size()]);

LoadedPlugin 中将我们插件中的资源合并进了宿主 App 中,至此插件 App 的加载过程就已经完成了,这里大家肯定会有疑惑,该Activity必然没有在Manifest中注册,这么启动不会报错吗?

这就要涉及到 Activity 的启动流程了,我们在startActivity之后系统最终会调用 Instrumentation 的 execStartActivity 方法,然后再通过 ActivityManagerProxy 与 AMS 进行交互。

Activity 是否注册在 Manifest 的校验是由 AMS 进行的,所以我们在于 AMS 交互前,提前将 ActivityManagerProxy 提交给 AMS 的 ComponentName替换为我们占坑的名字即可。
通常我们可以选择 Hook Instrumentation 或者 Hook ActivityManagerProxy 都可以达到目标,VirtualAPK 选择了 Hook Instrumentation 。

 private void hookInstrumentationAndHandler() {
        try {
            Instrumentation baseInstrumentation = ReflectUtil.getInstrumentation(this.mContext);
            if (baseInstrumentation.getClass().getName().contains("lbe")) {
                // reject executing in paralell space, for example, lbe.
                System.exit(0);
            }
            // 用于处理替换 Activity 的名称
            final VAInstrumentation instrumentation = new VAInstrumentation(this, baseInstrumentation);
            Object activityThread = ReflectUtil.getActivityThread(this.mContext);
            // Hook Instrumentation 替换 Activity 名称
            ReflectUtil.setInstrumentation(activityThread, instrumentation);
            // Hook handleLaunchActivity
            ReflectUtil.setHandlerCallback(this.mContext, instrumentation);
            this.mInstrumentation = instrumentation;
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

上面我们已经成功的 Hook 了 Instrumentation ,接下来就是需要我们的李鬼上场了

    public ActivityResult execStartActivity(
            Context who, IBinder contextThread, IBinder token, Activity target,
            Intent intent, int requestCode, Bundle options) {
        mPluginManager.getComponentsHandler().transformIntentToExplicitAsNeeded(intent);
        // 只有是插件中的Activity 才进行替换
        if (intent.getComponent() != null) {
            Log.i(TAG, String.format("execStartActivity[%s : %s]", intent.getComponent().getPackageName(),
                    intent.getComponent().getClassName()));
            // 使用"李鬼"进行替换
            this.mPluginManager.getComponentsHandler().markIntentIfNeeded(intent);
        }
        ActivityResult result = realExecStartActivity(who, contextThread, token, target,
                    intent, requestCode, options);
        return result;
    }

我们来看一看 markIntentIfNeeded(intent); 到底做了什么

    public void markIntentIfNeeded(Intent intent) {
        if (intent.getComponent() == null) {
            return;
        }
        String targetPackageName = intent.getComponent().getPackageName();
        String targetClassName = intent.getComponent().getClassName();
        // 保存我们原有数据
        if (!targetPackageName.equals(mContext.getPackageName()) && mPluginManager.getLoadedPlugin(targetPackageName) != null) {
            intent.putExtra(Constants.KEY_IS_PLUGIN, true);
            intent.putExtra(Constants.KEY_TARGET_PACKAGE, targetPackageName);
            intent.putExtra(Constants.KEY_TARGET_ACTIVITY, targetClassName);
            dispatchStubActivity(intent);
        }
    }

    private void dispatchStubActivity(Intent intent) {
        ComponentName component = intent.getComponent();
        String targetClassName = intent.getComponent().getClassName();
        LoadedPlugin loadedPlugin = mPluginManager.getLoadedPlugin(intent);
        ActivityInfo info = loadedPlugin.getActivityInfo(component);
        // 判断是否是插件中的Activity
        if (info == null) {
            throw new RuntimeException("can not find " + component);
        }
        int launchMode = info.launchMode;
        // 并入主题
        Resources.Theme themeObj = loadedPlugin.getResources().newTheme();
        themeObj.applyStyle(info.theme, true);
        // 将插件中的 Activity 替换为占坑的 Activity
        String stubActivity = mStubActivityInfo.getStubActivity(targetClassName, launchMode, themeObj);
        Log.i(TAG, String.format("dispatchStubActivity,[%s -> %s]", targetClassName, stubActivity));
        intent.setClassName(mContext, stubActivity);
    }

可以看到上面将我们原本的信息保存至 Intent 中,然后调用了 getStubActivity(targetClassName, launchMode, themeObj); 进行了替换


    public static final String STUB_ACTIVITY_STANDARD = "%s.A$%d";
    public static final String STUB_ACTIVITY_SINGLETOP = "%s.B$%d";
    public static final String STUB_ACTIVITY_SINGLETASK = "%s.C$%d";
    public static final String STUB_ACTIVITY_SINGLEINSTANCE = "%s.D$%d";

    public String getStubActivity(String className, int launchMode, Theme theme) {
        String stubActivity= mCachedStubActivity.get(className);
        if (stubActivity != null) {
            return stubActivity;
        }

        TypedArray array = theme.obtainStyledAttributes(new int[]{
                android.R.attr.windowIsTranslucent,
                android.R.attr.windowBackground
        });
        boolean windowIsTranslucent = array.getBoolean(0, false);
        array.recycle();
        if (Constants.DEBUG) {
            Log.d("StubActivityInfo", "getStubActivity, is transparent theme ? " + windowIsTranslucent);
        }
        stubActivity = String.format(STUB_ACTIVITY_STANDARD, corePackage, usedStandardStubActivity);
        switch (launchMode) {
            case ActivityInfo.LAUNCH_MULTIPLE: {
                stubActivity = String.format(STUB_ACTIVITY_STANDARD, corePackage, usedStandardStubActivity);
                if (windowIsTranslucent) {
                    stubActivity = String.format(STUB_ACTIVITY_STANDARD, corePackage, 2);
                }
                break;
            }
            case ActivityInfo.LAUNCH_SINGLE_TOP: {
                usedSingleTopStubActivity = usedSingleTopStubActivity % MAX_COUNT_SINGLETOP + 1;
                stubActivity = String.format(STUB_ACTIVITY_SINGLETOP, corePackage, usedSingleTopStubActivity);
                break;
            }
            case ActivityInfo.LAUNCH_SINGLE_TASK: {
                usedSingleTaskStubActivity = usedSingleTaskStubActivity % MAX_COUNT_SINGLETASK + 1;
                stubActivity = String.format(STUB_ACTIVITY_SINGLETASK, corePackage, usedSingleTaskStubActivity);
                break;
            }
            case ActivityInfo.LAUNCH_SINGLE_INSTANCE: {
                usedSingleInstanceStubActivity = usedSingleInstanceStubActivity % MAX_COUNT_SINGLEINSTANCE + 1;
                stubActivity = String.format(STUB_ACTIVITY_SINGLEINSTANCE, corePackage, usedSingleInstanceStubActivity);
                break;
            }

            default:break;
        }

        mCachedStubActivity.put(className, stubActivity);
        return stubActivity;
    }
       <!-- Stub Activities -->
       <activity android:name=".B$1" android:launchMode="singleTop"/>
       <activity android:name=".C$1" android:launchMode="singleTask"/>
       <activity android:name=".D$1" android:launchMode="singleInstance"/>
        其余略····

StubActivityInfo 根据同的 launchMode 启动相应的“李鬼” Activity 至此,我们已经成功的 欺骗了 AMS ,启动了我们占坑的 Activity 但是只成功了一半,为什么这么说呢?因为欺骗过了 AMS,AMS 执行完成后,最终要启动的并非是占坑的 Activity ,所以我们还要能正确的启动目标Activity。

我们在 Hook Instrumentation 的同时一并 Hook 了 handleLaunchActivity,所以我们之间到 Instrumentation 的 newActivity 方法查看启动 Activity 的流程。

  @Override
    public Activity newActivity(ClassLoader cl, String className, Intent intent) throws InstantiationException, IllegalAccessException, ClassNotFoundException {
        try {
            // 是否能直接加载,如果能就是宿主中的 Activity
            cl.loadClass(className);
        } catch (ClassNotFoundException e) {
            // 取得正确的 Activity
            LoadedPlugin plugin = this.mPluginManager.getLoadedPlugin(intent);
            String targetClassName = PluginUtil.getTargetActivity(intent);
            Log.i(TAG, String.format("newActivity[%s : %s]", className, targetClassName));
            // 判断是否是 VirtualApk 启动的插件 Activity
            if (targetClassName != null) {
                Activity activity = mBase.newActivity(plugin.getClassLoader(), targetClassName, intent);
                // 启动插件 Activity
                activity.setIntent(intent);
                try {
                    // for 4.1+
                    ReflectUtil.setField(ContextThemeWrapper.class, activity, "mResources", plugin.getResources());
                } catch (Exception ignored) {
                    // ignored.
                }
                return activity;
            }
        }
        // 宿主的 Activity 直接启动
        return mBase.newActivity(cl, className, intent);
    }

好了,到此Activity就可以正常启动了。

小结

VritualApk 整理思路很清晰,在这里我们只介绍了 Activity 的启动方式,感兴趣的同学可以去网上了解下其余三大组建的代理方式。不论如何如果想使用插件化框架,一定要了解其中的实现原理,文档上描述的并不是所有的细节,很多一些属性什么的,以及由于其实现的方式造成一些特性的不支持。

引入插件化之痛

由于项目的宿主与插件需要进行较为紧密的交互,在插件化的同时需要对项目进行模块化,但是模块化并不能一蹴而就,在模块化的过程中经常出现,牵一发而动全身的问题,在经历过无数个通宵的夜晚后,我总结出了模块化的几项准则。

插件化的使用

VirtualAPK 本身的使用并不困难,困难的是需要逐步整理项目的模块,在这期间问题百出,因为自身没有相关经验在网上看了很多关于模块化的文章,最终我找到有赞模块化的文章,对他们总结出来的经验深刻认同。

在项目模块化时应该遵循以下几个准则

  • 确定业务逻辑边界
  • 模块的更改上保持克制
  • 公共资源及时抽取

确定业务逻辑边界
在模块化之前,我们先要详细的分析业务逻辑,App 作为业务链的末端,由于角色所限,开发人员对业务的理解比后端要浅,所谓欲速则不达,重构不能急,理清楚业务逻辑之后再动手。

项目改造前结构

在模块化进行时,我们需要将业务模块进行隔离,业务模块之间不能互相依赖能存在数据传输,只能单向依赖宿主项目,为了达到这个效果 我们需要借用市面上的路由方案 ARouter ,由于篇幅原因,我在这里不做过多介绍,感兴趣的同学可以自行搜索。

项目改造前结构

项目改造后宿主只留下最简单的公共基础逻辑,其他部分都由插件的形式装载,这样使得我们在版本更新的过程中自由度很高,从项目结构上我们看起来很像所有插件都依赖了宿主 App 的代码,但实际上在打包的过程中 VirtualAPK 会帮助我们剔除重复资源

打包完成的插件

模块的更改上保持克制
在模块化进行时,不要过分的追求完美的目标,简单粗暴一点,后续再逐渐改善,很多业务逻辑经常会和其他业务逻辑产生牵连,它们俩会处于一个相对暧昧的关系,这种时候我们不要去强行的分割它们的业务边界,过分的分割往往会因为编码人员对于模块的不清晰导致项目改造的全盘崩溃。

公共资源及时抽取
VirtualAPK 会帮助我们剔除重复资源,对于一些暧昧不清的资源我们可以索性将它放入宿主项目中,如果将过多的资源存于插件项目中,这样会导致我们的插件失去应有的灵活性和资源的复用性。

总结

最初在公司内部推广插件化的时候,同事们哗然一片大多数都是对插件化的质疑,在这里我要感谢我原来的领导,在关键时刻给我的支持帮我顶住了大家质疑的声音,在十多个日日夜夜的修改重构后,插件化后的第一个上线的版本,插件化灵活的优势体现的淋漓尽致,每个插件只有60 KB 的大小,对服务端的带宽几乎没有丝毫的压力,帮助我们快速的进行了产品的迭代 、Bug的修复。
本文中,只是我自己在项目插件化的一些经验与想法,并没有深入的介绍如何使用 VirtualAPK 感兴趣的同学可以读一下 VirtualAPK 的 WiKi ,希望本文的设计思路能带给你一些帮助。

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

推荐阅读更多精彩内容