乐趣区

关于ios:iOS-组件化模块化架构设计实践

一、背景

业务组件化(或者叫模块化)作为挪动端利用架构的支流形式之一,近年来始终是业界积极探索和实际的方向。有赞挪动团队自 16 年起也在一直尝试各种组件化计划,在有赞微信商城,有赞批发,有赞美业等多个利用中进行了实际。咱们踩过一些坑,也播种了很多贵重的教训,并积淀出 iOS 相干框架 Bifrost (雷神里的彩虹桥)。在过程中咱们粗浅领会到“没有相对正确的架构,只有最合适的架构”这句话的意义。

iOS 组件化 / 模块化的计划有很多,咱们只提供一种实现思路,对遇到相似问题的同学能有所启发,并不筹备对组件化架构设计计划给出一份标准答案。区别于功能模块 / 组件(比方图片库,网络库),本文探讨的是业务模块 / 组件(比方订单模块,商品模块)相干的架构设计。

二、业务模块化 / 组件化

传统的 App 架构设计更多强调的是分层,基于设计模式六大准则之一的繁多职责准则,将零碎划分为根底层,网络层,UI 层等等,以便于保护和扩大。但随着业务的倒退,零碎变得越来越简单,只做分层就不够了。App 内各子系统之间耦合重大, 边界越来越含糊,常常产生你中有我我中有你的状况(图一)。这对代码品质,性能扩大,以及开发效率都会造成很大的影响。此时,个别会将各个子系统划分为绝对独立的模块,通过中介者模式收敛交互代码,把模块间交互局部进行集中封装, 所有模块间调用均通过中介者来做(图二)。这时架构逻辑会清晰很多,但因为中介者依然须要反向依赖业务模块,这并没有从根本上解除循坏依赖等问题。时不时产生一个模块进行改变,多个模块受影响编译不过的状况。进一步的,通过技术手段,打消中介者对业务模块依赖,即造成了业务模块化架构设计(图三)。


总的来说,通过业务模块化架构,个别能够达到明确模块职责及边界,晋升代码品质,缩小简单依赖,优化编译速度,晋升开发效率等成果。

三、常见模块化计划

业务模块化设计通过对各业务模块的解耦革新,防止循环双向依赖,达到晋升开发效率和品质的目标。但业务需要的依赖是无奈打消的,所以模块化计划首先要解决的是如何在无代码依赖的状况下实现跨模块通信的问题。

iOS 因为其弱小的运行时个性,无论是基于 NSInvocation 还是基于 peformSelector 办法, 都能够很很容易做到这一点。但不能为理解耦而解耦,晋升品质与效率才是咱们的目标。间接基于 hardcode 字符串 + 反射的代码显著会极大侵害开发品质与效率,与指标南辕北辙。所以,模块化解耦需要的更精确的形容应该是“如何在保障开发品质和效率的前提下做到无代码依赖的跨模块通信”。

目前业界常见的模块间通信计划大抵如下几种:

  • 基于路由 URL 的 UI 页面统跳治理。
  • 基于反射的近程接口调用封装。
  • 基于面向协定思维的服务注册计划。
  • 基于告诉的播送计划。

3.1 路由 URL 统跳计划

统跳路由是页面解耦的最常见形式,大量利用于前端页面。通过把一个 URL 与一个页面绑定,须要时通过 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 自身是一种跨多端的通用协定。应用路由 URL 统跳计划的劣势是动态性及多端对立 (H5, iOS,Android,Weex/RN); 毛病是能解决的交互场景偏简略。所以个别更实用于简略 UI 页面跳转。一些简单操作和数据传输,尽管也能够通过此形式实现,但都不是很效率。

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

在平时的开发中,当无奈间接 import 某个类的头文件但仍需调用其办法时,最罕用的办法就是反射了。反射是面向对象语言的基本特征,Java 和 oC 都有这一特色。

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 协定与具体实现绑定起来。

3.4 告诉播送计划

基于告诉的模块间通信计划,实现思路非常简单, 间接基于零碎的 NSNotificationCenter 即可。
劣势是实现简略,非常适合解决一对多的通信场景。
劣势是仅实用于简略通信场景。简单数据传输,同步调用等形式都不太不便。
模块化通信计划中,更多的是把告诉计划作为以上几种计划的补充。

