我的Android重构之旅:插件化篇
【公众号回复“1024”,送你一个特别推送】
随着项目的不断成长,即便项目采用了 MVP 或是 MVVM 这类优秀的架构,也很难跟得上迭代的脚步,当 APP 端功能越来越庞大、繁琐,人员不断加入后,牵一发而动全局的事情时常发生,后续人员如同如履薄冰似的维护项目,为此我们必须考虑团队壮大后的开发模式,提前对业务进行隔离,同时总结出插件化开发的流程,完善 Android 端基础框架。
本文是“我的Android重构之旅”的第三篇,也是让我最为头疼的一篇,在本文中,我将会和大家聊一聊“插件化”的概念,以及我们在“插件化”框架上的选择与碰到的一些问题。
Plug-in Hello World
插件化是指将 APK 分为宿主和插件的部分,在 APP 运行时,我们可以动态的载入或者替换插件部分。
宿主: 就是当前运行的APP。
插件: 相对于插件化技术来说,就是要加载运行的apk类文件。
插件化分为俩种形态,一种插件与宿主 APP 无交互例如微信与微信小程序,一种插件与宿主极度耦合例如滴滴出行,滴滴出行将用户信息作为独立的模块,需要与其他模块进行数据的交互,由于使用场景不一致,本文只针对插件与宿主有频繁数据交互的情况。
在我们开发的过程中,往往会碰到多人协作进行模块化的开发,我们期望能够独立运行自己的模块而又不受其他人模块的影响,还有一个更为常见的需求,我们在快速的产品迭代过程中,我们往往希望能无缝衔接新的功能至用户手机上,过于频繁的产品迭代或过长的开发周期,这会使得我们在与竟品竞争时失去先机。
上图是一款人脸识别产品的迭代记录,由于上线的各个城市都有细微的逻辑差别,导致每次核心业务出现 BUG 同事要一个个 Push 至各各版本,然后通知各个城市的推广商下载,这时候我就在想,能不能把我们的应用做成插件的形式动态下发呢,这样就避免了每次都需要的版本升级,在某次 Push 版本的深夜,我决定不能这样下去了,我一定要用上插件化。
插件化框架的选择
下图是主流的插件化、组件化框架
特性
DynamicLoadApk
DynamicAPK
Small
DroidPlugin
VirtualAPK
支持四大组件 | 只支持Activity | 只支持Activity | 只支持Activity | 全支持 | 全支持 |
组件无需在宿主manifest中预注册 | √ | × | √ | √ | √ |
插件可以依赖宿主 | √ | √ | √ | × | √ |
支持PendingIntent | × | × | × | √ | √ |
Android特性支持 | 大部分 | 大部分 | 大部分 | 几乎全部 | 几乎全部 |
兼容性适配 | 一般 | 一般 | 中等 | 高 | 高 |
插件构建 | 无 | 部署aapt | Gradle插件 | 无 | Gradle插件 |
最终反复推敲决定使用滴滴出行的 VirtualAPK 作为我们的插件化框架,它有以下几个优点:
可与宿主工程通信
兼容性强
使用简单
编译插件方便
经过大规模使用
如果你要加载一个插件,并且这个插件无需和宿主有任何耦合,也无需和宿主进行通信,并且你也不想对这个插件重新打包,那么推荐选择DroidPlugin。
插件化原理
VirtualAPK 对插件没有额外的约束,原生的apk即可作为插件。插件工程编译生成 Apk 后,即可通过宿主 App 加载,每个插件apk被加载后,都会在宿主中创建一个单独的 LoadedPlugin 对象。如下图所示,通过这些 LoadedPlugin 对象,VirtualAPK 就可以管理插件并赋予插件新的意义,使其可以像手机中安装过的 App 一样运行。
我们在引入一款框架的时候往往不能只单纯的了解如何使用,应去深入的了解它是如何工作的,特别是插件化这种热门的技术,十分感谢开源项目给了我们一把探寻 Android 世界的金钥匙,下面将和大家简易的分析下 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 的流程。
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/c6f2a516b182
阅读更多
相信自己,没有做不到的,只有想不到的
在这里获得的不仅仅是技术!