iOS探索 KVO原理及自定義

我是好寶寶發表於2020-03-15

歡迎閱讀iOS探索系列(按序閱讀食用效果更加)

寫在前面

KVC(鍵值編碼)KVO(鍵值觀察),可能讀者老爺們都用的溜溜的,但是你真的瞭解它嗎?本文就將全方位分析KVO的原理

一、KVO初探

KVO(Key-Value Observing)是蘋果提供的一套事件通知機制,這種機制允許將其他物件的特定屬性的更改通知給物件。iOS開發者可以使用KVO 來檢測物件屬性的變化、快速做出響應,這能夠為我們在開發強互動、響應式應用以及實現檢視和模型的雙向繫結時提供大量的幫助。

Documentation Archieve中提到一句想要理解KVO,必須先理解KVC,因為鍵值觀察是建立在鍵值編碼的基礎上

In order to understand key-value observing, you must first understand key-value coding.——Key-Value Observing Programming Guide

KVONSNotificatioCenter都是iOS觀察者模式的一種實現,兩者的區別在於:

  • 相對於被觀察者和觀察者之間的關係,KVO是一對一的,NSNotificatioCenter是一對多的
  • KVO對被監聽物件無侵入性,不需要修改其內部程式碼即可實現監聽

二、KVO使用及注意點

1.基本使用

KVO使用三部曲:

  • 註冊觀察者
[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld) context:NULL];
複製程式碼
  • 實現回撥
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    if ([keyPath isEqualToString:@"name"]) NSLog(@"%@", change);
}
複製程式碼
  • 移除觀察者
[self.person removeObserver:self forKeyPath:@"name"];
複製程式碼

2.context的使用

Key-Value Observing Programming Guide是這麼描述context

iOS探索 KVO原理及自定義
訊息中的上下文指標包含任意資料,這些資料將在相應的更改通知中傳遞迴觀察者;您可以指定NULL並完全依賴鍵路徑字串來確定更改通知的來源,但是這種方法可能會導致物件的父類由於不同的原因而觀察到相同的鍵路徑,因此可能會出現問題;一種更安全,更可擴充套件的方法是使用上下文確保您收到的通知是發給觀察者的,而不是超類的。

這裡提出一個假想,如果父類中有個name屬性,子類中也有個name屬性,兩者都註冊對name的觀察,那麼僅通過keyPath已經區分不了是哪個name發生變化了,現有兩個解決辦法:

  • 多加一層判斷——判斷object,顯然為了滿足業務需求而去增加邏輯判斷是不可取的
  • 使用context傳遞資訊,更安全、更可擴充套件

context使用總結:

  • 不使用context作為觀察值
// context是 void * 型別,應該填 NULL 而不是 nil
[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];
複製程式碼
  • 使用context傳遞資訊
static void *PersonNameContext = &PersonNameContext;
static void *ChildNameContext = &ChildNameContext;

[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:PersonNameContext];
[self.child addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:ChildNameContext];

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    if (context == PersonNameContext) {
        NSLog(@"%@", change);
    } else if (context == ChildNameContext) {
        NSLog(@"%@", change);
    }
}
複製程式碼

3.移除通知的必要性

也許在日常開發中你覺得是否移除通知都無關痛癢,但是不移除會帶來潛在的隱患

以下是一段沒有移除觀察者的程式碼,頁面push前後、鍵值改變前後都很正常

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.child = [FXChild new];
    self.child.name = @"Feng";
    
    [self.child addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld) context:ChildNameContext];
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    if ([keyPath isEqualToString:@"name"]) NSLog(@"%@", change);
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    self.child.name = [NSString stringWithFormat:@"%@+", self.child.name];
}
複製程式碼

但當把FXChild單例的形式建立後,pop回上一頁再次push進來程式就崩潰了

iOS探索 KVO原理及自定義

這是因為沒有移除觀察,單例物件依舊存在,再次進來時就會報出野指標錯誤

移除了觀察者之後便不會發生這種情況了——移除觀察者是必要的

iOS探索 KVO原理及自定義

蘋果官方推薦的方式是——在init的時候進行addObserver,在deallocremoveObserver,這樣可以保證addremove是成對出現的,這是一種比較理想的使用方式

