背景

当我们实现复杂UI的时候,很容易形成一种多层级的对象关系,如下图

随着ContentViewController业务的增长,ContentViewController/ContainerViewController/RootViewController的delegate会变得越来越大。很多时候,ContainerViewController/RootViewController可能并不关心其中的方法,只是承担传递事件的责任,但是这些实现又是和自身没有关系的。所以给这些对象建立一个通用的机制就显得逻辑更加简单清晰一些。

思路

我的思路是在对象之间创建了一条事件链,就像系统的Responder Chain一样。这样每个事件就可以沿着这条链传播,谁想要,谁订阅就好了。这种方式源自于CASA的一种基于ResponderChain的对象交互方式,但是又觉得文中提到的实现方式存在一些问题,其中最重要的问题在于事件的声明,使用字符串和字典来传递的话,参数类型很难确定,这样读的时候老得校验,肯定是不爽的。
这个问题考虑了好久,最后才想到NSInvovation来解决这个问题,既然这个问题源自delegate肥大,那么为什么实现的时候还原delegate的本质呢?用Protocol来定义事件,用selector作为事件名。

实践

处于想要所有对象都可以用这种机制的考虑,可以直接给NSObject新增扩展,给每一个对象加一个node专门维护这个事情,让每一个node都维护一张路由表,每个node只关心自己的上级是谁,即事件需要告诉谁即可。在分发事件的时候,如果当前节点为UIResponder/NSResponder的话,也会将事件传给nextResponder,这样就针对Responder Chain形成了一条默认的事件链。通过定义routerEvent:方法来实现事件逐级向上传。

DominoEventTrigger

实现了一个DominoEventTrigger来专门用于触发事件,通过定义一个宏

#define DominoSelectorEvents(...) interface DominoEventTrigger()<__VA_ARGS__> @end

这样就可以让事件的发送者可以像调delegate方法一样的触发事件。
DominoEventTrigger的主要职责是转发消息,所以直接继承自NSProxy比较合适。
消息转发走的methodSignatureForSelector:forwardInvocation:由于DominoEventTrigger并不知道真正的实现者是谁,所以没办法利用methodSignatureForSelector来生成签名,所以这个时候让事件先出去兜一圈,带个方法签名回来!当然是不用兜一整圈的,找到一个方法签名就可以回来了。当然肯定需要考虑如果没人监听事件怎么办,那就声明一个最简单的签名[NSMethodSignature signatureWithObjCTypes:@encode(void)]
然后到了forwardInvocation:之后,再让事件带着Invocation出去兜一圈,谁要用就对谁执行即可。
其实这里可以只让事件兜一圈,带着签名和targets一块回来就可以了,我最初是这样想的,但是后来考虑到事件拦截、修改事件参数的时候,还是兜两圈更为简单。当然,为了防止通过多条路导致某个事件让同一个对象触发两次,让事件自身维护一个触发者的表即可。

DominoEventTracker

DominoEventTracker专门用于调用每个节点的监听器,这个比较简单,用个字典存储一下即可。

事件拦截

以上就完成了整个事件链的构造,但是业务肯定会出现中间某层要拦截事件或者修改事件的参数的情况。
于是考虑拦截器的问题,
先说单纯的拦截,这个比较简单,调用前判断一下就好了,但是很多是时候一个一个判断太麻烦,比如对组件外的事件肯定是维护一个列表的,那么如果直接能这么判断就好了,系统并没有提供某个selector是否在Protocol内的方法,可以利用protocol_getMethodDescription来间接完成这件事情

BOOL DominoProtocolContainSelector(Protocol *protocol, SEL selector) {
    return (protocol_getMethodDescription(protocol, selector, YES, YES).name
            || protocol_getMethodDescription(protocol, selector, NO, YES).name
            || protocol_getMethodDescription(protocol, selector, NO, NO).name
            || protocol_getMethodDescription(protocol, selector, YES, YES).name);
}

参数修改这个问题的坑就比较多了,首先考虑如果参数修改的话,其后面的Event必须是一个新的Event,不能影响这个事件向别的链的传播,
NSInvocation的copy方法很奇怪!他只会把方法签名拷过去= =最后只好自己来吧

