组件化设计方案

组件化

随着多个功能需要在多个业务线短时间内实现功能同步,我们需要将新增功能模块和已有的功能模块逐步组件化,以达到多个业务线间可快速同步的效果

何为组件

既然要实现组件化 那么什么样的叫做组件呢?
组件我们可以根据功能性和颗粒度的不同分为

  • 基础组件
  • 独立功能组件
  • 独立业务组件

我们先看下对于整个APP架构,我们的层次划分

对于不同的业务组件我们的拆分方法和颗粒度是不同的下面我们来分别介绍一下这三种组件的设计方案

基础组件

基础组件是我们实现上层业务的基础,因此我们需要颗粒度较细的去拆分,根据上面的架构图我们可以看到这里所说基础组件就是APP架构中的最底层。

对于放到基础组件中的代码,我们务必需要遵守下面几个条件:

  • 基础组件中的代码必须无任何依赖(可依赖三方)
  • 基础组件中的代码不涉及任何业务
  • 基础组件中的代码为上层组件服务,尽可能精简,功能也尽可能通用
  • 基础组件中不应包含任何资源文件

独立功能组件

独立功能组件,实际上是对上层常用功能的封装,比如基类、数据库、网络、图片下载和展示等。拆分的颗粒度同样要求较细,相似的功能应尽可能的拆分。

独立功能组件在层次结构上位于基础组件的上层,可依赖基础组件的代码。

对于放到独立功能组件中的代码,我们务必遵守下面几个条件:

  • 功能独立,每个组件代码要求遵守单一功能原则,不可合并类似功能的组件为一个大组件
  • 明确每个组件所属类别,例如:UI控件、网络模块
  • 每个功能模块如果依赖上层的配置 需要提供明确的接口供上层设置
  • 功能设计不涉及业务开关,如果需要那么提供配置来进行功能区分
  • 如果独立的功能有资源文件,那么资源文件考虑由外部配置,或者提供支持多条业务线的资源文件包
  • 多个独立功能组件间尽量减少依赖关系,尤其不可互相依赖
  • 独立功能组件必须是多个模块共用的,不可将只有某个模块使用的组件放入
  • 独立功能组件不可依赖上层组件

独立业务组件

独立业务组件,顾名思义一个独立的业务功能模块,是某个业务的多个功能模块的集合。往往也是我们组件化过程中最复杂和最难的部分,当然也是我们这篇文章中重点介绍的部分。

下面我们来详细的介绍下,业务组件化的主要流程:

  • 组件代码模块化
  • 组件内引用外部解耦
  • 组件外引用组件内方法解耦
  • 组件资源整理
  • 组件间的跳转
  • 组件多业务兼容

下面我们来详细的描述这几个流程

组件代码模块化

模块化,实际上是组件化过程中的一个中间形态,对于不是太复杂的工程中实际上做到模块化就可以了,但是对于较为复杂的工程组件化才是其终极形态。

组件代码归纳集中

这一步我们主要的工作是:将同属于该业务的分散在各个位置的功能页面集中到一个文件夹中。如果之前的项目就是根据业务模块按照MVC或者MVVM的实体文件夹结构来划分的,那么恭喜你,这一步的工作量可以大大减少。

而对于将要移动到业务组件中的业务代码,我们需要确认该功能模块是否隶属于该业务模块。实际上这是对整个项目的业务模块划分,这样我们才能比较清楚的了解每个业务模块应该包含哪些内容。

注意:代码集中时务必要使用实体文件夹进行管理

按照组件结构模块化

再将业务模块代码集中到独立文件夹后,我们可以根据组件化的模块结构对业务组件代码重新进行文件结构划分。

下面我们先看下组件化结构中文件夹结构划分,以及每个文件夹下代码的功能

当然在这个过程中,我们可以再次检查是否有遗漏的未移动的代码,同时要将外部涉及该业务模块的代码删除,统一移动到组件模块中进行统一的管理

抽取组件中的各类常量

