组件化的实现,主要解决的就是模块的划分,以及划分后的交互问题。
另外在组件化的过程中,也是一个,代码Review的过程,比如是否使用了通用的父类,以及对业务逻辑是否进行了友好的封装,总之,组件化可以说一面照妖镜,让之前代码存在的耦合问题充分暴露出来。
这次使用的组件化样例是一个即时通讯软件,以下是组件化之前的样子,其中SuperHelper是底层通用帮助类可以看成CommonBase,封装了一些如数据库和网络访问类型的第三方库,方便程序快速开发。上层是业务实现层,业务实现层包含了程序的所有模块,由于SuperHelper是通用模块,所以SuperHelper中并不存在即时通讯的SDK.由于程序简单,自然SDK就放到上层了。
我们可以通过程序的包名看到里面实现了一个即时通讯软件几乎都有的模块:
登录 呼叫 联系人 历史记录 短信
其中登录模块是其他模块可以正常运行的基础,只有登录成功后,且用户在线,才能正常的进行呼叫和短信类的业务,同时登录模块也是需要和所有其他模块产生交互的模块,这主要体现在,登录状态和登录信息相当于是一个常量,在各个模块中都有可能被调用。
登录之后,另外一个和其他模块有较多交互的是通讯录模块,因为我们进行呼叫业务和短信业务是需要通讯录进行支持的,而且生成历史也是需要通讯录里面的对象。
以上的分析,就是大致的我们在实现组件化过程中需要考虑的交互接口设计。当然这只是一个大概的设计。
当前的程序模块图是这样的:
项目比较简单,也做了一定的模块化和内聚,也包括在思想上的代码隔离。这里面对于登录,其他模块都需要他的状态和缓存进行自己的业务,而主要的功能模块,在这个项目中是一个整体,基本上这几个模块和其他模块或多或少的都有关系,把他们放到一起可以很方便的调用彼此的方法和数据对象,这样的调用就会发生在一个模块包下,对其他包的类有引用。这就产生了模块间的耦合,在项目不断发展下,就会发生耦合越来越严重,理清思维越来越困难的问题。
此时就是引入组件化来解决这个问题的时候了。
对于这一步我认为是一个较为简单的过程。快速划分,一般是根据效果图首页的叶签进行划分,这是一个相对想说较为粗糙的划分方法,不过,大多数时候他可以快速的确定一些功能模块。当然这是一个比较偷懒的方法,其实较为好的方法是画出程序的流程图,或者功能模块图,此时再次划分就会是一个相对准确,且有说服力的划分方案了。另外根据项目的大小和功能性能,我们还有一些特殊的划分要求,比如说一个显示组件后面包含着一整套复杂的逻辑,那么我们也可以将这个显示组件和其后面的逻辑进行一个组件化。又或者,大多数应用都存在我的页面,而设置一般也会放到我的页面中,那么有的应用设置非常简单,此时就可以让他和我的在一个组件中,而有的设置十分复杂,那么可能一个设置项就变成了一个组件,如像微信中的扫一扫,朋友圈,摇一摇,他们都至少会是一个组件。所以对于组件的划分我们可以从功能的大小出发,如果是几个小功能组合成一个大功能,且这个功能不是很大,那么这个大功能就是一个组件,小功能做的很大时,也可能自己就是一个组件了。
下面给出上面项目的组件化之后的结构:
此时已经经过相对成熟的模块划分。其中SuperHelper是作为通用三方出现的,里面包含了常用的三方和一些工具类,这里他不在以module的形式存在,为了保证他的不可修改性,已经将其变为aar的形式引入项目中。
这里面重点要说的是CommonSDK这个底层通用依赖,他的内部包含了自己的通用三方(SuperHelper.aar)和接入的其他业务SDK,并且根据组件化做了部分业务下沉,主要体现在
而在其之上是现有的业务组件,将所有的功能模块尽可能的划分,然后暴露出组件化过程中存在的问题。和之前的结构图相比,其功能组件层,彼此不在存在关系,此时彼此独立。
最上层的是App壳层,这一层也不在存在具体的业务类,它主要实现Application对组件的初始化和方便打包。
此处建议将组件类module加上前缀module,底层依赖不加前缀,以此来区分组件和底层库。
这里面除了对外开发的接口外,都可以按照正常的方式命名了(这些类已经可以正常的实现具体业务了)。
这里面关于接口建议如下形式:
接口声明:组件名称+Service,代表是这个组件对外开放暴露的方法。
public interface CallService extends IProvider {
public String path="/call/CallService";
public void call(int number);
}
组件内的实现:组件名称+Service+Impl
@Route(path = CallService.path)
public class CallServiceImpl implements CallService {
@Override
public void call(int number) {
CallUtil.call(number);
}
}
由于组件间不可见,当我们使用别的组件的对外暴露的接口时,需要一个接口管理类:
组件名称+Service+Manager 代表对所有接入的服务统一管理
public class CallServiceManager {
private static final String TAG = "CallServiceManager";
private static Context context;
private static LoginService loginService;
public static void init(Context c){
context=c;
loginService=ARouter.getInstance().navigation(LoginService.class);
}
public static Context getContext() {
return context;
}
public static LoginService getLoginService(){return loginService;}
}
这样通过静态服务管理,就可以在组件内较为方便调用其他组件的方法了。
首先Android对于同名资源文件的和参数的打包策略是,上层资源覆盖下层,好比我们的底层库有一个颜色资源声明为
<color name="white">#ffffff</color>
依赖于他的module也声明了一个同名的资源:
<color name="white">#000000</color>
那么此时我们引用到这个white资源的地方在实际运行的时候会体现为黑色。
根据这个特性,我们可以在App外壳层最后决定我们的应用的一些整体资源的属性值。
一般来说,一个应用的整体风格应该是一致,这体现在交付设计图时,会给出字体的大小,和主题颜色,以及一些通用的属性,此时我们可以将这些资源直接放到CommonSDK中,然后供上层组件调用,以此来实现整体风格统一的效果。
不过仍然存在一些特殊情况,以美团为例,首先美团应用整体风格目前定义为黄色,假设他的导航里面每个分类都是一个组件,那么我希望打开药店的时候整体风格是绿色,打开酒店的时候是淡金色,即进入组件后根据组件的实际业务更换整体风格。此时同样还需要考虑如果这个组件承载的业务量膨胀,进而发展成独立的应用,如美团外卖。显然此时应该将这个组件内的资源独立化即组件内有自己完整的风格不在依赖于整体,也不能被上层覆盖,此时建议的命名方式是:
<!--组件名称_实际名称-->
<color name="login_white">#000000</color>
进行命名。
另外为了更好迎接变换,可以先以组件名称_实际名称此方式命名,在整体风格统一的时候,指向CommonSDK层的资源,在不统一的时候,直接修改指向即可。
这个部分先介绍关于Android Studio在3.0之前和3.0之后关于依赖关键字的使用和意义。
Android3.0之前:
名称 | 功能 |
---|---|
compile | 普通依赖,无依赖限制 |
provided | 只在编译时有效,不会参与打包 |
apk | 只在生成apk的时候参与打包,编译时不会参与 |
test compile | 只在单元测试代码的编译以及最终打包测试apk时有效 |
debug compile | debugCompile 只在debug模式的编译和最终的debug apk打包时有效 |
release compile | Release 模式的编译和最终的Release apk打包 |
Android3.0之后
名称 | 功能 |
---|---|
implementation | 只有当前的module可以使用此依赖 |
api | 普通依赖,无依赖限制 |
compile only | 只在编译时有效,不会参与打包 |
runtime only | 只在生成apk的时候参与打包,编译时不会参与 |
test implementation | 只在单元测试代码的编译以及最终打包测试apk时有效 |
debug implementation | debugCompile 只在debug模式的编译和最终的debug apk打包时有效 |
release implementation | Release 模式的编译和最终的Release apk打包 |
Compile和 Api:功能类似,这种引用的方式可能会引起,相同依赖包,由于版本不同,而导致打包的时候失败,这个时候可以对冲突的包进行版本同以解决,也可以通过implementation解决。
Provided和Compile Only:在依赖一些定制系统或者和定制系统相关的包的时候可以使用这个策略,也可以在自己的module中使用该方式依赖一些比如com.android.support,gson这些使用者常用的库,避免冲突。
这里重点说下比较坑的版本冲突,导致的打包报错:
同样的配置下的版本冲突,会自动使用最新版;而不同配置下的版本冲突,gradle同步时会直接报错。可使用exclude、force解决冲突。
implementation 'com.android.support:appcompat-v7:27.0.0'
implementation 'com.android.support:appcompat-v7:28.0.0'
最后会使用28.0.0版本。
但是如
implementation 'com.android.support:appcompat-v7:23.1.1'
androidTestImplementation 'com.android.support.test.espresso:espresso-core:2.1'
所依赖的
com.android.support:support-annotations
版本不同,就会导致冲突。除了可以用exclude、force解决外,也可以自己统一为所有依赖指定support包的版本,不需要为每个依赖单独排除了:
configurations.all {
resolutionStrategy.eachDependency { DependencyResolveDetails details ->
def requested = details.requested
if (requested.group == 'com.android.support') {
if (!requested.name.startsWith("multidex")) {
details.useVersion '26.1.0'
}
}
}
}
组件内的依赖请,尽量使用implementation。
组件间交互我认为可以分为两个方向,一个是通过路由进行跳转,这种利用框架可以很好的为我们解决问题;另外一种是方法调用,已登录模块为例,我需要得知当前的登录账号,或者程序掉线的时候控制是否重新登录等,这些功能性的交互。
public class ActivityPath {
public static final String MAIN_PATH = "/main/";
public static final String MainActivity=MAIN_PATH+"MainActivity";
}
@Route(path = ActivityPath.MainActivity)
public class MainActivity extends BaseActivity {
}
//通过路径传值跳转
ARouter.getInstance().build(ActivityPath.MainActivity)
.with(new Bundle())
.navigation();
//根据类型跳转
ARouter.getInstance().navigation(MainActivity.class);
public class FragmentPath {
public static final String SETTINVG_PATH = "/setting/";
public static final String SettingFragment=SETTINVG_PATH+"SettingFragment";
}
@Route(path = FragmentPath.SettingFragment)
public class SettingFragment extends BaseFragment {
}
Fragment fragment = (Fragment) ARouter.getInstance().build(FragmentPath.SettingFragment).navigation();
对于方法要求度不高的可以采用接口下沉的方式,具体实现参考《2.2组件内类的命名》的实现过程。
在阐述接口交付这个概念之前先说两个例子:
1.AIDL的实现。对于在一台手机上的两个应用如果想使用一方的服务,那么就需要拿到AIDL开放的接口和接口涉及的数据对象。
2.服务端和客户端之间交互,客户端根据服务端告知的接口和参数去访问服务端,然后服务端返回数据,无论是xml还是json格式都是key&value的形式。
通过上面两个例子,不难发现已经隔离的业务,要想进行交互,最小代价就是告知对方接口和数据对象。那么在组件化设计当中,我们必须要做的也是数据和接口的抽离。
如果想让其他组件了解自己组件提供那些功能有两种解决方案,一种是将这些接口和数据对象进行下沉,下沉后,接口方法和数据对象彼此可见,在注册服务后,组件就可以使用彼此的功能了。这种形式是比较方便简单的,而且更新方法和数据对象之后,上面可以通过报错的形式被通知。不过他是存在缺点的。
这里给出的解决方案是,将组件对外开放的功能和数据对象打成jar包,哪个组件需要,那么通过申请的形式获取这个jar包,那么对于开放方是知晓那些组件使用了自己的功能的,发生问题时,只需要在这几个组件里面排查就可以了。此时由于不在经过通用层,通用层膨胀的问题也解决了。
另外有一些组件可能是所有组件都要使用的例如登录和设置,像这种就建议直接放到通用层中,减少不必要的工作量。
引入组件化的项目,一般是存在了一段时间,随着版本的迭代发现现有的架构不在能很好的为项目开发服务了,才引入组件化架构,由于项目的创建时间可能略久,而gradle中的依赖由于一般不会改动,可能还停留在创建时的版本。而组件化的过程对现有项目来说是一个很好的重构过程,那么自然涉及到对依赖的更新,那么这里就说下关于切换AndroidX的步骤和一点注意事项。
其实切换AndroidX是十分方便的,切换后也不会产生太多的问题。
当前使用的Android Studio版本3.6.1
项目build.gradle版本:
classpath 'com.android.tools.build:gradle:3.6.1'
项目gradle-wrapper.properties版本:
distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.4-all.zip
切换第一步:
选择这个后,Studio会自动的将项目整体切换到AndroidX的依赖上去。切换后一般会报错。
这里说两个我遇到的问题,第一个就是将项目中的moudel的:
compileSdkVersion 28
targetSdkVersion 28
进行对齐,统一到28或者以上,不修改28以下的module可能在资源上出错。
第二记得修改以下Fragment将之前的依赖切换到:
import androidx.fragment.app.Fragment;
Butterknife作为快速实现View初始化的工具,可以很大的提高我们的开发效率。不过在Butterknife的发展过程中,存在着一段时间,其无法很好的在module层使用的问题,如果无法在module使用只能在应用层使用的话,其在组件化的开发中就失去了意义,所以此部分特别说一下,目前的解决和使用状态。
首先不管现在使用的是什么版本的Butterknife,直接去Github上更新到最新的Butterknife。
然后在项目build.gradle中更换成最的新插件
classpath 'com.jakewharton:butterknife-gradle-plugin:10.2.1'
对于使用的项目先接入插件:
在使用Butterknife的组件中添加:
apply plugin: 'com.jakewharton.butterknife'
然后在依赖中接入依赖:
implementation 'com.jakewharton:butterknife:10.2.1'
annotationProcessor 'com.jakewharton:butterknife-compiler:10.2.1'
然后rebuild。
对于应用层的module不会产生影响,这里说下组件层需要处理的事情。
首先我们的module层会出现两个R文件的引用,一个.R一个是.R2。
简单来说涉及到android原生部分我们使用R.的资源文件。
对于涉及到Butterknife的我们使用R2.的资源文件。
其具体体现就是原生方法如:setContentView 使用R.的资源:
setContentView(R.layout.activity);
涉及到Butterknife的
@BindView(R2.id.view)
view view;
@OnClick({R2.id.view}
public void onViewClicked(View view) {
switch (view.getId()) {
case R2.id.view;
break;
}
}
引入此三方主要的原因是,GreenDao是一种对于原生数据库和数据存取速度处理的折中方案。在对Android原生Sqlite的处理能力上,GreenDao无疑是非常优秀的,所以考虑兼容时,其还是项目首选。
不过GreenDao也引入了编译时注解,所以在组件使用时,这里还是需要稍微提及一下,避免由于切换失败导致整体换数据库三方。(这事发生过,是十分疼的)
此处我们面临的场景是,一般只有数据存处理组件会设计到数据库的操作,比如联系人这个模块,其就高度依赖于数据库。
那么我们对于联系人这个模块主要修改module的build.gradle:
先引入插件:
apply plugin: 'org.greenrobot.greendao'
然后在(注意此处module间的版本需要统一!):
android{
schemaVersion 1 //当前数据库版本,
}
最后添加依赖即可:
implementation 'org.greenrobot:greendao:3.2.2'
之后拆分数据库按照之前的模式拆分即可,rebuild之后数据Dao和DaoSession就都有了。
这个模块主要是承接上面的问题,此时我们已经解决了GreenDao在module中使用的问题。不过新的问题出现了,前面说了只有数据提供方需要使用数据库三方,而不涉及到数据库操作的组件,显然是不应该引入的(引入会降低编译速度),那么此时引入GreenDao的组件其数据对象由于要进行数据库操作,会使用GreenDao提供的注解。而注解是不在不使用数据库的组件中出现的!显然,此时的数据对象是不能拿来直接给外部的。
对于这种的解决方案是,将数据处理对象DBBean去掉注解后,在生成一个不包含注解的数据Bean,此数据对象和DBBean需要完全一致,用于数据库回存。然后由数据提供方增加工厂方法,实现DBBean<->Bean的互转。
命名建议:
数据处理对象更改为:
com.dbbean.DBDataBean
方便其和通用数据Bean区别。
问题:组件A自己有一个开发完毕的Adapter,这个时候为了用户方便需要在组件B中引入一个一样的Adapter。由于组件化,组件A的Adapter对组件B不可见,此时就产生了Adapter的复用问题。
解决方案有下面几种:
组件A的Adapter下沉到CommonBase里面,这样对B可见。这在组件中有一个问题,这个Adapter如果下沉了,他应该有谁来维护?显然CommonBase是不合适的,但是组件A的开发人员也不应该有权限去开发CommonBase。显然这种并不是很合理。
将组件A的Adapter粘贴一份到组件B,这种也不太好,第一个原因是代码冗余,第二个原因是组件A如果更新了Adapter,就需要通知B去同步,如果增加了其他组件那么就要都通知一遍。这个感觉也不是很合理。
最后是我想到的解决方案,一般对于Adapter我们都会进行封装一个BaseAdapter做为父类,那么我们采用公共父类的形式声明Adapter,这样最大的保证了Adapter的功能,但是子类Adapter独有的其他功能却不可见了,此时Adapter变得不在有业务处理能力!只是单纯的显示帮助者。
另外作为Adapter还需要处理点击事件,因为两个组件中的Adapter虽然显示一样,但是点击逻辑可能不同。显然如果Adapter由一个组件维护,那么这个组件对应的layout文件也应该在组件A中,同时对组件B不可见。这在处理点击时使用view.getId()的形式已经不能满足了,可能有些同学会想到,使用getTag()的形式来实现,这种实现方式的缺点是,这个Tag是你需要写到文档中告知对方的,Tag修改的时候同理。这样就有点像方法2那样的困境了,告诉一个和告诉两个的成本其实是一样的。
此处就产生了新的问题如何解决点击事件?
这里给出的解决方案是:
给出接口,回调接口,在获取BaseAdapter的时候作为参数传进来。
其在和组件A获取Adapter的时候是这样的:
public BaseAdapter getCallAdapter(Context context,CallAdapterCallback callAdapterCallback);
关于接口的定义如下:
public interface CallAdapterCallback {
public void onTermianlClick(int position, View view,CallListBean callListBean);
public void onAcceptlick(int position, View view,CallListBean callListBean);
public void onSwitchClick(int position, View view,CallListBean callListBean);
}
在组件化过程中,发现一个十分难受的问题,就是做一个组件的时候,由于这部分组件的代码其实对于之前说的App层代码是下沉的,这就会导致,资源,以及和对其他功能的引用报错,这个时候,不得不对另外一个功能进行组件化或者让其设计开放接口,在这个功能组件化的时候同样面临相同的问题,最后很可能把整个项目都组件化了,而这段期间,项目都是无法编译的,而当整体组件化完成的时候,就产生了大量的代码修改,这种时候一般会产生大量问题,不利于项目维护!
这里给出的解决方案是,首先将APP层变成Library,然后建立设计好的组件,再在组件的上层建立一个壳。这样在我们开发组件的时候就是代码上移,这样就不会产生因为引用造成的问题了,随时都可以编译了。
当然这种办法可能产生组件化划分的不完全,即可能存在遗漏的文件留在了下层。
此时的解决方案是:当我们组件化基本完成的时候,我们用App覆盖掉壳,根据报错,在将遗漏的文件进行组件划分处理。
其实一开始是没打算将MainActivity组件化的,开始时打算将MainActivity作为上层打包时的壳,那么他就可以拼装相关的Fragment了,还可以看见每个组件的方法,基本没有太的改动。不过考虑到,其实MainActivity模块也有可能承载很多的业务量,如自动登录,语言设置,当前网络状态回调,以及账号的登录状态等,你会发现其实MainActivity和很多模块有关联成了业务耦合的重灾区。由于耦合严重MainActivity往往代码量较大,不易于维护。
另外对于自动登录这个功能来说,他应该由登录组件进行维护,对于MainActivity来说就不应该存在他的逻辑了。其他的功能同理。
MainActivity组件化后,主要体现在需要增加了很多的接口,和修改某些回调为生命周期回调,此时可以很好的解决MainActivity的耦合问题。同时由于去掉了接口的直接回调处理,MainActivity会变小,也很难膨胀变大。
先举个栗子:
业务场景如下,登录模块除了在登录页使用,一般也会在首页使用进行自动登录业务,那么此时很正常的就是,首页组件调用登录组件的登录功能。此时有一个要求,就是首页在登录成功前,不希望用户进行业务,需要加一个挡板,用于防止用户进行操作。
显然挡板是发起业务方的业务,而生命周期是被调用方的事情。那么我们只需在登录组件中暴露他的声明周期接口就可以了:
public interface LoginCallback {
public void onLoginStart();
public void onLoginSuccess();
public void onLoginError(String errorMessage);
public void onLoginEnd();
}
其实此处只是想提醒大家,在处理这种可能涉及到请求有回调的方法时,应该记得开放对应的周期接口,方便调用方完成业务。
组件化之后的多语言文件已经被打散分布在各个组件当中,那么当我们在开发末期,需要整体提交需要翻译的语言时就成为了一个问题,需要各组件进行整理,然后汇总,这是一个易出错且效率地下的方式。此处可以依靠插件化的方式去解决,即在编译时自动汇总组件内的strings。