iOS探索 KVO原理及自定義

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

寫在前面

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

1、KVO初探

KVO(Key-Value Observing)是蘋果提供的一套事件通知機制,這種機制容許將其餘對象的特定屬性的更改通知給對象。iOS開發者可使用KVO 來檢測對象屬性的變化、快速作出響應,這可以爲咱們在開發強交互、響應式應用以及實現視圖和模型的雙向綁定時提供大量的幫助。github

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對被監聽對象無侵入性,不須要修改其內部代碼便可實現監聽

2、KVO使用及注意點

1.基本使用

KVO使用三部曲:bash

  • 註冊觀察者
[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多線程

消息中的上下文指針包含任意數據,這些數據將在相應的更改通知中傳遞迴觀察者;您能夠指定NULL並徹底依賴鍵路徑字符串來肯定更改通知的來源,可是這種方法可能會致使對象的父類因爲不一樣的緣由而觀察到相同的鍵路徑,所以可能會出現問題;一種更安全,更可擴展的方法是使用上下文確保您收到的通知是發給觀察者的,而不是超類的。

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

  • 多加一層判斷——判斷object,顯然爲了知足業務需求而去增長邏輯判斷是不可取的
  • 使用context傳遞信息,更安全、更可擴展

context使用總結:ide

  • 不使用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進來程序就崩潰了

這是由於沒有移除觀察,單例對象依舊存在,再次進來時就會報出野指針錯誤

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

蘋果官方推薦的方式是——在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"];
複製代碼

3、KVO原理——isa-swizzling

1.官方解釋

Key-Value Observing Programming Guide中有一段底層實現原理的敘述

  • KVO是使用isa-swizzling技術實現的
  • 顧名思義,isa指針指向維護分配表的對象的類,該分派表實質上包含指向該類實現的方法的指針以及其餘數據
  • 在爲對象的屬性註冊觀察者時,將修改觀察對象的isa指針,指向中間類而不是真實類。isa指針的值不必定反映實例的實際類
  • 您永遠不該依靠isa指針來肯定類成員身份。相反,您應該使用class方法來肯定對象實例的類

2.代碼探索

這段話說的雲裏霧裏的,仍是敲代碼見真章吧

  • 註冊觀察者以前:類對象爲FXPerson,實例對象isa指向FXPerson
  • 註冊觀察者以後:類對象爲FXPerson,實例對象isa指向NSKVONotifying_FXPerson

從這兩圖中能夠得出一個結論:觀察者註冊先後FXPerson類沒發生變化,但實例對象的isa指向發生變化

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

在註冊觀察者先後分別調用打印子類的方法——發現NSKVONotifying_FXPersonFXPerson的子類

3.動態子類探索

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

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

②經過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);
}
複製代碼

經過打印能夠看出:

  • FXPerson類中的方法沒有改變(imp實現地址沒有變化)
  • NSKVONotifying_FXPerson類中重寫了父類FXPersondealloc方法
  • NSKVONotifying_FXPerson類中重寫了基類NSObjectclass方法和_isKVOA方法
    • 重寫的class方法能夠指回FXPerson類
  • NSKVONotifying_FXPerson類中重寫了父類FXPersonsetName方法
    • 由於子類只繼承、不重寫是不會有方法imp的,調用方法時會問父類要方法實現
    • 且兩個setName的地址指針不同
    • 每觀察一個屬性變量就重寫一個setter方法(可自行論證)

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

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

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

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

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

4.總結

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

4、自定義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)

最近在掘金上看到一個沸點——「不少人明白原理,但到了真正敲代碼的時候就不會了」

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

你要悄悄拔尖,而後驚豔全部人🌺——————與君共勉

相關文章
相關標籤/搜索