4.手動觸發鍵值觀察

有時候業務需求需要觀察某個屬性值,一會兒要觀察了,一會又不要觀察了...如果把KVO三部曲整體去掉、再整體添上,必然又是一頓繁瑣而又不必要的工作,好在KVO中有兩種辦法可以手動觸發鍵值觀察:

  • 將被觀察者的automaticallyNotifiesObserversForKey返回NO(可以只對某個屬性設定)
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
    if ([key isEqualToString:@"name"]) {
        return NO;
    }
    return [super automaticallyNotifiesObserversForKey:key];
}
複製程式碼
  • 使用willChangeValueForKeydidChangeValueForKey重寫被觀察者的屬性的setter方法

    這兩個方法用於通知系統該 key 的屬性值即將和已經變更了

- (void)setName:(NSString *)name {
    [self willChangeValueForKey:@"name"];
    _name = name;
    [self didChangeValueForKey:@"name"];
}
複製程式碼

兩種方式使用的排列組合如下,可以自由組合如何使用

情況 回撥次數
正常情況 1
automaticallyNotifiesObserversForKey為NO 0
automaticallyNotifiesObserversForKey為NO且新增willChangeValueForKey、didChangeValueForKey 1
automaticallyNotifiesObserversForKey為YES且新增willChangeValueForKey、didChangeValueForKey 2

最近發現[self willChangeValueForKey:name]和[self willChangeValueForKey:"name"]兩種寫法是不同的結果:重寫setter方法取屬性值操作不會額外傳送通知;而使用“name”會額外傳送一次通知

5.鍵值觀察多對一

比如有一個下載任務的需求,根據總下載量Total當前已下載量Current來得到當前下載進度Process,這個需求就有兩種實現:

  • 分別觀察總下載量Total當前已下載量Current兩個屬性,其中一個屬性發生變化時計算求值當前下載進度Process
  • 實現keyPathsForValuesAffectingValueForKey方法,並觀察process屬性

只要總下載量Total當前已下載量Current任意發生變化,keyPaths=process就能收到監聽回撥

+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key {
    NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
    if ([key isEqualToString:@"process"]) {
        NSArray *affectingKeys = @[@"total", @"current"];
        keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
    }
    return keyPaths;
}
複製程式碼

但僅僅是這樣還不夠——這樣只能監聽到回撥,但還沒有完成Process賦值——需要重寫getter方法

- (NSString *)process {
    if (self.total == 0) {
        return @"0";
    }
    return [[NSString alloc] initWithFormat:@"%f",1.0f*self.current/self.total];
}
複製程式碼

6.可變陣列

如題:FXPerson下有一個可變陣列dataArray,現觀察之,問點選螢幕是否列印?

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.person = [FXPerson new];
    [self.person addObserver:self forKeyPath:@"dataArray" options:(NSKeyValueObservingOptionNew) context:NULL];
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    if ([keyPath isEqualToString:@"dataArray"]) NSLog(@"%@", change);
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [self.person.dataArray addObject:@"Felix"];
}
複製程式碼

答:不會

分析:

  • KVO是建立在KVC的基礎上的,而可變陣列直接新增是不會呼叫Setter方法
  • 可變陣列dataArray沒有初始化,直接新增會報錯
// 初始化可變陣列
self.person.dataArray = @[].mutableCopy;
// 呼叫setter方法
[[self.person mutableArrayValueForKey:@"dataArray"] addObject:@"Felix"];
複製程式碼

三、KVO原理——isa-swizzling

1.官方解釋

iOS探索 KVO原理及自定義
Key-Value Observing Programming Guide中有一段底層實現原理的敘述

  • KVO是使用isa-swizzling技術實現的
  • 顧名思義,isa指標指向維護分配表的物件的類,該分派表實質上包含指向該類實現的方法的指標以及其他資料
  • 在為物件的屬性註冊觀察者時,將修改觀察物件的isa指標,指向中間類而不是真實類。isa指標的值不一定反映例項的實際類
  • 您永遠不應依靠isa指標來確定類成員身份。相反,您應該使用class方法來確定物件例項的類

2.程式碼探索

