个人Android重构之旅:插件化篇

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

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

鲁迅如是说道

Plug-in Hello World

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

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

在咱们开发的过程当中,每每会碰到多人协做进行模块化的开发,咱们指望可以独立运行本身的模块而又不受其余人模块的影响,还有一个更为常见的需求,咱们在快速的产品迭代过程当中,咱们每每但愿能无缝衔接新的功能至用户手机上,过于频繁的产品迭代或过长的开发周期,这会使得咱们在与竟品竞争时失去先机。github

 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 请点我app

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 ,但愿本文的设计思路能带给你一些帮助。

连接:https://www.jianshu.com/p/c6f... 转载请注明原创

阅读更多

个人Android重构之旅:框架篇

个人Android重构之旅:架构篇

Android进程保活招数概览

NDK项目实战—高仿360手机助手之卸载监听

相信本身,没有作不到的,只有想不到的

在这里得到的不只仅是技术!

技术+职场

相关文章
相关标签/搜索