KVO探秘之使用篇

Key Value Observing是一种让你能够在,你关心的对象的某些属性发生变化时,得到通知的机制。我决定深入探究一下这个在 iOS 开发中常用的功能,整个系列会分为三篇,依次介绍一下功能的使用,实现的原理,以及如何自己来实现一个这样的通知机制。

本篇主要聚焦在功能的使用上,将从以下几个维度深入进行分析:

  • 基本用法
    • 常用接口
    • 使用案例
    • 案例分析
  • 进阶用法
    • API 概览
    • 更多回调触发时机
    • 观察属性链
    • 观察一对一关系
    • 观察一对多关系
    • 手动通知
  • 存在的问题及优化方案

基本用法

常用接口

KVO 的使用主要分为3个步骤:

第一步:注册观察(往观察对象中加入一个观察者,设置好要观察的内容及回调相关参数)

- (void)addObserver:(NSObject *)observer 
          forKeyPath:(NSString *)keyPath 
             options:(NSKeyValueObservingOptions)options 
             context:(nullable void *)context;

第二步:处理回调(观察者需要实现下面的回调方法来处理观察到的变化)

- (void)observeValueForKeyPath:(nullable NSString *)keyPath 
                      ofObject:(nullable id)object 
                        change:(nullable NSDictionary<NSKeyValueChangeKey, id> *)change 
                       context:(nullable void *)context;

第三步:取消观察(在时机合适的时候,需要手动从被观察者移除观察对象)

- (void)removeObserver:(NSObject *)observer 
              forKeyPath:(NSString *)keyPath 
                 context:(nullable void *)context;
                 
- (void)removeObserver:(NSObject *)observer 
              forKeyPath:(NSString *)keyPath; // 不推荐,原因后面解释

在平常的开发工作中,上面所列的几个接口基本就能覆盖我们大多数的使用场景了,下面我们结合一个具体的例子来分析一下这三个步骤做的事情。

使用案例

假如我们现在要实现一个下拉刷新控件,我们需要往UIScrollView上加一个PullToRefreshView,这个 View 会随着 ScrollView 的滚动而更新自己的内容。

在直接给出KVO的实现方案前,我想先来聊聊为什么这是一个很好的使用场景。

如果我们只是实现一个这样的页面,那么我们可以在 Controller 中实例化一个 ScrollView,并在scrollViewDidScroll:回调中获取contentOffset,然后去更新PullToRefreshView的效果即可。

但实际上往往这是一个要在多个页面上实现的效果,如果按上述说法去做的话,就得一遍遍地在回调方法中去实现这个逻辑。这时候我们就会想把这部分代码放到PullToRefresh中去实现。然而这样虽然解决了原来的重复问题,却造成了一个新的问题,delegate 无法被 controller 使用了!虽然我们还是可以用其他方法来解决这个新问题,但这时候采用KVO去实现这个功能无疑会简单很多。

好了,终于到了 Show Me The Code 的时候了:

@interface PullToRefreshView: UIView

@property (nonatomic, weak) UIScrollView *scrollView;

@end

@implenmentation

static void * const MyContext = &MyContext; // 1