static NSInvocation *CopyInvocationFrom(NSInvocation *invocation) {
    NSInvocation *result = [invocation copy];
    result.selector = invocation.selector;
    for (NSInteger index=2; index<invocation.methodSignature.numberOfArguments; index++) {
        NSUInteger valueSize = 0;
        const char *argType = [invocation.methodSignature getArgumentTypeAtIndex:index];
        NSGetSizeAndAlignment(argType, &valueSize, NULL);
        char argument[valueSize];
        [invocation getArgument:&argument atIndex:index];
        [result setArgument:&argument atIndex:index];
    }    
    return result;
}

这里的还隐藏了一个坑,就是网上很多人在对NSInvocation的参数赋值的时候采用id arg,然后[invocation getArgument:&arg atIndex:index]的形式,这样的话会导致基础类型比如int/float/BOOL之类的参数出问题,所以这里应该直接使用最原始的char[]来存储,这样可以兼容所有类型的参数。

解决完NSInvocation的赋值之后,考虑怎么改参数,= =不得不说NSInvocation的这个接口一点也不友好!于是想把这个接口搞得更加简单一些,直到到看到RAC对NSInvocation的一个扩展

- (id)rac_argumentAtIndex:(NSUInteger)index {
#define WRAP_AND_RETURN(type) \
do { \
type val = 0; \
[self getArgument:&val atIndex:(NSInteger)index]; \
return @(val); \
} while (0)

    const char *argType = [self.methodSignature getArgumentTypeAtIndex:index];
    // Skip const type qualifier.
    if (argType[0] == 'r') {
        argType++;
    }

    if (strcmp(argType, @encode(id)) == 0 || strcmp(argType, @encode(Class)) == 0) {
        __autoreleasing id returnObj;
        [self getArgument:&returnObj atIndex:(NSInteger)index];
        return returnObj;
    } else if (strcmp(argType, @encode(char)) == 0) {
        WRAP_AND_RETURN(char);
    } else if (strcmp(argType, @encode(int)) == 0) {
        WRAP_AND_RETURN(int);
    } else if (strcmp(argType, @encode(short)) == 0) {
        WRAP_AND_RETURN(short);
    } else if (strcmp(argType, @encode(long)) == 0) {
        WRAP_AND_RETURN(long);
    } else if (strcmp(argType, @encode(long long)) == 0) {
        WRAP_AND_RETURN(long long);
    } else if (strcmp(argType, @encode(unsigned char)) == 0) {
        WRAP_AND_RETURN(unsigned char);
    } else if (strcmp(argType, @encode(unsigned int)) == 0) {
        WRAP_AND_RETURN(unsigned int);
    } else if (strcmp(argType, @encode(unsigned short)) == 0) {
        WRAP_AND_RETURN(unsigned short);
    } else if (strcmp(argType, @encode(unsigned long)) == 0) {
        WRAP_AND_RETURN(unsigned long);
    } else if (strcmp(argType, @encode(unsigned long long)) == 0) {
        WRAP_AND_RETURN(unsigned long long);
    } else if (strcmp(argType, @encode(float)) == 0) {
        WRAP_AND_RETURN(float);
    } else if (strcmp(argType, @encode(double)) == 0) {
        WRAP_AND_RETURN(double);
    } else if (strcmp(argType, @encode(BOOL)) == 0) {
        WRAP_AND_RETURN(BOOL);
    } else if (strcmp(argType, @encode(char *)) == 0) {
        WRAP_AND_RETURN(const char *);
    } else if (strcmp(argType, @encode(void (^)(void))) == 0) {
        __unsafe_unretained id block = nil;
        [self getArgument:&block atIndex:(NSInteger)index];
        return [block copy];
    } else {
        NSUInteger valueSize = 0;
        NSGetSizeAndAlignment(argType, &valueSize, NULL);

        unsigned char valueBytes[valueSize];
        [self getArgument:valueBytes atIndex:(NSInteger)index];

        return [NSValue valueWithBytes:valueBytes objCType:argType];
    }

    return nil;

#undef WRAP_AND_RETURN
}
@end

