我的Android组件化实践

编译时间越来越长,时间=生命,我要救命。

项目框架

最开始项目只有一个app,项目结构很简单,就是一个业务module加上一个通用的基础库。


图1

随着业务的开展,有了第二个第三个乃至第N个app,项目结构变成如下样子。

图2

不同app有公共的功能,于是增加一个业务基础库,将公用部分移到里面。

这个框架维持了很长一段时间,随着业务的快速发展,开发人员的增加,代码也越来越臃肿,一些问题开始出现。正好由于多个app不利于推广,项目开始向云平台的方向发展,需要一个融合的平台app,将原先的app作为业务模块加入到平台app中。

问题在哪里

旧框架最大问题是业务基础库在膨胀,任意两个app需要用到的公用功能,只能将代码移入业务基础库。长时间后,这个库无法看了,里面什么都有,所有app都直接引用,耦合严重,简单归纳几个问题:

  • 业务基础库只会越来越大,功能简单地用文件夹区分,没人可以完全掌握;
  • 代码只增不减,编译时间越来越长;
  • 修改一个功能,不得不测试调用到这个功能的每个app,测试成本高;
  • 多名开发人员对业务基础库进行修改,带来较多代码冲突,沟通成本高;
  • 直接引用代码,缺少接口化和封装,业务迭代不够灵活。

一句话,整个工程要拆。

组件化实践

今时今日搜到的就是组件化和插件化两种,两者的讨论分析很多,插件化最大的好处是具备动态修改代码的能力。如果不需要这种动态功能,建议不要考虑插件化。官方不推荐的东西,没必要蹚浑水,支持插件化的库,都是国产厂商。

今次组件化的改造,最大目标是减少代码间的依赖,让各业务模块相对独立,原有业务app的开发人员可以更加专注于自己的部分,不需要次次全工程编译。

拆拆拆,最后拆成这个样子:


图3
  • 基础库是业务无关的,可以应用到任意项目里;
  • 业务基础库有个基础的base module,引用基础库。然后base module下,根据功能划分几个功能module。下一层根据自身需要,选择性包含;
  • 业务app层是组件化主要改进的地方,后面分析;
  • 平台app里只有一个mainapp module,这是一个壳工程,没有任何业务代码,是最终业务app集成的载体。
1、application和library

能够独立运行的app,module的属性是application,在build.gradle定义为:

apply plugin: "com.android.application"

不能独立运行,提供其他module依赖的叫library,在build.gradle定义为:

apply plugin: 'com.android.library'

看回上面的结构图,基础库和业务基础库自然都是library,mainapp是application。对于业务app,则要区分开发阶段和集成阶段。在开发阶段,希望业务app可以单独运行;集成阶段,希望业务app摇身一变,以library形式整合到平台app。

开发阶段和集成阶段的切换,可以通过在gradle定义全局变量,提供给module读取。我定义了一个config.gradle,在根build.gradle引入:

apply from: "config.gradle"

config.gradle的用途是管理配置、版本号和依赖库,避免散落到各个module中,方便集中管理。

ext {
    buildBizApp = false  //是否构建单独的业务app

    compileSdkVersion = 24
    buildToolsVersion = "26.0.1"
    minSdkVersion = 15
    targetSdkVersion = 19
    versionCode = 1
    versionName = "1.0.0"

    dependencies = [
            supportV4                    : 'com.android.support:support-v4:24.2.1',
            appcompatV7                  : 'com.android.support:appcompat-v7:24.2.1',
            recyclerviewV7               : 'com.android.support:recyclerview-v7:24.2.1',
            design                       : 'com.android.support:design:24.2.1'
    ]
}

在业务app的build.gradle,通过判断变量buildBizApp,达到自由切换的目的。

if (rootProject.ext.buildBizApp) {
    apply plugin: "com.android.application"
} else {
    apply plugin: 'com.android.library'
}
2、photo module

下面以拍照和看图的一个module为例子,它提供CameraActivity和PhotoActivity两个Activity。这是很基础的功能,如果是组件化之前的框架,我会毫不犹豫将功能扔进业务基础库,因为所有app都需要用到。

module之间的直接引用用起来很方便,但不利于长远代码的维护,毕竟只靠着包名划分功能,代码边界很容易破坏。最好的方法是编译上的隔离,调用者和photo module之间没有直接引用。

利用application和library切换的方法,我们可以达到如下效果。

图4

