首发于《程序员》杂志五月刊

一、前言

万维网发明人 Tim Berners-Lee 谈到设计原理时说过:“简单性和模块化是软件工程的基石;分布式和容错性是互联网的生命。” 由此可见模块化之于软件工程领域的重要性。

从 2016 年开始,模块化在 Android 社区越来越多的被提及。随着移动平台的不断发展,移动平台上的软件慢慢走向复杂化,体积也变得臃肿庞大;为了降低大型软件复杂性和耦合度,同时也为了适应模块重用、多团队并行开发测试等等需求,模块化在 Android 平台上变得势在必行。阿里 Android 团队在年初开源了他们的容器化框架 Atlas 就很大程度说明了当前 Android 平台开发大型商业项目所面临的问题。

二、什么是模块化

那么什么是模块化呢?《 Java 应用架构设计:模块化模式与 OSGi 》一书中对它的定义是:模块化是一种处理复杂系统分解为更好的可管理模块的方式。

上面这种描述太过生涩难懂,不够直观。下面这种类比的方式则可能加容易理解。

我们可以把软件看做是一辆汽车,开发一款软件的过程就是生产一辆汽车的过程。一辆汽车由车架、发动机、变数箱、车轮等一系列模块组成;同样,一款大型商业软件也是由各个不同的模块组成的。

汽车的这些模块是由不同的工厂生产的,一辆 BMW 的发动机可能是由位于德国的工厂生产的,它的自动变数箱可能是 Jatco(世界三大变速箱厂商之一)位于日本的工厂生产的,车轮可能是中国的工厂生产的,最后交给华晨宝马的工厂统一组装成一辆完整的汽车。这就类似于我们在软件工程领域里说的多团队并行开发,最后将各个团队开发的模块统一打包成我们可使用的 App 。

一款发动机、一款变数箱都不可能只应用于一个车型,比如同一款 Jatco 的 6AT 自动变速箱既可能被安装在 BMW 的车型上,也可能被安装在 Mazda 的车型上。这就如同软件开发领域里的模块重用。

到了冬天,特别是在北方我们可能需要开着车走雪路,为了安全起见往往我们会将汽车的公路胎升级为雪地胎;轮胎可以很轻易的更换,这就是我们在软件开发领域谈到的低耦合。一个模块的升级替换不会影响到其它模块,也不会受其它模块的限制;同时这也类似于我们在软件开发领域提到的可插拔。

三、模块化分层设计

上面的类比很清晰的说明的模块化带来的好处:

  • 多团队并行开发测试;
  • 模块间解耦、重用;
  • 可单独编译打包某一模块,提升开发效率。

《安居客 Android 项目架构演进》这篇文章中,我介绍了安居客 Android 端的模块化设计方案,这里我还是拿它来举例。但首先要对本文中的组件模块做个区别定义

  • 组件:指的是单一的功能组件,如地图组件(MapSDK)、支付组件(AnjukePay)、路由组件(Router)等等;

  • 模块:指的是独立的业务模块,如新房模块(NewHouseModule)、二手房模块(SecondHouseModule)、即时通讯模块(InstantMessagingModule)等等;模块相对于组件来说粒度更大。

具体设计方案如下图:

整个项目分为三层,从下至上分别是:

  • Basic Component Layer: 基础组件层,顾名思义就是一些基础组件,包含了各种开源库以及和业务无关的各种自研工具库;
  • Business Component Layer: 业务组件层,这一层的所有组件都是业务相关的,例如上图中的支付组件 AnjukePay、数据模拟组件 DataSimulator 等等;
  • Business Module Layer: 业务 Module 层,在 Android Studio 中每块业务对应一个单独的 Module。例如安居客用户 App 我们就可以拆分成新房 Module、二手房 Module、IM Module 等等,每个单独的 Business Module 都必须准遵守我们自己的 MVP 架构。

我们在谈模块化的时候,其实就是将业务模块层的各个功能业务拆分层独立的业务模块。所以我们进行模块化的第一步就是业务模块划分,但是模块划分并没有一个业界通用的标准,因此划分的粒度需要根据项目情况进行合理把控,这就需要对业务和项目有较为透彻的理解。拿安居客来举例,我们会将项目划分为新房模块、二手房模块、IM 模块等等。