哇,看到这个的时候给跪了,之前是查过开发文档的时候,看到方法签名里面的type的时候想过自己转,但是总感觉太复杂,= =不过既然有现成的代码了,就直接拿来用了。
封装一个DominoSelectorEventParams专门用于修改参数,顺带新增支持下标访问,也把参数列表的index改成从0开始。
于是修改参数变成这个样子

- (void)reformDominoParams:(DominoSelectorEventParams *)params forSelectorEvent:(SEL)selector {
    if (selector == @selector(contentDidLoadWithArg1:arg2:)) {
        params[0] = @"hook!!!";
        params[1] = @(333);
    }
}

这样就比较简单了,也不需要担心类型问题,会在调用的时候会按照签名中的type进行解包。

最后再加了对NSString那种类型的事件的支持,就大功告成了。

使用

声明事件:

// ContentViewControllerEvents.h

/// SimpleEvent
extern NSString * const ContentViewControllerStatisticsEvent;

/// SelectorEvent
@protocol ContentViewControllerEvents <NSObject>
@optional

- (void)contentDidLoadWithArg1:(NSString *)arg1 arg2:(NSInteger)arg2;
- (void)contentDidLoadWithArg:(NSInteger)arg;
- (NSString *)fetchChannelId;
@end

发送事件:

#import "Domino.h"
#import "ContentViewControllerEvents.h"

@DominoSelectorEvents(ContentViewControllerEvents); // declare events would be posted
@interface ContentViewController ()

@end

@implementation ContentViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // ...

    // post SelectorEvent
    [self.domino.trigger contentDidLoadWithArg:12];

    // post NormalEvent
    [self.domino.trigger postEvent:ContentViewControllerStatisticsEvent params:@{@"msg":@"did load"}];

}
@end

接收事件:

#import "Domino.h"
#import "ContentViewControllerEvents.h"

@interface ContainerViewController () // <ContentViewControllerEvents> NOT neccessary
@end
@implementation ContainerViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // 注册监听SelectorEvent
    [self.domino.tracker subscribeSelectorEvent:@selector(contentDidLoadWithArg:) target:self];

    // 注册监听NormalEvent
    [self.domino.tracker subscribeEvent:ContentViewControllerStatisticsEvent handler:^(NSDictionary *params) {
        NSLog(@"[%@]%@",ContentViewControllerStatisticsEvent, params);
    }];
}

- (void)contentDidLoadWithArg:(NSInteger)arg {
    NSLog(@"ContainerViewController - contentDidLoad %td",arg);
}
@end

修改/拦截事件:

@interface ContainerViewController ()<DominoInterceptor> // !!! declare IS neccessary !!!
@end
@implementation ContainerViewController

- (void)reformDominoParams:(DominoSelectorEventParams *)params forSelectorEvent:(SEL)selector {
    if (selector == @selector(contentDidLoadWithArg1:arg2:)) {
        NSLog(@"ContainerViewController - reform @selector(contentDidLoadWithArg1:arg2:)");
        params[0] = @"hook!!!"; // index from 0
        params[1] = @(333); // Don't worry about type
    }
}
@end

轮子

其实当我决定向上也可以有多个上级即可以分叉的时候,当时就想到的多米诺骨牌,索性就以Domino命名吧。
Github在此:Domino

总结

使用场景:

  • 父VC包含子VC,子VC又包含其他子VC,依次
  • 从VC中将部分功能拆分出独立对象,但很多事件仍然需要VC知道
  • delegate(如UIScrollDelegate)中的事件需要被多方知晓

vs Delegate:

  • 避免多层级之间的delegate只用作转发而变得臃肿
  • delegate只能实现一对一通信,但有些事件需要被多方知晓
  • 无视命名域,也就是说,事件是可以直接跨过组件传递的,只需要将需要公开的事件单独定义成头文件为外界引用即可

vs Notification:

  • 很多时候当有多个实例的时候Notification并不适合
  • 只有上级才能接收下级的事件,也就是说比Notification更加严格,便于管理

需要说明的是,这种模式并不能解决所有的问题,该使用什么模式的时候依旧需要使用模式,只是在这种多层级连续调用的时候,采用事件链的形式会让业务逻辑更加简单清晰,顺带解决了组件之间的事件传递问题。

Perference

  1. 一种基于ResponderChain的对象交互方式
  2. ObjC与鸭子对象
  3. ReactiveCocoa