這段話說的雲裡霧裡的,還是敲程式碼見真章吧

  • 註冊觀察者之前:類物件為FXPerson,例項物件isa指向FXPerson
    iOS探索 KVO原理及自定義
  • 註冊觀察者之後:類物件為FXPerson,例項物件isa指向NSKVONotifying_FXPerson
    iOS探索 KVO原理及自定義

從這兩圖中可以得出一個結論:觀察者註冊前後FXPerson類沒發生變化,但例項物件的isa指向發生變化

那麼這個動態生成的中間類NSKVONotifying_FXPersonFXPerson是什麼關係呢?

在註冊觀察者前後分別呼叫列印子類的方法——發現NSKVONotifying_FXPersonFXPerson的子類

iOS探索 KVO原理及自定義

3.動態子類探索

①首先得明白動態子類觀察的是什麼?下面觀察屬性變數name成員變數nickname來找區別

兩個變數同時發生變化,但只有屬性變數監聽到回撥——說明動態子類觀察的是setter方法

iOS探索 KVO原理及自定義

②通過runtime-API列印一下動態子類和觀察類的方法

- (void)printClassAllMethod:(Class)cls {
    unsigned int count = 0;
    Method *methodList = class_copyMethodList(cls, &count);
    for (int i = 0; i<count; i++) {
        Method method = methodList[i];
        SEL sel = method_getName(method);
        IMP imp = class_getMethodImplementation(cls, sel);
        NSLog(@"%@-%p",NSStringFromSelector(sel),imp);
    }
    free(methodList);
}
複製程式碼

iOS探索 KVO原理及自定義
通過列印可以看出:

  • FXPerson類中的方法沒有改變(imp實現地址沒有變化)
  • NSKVONotifying_FXPerson類中重寫了父類FXPersondealloc方法
  • NSKVONotifying_FXPerson類中重寫了基類NSObjectclass方法和_isKVOA方法
    • 重寫的class方法可以指回FXPerson類
  • NSKVONotifying_FXPerson類中重寫了父類FXPersonsetName方法
    • 因為子類只繼承、不重寫是不會有方法imp的,呼叫方法時會問父類要方法實現
    • 且兩個setName的地址指標不一樣
    • 每觀察一個屬性變數就重寫一個setter方法(可自行論證)

dealloc之後isa指向誰?——指回原類

iOS探索 KVO原理及自定義

dealloc之後動態子類會銷燬嗎?——不會

頁面pop後再次push進來列印FXPerson類,子類NSKVONotifying_FXPerson類依舊存在

iOS探索 KVO原理及自定義

automaticallyNotifiesObserversForKey是否會影響動態子類生成——會

動態子類會根據觀察屬性的automaticallyNotifiesObserversForKey的布林值來決定是否生成

4.總結

  1. automaticallyNotifiesObserversForKeyYES時註冊觀察屬性會生成動態子類NSKVONotifying_XXX
  2. 動態子類觀察的是setter方法
  3. 動態子類重寫了觀察屬性的setter方法、deallocclass_isKVOA方法
    • setter方法用於觀察鍵值
    • dealloc方法用於釋放時對isa指向進行操作
    • class方法用於指回動態子類的父類
    • _isKVOA用來標識是否是在觀察者狀態的一個標誌位
  4. dealloc之後isa指向元類
  5. dealloc之後動態子類不會銷燬

四、自定義KVO

根據KVO的官方文件和上述結論,我們將自定義KVO——下面的自定義會有runtime-API的使用和介面設計思路的講解,最終的自定義KVO能滿足基本使用的需求但仍不完善。系統的KVO回撥和自動移除觀察者都與註冊邏輯分層,自定義的KVO將使用block回撥和自動釋放來優化這一點不足

新建一個NSObject+FXKVO的分類,開放註冊觀察者方法

-(void)fx_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath block:(FXKVOBlock)block;

1.註冊觀察者

  1. 判斷當前觀察值keypath是否存在/setter方法是否存在

一開始想的是判斷屬性是否存在,雖然父類的屬性不會對子類造成影響,但是分類中的屬性雖然沒有setter方法,但是會新增到propertiList中去——最終改為去判斷setter方法

if (keyPath == nil || keyPath.length == 0) return;
// if (![self isContainProperty:keyPath]) return;
if (![self isContainSetterMethodFromKeyPath:keyPath]) return;

