移动端iOS组件化

本文初次发表于InfoQ 移动端iOS组件化

组件化背景

随着移动互联网的迅猛发展,手机APP已经成为了与我们生活紧密关联的一部分,各种应用场景也都已经落地到了手机移动端,但这也使得APP的业务模块以及对应的代码量越来越繁多,旧的开发架构已经没法满足业务快速发展的需求,重构整合也就成为了不可避免的问题。

组件化开发则能够解决这一问题,而且经过业界近年来的探索与实践,慢慢地这已经成为了移动端开发架构的主流方式,并且市面上也已经开源了不少组件化实施方案。但现成的并不一定就是最好的,只有经过实践才能知道什么最合适,通过此文我将来谈谈自己对于组件化的理解以及在组件化实践中的一些经验。

组件化拆分

首先应该明白组件化开发,是要对哪些模块进行组件化,以及模块之间的拆分是怎样的。在传统的APP架构中,更多的是关注于按功能层级来拆分:比如最底层的系统Framework,中间的基础功能库、网络库、图像处理库、UI库,最顶部则是各个业务模块。

传统架构

这里其实已经可以看到一些组件化的影子,那便是中间层级的功能组件,Github上已经提供了很多有关各类功能组件的三方库。但是传统架构中忽略了顶层业务模块之间的组件解耦,比如上图中的个人中心、订单、消息三个模块彼此之间都有通信关联,那么在实际开发中则很可能是彼此都引用了对方模块中的类(下图一),这将导致模块之间耦合严重,大大降低了代码质量,也不利于后续的功能扩展。

而真正的组件化应该是对业务模块也进行组件拆分,而且拆分之后的不同业务模块之间不应该存在直接的通信关联,消息通信将由中间的路由( CJLRouter )来完成,业务组件单向依赖路由组件,并且所有业务组件都是可以独立迭代开发的(下图二)。最终构建APP时只需要指定各个组件的版本号,组合编译打包之后便能快速生成ipa包;当某个模块发生问题时,也只需要还原或升级对应模块的组件,就可以快速修复问题。

业务模块拆分

组件化架构

前面讲到,为了解决业务模块之间的耦合关系引入了路由组件,那么新的APP架构将变成如下图所示。

组件化架构

APP总体还是由三层架构构成,各个层级之间依据箭头指向存在上层依赖下层的单向依赖关系,顶层业务层级的各个模块彼此独立,横向之间并不发生关联。从下往上最底层是系统Framework框架;中间是各种功能类组件:比如网络AFNetworking、图片SDWebImage、下拉刷新MJRefresh、自定义封装的通用基础库等等,使用一般都是通过 CocoaPods 直接引用即可。

着重来看一下最顶层的业务层,可以发现这里跟传统架构的区别是业务层的最底部多了 CJLRouter 路由组件。将路由组件放到业务层可能会带来疑义,是不是意味着路由方案必须跟随业务走无法单独剥离出来成为通用组件?事实上并不如此,路由组件其实是通用解决方案,是可以下沉放到中间层作为通用功能库来用的,只不过具体到项目中它作为解耦业务模块的中间人,更多的是处理业务层的问题,所以架构图中把它归并到业务层了。

CJLRouter组件化方案

组件化前提