对于photo module,除了CameraActivity和PhotoActivity,还添加了DebugMainActivity。当photo module单独编译成photo app时,以DebugMainActivity为主页。当需要编译mainapp时,photo module作为library和其他module打包进mainapp,注意,这个时候DebugMainActivity没有用了,只有橙色框部分需要。红色箭头跳转不能再使用显式Intent,可以用隐式Intent跳转,或者使用后面介绍的路由跳转。

好处显而易见,模块间完全解耦了。在开发阶段,可以单独编译photo app,在DebugMainActivity中调试拍照和看图,省掉编译完整app和在app中点击测试的时间。

上面模式的实现,需要对配置进行改造,接下来一步步来讲解。

3、AndroidManifest合并

每一个module都有AndroidManifest.xml,很明显,当module分别处于application和library时,它需要的AndroidManifest.xml是不同的。

  • application:定义CameraActivity、PhotoActivity、DebugMainActivity,其中DebugMainActivity定义为启动页。
  • library:只需要定义CameraActivity、PhotoActivity,最终合并到mainapp的AndroidManifest.xml,描述了photo module提供了什么页面。
sourceSets {
    main {
        if (rootProject.ext.buildBizApp) {
            manifest.srcFile 'src/main/debug/AndroidManifest.xml'
        } else {
            manifest.srcFile 'src/main/AndroidManifest.xml'
            //移除debug资源
            java {
                exclude 'debug/**'
            }
        }
    }
}

AndroidManifest.xml的内容无什么特别,就不贴了,然后配置build.gradle,区分开发模式和集成模式对应AndroidManifest.xml文件位置。对于类似DebugMainActivity正式发布不需要的测试文件,可以放入java/debug文件夹,然后在集成模式排除。

4、Application处理

类似于AndroidManifest.xml,最终运行时Application只有一个,Application类也要区分开发模式和集成模式。

  • 开发模式:photo module有自己的Application,就叫PhotoApplication,里面初始化第三方库或者添加其他一些操作。对应地PhotoApplication需要定义到debug/AndroidManifest.xml,PhotoApplication文件放进java/debug中。
  • 集成模式:PhotoApplication是photo module单独编译时才有用的。集成后,需要在mainapp中定义MainApplication作为最终唯一的Application。

很容易想到,这个时候还需要一个BaseApplication,作为PhotoApplication和MainApplication的父类,提供公有的初始化方法和全局Context的获取。

5、路由跳转

由于模块的拆分,页面间无法使用显式Intent跳转。隐式Intent可以用,但是书写比较麻烦,一些面向切面的功能难以实现,所以我不用。

我使用了支持路由功能的这个库alibaba/ARouter。项目有详细的文档和demo,我就不复制粘贴了,下面说说我怎样用。

首先为CameraActivity定义地址,直接对class添加注解:

@Route(path = "/photo/activity/camera")

然后在需要调用的地方这样写:

Bundle bundle = new Bundle();
//set bundle

ARouter.getInstance()
       .build("/photo/activity/camera")
       .with(bundle)
       .navigation(activity, BizConstant.RequestCode.TAKE_PHOTO);

定义好路径、参数和requestCode,很简单地实现了一次路由跳转。

跳转一定成功吗?如果在开发阶段单独编译一个业务app,photo module不存在,前置登录的login module也不存在,如下图所示:

图5

photo module不存在比较好办,Arouter提供了一个Callback函数:

public abstract class NavCallback implements NavigationCallback {
    public NavCallback() {
    }

    public void onFound(Postcard postcard) {
    }

    public void onLost(Postcard postcard) {
    }

    public abstract void onArrival(Postcard var1);

    public void onInterrupt(Postcard postcard) {
    }
}
  1. 跳转失败时,可以直接返回一些测试数据,photo module应该是他人维护的稳定组件,不需要在开发阶段浪费点击时间。
  2. 如果需要测试调用photo module,只能启用集成模式,不过可以手动修改mainapp的配置,因为业务app层的module可以任意组合,只需要包括用到的,最大限度减少编译时间。

至于前置的login,那是必须得有,要输入账号密码好烦啊,而且login module在开发阶段我不想集成,有什么办法?

回想之前每个业务app层的module在开发阶段都有自己的Application,完全可以把模拟登陆过程放在里面。这是一个思路,写一次,受益几个月。

6、依赖注入

依赖注入大家应该要很熟悉,这是一种很好的代码解耦方式,不了解的请自行学习。

Android有dagger这个出名的依赖注入框架,不过我没有用,Arouter也带了依赖注入功能,够用了。