- (void)setScrollView:(UIScrollView *)scrollView {
    
    if (_scrollView) {
        [_scrollView removeObserver:self forKeyPath:@"contentOffset" context:MyContext]; // 2
    }
    
    _scrollView = scrollView;
    
    [_scrollView addObserver:self forKeyPath:@"contentOffset" options:NSKeyValueObservingOptionNew context:MyContext]; // 3
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {

    if (context != MyContext) {
        [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
        return;
    } // 4

    if (object == self.scrollView && [keyPath isEqualToString:@"contentOffset"]) {
        /* Do Something Supposed To Do Here */
    } // 5
}

- (void)dealloc {
    self.scrollView = nil; // 6
}

@end

案例分析

代码中有几处标注需要额外解释一下:

注释1: context在注册观察、处理回调以及取消观察这三个步骤中都有用到。如果直接使用NULL,大多数情况下也不会影响使用,但是当父类和子类观察了同一个对象的同一个 keyPath 的时候,使用上就会产生冲突,子类很有可能会重复处理观察到的变化,而父类却无法得到回调。所以,我建议还是每次都要指定 context 的,而且 context 最好是唯一且私有的不可变值,这样可以在检测时确信是自己所观察到的变化。

另外,你也可以在一个类中使用多个 context,可以每个 keyPath 对应一个 context 来解除回调中对 keyPath 字符串比较的依赖。你甚至可以每个 keyPath 对应多个 context,来解决不同功能逻辑对同一个 keyPath 的 KVO 需求。

注释2: 取消观察的方法采用了有 context 的版本,这样跟注册时候的 context 一一对应,逻辑上会比没有 context 的版本更加清晰。如果没有指定 context,接口得自己猜测着去删除一个注册的 keyPath,说不定删掉的是父类所注册的那个哦。

注释3: 注册观察这里总共有4个参数,除了 context 前面已经分析过了,另外3个这里都详细讲讲。

首先是Observer,这个参数一般都会是self,这样注册观察、处理回调、取消回调逻辑都在一处,比较清楚,如果指向别的对象,就会产生耦合,大家可以看具体情况决定要不要指向别的对象。Observer 对象需要实现回调处理函数,如果父类和自己都没有实现回调的话,那么触发回调的时候程序就会崩溃了。

接着我们来看一下keyPath,代码中我们直接使用了字符串常量,但这个写法其实是有安全隐患的,如果你的拼写出错了或者是观察对象属性发生变化了,你的代码逻辑就会走不通。更安全的写法通常是NSStringFromSelector(@selector(contentOffset)),但这样无疑会增加很多额外的工作,并不是十分优雅,如何取舍,完全看你。另外,不知道你注意到没有,这里的变量名是keyPath, 而不是key,其中的差别,我会在后面进阶用法中进行分析。

最后是options,这个参数会影响回调中的change参数传递的值。常用的就是NSKeyValueObservingOptionOldNSKeyValueObservingOptionNew这两个,分别会在 change 这个字典中加入 NSKeyValueChangeOldKeyNSKeyValueChangeNewKey这两个 key,用于获取变化前后的值,如果你想同时使用多个选项,可以用|将它们连接在一起。由于大多数时候,你只关心变化后的值,而变化后的值又可以直接从观察对象身上取到,所以 options 也可以直接设置为 0,这样 change 字典中就只会有一个NSKeyValueChangeKindKey。关于这个一直存在的 kindKey 以及另外几个 options 我会在后面进阶用法中进行分析。

注释4: 在处理回调的时候,如果不是自己所要观察内容的回调,请直接转发给super

注释5: 因为注册的所有观察都会触发同一个回调,因此你还需要在回调函数中针对不同的 object 和 keyPath 分别进行处理。如果你不想像代码中一样进行比较,也可以用我之前在注释1所说的用多 context 方案解决这个问题。

注释6: 这里的目的其实是为了取消观察,注册和取消观察必须成对出现,取消观察的次数多了或者少了都会出现问题,下面我们就来一一分析一下。

第一种情况,在没有取消观察前,观察对象就释放了。这种情况问题不大,可以先不管。

第二种情况,在没有取消观察前,观察者被释放了。这时候,被观察对象会继续对变化进行通知,而通知对象却已经释放,崩溃就会在回调触发时发生了。

第三种情况,取消观察后,又取消观察。这个情况最简单,因为找不到需要删除的观察者,程序会抛出一个NSRangeException

基于以上分析,我的建议是尽量做到成对的注册与取消,如果实现无法保证,基于多次删除只是抛出异常这一点,你可以试试用 try...catch... 来进行反复删除。

进阶用法

API 概览

了解了 KVO 的基本用法之后,我们来深入了解一下相关的 API,看看还能够用 KVO 来做哪些事情。

KVO相关的API都定义在NSKeyValueObserving.h文件中,主要分为四个部分:

  • NSKeyValueObserving
  • NSKeyValueObserverRegistration
  • NSKeyValueObserverNotification
  • NSKeyValueObservingCustomization

这四个部分从命名上就可以看出各自负责的内容,基本都是以 category 的形式加在NSObject上来实现所需的功能,除了 Registration 这个稍微特殊一点,还在NSArrayNSSetNSOrderedSet上实现了这个 category。这些额外的 category 中做的事情是重写注册观察相关的方法,在方法中抛出异常。这是因为这几个集合类都是不能直接进行 KVO 的,关于这一点我会在后面详细分析一下集合对象要如何进行 KVO。

NSArray(NSKeyValueObserverRegistration)上还提供了下面这三个接口:

- (void)addObserver:(NSObject *)observer 
 toObjectsAtIndexes:(NSIndexSet *)indexes 
         forKeyPath:(NSString *)keyPath 
            options:(NSKeyValueObservingOptions)options 
            context:(nullable void *)context;
            
- (void)removeObserver:(NSObject *)observer 
  fromObjectsAtIndexes:(NSIndexSet *)indexes 
            forKeyPath:(NSString *)keyPath 
                context:(nullable void *)context;
                
- (void)removeObserver:(NSObject *)observer 
  fromObjectsAtIndexes:(NSIndexSet *)indexes 
            forKeyPath:(NSString *)keyPath;

这三个接口可以让你批量注册观察 array 中包含的对象,并且执行效率上会比循环调用原来的接口要好很多。

关于NSObject上的这个4个 category,又可以分为2个部分。

第一部分是NSObject(NSKeyValueObserving)NSObject(NSKeyValueObserverRegistration)。这两个 category 里所包含的公有接口就是我在基本用法中列出的那几个常用接口,大部分内容我已经在基本用法中说明过了,接下来我还会详细分析一下其余几个NSKeyValueObservingOptions参数以及多级keyPath的使用问题。

第二部分是NSObject(NSKeyValueObserverNotification)NSObject(NSKeyValueObservingCustomization)。这两个 category 里的接口可以让我们做一些更复杂的事。

我们首先来看看NSObject(NSKeyValueObservingCustomization)中的接口:

+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key;

+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key;

@property (nullable) void *observationInfo;

这个 category 中内容不多,但却包含了很多东西。第一个方法可以帮助我们轻松实现对to-one relatioship的观察,我们只要重写这个方法,返回key之间的依赖关系即可;第二个方法让我们能够不使用系统默认实现的KVO通知机制,自己选择在合适的时候发出通知;而最后的那个property,则是用于获取观察对象们的信息,可以帮助我们了解当前实例是否有被观察,以及更多观察者相关的信息。

最后就是定义在NSObject(NSKeyValueObserverNotification)中通知相关的接口了:

- (void)willChangeValueForKey:(NSString *)key;
- (void)didChangeValueForKey:(NSString *)key;

- (void)willChange:(NSKeyValueChange)changeKind 
   valuesAtIndexes:(NSIndexSet *)indexes 
            forKey:(NSString *)key;
- (void)didChange:(NSKeyValueChange)changeKind 
  valuesAtIndexes:(NSIndexSet *)indexes 
           forKey:(NSString *)key;

- (void)willChangeValueForKey:(NSString *)key 
              withSetMutation:(NSKeyValueSetMutationKind)mutationKind
                 usingObjects:(NSSet *)objects;
- (void)didChangeValueForKey:(NSString *)key 
             withSetMutation:(NSKeyValueSetMutationKind)mutationKind
                usingObjects:(NSSet *)objects;

这里总共有3组方法,每组两个方法,一个用于在修改前通知变化,另一个则在修改后调用,两个方法必须成对调用。第一组方法用于发出一般的变动通知,而后面两组方法发出的通知则更精细化一点。第二组方法用于处理有序的一对多关系,第三组方法处理的则是无序的一对多关系。

下面,我们结合实际需求来看看这些接口是如何使用的。

更多回调触发时机

我们先来看看如何使用注册观察接口中的options参数,来获得更多的回调。

NSKeyValueObservingOptions的定义是这样的:

typedef NS_OPTIONS(NSUInteger, NSKeyValueObservingOptions) {
    NSKeyValueObservingOptionNew = 0x01,
    NSKeyValueObservingOptionOld = 0x02,
    NSKeyValueObservingOptionInitial = 0x04,
    NSKeyValueObservingOptionPrior = 0x08
};

前两个我们已经在基本功能中分析过了,下面我们来着重介绍一下后面两个。

如果你尝试添加NSKeyValueObservingOptionInitial选项,你会发现在注册观察后,回调函数立即被触发了一次。如果你够仔细的话,你甚至会发现,回调函数实际上是在注册观察接口返回前就被触发了的。这个功能可以帮助你减少一次初始化相关的手动调用。有一点需要额外注意一下,这次触发的回调中,即使你添加了NSKeyValueObservingOptionOld参数,change字典中也不会有NSKeyValueChangeOldKey。因为这时候对于观察者来说是没有旧值的,即使这个值是早就设置好的,对观察者来说也是新值。

NSKeyValueObservingOptionPrior选项可以让你在观察对象的属性真正变化前触发一次回调,而原来变化后会触发的回调依旧会触发。所以在添加了这个选项后,每次观察的属性发生变化时,都会触发两次回调。第一次触发的回调中,change字典里会包含一个NSKeyValueChangeNotificationIsPriorKey,用来标记这次触发是在变化真正发生前触发的。同时需要注意的是,change字典中不会包含NSKeyValueChangeNewKey,这让这个通知的价值大大减少。

观察属性链

之前我们提到过,接口中用的参数都是叫keyPath,而不是key,现在我们来深究一下它们的区别。keyPath是由一堆key.连接而成的,可以用来表示一个多级的属性链。这就是说,KVO是支持我们观察属性链的变化的。

假设我们现在观察的keyPathuser.firstName,那么回调会在什么情况下被触发呢?简单来说,有两种情况,一个是user发生了变化,一个是 user 的firstName发生了变化。

所以说,只要我们所要观察的属性链上的每一个节点都是满足KVO条件的,那么不管属性链有多长,我们都能使用keyPath轻松地观察到链上任何一个节点的变化。

观察一对一关系

一对一关系(to-one relationship),简单来说,就是两个属性是一一对应的。我们来看一个例子:一个人的全名(fullName)是由,姓(lastName)和名(firstName)两部分构成的。全名中只能包含一个姓,所以全名和姓是一一对应的;全名也只能包含一个名,所以全名和名也是一一对应的。当然,你可以改名,甚至改姓,但它们之间永远都是一对一的关系。

现在,我们有一个User类,上面有firstNamelastNamefullName三个属性。前两个使用ivar进行存储,而fullName实现如下:

- (NSString *)fullName {
    return [NSString stringWithFormat:@"%@ %@", firstName, lastName];
}

所以,当一个观察者观察fullName变化时,它应该在firstNamelastName发生变化时,都得到通知。但默认的KVO实现中,只有在你直接修改fullName属性或者调用setFullName:方法时才会触发通知,我们得自己实现我们所要的效果。

实现方法有两种:

  1. 手动通知变化
  2. 注册依赖关系

前者我们需要再放一放,后面会有一节专门来说说如何手动通知变化,我们先来看看第二种是如何实现的:

+ (NSSet *)keyPathsForValuesAffectingValueForKey:(NSString *)key {
 
    NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
 
    if ([key isEqualToString:@"fullName"]) {
        NSArray *affectingKeys = @[@"lastName", @"firstName"];
        keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
    }
    return keyPaths;
}

例子很简单,用到的接口我们之前在 API 概览里也已经说明过了,需要注意的是,记得在重写的时候先去获取父类所注册的依赖关系,然后把自己所需的依赖关系加进去。

其实除了这个方法外,还有另一个更实用的方法可以达到这个效果,因为另一个方法不用像这个方法一样对所有 key 集中进行管理,每个 key 都有一个专门的方法去管理依赖关系,你只需要遵循这个命名规则就可以了:keyPathsForValuesAffecting<Key>

比如我们这个功能就可以这么实现:

+ (NSSet *)keyPathsForValuesAffectingFullName {
    return [NSSet setWithObjects:@"lastName", @"firstName", nil];
}

当你在 category 中添加类似的属性时,你会感谢苹果还提供了后面这种解决方案的。

我们基本已经讲清楚这个问题了,不过你还需要注意以下几点:

  1. 我们第二种方案的例子中虽然没有调用super,但是如果你是在子类中为父类添加依赖关系的话,你确认一下调用super的必要性!
  2. 例子中用的都是简单的key,但请放心添加keyPath到依赖关系中去。
  3. 你可以为一对多关系的属性添加依赖关系,但你不能使用一对多关系的属性作为其他属性依赖关系的一部分。简单一点来讲,在使用时,基本上只要有集合类属性出现,你就不用考虑这种解决方案了。

观察一对多关系

一对多关系(to-many relationship),比较常见的就是集合属性和它包含的内容的关系。所以我们先把问题简单转化为,如何观察一个可变数组内容的变化,当我们解决了这个问题,我们再回过头来分析更通用的情形。

要观察NSMutableArray内容的变化,也有两种方案:

  1. 手动通知变化
  2. 使用集合代理对象(collection proxy object

和前面一样,我们还是先把手动通知变化放一放,先来看看集合代理对象是怎么帮我们解决这个问题的。

我们知道,NSMutableArray本身的方法是不支持KVO的,但是我们可以通过下面这个方法,获取到一个代理对象:

- (NSMutableArray *)mutableArrayValueForKey:(NSString *)key;

我们可以直接把这个代理对象看做一个支持KVONSMutableArray进行后续操作,所有原本对NSMutableArray的操作都转为对这个代理对象的操作,这样我们就能实现我们的效果了。

现在我们回到观察一对多关系上来,除了NSMutableArray外的其他一对多关系要怎么进行观察呢?简单来说,只要做到像NSMutableArray那样可以获取到一个代理对象就可以了,至于要怎样才能达到这一点,我们在这里不进行展开,感兴趣的人可以看一下苹果KVC官方文档中的这部分内容

手动通知变化

在讲解这部分内容之前,我首先得建议你尽量用自动通知的方案去解决你的问题,如果经过你的仔细思考后,还是无法解决,那么你可以试试手动通知的方案。

在进行手动通知前,你需要先禁用系统通知:

+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)theKey {
 
    BOOL automatic = NO;
    if ([theKey isEqualToString:@"firstName"]) {
        automatic = NO;
    } else {
        automatic = [super automaticallyNotifiesObserversForKey:theKey];
    }
    return automatic;
}

接着你就可以使用我们之前介绍过的手动通知接口去通知变化了:

- (void)setFirstName:(NSString *)firstName {
    [self willChangeValueForKey:@"firstName"];
    _firstName = firstName;
    [self didChangeValueForKey:@"firstName"];
}

当然,你的使用场景肯定不会这么简单,不然也就没有使用手动通知的必要了。基于你自己的需求,去实现你所要的通知效果吧!

存在的问题及优化方案

KVO虽然可以帮我们解决一些切实的需求,但接口设计上还是存在比较多问题的:

  • keyPath是字符串类型
    • 缺少安全校验
    • 频繁的字符串比较
  • 所有的回调处理都在一个函数中进行
    • 需要帮忙传递父类的通知
    • 需要通过比较确认通知类型
  • 需要手动取消观察
    • 需要和注册观察完全配对,不匹配会导致崩溃

以上问题大多数可以对 API 进行二次封装解决问题,至少在使用上我们可以避免这些丑陋的问题,感兴趣的读者可以了解一下 facebook 的 KVOController

参考内容

  1. [苹果官方文档] Key-Value Observing Programming Guide
  2. [Draveness博客] 如何优雅地使用 KVO
  3. [南峰子的技术博客] Foundation: NSKeyValueObserving(KVO)
  4. [KHANLOU博客] KVO Considered Harmful
  5. [facebook] KVOController Repo
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,980评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,178评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,868评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,498评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,492评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,521评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,910评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,569评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,793评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,559评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,639评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,342评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,931评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,904评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,144评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,833评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,350评论 2 342

推荐阅读更多精彩内容

  • KVO 作为 iOS 中一种强大并且有效的机制,为 iOS 开发者们提供了很多的便利;我们可以使用 KVO 来检测...
    JzRo阅读 923评论 0 2
  • 上半年有段时间做了一个项目,项目中聊天界面用到了音频播放,涉及到进度条,当时做android时候处理的不太好,由于...
    DaZenD阅读 3,011评论 0 26
  • 你要知道的KVC、KVO、Delegate、Notification都在这里 转载请注明出处 http://www...
    WWWWDotPNG阅读 1,793评论 1 3
  • 该文章属于刘小壮原创,转载请注明:刘小壮[https://www.jianshu.com/u/2de707c93d...
    刘小壮阅读 48,222评论 35 227
  • KVO 作为 iOS 中一种强大并且有效的机制,为 iOS 开发者们提供了很多的便利;我们可以使用 KVO 来检测...
    Draveness阅读 6,885评论 11 59