题记

虽说OA系统开发已于2020年因公司业务调整而终结,且离当前时间已久远;但其中在我负责的终端开发团队中所使用的技术还是有所考究的,例如混合开发技术、组件化技术、插件化方案等等。现在各个团队成员已各奔东西,我业已与今年11月从东家离职,其中不乏一些情愫、blabla等技术或故事回忆。下面就项目产品回忆将组件化架构设计方案一一分享。

1引言

  组件化不是个新概念,其在各行各业都一直备受重视。在服务端已十分成熟,后来在该思想的指导下,前端开发和移动端开发也产生各自的开发方式。

  OA经过多年迭代开发终端功能强大,同时代码量也十分庞大。OA的业务特性,功能独立,很适合应用组件化设计。现在的架构虽然做了代码结构分层,但是在项目设计之初没有按照组件化思想进行业务分层,一些需要下沉的业务放在主模块内,当模块功能越来越多时,模块间就容易盘根错节。曾经尝试只独立单独的业务模块,但是却发现模块间业务耦合较多,相互掣肘举步维艰。

要解决此问题,组件化是最优解决方案。现在因为有Blade框架的思想,代码耦合并不严重,主要重点在于业务解耦。如果要设计组件化架构,必须要跳出原有代码结构思路,将OA的所有功能进行重新整理,依据组件化思想,将业务重新分类、分层、功能内聚、剥离耦合,方能实现组件化的最终目标。

1.1模块化、组件化、插件化关系

模块化
中心思想与组件化基本一致,都是“大化小”,不强调单独运行,业务分离粒度较小,实现各个模块代码分离,更侧重于业务解耦,是组件化的基础。

组件化
拆分每个业务为独立工程,侧重于每个模块独立运行,可以极大提高大型项目的编译速度。业务组件之间没有引用关系,每个组件都把对应的业务功能收敛在一个工程里。每个人只负责自己的模块,对模块的修改也限定于模块内。组件业务高度独立,可以在其他项目中复用,维护好每个组件,在用到时可以快速集成。但是组件化开发前期可能要花费更多的时间来进行模块拆分,并可能带来一部分重复代码。

插件化
插件化是将宿主和插件分开编译,独立打包,支持在线热更新,支持插件的动态安装、卸载的功能,可以减少安装包体积用户可只要按需下载模。是项目组件化到一定程度的进阶产物。现在市面上插件化框架比较多,已经发展到第三代,比较成熟。

1.2 OA现在的痛点
  1. 代码数量庞大,任何一个小功能修改,都需要编译整个项目,编译效率低下。
  2. 不利于多人开发,现在OA开发团队越来越庞大,经常发生开发过程中不小心影响到其他模块的问题。这样开发人员之间势必要花更多的时间去沟通和协调,没法专注自己的功能点。
  3. 业务模块耦合严重,部分基础模块边界模糊,应该作为基础数据的模块与界面耦合在一起,需要通过接口代理,模块反向依赖主模块进行业务交互。
  4. 组件无法抽出来重用,有很多比较成熟的组件无法给其他项目使用。
  5. 定制化需求导致需要维护多个代码分支,经常相互合并各个分支,经常在合并代码时,解决代码冲突解决地让人怀疑人生。

2 组件化方案描述

2.1 架构图

记一次基于OA系统的终端组件化架构设计_组件化

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 基础业务层

记一次基于OA系统的终端组件化架构设计_oa_02

基础业务层可以理解为核心数据提供层,核心数据包含联系人数据和账户数据,主要对这些基础数据进行同步和维护。包含数据同步、数据持久化、数据读取等维护独立数据的业务,不包含任何界面业务。与上层业务只能通过公共服务层才能访问到基础服务层。

2.2.4.1.1 联系人业务

联系人的所有数据业务均有此模块维护,将现有的ContactReponsitory接口的实现和ContactDataStoreContactCloudStore全部移到这里,通过公共服务层进行对外提供业务接口,这样可以通过接口实现隔离,并且遵循了Blade本身的业务层级隔离的思想,以最小的改动,实现业务分层。

2.2.4.1.2 账户业务

账户模块用来维护账户的所有信息,需要再登陆业务完成后立即初始化,以便所有业务使用账户数据。迁移实现方式与联系人业务相同。

2.2.4.2 公共服务层