每个业务模块在 Android Studio 中的都是一个 Module ,因此在命名方面我们要求每个业务模块都以 Module 为后缀。如下图所示:

对于模块化项目,每个单独的 Business Module 都可以单独编译成 APK。在开发阶段需要单独打包编译,项目发布的时候又需要它作为项目的一个 Module 来整体编译打包。简单的说就是开发时是 Application,发布时是 Library。因此需要在 Business Module 的 build.gradle 中加入如下代码:

if(isBuildModule.toBoolean()){
apply plugin: 'com.android.application'
}else{
apply plugin: 'com.android.library'
}

isBuildModule 在项目根目录的 gradle.properties 中定义:

isBuildModule=false

同样 Manifest.xml 也需要有两套:

sourceSets {
main {
if (isBuildModule.toBoolean()) {
manifest.srcFile 'src/main/debug/AndroidManifest.xml'
} else {
manifest.srcFile 'src/main/release/AndroidManifest.xml'
}
}
}

如图:

debug 模式下的 AndroidManifest.xml :

<application
...
>
<activity
android:name="com.baronzhang.android.newhouse.NewHouseMainActivity"
android:label="@string/new_house_label_home_page">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>

realease 模式下的 AndroidManifest.xml :

<application
...
>
<activity
android:name="com.baronzhang.android.newhouse.NewHouseMainActivity"
android:label="@string/new_house_label_home_page">
<intent-filter>
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<action android:name="android.intent.action.VIEW" />
<data android:host="com.baronzhang.android.newhouse"
android:scheme="router" />
</intent-filter>
</activity>
</application>

同时针对模块化我们也定义了一些自己的游戏规则:

  • 对于 Business Module Layer,各业务模块之间不允许存在相互依赖关系,它们之间的跳转通讯采用路由框架 Router 来实现(后面会介绍 Router 框架的实现);
  • 对于 Business Component Layer,单一业务组件只能对应某一项具体的业务,个性化需求对外部提供接口让调用方定制;
  • 合理控制各组件和各业务模块的拆分粒度,太小的公有模块不足以构成单独组件或者模块的,我们先放到类似于 CommonBusiness 的组件中,在后期不断的重构迭代中视情况进行进一步的拆分;
  • 上层的公有业务或者功能模块可以逐步下放到下层,合理把握好度就好;
  • 各 Layer 间严禁反向依赖,横向依赖关系由各业务 Leader 和技术小组商讨决定。

四、模块间跳转通讯(Router)

对业务进行模块化拆分后,为了使各业务模块间解耦,因此各个 Bussiness Module 都是独立的模块,它们之间是没有依赖关系。那么各个模块间的跳转通讯如何实现呢?

比如业务上要求从新房的列表页跳转到二手房的列表页,那么由于是 NewHouseModule 和 SecondHouseModule 之间并不相互依赖,我们通过想如下这种显式跳转的方式来实现 Activity 跳转显然是不可能的实现的。

Intent intent = new Intent(NewHouseListActivity.this, SecondHouseListActivity.class);
startActivity(intent);

有的同学可能会想到用隐式跳转,通过 Intent 匹配规则来实现:

Intent intent = new Intent(Intent.ACTION_VIEW, "<scheme>://<host>:<port>/<path>");
startActivity(intent);

但是这种代码写起来比较繁琐,且容易出错,出错也不太容易定位问题。因此一个简单易用、解放开发的路由框架是必须的了。

我自己实现的路由框架分为路由(Router)参数注入器(Injector)两部分:

Router 提供 Activity 跳转传参的功能;Injector 提供参数注入功能,通过编译时生成代码的方式在 Activity 获取获取传递过来的参数,简化开发。

4.1 Router

路由(Router)部分通过 Java 注解结合动态代理来实现,这一点和 Retrofit 的实现原理是一样的。

首先需要定义我们自己的注解(篇幅有限,这里只列出少部分源码)。

用于定义跳转 URI 的注解 FullUri:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface FullUri {
String value();
}