// 判斷屬性是否存在
- (BOOL)isContainProperty:(NSString *)keyPath {
    unsigned int number;
    objc_property_t *propertiList = class_copyPropertyList([self class], &number);
    for (unsigned int i = 0; i < number; i++) {
        const char *propertyName = property_getName(propertiList[i]);
        NSString *propertyString = [NSString stringWithUTF8String:propertyName];
        
        if ([keyPath isEqualToString:propertyString]) return YES;
    }
    free(propertiList);
    return NO;
}

/// 判斷setter方法
- (BOOL)isContainSetterMethodFromKeyPath:(NSString *)keyPath {
    Class superClass    = object_getClass(self);
    SEL setterSeletor   = NSSelectorFromString(setterForGetter(keyPath));
    Method setterMethod = class_getInstanceMethod(superClass, setterSeletor);
    if (!setterMethod) {
        NSLog(@"沒找到該屬性的setter方法%@", keyPath);
        return NO;
    }
    return YES;
}
複製程式碼
  1. 判斷觀察屬性的automaticallyNotifiesObserversForKey方法返回的布林值
BOOL isAutomatically = [self fx_performSelectorWithMethodName:@"automaticallyNotifiesObserversForKey:" keyPath:keyPath];
if (!isAutomatically) return;

// 動態呼叫類方法
- (BOOL)fx_performSelectorWithMethodName:(NSString *)methodName keyPath:(id)keyPath {

    if ([[self class] respondsToSelector:NSSelectorFromString(methodName)]) {

#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
        BOOL i = [[self class] performSelector:NSSelectorFromString(methodName) withObject:keyPath];
        return i;
#pragma clang diagnostic pop
    }
    return NO;
}
複製程式碼
  1. 動態生成子類,新增class方法指向原先的類
// 動態生成子類
Class newClass = [self createChildClassWithKeyPath:keyPath];

- (Class)createChildClassWithKeyPath:(NSString *)keyPath {
    NSString *oldClassName = NSStringFromClass([self class]);
    NSString *newClassName = [NSString stringWithFormat:@"%@%@", kFXKVOPrefix, oldClassName];
    Class newClass = NSClassFromString(newClassName);
    // 防止重複建立生成新類
    if (newClass) return newClass;
    
    // 申請類
    newClass = objc_allocateClassPair([self class], newClassName.UTF8String, 0);
    // 註冊類
    objc_registerClassPair(newClass);
    // class的指向是FXPerson
    SEL classSEL = NSSelectorFromString(@"class");
    Method classMethod = class_getInstanceMethod([self class], classSEL);
    const char *classTypes = method_getTypeEncoding(classMethod);
    class_addMethod(newClass, classSEL, (IMP)fx_class, classTypes);
    
    return newClass;
}
複製程式碼
  1. isa重指向——使物件的isa的值指向動態子類
object_setClass(self, newClass);
複製程式碼
  1. 儲存資訊

由於可能會觀察多個屬性值,所以以屬性值-模型的形式一一儲存在陣列中

typedef void(^FXKVOBlock)(id observer,NSString *keyPath,id oldValue,id newValue);

@interface FXKVOInfo : NSObject
@property (nonatomic, weak) NSObject *observer;
@property (nonatomic, copy) NSString *keyPath;
@property (nonatomic, copy) FXKVOBlock handleBlock;
@end

@implementation FXKVOInfo

- (instancetype)initWitObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath handleBlock:(FXKVOBlock)block {
    if (self=[super init]) {
        _observer = observer;
        _keyPath  = keyPath;
        _handleBlock = block;
    }
    return self;
}
@end

