Key-Value Observing(KVO) 是一种机制,它答应目标在其他目标的指定特点产生更改时得到告诉。要运用 KVO,首要你有必要保证被调查目标是 KVO 兼容的。一般,假如你的目标继承自 NSObject 而且你以一般的办法创建特点,那么你的目标及其特点将主动兼容 KVO。KVO 的主要优点是你不必完成自己的方案来在每次特点更改时发送告诉,其界说良好的基础架构具有框架级别的支撑。

KVO 的基本运用

你有必要履行以下进程以使目标能够接纳 KVO 兼容特点的 KVO 告诉:

  • 运用办法 addObserver:forKeyPath:options:context: 将调查者注册到被调查目标。
  • 在调查者内部完成 observeValueForKeyPath:ofObject:change:context: 以接受更改告诉音讯。
  • 运用 removeObserver:forKeyPath: 办法撤销注册调查者,当它不再应该接纳音讯时。至少,在调查者从内存中开释之前调用此办法。

注册为调查者

被调查目标首要经过发送 addObserver:forKeyPath:options:context: 音讯将调查者注册到被调查目标,并将调查者和要调查的特点的 keyPath 传递。调查者还指定了一个选项参数和一个上下文指针来办理告诉的各个方面。

Options

options 参数为常量选项的按位或,它会影响告诉中供给的 change 字典的内容以及生成告诉的办法。可选的值有如下四个:

typedef NS_OPTIONS(NSUInteger, NSKeyValueObservingOptions) {
    NSKeyValueObservingOptionNew = 0x01, // 新值
    NSKeyValueObservingOptionOld = 0x02, // 旧值
    // 特点的初始值
    NSKeyValueObservingOptionInitial API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0)) = 0x04,
    // 在改动前发送告诉
    // change 字典经过包含键 NSKeyValueChangeNotificationIsPriorKey 和 NSNumber 包装 YES 的值来表示更改前告诉。
    NSKeyValueObservingOptionPrior API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0)) = 0x08
};

Context

addObserver:forKeyPath:options:context: 音讯中的 context 指针包含任意数据,这些数据将在相应的更改告诉中传递回调查者。你能够指定 NULL 并完全依靠 keyPath 字符串来确认更改告诉的来历,但这种办法或许会导致父类出于不同原因也调查相同 keyPath 的目标出现问题。

一种更安全、更可扩展的办法是运用上下文来保证你收到的告诉是发给你的调查者而不是父类的。

类中仅有命名的静态变量的地址是一个很好的上下文。在父类或子类中以相似办法挑选的上下文不太或许重叠。你能够为整个类挑选一个上下文,并依靠告诉音讯中的 keyPath 字符串来确认产生了什么改动。或者,你能够为每个调查到的 keyPath 创建一个不同的上下文,这完全绕过了字符串比较的需求,然后提高了告诉解析的功率。

static void *PersonAccountBalanceContext = &PersonAccountBalanceContext;
static void *PersonAccountInterestRateContext = &PersonAccountInterestRateContext;
- (void)registerAsObserverForAccount:(Account*)account {
    [account addObserver:self
              forKeyPath:@"balance"
                 options:(NSKeyValueObservingOptionNew |
                          NSKeyValueObservingOptionOld)
                 context:PersonAccountBalanceContext];
    [account addObserver:self
              forKeyPath:@"interestRate"
                 options:(NSKeyValueObservingOptionNew |
                          NSKeyValueObservingOptionOld)
                  context:PersonAccountInterestRateContext];
}

需求注意的是 addObserver:forKeyPath:options:context: 办法不保护对调查目标、被调查目标或 context 的强引用。你应该保证依据需求保护对调查目标和调查目标以及 context 的强引用。

接纳改动告诉

当目标的被调查特点的值产生改动时,调查者会收到一个 observeValueForKeyPath:ofObject:change:context: 音讯。一切的调查者都有必要完成这个办法。

被调查目标供给触发告诉的 keyPath、被调查目标、包含有关更改的详细信息的字典以及在为该 keyPath 注册调查者时供给的上下文指针。

change 字典的 NSKeyValueChangeKindKey 供给有关产生的更改类型的信息:

  • 假如被调查目标的值产生了改动,则 NSKeyValueChangeKindKey 对应的值为 NSKeyValueChangeSetting。依据调查者注册时指定的选项,change 字典中的 NSKeyValueChangeOldKeyNSKeyValueChangeNewKey 条目包含更改之前和之后的特点值。假如特点是目标,则直接供给值。假如特点是基本数据类型或 C 结构,则将值包装在 NSValue 目标中。
  • 假如调查到的特点是一对多联系,则 NSKeyValueChangeKindKey 条目还经过分别回来 NSKeyValueChangeInsertionNSKeyValueChangeRemovalNSKeyValueChangeReplacement 指示联系中的目标是否被刺进、删除或替换。
  • change 字典的 NSKeyValueChangeIndexesKey 条目是一个 NSIndexSet 目标,用于指定更改的联系中的索引。假如在注册调查者时将 NSKeyValueObservingOptionNewNSKeyValueObservingOptionOld 指定为选项,则 change 字典中的 NSKeyValueChangeOldKeyNSKeyValueChangeNewKey 条目是包含更改前后相关目标值的数组
- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context {
    if (context == PersonAccountBalanceContext) {
        // Do something with the balance…
    } else if (context == PersonAccountInterestRateContext) {
        // Do something with the interest rate…
    } else {
        // Any unrecognized context must belong to super
        [super observeValueForKeyPath:keyPath
                             ofObject:object
                               change:change
                               context:context];
    }
}

假如你在注册调查者时指定了 NULL 上下文,则将告诉的 keyPath 与你正在调查的 keyPath 进行比较以确认产生了什么改动。假如你对一切调查到的 keyPath 运用单个上下文,则首要依据告诉的 context 对其进行测试,并找到匹配项,然后运用 keyPath 字符串比较来确认具体产生了什么改动。假如你为每个 keyPath 供给了仅有的 context,如上述代码所示,一系列简略的指针比较会一起告诉你告诉是否针对此调查者,假如是,则哪些 keyPath 已更改。

在任何情况下,调查者应该总是调用父类的 observeValueForKeyPath:ofObject:change:context: 完成,当它不能辨认 context(或者在简略的情况下,任何 keyPath),由于这意味着一个父类现已注册了相关告诉。

移除调查者

你能够经过向被调查目标发送 removeObserver:forKeyPath:context: 音讯来移除调查者,并指定调查者、keyPathcontext

- (void)unregisterAsObserverForAccount:(Account*)account {
    [account removeObserver:self
                 forKeyPath:@"balance"
                    context:PersonAccountBalanceContext];
    [account removeObserver:self
                 forKeyPath:@"interestRate"
                    context:PersonAccountInterestRateContext];
}

移除调查者时,请记住以下几点:

  • 假如尚未注册为调查者,则要求将其作为调查者移除会导致 NSRangeException。你能够只调用一次 removeObserver:forKeyPath:context: 来对应调用 addObserver:forKeyPath:options:context:,或者假如这在你的应用程序中不可行,请将 removeObserver:forKeyPath:context: 调用放在 try/catch 块中处理潜在的反常。
  • 调查者在开释时不会主动移除自己。被调查目标继续发送告诉,而疏忽了调查者的状态。但是,与任何其他音讯一样,发送到已开释目标的更改告诉会触发内存访问反常。因此,你要保证调查者在从内存中消失之前将自己移除。
  • 该协议无法问询目标是调查者还是被调查者。构建你的代码以避免产生相关的过错。一个典型的模式是在调查者初始化期间注册为调查者(例如在 initviewDidLoad 中)并在开释期间撤销注册(一般在 dealloc 中),保证正确配对和有序的增加和删除音讯,而且调查者在它被内存开释之前撤销注册调查。

KVO 的触发办法

KVO 触发的办法有两种:

  • 主动触发:NSObject 供给了主动触发 KVO 的基本完成。
  • 手动触发:由开发者自行控制哪些特点会触发 KVO

主动触发

// 运用访问器办法
[account setName:@"Savings"];
// 运用 setValue:forKey:.
[account setValue:@"Savings" forKey:@"name"];
// 运用 setValue:forKeyPath:
[document setValue:@"Savings" forKeyPath:@"account.name"];
// 运用 mutableArrayValueForKey: 检索联系署理目标。
Transaction *newTransaction = <#Create a new transaction for the account#>;
NSMutableArray *transactions = [account mutableArrayValueForKey:@"transactions"];
[transactions addObject:newTransaction];

手动触发

在某些情况下,你或许期望控制告诉进程,例如,尽量削减因应用程序特定原因而不必要的触发告诉,或将多个更改组合到单个告诉中。手动触发告诉供给了履行此操作的办法。

手动和主动告诉并不相互排挤。除了现已存在的主动告诉之外,你还能够自在发布手动告诉。更典型的是,你或许期望完全控制特定特点的告诉。在这种情况下,你掩盖了 automaticNotifiesObserversForKey:NSObject 完成。关于要排除其主动告诉的特点,automaticNotifiesObserversForKey: 的子类完成应该回来 NO。子类完成应该为任何无法辨认的键调用 super

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

要完成手动触发调查者告诉,你在更改值之前调用 willChangeValueForKey:,并在更改值之后调用 didChangeValueForKey:

- (void)setBalance:(double)theBalance {
    [self willChangeValueForKey:@"balance"];
    _balance = theBalance;
    [self didChangeValueForKey:@"balance"];
}

假如单个操作导致多个键更改,则有必要嵌套更改告诉:

- (void)setBalance:(double)theBalance {
    [self willChangeValueForKey:@"balance"];
    [self willChangeValueForKey:@"itemChanged"];
    _balance = theBalance;
    _itemChanged = _itemChanged+1;
    [self didChangeValueForKey:@"itemChanged"];
    [self didChangeValueForKey:@"balance"];
}

在有序一对多联系的情况下,你不仅有必要指定更改的键,还有必要指定更改的类型和所触及目标的索引。更改的类型是指定 NSKeyValueChangeInsertionNSKeyValueChangeRemovalNSKeyValueChangeReplacementNSKeyValueChange。受影响目标的索引作为 NSIndexSet 目标传递。

- (void)removeTransactionsAtIndexes:(NSIndexSet *)indexes {
    [self willChange:NSKeyValueChangeRemoval
        valuesAtIndexes:indexes forKey:@"transactions"];
    // Remove the transaction objects at the specified indexes.
    [self didChange:NSKeyValueChangeRemoval
        valuesAtIndexes:indexes forKey:@"transactions"];
}

注册依靠键

在很多情况下,一个特点的值取决于另一个目标中的一个或多个其他特点的值。假如一个特点的值产生改动,那么派生特点的值也应该被标记为改动。如何保证为这些依靠特点发布 KVO 告诉取决于联系的基数。

1对1的联系

要为1对1联系主动触发告诉,你应该掩盖 keyPathsForValuesAffectingValueForKey: 或完成一个合适的办法,该办法遵循它为注册相关键界说的模式。

例如,一个人的全名取决于姓名和姓氏。 回来全名的办法能够写成如下:

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

firstNamelastName 特点产生更改时,有必要告诉调查 fullName 特点的应用程序,由于它们会影响特点的值。

一种解决方案是掩盖 keyPathsForValuesAffectingValueForKey: 指定一个人的 fullName 特点依靠于 lastNamefirstName 特点。

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

你的办法覆写一般应该调用 super 并回来一个集合,该集合包含该集合中的任何成员(避免干扰父类中此办法的掩盖)。

你还能够经过完成遵循命名约好 keyPathsForValuesAffecting<Key> 的类办法来完成相同的成果,其中 <Key> 是依靠于值的特点的称号(首字母大写)。运用这种模式,上述代码能够重写为名为 keyPathsForValuesAffectingFullName 的类办法:

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

当你运用 category 将核算特点增加到现有类时,你不能掩盖 keyPathsForValuesAffectingValueForKey: 办法,由于你不应该掩盖类中的办法。在这种情况下,完成一个匹配的 keyPathsForValuesAffecting<Key> 类办法来使用这个机制。

KVO 完成细节

让咱们来首要完成一个简略的运用了 KVOdemo:

@interface Person : NSObject
@property (nonatomic, assign) NSUInteger age;
@end
@implementation Person
@end
@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    self.person = [Person new];
    // 增加监听
    [self.person addObserver:self 
                  forKeyPath:@"age" 
                     options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld 
                     context:nil];
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    // 改动目标特点值
    self.person.age = 18;
}
- (void)dealloc {
    // 移除监听
    [self.person removeObserver:self forKeyPath:@"age"];
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    // 被监听的特点值改动后的回调
    NSLog(@"%@", change);
}
@end

addObserver:forKeyPath:options:context: 一行打上断点,会发现在增加调查者的前后,person 目标的 isa 指针的指向产生了改动。

增加调查者前:

Key-Value Observing(KVO) 基本使用与实现原理

增加调查者后:

Key-Value Observing(KVO) 基本使用与实现原理

特别可知,KVO 是经过 runtime 动态生成 NSKVONotify_Person 类的办法,并将 person 目标的 isa 指针指向了新类,来为咱们完成 KVO 的,这套技术称之为 isa-swizzling。其中,NSKVONotify_Person 类是 Person 类的子类,NSKVONotify_Person 还重写了 class 办法,用于在咱们进行内省时,得到的是当前类的准确类。

再将断点打在 observeValueForKeyPath:ofObject:change:context: 办法中,能够看到,在接纳到告诉的进程中,调用了体系完成的 _NSSetUnsignedLongLongValueAndNotify: 办法:

Key-Value Observing(KVO) 基本使用与实现原理

正是经过该办法,完成了更新特点值+告诉调查者值有变化的功用。该办法也是 Foundation 为咱们完成的一系列针对不同类型用于在 KVO 时设值的办法,想了解该办法的底层完成能够看下面这篇文章:

iOS大解密:玄之又玄的KVO