3.5 其它

除了模块间通信的实现,业务模块化架构还须要思考每个模块外部的设计,比方其生命周期管制,简单对象传输,反复资源的解决等。可能因为每个公司都有本人的理论场景,业界计划里对这些问题形容的并不是很多。但实际上他们十分重要,有赞在模块化过程中做了很多相干思考和尝试,会在前面环节进行介绍。

四、模块化实际

4.1 摸索与尝试

16 年,有赞微信商城、有赞收银等 App 经验了初期的性能疾速迭代,外部依赖凌乱,耦合重大,急需优化重构。传统的 MVVM、MVP 等优化形式无奈从全局层面解决这些问题。起初在 InfoQ 的 ” 挪动开发火线 ” 微信群里听了蘑菇街的组件化计划分享,十分受启发。不过过后还是有一些顾虑,比方微信商城和收银过后都属于中小型我的项目,每端开发人员都只有 4-6 人。业务模块化革新后会造成肯定的开发门槛,带来肯定的开发效率降落。小我的项目适宜模块化革新吗?其收益是否能匹配付出呢?但思考到过后 App 各模块边界曾经稳固,即便模块化革新呈现问题,也能够用很小的代价将其降级到传统的中介者模式,所以革新开始了。

4.1.1 模块间通信形式设计

首先,是梳理咱们的模块间通信需要,次要包含以下三种场景:

  • UI 页面跳转:比方 IM 模块点击用户头像关上会员模块的用户详情页。
  • 动作执行及简单数据传输:比方商品模块向开单模块传递商品数据模型并进行价格计算。
  • 一对多的告诉播送:比方 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 模式定义了一个高层接口,这个接口使得这一子系统更加容易应用。引入外观角色之后,用户只须要间接与外观角色交互,用户与子系统之间的简单关系由外观角色来实现,从而升高了零碎的耦合度。

另外,为什么还须要路由 URL 呢?
其实从性能角度,近程接口的网络层,齐全能够取代路由 URL 实现页面跳转,而且没有路由 URL 的一些 hardcode 的问题。而且路由 URL 和
近程接口存在肯定的性能重合,还会造成后续实现新性能时,分不清应抉择路由 URL 还是抉择近程接口的困惑。这里抉择反对路由 URL 的次要起因是咱们存在动态化且多端对立的需要。比方音讯模块下发的各种音讯数据模型齐全是动静的。后端配好展现内容以及跳转需要后,客户端不须要了解具体需要,只须要通过对立的路由跳转协定执行跳转动作即可。

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

个模块除了 Facade 模式革新之外,还须要思考以下问题:

  • 适合的注册及初始化形式。
  • 接管并解决全局事件。
  • App 层和 Common 层设计。
  • 模块编译产出以及集成到 App 中的形式。

因为思考到每个 App 中业务模块数量不会很多,所以咱们为每个模块创立了一个 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 的品质。通过探讨后,决定把资源分为三类:

  • 通用性能所用资源,将相干代码整顿为性能组件后一起放入 Common.
  • 业务性能的大部分资源能够通过无损压缩管制体积,体积不大的资源容许肯定水平上的反复。
  • 较大体积的资源放到服务端,App 端动静拉取放在本地缓存中。

同时,工程打包前通过自动化工具检测无用资源,以及反复资源的大小,以便及时优化包体积。

4.1.5 成绩展现

基于以上设计,咱们大略花了 3 的个月的工夫对已有我的项目进行了业务模块化革新(边做业务边革新)。因为计划细节思考的比拟多,大家对一些可能存在的问题也都有预期,所以过后革新后大家多持必定态度,不过最终的后果还是可观的。

1.0 版本革新后,App 整体架构关系如下图。


而整体工程构造如下图所示。

4.2 优化

下面介绍的模块化设计方案尽管可行,但还存在两个重大的问题:

  • 模块间网络层的封装基于反射代码, 写起来依然有些麻烦。而且须要额定写单测保证质量。
  • 简单对象的解决形式带来了额定的问题,比方拷贝粘贴的形式比拟俊俏,反复代码会带来包体积的减少。

针对下面的问题,咱们着手从如下几个方面进行优化。

4.2.1 近程接口封装优化

