Android组件化业界方案总结

Android系统发展至今已经比较成熟,从每年的Google I/O 大会也可以看出,Google I/O大会的主题,从原来的Mobile First到AI First转变,实际上近两年Android系统也没有什么重大更新,除了加入AI元素,更多的还是系统性能提升,以及一些细节优化。随着Android的成熟,Google也开始注重开发效率的提升,从Kotlin的推出到官方出品的各种组件库,以及最近的App Bundle可见一斑。

移动端App经过几年的发展,功能已经相对丰富和稳定,然而随之而来的是代码的臃肿,耦合,常常牵一发而动全身,严重影响开发效率以及项目的稳定。于是模块化这种古老的思想开始在移动端应用,分而治之,每个模块单独开发维护,模块间通过相对简单的方式进行通信,从而降低整个App的耦合度,复杂度,提高效率和稳定性。然而不幸的是,Android目前并没有官方支持的模块化开发方式(App Bundle刚出,限制也比较多),各路Android大神各显神通,网上相关文章非常多,单其中一个问题的解决就可以找到几个库。JOOX目前正在对项目进行重构,对业界组件化相关的解决方案做了一下总结。



目录

- 一, 插件化与组件化

- 二,为什么要组件化

- 三,组件化主要问题和业界方案对比

- 四,总结



一,插件化与组件化

我们首先来区分下概念,“插件化”更多的体现在结果,即某个功能模块可以不跟随App安装时立即安装在系统,可以在随后开发者认为合适的时机动态地加载。而“组件化”更多提现在过程,强调的开发模式,即在开发过程中不同功能模块的代理可以隔离,单独维护开发。组件化并不具备插件化运行时动态加载功能的特性。

二,为什么要组件化

组件化可以达到什么效果呢? 一个完全组件化的项目大概是这样的:

20180605194747

- 对于每一个组件,如图中的组件A,B,C等可以单独作为一个Android App运行,也可以被当成library被集合到主App中运行。

- 在开发阶段主App可以选择加载任意一个或多个组件,而不需要每个组件都加载。

- 开发阶段任意一个组件都不能直接去引用另外一个组件的代码,即在gradle中一个组件并不会直接去compile另外一个组件,开发人员连代码都import不了,这样就实现了代码的物理隔离。

从以上特性可以看出,组件化的项目有以下优势:

- 编译速度更快
虽然现在有instant run和其他编译优化组件但是实际使用时还是有很多坑,组件化使得每次运行只加载需要的代码,即使clean整个项目,编译速度而已可以大大提高。

- 调试方便

想象下没有组件化时,终端同学与后台同学调试接口是是怎样的?我们必须整个项目运行,同时有时候业务逻辑比较复杂时,我们需要进行很多的操作,才能进入到对应的功能界面进行调试,而组件化后,我们可以单独启动需要调试的业务模块,单独调试。

- 更专注业务,开发效率高。
开发人员可以聚焦自己的业务开发,代码物理上已经隔离开,所以不需要担心自己的提交的代码会干扰别人,同时别人的代码也不会干扰自己。

- 项目更稳定
组件化后每个组件应该是高度内聚的,我们可以很方便地进行单元测试,所以正常来说基础库,以及每个组件应该都是比较稳定的。

三,组件化主要问题和业界方案对比

​ 组件化听起来很完美,但是实际上在Android平台上实现起来还是很多坑,接下来我们会总结下Android组件化过程中一些主要的问题以及业界的一些解决方案。

- 单独运行,集成调试,代码隔离
上面提到对于组件化的项目,每个组件可以单独运行,也可以被集成到主app中,那具体是怎么实现的呢?

20180622105946

以上面的demo工程为例,module component_a和component_b既可以被Android Application也可以被当成Android Library, 我们知道一个Android Studio的module 是library还是application是通过module的build.gradle配置的,

20180622112056

所以实际很简单,我们可以定一个变量,来控制编译类型:

1
2
3
4
5
if (IsBuildModule.toBoolean()) {
apply plugin: 'com.android.application'
} else {
apply plugin: 'com.android.library'
}

