有赞移动 iOS 组件化(模块化)架构设计实践

1、背景

业务组件化(或者叫模块化)做为移动端应用架构的主流方式之一,近年来一直是业界积极探索和实践的方向。有赞移动团队自16年起也在不断尝试各类组件化方案,在有赞微商城,有赞零售,有赞美业等多个应用中进行了实践。咱们踩过一些坑,也收获了不少宝贵的经验,并沉淀出 iOS 相关框架 Bifrost (雷神里的彩虹桥)。在过程当中咱们深入体会到“没有绝对正确的架构,只有最合适的架构”这句话的意义。不少通用方案只是组件化的冰山一角,实际落地过程当中还有至关多的东西须要考量。
本文并不许备对组件化架构设计方案给出一份标准答案,而是但愿经过咱们的实践经验和思考分析,提供一种思路,对遇到相似问题的同窗能有所启发。前端

git

  1. 区别于功能模块/组件(好比图片库,网络库),本文讨论的是业务模块/组件(好比订单模块,商品模块)相关的架构设计。
  2. 相比组件(Component),我的感受称之为模块(Module)更为合适。组件强调物理拆分,以便复用;模块强调逻辑拆分,以便解耦。并且若是用过 Android Studio, 会发现它建立的子系统都叫 Module. 但介于业界习惯称之为组件化,因此咱们继续使用这个术语。本文下面所用名词,“模块”等同于“组件”。

2、什么是业务模块化(组件化)

传统的 App 架构设计更多强调的是分层,基于设计模式六大原则之一的单一职责原则,将系统划分为基础层,网络层,UI层等等,以便于维护和扩展。但随着业务的发展,系统变得愈来愈复杂,只作分层就不够了。App 内各子系统之间耦合严重, 边界愈来愈模糊,常常发生你中有我我中有你的状况(图一)。这对代码质量,功能扩展,以及开发效率都会形成很大的影响。此时,通常会将各个子系统划分为相对独立的模块,经过中介者模式收敛交互代码,把模块间交互部分进行集中封装, 全部模块间调用均经过中介者来作(图二)。这时架构逻辑会清晰不少,但由于中介者仍然须要反向依赖业务模块,这并无从根本上解除循坏依赖等问题。时不时发生一个模块进行改动,多个模块受影响编译不过的状况。进一步的,经过技术手段,消除中介者对业务模块依赖,即造成了业务模块化架构设计(图三)。
<center></center>
经过业务模块化架构,通常能够达到明确模块职责及边界,提高代码质量,减小复杂依赖,优化编译速度,提高开发效率等效果。不少文章都有相关分析,在此再也不累述。github

3、业界常见模块化方案

业务模块化设计经过对各业务模块的解耦改造,避免循环双向依赖,达到提高开发效率和质量的目的。但业务需求的依赖是没法消除的,因此模块化方案首先要解决的是如何在无代码依赖的状况下实现跨模块通讯的问题。iOS 由于其强大的运行时特性,不管是基于 NSInvocation 仍是基于 peformSelector 方法, 均可以很很容易作到这一点。但不能为了解耦而解耦,提高质量与效率才是咱们的目的。直接基于 hardcode 字符串 + 反射的代码明显会极大损害开发质量与效率,与目标背道而驰。因此,模块化解耦需求的更准确的描述应该是“如何在保证开发质量和效率的前提下作到无代码依赖的跨模块通讯”。
目前业界常见的模块间通信方案大体以下几种:objective-c

  1. 基于路由 URL 的 UI 页面统跳管理。
  2. 基于反射的远程接口调用封装。
  3. 基于面向协议思想的服务注册方案。
  4. 基于通知的广播方案。

根据具体业务和需求的不一样,大部分公司会采用以上一种或者某几种的组合。编程

3.1 路由 URL 统跳方案

统跳路由是页面解耦的最多见方式,大量应用于前端页面。经过把一个 URL 与一个页面绑定,须要时经过 URL 能够方便的打开相应页面。json

//经过路由URL跳转到商品列表页面
//kRouteGoodsList = @"//goods/goods_list"
UIViewController *vc = [Router handleURL:kRouteGoodsList]; 
if(vc) {
    [self.navigationController pushViewController:vc animated:YES];
}

