组件生命周期管理和通信方案

2,332 阅读11分钟

随着移动互联网的快速发展,项目的迭代速度越来越快,需求改变越来越频繁,传统开发方式的工程所面临的一些,如代码耦合严重、维护效率低、开发不够敏捷等问题就凸现了出来。于是越来越多的公司开始推行"组件化",通过对原有业务或新业务进行组件(或模块)拆分来提高并行开发效率。

在笔者面试过程中发现,很多同学口中的"组件化"也只是把代码分库,然后在主项目中使用 CocoaPods 把各个子库聚合起来。对于怎样合理地对组件分层、如何管理组件(主要包括组件的生命周期管理和组件的通信管理),如何管理不同版本的依赖,以及是否有整套集成和发布工具,这类问题的知之甚少。如果完全不了解这些问题,那么只是简单的对主项目进行组件拆分,并不能提高多少开发效率。

笔者认为合理地进行组件拆分和管理各个组件之间的通信是组件化过程中最大的难点。合理地进行组件拆分是为了解耦,并且各个组件能更容易地独立变化。而对于一个完整的应用来说,每个组件不可能孤零零地存在,必定会互相调用。这样不同组件之间必须能进行通信而又没有编译期的依赖

组件生命周期管理

可能很多同学在实施组件化的过程中知道要解决组件通信的问题,却很少关注组件的生命周期。这里的生命周期主要是指 AppDelegate 中的生命周期方法。有时候一些组件需要在这些钩子方法中做一些事情,这时候就需要一个能够管理组件的工具,并在适当的时机执行组件相应的逻辑。

比如笔者在项目中是这样做的:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    [[Ant shareInstance] application:application didFinishLaunchingWithOptions:launchOptions];
    return YES;
}

- (void)applicationWillResignActive:(UIApplication *)application
{
    [[Ant shareInstance] applicationWillResignActive:application];
}

- (void)applicationDidEnterBackground:(UIApplication *)application
{
    [[Ant shareInstance] applicationDidEnterBackground:application];
}

- (void)applicationWillEnterForeground:(UIApplication *)application
{
    [[Ant shareInstance] applicationWillEnterForeground:application];
}

所有注册的组件(模块)会在 AppDelegate 相应的生命周期方法调用时自动调用。例如有如下组件定义:

ANT_MODULE_EXPORT(Module1App)

@interface Module1App() <ATModuleProtocol> {
    NSInteger state;
}
@end

@implementation Module1App
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    state = 0;
    NSLog(@"Module A state: %zd", state);
    return YES;
}

- (void)applicationWillEnterForeground:(UIApplication *)application {
    state += 1;
    NSLog(@"Module A state: %zd", state);
}
@end

上面示例代码中第一行的 ANT_MODULE_EXPORT(Module1App) 是导出组件。Ant 会在 dyld 加载完 image 后将导出的组件进行注册。当应用生命周期方法被调用时,会实例化所有注册过的组件,调用组件相应的方法,并进行缓存,之后再次调用就会从缓存中取出组件的实例对象。

一般拥有完整生命周期的组件一般称为一个模块,一个模块其实也是一个独立的组件,它一般是包含一个完整的业务,列如:登录模块,外卖模块,消息模块等。

组件的生命周期管理并不复杂,实现方案都没有太大区别,但它也是组件化中必不可少的部分。

组件通信

业界关于组件通信的方案比较多,主要有:url-block, target-action, protocol-class。下面笔者会对这三种方案做个简单的介绍。

URL-Block

这是蘑菇街在组件化过程中使用的一种组件间通信方式,在应用启动时注册组件提供的服务,把调用组件使用的url和组件提供的服务block对应起来,保存到内存中。在使用组件的服务时,通过url找到对应的block,然后获取服务。

[MGJRouter registerURLPattern:@"mgj://foo/bar" toHandler:^(NSDictionary *routerParameters) {
    NSLog(@"routerParameterUserInfo:%@", routerParameters[MGJRouterParameterUserInfo]);
}];

[MGJRouter openURL:@"mgj://foo/bar"];

笔者是在15年开始学习组件化,那个时候就是使用的蘑菇街的这种发案。不过笔者从来没有在实际项目中使用这种方案。casa 在这篇文章中批判了这种方案。笔者对 case 的观点很是赞同。

