概述
组件化缘由
记得刚开始接触Android开发的时候,只知道MVC分层架构,而且感觉Model,View以及Controller太简单了,也能称之为分层架构,随便写就是MVC。就像在接触设计模式之前,你可能已经写了无数个单例模式,只是那个时候你可能并不知道,你已经在用设计模式了,你不会去想是用DCL还是使用内部类实现的单例优雅。
后来当一个类中的代码上千行之后,就开始想着抽取公共方法作为工具类,使用封装、继承以及多态来优化自己的代码,直到随着业务的发展,在View层的逻辑越来越多,无法抽取时,发现MVC的天花板其实很低,Activity跟Fragment作为View层经常会跟Model层纠缠不清,及时进行抽取之后,也还是很臃肿。MVP的出现,彻底解决了这个问题,解耦Model层跟View层,使得整个项目的代码显得更加简洁。
在项目初期的的时候,感觉MVP还是很不错的,当项目逐渐变大的时候,每次你改动了很小的一部分,你也需要重新编译整个APP,举个例子,就拿购物车来说,我修改了数量比狂的样式,我需要重新编译整个APP,为了加快速度,我可能要开启InstantRun,可能要使用Freeline来加速编译,这并不是我想要的,而且在使用InstantRun之后,output目录下生成的apk是差量包,只能供开发调试,给测试是无法安装的,我要是想通过脚本上传到fir给测试人员,那又得打一个全量的包,并且InstantRun也不是很稳定。
组件化效果
毫无悬念,组件化势在必行,在网上看了很多相关的资料,对组件化有一个初步的了解,然后就开始组件化了,下面1以我自己的项目为例,放两张组件化之前跟之后的图对比一下。
可以明显的发现我们的Module变多了,就像MVC切换到MVP之后,需要写很多的Presenter组件化最大的好处就是可以模块可以单独开发调试,这样效率一下子就上来了,还是拿购物车举例,购物车实际上就只有一个界面,也就是一个Fragment,加上启动页跟Fragment的父Activity,也就两个界面,可以说想慢都慢不下来,下面就我在组件化过程中遇到的一些问题进行总结一下。
正文
指导思想
组件拆分
组件化的目的在于将一个project划分成业务组件、基础组件、路由组件。其中业务组件是相互隔离的,可以单独调试,基础组件提供业务组件所公用的功能,路由组件为业务组件之间通信提供支持。
一般来讲,一个APP可以由一个app壳,然后集成多个Module,这是理想的情况,但是从运营的需求到产品的设计到UI出图,可能你就会对组件化很绝望,并不是那么的理想,很多时候我们程序入口所在的Module实际上跟其它很多Module是关联的,实际上没法拆分,本文将会以这种比较复杂的情况进行组件化分析。
组件隔离
组件化的一个很大的特性在于可以单独调试,但是由于业务组件之间的隔离,所以导致了多个组件之间无法进行通信,其实我觉得是很正常的,既然是单独调试,就必然不应该跟其它的Module间进行依赖,不管是编译期还是运行期都应如此,不然组件化就没有任何意义了,但是由于我们的业务组件都是相互关联的,如果不依赖其他的组件的话,作为一个单独的APP运行有时候是需要参数的,鉴于此,我们可以在Application初始化的时候,新增一个页面作为参数配置,或者直接在Application中固定写死。
核心法则
不管我们如何划分,如何依赖,组件间的关系都要严格遵守一个准则:编译器隔离,运行期按需依赖。
整体架构
通过组件化将项目按照业务进行化分成GoodsModule,CartModule,UserModule,OrderModule四个模块,模块间通过RouterModule进行通信,也就是说业务组件依赖于路由组件,RouterModule依赖于Base,也就是BaseModule,LibraryModule,基础库跟第三方库,然后MainModule实际上相当于程序的入口跟容器,通过MainModule依赖上述四个Module,完成整个APP的打包。
当然在单独调试的时候,GoodsModule,CartModule,UserModule,OrderModule又各自成为一个APP,可以单独进行调试,这样就实现了APP的组件化,下面就组件化过程中遇到的一些问题总结一下。
组件化分析
在组件化的过程中,由于Module之间是隔离的,所以就产生了一系�列问题,现在就组件化前后的遇到的问题总结如下:
- 组件划分:如何根据业务对项目进行Module划分
- 模式切换:如何使得APP在单独调试跟整体调试自由切换
- 资源冲突:当我们创建了多个Module的时候,如何解决相同资源文件名合并的冲突
- 依赖关系:多个Module之间如何引用一些共同的library以及工具类
- 组件通信:组件化之后,Module之间是相互隔离的,如何进行UI跳转以及方法调用
- 入口参数:我们知道组件之间是有联系的,所以在单独调试的时候如何拿到其它的Module传递过来的参数
接下来会根据�这几个问题,提出对应的解决方法
组件划分
业务划分
由于我们做的是一个电商项目,网上也查找了很多资料,感觉他们举的例子都有些过于简单,因为模块间基本上没有什么耦合,所以很好拆分,不过还是很感谢他们提供了一种解决思路。玩过京东,淘宝都知道,大致分为几个大的模块:商品模块,购物车模块,订单模块,用户模块。没错,我也是这么拆分我们APP的。但是拆着拆着就发现问题了,模块间耦合性太高,我们过了SplashActivity之后就是MainActivity,看图说话
所以网上的一些一进来就是一个空的APP壳的方法并不适用,从一开始就遇到了这个棘手的问题,有点尴尬,按照之前的模块划分,在用户登陆的情况下MainModule一进来就必须拿到GoodsModule,CartModule以及UserModule中的三个Fragment。所以首先必须得解决这个问题,很显然之前的使用一个APP壳来合并多个Module的情况并不适用,起初我直接定义了一个MainModule,然后让他直接引用多个Module,那么MainModule就承担了APP壳的功能,这样一来,就可以解决MainModule对其它Module的引用问题,但是违背了组件化的业务组件隔离的原则。
所以不能让MainModule依赖另外三个Module,但是如果我不引用其他的Module,那么很显然我无法拿到这四个Fragment的引用,有一点可以很明确,那就是编译期业务Module之间必须不可见,这点是毫无疑问的。但是运行期是可见的,因为所有的Module在运行期间肯定都是通过直接或者间接依赖,不然有些Module就没用了,在运行时获取实例,那么很自然地就会想到反射了,没错就是反射。
依赖划分
除了业务模块之外,我们还会有一些公用的工具类以及资源文件,也就是Base类,比如说多个Module共同使用的资源文件,我们都可以放在一个Module里面,另外就是还有第三方依赖,这里我新建了两个Module一个是BaseModule,一个是LibraryModule。整体关系如下
业务组件——>路由组件——>基础组件
模式切换
定义开关
切换的时候需要一个开关,来表示是单个Module间运行还是多个Module间运行,很容易想到是一个布boolean类型的标志,可能你也想到了,在gradle.properties中来定义,网上好像都是这么做的,实际上我们还可以在BaseModule以及LibraryModule定义,原因很简单,只需要所有的Module中都能够访问就行了,只要遵循这个原则都是OK的,只是在gradle.properties中定义跟使用都比较方便。
isDebug=false//Debug还是Release
isModuleRun=true//是否单Module运行
这里我不仅仅定义了isModuleRun,还定义了isDebug,是不是感觉有些奇怪,不是可以通过BuildConfig.Debug来判断当前是否是Debug模式么,因为我们的url配置信息都是写在BaseModule中以便于所有的Module调用,他是一个Library,关于Library这里还有一个问题注意下,由于Library的Module打包方式是使用release模式打包的,所以BuildConfig.Debug永远是false,所以我们需要额外定义一个变量isDebug,然后手动在Debug跟Release中进行切换,然后在BaseModule的gradle中进行判断
if (isDebug.toBoolean()) {
//debug模式
buildConfigField "String", "AlphaUrl", "\"${url["debug"]}\""
} else {
//release模式
buildConfigField "String", "AlphaUrl", "\"${url["release"]}\""
}
使用开关
Application
isModuleRun为false的时候,Application跟AndroidManifest都是以Library的形式参与编译,不需要启动的Activity以及自定义的Application反之则需要。
isModuleRun=false
无序修改
<application
android:allowBackup="true"
android:supportsRtl="true"
android:theme="@style/AppTheme">
</application>
isModuleRun=false
在main/debug目录下新建一个AndroidManifest.xml文件
<application
android:name=".debug.GoodsApplication"
android:allowBackup="true"
android:label="@string/goods_name"
android:supportsRtl="true"
tools:replace="android:label"
android:theme="@style/AppTheme">
<activity android:name=".GoodsActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
引用方式
在Module的gradle目录下进行引用
修改插件
if (isModuleRun.toBoolean()) {
apply plugin: 'com.android.application'
} else {
apply plugin: 'com.android.library'
}
新增applicationId
if (isModuleRun.toBoolean()) {
applicationId "com.wustor.cartmoudle"
}
切换AndroidManifest文件
sourceSets {
main {
if (isModuleRun.toBoolean()) {
manifest.srcFile 'src/main/debug/AndroidManifest.xml'
} else {
manifest.srcFile 'src/main/AndroidManifest.xml'
java {
//全部Module一起编译的时候剔除debug目录
exclude '**/debug/**'
}
}
}
}
资源冲突
假如我们在CartModule中定义了一个Application,然后在当前Module中的strings.xml中定义了app_name,同时在OrderModule中的strings.xml中也定义了这个app_name,那么合并你的时候就会出现冲突,我们只可以通过将上述字段分别改成cart_name跟order_name来解决这个问题,在严格的开发规范下,可以通过这种差异化命名来解决,因为不同的Module基本上资源文件的名称基本都不一样,即时冲突也是少量的冲突,很容易解决。
当然除了这种方式之外可以在build.gradle中给资源文件名添加前缀
resourcePrefix "cart_"
可以强行检查,命名都需要价格前缀,这样反而违背了组件化的初衷,使得操作变麻烦了,不过感觉这种方式不是很有必要,当然有时候还可能出现图片名字相同,这个其实可以还原到组件化之前的项目中分析,是不可能发生的事情,所以归根到底还是没有良好的开发规范跟开发习惯造成,没必要为这种去做一些修改,毕竟约定大于配置。
依赖配置
通过最开始的整体架构图可以看出来,凡是能够在Library跟Application之间进行切换的Module毫无疑问是需要依赖我们Base的两个Module的,其实可以合并成一个Module,我这里分了两个,一个是BaseModule,一个是LibraryModule。下面通过build.gradle中的配置来梳理一下他们的依赖关系:
MainModule
dependencies {
compile fileTree(include: ['*.jar'], dir: 'libs')
compile project(':routermodule')
}
编译期间组件进行隔离,所以MainModule只依赖了RouterModule,刚才说的还有在运行期按需依赖,这里是通过gradle的脚本实现控制的
//编译期组件隔离,运行期组件按需依赖
//mainModule需要跟cartModule,goodsModule,usersModule进行交互,所以在运行期添加了依赖
def tasks = project.gradle.startParameter.taskNames
for (String task : tasks) {
def upperName = task.toUpperCase()
if (upperName.contains("ASSEMBLE") || upperName.contains("INSTALL")) {
dependencies.add("compile", project.project(':' + 'cartmodule'))
dependencies.add("compile", project.project(':' + 'goodsmodule'))
dependencies.add("compile", project.project(':' + 'usermodule'))
dependencies.add("compile", project.project(':' + 'ordermodule'))
}
}
BusinessModule
这里指的是Goods/Cart/User/OrderModule,其实是平行的
dependencies {
compile fileTree(include: ['*.jar'], dir: 'libs')
compile project(':routermodule')
}
业务Module依赖于RouterModule
RouterModule
dependencies {
compile fileTree(include: ['*.jar'], dir: 'libs')
compile project(':modulelib')
compile 'com.alibaba:arouter-api:1.2.1.1'
}
RouterModule依赖了LibraryModule
BaseModule
dependencies {
compile fileTree(include: ['*.jar'], dir: 'libs')
testCompile 'junit:junit:4.12'
compile project(':librarymodule')
}
BaseModule作为一个基础库,依赖了LibraryModule
LibraryModule
这个作为最底层的劳苦大众,实际上就是提供了一个依赖,所以就没有什么好依赖,只能自己跟自己玩儿。
所以到这里的话,基本的依赖关系已经很清楚了,知道了整个架构图,接下来进行施工也就很简单了
组件通信
其实在当初进行模块划分的时候,是根据业务来的,所以当我们进入到一个模块之后,大部分逻辑应该还是在这个模块内进行处理的,但是偶尔还是会跟别的Module进行打交道,看一个界面
就拿GoodsModule跟CartModule来说,这两个Module是可以进行相互跳转的,在GoodsModule的列表页面点击购物车图标可以进入到CartModule的购物车列表,购物车列表点击商品也可以进入GoodsModule的商品详情页。除了这个跳转实际上还有变量的获取,比如在首页,我需要同时获取到GoodsModule中的HomeFragment、SortFragment,CartModule中的CartFragment,UserModule中的MineFragment。我是在MainModule中直接依赖了四个业务Module,实际上可以不这样,我们也可以使用Arouter来进行获取Fragment的实例。
获取实例
其实这里的实例大多数情况下指的就是Fragment,下面以Fragment为例,别的实例如法炮制即可
- 反射获取
由于模块间是隔离的,所以我们没办法直接创建Fragment的实例,那么这个时候其实很容易想到的就是反射,发射可谓无所不能,下面贴一下代码。
//获取Fragment实例
public static Fragment getFragment(String className) {
Fragment fragment;
try {
Class fragmentClass = Class.forName(className);
fragment = (Fragment) fragmentClass.newInstance();
} catch (Exception e) {
throw new RuntimeException(e);
}
return fragment;
}
- Arouter
Arouter是阿里巴巴退出的一款路由框架,在组件中进行路由操作表方便,下面举例说明
目标Fragment中加入注解
@Route(path = "cart/fragment")
public class CartFragement extends BaseFragment{
}
在任何地方获取实例
Fragmetn fragment = (Fragment) ARouter.getInstance().build("/cart/fragment").navigation();
方法调用
在不同的Module之间都存在方法的调用,我们可以在每个Module里面定义一个接口,并且实现这个接口,然后在需要调用的地方获取到这个接口,然后进行方法调用即可。为了统一管理,我们把每个Module的接口都定义在RouterModule里面,然后由于各个业务Module都依赖于这个RouteModule,然后只需要通过反射获取到这个接口,进行方法调用就可以了。
ModuleCall�
Module之间回调的接口
public interface ModuleCall {
//调用init方法可以传递Context参数
void initContext(Context context);
}
Service接口继承自ModuleCall可以定义一些回调方法供本身之外的其他Module进行调用
public interface AppService extends ModuleCall {
//TODO 调用方法自定义
void showHome();
void finish();
}
Impl实现类则是对应在每个Module中的具体回调,是实现Service接口的直接子类
public class AppServiceImpl implements AppService {
@Override
public void showHome() {
}
@Override
public void finish() {
}
@Override
public void initContext(Context context) {
}
}
下面还是通过反射跟Arouter两种方式进行说明
-
反射调用
public static Object getModuleCall(String name) { T t; try { Class aClass = Class.forName(name); t = (T) aClass.newInstance(); } catch (Exception e) { throw new RuntimeException(e); } return t; }
获取接口
AppService appService = (AppService) ReflectUtils.getModuleCall(Path.APP_SERVICE);
其实跟获取Fragment实例一样,通过类名来获取对应的接口,然后调用对应的方法就行,有一点需要注意的就是,如果获取的接口之后调用的方法需要传入Context参数,那么在调用接口方法之前必须先调用initContext方法才能使用传入的Context,不然会报空指针异常。
- Arouter
Arouter中有一个IProvider接口,如下
public interface IProvider {
void init(Context var1);
}
其实IProvider跟上面的ModuleCall是一样的,只不过他在获取到接口实例之后,就会调用initContext方法,其中的Context来自ARouter.init(this)中传入的参数,不需要我们再手动调用initContext。
目标类中注入路径
@Route(path = Path.APP_SERVICE)
public class AppServiceImpl implements AppService {
private Context mContext;
@Override
public void showHome() {
Log.d("go--->", "home--->");
}
@Override
public void finish() {
}
@Override
public void init(Context context) {
mContext = context;
}
}
任意地方获取目标类
AppService appService = (AppService) RouterUtils.navigation(Path.APP_SERVICE);
然后调用方法即可
UI跳转
跳转基本上指的就是Activity之间的跳转,废话不多说,依旧是Arouter跟反射
-
反射
//将类名转化为目标类 public static void startActivityWithName(Context context, String name) { try { Class clazz = Class.forName(name); startActivity(context, clazz); } catch (ClassNotFoundException e) { throw new RuntimeException(e); } } //获取Intent public static Intent getIntent(Context context, Class clazz) { return new Intent(context, clazz); } //启动Activity public static void startActivity(Context context, Class clazz) { context.startActivity(getIntent(context, clazz)); }
-
Arouter
将目标Activity注册到Arouter
@Route(path = Path.CART_MOUDLE_CART) public class CartActivity extends BaseActivity<UselessPresenter, UselessBean> { }
启动目标Activity
ARouter.getInstance().build(Path.CART_MOUDLE_CART).navigation()
入口参数
Application
当组件单独运行的时候,每个Module自成一个APK,那么就意味着会有多个Application,很显然我们不愿意重复写这么多代码,所以我们只需要定义一个ModuleApplication即可,其它的Application直接继承此ModuleApplication就OK了,看一下结构图:
实际上所有的逻辑都是在ModuleApplication中,业务Module分别有自己的子类,通过子类可以对Application做一些自己的定制化操作。
无参原因
之前在网上看到过携程以及得到的组件化,他们从MainModule进入到别的Module貌似都是不需要传参数的,所以不管是组件单独调试还是所有的Module一起远行对于从ModuleA跳转到ModuleB都是不需要传参的。但是很多时候不同的Module间跳转是需要传参的,就拿购物车来说,我单独调试的时候是需要知道用户的加密的userId,才能向服务器请求数据,如果是多个Module一起运行,访问购物车的时候,是可以从别的Module取到userId的,单独调试的时候就没法获取到,也就是入口的时候没有参数对购物车进行初始化。
解决方式
因为当我们在组件化进行调试的时候,我们每个Module在cartmodule/src/main/debug目录下有自己的Application,对于入口参数比较简单的情况,我们可以直接在Application中写死,而对于一些比较复杂的或者动态的参数,我们可以继续在此目录下新疆一个Activity来配置我们单Module调试所需要的参数,然后在整个项目进行编译的时候剔除debug目录下的文件。
sourceSets {
main {
if (isModuleRun.toBoolean()) {
manifest.srcFile 'src/main/debug/AndroidManifest.xml'
} else {
manifest.srcFile 'src/main/AndroidManifest.xml'
java {
//release 时 debug 目录下文件不需要合并到主工程
exclude '**/debug/**'
}
}
}
}
总结
项目组件化运行了一段时间,通过划分Module,单独调试,确实大大提升了开发效率,随着使用的时间的推移,也在对组件化的理解也进一步加深,也在不断地完善,下面几点是在组件化过程中总结的一些经验。
- Module划分:在划分Module的时候没必要划分地太细,但是要严格按照业务来划分,这样单独调试对于习作开发才有意义。
- Module隔离:业务Module之间应该是相互隔离不可见的,不能相互依赖,如果相互之间需要通信,则必须经过路由转发,便于统一管理。
- 面向接口编程:不管是也业务Module还是BaseModule、LibraryModule以及RouterModule,在对外提供服务的时候尽可能的以接口的形式,不同的Module对外提供的服务接口应该都有一个共同的抽象父类,便于管理。
- 防止循环依赖:循环依赖就是A依赖B,B依赖A,在运行期间动态添加依赖的时候,一定要考虑这个依赖是否被添加到项目中去了,所谓添加到项目中就是但凡被其它的Module进行依赖过就算添加进项目中,不然很容易造成循环依赖。