题记
虽说OA系统开发已于2020年因公司业务调整而终结,且离当前时间已久远;但其中在我负责的终端开发团队中所使用的技术还是有所考究的,例如混合开发技术、组件化技术、插件化方案等等。现在各个团队成员已各奔东西,我业已与今年11月从东家离职,其中不乏一些情愫、blabla等技术或故事回忆。下面就项目产品回忆将组件化架构设计方案一一分享。
1引言
组件化不是个新概念,其在各行各业都一直备受重视。在服务端已十分成熟,后来在该思想的指导下,前端开发和移动端开发也产生各自的开发方式。
OA经过多年迭代开发终端功能强大,同时代码量也十分庞大。OA的业务特性,功能独立,很适合应用组件化设计。现在的架构虽然做了代码结构分层,但是在项目设计之初没有按照组件化思想进行业务分层,一些需要下沉的业务放在主模块内,当模块功能越来越多时,模块间就容易盘根错节。曾经尝试只独立单独的业务模块,但是却发现模块间业务耦合较多,相互掣肘举步维艰。
要解决此问题,组件化是最优解决方案。现在因为有Blade框架的思想,代码耦合并不严重,主要重点在于业务解耦。如果要设计组件化架构,必须要跳出原有代码结构思路,将OA的所有功能进行重新整理,依据组件化思想,将业务重新分类、分层、功能内聚、剥离耦合,方能实现组件化的最终目标。
1.1模块化、组件化、插件化关系
模块化:
中心思想与组件化基本一致,都是“大化小”,不强调单独运行,业务分离粒度较小,实现各个模块代码分离,更侧重于业务解耦,是组件化的基础。
组件化:
拆分每个业务为独立工程,侧重于每个模块独立运行,可以极大提高大型项目的编译速度。业务组件之间没有引用关系,每个组件都把对应的业务功能收敛在一个工程里。每个人只负责自己的模块,对模块的修改也限定于模块内。组件业务高度独立,可以在其他项目中复用,维护好每个组件,在用到时可以快速集成。但是组件化开发前期可能要花费更多的时间来进行模块拆分,并可能带来一部分重复代码。
插件化
插件化是将宿主和插件分开编译,独立打包,支持在线热更新,支持插件的动态安装、卸载的功能,可以减少安装包体积用户可只要按需下载模。是项目组件化到一定程度的进阶产物。现在市面上插件化框架比较多,已经发展到第三代,比较成熟。
1.2 OA现在的痛点
- 代码数量庞大,任何一个小功能修改,都需要编译整个项目,编译效率低下。
- 不利于多人开发,现在OA开发团队越来越庞大,经常发生开发过程中不小心影响到其他模块的问题。这样开发人员之间势必要花更多的时间去沟通和协调,没法专注自己的功能点。
- 业务模块耦合严重,部分基础模块边界模糊,应该作为基础数据的模块与界面耦合在一起,需要通过接口代理,模块反向依赖主模块进行业务交互。
- 组件无法抽出来重用,有很多比较成熟的组件无法给其他项目使用。
- 定制化需求导致需要维护多个代码分支,经常相互合并各个分支,经常在合并代码时,解决代码冲突解决地让人怀疑人生。
2 组件化方案描述
2.1 架构图
2.2 架构图设计
架构总体分为四层,从高到底依次为宿主层、业务层、组件层、基础层。结合OA现有业务,拆分Domain层和Data层,将用例和仓储层划分到各个组件内。通过Router分离代码直接的强引用,并通过生命周期分发初始化各个组件。
2.2.1 宿主层
位于应用最上层,作为整个应用的主入口,主要将各个组件进行组装、Application初始化、各个组件化生命周期控制等业务。
2.2.2 业务层
业务层位于中间层,是各个组件的入口,也是真正的业务的所在,通常按照产品需求功能来划分。每个模块拥有自己的业务、界面、独享的资源,模块之间互不依赖, 但又可以通过路由进行交互,没有直接代码引用关系。
2.2.2.1 业务层划分规则
创建模块之前一定要先将产品需求和后期运营规划思考清楚,不要急于编码,考虑好未来可能的发展方向,确定业务的边界。哪些属于公共业务,哪些属于模块内部业务,最后再确定下来如何拆分的业务模块。
例如登录模块包含:登录业务、登录方式切换业务、芯片绑定业务等等。但是登录后的账户数据不属于登录模块,属于账户模块,登录成功后将登录后的数据交由账户模块进行持久化,供全局使用。
2.2.3 组件层
组件层是对系统通用的业务能力进行封装的组件,所包含的模块必须是拥有完全独立的功能,并且具有可重用性,本身不涉及OA自身的业务。同层之间不能任何形式的交互。可以拥有自己独立的布局与资源。没有生命周期初始化之类的需求。如:选择联系人控件、图片预览、图片选择等。
2.2.4 基础层
基础层位于最底层,包含基础业务层、公共服务层、通用组件层。是为各个组件提供通用功能和基础数据服务的模块,编写基础模块时注意遵循开闭原则。
2.2.4.1 基础业务层
基础业务层可以理解为核心数据提供层,核心数据包含联系人数据和账户数据,主要对这些基础数据进行同步和维护。包含数据同步、数据持久化、数据读取等维护独立数据的业务,不包含任何界面业务。与上层业务只能通过公共服务层才能访问到基础服务层。
2.2.4.1.1 联系人业务
联系人的所有数据业务均有此模块维护,将现有的ContactReponsitory接口的实现和ContactDataStore、ContactCloudStore全部移到这里,通过公共服务层进行对外提供业务接口,这样可以通过接口实现隔离,并且遵循了Blade本身的业务层级隔离的思想,以最小的改动,实现业务分层。
2.2.4.1.2 账户业务
账户模块用来维护账户的所有信息,需要再登陆业务完成后立即初始化,以便所有业务使用账户数据。迁移实现方式与联系人业务相同。
2.2.4.2 公共服务层
公共服务层主要存放数据库表结构和联系人、账户的业务对象,用来与上层继续业务交互。这些所有对象放在独立的模块内,公共服务层以上均可以直接引用。服务接口传递数据也直接使用这些对象,解决对象反复转换的问题。
2.2.4.2.1 数据库层
基础层数据库层只存放联系人和账户相关的数据库表结构,并开放数据库初始化的接口供登录后使用账户id初始化数据库使用。
2.2.4.2.2 业务对象
业务对象具体包含:Account,ContactBean、PersonBean、DepartmentBean、CompanyBean、DeptPersonBean、FavoriteContactBean。均用于联系人和账户数据传递使用。
2.2.4.2.3 业务接口
业务接口包含AccountReponsitory和ContactReponsitory内的所有业务接口类,接口实现者置于基础业务层,通过Router对外提供。
2.2.4.3 业务组件库
业务组件库包含所有的第三方依赖库,用来所有组件引用的第三方库均有这一层进行管理。 便于重用、管理和规范已有的依赖,防止出现依赖版本冲突问题。
2.2.4.4 通用组件库
主要放置一些业务层可以通用的与 UI 有关的资源供所有业务层模块使用, 便于重用、管理和规范已有的资源。
可放置资源包括:
- 通用的 Style, Theme
- 通用的 Layout
- 通用的 Color, Dimen, String
- 通用的 Shape, Selector, Interpolator
- 通用的 图片资源
- 通用的 动画资源
- 通用的 自定义 View
- 通用的第三方 自定义 View
2.3 组件间通信
2.3.1 跨组件通信场景
例如 业务模块 办公 与 业务模块 消息中心, 办公模块与消息中心模块本身是平级关系,互相不依赖, 但是办公需要跳转到消息中心模块, 在不依赖的情况是无法通过 强依赖的方式 指向消息中心的页面的。
所以引入了中间层 路由层来解决这个问题。
2.3.2 跨组件通讯方案
假设有业务模块A、业务模块B,现有需求A需要启动B的某个功能页面。
解除A、B业务组件之间强依赖跳转最简单的方式就是通过 隐式 intent 来处理,将强class指向替换成隐式action的方式启动,但是这种方式需要在B模块中配置隐式参数,在A当中指定隐式参数,而隐式参数如果要统一还需要再下沉到基础模块来统一管理,这无形中会增加较多的工作量。
任何软件工程遇到的问题都可以通过增加一个中间层来解决。
可以增加一个路由层, 路由层需要提供两个能力: 注册路由 和 发现路由。
B模块需要提供页面给其他模块,会将自己的注册到路由中,建立 路由路径和具体页面之间的映射关系,对外只暴露路由路径。
A模块会拿着路由路径向路由层发起请求,路由层负责找到具体的页面进行跳转。
2.3.3 跨组件通讯方案说明
ARouter可以做到通过注解的形式,在编译期自动生成路由配置、初始化时收集路由信息并注册生成路由表、运行时发现路由完成跳转。
他的本质还是使用android原生的startActivity来完成跳转。
因为对ARouter较为熟悉,基本满足我们的需求,所以采用了ARouter。
这里额外附一张 两款成熟的路由框架WMRouter 和 ARouter之间的对比:
ARouter路由发现流程大致如下图:
2.3.4 跨组件数据传递
正常的组件间传递数据,是使用Intent
,里面有个对象 叫private Bundle mExtras
,来存放需要传递的数据。
ARouter本质上也是使用Intent来传递数据,不过他作为一个中间层,封装了一个叫做Postcard(明信片)
这样一个对象。最终会将数据从Postcard
中取出,塞到Intent
的Bundle
中。
ARouter传递数据大致用法如下:
ARouter.getInstance().build("/test/1")
.withLong("key1", 666L)
.withString("key3", "888")
.withObject("key4", new Test("Jack", "Rose"))
.navigation();
2.3.5 服务发现
ARouter同时也可以支持服务暴露和服务发现功能。
a. 需要暴露的服务接口 继承 ARouter
的 IProvider
接口
public interface ContactInterface extends IProvider {
String getContact();
}
b. 在暴露服务的实现类上加上 @Route(path = Constant.SERVICE_CONTACT)
@Route(path = Constant.SERVICE_CONTACT)
public class ContactImpl implements ContactInterface {
...
}
c. 在使用方的 field字段上加上@Autowired
注解以及 ARouter.getInstance.inject(this)
public class LoginActivity extends AppCompatActivity {
@Autowired(name = Constant.SERVICE_CONTACT)
ContactInterface contactInterface;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
ARouter.getInstance().inject(this);
...
}
}
d. 编译以后会产生中间文件
加入路由
public class ARouter$$Group$$contact implements IRouteGroup {
@Override
public void loadInto(Map<String, RouteMeta> atlas) {
...
atlas.put("/contact/service", RouteMeta.build(RouteType.PROVIDER, ContactImpl.class, "/contact/service", "contact", null, -1, -2147483648));
}
}
LoginActivity$$ARouter$$Autowired.java
帮助完成服务注入
public class LoginActivity$$ARouter$$Autowired implements ISyringe {
private SerializationService serializationService;
@Override
public void inject(Object target) {
serializationService = ARouter.getInstance().navigation(SerializationService.class);
LoginActivity substitute = (LoginActivity)target;
substitute.contactInterface = (ContactInterface)ARouter.getInstance().build("/contact/service").navigation();
}
}
整体服务发现流程如下图:
2.3.6 路由表管理
2.3.6.1 路由信息规范
路由信息定义为两级: /Module/Path。举例:
/contact/level
、 /contact/tree
。
目前路由信息声明在 RouterConstants
中:
public class RouterConstants {
public static final String MODULE_CONTACT = "contact";
public static final String PATH_CONTACT_LEVEL =
"/" + MODULE_CONTACT + "/level";
/**
* 模块传参KEY定义
*/
public interface ParamContact {
String PARAM1 = "param1";
}
...
}
在运行时,ARouter的路由信息存放在 Warehouse
中:
static Map<String, Class<? extends IRouteGroup>> groupsIndex = new HashMap<>();
static Map<String, RouteMeta> routes = new HashMap<>();
// Cache provider
static Map<Class, IProvider> providers = new HashMap<>();
static Map<String, RouteMeta> providersIndex = new HashMap<>();
何时注册路由信息?
在Application初始化时,需要调用ARouter.init(application)
,会将路由信息注入到 groupsIndex
当中。
groupsIndex
存放的是分组信息,routes
存放的是具体路径信息。
何时注销路由信息?
应用退出时,可以调用 ARouter.destroy()
来清除路由信息。
2.5 组件生命周期
2.5.1 生命周期绑定方案
将将生命周期代理类下沉到基础层,主应用与组件共同依赖基础层,相互可以实现业务隔离。因为之前组件化部分代码在Blade框架内,拓展不便,所以新版使用代理类实现组件生命周期控制。在基础层通过ApplicationDelegate调用各个组件进行生命周期分发。
使用方法:
- 在组件的AndroidManifest.xml中配置Application节点下增加,指定ComponentConfig路径。
<application >
<meta-data
android:name="com.example.mysublib1.SubConfig"
android:value="ComponentConfig" />
</application>
- ComponentConfig内配置绑定组件内Application和Activity生命周期实现。增加该类是为了组件的Application只关注组件内业务,组件外业务均交由Config来实现。将组件生命周期去宿主生命周期绑定和CommonComponent能力分发等放在此处,保证设计符合单一原则。
@Override
public void injectAppLifecycle(@NonNull Context context, @NonNull List<AppLifecycles> lifecycles) {
//绑定组件生命周期
lifecycles.add(SubApplication.getInstencs();
}
@Override
public void injectActivityLifecycle(@NonNull Context context, @NonNull List<Application.ActivityLifecycleCallbacks> lifecycles) {
//绑定Activity生命周期到主应用
lifecycles.add(new ActivityLifecycleCallbacksImpl());
}
- 在主Application 内初始化代理类ApplicationDelegate,绑定各个组件生命周期。
改造方案:因为现有的组件初始化优先级实现在框架内的ForkedApplication里,不方便拓展。所以现在通过代理类替换继承,去掉*buildBoundedAppGraph()*方法的实现。通过ComponentConfig传递EoaCommonApplication等几个Application。
2.5.2 架构设计类图
2.5.3 优劣势
优势:
- 通过接口下沉方式隔离组件与壳
- 支持按照优先级进行初始化组件
- 上手难度小,维护成本低
- 通过接口隔离在ApplicationDelegate类内,可以对组件通用进行统一管理(例如:传递基础配置,初始化全局OkHttp等),不会影响组件内部逻辑。
- 分发生命周期同时将Application同时下发,让组件操作宿主Application更加灵活
劣势:
- 组件仍需做特殊配置才能与主生命周期进行绑定。
- 基础模块增加了对组件化生命周期的维护成本。
2.5.4 方案优化
该方案已经很好了,但是还需要在Manifest中进行配置,有没有更加优雅的方案呢。当然有,简单易用,一次配置终身受益。
AutoRegister框架,通过Gradle插件配合ASM框架在编译期自动扫描所有实现了指定接口的类,插入实现到指定类中。可以省略在Manifest中配置这一步,让所有关联都在打包过程中,自动注册。该框架已应用于ARouter官方框架和CC组件化框架,已经很成熟。实现原理
使用AutoRegister替换Manifest解析的步骤,在编译时扫描所有实现了ComponentConfig接口的类。统一注册到ApplicationDelegate类的init方法中。
2.6 组件单独运行
2.6.1 单独运行的意义
组件单独运行的意义有以下几点:
a. 提升编译效率,所需要修改的内容属于哪个业务,就把这个业务模块单独运行,可以大大提高编译时间。
b. 为插件化做铺垫。后续项目会接入插件化方案,需要业务组件能够单独打包apk。
2.6.2 单独运行实现原理
Android 原生在界定 application
与 library
,不同的地方就是 在 build.gradle
当中,应用不同的插件:
Application模块:
apply plugin: 'com.android.application'
Library模块:
apply plugin: 'com.android.library'
根据这个思路,如果要单独运行library,可以讲library使用的插件替换成application的插件。
2.6.3 单独运行实现方案
新增是否需要单独运行的配置,修改gradle根据配置动态修改应用插件,同时还需要增加一些单独运行所需要的环境配置,比如入口导航页等,仅仅在debug模式下启用。大体实现步骤如下:
a. 在每个可单独运行的业务模块下定义gradle.properties, 添加 isApplication = true/false配置项
b. 在 app模块下也添加gradle.properties文件,一个项目只能有一个可运行的application,所以在单独运行document时候,需要把app下的gradle.properties中的isApplication = false
c. 原app模块配置文件修改如下:
if(!project.isApplication.toBoolean()) {
apply plugin: 'com.android.library'
}else{
apply plugin: 'com.android.application'
}
//如果document是application时候,取消app对它的依赖
if(!project(':document').isApplication.toBoolean()) {
compile project(':document')
}
d. 为需要单独的模块新增debug环境代码
-src
-debug
─java
└─com
└─**
└─eoa
└─document
-DocumentApplication.java
document_AndroidManifest.xml
-main
─java
└─com
└─**
└─eoa
└─document
并由此改造build.gradle
:
if(project.isApplication.toBoolean()) {
...
sourceSets {
main {
manifest.srcFile "src/debug/document_AndroidManifest.xml"
java.srcDirs += 'src/debug/java'
}
}
} else {
sourceSets {
main {
manifest.srcFile "src/main/AndroidManifest.xml"
java {
exclude "src/debug/**"
}
resources {
exclude "src/debug/**"
}
jniLibs.srcDirs = ['libs']
}
}
}
2.7 组件化开发规范
2.7.1 项目创建
每个组件都应是一个单独的小工程,而不是像以前那样,只有一个主工程,每个组件只是工程里的一个module,这种方式实质上还是单一工程模式。这样在代码权限管控,组件职责划分上就很明确了,每个工程是一个组件,每个组件有一个owner(也就是负责人)。
每一个组件都至少包含两个Module,一个名为app 的主Module和多个Library Module。
APP 包含包含组件的入口或者测试Demo代码。
Library Module 包含该组件的核心业务代码。包名设置规则:应用包名+ “.” + 业务模块名,假设你的应用包名为com..eoa,你要开发的业务组件为用户个人中心,则你的包名可定义为:com..eoa.userinfo。注意不要与应用以及其他业务组件的包名发生冲突。
2.7.2 资源命名
资源命名在Gradle文件内加上前缀。
resourcePrefix "module_name"
加上组件的简称。其他部分命名需要注意符合公司编码规范,具体细则参考公司Android编码规范。
2.7.3 第三方库依赖
在我们使用第三方依赖库时,需要特别注意依赖库的版本号。如果第三方依赖库在多个组件中都有使用,考虑将这些第三方依赖下沉到底层组件库中统一管理,防止版本号冲突。
2.7.4 数据存储
1、数据库存储
当使用ORM数据库框架时,要特别注意所用ORM框架在多Module是是否需要特殊处理。
2、SharedPreferences
每个模块只管理自己的SharedPreferences文件,通过自己的模块前缀来区分,防止出现不同组件间数据发生冲突。
3、当某些数据需要全局共享时,可以考虑下沉到底层模块。
2.7.5 组件发布
组件发布的优点
- 组件发布aar以后,源码从源码依赖变成了aar依赖,可以大大提高编译效率
- 组件源码依赖隔离以后,可以作为单独仓库git,大大减少与依赖方的代码耦合,并且可以多团队协作
- 独立发布后,可以作为公共组件提供给三方使用,不再局限在自身项目当中
组件发布的缺点
- 组件发布时,所依赖的子项需要预先发布为aar,例如
准备发布aar 1 , 依赖 B 和 C。需要预先将B和C发布为aar,否则会导致发布的aar1中关于B和C的依赖版本是unspecified
, 当拉取aar1时,一直等待无法完成。
- 组件在不稳定的时候会导致频繁发布aar,导致依赖方也需要不断更新aar的版本
- 组件在经历较长时间以后,出现多个版本的aar,会出现版本冲突。
比如同一个项目,A模块使用了util aar1.0, B模块使用了test aar1.0(间接依赖了 util aar1.1),会导致依赖冲突, 需要升级项目本身依赖的 util aar的版本。
组件发布的过程
定义公共发布gradle脚本 publish.gradle
,可以发布 snapshot和release版本:
apply plugin: 'maven'
def projectGroupId = "com.*.*.eoa"
def publishSuffix = project.hasProperty('release') ? "" : "-SNAPSHOT"
println(project.name + " publishSuffix =" + publishSuffix)
def publishRepoUrl = project.hasProperty('release') ? REPOSITORY_PUBLIC_URL_RELEASE : REPOSITORY_PUBLIC_URL_SNAPSHOT
println(project.name + " publishRepoUrl =" + publishRepoUrl)
uploadArchives {
repositories {
mavenDeployer {
repository(url: publishRepoUrl) {
authentication(userName: NEXUS_USERNAME, password: NEXUS_PASSWORD)
}
pom.groupId = projectGroupId
println("project.ext.mVersion=" + project.ext.mVersion)
println("project.ext.artifactId=" + project.ext.artifactId)
pom.version = project.ext.mVersion + publishSuffix
pom.artifactId = project.ext.artifactId
}
}
}
task sourcesJar(type: Jar) {
from android.sourceSets.main.java.srcDirs
classifier 'sources'
}
artifacts {
archives sourcesJar
}
需要发布的模块引用该脚本,比如util模块:
apply from: '../../eoa-build.gradle'
dependencies {
...
}
ext {
artifactId = "eoaCommonUtil"
mVersion = "1.0.1"
}
apply from: '../../publish.gradle'
使用发布指令进行发布,发布release版本如下:
gradlew :util:uploadArchives --info --offline -Prelease=true
发布snapshot版本如下:
gradlew :util:uploadArchives --info --offline
2.7.6 组件文档
每个组件都要维护好说明文档,名为Readme文件。一般包含以下说明:
- 组件的功能介绍;
- 组件怎么集成,以及注意事项;
- 组件功能使用说明;
- 组件历史版本记录;
尽量做到团队内任何一个成员,通过该文档就能使用组件,而不需要找到组件的开发人员来讲解。