用于定义跳转传参的 UriParam( UriParam 注解的参数用于拼接到 URI 后面):

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface UriParam {
String value();
}

用于定义跳转传参的 IntentExtrasParam( IntentExtrasParam 注解的参数最终通过 Intent 来传递):

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface IntentExtrasParam {
String value();
}

然后实现 Router ,内部通过动态代理的方式来实现 Activity 跳转:

public final class Router {
...
public <T> T create(final Class<T> service) { return (T) Proxy.newProxyInstance(service.getClassLoader(), new Class[]{service}, new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { FullUri fullUri = method.getAnnotation(FullUri.class);
StringBuilder urlBuilder = new StringBuilder();
urlBuilder.append(fullUri.value());
//获取注解参数
Annotation[][] parameterAnnotations = method.getParameterAnnotations();
HashMap<String, Object> serializedParams = new HashMap<>();
//拼接跳转 URI
int position = 0;
for (int i = 0; i < parameterAnnotations.length; i++) {
Annotation[] annotations = parameterAnnotations[i];
if (annotations == null || annotations.length == 0)
break; Annotation annotation = annotations[0];
if (annotation instanceof UriParam) {
//拼接 URI 后的参数
...
} else if (annotation instanceof IntentExtrasParam) {
//Intent 传参处理
...
}
}
//执行Activity跳转操作
performJump(urlBuilder.toString(), serializedParams);
return null;
}
});
}
...
}

上面是 Router 实现的部分代码,在使用 Router 来跳转的时候,首先需要定义一个 Interface(类似于 Retrofit 的使用方式):

public interface RouterService {

    @FullUri("router://com.baronzhang.android.router.FourthActivity")
void startUserActivity(@UriParam("cityName")
String cityName, @IntentExtrasParam("user") User user); }

接下来我们就可以通过如下方式实现 Activity 的跳转传参了:

 RouterService routerService = new Router(this).create(RouterService.class);

 User user = new User("张三", 17, 165, 88);
routerService.startUserActivity("上海", user);

4.2 Injector

通过 Router 跳转到目标 Activity 后,我们需要在目标 Activity 中获取通过 Intent 传过来的参数:

getIntent().getIntExtra("intParam", 0);

getIntent().getData().getQueryParameter("preActivity");

为了简化这部分工作,路由框架 Router 中提供了 Injector 模块在编译时生成上述代码。参数注入器(Injector)部分通过 Java 编译时注解来实现,实现思路和 ButterKnife 这类编译时注解框架类似。

首先定义我们的参数注解 InjectUriParam :

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.CLASS)
public @interface InjectUriParam {
String value() default "";
}

然后实现一个注解处理器 InjectProcessor ,在编译阶段生成获取参数的代码:

@AutoService(Processor.class)
public class InjectProcessor extends AbstractProcessor {
...
@Override
public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) { //解析注解
Map<TypeElement, TargetClass> targetClassMap = findAndParseTargets(roundEnvironment); //解析完成后,生成的代码的结构已经有了,它们存在InjectingClass中
for (Map.Entry<TypeElement, TargetClass> entry : targetClassMap.entrySet()) {
...
}
return false;
}
...
}

使用方式类似于 ButterKnife ,在 Activity 中我们使用 Inject 来注解一个全局变量:

@Inject User user;

然后 onCreate 方法中需要调用 inject(Activity activity) 方法实现注入:

RouterInjector.inject(this);

这样我们就可以获取到前面通过 Router 跳转的传参了。

由于篇幅限制,加上为了便于理解,这里只贴出了极少部分 Router 框架的源码。希望进一步了解 Router 实现原理的可以到 GiuHub 去翻阅源码,Router 的实现还比较简陋,后面会进一步完善功能和文档,之后也会有单独的文章详细介绍。源码地址:https://github.com/BaronZ88/Router

五、问题及建议

5.1 资源名冲突

对于多个 Bussines Module 中资源名冲突的问题,可以通过在 build.gradle 定义前缀的方式解决:

defaultConfig {
...
resourcePrefix "new_house_"
...
}

而对于 Module 中有些资源不想被外部访问的,我们可以创建 res/values/public.xml,添加到 public.xml 中的 resource 则可被外部访问,未添加的则视为私有:

