国内Android动态化方案已经蓬勃发展数年之久,在React Natvie、Flutter这些跨平台方案未出现之前,类似Atlas、Replugin、DLA等Android动态化方案在业界独领风骚。在国内动态化方案也分为两个流派:组件化与插件化。比如Atlas自称为组件化方案,另外诸如Replugin、DroidPlugin等称为插件化方案。本文不具体说明组件化与插件化区别相关介绍文章已多入牛毛,这里就不再赘述。
在项目膨胀到一定阶段时,解耦工作就迫在眉睫。项目初期,我们会把网络请求、下载、存储等核心功能库作为Library Module,这是解耦雏形。然而当业务代码继续扩张后,具有独立业务功能模块也会慢慢被剥离出来,作为独立的Library Module,这些被解耦出的业务模块,我们称之为业务组件,例如登录、支付、分享等。当公司业务处于急速发展时期,过长的发布周期、过大的应用程序包体积等都会阻碍业务发展,因此业务组件动态化需求日益强烈,以此为契机插件化就此诞生。组件化初期是为解耦,羽化期就是动态部署。
我们将组件分为三种类型,核心组件、基础组件、业务组件。在业务层分为业务组件和业务插件,业务插件相较于业务组件是具有动态部署能力,同时业务组件与业务插件能互相转换,这取决于业务发展情况。当业务初期阶段,以业务插件形式接入主客(一般会将插件作为独立进程存在),好处是不增加主客包体积、不影响主客崩溃率等。当业务插件发展成熟且流量巨大,此时我们会考虑将其以业务组件的时候接入主客。毕竟业务插件的稳定性、到达率等都会存在风险。爱奇艺泡泡早期在Android是以业务插件形式接入,随着泡泡业务成熟发展,DAU急速扩增,就将其接入主客变成业务组件。
组件解耦是一个长期且复杂的工程,为避免组件相互依赖,我们一般会开发一套组件间通信方案。目前来说,组件间通信方案有两种形式:一种是协议型,另外一种是接口型。爱奇艺开源的Andromeda库就是基于接口型组件间通信方案,支持跨进程和同进程。
基于前期调研与探索,我们决定基于Google提供动态化方案来做组件化Qigsaw,具有以下优势。
0 Hook。不修改系统成员变量。
极少量私有Api访问。
利用Google Android App Bundle打包插件完成打包工作,无需维护定制的打包插件。
天然支持业务插件和业务组件之间的无缝转换。
国内走Qigsaw动态部署业务插件,国际版通过Play Store分发,共享开发工具、环境。
业务组件之间是不应该存在直接依赖,业务组件对外暴露应该以Activity、Service、Receiver等为主。
在爱奇艺组件化探索之原理篇中有详细介动态加载组件的原理,同时在爱奇艺第一期移动技术沙龙中也提到我们如何探索及演进组件化框架。在开始设计爱奇艺自身组件化框架时,我们的核心诉求是组件能在组件化和插件化中随时切换以应变业务发展需要,且能够在主工程一起完成打包。
从上图中打包流程中可以看出:
所有业务组件、业务插件的Manifest文件会合并。
业务插件打包产物为APK文件,用于动态部署。
众所周知,Android四大组件必须在应用程序Manifest文件中声明才能被正常启动。将插件的Manifest预先声明至主客中,我们就无需通过黑科技手段启动四大组件,稳定性更高。但该方案无法满足业务插件新增四大组件需求,本文后续将会介绍一种新增Activity的方案,毕竟新增Activity需求远远大于其他Android组件。
国内Android动态化方案不胜枚举,其中我们选取Atlas调研,此外针对Google动态化方案Instant Apps和Android App Bundles(AAB)陆续展开分析。在年初开始组件化探索之时,Atlas的方案是比较符合我们需求,但其存在两个比较棘手问题。
打包插件极其厚重。
存在大量私有API访问,兼容性处理逻辑较多。
Atlas打包方案是完全自研一套,数十万行代码。即使套用Atlas打包插件,其接入维护成本也是巨大,而且Android Gralde插件升级后,还需等Atlas团队适配。Atlas还大量修改aapt源码(非aapt2),这也需投入巨大资源完成升级适配工作。
借助Atlas打包插件或者自研一套打包方案在年初爱奇艺组件化框架立项时就被否决。因为不管哪种方式,都需要花费大量资资源,对于我们这种比较精小的团队来说不划算。所以我们另辟蹊径,看能不能从官方提供的动态化框架中寻找蛛丝马迹。
Google于2016年推出Instant Apps,在安装有google play service的Android设备上,只需一个链接,无须安装App就可以体验该App的部分功能。
在https://developer.android.com/topic/google-play-instant/文档中,有介绍如何开始Instant Apps开发。
上图就是依据官方文档介绍、工程结构、运行结果总结得出。Instant Apps提供两种类型Gradle打包插件com.android.feature、com.android.instantapp,在com.android.feature中必须声明一base feature模块。所有业务feature所需公共模块都可放入base feature中。com.android.instantapp插件作用是生成Instant App所需应用程序包,通过图中输出产物可知,它是zip格式压缩文件,通过解压发现它包含所有feature插件生成的apk文件。免安装运行apk,以DroidPlugin为代表的插件化方案也能如此。所以,我们可以大胆猜测Instant Apps就是官方插件化机制。
上图是运行Android Studio中Instant Apps工程后在Nexus 5(OS 6.0)得到的启动页。在该页有两种操作方式,一种是打开Instant App,另外一种是用浏览器打开该页面。前文提到,Instant App只需一个链接就可以打开应用程序,通过链接方式Instant App和浏览器就完美兼容,对用户来说无感知。我们选择“打开应用”查看运行结果。
上图页面就是Instant App工程feature模块主Activity。
执行adb命令。
adb shell dumpsys activity | grep “mFocusedActivity”
获取当前手机正在显示的Activity。
mFocusedActivity: ActivityRecord{fd0a677 u0 com.google.android.instantapps.supervisor/.shadow.ShadowActivity22 t2577}
从实际运行结果来看,正在运行Activity包名、类名并不是我们在feature模块中声明的Activity,实际类名为com.iqiyi.androidinstantapp.feature.MainActivity。熟悉插件化的朋友可能已经想到,该方案应该是通过“Activity预埋,运行时偷梁换柱”到达启动插件中Activity目的。
为验证Instant Apps是插件化框架猜想,我们找到google play services for instant apps的apk安装包。通过反编译工具jadx-gui查看其AndroidManifest.xml文件。
<service android:name="com.google.android.instantapps.supervisor.probe.probes.smoke.smoketests.hotpatching.IsolatedLinkerPatchingService" android:enabled="true" android:exported="false" android:process="com.google.android.instantapps.supervisor.probe.probes.smoke.smoketests.hotpatching.isolated" android:isolatedProcess="true"/>
...
<service android:name="com.google.android.instantapps.supervisor.shadow.ShadowService45" android:exported="false"/>
<service android:name="com.google.android.instantapps.supervisor.shadow.JobSchedulerShadowService1" android:permission="android.permission.BIND_JOB_SERVICE" android:exported="false"/>
...
<service android:name="com.google.android.instantapps.supervisor.shadow.JobSchedulerShadowService10" android:permission="android.permission.BIND_JOB_SERVICE" android:exported="false"/>
<activity android:theme="@style/Theme.ShadowActivity" android:name="com.google.android.instantapps.supervisor.shadow.ShadowActivity1" android:exported="false" android:configChanges="mcc|mnc|locale|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|uiMode|screenSize|smallestScreenSize|layoutDirection|fontScale"/>
...
<activity android:theme="@style/Theme.ShadowActivity" android:name="com.google.android.instantapps.supervisor.shadow.ShadowActivity30" android:exported="false" android:configChanges="mcc|mnc|locale|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|uiMode|screenSize|smallestScreenSize|layoutDirection|fontScale"/>
<activity android:theme="@style/Theme.TransparentShadowActivity" android:name="com.google.android.instantapps.supervisor.shadow.TransparentShadowActivity1" android:exported="false" android:configChanges="mcc|mnc|locale|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|uiMode|screenSize|smallestScreenSize|layoutDirection|fontScale"/>
...
<activity android:theme="@style/Theme.TransparentShadowActivity" android:name="com.google.android.instantapps.supervisor.shadow.TransparentShadowActivity15" android:exported="false" android:configChanges="mcc|mnc|locale|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|uiMode|screenSize|smallestScreenSize|layoutDirection|fontScale"/>
在Manifest文件中,声明了大量预埋用的Activity和Service。另外通过简要分析其代码逻辑也验证了我们之前的想法(有兴趣的朋友可以深入分析gps for instant apps逻辑)。
从Android 8.0开始,Instant App核心逻辑均迁至Android Framework层,由系统层面提供支持,Android四大组件启动无须通过“组件预埋,运行时偷梁换柱”方式。
在Android 8.0及以上设备执行adb命令(Android 8.0开始某些adb命令格式有所改变)。
adb shell dumpsys activity | grep “mResumedActivity”
获取当前手机正在显示的Activity。
mResumedActivity: ActivityRecord{dd97056 u0 com.iqiyi.androidinstantapp.app/com.iqiyi.androidinstantapp.feature.MainActivity t11}
从执行结果来看,Activity包名、类名与实际feature模块中主Activity一致。通过阅读Android 8.0 Framework源码,可以看到不少Instant Apps相关的Api。
正当我们准备基于Instant App做爱奇艺组件化改造时,Android P对私有Api开始限制访问。
https://developer.android.com/preview/restrictions-non-sdk-interfaces中有介绍调用非SDK接口后果。
上图中调用非SDK接口所引发的异常是指调用除浅灰名单以外所有私有Api。Android P对私有Api分为三个级别:浅灰名单、深灰名单、黑名单。调用深灰名单和黑名单私有Api在Android P设备上将会抛出上图所列异常结果,调用浅灰名单私有Api不会抛出异常,但会输出警告日志。目前处于浅灰名单私有Api可能在后续Android版本中迁移至深灰或黑名单中。
级别 | 范围 |
---|---|
浅灰名单(light greylist) | Android P 可用,后续版本可能无法正常使用 |
深灰名单(drak greylist) | targeSDK >= 9禁止使用,targeSDK < 9可用 |
黑名单 (black list) | 禁止使用 |
从上述介绍可知,调用私有Api会出现一定风险。虽然已有黑科技可以绕过私有Api访问检查,但这些并不是长久之计。经过权衡,我们决定尽量避免调用私有Api。
Android P对私有Api访问限制,并不是一刀切禁止所有私有Api,而是通过级别划分,决定其危险级别。如果你实在需要调用某一深灰名单Api,你也可以提出申请,具体介绍参考Android 应用兼容性最佳实践&version=12020810&nettype=WIFI&lang=zh_CN&fontScale=100&pass_ticket=tRMP6avvOsruKLhr%2BCfRz2Mm8luK86D2BLX6wgqwwbsRGM7KBf6BriOQVoAahT17)。即使目前处于浅灰名单Api,在后续Android版本中可能会提供SDK接口,Google还是很善于倾听开发者意见。Android P私有Api访问限制并不是洪水猛兽,它主要解决Android版本升级时,国内App兼容性很差的问题。
在最开始设计爱奇艺组件化时,就是希望尽量少调用私有Api,同时借助Android提供打包插件完成打包工作。Android P私有Api访问限制,更加让我们坚定最初决策。Atlas最初是不支持新增四大组件,插件Manifest信息会合并至主客中,但即便如此还是存在较多私有api访问,因为它是在组件启动过程中去判断插件是否安装,以Activity为例。
调用Context#startActivity启动Activity,会执行到Instrumentation#execStartActivity方法,Atlas做法是在该方法内嵌入插件是否安装逻辑。此外还hook ActivityThread#mH同样用于拦截Activity启动判断插件是否安装。
Atlas的拦截系统启动四大组件启动过程判断插件是否安装,好处是对开发人员无任何侵入,都是基于Android SDK开发,但过多私有Api访问给应用稳定性带来挑战,特别是Android P限制也带来诸多不确定性。
Atlas虽然对开发人员无感知,但对后续Android版本升级适配存在较大风险,因此我们决定将Atlas对插件是否安装判断提供统一处理逻辑供开发人员调用。虽然这样做会给开发人员带来一定侵入性,但鱼和熊掌不可兼得,为了组件化健康稳定发展牺牲一点便利性未尝不可。当然,我们也可以通过AOP框架注入插件是否安装逻辑,大致思路如下。
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
findViewById(R.id.btn).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
Intent intent = new Intent();
intent.setClassName(MainActivity.this, "com.iqiyi.demo.FeatureActivity");
startActivity(intent);
}
});
}
例如在启动插件Activity时,是会调用startActivity,我们通过AOP框架扫描所有调用到该方法之处。加入以下逻辑。
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
findViewById(R.id.btn).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
Intent intent = new Intent();
intent.setClassName(MainActivity.this, "com.iqiyi.demo.FeatureActivity");
checkPlugin(intent);
}
});
}
public void checkPlugin(Intent intent) {
String className = intent.getComponent().getClassName();
String moduleName = PluginUtil.getModuleNameByClassName(className);
if (PluginUtil.checkPlugin(moduleName)) {
startActivity(intent);
} else {
PluginUtil.installPlugin(this, intent, smoduleNames);
}
}
每次在我们组件化遇到瓶颈时,Google就会在关键时候给我们带来惊喜。Instant Apps的打包插件虽然解决插件打包为apk,但我们还需处理以下问题。
将插件manifest信息合并至主客。
aapt2打包出在系统5.0下会有异常(我们尝试改过aapt2源码解决了此问题)。
在我们开始解决以上问题时,Google推出Android App Bundle。关于AAB简要介绍可以参考我们之前写的一篇文章系统级插件化?Google全新的动态化框架Android App Bundles分析,感兴趣朋友可以翻阅。AAB可以理解为一款全新的动态化框架,它是基于split apks完成,可有效减少应用程序包体积。
AAB与Instant Apps有何不同,我相信多数朋友会有此疑问。区别还是挺大的,Instant Apps是应用程序未下载,用户通过链接即可体验其部分功能,Instant Apps应用程序是运行在google play service上,而AAB插件是运行在咱们应用程序进程内。AAB强调的是减少app包体积同时提供一样的用户功能体验,提供按需下载安装模式。
上图是总结AAB打包结构图,从此图可以发现它和我们最初设计组件化方案一致(AAB打包结构与Atlas类似,说明Atlas设计很具有前瞻性)。AAB打包结构中,业务插件、业务组件、主客一起打包输出,业务插件的manifest信息会合并至主客中。需要说明的是,AAB并不支持新增Android四大组件.官方文档有提到过未来AAB会与Instant Apps融合(google提出play instant),提供更加强大功能。
AAB提供Play Core Library供开发者下载安装业务插件,感兴趣朋友可体验AAB官方示例。AAB看似一完美解决方案,但其需要google play service支持,国内环境无法使用,在国内必须提供下载安装业务插件核心逻辑。所以经过考量,我们做出如下决定:
模仿Play Core Library提供的SDK,山寨出一套一模一样SDK。好处是国际化版本走AAB,国内版本走自身组件化方案,无缝切换。
在AAB打包基础上,增加定制化插件处理(非常轻量,易于维护)。
BundleInstallRequest request = BundleInstallRequest.newBuilder()
.addPackage("com.iqiyi.feature")
.addPackage("com.qiyi.cartoons")
.build();
installManager.startInstall(request).addOnSuccessListener(new OnSuccessListener<Integer>() {
@Override
public void onSuccess(Integer integer) {
}
}).addOnFailureListener(new OnFailureListener() {
@Override
public void onFailure(Exception e) {
}
}).addOnCompleteListener(new OnCompleteListener<Integer>() {
@Override
public void onComplete(Task<Integer> task) {
}
});
以上代码片段是我们山寨SDK第一期版本,Play Core Library SDK是经过混淆处理,因此花费了一定时间在山寨Play Core上。
前文提到,新增四大组件肯定会存在私有Api访问,因此只能另寻他法。目前我们只支持“新增Activity”,Service、Receiver等暂不支持。Android提供更加细粒度视图容器Fragment,用于视图显示,且Fragment无需在Manifest中声明。因此我们将“新增Activity”降级为新增Fragment(改为新增Fragment对开发人员来说无任何侵入性),如此就能避免访问过多私有Api。Fragment加载必须要以Activity为载体,因此我们需要预埋一些Activity,用于处理新增Fragment。在Google今天IO大会推出Android Jetpack navigation组件,用它可以非常方便处理Fragment跳转,同时也提供一些类似Activity启动模式特性。
需要注意的是,业务插件新增Activity,只是一种业务插件当前版本的临时解决方案,我们应该在业务插件下个版本迭代中新增属于它的真正Activity。
在借鉴Google动态化方案做爱奇艺组件化过程中,也踩了相当多坑,限于本文篇幅,仅仅介绍爱奇艺组件化的演进过程以及设计初衷。如果有兴趣深入交流的朋友,欢迎留言。Android动态化方案在未来的前景我们不敢妄下结论,但跟随Google官方思路,会提供更佳的阳关大道。
Instant App 资源Package Id大于0x7f。
AAB 资源package Id小于0x7f。
第三方应用通过PackageInstaller安装split apk是会调起系统安装器。
split apk中通过Resources#getIdentifier获取资源id,packageName需要传入应用程序包名加上split name。