如果项目中需要很多组件的服务,那么就需要在内存中维护大量的 url-block项,造成内存问题,对于服务注册的代码应该放在什么地方也是一个问题。笔者一直认为 url-block 注册是一种很粗暴的方式,比如某个应用在启动时注册了100个服务,但某些服务在用户使用过程中根本就没有触发,这就造成了内存浪费。比如我们点击应用中的按钮跳转到某个页面,如果用户没有点击按钮,下个页面就永远不会创建,我们一般不会提前创建这个页面的。笔者更倾向于在需要服务的时候才进行服务对象的创建,在特定场景下也提供服务对象的缓存。

使用 url 传参也是一个不可忽略的问题,对于一些基础数据类型,使用这种方案倒是没有问题,但是对于一些非常规对象就无能为力了,如 UIImage, NSData 等类型。

还有一个问题是 casa 在文章中没有指出的,这个问题在他的 target-action 方案中也存在。下面用一个例子来说明一下。

比如在一个组件 A 中提供了一个服务:

[MGJRouter registerURLPattern:@"mgj://foo/bar" toHandler:^(NSDictionary *routerParameters) {
    NSLog(@"routerParameterUserInfo:%@", routerParameters[MGJRouterParameterUserInfo]);
}];

然后在一个组件 B 中使用了服务:

[MGJRouter openURL:@"mgj://foo/bar"];

从上面示例代码中可以看到,两个不同组件能通信其实是通过一个字符串来定义的。如果服务使用方在写代码时写错了一个字符,那么使用方根本就不可能调起正确的服务,一旦出现这个问题,在开发过程中很难被发现。如果我们对组件多,注册的服务多,那么在使用时就存在很大的沟通问题,提供方和接入方可能会在每个字符串所代表的意义上浪费大量的时间。而这些问题都可以在工程设计上避免的。虽说我们在写代码时要低耦合,但并不代表不要耦合,有时候需要一些耦合来提高代码的健壮性和可维护性。

在 Swift 中可以使用枚举来解决上面的问题,我们可以像下面这样做:

protocol URLPatternCompatible {
    var URLPattern: String { get }
}

enum SomeService {
    case orderDetail
    case others
}

enum SomeService: URLPatternCompatible {
    var URLPattern: String {
        switch self {
        case .orderDetail:
            return "mgj://foo/bar/orderdetail"
        case .others:
            return "mgj://foo/bar/others"
        }
    }
}

// 组件 A (服务提供方)
MGJRouter.register(.orderDetail) { ... }

// 组件 B (服务使用方)
MGJRouter.open(.orderDetail)

SomeService 的定义可以放到一个专门的组件中,服务提供方和使用方都依赖这个专门的组件。我们这里不仅将字符串放到了一个统一的地方进行维护,而且还将一些在运行期才能发现的问题提前暴露到编译器。这里我们通过耦合来达到提高代码的健壮性和可维护性的目的。

Target-Action

Target-actin 是 casa 在批判蘑菇街的方案时提出的一种方案。它解决了 url-block 方案中内存问题、url 传参问题、没有区分本地调用和远程调用等问题。其核心就是使用了 NSObject 的 - (id)performSelector:(SEL)aSelector withObject:(id)object; 方法。

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

casa 在文章中也给出了 demo,在具体的项目中,我们可以这样使用:

// CTMediator+SomeAction.h
- (UIViewController *)xx_someAction:(NSDictionary *)params;

// CTMediator+SomeAction.m 
- (UIViewController *)xx_someAction:(NSDictionary *)params {
	return [self performTarget:@"A" action:@"someAction" params:params shouldCacheTarget:NO]
}

上面是提供给服务调用方的一个简洁的接口。其实就是对 CTMediator 方法的封装。我们一般将 CTMediator 的这个分类放到一个独立的组件中。调用方依赖这个独立的组件就可以了。

在某个组件中调用服务:

// 组件 A 中
UIViewController *vc = [CTMediator sharedInstance] xx_someAction:@{@"key": value}];

针对上面服务的定义,服务提供方的定义就必须是下面这样:

// TargetA.h
@interface Target_A : NSObject
- (UIViewController *)someAction:(NSDictionary *)params;
@end

// TargetA.m
- (UIViewController *)someAction:(NSDictionary *)params { ... }

在这整个过程中可以看到,服务的调用方只需要依赖 CTMediator 这个中间件及其分类(定义服务)。服务提供方和调用方没有任何依赖。确实做到了组件解耦。可以肯定的是 target-action 方案确实解决了 url-block 方案的一些问题。但是仔细一看,也是存在一些问题的。