首先,是如何防止反射及 hardcode. 阿里 Beehive 的基于服务注册的形式 是不须要 hardcode 代码的。但它有额定的服务注册过程,可能会影响启动速度,性能弱于基于反射的接口封装计划。

应用服务注册对启动速度的影响到底有多少呢?咱们做了个测试,在 +load 办法中注册了 1000 个 Sevice Protocol,启动工夫影响大略是 2-4 ms,所以影响是非常少的。


因为咱们每个模块都是基于外观模式设计的。所以每个模块只须要对外裸露一个 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;

通过以上优化革新,根本解决了之前模块化的所有品质及效率方面的隐患,业务模块化计划趋近成熟。

积淀与欠缺

在解决了简单对象的传递等问题后,模块化的计划根本走向了成熟。不过,架构对于一些新人还是不太敌对,于是咱们持续思考。

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 架构为:

业界有些计划是把 ModuleServie 离开寄存的,相当于把以上计划里的 Mediator 局部进行分拆,每个业务模块都有一个。这种形式的长处是职责明确,大家不必同时对一个公共模块进行批改,同时能够做到依赖关系很清晰;劣势是模块的数量减少了一倍,保护成本增加很多。思考到咱们目前的状况,Mediator 模块是很薄的一层,独特批改保护这个模块也能够承受,所以目前没有将其拆开。未来如果须要,再将其做分拆革新即可,革新工作量很小。

4.3.2 代码隔离的思考

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

业务模块化的成果之一就是个业务模块能够独自打包,放入壳工程运行。很容易想到的一个革新就是把各个模块拆到不同的 git 中。益处很多,比方独自的权限管制,独立的版本号,万一发版时发现问题能够及时 rollback 用老版本打包。咱们的微信商城 App 就做了这种尝试。将代码迁到了很多 git 中,通过 pod 的形式进行治理。但后续开发中体验并不是很好。过后微信商城 App 的模块数量比开发同学数量多很多,每个同学都同时保护着多个模块。有时一个我的项目,一个人须要同时在多个 git 中批改多个模块的代码。批改实现后,要屡次执行提交、打版本号以及集成测试等操作,效率很低。同时因为波及到多个 git,代码提交的 Merge Request 和相干的编译查看也简单了很多。同样的,因为微信商城 App 中不同模块的开发发版节奏也基本一致,所以多 git 多 pod 的不同版本治理及回退的劣势也没有体现进去。最终还是将各模块代码迁回了主 git 中。

但编译隔离和代码隔离真的没有价值吗?当然不是,次要是咱们以后阶段并不需要。过早的调整减少了老本却没有价值产出,所以并不适合。实际上咱们还有一些业务模块是跨 App 应用的,比方 IM 模块,资产模块等等。他们都是独立 git 独立发版的。编译隔离和代码隔离属性对他们很无效。

另外,每个模块独自 git 能够有更细粒度的权限治理。咱们因为在一个 git 中,曾产生过好几次小伙伴改他人的模块改出问题的例子(尽管有 MR, 但人不免有脱漏)。起初咱们是通过 git commit hook + 批改文件门路来管制批改权限才解决了这个问题。后续介绍有赞挪动基础设施建设的文章中会有更多相干细节。

4.3.3 业务模块化的一些倡议

咱们倡议所有进入业务畛域划分稳定期(业务模块根本确定,不会产生较大变动)的团队采纳业务模块化架构设计。即便模块划分还没齐全明确,也能够思考对局部明确了模块进行模块化革新。因为迟早要用,晚用不如早用。目前基于路由 URL + 协定注册的模块间通信形式,对开发效率根本无损。

五、总结

挪动利用的业务模块化架构设计,其真正的指标是晋升开发品质和效率。单从实现角度来看并没有什么黑魔法或技术难点,更多的是联合团队理论开发合作形式和业务场景的具体考量——“适宜本人的才是最好的”。有赞挪动技术团队通过 4 年的技术实际,发现一味的谋求性能,相对的谋求模块间编译隔离,过早的谋求模块代码治理隔离等形式都偏离了模块化设计的真正目标,是得失相当的。

更适合的形式是在可控的革新代价下,肯定水平思考将来的优化形式,更多的思考以后的理论场景,来设计适宜本人的模块化形式。

退出移动版