固然有些场景会比这个复杂,好比有些页面须要更多参数。
基本类型的参数,URL 协议自然支持:后端

//kRouteGoodsDetails = @“//goods/goods_detail?goods_id=%d”
NSString *urlStr = [NSString stringWithFormat:@"kRouteGoodsDetails", 123];
UIViewController *vc = [Router handleURL:urlStr];
if(vc) {
   [self.navigationController pushViewController:vc animated:YES];
}

复杂类型的参数,能够提供一个额外的字典参数 complexParams, 将复杂参数放到字典中便可:设计模式

+ (nullable id)handleURL:(nonnull NSString *)urlStr
           complexParams:(nullable NSDictionary*)complexParams
              completion:(nullable RouteCompletion)completion;

上面方法里的 completion 参数,是一个回调 block, 处理打开某个页面须要有回调功能的场景。好比打开会员选择页面,搜索会员,搜到以后点击肯定,回传会员数据:缓存

//kRouteMemberSearch = @“//member/member_search”
UIViewController *vc = [Router handleURL:urlStr complexParams:nil completion:^(id  _Nullable result) {
    //code to handle the result
    ...
}];
if(vc) {
    [self.navigationController pushViewController:vc animated:YES];
}

考虑到实现的灵活性,提供路由服务的页面,会将 URL 与一个 block 相绑定。block 中放入所需的初始化代码。能够在合适的地方将初始化 block 与路由 URL 绑定,好比在 +load 方法里:服务器

+ (void)load {
    [Router bindURL:kRouteGoodsList
           toHandler:^id _Nullable(NSDictionary * _Nullable parameters) {
        return [[GoodsListViewController alloc] init];
    }];
}

更多路由 URL 相关例子,能够参考 Bifrost 项目中的 Demo.

URL 自己是一种跨多端的通用协议。使用路由URL统跳方案的优点是动态性及多端统一 (H5, iOS,Android,Weex/RN); 缺点是能处理的交互场景偏简单。因此通常更适用于简单 UI 页面跳转。一些复杂操做和数据传输,虽然也能够经过此方式实现,但都不是很效率。
目前天猫和蘑菇街都有使用路由 URL 做为本身的页面统跳方案,达到解耦的目的。

3.2 基于反射的远程调用封装

当没法 import 某个类的头文件但仍需调用其方法时,最常想到的就是基于反射来实现了。例:

Class manager = NSClassFromString(@"YZGoodsManager");
NSArray *list = [manager performSelector:@selector(getGoodsList)];
//code to handle the list
...

但这种方式存在大量的 hardcode 字符串。没法触发代码自动补全,容易出现拼写错误,并且这类错误只能在运行时触发相关方法后才能发现。不管是开发效率仍是开发质量都有较大的影响。

如何进行优化呢?这实际上是各端远程调用都须要解决的问题。移动端最多见的远程调用就是向后端接口发网络请求。针对这类问题,咱们很容易想到建立一个网络层,将这类“危险代码”封装到里面。上层业务调用时网络层接口时,不须要 hardcode 字符串,也不须要理解内部麻烦的逻辑。

相似的,我能够将模块间通信也封装到一个“网络层”中(或者叫消息转发层)。这样危险代码只存在某几个文件里,能够特别地进行 code review 和联调测试。后期还能够经过单元测试来保障质量。模块化方案中,咱们能够称这类“转发层”为 Mediator (固然你也能够起个别的名字)。同时由于 performSelector 方法附带参数数量有限,也没有返回值,因此更适合使用 NSInvocation 来实现。

//Mediator提供基于NSInvocation的远程接口调用方法的统一封装
- (id)performTarget:(NSString *)targetName
             action:(NSString *)actionName
             params:(NSDictionary *)params;

//Goods模块全部对外提供的方法封装在一个Category中
@interface Mediator(Goods)
- (NSArray*)goods_getGoodsList;
- (NSInteger)goods_getGoodsCount;
...
@end
@impletation Mediator(Goods)
- (NSArray*)goods_getGoodsList {
    return [self performTarget:@“GoodsModule” action:@"getGoodsList" params:nil];
}
- (NSInteger)goods_getGoodsCount {
    return [self performTarget:@“GoodsModule” action:@"getGoodsCount" params:nil];
}
...
@end

