跃迁引擎

空気を読んだ雨降らないでよ

iOS Research & Development


洋葱学园 iOS 端组件化重构之路[一]-现状梳理

背景

当前,洋葱学园 iOS 端工程的组件化水平过低,在影响工程师开发效率的同时,又难以兜住持续集成的影响范围,不利于整体工程的高质量建设,已无法满足日益增长的工程预期与精细化控制的需求。

现存问题如下

  • 缺乏组件必要的独立运作能力
  • 缺乏统一中间件进行调度
  • 无法进行单元测试,回归测试成本高
  • 组件间依赖链紊乱,维护成本高
  • 组件化颗粒度过粗,服务下沉不达标,多端引用成本高

目标收益

我们期望组件化重构完成后,达成以下目标

  • 减少开发构建时间和测试回归成本,工程各业务组件
    • 可独立运行
    • 可独立迭代
    • 可独立测试
  • 减小维护成本,构建统一中间件,由中间件管理组件之间清晰的依赖关系
  • 完成基础服务下沉,各业务组件可横向依赖,重构完善现有组件库其中包括
    • 中台
    • 中学业务线
    • 教师/入校业务线

工程现状

CocoaPods 私有库依赖链

下列所有数据均已脱敏

中台

中学业务线

教师/入校业务线

行业现状

业内主流组件化方案

路由

基于路由URL的UI页面统跳管理

一般用法

1
2
3
// kRouteGoodsDetail = @"/goods/goods_detail"
UIViewController * vc = [Router handleURL:kRouteGoodsDetail];
if (vc) [self.navigationController pushViewController:vc animated:YES];

传参的情况

1
2
3
4
5
6
//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];
}

复杂的参数类型

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

从接口上可以看出来,通过@"/goods/goods_detail"字符串,我们可以拿到需要交互的模块,来进行操作。在需要路由服务的页面,通过bindURL的形式,完成字符串路由的绑定。

其基本流程是:

  • App启动时实例化各组件模块,然后这些组件向ModuleManager注册Url,有些时候不需要实例化,使用class注册。
  • 当组件A需要调用组件B时,向ModuleManager传递URL,参数跟随URL以GET方式传递,类似openURL。然后由ModuleManager负责调度组件B,最后完成任务。

该方案的缺陷如下:

  • URL注册对于实施组件化方案是完全不必要的,且通过URL注册的方式形成的组件化方案,拓展性和可维护性都会被打折。因为在组件化的过程中,注册URL并不是充分必要条件,组件是不需要向组件管理器注册Url的。而且注册了 Url之后,会造成不必要的内存常驻 ,如果只是注册Class,内存常驻量就小一点,如果是注册实例,内存常驻量就大了。而且对路由表的维护也是个不必要成本,还会增加工程师维护时的负担。
  • 路由的绑定放到load方法中,会对项目的冷启动有一定的影响
  • 对业务代码存在侵入性

协议

基于面向协议 (POP)思想的服务注册方案

每个模块提供自己的服务协议,然后将此协议声明注册到中间层。调用方能从中间层看到有哪些服务的接口,然后直接调用即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 在中间件中完成协议的声明,方便所有模块调用和查阅@protocol GoodsModuleService
- (NSArray*)getGoodsList;
- (NSInteger)getGoodsCount;
...
@end// 在模块的load方法中,注册协议。并且让该模块实现协议中的方法@interface GoodsModule : NSObject<GoodsModuleService>@end@implementation GoodsModule
+ (void)load {
[ServiceManager registerService:@protocol(GoodsModuleService) withModule:self.class]
}
//提供具体实现
- (NSArray*)getGoodsList {...}
- (NSInteger)getGoodsCount {...}

// 在其他模块中调用id<GoodsModuleService> goodsModule = [ServiceManager objByService:@protocol(GoodsModuleService)];NSArray *list = [goodsModule getGoodsList];

面向协议编程,看起来是比较酷的,而且也不需要写反射代码。

该方案缺陷如下:

  • 把协议的内容放到公共的地方,一旦发生改变,意味着用到协议的地方都要改一遍
  • load方法中进行协议绑定,还是会有老毛病存在
  • 对业务代码存在侵入性

Runtime

基于 Objective-C Runtime 反射的接口调用封装

大家知道 OC 是支持反射的,Swift 拥有独立的 Runtime 所以也是可以的,比如:

1
2
3
Class className = NSClassFromString(@"Person");
SEL sel = NSSelectorFromString(@"getPersonName:");
...

然后可以通过- (id)performSelector:(SEL)aSelector来完成消息的发送。

但是这种方式会存在大量的硬编码,也无法触发编译器的自动补全,同时,只有在运行时才可以发现一些未知的错误。除此之外,无法实现多参数传值和方法返回值的获取的问题。所以这里选择NSInvocation更合适。