公共服务层主要存放数据库表结构和联系人、账户的业务对象,用来与上层继续业务交互。这些所有对象放在独立的模块内,公共服务层以上均可以直接引用。服务接口传递数据也直接使用这些对象,解决对象反复转换的问题。

2.2.4.2.1 数据库层

基础层数据库层只存放联系人和账户相关的数据库表结构,并开放数据库初始化的接口供登录后使用账户id初始化数据库使用。

2.2.4.2.2 业务对象

业务对象具体包含:AccountContactBeanPersonBeanDepartmentBeanCompanyBeanDeptPersonBeanFavoriteContactBean。均用于联系人和账户数据传递使用。

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之间的对比:

记一次基于OA系统的终端组件化架构设计_oa_03

ARouter路由发现流程大致如下图:

记一次基于OA系统的终端组件化架构设计_oa_04

2.3.4 跨组件数据传递

正常的组件间传递数据,是使用Intent,里面有个对象 叫private Bundle mExtras ,来存放需要传递的数据。

ARouter本质上也是使用Intent来传递数据,不过他作为一个中间层,封装了一个叫做Postcard(明信片)这样一个对象。最终会将数据从Postcard中取出,塞到IntentBundle中。

ARouter传递数据大致用法如下:

ARouter.getInstance().build("/test/1")
            .withLong("key1", 666L)
            .withString("key3", "888")
            .withObject("key4", new Test("Jack", "Rose"))
            .navigation();
2.3.5 服务发现

ARouter同时也可以支持服务暴露和服务发现功能。

a. 需要暴露的服务接口 继承 ARouterIProvider 接口

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();
  }
}

整体服务发现流程如下图:

记一次基于OA系统的终端组件化架构设计_oa_05

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调用各个组件进行生命周期分发。

使用方法:

  1. 在组件的AndroidManifest.xml中配置Application节点下增加,指定ComponentConfig路径。
<application >
 <meta-data
     android:name="com.example.mysublib1.SubConfig"
     android:value="ComponentConfig" />
</application>
  1. 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());
    }
  1. 在主Application 内初始化代理类ApplicationDelegate,绑定各个组件生命周期。

改造方案:因为现有的组件初始化优先级实现在框架内的ForkedApplication里,不方便拓展。所以现在通过代理类替换继承,去掉*buildBoundedAppGraph()*方法的实现。通过ComponentConfig传递EoaCommonApplication等几个Application。

2.5.2 架构设计类图

记一次基于OA系统的终端组件化架构设计_oa_06

2.5.3 优劣势

优势:

  1. 通过接口下沉方式隔离组件与壳
  2. 支持按照优先级进行初始化组件
  3. 上手难度小,维护成本低
  4. 通过接口隔离在ApplicationDelegate类内,可以对组件通用进行统一管理(例如:传递基础配置,初始化全局OkHttp等),不会影响组件内部逻辑。
  5. 分发生命周期同时将Application同时下发,让组件操作宿主Application更加灵活

劣势:

  1. 组件仍需做特殊配置才能与主生命周期进行绑定。
  2. 基础模块增加了对组件化生命周期的维护成本。
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 组件发布
组件发布的优点
  1. 组件发布aar以后,源码从源码依赖变成了aar依赖,可以大大提高编译效率
  2. 组件源码依赖隔离以后,可以作为单独仓库git,大大减少与依赖方的代码耦合,并且可以多团队协作
  3. 独立发布后,可以作为公共组件提供给三方使用,不再局限在自身项目当中
组件发布的缺点
  1. 组件发布时,所依赖的子项需要预先发布为aar,例如

准备发布aar 1 , 依赖 B 和 C。需要预先将B和C发布为aar,否则会导致发布的aar1中关于B和C的依赖版本是unspecified, 当拉取aar1时,一直等待无法完成。

  1. 组件在不稳定的时候会导致频繁发布aar,导致依赖方也需要不断更新aar的版本
  2. 组件在经历较长时间以后,出现多个版本的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文件。一般包含以下说明:

  1. 组件的功能介绍;
  2. 组件怎么集成,以及注意事项;
  3. 组件功能使用说明;
  4. 组件历史版本记录;

尽量做到团队内任何一个成员,通过该文档就能使用组件,而不需要找到组件的开发人员来讲解。