用更优雅的方式使用KVO
背景
最近在做项目的改造工作,发现了项目中用到了不少KVO,而且是通过字符串硬编码的方式书写,正所谓复制一时爽,重构火葬场,底层重构属性的时候这种字符串是不会给错误或者警告提示的,造成了排查的困难。以前个人使用过一个叫libextobjc
的拓展库,里面有一些对keypath的封装,可以在编译期生成并且检查keypath,非常方便。
用法
这部分KVO的拓展叫EXTKeyPathCoding
,官方注释说支持两种写法。
NSString *UTF8StringPath = @keypath(str.lowercaseString.UTF8String); // => @"lowercaseString.UTF8String"
NSString *lowercaseStringPath = @keypath(NSString.new, lowercaseString); // => @"lowercaseString"
非常简洁明了,第一种比较直观,连续的点语法会截掉第一个"."
之前,保留之后的字符串。第二种只保留","
后的字符串。值得一提的是对于类属性,也支持这种语法。比如这样:NSString *versionPath = @keypath(NSObject, version);// => @"version"
对于只是想了解用法的同学,下面的原理可以直接跳过,看最后的注意事项即可。
原理
近来有同事问起原理,于是深入研究了下,感叹作者对宏定义用法的炉火纯青,现在把过程分享出来。
先看源码,对于@keypath
,主要的宏定义是这样写的:
1 | #define keypath(...) \ |
看到的时候我的表情是黑人问号,幸好xcode有牛x闪闪的预处理功能,点击这里能看到:
对于@keypath(self.view)
展开后去掉断言等无关代码,核心代码变成了这样:
1 | @(__objc_no).boolValue ? ((NSString * _Nonnull)((void *)0)) : |
我们看到,这个宏定义就是一个三目运算符,接下来我们一步一步分析做了什么。
看第一行@(__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 | NSArray *arr = ({ |
最后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)