这里的一个思路是业界比较知名的 CTMediator,它是基于Mediator模式和Target-Action模式来完成的 。

先说调用方法:(住:本次只讨论本地模块之间的调用,不考虑远程调用)

  1. 本地组件A在某处调用[[CTMediator sharedInstance] performTarget:targetName action:actionName params:@{...}]CTMediator发起跨组件调用
  2. CTMediator根据获得的target和action信息,通过 oc/swiftruntime转化生成target实例以及对应的action选择项
  3. 然后最终调用到目标业务提供的逻辑,完成需求。

组件仅需要通过Action暴露可调用的接口即可。所有组件都通过组件自带的Target-Action来响应,也就是说,模块与模块之间的接口被固化在了Target-Action这一层,避免了实施组件化的改造过程中,对**Business**的侵入 ,同时也提高了组件化接口的可维护性。

最后,调用者通过响应者给 CTMediator 做的 category 或者 extension 来发起调用,来避免使用字符串调用出现的不友好。

代码大家可以看下,可以说非常简洁了。考虑的也非常全面。

简化下代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Mediator提供基于NSInvocation的接口调用方法的统一入口
- (id)performTarget:(NSString *)targetName action:(NSString *)actionName params:(NSDictionary *)params;

// 业务模块对外提供的方法封装到Category中@interface CTMediator (Goods)
- (NSArray *)goods_getGoodsList;
- (void)goods_gotoGoodsDetail:(NSString *)id;
...
@end

@impletation CTMediator(Goods)

- (NSArray *)goods_getGoodsList{
return [self performTarget:@“Target_Goods” action:@"getGoodsList" params:nil];
}
- (void *)goods_gotoGoodsDetail:(NSString *)id{
return [self performTarget:@“Target_Goods” action:@"gotoGoodsDetail" params:{@"id":id}];
}

@interface Target_Goods : NSObject

- (NSArray *)getGoodsList;

- (void)gotoGoodsDetail:(NSString *)id;

@end

广播通知

基于通知的广播方案

基于通知的模块间通讯的方案,实现起来是最简单的,直接基于系统提供的NSNotificationCenter即可。适合一对多的通讯场景。但是劣势也特别明显。复杂的数据传输,同步调用等方式都不太方便。通常用来作为以上几种方案的补充。

我们怎么做

  1. 梳理职能范围,确定基础库和业务库
  2. 进行改造抽离,确保职责单一高内聚,保持单向链路依赖
    1. 重构基本原则:
      1. 基础服务全部下沉至中台(如网络、AMP、日志)
      2. 通用服务交由中台维护向下依赖基础服务(如支付、视频播放器、分享、跳转路由等),跨端复用组件必须下沉至中台通用服务层来维护
      3. 抽离真正可复用的基础业务组件独立成库(如可复用的模板UI、客服等),其余独立业务组件糅合进各业务模块(如各端独立的登录注册体系),所有业务组件统一上提至各业务线维护,不再相互糅合
  3. 基础服务下沉,横向业务依赖
  4. 配套工具开发,统一库的迭代流程和方式(H5 发版工具等)
  5. Rust 编写双端统一的协议中间件,利用 ffi 抛出给 Kotlin/Java 和 Swift/Objective-C 使用,统一移动端端中间件

基础架构

Action

  • 中间件的方案,产出完整调度能力的中间件Demo,包含下沉依赖关系演示
  • 列出现有功能的基于中间件改造成本
  • 如何梳理职能范围,拆分力度如何确认(依赖链是否保持单一)
  • 与 Android 对齐方向和内容,架构思路,双端都需要保证基于组件的独立运行单元测试、运行、迭代
  • Jenkins 基于独立工程改造(能设置指定工程路径)以分支切换的形式
最近的文章

洋葱学园 iOS 端组件化重构之路[二]-实施方案

背景基于洋葱学园 iOS 端组件化重构之路[一]-现状梳理 得出的结论与方案,需要验证方案的可行性及实施成本,设计完整架构图和演示工程,包括后续持续集成的改造思路等。 实施目标 中间件的方案,产出完整调度能力的中间件Demo,包含下沉依赖关系演示 列出现有功能的基于中间件改造成本 如何梳理职能范 …

, , 开始阅读
更早的文章

iOS & macCatalyst 混编应用实践

背景需求开发一款通用的 macAPP 来辅助 DSL 项目的快速调试,要求拥有与移动端完全相同的渲染效果,所以需要从 iOS 端移植 DSL 渲染引擎的代码到 macOS 系统上运行 项目搭建搭建的总体流程与创建一个 iOS 别无二致,需要选择 macCatalyst 模式运行(M1设备选择Ro …

, , 开始阅读
comments powered by Disqus