// 儲存資訊
FXKVOInfo *info = [[FXKVOInfo alloc] initWitObserver:observer forKeyPath:keyPath handleBlock:block];
NSMutableArray *mArray = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(kFXKVOAssiociateKey));
if (!mArray) {
    mArray = [NSMutableArray arrayWithCapacity:1];
    objc_setAssociatedObject(self, (__bridge const void * _Nonnull)(kFXKVOAssiociateKey), mArray, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
[mArray addObject:info];
複製程式碼

2.新增setter方法並回撥

往動態子類新增setter方法

- (Class)createChildClassWithKeyPath:(NSString *)keyPath {
    ...
    // 新增setter
    SEL setterSEL = NSSelectorFromString(setterForGetter(keyPath));
    Method setterMethod = class_getInstanceMethod([self class], setterSEL);
    const char *setterTypes = method_getTypeEncoding(setterMethod);
    class_addMethod(newClass, setterSEL, (IMP)fx_setter, setterTypes);
    
    return newClass;
}
複製程式碼

setter方法的具體實現

static void fx_setter(id self,SEL _cmd,id newValue) {
    NSString *keyPath = getterForSetter(NSStringFromSelector(_cmd));
    id oldValue = [self valueForKey:keyPath];
    
    // 改變父類的值 --- 可以強制型別轉換
    void (*lg_msgSendSuper)(void *,SEL , id) = (void *)objc_msgSendSuper;
    struct objc_super superStruct = {
        .receiver = self,
        .super_class = class_getSuperclass(object_getClass(self)),
    };
    lg_msgSendSuper(&superStruct,_cmd,newValue);
    
    // 資訊資料回撥
    NSMutableArray *mArray = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(kFXKVOAssiociateKey));
    
    for (FXKVOInfo *info in mArray) {
        if ([info.keyPath isEqualToString:keyPath] && info.handleBlock) {
            info.handleBlock(info.observer, keyPath, oldValue, newValue);
        }
    }
}
複製程式碼

3.銷燬觀察者

往動態子類新增dealloc方法

- (Class)createChildClassWithKeyPath:(NSString *)keyPath {
    ...
    // 新增dealloc
    SEL deallocSEL = NSSelectorFromString(@"dealloc");
    Method deallocMethod = class_getInstanceMethod([self class], deallocSEL);
    const char *deallocTypes = method_getTypeEncoding(deallocMethod);
    class_addMethod(newClass, deallocSEL, (IMP)fx_dealloc, deallocTypes);
    
    return newClass;
}
複製程式碼

由於頁面釋放時會釋放持有的物件,物件釋放時會呼叫dealloc,現在往動態子類的dealloc方法名中新增實現將isa指回去,從而在釋放時就不會去找父類要方法實現

static void fx_dealloc(id self, SEL _cmd) {
    Class superClass = [self class];
    object_setClass(self, superClass);
}
複製程式碼

但僅僅是這樣還是不夠的,只把isa指回去,但物件不會呼叫真正的dealloc方法,物件不會釋放

出於這種情況,根據iOS探索 runtime面試題分析講過的方法交換進行一波操作

  • 取出基類NSObject的dealloc實現與fx_dealloc進行方法交換
  • isa指回去之後繼續呼叫真正的dealloc進行釋放
  • 之所以不在+load方法中進行交換,一是因為效率低,二是因為會影響到所有類
- (Class)createChildClassWithKeyPath:(NSString *)keyPath {
    ...
    // 新增dealloc
//    SEL deallocSEL = NSSelectorFromString(@"dealloc");
//    Method deallocMethod = class_getInstanceMethod([self class], deallocSEL);
//    const char *deallocTypes = method_getTypeEncoding(deallocMethod);
//    class_addMethod(newClass, deallocSEL, (IMP)fx_dealloc, deallocTypes);
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        [self FXMethodSwizzlingWithClass:[self class] oriSEL:NSSelectorFromString(@"dealloc") swizzledSEL:@selector(fx_dealloc)];
    });
    
    return newClass;
}

- (void)fx_dealloc {
    Class superClass = [self class];
    object_setClass(self, superClass);
    [self fx_dealloc];
}
複製程式碼

就這樣自定義KVO將KVO三部曲用block形式合成一步

寫在後面

本文demoJ_Knight_寫的SJKVOControllerFBKVO(建議看看這個成熟的自定義KVO)

最近在掘金上看到一個沸點——“很多人明白原理,但到了真正敲程式碼的時候就不會了”

學習如同踩坑爬坑,有些坑看過別人踩過,自己不去嘗試過都不知道是怎麼回事。或許你會有抓耳撓腮迷惑的時候,但是你不去解決困難,困難永遠會擋在你成長的路上

你要悄悄拔尖,然後驚豔所有人?——————與君共勉

相關文章