在将业务组件的代码都归纳整理之后,我们需要抽取该业务模块中的一些常量。

下面我们罗列了其中的集中类型的常量,如图所示:

我们需要将之前写死在代码中的常量提取到这几个文件中,实现统一管理。

组件内引用外部解耦

组件化过程中,其实最复杂的就是解耦。首先我们需要知道组件内引用了多少外部业务组件的类,然后分别进行解耦。

这一步的操作流程:

  • 通过查看组件中的每一个类,确认头文件和实现文件中对外部类的引用,然后通过文档进行记录。

  • 将项目中的其他业务删除只保留当前组件化的业务模块,保证可以编译通过,进而找到之前隐藏未发现的依赖并记录。

  • 确认记录的每一个类该如何解决,解决方法大抵分为下面几种方法

    1、将依赖外部的类复制一份放到组件中
    2、将依赖的方法抽取放到组件中
    3、将依赖的类解耦后放到独立功能组件中
    4、如果只是跳转依赖,那么记录后 在后续考虑如何解耦

这一步操作完成后,我们实现了组件对外部依赖的耦合,且当前项目在只保留基础组件、独立项目组件和该业务组件时可以编译通过。

组件外引用组件内方法解耦

完成组件内对外部的引用解耦之后,我们下一步要做的就是组件外部内内部的引用。

这一步的操作流程:

  • 将该模块从项目中移除通过编译报错的方式确认外部对改模块的依赖并记录成wiki

  • 对于这部分发现的依赖主要有下面几种解决方法

    1、如果是外部跳转到该组件,那么需要改为调用该组件提供的跳转方法,进行跳转同时进行解耦

    2、如果外部依赖了组件内的某些类或者方法那么可以采取类似解决内因外耦合的方式通过复制类,复制方法或者通过非显示调用的方式进行解耦。

这一步操作完成后,我们基本上已经实现了代码层面的解耦!

组件资源整理

组件的资源包括下面几个:

  • 国际化资源(不同语言文字转换)
  • 图片资源(尤其是图片上有中文,或者图片与APP的icon有关)
  • 文件资源(lottie资源、plist配置文件)

我们首先需要将该组件用到的说有图片资源,放到一起,然后根据上面这三种类型对资源进行分类。其次我们需要将差异化的资源(例如图片上有中文的图片),根据不同业务线(业务线A、业务线B、业务线C)分别建立一个资源文件夹。

经过上述的整理最终的资源文件夹结构应该是

在不同的业务线即不同的APP中,我们使用图中的Public Resource和对应业务线的Resource。

在这一步操作完成后,我们基本上实现了不同业务线在该组件的资源整理和使用。

组件间的跳转

组件间的跳转,实际上是组件化中最重要的一部,因为经过上面的这些操作,我们将组件的代码从主工程中剥离,且可以独立编译通过。但是这个模块目前并没有与主工程链接起来。

所以组件间跳转实际上就是在主工程中将所有的组件链接起来。

组件化时,组件间的跳转大体有两种思路:

  • 1、基于 URL Router、ModuleManager的跳转(目前博客已删除活设置为私有)
  • 2、基于Target-Action的组件间跳转
  • 3、iOS 组件化方案探索

第三篇博客(Bang神)实际上比较了方案1和方案2两种做法,下面是从文章中摘抄出的总结:

1
2
3
上面论述下方案1(Target-Action)确实比方案2+方案3(Router)简单明了,没有 注册表常驻内存/参数传
递限制/调用分散 这些缺点,方案1多做的一步是需要对所有组件方法进行一层 wrapper,但若想要明确提供
组件的方法和参数类型,解耦统一处理,方案2和方案3同样需要多加这层。

但是,对于目前项目来说,盲目的迁移到Router或者Mediator实现组件间的跳转显然不太实际,而且维护的成本较高。

因此,我们采取的方法是在之前跳转的基础上稍加改动,并结合使用ProtocolKit进行解耦。

