编译时间越来越长,时间=生命,我要救命。
项目框架
最开始项目只有一个app,项目结构很简单,就是一个业务module加上一个通用的基础库。
随着业务的开展,有了第二个第三个乃至第N个app,项目结构变成如下样子。
不同app有公共的功能,于是增加一个业务基础库,将公用部分移到里面。
这个框架维持了很长一段时间,随着业务的快速发展,开发人员的增加,代码也越来越臃肿,一些问题开始出现。正好由于多个app不利于推广,项目开始向云平台的方向发展,需要一个融合的平台app,将原先的app作为业务模块加入到平台app中。
问题在哪里
旧框架最大问题是业务基础库在膨胀,任意两个app需要用到的公用功能,只能将代码移入业务基础库。长时间后,这个库无法看了,里面什么都有,所有app都直接引用,耦合严重,简单归纳几个问题:
- 业务基础库只会越来越大,功能简单地用文件夹区分,没人可以完全掌握;
- 代码只增不减,编译时间越来越长;
- 修改一个功能,不得不测试调用到这个功能的每个app,测试成本高;
- 多名开发人员对业务基础库进行修改,带来较多代码冲突,沟通成本高;
- 直接引用代码,缺少接口化和封装,业务迭代不够灵活。
一句话,整个工程要拆。
组件化实践
今时今日搜到的就是组件化和插件化两种,两者的讨论分析很多,插件化最大的好处是具备动态修改代码的能力。如果不需要这种动态功能,建议不要考虑插件化。官方不推荐的东西,没必要蹚浑水,支持插件化的库,都是国产厂商。
今次组件化的改造,最大目标是减少代码间的依赖,让各业务模块相对独立,原有业务app的开发人员可以更加专注于自己的部分,不需要次次全工程编译。
拆拆拆,最后拆成这个样子:
- 基础库是业务无关的,可以应用到任意项目里;
- 业务基础库有个基础的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切换的方法,我们可以达到如下效果。
对于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也不存在,如下图所示:
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) {
}
}
- 跳转失败时,可以直接返回一些测试数据,photo module应该是他人维护的稳定组件,不需要在开发阶段浪费点击时间。
- 如果需要测试调用photo module,只能启用集成模式,不过可以手动修改mainapp的配置,因为业务app层的module可以任意组合,只需要包括用到的,最大限度减少编译时间。
至于前置的login,那是必须得有,要输入账号密码好烦啊,而且login module在开发阶段我不想集成,有什么办法?
回想之前每个业务app层的module在开发阶段都有自己的Application,完全可以把模拟登陆过程放在里面。这是一个思路,写一次,受益几个月。
6、依赖注入
依赖注入大家应该要很熟悉,这是一种很好的代码解耦方式,不了解的请自行学习。
Android有dagger这个出名的依赖注入框架,不过我没有用,Arouter也带了依赖注入功能,够用了。
项目采用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的过程,下面再举个复杂一点的例子。
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自身的业务表。组件化之后,有三种方向:
- 业务app依旧各自维护数据库;
- 提取公共表到上层的业务基础层,统一管理;
- 所有表放在业务基础层。
第二种是在模块划分上是理想的,公共表在业务基础层,业务表维护在各自业务app中,合情合理,不过有拦路虎。greenDAO通过@Entity将对象定义为表,在编译时,不同module中的表会分别生成DaoMaster和DaoSession,换言之,每个module都有一个数据库。跨数据库的多表查询难搞,不用第二种了。
第一种改动最少,但是由于公共表不是定义在业务基础库,所有公共表的逻辑都需要在业务app中实现一遍,我不能接受咯。
剩下第三种,虽然所有业务表需要定义在业务基础库,感觉不太好,但也仅仅是表定义,增删改查的逻辑还是在业务app中,在不修改数据库品种的情况下,不好也先用着。
8、资源冲突
多个module由多名开发人员并行开发,无可避免会出现资源名称的重复。在最终合并到mainapp时,肯定会出现冲突,最好的方法是为资源定义一个前缀。例如photo module中所有资源都加个前缀photo_。
build.gradle可以增加一个配置,强制资源指定前缀。
android{
resourcePrefix "photo_"
}
这个配置只能限制xml文件里的资源,对于图片资源,养成习惯添加吧。
结束语
上面做了很多工作,但还是处于组件化的“初级阶段”。组件服务暴露、代码彻底隔离、组件生命周期、组件通信、组件测试等还有一大堆可以改进的方向,后续会一一实践。
多谢很多网上大神的努力和无私分享,获益良多。遇到疑问或者有更好的方法,欢迎交流。