而后各个业务模块依赖Mediator, 就能够直接调用这些方法了。

//业务方依赖Mediator模块,能够直接调用相关方法
...
NSArray *list = [[Mediator sharedInstance] goods_getGoodsList];
...

这种方案的优点是调用简单方便,代码自动补全和编译时检查都仍然有效。
劣势是 category 存在重名覆盖的风险,须要经过开发规范以及一些检查机制来规避。同时 Mediator 只是收敛了 hardcode, 并未消除 hardcode, 仍然对开发效率有必定影响。

业界的 CTMediator 开源库,以及美团都是采用相似方案。

3.3 服务注册方案

有没有办法绝对的避免 hardcode 呢?若是接触事后端的服务化改造,会发现和移动端的业务模块化很类似。Dubbo 就是服务化的经典框架之一。它是经过服务注册的方式来实现远程接口调用的。即每一个模块提供本身对外服务的协议声明,而后将此声明注册到中间层。调用方能从中间层看到存在哪些服务接口,而后直接调用便可。例:

//Goods模块提供的全部对外服务都放在GoodsModuleService中
@protocol GoodsModuleService
- (NSArray*)getGoodsList;
- (NSInteger)getGoodsCount;
...
@end
//Goods模块提供实现GoodsModuleService的对象, 
//并在+load方法中注册
@interface GoodsModule : NSObject<GoodsModuleService>
@end
@implementation GoodsModule
+ (void)load {
    //注册服务
    [ServiceManager registerService:@protocol(service_protocol) 
                  withModule:self.class]
}
//提供具体实现
- (NSArray*)getGoodsList {...}
- (NSInteger)getGoodsCount {...}
@end

//将GoodsModuleService放在某个公共模块中,对全部业务模块可见
//业务模块能够直接调用相关接口
...
id<GoodsModuleService> module = [ServiceManager objByService:@protocol(GoodsModuleService)];
NSArray *list = [module getGoodsList];
...

这种方式的优点也包括调用简单方便。代码自动补全和编译时检查都有效。实现起来也简单,协议的全部实现仍然在模块内部,因此不须要写反射代码了。同时对外暴露的只有协议,符合团队协做的“面向协议编程”的思想。劣势是若是服务提供方和使用方依赖的是公共模块中的同一份协议(protocol), 当协议内容改变时,会存在全部服务依赖模块编译失败的风险。同时须要一个注册过程,将 Protocol 协议与具体实现绑定起来。

业界里,蘑菇街的 ServiceManager 和阿里的 BeeHive 都是采用的这个方案。

3.4 通知广播方案

基于通知的模块间通信方案,实现思路很是简单, 直接基于系统的 NSNotificationCenter 便可。
优点是实现简单,很是适合处理一对多的通信场景。
劣势是仅适用于简单通信场景。复杂数据传输,同步调用等方式都不太方便。
模块化通信方案中,更多的是把通知方案做为以上几种方案的补充。

3.5 其它

除了模块间通信的实现,业务模块化架构还须要考虑每一个模块内部的设计,好比其生命周期控制,复杂对象传输,重复资源的处理等。可能由于每一个公司都有本身的实际场景,业界方案里对这些问题描述的并非不少。但实际上他们很是重要,有赞在模块化过程当中作了不少相关思考和尝试,会在后面环节进行介绍。

4、有赞的模块化实践

有赞移动自 16 年起开始实践业务模块化架构方式,大体经历了 2016 年的尝试+摸索,2017 年的思考+优化以及 2018 年的成熟+沉淀几个阶段。期间有过对已有 App 的模块化改造,也试过直接应用于新起项目。模块化方案经历过几回改版,踩过一些坑,也收获了不少宝贵的经验。

4.1 v1.0: 尝试+摸索