图6

项目采用MVP模式,view和presenter是一一对应,所以直接在Activity里new出Presenter对象,没弄什么花样。

Model层根据业务分为各种service,比如TaskService、UserService、SettingService,对外只暴露接口。这个时候使用依赖注入就很合适,Presenter只需要持有service的引用,实例由Arouter负责注入。

类似Activity定义路径,为service实现类定义注解:

@Route(path = "/test/service/task")
public class TaskServiceImpl implements TaskService {}

然后在Presenter,用Autowired注解需要被注入的Service。例子里有两种方式,一种是全局注入,一种是单个注入,根据实际情况使用。

@Autowired
TaskService taskService;
@Autowired
UserService userService;

public MyPresenter() {
    ARouter.getInstance().inject(this);
    //taskService = ARouter.getInstance().navigation(PollingTaskService.class);
    //userService = ARouter.getInstance().navigation(UserService.class);
}

上面无非是省略了new的过程,下面再举个复杂一点的例子。

图7

select user展示了user列表,提供选择user的功能,但是user列表的生成方法,只有对应的caller知道。在caller和select user已经组件化的情况下,可以使用依赖注入简化代码。

首先,在它们俩共同的业务库层定义一个接口BaseSelectUserService,里面有一个方法listUser()。caller1和caller2分别实现BaseSelectUserService接口,完成各自listUser()的逻辑。

BaseSelectUserService selectUserService = 
(BaseSelectUserService) ARouter.getInstance().build(servicePath).navigation();

最关键是为select user注入合适的SelectUserServiceImpl对象,其中servicePath是页面跳转传递过来的参数,这样就可以正确地调用到对应caller的listUser()。如果有第三个caller,完全不用管select user,只需要依葫芦画瓢实现BaseSelectUserService接口并传递service路径。

7、数据库

项目的数据库使用sqlite,orm框架是greenDAO。组件化之前,各业务app维护自己的数据库,集成后就要考虑各数据库之间的关系。

数据库表分为两类,一是公共的表,比如用户表、资源表;二是各业务app自身的业务表。组件化之后,有三种方向:

  1. 业务app依旧各自维护数据库;
  2. 提取公共表到上层的业务基础层,统一管理;
  3. 所有表放在业务基础层。

第二种是在模块划分上是理想的,公共表在业务基础层,业务表维护在各自业务app中,合情合理,不过有拦路虎。greenDAO通过@Entity将对象定义为表,在编译时,不同module中的表会分别生成DaoMaster和DaoSession,换言之,每个module都有一个数据库。跨数据库的多表查询难搞,不用第二种了。

第一种改动最少,但是由于公共表不是定义在业务基础库,所有公共表的逻辑都需要在业务app中实现一遍,我不能接受咯。

剩下第三种,虽然所有业务表需要定义在业务基础库,感觉不太好,但也仅仅是表定义,增删改查的逻辑还是在业务app中,在不修改数据库品种的情况下,不好也先用着。

8、资源冲突

多个module由多名开发人员并行开发,无可避免会出现资源名称的重复。在最终合并到mainapp时,肯定会出现冲突,最好的方法是为资源定义一个前缀。例如photo module中所有资源都加个前缀photo_。

build.gradle可以增加一个配置,强制资源指定前缀。

android{
       resourcePrefix "photo_"
}

这个配置只能限制xml文件里的资源,对于图片资源,养成习惯添加吧。

结束语

上面做了很多工作,但还是处于组件化的“初级阶段”。组件服务暴露、代码彻底隔离、组件生命周期、组件通信、组件测试等还有一大堆可以改进的方向,后续会一一实践。

多谢很多网上大神的努力和无私分享,获益良多。遇到疑问或者有更好的方法,欢迎交流。

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

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,463评论 25 707
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,598评论 18 139
  • 不怕跌倒,所以飞翔 组件化开发 参考资源 Android组件化方案 为什么要组件化开发 解决问题 实际业务变化非常...
    笔墨Android阅读 2,968评论 0 0
  • 一 事情要从去年(或者是前年,我也记不清了)学《等离子体动理学》朗道阻尼那部分说起,王老师的课件中出现这么一个公式...
    lishucai阅读 2,637评论 0 1
  • 任可和豆葵是两个热血青年。 他俩年龄不大,做事总是三分钟热度,还常常想着为家里人分忧,怎奈身无一技之长,因此总是吃...
    路索阅读 498评论 0 1