业界很多组件化方案基本都是利用这点进行改进,但是这种方法,每次修改gradle配置需要重新执行gradle sync,项目一大每次执行也需要比较长时间,所以需要进行改进,目前看到做得比较好的方案是得到App的方案JIMU(原先叫DDComponent,作者离职后改名为JIMU并单独维护),作者在网上也有写了一些文章介绍, 不过组件单独运行集成调试这块作者并没有详细分析,我们clone下代码来看下,具体实现是自定义了一个gradle插件代码在JIMU/build-gradle/下的ComBuild.groovy里面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
void apply(Project project) {
...

//对于isRunAlone==true的情况需要根据实际情况修改其值,
// 但如果是false,则不用修改
boolean isRunAlone = Boolean.parseBoolean((project.properties.get("isRunAlone")))
String mainmodulename = project.rootProject.property("mainmodulename")
if (isRunAlone && assembleTask.isAssemble) {
//对于要编译的组件和主项目,isRunAlone修改为true,其他组件都强制修改为false
//这就意味着组件不能引用主项目,这在层级结构里面也是这么规定的
if (module.equals(compilemodule) || module.equals(mainmodulename)) {
isRunAlone = true
} else {
isRunAlone = false
}
}
project.setProperty("isRunAlone", isRunAlone)

//根据配置添加各种组件依赖,并且自动化生成组件加载代码
if (isRunAlone) {
project.apply plugin: 'com.android.application'
....
System.out.println("apply plugin is " + 'com.android.application')
if (assembleTask.isAssemble && module.equals(compilemodule)) {
compileComponents(assembleTask, project)
project.android.registerTransform(new ComCodeTransform(project))
}
} else {
project.apply plugin: 'com.android.library'
System.out.println("apply plugin is " + 'com.android.library')
}

}

为了方便查看,省略了部分代码,其实很简单,这个gradle插件会读取已经定义好的配置,如果用户自己设置为某个module的’isRunAlone’值为true,则调用apply plugin: ‘com.android.application’, 但是当用户当前运行的是主工程时,则忽略这个配置,只有主工程为application其他工程自动修改为library,即做到了根据实际情况去决定到底是application还是library。

同时这里也体现了另外一个重要的问题,就是代码隔离,即在开发编写代码阶段,根本引用不了不同组件里的代码,上面的做法做到了物理隔离,即我们在IDE里连import都不行,不同组件之间只能通过预先定好的方式进行调用,不用担心原有代码被随意修改,减少不稳定因素。

上面提到的都是基于Android Studio module级别的隔离,module过多时会影响编译效率,而且有时候我们需要一种粒度更细的隔离,微信在微信android模块化架构重构实践 一文中提供另外了一种代码隔离的 方式,即pins工程,微信并没有给出具体的实现,我们可以通过一个简单的demo来模拟,

20180626105329

配置文件如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
android {
compileSdkVersion Integer.parseInt(project.ANDROID_BUILD_SDK_VERSION)
buildToolsVersion project.ANDROID_BUILD_TOOLS_VERSION

...

sourceSets {
main {

manifest.srcFile 'src/runalone/main/AndroidManifest.xml'

java.srcDir("src/runalone/main/java")
res.srcDir("src/runalone/main/res")

def dirs = ['p_user_profile', 'p_user_info']

dirs.each { dir ->
java.srcDir("src/$dir/main/java")
res.srcDir("src/$dir/main/res")
}

}
}

}

gradle 3.0以后支持了多种依赖方式

gralde 3.0以上 gradle 2.x 说明
implementation compile 编译期间只对直接依赖的组件可见,在运行期间所有组件可见
api compile 编译期,运行期对所有组件可见
compileOnly provided 只参与编译不打包到apk
runtimeOnly apk 编译期间不可见,会打包到apk

直观上来看api与compile基本没区别,对于implementation, 假如我们有A, B,C三个模块,B使用implementation 依赖了A, 同时C用implementation依赖了B, 那么在编译期C不能直接访问A里面的类。同时当A里面的类进行修改时,gradle只会重新编译B,不回重新编译C,以此提高编译效率。

对于runtimeOnly 看起来很符合代码隔离的需求,但是不支持aar,gradle 3.0 runtimeOnly开始支持aar,但是不支持资源隔离。