项目中之前就存在一个JumpUtil类这个类管理了项目中页面间的跳转,不过仍然可能会存在某些直接创建对应目标控制器并通过PUSH或Present方式跳转到对应页面。

因此 我们要做的第一件事为:将所有跳转到当前组件内页面的跳转方法统一改到JumpUtil中实现。每个模块我们通过创建JumpUtil对应分类的方式进行跳转的管理。通过这种方式,外部类在往组件中页面跳转时只需要引用JumpUtil对应的分类即可,不需要引用对应的控制器。

对于组件内部需要跳转到组件外的场景,我们使用ProtocolKit的方式进行解耦。

ProtocolKit 实际上是为对应的某个协议写一个默认实现。即我们声明了某个协议之后,不需要设置代理,直接调用协议中的方法。即可调用到我们写的默认实现中。

下面我们贴出ProtoclKit的核心源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// For a magic reserved keyword color, use @defs(your_protocol_name)
//defs 只是 _pk_extension 的别名,为了提供一个更加合适的名字作为接口 使用的时候用@defs()
#define defs _pk_extension

// 我们使用的时候用@defs(Protocol)实际上是调用_pk_extension_imp方法
#define _pk_extension($protocol) _pk_extension_imp($protocol, _pk_get_container_class($protocol))

// 这个宏实际上是创建了一个隐藏类(container_class) 这个类遵守protocol这个协议
// 然后在load方法中调用内部实现的load(load方法也是在main函数执行之前执行的)
#define _pk_extension_imp($protocol, $container_class) \
protocol $protocol; \
@interface $container_class : NSObject <$protocol> @end \
@implementation $container_class \
+ (void)load { \
_pk_extension_load(@protocol($protocol), $container_class.class); \
} \

// _pk_get_container_class 实际上就是生成一个类名
// 可以看到这个类名实际上是由类名+协议名+count(一个计数器每次调用会自动加一)
#define _pk_get_container_class($protocol) _pk_get_container_class_imp($protocol, __COUNTER__)
#define _pk_get_container_class_imp($protocol, $counter) _pk_get_container_class_imp_concat(__PKContainer_, $protocol, $counter)
#define _pk_get_container_class_imp_concat($a, $b, $c) $a ## $b ## _ ## $c

void _pk_extension_load(Protocol *protocol, Class containerClass);

因此当A组件中的某个事件响应要跳转到B组件中的某个页面时

之前的写法:

1
2
3
4
5
#import "BViewController.h"
- (void)moduleAJumpToModuleB {
BViewController *bVc = [[BViewController alloc] init];
[self.navigationController pushViewController:bVc animated:YES];
}

修改之后的写法:

新建protocol

1
2
3
4
5
@protocol ULModuleBJumpDelegate <NSObject>

- (void)moduleAJumpToModuleB;

@end

首先当前类应遵守这个协议 ULModuleBJumpDelegate

跳转方法改为直接调用协议中的方法

1
2
3
- (void)moduleAJumpToModuleBMediator {
[self ul_moduleAJumpToModuleB];
}

最后,在组件外新建一个解耦类ULModuleBMediator并实现对应方法

1
2
3
4
5
6
7
8
#import "BViewController.h"

@defs(ULModuleBJumpDelegate)

- (void)ul_moduleAJumpToModuleB {
BViewController *bVc = [[BViewController alloc] init];
[self.navigationController pushViewController:bVc animated:YES];
}

这样我们就解除了,在A模块要跳转到B模块时必须导入B模块的相关类的问题,上面声明的协议放在A组件内,组件的实现放到外部主工程中。

总结

对于一个成熟的项目,尤其是创建时间比较久的项目,组件化是一个任重而道远的过程,组件化过程中,我们要本着小步快走的方式,逐步进行组件化。同时也避免因为组件化而影响业务需求的进度。但组件化后,对于各个业务模块的维护有比较明显的提升同时组件在不同业务线之间进行复用也能更加的方便和快速。所以,组件化是项目的终极目标。