CJLRouter 组件化方案的前提是基于 CocoaPods 来实现的。CocoaPods能够便捷直观地管理使用第三方库,这相信大家都很熟悉。那同样我们也可以将最顶层的业务模块拆分成不同的pod库,通过CocoaPods来管理业务组件的开发,这里有几个要点:

  1. 不同业务组件模块对应不同的代码仓库,生成各自的 .podspec 索引,并实现组件之间的完全隔离。另外由于网络原因以及代码的安全性,不应该直接使用GitHub上的CocoaPods库索引,一般都是创建私有 Specs 索引库,同时业务组件的.podspec索引也将会被放到私有索引库中。这里解释一下每一个pod库都会有对应的.podspec索引,它记录了该组件库的内容以及依赖关系;而Specs索引库则是记录所有.podspec索引文件的仓库地址,使用的时候你只需要在 Podfile 文件中指明索引库地址便能够找到对应的三方库了(创建pod库以及pod索引库并不是本文的要点,在此不再做详细介绍,有兴趣的同学可以自行搜索 如何发布CocoaPods公开库以及新建私有Pod Specs索引库

  2. 所有组件都集成为Pod管理后,项目主工程将变得非常干净,同时在Pofile中声明指定哪些组件需要本地开发联调,开发的时候开发人员只需关注当前开发的组件模块即可。

    组件化结构

  3. 使用业务组件 模块初始化脚本 来创建业务组件Pod库,新建pod库默认已经依赖CJLRouter(这里建议所有业务组件的podspec中都不要对公共依赖的pod库指定版本号,改为统一在主工程的Pofile中来指定pod库版本,从而减少冲突),新创建组件模块的Router文件夹下将自动生成CJLRouter+ModuleName.h、ModuleNameModule.h、ModuleNameBundle.h等文件,如上图CJMain组件所示,这几个类便是用于实现路由通信的相关类。

    1
    2
    3
    4
    # CJLRouterModule.py 路由业务组件模板快速创建脚本
    # 初次运行请先执行 chmod 777 CJLRouterModule.py 指令,对脚本赋予可执行指令
    # 使用方式:
    $ ./CJLRouterModule.py [组件模块名称]

组件化实现

为了实现不同模块间的通信并且减少耦合关系,目前业界常见的组件化方案有:

  • 路由URL跳转。优点是统一适用多端平台(H5、iOS、Android),缺点是无法处理复杂的业务场景,以及需要维护大量的URL硬编码。
  • 运行时反射。借助OC运行时机制无需#import也能够调用指定类的方法,从而实现模块间解耦,缺点是同样存在硬编码字符串,并且调用错了只会在运行时才能暴露。
  • 注册服务协议Protocol 。使用服务协议的方式,能够显式地声明当前模块的对外接口,同时能够做到代码自动补全和编译时检查,缺点是需要引入额外的公用模块来管理所有模块的对外协议声明,而且某个Protocol 协议的改变可能会导致其他遵循了该协议的模块编译失败,降低了开发效率。
  • NSNotification广播通知。使用非常简单,但没法处理通信回调,而且在开发调试中难以定位问题。

CJLRouter 组件化方案在分析以上各种方案的优缺点以及结合项目实际的基础上,最终实现的路由方案包含了组件间通信、生命周期管理、资源管理三个方面的内容。

组件间通信

组件间通信基于Runtime机制,CJLRouter路由中心提供类似 -performSelector:withObject: 的统一调用方法,并在该方法内使用NSInvocation对目标方法进行最终调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@implementation CJLRouter
+ (id)routerPerformSELname:(NSString *)SELname params:(NSArray *)params {
// 使用NSInvocation进行方法转发
SEL aSelector = NSSelectorFromString(SELname);
NSInvocation *inv = [NSInvocation invocationWithMethodSignature:[self methodSignatureForSelector:aSelector]];
[inv setTarget:self];
[inv setSelector:aSelector];
[inv setArgument:&params atIndex:index];
[inv invoke];
void *result;
[invocation getReturnValue:&result];
return result;
}
@end

前面“组件化前提”中讲到新业务组件使用模块初始化脚本创建,新建后会自动生成 CJLRouter+ModuleName.h 分类,该分类用于声明当前组件的所有对外通信接口,并且接口方法的命名格式规定为:**+ 当前模块名_方法名** ,接口方法规定以模块名为前缀是为了解决CJLRouter分类可能会存在方法重名的问题。

1
2
3
4
5
6
7
@implementation CJLRouter (CJMain)
/// 获取主页面Controller
+ (UIViewController *)main_mainViewController {
/// 返回主页面
return [UIViewController new];
}
@end

当其他业务组件需要调用main组件的通信接口时,那么只需要在目标组件的CJLRouter+ModuleName.h分类中查找对应方法,然后执行 routerPerformSELname: 进行调用即可。

1
UIViewController *mainVC = [CJLRouter routerPerformSELname:@"main_mainViewController"];

这里之所以不直接使用系统[CJLRouter performSelector:@selector(main_mainViewController)]的方式调用,一是因为performSelector:没法满足复杂参数的调用;二是使用performSelector:如果目标方法改变那么将直接导致编译失败(某个组件的错误会影响其他组件的开发这明显违背了组件化开发的初衷)。而通过CJLRouter的统一中转方法 + routerPerformSELname: params: 来调用,则可以提前在中转方法内做异常捕获和错误提示。

另外为了满足多平台统一页面跳转的需求,CJLRouter同时提供了路由URL的调用方式:

1
UIViewController *mainVC = [CJLRouter routerPerformWithUri:@"CJLRouter://main/main_mainViewController"];

路由URL的定义为: AppScheme://模块名/调用方法名?方法参数 ,实际使用中为了多端统一,后台下发的跳转路由URL不一定是这里规定的格式,那么你只需额外维护一份路由URL与对应模块方法的映射表即可。

生命周期管理

CJLRouter组件化方案中,所有的模块都使用CocoaPods来管理,而且所有的业务组件都是独立开发的,那么也就意味着不应该再在一个统一的入口中来维护所有业务模块的初始化设置、以及各业务模块监听APP的生命周期事件。当然你也许会说可以在主工程的AppDelegate中进行这些操作,但所有业务组件的这些处理都放置其中那将会使得AppDelegate非常臃肿并且丑陋,而且严格上说组件化方案中APP主工程也可以是一个组件,那么它就不应该与其他业务模块有太多的耦合关系。

为此新建 CJLModuleManager 模块管理中心,专门用于处理APP生命周期管理、组件初始化以及广播消息事件响应。这里用到了前面讲到的注册服务协议Protocol的思想,所有新建组件都遵循CJLModuleProtocol协议,可在协议方法中完成初始化配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/// 组件消息广播协议
@protocol CJLModuleProtocol <UIApplicationDelegate, NSObject>
@required
/// 组件模块必须实现单例方法
+ (instancetype)sharedInstance;
/// 组件模块必须实现初始化
- (void)setup;
@end

@interface CJLModuleManager : NSObject
/// 业务组件需要提前向路由进行注册
/// @param moduleClass 业务组件Module类
+ (void)registerModuleClass:(Class<CJLModuleProtocol>)moduleClass;

/// 初始化所有已经向路由注册的业务组件Module
+ (void)setupAllModules;

/// 对所有已经向路由注册的业务组件Module进行方法转发
/// @param selector 转发方法名
/// @param arguments 方法参数
+ (void)checkAllModulesWithSelector:(SEL)selector arguments:(NSArray*)arguments;

通过模块初始化脚本创建的组件自动生成 ModuleNameModule.h 类,用于处理组件模块注册、初始化,APP生命周期事件,响应其他组件广播消息事件的实现等等。

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
26
27
28
29
30
31
@interface CJMainModule : NSObject<CJLModuleProtocol>
@end
@implementation CJMainModule
+ (void)load {
[CJLModuleManager registerModuleClass:self.class];
}
#pragma mark - CJLModuleProtocol @required
+ (instancetype)sharedInstance {
static CJMainModule *share = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
share = [[CJMainModule alloc] init];
});
return share;
}
/// 组件的初始化配置方法
- (void)setup {
//在此处执行组件初始化时需进行的基础配置
//注:该方法是在主线程调用的,如果是进行网络请求及其他比较耗时的操作需要另外注意,可以将其放到异步线程
}
#pragma mark - application life cycle
/// 处理APP生命周期事件
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(nullable NSDictionary *)launchOptions {
return YES;
}
//...
#pragma mark - 其他组件转发的消息事件
//监听登录组件转发的登录成功事件
- (void)login_loginSuccess {
}
@end