如果只要求代码隔离,那么runtimeOnly基本是符合需求的,也不要自己编写任何代码。

  • 资源隔离

    一般项目中除了代码当然还有资源,资源隔离一个比较常见的问题是资源冲突,假如A, B两人同时分别在comonent_a和component_b模块中开发,理想的情况肯定是A,B不需要互相干预,可以任意定义和使用资源,但是不要忘了我们最终只会打包成一个apk,所以资源冲突是一个必须解决的问题,假设A在模块component_a中开发定义了一个字符串资源demo_string=”demo_string_a”,B在模块B中也定义个同样key的字符串demo_string=”demo_string_b”, 这个时候就出问题了,目前通常的做法是在gradle配置文件中加入

    资源前缀

    1
    resource_prefix = "component_a"

    可惜的是,即使这样配置了Android Studio并不会为每个模块的资源自动命名,实际只是在lint过程中加了一次检查,当每个模块里面的资源没有按照配置添加前缀时会导致编译失败。

    20180626110721

  • 组件通信机制

    组件间被互相隔离那么必然存在组件间怎样通信的问题,这也是各种组件化框架差异比较大的地方,

    | | 微信 | JIMU(DDComponent) | CC/ModularizationArchitecture | 爱奇艺Andromeda | 阿里ARouter |
    | ————– | ——– | —————– | —————————– | —————————– | ————- |
    | 通信机制 | 接口下沉 | 接口下沉+路由 | 组件总线(协议通信) | 支持跨进程的接口下沉+事件总线 | 接口下沉+路由 |
    | 是否支持跨进程 | | No | Yes | Yes | No |

    组件间通信的方式目前常见的就是上面的几种,我们简单看下这几种方式

    • 路由:比较著名的是阿里的ARouter,不过目前主要主要用来进行UI跳转,下面会有介绍。
    • 事件总线:早在组件化前eventbus就已经被广泛使用,强大的解耦能力和非常方便的调用方式一度非常受欢迎,然而随着项目越来越大,事件总线也暴露出不少问题,项目中的event越来越多,调用逻辑不清晰,调试困难,过度解耦导致的项目太过松散,导致很多代码基本不敢动
    • 组件总线(协议通信):

    我们通过一段简单的代码来看下协议通信:

    1
    2
    3
    4
    cc = CC.obtainBuilder("component_a")
    .setActionName("showActivityA")
    .build();
    result = cc.call();
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    public class ComponentA implements IComponent {
    @Override
    public String getName() {
    //组件的名称,调用此组件的方式:
    return "component_a";
    }

    @Override
    public boolean onCall(CC cc) {
    String actionName = cc.getActionName();
    switch (actionName) {
    case "showActivityA":
    openActivity(cc);
    break;
    ...
    }
    return false;
    }
    }

    简单来说组件总线的本质是转发调用请求 ,各个组件通过字符串的方式,暴露可以调用的接口,调用方,通过预先定义好的功能字符串来进行调用,相当于定义了一个协议,使用方通过预先定义好的协议进行调用。

    好处在于通用性很高,解耦程度也高 ,然而对于协议通信,协议的制定和修改需要让通信双方都能够获知,同时当协议更改时,双方的协议更新同步也会比较复杂,而且容易出错。

    • 接口下沉:数据结构+接口,这也是微信推荐的通信方式,个人也非常赞同,实现和维护都很简单,也没有复杂的调用过程。

    我们直接看下demo

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    public class ComponentManager {

    private ComponentManager() {}

    private static Map<Class, IComponent> mComponents = new HashMap<>();

    public static <T extends IComponent> T get (Class<T> clz) {
    if (!clz.isInterface()) {
    throw new RuntimeException(String.format("clz %s should be a interface class", clz.getCanonicalName()));
    }

    return (T) mComponents.get(clz);
    }

    public static <T extends IComponent> void register (Class<T> clz, T impl) {
    if (clz == null || impl == null) {
    return;
    }

    if (mComponents.containsKey(clz)) {
    return;
    }

    mComponents.put(clz, impl);
    }

    public static void unRegister (Class clz) {
    if (clz == null) {
    return;
    }

    mComponents.remove(clz);
    }

    }
    1
    2
    3
    4
    5
    6
    7
    8
    public interface IUserComponent extends IComponent {

    public long getWmid();
    public boolean isLoginOK();
    public boolean isVip();
    public boolean isVVip();

    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    public class UserComponent implements IUserComponent {

    @Override
    public long getWmid() {
    return 10086;
    }

    @Override
    public boolean isLoginOK() {
    return false;
    }

    @Override
    public boolean isVip() {
    return false;
    }

    @Override
    public boolean isVVip() {
    return false;
    }
    }
1
2
3
4
5
6
注册
ComponentManager.register(IUserComponent.class, new UserComponent());

调用代码
UserComponent component = ComponentManager.get(IUserComponent.class);
component.getWmid()
  • 支持跨进程的接口通信

    照理来说接口通信已经相当完美,还有什么问题呢? 随着项目越来越庞大,很多的项目开始使用了多进程,对于默认的接口通信机制并不支持多进程,因为接口管理类是在一个进程中注册的,其他进程是拿不到的。 假如进程间的调用不多,那么可以对每个接口自己编写aidl,把跨进程的调用隐藏在对外的接口中。

    一般的多进程通信机制如ModularizationArchitecture中描述

    20180626171324

    即有个专门用来管理各个进程暴露的接口的进程我们叫路由进程,这个进程可以是在主进程或者单独新开的进程里(保证这个进程的生命周期最长即可),每个进程向这个进程注册自己的服务,每次当进程A需要调用进程N里的接口时,先通过路由进程获取进程N的binder引用,然后再进行调用。即每次通信都需要有两次IPC调用,爱奇艺的Andromeda对此进行了优化

    163aeed44d514808

    可以这么理解,当进程A向路由进程注册自己的服务时,路由进程里持有所有进程的服务管理类Dispather会反向将自己注册回进程A,每个进程都有一个RemoteManger类用来保存这个引用,当进程A需要进程N的服务时,可以直接通过刚刚保存的所有进程的服务管理类Dispather的引用获取进程N的接口binder引用,同时缓存下来,那么当下一次进程A需要进程N的服务时,可以直接用刚刚缓存下来的引用进行调用,就只需要一次IPC调用了。

  • 页面跳转方式

    页面跳转可以用组件间通信的方式来实现,即暴露一个接口,直接调这个接口去startActivity,然而很多时候可能会有很多的页面跳转,这样就需要写很多的接口,而实际上这些接口除了打开一个新的页面,没有其他用处,同时很多时候页面跳转是一次性行为,使用接口看起来会很重,可以认为页面跳转是比接口调用更为轻量级的一种通信方式,我们需要一种更加轻量级的方式,目前很流行的方式就是Router。

    Router有比较多的实现,我们来看下阿里的ARouter的调用方式:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    @Route(path = "/test/activity")
    public class YourActivity extend Activity {
    ...
    }

    ARouter.getInstance().build("/test/activity").navigation();

    ARouter.getInstance().build("/test/1")
    .withLong("key1", 666L)
    .withString("key3", "888")
    .withObject("key4", new Test("Jack", "Rose"))
    .navigation();

    可以看到确实比原生的startAcivity调用更加方便,参数的传递也更加简单,那除了方便还有还什么理由要用Router呢?

    • 解耦。通过路由的方式,我们不再需要直接显式地调用相关的Activity,少了很多的直接代码依赖。

    • 动态拦截 ,出错处理。原生的startActivity方式完全由系统管理,无法控制,通过路由处理,我们可以进行拦截,比如跳转的页面需要登录,那我们可以先跳到登录页面,登录成功后再打开后面的页面,同时我们也可以在跳转出错时进行降级处理。

    • 统一跳转,H5,Android, iOS可以统一跳转方式,对于一些场景比如一些推送点击后的需要打开的页面,
      我们也可以很方便地进行配置。

目前Router的方案有很多,阿里ARouter,AirbnbDeepLinkDispatchActivityRouter 等, Android 组件化 —— 路由设计最佳实践做了比较详细的对比,这里不再赘述。

  • 数据传输

    为什么要用content provider

四,总结

​ Android组件化目前虽然并没有统一的解决方案,也有一些问题,但是基本都有对应的解决或者规避方法。我们只需要根据实际情况选择合适的方案即可。还有很多其他问题需要我们认真去思考。

​ 项目到底需不需要组件化?

​ 个人觉得对于一些比较小,开发人员少的项目,不需要考虑了,强行使用组件化只会增加维护和开发成本。当然这不意味着放任代码肆意发展,需要开发人员自己把握,随时做好重构的准备。

​ 怎样实施组件化?

​ 看了很多文章,组件化的思想很清晰,但是实际实施起来确实困难重重,陈旧又庞大的代码,牵一发而动全身,并行进行的业务需求开发,项目稳定性要求等等,导致重构难以进行。微信android模块化架构重构实践业内首个支持渐进式组件化的开源框架都提供了很好的经验,组件化或者说重构都是一件需要循序渐进的事,不可能像修复bug一样一下改完直接合入,我们可以立即开始,处理好新老代码并存的情况,这样就可以跟随版本迭代不停完善,逐步迁移到新的代码上,即“拆分” -> “灰度“ -> ”回流“。

好的架构不是设计出来的,而是演进出来的。 不同项目在不同的阶段需要不同的架构,适合的才是最好的。

参考文章:

Android彻底组件化方案实践

微信Android模块化重构实践

聚美组件化实践之路

美团外卖Android平台化架构演进实践

Android架构思考(模块化、多进程)

悦跑圈Android单业务开发,提高编译效率15倍

总结一波安卓组件化开源方案

刚刚,爱奇艺发布重磅开源项目!

Android 组件化 —— 路由设计最佳实践
业内首个支持渐进式组件化的开源框架