<resources>
<public name="new_house_settings" type="string"/>
</resources>

5.2 重复依赖

模块化的过程中我们常常会遇到重复依赖的问题,如果是通过 aar 依赖, gradle 会自动帮我们找出新版本,而抛弃老版本的重复依赖。如果是以 project 的方式依赖,则在打包的时候会出现重复类。对于这种情况我们可以在 build.gradle 中将 compile 改为 provided,只在最终的项目中 compile 对应的 library ;

其实从前面的安居客模块化设计图上能看出来,我们的设计方案能一定程度上规避重复依赖的问题。比如我们所有的第三方库的依赖都会放到 OpenSoureLibraries 中,其他需要用到相关类库的项目,只需要依赖 OpenSoureLibraries 就好了。

5.3 模块化过程中的建议

对于大型的商业项目,在重构过程中可能会遇到业务耦合严重,难以拆分的问题。我们需要先理清业务,再动手拆分业务模块。比如可以先在原先的项目中根据业务分包,在一定程度上将各业务解耦后拆分到不同的 package 中。比如之前新房和二手房由于同属于 app module,因此他们之前是通过隐式的 intent 跳转的,现在可以先将他们改为通过 Router 来实现跳转。又比如新房和二手房中公用的模块可以先下放到 Business Component Layer 或者 Basic Component Layer 中。在这一系列工作完成后再将各个业务拆分成多个 module 。

模块化重构需要渐进式的展开,不可一触而就,不要想着将整个项目推翻重写。线上成熟稳定的业务代码,是经过了时间和大量用户考验的;全部推翻重写往往费时费力,实际的效果通常也很不理想,各种问题层出不穷得不偿失。对于这种项目的模块化重构,我们需要一点点的改进重构,可以分散到每次的业务迭代中去,逐步淘汰掉陈旧的代码。

各业务模块间肯定会有公用的部分,按照我前面的设计图,公用的部分我们会根据业务相关性下放到业务组件层(Business Component Layer)或者基础组件层(Common Component Layer)。对于太小的公有模块不足以构成单独组件或者模块的,我们先放到类似于 CommonBusiness 的组件中,在后期不断的重构迭代中视情况进行进一步的拆分。过程中完美主义可以有,切记不可过度。

以上就是我在模块化探索实践方面的一些经验,不住之处还望大家指出。

如果你喜欢我的文章,就关注下我的 知乎专栏 或者在 GitHub 上添个 Star 吧!