ModuleNameModule.m 的+load方法中完成组件模块的注册,同时每个组件的module都会生成一个单例+sharedInstance,以及对应的初始化方法-setup。CJLModuleManager 模块管理中心在APP启动的时候查找所有已经注册的组件模块并初始化,同时向已经注册监听的组件转发消息事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 在AppDelegate.m中进行生命周期事件的转发
- (BOOL)application:(UIApplication *)application willFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
//设置当前app路由AppScheme
[CJLRouter setupAppScheme:@"CJLRouter://"];
//完成所有已经注册组件module的初始化
[CJLModuleManager setupAllModules];
//向业务组件转发当前方法
[CJLModuleManager checkAllModulesWithSelector:_cmd arguments:@[CJLSafe(application),CJLSafe(launchOptions)]];

return YES;
}

// login组件转发登录成功事件
- (void)loginSuccess {
//向所有业务组件转发用户登录成功的消息
[CJLModuleManager checkAllModulesWithSelector:NSSelectorFromString(@"login_loginSuccess") arguments:@[]];
}
资源管理

使用CocoaPods管理,每个业务模块最后会生成静态库+资源bundle,这里传统的资源读取方式将不再适用,需要改为从指定bundle中读取。CJLBundle 就是用于处理资源管理的专用类,而通过模块初始化脚本自动生成的 ModuleNameBundle.h 类继承自CJLBundle,并在其中返回当前业务组件的bundle。

1
2
3
4
5
6
7
8
9
10
11
12
13
@interface CJMainBundle : CJLBundle <CJLBundleProtocol>
@end
@implementation CJMainBundle
#pragma mark - CJLBundleProtocol
/// 需要返回当前业务组件的bundle(默认bundle名与业务组件名称相同)
+ (NSBundle *)cjl_bundle {
return [self cjl_bundleWithName:@"CJMain"];
}
@end

//main组件读取资源
UIImage *image = [CJMainBundle cjl_imageNamed:@"icon_name"];
UINib *nib = [CJMainBundle cjl_nibWithName:@"nib_name"];

写在最后

到此也就已经分析梳理完了CJLRouter组件化方案的实现,主要关键点是借助CocoaPods来管理所有业务组件,业务模块组件使用模块初始化脚本创建,新建的pod库默认依赖CJLRouter,并自动生成CJLRouter+ModuleName.h用于声明当前组件对外通信接口,ModuleNameModule.h用于处理当前组件的APP生命周期管理以及监听其他组件广播事件的响应,ModuleNameBundle.h用于管理组件资源的读取。

最后附上 CJLRouter 的源码地址,欢迎点击关注,如果你有更好的想法,请留言交流。