背景

最近在做项目的改造工作,发现了项目中用到了不少KVO,而且是通过字符串硬编码的方式书写,正所谓复制一时爽,重构火葬场,底层重构属性的时候这种字符串是不会给错误或者警告提示的,造成了排查的困难。以前个人使用过一个叫libextobjc的拓展库,里面有一些对keypath的封装,可以在编译期生成并且检查keypath,非常方便。

用法

这部分KVO的拓展叫EXTKeyPathCoding,官方注释说支持两种写法。

  1. NSString *UTF8StringPath = @keypath(str.lowercaseString.UTF8String); // => @"lowercaseString.UTF8String"

  2. NSString *lowercaseStringPath = @keypath(NSString.new, lowercaseString); // => @"lowercaseString"

非常简洁明了,第一种比较直观,连续的点语法会截掉第一个"."之前,保留之后的字符串。第二种只保留","后的字符串。值得一提的是对于类属性,也支持这种语法。比如这样:
NSString *versionPath = @keypath(NSObject, version);// => @"version"

对于只是想了解用法的同学,下面的原理可以直接跳过,看最后的注意事项即可。

原理

近来有同事问起原理,于是深入研究了下,感叹作者对宏定义用法的炉火纯青,现在把过程分享出来。

先看源码,对于@keypath,主要的宏定义是这样写的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define keypath(...) \
_Pragma("clang diagnostic push") \
_Pragma("clang diagnostic ignored \"-Warc-repeated-use-of-weak\"") \
(NO).boolValue ? ((NSString * _Nonnull)nil) : ((NSString * _Nonnull)@(cStringKeypath(__VA_ARGS__))) \
_Pragma("clang diagnostic pop") \

#define cStringKeypath(...) \
metamacro_if_eq(1, metamacro_argcount(__VA_ARGS__))(keypath1(__VA_ARGS__))(keypath2(__VA_ARGS__))

#define keypath1(PATH) \
(((void)(NO && ((void)PATH, NO)), \
({ char *__extobjckeypath__ = strchr(# PATH, '.'); NSCAssert(__extobjckeypath__, @"Provided key path is invalid."); __extobjckeypath__ + 1; })))

#define keypath2(OBJ, PATH) \
(((void)(NO && ((void)OBJ.PATH, NO)), # PATH))

看到的时候我的表情是黑人问号,幸好xcode有牛x闪闪的预处理功能,点击这里能看到:
图片

对于@keypath(self.view)展开后去掉断言等无关代码,核心代码变成了这样:

1
2
3
4
5
@(__objc_no).boolValue ? ((NSString * _Nonnull)((void *)0)) : 

((NSString * _Nonnull)@((((void)(__objc_no && ((void)self.view, __objc_no)),

({ char *__extobjckeypath__ = strchr("self.view", '.'); __extobjckeypath__ + 1; }))) ));

我们看到,这个宏定义就是一个三目运算符,接下来我们一步一步分析做了什么。

看第一行@(__objc_no).boolValue ? ((NSString * _Nonnull)((void *)0))__objc_no就是NO,这里通过@(NO)将基本类型NO封装成了NSNumber。

((NSString * _Nonnull)((void *)0))其实就是((NSString * _Nonnull)nil)

所以这表达式简单写就是 @(NO).boolValue ? nil : 巴拉巴拉。或许你会疑惑这个三木运算符的意义何在,经过测试,移除掉没啥影响,我猜就是为了前面可以加一个酷炫的@符号吧。RAC里有@weakify这种写法也是要加@,其实也没什么用,就是为了酷炫,哈哈哈。

接下来我们看看第二行((NSString * _Nonnull)@((((void)(__objc_no && ((void)self.view, __objc_no)),这句最大的意义其实就是提供了self.view让编译器能检查拼写。其实这里就是上面的#define keypath1 (void)(NO && ((void)PATH, NO))展开的内容。这里用到了C语言里一些不太常用的写法。

1
int c = (a, b)

c最后的取值会是b。但是这样写编译器会报警告,简单的做法就是改成这样:

1
int c = ((void)a, b)

所以这里写void的原因你也理解了吧。

然后看最后一句:({ char *__extobjckeypath__ = strchr("self.view", '.'); __extobjckeypath__ + 1; })

这一句是由keypath1的后半部分展开而来。

strchr是c语言中寻找一个字符在另一个字符串中第一次出现的位置的函数,找到会返回位置的指针,没找到返回NULL。具体到这里,就是找到'.'"self.view"中第一次出现的位置。这里+1相当于截断了'.'之前包括'.'的内容,所以能理解为什么它能找到对应的keypath了吧。{()}也是一个语法糖,会把最后一行做为表达式的返回值。好处就是在大括号中有自己的局部变量作用域,在临时变量很多,难以管理的时候这种写法可以比较优雅的解决问题。常见的写法像这样:

1
2
3
4
5
NSArray *arr = ({
NSMutableArray *mArr = [NSMutableArray array];
[mArr addObject:@(123)];
mArr;
});

最后arr的取值会是mArr,不需要写return。

最后我们看到,返回值被@()包住,这是什么语法?我们知道@(123)这个语法糖会生成NSNumber,而比较少见的@("123")则会生成NSString。这里的char *__extobjckeypath__就是一个c的字符串,所以你理解为什么最后能生成了一个NSString了吧。

讲到这里我们梳理完了第一种keypath的写法,第二种写法原理大致相同,在宏定义中 # 变量名的写法会直接转换成c字符串,这就是第二种写法会把,之后的内容转换成字符串的基础。

总结

我们可以看到作者对宏定义的运用到了非常高的水平,如果感兴趣,还可以阅读开源库里其它模块的代码,很值得研究。

说一下注意点,有同事在写keypath的时候写过类似这样的代码:

@keypath(XXManager.shareManager.abc),编译没有任何问题,跑起来却crash了,报错是xxx was sent to an object that is not KVC-compliant for the "shareManager" property.为什么?

其实不是这个库的锅,因为shareManager是个类属性,它的get方法本质其实是个类方法,而只有实例属性才支持KVO,所以一定要小心。改法也很简单,使用第二种写法就行@keypath(FTManager.shareManager, abc)

参考

Reactive Cocoa Tutorial [1] = 神奇的Macros