key-value observing (KVO)是NSObject一个非正式协议。他可以使得一个对象可以让任意观察者来监听该对象的特定keypath。我们可以使用KVO来对被观察者对象属性发生变化时做出快速及时的响应。例如我们可以使用KVO对model与viewcontroller实现双向绑定。
但是KVO也有着许多问题去解决,比如观察对象的keyPath发生变化无法被编译器检查,在运行时才能被发现。误操作移除两次相同的KVO,或在对象被销毁时,没有及时移除KVO,都会造成程序的crash。那么如何安全、高效的使用KVO,以及如何及时、智能的移除KVO是我们需要探讨的问题。
一、更好的使用KeyPath
首先我们创建一个model和一个viewController
@interface ViewModel : NSObject
@property (nonatomic, strong) NSString *observeString;
@end
当model里的observeString发生变化时,viewController希望得到通知。
平常我们使用KVO时,一般都是这么写:
[viewModel addObserver:self
forKeyPath:@"observeString"
options:NSKeyValueObservingOptionNew
context:nil];
这时,我们可以发现由于keypath使用的是字符串编码,所以拼写错误或是observeString属性名称发生变化等问题都无法被编译器检查出来,最终导致了许多问题延后到了运行时才能被发现。
那如何解决这个问题呢?
我们可以使用宏来解决这个问题:
#define KvoKeyPath(PATH) @(((void)(NO && ((void)PATH, NO)), strchr(# PATH, '.') + 1))
对于编译语言来说,所有的宏都是在预编译的时候被展开的,所以我们可以通过Xcode直接查看预处理或者预编译阶段的宏展开。
我们可以看到 KvoKeyPath(self.class)
被展开成了 @(((void)(__objc_no && ((void)self.class, __objc_no)), strchr("self.class", '.') + 1))
(void)是为了防止逗号表达式的warning。加NO是为了C短路判断条件表达式。编译器看见了NO && 以后,就会很快的跳过之后的判断条件。
在宏中,#代表把宏的参数名转化为一个字符串。而strchr函数使用来查找字符串s中首次出现字符c的位置。返回首次出现字符c的位置的指针,返回的地址是被查找字符串指针开始的第一个与字符c相同字符的指针,如果字符串中不存在字符c则返回NULL。
最后我们通过strchr函数得到了一个C的字符串,通过@( )包起来,就变成了一个OC的字符串了。
使用宏之后,keyPath的就可以这样写:
[viewModel addObserver:self
forKeyPath:KvoKeyPath(viewModel.observeString)
options:NSKeyValueObservingOptionNew
context:nil];
由于我们在判断式中加入了self.class,编译器会对你的属性名称进行拼写校验。
二、KVO消息转发
KVO所有的改变通知回调都被到了都被集中到了一个单独的方法 -observeValueForKeyPath:ofObject:change:context:
中,这就导致了当我们在一个类中观察了多个对象时需要使用if else来做区分:
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
if ([keyPath isEqualToString:@"observeString"]) {
// TODO
}
}
这容易导致 -observeValueForKeyPath:ofObject:change:context:
中代码的臃肿,可读性的下降。
那我们是否可以在建立观察者联系时,就可以通过block或者指定@selector的形式来接收消息的回调呢?
实现步骤
1、创建NSObject的分类,利用runtime来对本身关联一个KvoPoint对象,在对象中,保存观察对象的KVO配置,保存自己的持有者,并实现对观察对象的监听
2、封装 -observeValueForKeyPath:ofObject:change:context:
方法
3、在KvoPoint接收到消息回调时,把KVO消息回调转发给A
KVO消息转发思维导图.png下面是核心代码:
创建NSObject分类
@interface NSObject (SafeKvo)
- (void)safe_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options block:(KVOSafeBlock)block;
@end
@implementation NSObject (SafeKvo)
- (void)safe_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options block:(KVOSafeBlock)block
{
//关联一个KvoPoint对象
KvoPoint *sPoint = [self point];
KvoPoint *oPoint = [(NSObject *)observer point];
[oPoint addPoint:sPoint keyPath:keyPath options:options block:block];
}
@end
利用runtime来关联一个对象
- (KvoPoint *)point {
KvoPoint *point = objc_getAssociatedObject(self, @selector(point));
if (point == nil) {
point = [KvoPoint alloc] initWithObject:self];
objc_setAssociatedObject(self, @selector(point), point, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
return point;
}
KvoPoint配置
@interface KvoPoint ()
{
NSMapTable<ZWNKvoPoint *, NSMutableSet<_ZWNKvoInfo *> *> *_toPoints;//保存所有观察对象的配置
}
@property (nonatomic, unsafe_unretained) id theObject;//指向持有者
@end
PointA将PointB和KvoInfo添加到自己的_toPoints中
- (void)addPoint:(ZYKvoPoint *)point withInfo:(ZYKvoInfo *)info
{
NSMutableSet *infos = [_toPoints objectForKey:point];
ZYKvoInfo *existingInfo = [infos member:info];
if (nil != existingInfo) {
return;
}
if (nil == infos) {
infos = [NSMutableSet set];
[_toPoints setObject:infos forKey:point];
}
[infos addObject:info];
[point.theObject addObserver:self forKeyPath:info->_keyPath options:info->_options context:(void *)info];
}
调用block,把PointA的KVO消息回调转发给A
#pragma mark - observe
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
{
NSAssert(context, @"missing context keyPath:%@ object:%@ change:%@", keyPath, object, change);
ZWNKvoInfo *info = (__bridge id)context;
if (info->_block) {
info->_block(_theObject, object, change);
}
else {
[_theObject observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}
kvoInfo配置
@interface KvoInfo : NSObject
@end
@implementation KvoInfo
{
@public
NSString *_keyPath;
NSKeyValueObservingOptions _options;
KVOSafeBlock _block;
void *_context;
SEL sel;
}
三、如何安全的取消注册
在使用者KVO最大的痛苦就是在什么时候及时的移除观察者,而且移除观察者的时机必须合适。没有及时的移除或者多次重复的移除都会造成crash。
我们可以使用 @try / @catch 的方式来安全的取消注册。例如:
@try {
[object removeObserver:self forKeyPath:@"keyPath"];
}
@catch (NSException * __unused exception) {}
但是@try / @catch的方案会导致代码的臃肿,可读性的下降。我们可以采用更加优雅的方案来解决这个问题:
移除KVO
当观察者和被观察者建立连接时,我们可以把这一连接描述成一幅有向图。
A的有向图.jpeg如上图所示,对象A监听B某一个属性,但同时又是C的监听对象,如果A把监听自己的对象C信息也保存下来,是不是就能够在自己被销毁前,先手动移除所有的KVO。
1、我们对KVOPoint类进行改造,添加一个NSHashTable
类型的fromPoints表,用来给kvoPointA存储监听自己的kvoPointC信息。
@interface ZWNKvoPoint ()
{
NSMapTable<ZWNKvoPoint *, NSMutableSet<_ZWNKvoInfo *> *> *_toPoints;//保存所有观察对象的配置
NSHashTable<ZWNKvoPoint *> *_fromPoints; // 保存所有观察自己的持有者的KVOPoint
}
2、添加KVO,当我们要增加A对B的某个属性进行观察时,我们更新对应的A,B的KvoPoint中toPoints,fromPoints中对应的表信息,当A要移除对B的观察时,我们检查PointA中对应的_toPoints表内信息,如果_toPoints中有要移除的PointB以及对应的B的KvoInfo,就移除_toPoints中对应内容和PointA对B的观察,同时更新PointB中的_formPoints表信息。由于我们使用了KvoPoint来记录每次添加KVO时的信息,并在移除时进行了校验这样就可以防止多次添加同样的KVO或多次移除的发生。
PointB将PointA添加到_formPoints中
- (void)addFromPoint:(ZYKvoPoint *)point
{
if (nil == point) {
return;
}
[_formPoints addObject:point];
}
PointA 移除PointB对应的Kvoinfo
- (void)removePoint:(ZYKvoPoint *)point withInfo:(ZYKvoInfo *)info
{
NSMutableSet *infos = [_toPoints objectForKey:point];
ZYKvoInfo *existingInfo = [infos member:info];
if (nil == existingInfo) {
return;
}
[infos removeObject:existingInfo];
[point.theObject removeObserver:self forKeyPath:existingInfo->_keyPath context:(void *)existingInfo];
if (0 == infos.count) {
[_toPoints removeObjectForKey:point];
[point removeFromPoint:self];
}
}
四、更加智能的自动移除KVO
当一个对象被释放时我们必须需要手动移除观察者和被观察者,一旦其中一方在被释放时没有及时的移除KVO关系就会导致Crash,这迫使我们需要在对象 -dealloc
时,添加手动移除的代码。
为了能够在对象A释放时自动移除KVO关系,需要我们在接收到A将要销毁时,同步销毁PointA,并移除PointA表中对应的所有KVO关系就可以实现自动移除KVO,那么问题的关键便成为了如何获取一个对象的销毁时机?
Hook dealloc
借助Objective-C中的runtime的特性,我们可以实现很多常规方法下几乎不可能完成的事情。例如我们可以使用RunTime运行时的这个黑科技很容易的替换NSObject的 -dealloc
方法并在替换后盾方法中自动注销KVO关系的移除。
// 替换dealloc方法,自动注销observer
+ (void)load
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Method originalDealloc = class_getInstanceMethod(self, NSSelectorFromString(@"dealloc"));
Method newDealloc = class_getInstanceMethod(self, @selector(autoRemoveObserverDealloc));
method_exchangeImplementations(originalDealloc, newDealloc);
});
}
- (void)autoRemoveObserverDealloc
{
[self.point removeAll];
[self autoRemoveObserverDealloc];
}
但是直接替换基础类的-dealloc
对于其他的代码入侵性太强,容易产生一些可不遇见性的问题,所以不推荐使用这个方式。
使用关联对象
我们也可以借助runtime的另一个特性关联对象(Associated Objects)来完成获取任意对象的释放时机.
首先我们对 NSObject
添加一个 DeallocHook
的关联对象。
NSObject+DeallocHook
@interface NSObject (DeallocHook)
- (void) addDeallocMethod;
@end
@implementation NSObject (DeallocHook)
- (void)addDeallocMethod {
DeallocHook *hook = objc_getAssociatedObject(self, @selector(addDeallocMethod));
if (hook == nil) {
DeallocHook *hook = [DeallocHook new];
hook.thePoint = self.point;
objc_setAssociatedObject(self, @selector(addDeallocMethod),hook, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
}
@end
DeallocHook
@interface DeallocHook : NSObject
@property (strong) ZWNKvoPoint *thePoint;
@end
@implementation DeallocHook
- (void)dealloc {
[self.thePoint removeAll];
self.thePoint = nil;
}
@end
由于关联对象hook只被主对象所持用,所以当关联对象在主对象调用-dealloc中的object_dispose()中被进行释放后便会被一起释放掉。通过这个办法我们可以获取特定对象的释放时机,对其他没用添加关联对象的对象也不会产生任何影响。
总结
以上这些经过理论和demo初步实践,基本上实现了如何安全、高效的添加KVO,以及智能的自动移除KVO,对未来进行app功能模块化的应用场景希望能够提供帮助。
网友评论