16 年,有赞微商城、有赞收银等 App 经历了初期的功能快速迭代,内部依赖混乱,耦合严重,急需优化重构。传统的 MVVM、MVP 等优化方式没法从全局层面解决这些问题。后来在 InfoQ 的"移动开发前线"微信群里听了蘑菇街的组件化方案分享,很是受启发。不过当时仍是有一些顾虑,好比微商城和收银当时都属于中小型项目,每端开发人员都只有 4-6 人。业务模块化改造后会造成必定的开发门槛,带来必定的开发效率降低。小项目适合模块化改造吗?其收益是否能匹配付出呢?但考虑到当时 App 各模块边界已经稳定,即便模块化改造出现问题,也能够用很小的代价将其降级到传统的中介者模式,因此改造开始了。

4.1.1 模块间通讯方式设计

首先是梳理咱们的模块间通讯需求,主要包括如下三种:

  1. UI 页面跳转。好比IM模块点击用户头像打开会员模块的用户详情页。
  2. 动做执行及复杂数据传输。好比商品模块向开单模块传递商品数据模型并进行价格计算。
  3. 一对多的通知广播。好比 logout 时帐号模块发出广播,各业务模块进行 cache 清理及其它相应操做。

咱们选择了路由 URL + 远程接口调用封装 + 广播相结合的方式。

对于远程接口调用的封装方式,咱们没有彻底照抄 Mediator 方案。当时很是指望保留模块化的编译隔离属性。好比当 A 模块对外提供的某个接口发生变化时,不会引起依赖这个接口的模块的编译错误。这样能够避免依赖模块被迫中断手头的工做先去解决编译问题。当时也没有采用Beehive的服务注册方式,也是由于一样的缘由。 通过讨论,当时选择参考网络层封装方式,在每一个模块中设计一个对外的“网络层” ModuleService。将对其它模块的接口的反射调用,放入各个模块的 ModuleService 中。
同时,咱们但愿各业务模块不须要去理解所依赖模块的内部复杂实现。好比 A 模块依赖 D 模块的 class D1 的接口 method1, class D2 的接口method2, class D3 的接口 method3. A 须要了解 D 模块的这些内部信息才能完成反射功能的实现。若是 D 模块中这些命名有所变化,还会出现调用失败。因此咱们对各个模块使用外观(Facade)模式进行重构。D 模块建立一个外观层 FacadeD. 经过 FacadeD 对象对外提供全部服务,同时隐藏内部复杂实现。调用方也只须要理解 FacadeD 的头文件 包含哪些接口便可。

外观(Facade)模式: 为子系统中的一组接口提供一个一致的界面, Facade 模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。引入外观角色以后,用户只须要直接与外观角色交互,用户与子系统之间的复杂关系由外观角色来实现,从而下降了系统的耦合度。
<center>Facade</center>

另外,为何还须要路由 URL 呢?
其实从功能角度,远程接口的网络层,彻底能够取代路由 URL 实现页面跳转,并且没有路由 URL 的一些 hardcode 的问题。并且路由 URL 和
远程接口存在必定的功能重合,还会形成后续实现新功能时,分不清应选择路由 URL 仍是选择远程接口的困惑。这里选择支持路由 URL 的主要缘由是咱们存在动态化且多端统一的需求。好比消息模块下发的各类消息数据模型彻底是动态的。后端配好展现内容以及跳转需求后,客户端不须要理解具体需求,只须要经过统一的路由跳转协议执行跳转动做便可。

4.1.2 模块内设计及 App 结构调整

每一个模块除了 Facade 模式改造以外,还须要考虑如下问题:

  1. 合适的注册及初始化方式。
  2. 接收并处理全局事件。
  3. App 层和 Common 层设计。
  4. 模块编译产出以及集成到 App 中的方式。

由于考虑到每一个 App 中业务模块数量不会不少(咱们几个 App 内大可能是20个左右),因此咱们为每一个模块建立了一个 Module 对象并令其为单例。在 +load 方法中将自身注册给模块化 SDK Bifrost. 经测试,这里由于单例形成的内存占用以及 +load 方法引发的启动速度影响都微乎其微。模块须要监听的全局事件主要为 UIApplicationDelegate 中的那些方法。因此咱们定义了一个继承 UIApplicationDelegate 的协议 BifrostModuleProtocol,令每一个模块的 Module 对象都服从这个协议。App 的 AppDelegate对象,会轮询全部注册了的业务模块并进行必要的调用。