Android 模块化探索与实践的更多相关文章

  1. 关于 JS 模块化的最佳实践总结

    模块化开发是 JS 项目开发中的必备技能,它如同面向对象.设计模式一样,可以兼顾提升软件项目的可维护性和开发效率. 模块之间通常以全局对象维系通讯.在小游戏中,GameGlobal 是全局对象.在小程 ...

  2. NSIS:应用软件自动升级功能的探索与实践

    原文 NSIS:应用软件自动升级功能的探索与实践 记得以前轻狂曾分享过使用第三方软件实现应用软件自动升级功能 (详细http://www.flighty.cn/html/soft/20110106_1 ...

  3. 爱奇艺技术分享:爱奇艺Android客户端启动速度优化实践总结

    本文由爱奇艺技术团队原创分享,原题<爱奇艺Android客户端启动优化与分析>. 1.引言 互联网领域里有个八秒定律,如果网页打开时间超过8秒,便会有超过70%的用户放弃等待,对Andro ...

  4. FFM及DeepFFM模型在推荐系统的探索及实践

    12月20日至23日,全球人工智能与机器学习技术大会 AiCon 2018 在北京国际会议中心盛大举行,新浪微博AI Lab 的资深算法专家 张俊林@张俊林say 主持了大会的 搜索推荐与算法专题,并 ...

  5. Android自动化测试探索

    Android自动化测试探索 前言 通常来说,我们开发完成产品之后,都是由测试组或者是我们自己点一点,基本上没有问题了就开始上线.但是,随着时间的堆叠,一款产品的功能也越来越多.这时,我们为了保证产品 ...

  6. FPGA加速:面向数据中心和云服务的探索和实践

    欢迎大家前往腾讯云+社区,获取更多腾讯海量技术实践干货哦~ 本文由columneditor 发表于云+社区专栏 作者介绍:章恒--腾讯云FPGA专家,目前在腾讯架构平台部负责FPGA云的研发工作,探索 ...

  7. 云课堂Android模块化实战--如何设计一个通用性的模块

    本文来自 网易云社区 . 如何设计一个通用性的模块 前言 每个开发者都会知道,随着项目的开发,会发现业务在不断壮大,产品线越来越丰富,而留给开发的时间却一直有限,在有限的时间,尽快完成某个功能的迭代. ...

  8. android 模块化

    android 插件化 模块化开发(apkplug): http://blog.csdn.net/o1587790525/article/details/11891997 android 模块化环境搭 ...

  9. Android深度探索(卷1)HAL与驱动开发 虚拟环境的安装

    前言: 最近在看<Android深度探索(卷1)HAL与驱动开发>安装随书带的虚拟环境浪费了很多时间,说是虚拟环境的安装倒不如说是虚拟环境的导入,其实没什么技术含量,也没有什么复杂的,只是 ...

随机推荐

  1. C++ 虚函数相关,从头到尾捋一遍

    众所周知,C++虚函数是一大难点,也是面试过程中必考部分.此次,从虚函数的相关概念.虚函数表.纯虚函数.再到虚继承等等跟虚函数相关部分,做一个比较细致的整理和复习. 虚函数 OOP的核心思想是多态性( ...

  2. 使用cocapods报错 [!] Your Podfile has had smart quotes sanitised. To avoid issues in the future, you should not use TextEdit for editing it. If you are not using TextEdit, you should turn off smart quotes

    从github上下载的工程大部分都使用了cocapods,在install的时候可能会报错. 报错原因: 1.不要使用文本编辑去编辑Podfile文件,使用Xcode编辑,或者使用终端敲命令去编辑. ...

  3. WebService基础学习(二)—三要素

    一.Java中WebService规范      JAVA 中共有三种WebService 规范,分别是JAX-WS.JAX-RS.JAXM&SAAJ(废弃).   1.JAX-WS规范    ...

  4. Python爬虫 Cookie的使用

    Cookie,指某些网站为了辨别用户身份.进行session跟踪而储存在用户本地终端上的数据(通常经过加密) 比如说有些网站需要登录后才能访问某个页面,在登录之前,你想抓取某个页面内容是不允许的.那么 ...

  5. Linux必备 -- 如何在Mac OS 使用VMware 安装Linux

    Linux简介 Linux是一套免费使用和自由传播的类Unix操作系统,是一个基于POSIX和UNIX的多用户.多任务.支持多线程和多CPU的操作系统.它能运行主要的UNIX工具软件.应用程序和网络协 ...

  6. 使用mysql索引技巧及注意事项

    一.索引的作用 一般的应用系统,读写比例在10:1左右,而且插入操作和一般的更新操作很少出现性能问题,遇到最多的,也是最容易出问题的,还是一些复杂的查询操作,所以查询语句的优化显然是重中之重. 在数据 ...

  7. Tomcat access log配置

    在tomcat的access中打印出请求的情况可以帮助我们分析问题,通常比较关注的有访问IP.线程号.访问url.返回状态码.访问时间.持续时间. 在Spring boot中使用了内嵌的tomcat, ...

  8. Libevent浅析

    前段时间对Libevent的源码进行了阅读,现整理如下: 介绍 libevent是一个轻量级的开源高性能事件驱动网络库,是一个典型的Reactor模型.其主要特点有事件驱动,高性能,跨平台,统一事件源 ...

  9. 简单几步让网站支持https,windows iis配置方式

    1.https证书的分类 SSL证书没有所谓的"品质"和"等级"之分,只有三种不同的类型.SSL证书需要向国际公认的证书证书认证机构(简称CA,Certific ...

  10. 使用Docker

    1. 使用镜像 1.1 在Docker Hub上查找镜像 我们查找一下之前博客里面,推送到Docker Hub里面的bage88/docker-demo,能看到有2个仓库,第一个就是我们上次上传的镜像 ...