跟 url-block 方案一样,两个不同组件能通信其实仍然是通过一个字符串来定义的。为什么这么说呢,我们可以看一下下面的代码:

// CTMediator+SomeAction.m 
- (UIViewController *)xx_someAction:(NSDictionary *)params {
    return [self performTarget:@"A" action:@"someAction" params:params shouldCacheTarget:NO]
}

// TargetA.h
@interface Target_A : NSObject
- (UIViewController *)someAction:(NSDictionary *)params;
@end

从上面的代码中可以看到,服务能调起主要是调用了 CTMediator 的

- (id)performTarget:(NSString *)targetName action:(NSString *)actionName params:(NSDictionary *)params shouldCacheTarget:(BOOL)shouldCacheTarget; 方法。这里不管是 targetName 还是 action 都是字符串,在实现中 CTMediator 会示例化一个 Target_targetName 类的对象,并且创建一个 Action_actionName 的 selector,所有我们在服务提供的组件中的 Target 以及 Action 是不能随便定义的。Target 必须是以 Target_开头,方法必须以 Action_开头。这种强制要求感觉不是一种工程师的思维。这里想去耦合,却以一种不是很正确的方式造成了隐式的耦合。这也是让我抛弃 CTMediator 转而去开发自己的组件化通信方案的原因之一。

Protocol-Class

Protocol-Class 方案也是常用的组件化通信方式之一。这里把它放到最后,肯定是因为笔者使用的是这种方案咯(笑)。

Protocol-Class 方案就是通过 protocol 定义服务接口,服务提供方通过实现该接口来提供接口定义的服务。具体实现就是把 protocol 和 class 做一个映射,同时在内存中保存一张映射表,使用的时候,就通过 protocol 找到对应的 class 来获取需要的服务。这种方案的优缺点先不说,可以先看一下具体的实践:

示例图:

示例代码:

// TestService.h (定义服务)
@protocol TestService <NSObject>
/// 测试
- (void)service1;

@end

// 组件 A (服务提供方)
ANT_REGISTER_SERVICE(TestServiceImpl, TestService)
@interface TestServiceImpl() <TestService> @end

@implementation TestServiceImpl

- (void)service1 {
    NSLog(@"Service test from Impl");
}

@end

// 组件 B (服务使用方)
id <TestService> obj = [Ant serviceImplFromProtocol:@protocol(TestService)];
[obj service1];

像上面的方案一样,我们会将服务的定义放到独立的组件中。这个组件仅仅只包含了服务的声明。不管是服务提供方还是服务使用方都依赖这个独立的组件,服务提供方还是服务使用方互不依赖。

这里将系统提供的服务定义为协议,通过耦合提高了代码的健壮性和可维护性。这里定义服务的 protocol 对服务提供方做了一个限定:你可以提供哪些服务,同时也给服务使用方做了限定:你可以使用哪些服务。这种设计将系统有哪些服务都交代的清清楚楚,通过服务的 protocol 我们就知道了每个服务的功能,调用需要的参数,返回值等。这里的定义服务的同时也可以作为系统服务的接口文档,这节省了服务提供方和使用方很多的沟通时间,让其能关注业务的开发。这在大型项目,多团队开发中优势尤为明显。

当然 protocol-class 这种方案缺点也很明显,需要在内存中保存 protocol 到 Class 的映射关系。但是我们可以通过将服务分类,让系统注册的 protocol-class 项尽量少一些,不要一个服务定义一个实现。对于一个有100个服务的系统,定义10个服务实现,每个实现提供10个服务,肯定要比100个服务实现占用的内存少很多。这就要求我们在实践过程中能对系统中的服务能做好划分。

总结

以上就是笔者对组件化的一些思考,很多观点可能也不太成熟,如果有什么不合理的地方,也欢迎各位同学提出建议。组件解耦在 iOS 中其实有多种解决方案,各位同学可以根据项目实际情况选择适合自己的方案。

上面代码中的 Ant 是笔者最近开发的一个负责组件生命周期管理和通信的开源工具。因为笔者公司从17年开始就一直使用 Swift 进行开发,原来的工具是用 Swift 编写的,使用了很多 Swift 的特性,在 OC 中使用就显得不伦不类了,就针对 OC 进行了重新设计,于是就有了 Ant