@protocol BifrostModuleProtocol <UIApplicationDelegate, NSObject>
@required
+ (instancetype)sharedInstance;
- (void)setup;
...
@optional
+ (BOOL)setupModuleSynchronously;
...
@end

全部业务代码挪入各业务模块的 Module 对象后,AppDelegate 很是干净。

@implementation YZAppDelegate
- (BOOL)application:(UIApplication *)application willFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    [Bifrost setupAllModules];
    [Bifrost checkAllModulesWithSelector:_cmd arguments:@[Safe(application), Safe(launchOptions)]];
    return YES;
}
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    [Bifrost checkAllModulesWithSelector:_cmd arguments:@[Safe(application), Safe(launchOptions)]];
    return YES;
}
- (void)applicationWillEnterForeground:(UIApplication *)application {
    [Bifrost checkAllModulesWithSelector:_cmd arguments:@[Safe(application)]];
}
...
@end

每一个业务模块都做为一个子 Project 集成入 App Project. 同时建立一个特殊的模块 Common,用于放置一些通用业务和全局的基类。App 层只保留 AppDelegate 等全局类和 plist 等特殊配置,基本没有任何业务代码。Common 层由于没有明确的业务组来负责,因此也应该尽可能轻薄。各业务模块之间互不可见,但能够直接依赖 Common 模块。经过search path来设置模块依赖关系。

每一个业务模块的产出包括可执行文件和资源文件两部分。有2种选择:生成 framework 和生成静态库 + 资源 bundle.

使用framework的优势是输出在同一个对象内,方便管理。缺点是做为动态库载入,影响加载速度。因此当时选择了静态库 + bundle 的形式。不过我的感受这块仍是须要具体测一下会慢作少再作决定更合适。但由于两者差异不大,因此后续咱们也一直没做调整。

另外若是使用framework,须要注意资源读取的问题。由于传统的资源读取方式没法定位到framework内资源,须要经过 bundleForClass: 才行。

//传统方式只能定位到指定bundle,好比main bundle中资源
NSURL *path = [[NSBundle mainBundle] URLForResource:@"file_name" withExtension:@"txt"]; 

// framework bundle须要经过bundleForClass获取
NSBundle *bundle = [NSBundle bundleForClass:classA]; //classA为framework中的某各种
// 读UIStoryboard
UIStoryboard *storyboard = [UIStoryboard storyboardWithName:@“sb_name” bundle:bundle];
// 读UIImage
UIImage *image = [UIImage imageNamed:@"icon_name" inBundle:bundle compatibleWithTraitCollection:nil];
...

4.1.3 复杂对象传输

当时最纠结的点就是复杂对象的传输。例如商品模型,它包含几十个字段。若是是传字典或传 json, 那么数据提供方(商品模块)和使用方(开单模块)都须要专门理解并实现一下这种模型的各类字段,对开发效率影响很大.

有没有办法直接传递模型对象呢?这里涉及到模型的类文件放在哪里。最容易想到的方案是沉入 Common 模块。但一旦这个口子放开,后续会有愈来愈多的模型放入 Common,和前面提到的简化 Common 层的目标是相悖的。并且由于 Common 模块没有明确业务组归属,全部小组都能编辑, 其质量和稳定性难以保障。最终咱们采用了一个 tricky 的方案,把要传递的复杂模型的代码复制一份放在使用方模块中,同时经过修改类名前缀加以区分,这样就能够避免打包时的连接冲突错误。好比商品模块内叫 YZGGoodsModel, 开单模块内叫 YZSGoodsModel. 商品模块的接口返回的是 YZGGoodsModel,开单模块将其强转为 YZSGoodsModel 便可。

//YZSaleModuleService.m内
#import "YZSGoodsModel.h"

- (YZSGoodsModel*)goodsById:(NSString*)goodsId {
    //Sale Module远程调用Goods Module的接口
    id obj = [Bifrost performTarget:@"YZGoodsModule"
                           action:@"goodsById:"
                             params:@[goodsId]];
    //作一次强转
    YZSGoodsModel *goods = (YZSGoodsModel*)obj;
    return goods;
}

这种方式虽然比较粗暴,但考虑到两个模块间交互的复杂对象应该不会不少(若是太多则应考虑这两个模块是否划分合适),同时拷贝粘贴操做起来成本可控,因此能够接受。同时这种方法也能达到预期的编译隔离的效果。但两边模型定义及实现仍是有不一致的风险。为了解决一致性问题,咱们作了个检查脚本工具,在编译时触发。会根据命名规则查找这类“同名” model 的代码,并作一个比较。若是发现不一致,则报 warning. 注意不是报error, 由于咱们但愿一个模块作了接口修改,另外一个模块能够存在一种选择,是立刻更新接口,仍是先完成手头的工做未来再更新。

4.1.4 重复资源处理

这类资源主要包括图片、音视频,数据模型等等。

首先咱们排除了无脑放入 Common 的方案。由于下沉入 Common 会破坏各业务模块的完整性,同时也会影响 Common 的质量。通过讨论后,决定把资源分为三类:

  1. 通用功能所用资源,将相关代码整理为功能组件后一块儿放入 Common.
  2. 业务功能的大部分资源能够经过无损压缩控制体积,体积不大的资源容许必定程度上的重复。
  3. 较大致积的资源放到服务端,App 端动态拉取放在本地缓存中。

同时平时按期经过自动化工具检测无用资源,以及重复资源的大小,以便及时优化包体积。

4.1.5 体验与成果

基于以上设计,咱们大概花了 3 的个月的时间对已有项目进行了业务模块化改造(边作业务边改造)。由于方案细节考虑的比较多,你们对一些可能存在的问题也都有预期,因此当时改造后你们多持确定态度,成本 vs 收益仍是可观的。

v1.0 版本改造后,App 架构关系如图:
<center>Arch1.0</center>
App 项目结构如图:

<img src="https://tech.youzan.com/conte...; width = 180 />

4.2 v2.0: 思考+优化

16 年的初版模块化设计方案虽然可行,但还存在两个痛点:

  1. 模块间网络层的封装基于反射代码,写起来仍然有些麻烦。并且须要额外写单测保证质量。
  2. 复杂对象的处理方式也存在一些问题,好比拷贝粘贴的方式比较丑陋,重复代码会带来包体积的增长。

上述问题在团队规模扩大,新同窗到来时格外明显,常常须要答疑讲解。甚至有一次业务项目时间特别紧张时,有些小伙伴私下更改模块间头文件 search path,直接依赖的了别的模块,以便重用复杂模型类的状况。

这些问题的根本缘由仍是存在效率损失,"不方便",怎么优化呢?

4.2.1 远程接口封装优化

首先是如何避免反射及 hardcode. 阿里 Beehive 的基于服务注册的方式 是不须要 hardcode 代码的。但它有额外的服务注册过程,可能会影响启动速度,性能弱于基于反射的接口封装方案。这里对启动速度的影响究竟有多少呢?咱们作了个测试,在 +load 方法中注册了 1000 个 Sevice Protocol, 启动时间影响大概是 2-4 ms, 很是少。
<center></center>
由于咱们每一个模块都是基于外观模式设计的。因此每一个模块只须要对外暴露一个 Service Protocol 便可。咱们 App 的实际模块数量大概是 20 个,因此对启动速度的影响能够忽略不计。并且前文提到,每一个模块原本也须要注册本身的外观类(Module 对象)以处理生命周期和接受 AppDelegate 消息。这里 Service Protocl 的实现者就是这个 Module 对象,因此其实没有额外的性能消耗。

4.2.2 复杂对象传输优化

以前的业务模块化方案没有使用 Beehive 还有个缘由,就是服务提供方和使用方共同依赖同一个 Protocol,不符合咱们编译隔离的需求。但既然咱们能够拷贝粘贴复杂对象代码,是否也能够拷贝粘贴 Protocol 声明呢?答案是可行的。并且即便工程中同时存在多个同名的 Protocol 也不会引发编译问题,连更名这一步都省去了。以商品模型为例,为它定义一个 GoodModelProtocol, 服务使用方开单模块能够直接将这个 Protocol 的声明 copy 到本身模块中,也不须要更名,操做成本很是低。而后商品模块内就可使用这个 Protocol 了。同时由于用的是同一个协议对象,因此 v1.0 中的类型强转风险也没有了。

跨模块进行方法调用和数据读取很是便捷:

NSString *goodsID = @"123123123";
id<YZGoodsModelProtocol> goods = [BFModule(YZGoodsModuleService) goodsById:goodsID];
self.goodsCell.name = goods.name;
self.goodsCell.price = goods.price;
...

为尽可能减小拷贝粘贴频率,咱们将每一个模块对外提供的接口服务,路由定义,通知定义,以及复杂对象 Protocol 定义都放在 ModuleService.h 中。管理很是方便规范,别的模块 copy 起来也简单,只须要把这个 ModuleService.h 文件
copy 到本身模块内部,就能够直接依赖并调用接口了。并且若是未来须要从服务器拉取相关配置,一个文件会方便不少。可是也须要考虑若是以上内容都放入同一个头文件,会不会致使文件过大的问题。当时分析模块间交互是有限的,不然就须要考虑模块划分是否合适。因此问题应该不大。从结果来看,目前咱们最大的 ModuleService.h, 加上注释大概是 300 多行。

4.2.3 其它优化

另外,咱们发现每一个模块对初始化顺序也有需求。好比帐号模块的初始化可能要优先于别的模块,以便别的模块在初始化时使用其服务。因此咱们也对 ModuleProtocol 增长了优先级接口。每一个模块能够定义本身的初始化优先级。

/**
 The priority of the module to be setup. 0 is the lowest priority;
 If not provided, the default priority is BifrostModuleDefaultPriority;

 @return the priority
 */
+ (NSUInteger)priority;

通过以上优化改造,基本解决了 v1.0 的全部质量及效率方面的隐患,业务模块化方案趋近成熟。

4.3 v3.0: 成熟+沉淀

17 年优化后的模块化方案,基本算是具备有赞特点的相对成熟的方案了,支撑了包括零售在内的多个大型app的开发。

4.3.1 编译隔离的思考

Copy 头文件的方式仍然有一些理解成本。移动团队规模快速发展,一些新来的小伙伴仍是会提出疑问。18 年年中咱们作了几回检查,发现模块间 ModuleService 版本不一致的状况时有发生。当时零售移动团队虽然达到 30 多人,但仍然是一个协做紧密的总体,发版节奏基本一致。各业务模块代码都在同一个 git 工程中,基本每次发版用的都是各个模块的最新版本。并且实际作了几回调查,发现 ModuleService 中接口改变致使的依赖模块的修改,其实成本很低,改起来很快。此时咱们开始思考以前追求的编译隔离是否适合当前阶段,是否有实际价值。

最终咱们决定节省每一份精力,效率最大化。将各业务的 ModuleService进行下沉到 Commom 模块,各业务模块直接依赖 Common 中的这些 ModuleServie 头文件,再也不须要 copy 操做。这样改造的代价是造成了更多的依赖。原本一个业务模块是能够不依赖 Common 的,但如今就必须依赖了。但考虑到实际状况,尚未不依赖 Common 的业务模块存在,这种追求没有价值,因此应该问题不大。同时由于下沉的都是一些头文件,没有具体实现,未来若是须要模块间的进一步隔离,好比模块单独打包等,只须要将这些 Moduleservie 作到服务端可配置 + 自动化下载生成便可,改形成本很是小。

但这样改造后又发生了一件事。某个新来的同窗,直接在 Common 模块中写代码经过这些 ModuleService 调用了上层业务模块的功能,造成了底层 Commmon 模块对上层业务模块的反向依赖。因而咱们进一步拆分出了一个新模块 Mediator, 将 Bifrost SDK 和这些 ModuleSevice 放入其中。Common 模块和 Mediator 互不可见。

最终造成的 App 架构为:
<center>App_Arch</center>

:业界有些方案是把 ModuleServie 分开存放的,至关于把以上方案里的 Mediator 部分进行分拆,每一个业务模块都有一个。这种方式的优势是职责明确,你们不用同时对一个公共模块进行修改,同时能够作到依赖关系很清晰;劣势是模块的数量增长了一倍,维护成本增长不少。考虑到咱们目前的状况,Mediator 模块是很薄的一层,共同修改维护这个模块也能够接受,因此目前没有将其拆开。未来若是须要,再将其作分拆改造便可,改造工做量很小。

4.3.2 代码隔离的思考

除了不在不合适的阶段追求编译隔离,咱们还发现代码隔离并不适合咱们。

业务模块化的效果之一就是个业务模块能够单独打包,放入壳工程运行。很容易想到的一个改造就是把各个模块拆到不一样的 git 中。好处不少,好比单独的权限控制,独立的版本号,万一发版时发现问题能够及时 rollback 用老版本打包。咱们的微商城 App 就作了这种尝试。将代码迁到了不少 git 中,经过 pod 的方式进行管理。但后续开发中体验并非很好。当时微商城 App 的模块数量比开发同窗数量多不少,每一个同窗都同时维护着多个模块。有时一个项目,一我的须要同时在多个 git 中修改多个模块的代码。修改完成后,要屡次执行提交、打版本号以及集成测试等操做,很不效率。同时由于涉及到多个 git,代码提交的 Merge Request 和相关的编译检查也复杂了不少。一样的,由于微商城 App 中不一样模块的开发发版节奏也基本一致,因此多 git 多 pod 的不一样版本管理及回退的优点也没有体现出来。最终仍是将各模块代码迁回了主 git 中。

4.3.3 没价值的隔离?

但编译隔离和代码隔离真的没有价值吗?固然不是,主要是咱们当前阶段并不须要。过早的调整增长了成本却没有价值产出,因此并不合适。实际上咱们还有一些业务模块是跨 App 使用的,好比IM模块,资产模块等等。他们都是独立 git 独立发版的。编译隔离和代码隔离属性对他们颇有效。

另外,每一个模块单独git能够有更细粒度的权限管理。咱们由于在一个git中,曾发生过好几回小伙伴改别人的模块改出问题的例子(虽然有MR, 但人不免有遗漏)。后来咱们是经过 git commit hook + 修改文件路径来控制修改权限才解决了这个问题。后续介绍有赞移动基础设施建设的文章中会有更多相关细节。

4.3.4 Bifrost (雷神里的彩虹桥)

最终,咱们总结了全部咱们须要的业务模块化需求,沉淀出了轻量级的模块化 SDK Bifrost.

为何不直接使用业界的 CTMediator 或者 Beehive 或者 MGJRouter, 要再造个轮子呢?主要有三个缘由:一是咱们开始尝试模块化改造时,业界尚未相关框架开源出来,因此须要本身实现。二是咱们的需求和业界的开源库不彻底相符。MGJRouter 缺乏服务管理,CTMediator 和设计不符,Beehive 没有路由管理同时不够轻量(不少接口仍是基于阿里的需求提供的,咱们用不到,会造成理解成本)。缘由三实际上是最关键的,就是模块化 SDK 的实现其实不难。经过前面的介绍,能够发现其中并无什么黑魔法,代码量也很少,实现成本很低。模块化过程更多精力花在了全局架构设计,与之配合的开发规范,以及结合本身团队状况的一些取舍。模块化 SDK 只是模块化总体设计的冰山一角。咱们也推荐读者所在团队,若是有时间能够尝试本身实现模块化工具,Bifrost 只用作参考便可。

4.3.5 业务模块化时机

咱们建议全部进入业务领域划分稳按期(业务模块基本肯定,不会发生较大变更)的团队采用业务模块化架构设计。即便模块划分还没彻底明确,也能够考虑对部分明确了模块进行模块化改造。由于早晚要用,晚用不如早用。目前基于路由 URL + 协议注册的模块间通信方式,对开发效率基本无损。

5、总结

移动应用的业务模块化架构设计,其真正的目标是提高开发质量和效率。单从实现角度来看并无什么黑魔法或技术难点,更多的是结合团队实际开发协做方式和业务场景的具体考量——“适合本身的才是最好的”。有赞移动团队经过过往3年的实践,发现一味的追求性能,绝对的追求模块间编译隔离,过早的追求模块代码管理隔离等方式都偏离了模块化设计的真正目的,是得不偿失的。更合适的方式是在可控的改造代价下,必定程度考虑将来的优化方式,更多的考虑当前的实际场景,来设计适合本身的模块化方式。但愿经过本文提供的具体案例和思考方式,你们都能找到适合本身应用的业务模块化之路。

相关文章
相关标签/搜索