美文网首页收藏iosiOS底层
iOS之武功秘籍⑫: KVO原理及自定义

iOS之武功秘籍⑫: KVO原理及自定义

作者: 長茳 | 来源:发表于2021-02-27 21:24 被阅读0次

    iOS之武功秘籍 文章汇总

    写在前面

    说到KVC(键值编码)KVO(键值观察),可能大家都用的溜溜的,但是你真的了解它吗?本文就将全方位分析KVO的原理

    本节可能用到的秘籍Demo

    一、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

    在iOS日常开发中,经常使用KVO来监听对象属性的变化,并及时做出响应,即当指定的被观察的对象的属性被修改后,KVO会自动通知相应的观察者,那么KVONSNotificatioCenter有什么区别呢?

    • 相同点
      • 1、两者的实现原理都是观察者模式,都是用于监听
      • 2、都能实现一对多的操作
    • 不同点
      • 1、KVO只能用于监听对象属性的变化,并且属性名都是通过NSString来查找,编译器不会帮你检测对错和补全,纯手敲会比较容易出错
      • 2、NSNotification的发送监听(post)的操作我们可以控制,KVO由系统控制
      • 3、KVO可以记录新旧值变化

    二、KVO使用及注意点

    ①.基本使用

    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"];
      
      

    ②.context的使用

    Key-Value Observing Programming Guide是这么描述context


    消息中的上下文指针包含任意数据,这些数据将在相应的更改通知中传递回观察者;您可以指定NULL并完全依赖键路径字符串来确定更改通知的来源,但是这种方法可能会导致对象的父类由于不同的原因而观察到相同的键路径,因此可能会出现问题;一种更安全,更可扩展的方法是使用上下文确保您收到的通知是发给观察者的,而不是超类的.

    这里提出一个假想,如果父类中有个name属性,子类中也有个name属性,两者都注册对name的观察,那么仅通过keyPath已经区分不了是哪个name发生变化了,现有两个解决办法

    • 多加一层判断——判断object,显然为了满足业务需求而去增加逻辑判断是不可取的
    • 使用context传递信息,更安全、更可扩展

    通俗的讲,context上下文主要是用于区分不同对象的同名属性,从而在KVO回调方法中可以直接使用context进行区分,可以大大提升性能,以及代码的可读性

    context使用总结:

    • 不使用context作为观察值

      // context是 void * 类型,应该填 NULL 而不是 nil
      [self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];
      
      
    • 使用context传递信息

      //定义context
      static void *PersonNameContext = &PersonNameContext;
      static void *StudentNameContext = &StudentNameContext;
      //注册观察者
      [self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:PersonNameContext];
      [self.child addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:StudentNameContext];
      //KVO回调
      - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
          if (context == PersonNameContext) {
              NSLog(@"%@", change);
          } else if (context == StudentNameContext) {
              NSLog(@"%@", change);
          }
      }
      
      

    ③.移除通知的必要性

    也许在日常开发中你觉得是否移除通知都无关痛痒,但是不移除会带来潜在的隐患

    以下是一段没有移除观察者的代码,页面push前后、键值改变前后都很正常

    - (void)viewDidLoad {
        [super viewDidLoad];
        
        self.student = [TCJStudent new];
        
        [self.student 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.student.name = @"葵花宝典";
    }
    
    

    但当把TCJStudent以单例的形式创建后,先点击屏幕在push到TCJDetailViewController页面点击屏幕后pop返回上一页面再次点击屏幕程序就崩溃了

    这是因为没有移除观察,单例对象依旧存在,再次进来点击屏幕时就会报出野指针错误了

    移除了观察者之后便不会发生这种情况了——移除观察者是必要的

    苹果官方推荐的方式是 —— 在init的时候进行addObserver,在deallocremoveObserver,这样可以保证addremove是成对出现的,这是一种比较理想的使用方式

    ④.手动触发键值观察

    有时候业务需求需要观察某个属性值,一会儿要观察了,一会又不要观察了...如果把KVO三部曲整体去掉、再整体添上,必然又是一顿繁琐而又不必要的工作,好在KVO中有两种办法可以手动触发键值观察:

    • 将被观察者的automaticallyNotifiesObserversForKey返回NO(可以只对某个属性设置)-- 自动开关,返回NO,就监听不到,返回YES,表示监听

      + (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"];
      }
      
      

    两种方式使用的排列组合如下,可以自由组合如何使用


    最近发现[self willChangeValueForKey:name]和[self willChangeValueForKey:"name"]两种写法是不同的结果:重写setter方法取属性值操作不会额外发送通知;而使用“name”会额外发送一次通知

    ⑤.键值观察 一对多

    KVO观察中的一对多,意思是通过注册一个KVO观察者,可以监听多个属性的变化

    比如有一个下载任务的需求,根据总下载量totalData和当前已下载量writtenData来得到当前下载进度downloadProgress,这个需求就有两种实现方式:

    • 分别观察总下载量totalData和当前已下载量writtenData两个属性,其中一个属性发生变化时计算求值当前下载进度downloadProgress
    • 实现keyPathsForValuesAffectingValueForKey方法,并观察downloadProgress属性

    只要总下载量totalData或当前已下载量writtenData任意发生变化,keyPaths=downloadProgress就能收到监听回调

    + (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key{
        
        NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
        if ([key isEqualToString:@"downloadProgress"]) {
            NSArray *affectingKeys = @[@"totalData", @"writtenData"];
            keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
        }
        return keyPaths;
    }
    

    但仅仅是这样还不够——这样只能监听到回调,但还没有完成downloadProgress赋值——需要重写getter方法

    - (NSString *)downloadProgress{
        if (self.writtenData == 0) {
            self.writtenData = 10;
        }
        if (self.totalData == 0) {
            self.totalData = 100;
        }
        return [[NSString alloc] initWithFormat:@"%f",1.0f*self.writtenData/self.totalData];
    }
    

    ⑥.KVO观察 可变数组

    如题:TCJPerson下有一个可变数组dataArray,现观察之,问点击屏幕是否打印?

    - (void)viewDidLoad {
        [super viewDidLoad];
        
        self.person = [TCJPerson 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:@"1"];
    }
    
    

    答:不会
    分析:

    • KVO是建立在KVC的基础上的,而可变数组直接添加是不会调用Setter方法
    • 可变数组dataArray没有初始化,直接添加会报错
    // 初始化可变数组
    self.person.dataArray = @[].mutableCopy;
    // 调用setter方法
    [[self.person mutableArrayValueForKey:@"dataArray"] addObject:@"2"];
    
    

    三、KVO原理——isa-swizzling

    ①.官方解释

    Key-Value Observing Programming Guide中有一段底层实现原理的叙述

    • KVO是使用isa-swizzling技术实现的
    • 顾名思义,isa指针指向维护分配表的对象的类,该分派表实质上包含指向该类实现的方法的指针以及其他数据
    • 在为对象的属性注册观察者时,将修改观察对象的isa指针,指向中间类而不是真实类。isa指针的值不一定反映实例的实际类
    • 您永远不应依靠isa指针来确定类成员身份。相反,您应该使用class方法来确定对象实例的类

    ②.代码探索

    这段话说的云里雾里的,还是敲代码见真章吧

    • 注册观察者之前:类对象为TCJPerson,实例对象isa指向TCJPerson
    • 注册观察者之后:类对象为TCJPerson,实例对象isa指向NSKVONotifying_TCJPerson

    从这两图中可以得出一个结论:观察者注册前后TCJPerson类没发生变化,但实例对象的isa指向发生变化

    那么这个动态生成的中间类NSKVONotifying_TCJPersonTCJPerson是什么关系呢?

    在注册观察者前后分别调用打印子类的方法——发现NSKVONotifying_TCJPersonTCJPerson的子类

    ③.动态子类探索

    ➊首先得明白动态子类观察的是什么?下面观察属性变量nickName和成员变量name来找区别

    两个变量同时发生变化,但只有属性变量监听到回调——说明动态子类观察的是setter方法

    ➋通过runtime-API打印一下动态子类和观察类的方法

    #pragma mark - 遍历方法-ivar-property
    - (void)printClassAllMethod:(Class)cls{
        NSLog(@"*********************");
        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);
    }
    
    

    通过打印可以看出:

    • TCJPerson类中的方法没有改变(imp实现地址没有变化)
    • NSKVONotifying_TCJPerson类中重写了父类TCJPersondealloc方法
    • NSKVONotifying_TCJPerson类中重写了基类NSObjectclass方法和_isKVOA方法
      • 重写的class方法可以指回TCJPerson
    • NSKVONotifying_TCJPerson类中重写了父类TCJPersonsetNickName方法
      • 因为子类只继承、不重写是不会有方法imp的,调用方法时会问父类要方法实现
      • 且两个setNickName的地址指针不一样
      • 每观察一个属性变量就重写一个setter方法(可自行论证)

    dealloc之后isa指向谁?——指回原类

    dealloc之后动态子类会销毁吗?——不会

    页面pop后再次push进来打印TCJPerson类,子类NSKVONotifying_TCJPerson类依旧存在

    automaticallyNotifiesObserversForKey是否会影响动态子类生成——会

    动态子类会根据观察属性的automaticallyNotifiesObserversForKey的布尔值来决定是否生成

    ④.总结

    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+TCJKVO的分类,开放注册观察者方法

    -(void)cj_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath block:(TCJKVOBlock)block;
    

    ①.注册观察者

    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;
    }
    
    

    2.判断观察属性的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;
    }
    
    

    3.动态生成子类,添加class方法指向原先的类

    // 动态生成子类
    Class newClass = [self createChildClassWithKeyPath:keyPath];
    
    - (Class)createChildClassWithKeyPath:(NSString *)keyPath {
        NSString *oldClassName = NSStringFromClass([self class]);
        NSString *newClassName = [NSString stringWithFormat:@"%@%@", kTCJKVOPrefix, oldClassName];
        Class newClass = NSClassFromString(newClassName);
        // 防止重复创建生成新类
        if (newClass) return newClass;
        
        // 申请类
        newClass = objc_allocateClassPair([self class], newClassName.UTF8String, 0);
        // 注册类
        objc_registerClassPair(newClass);
        // class的指向是TCJPerson
        SEL classSEL = NSSelectorFromString(@"class");
        Method classMethod = class_getInstanceMethod([self class], classSEL);
        const char *classTypes = method_getTypeEncoding(classMethod);
        class_addMethod(newClass, classSEL, (IMP)cj_class, classTypes);
        
        return newClass;
    }
    
    

    4.isa重指向——使对象的isa的值指向动态子类

    object_setClass(self, newClass);
    

    5.保存信息
    由于可能会观察多个属性值,所以以属性值-模型的形式一一保存在数组中

    typedef void(^TCJKVOBlock)(id observer,NSString *keyPath,id oldValue,id newValue);
    
    @interface TCJKVOInfo : NSObject
    @property (nonatomic, weak) NSObject *observer;
    @property (nonatomic, copy) NSString *keyPath;
    @property (nonatomic, copy) TCJKVOBlock handleBlock;
    @end
    
    @implementation TCJKVOInfo
    
    - (instancetype)initWitObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath handleBlock:(TCJKVOBlock)block {
        if (self=[super init]) {
            _observer = observer;
            _keyPath  = keyPath;
            _handleBlock = block;
        }
        return self;
    }
    @end
    
    // 保存信息
    TCJKVOInfo *info = [[TCJKVOInfo alloc] initWitObserver:observer forKeyPath:keyPath handleBlock:block];
    NSMutableArray *mArray = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(kTCJKVOAssiociateKey));
    if (!mArray) {
        mArray = [NSMutableArray arrayWithCapacity:1];
        objc_setAssociatedObject(self, (__bridge const void * _Nonnull)(kTCJKVOAssiociateKey), mArray, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }
    [mArray addObject:info];
    
    

    ②.添加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)cj_setter, setterTypes);
        
        return newClass;
    }
    
    

    setter方法的具体实现

    static void cj_setter(id self,SEL _cmd,id newValue) {
        NSString *keyPath = getterForSetter(NSStringFromSelector(_cmd));
        id oldValue = [self valueForKey:keyPath];
        
        // 改变父类的值 --- 可以强制类型转换
        void (*cj_msgSendSuper)(void *,SEL , id) = (void *)objc_msgSendSuper;
        struct objc_super superStruct = {
            .receiver = self,
            .super_class = class_getSuperclass(object_getClass(self)),
        };
        cj_msgSendSuper(&superStruct,_cmd,newValue);
        
        // 信息数据回调
        NSMutableArray *mArray = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(kTCJKVOAssiociateKey));
        
        for (FXKVOInfo *info in mArray) {
            if ([info.keyPath isEqualToString:keyPath] && info.handleBlock) {
                info.handleBlock(info.observer, keyPath, oldValue, newValue);
            }
        }
    }
    
    

    ③.销毁观察者

    往动态子类添加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)cj_dealloc, deallocTypes);
        
        return newClass;
    }
    
    

    由于页面释放时会释放持有的对象,对象释放时会调用dealloc,现在往动态子类的dealloc方法名中添加实现将isa指回去,从而在释放时就不会去找父类要方法实现

    static void cj_dealloc(id self, SEL _cmd) {
        Class superClass = [self class];
        object_setClass(self, superClass);
    }
    
    

    但仅仅是这样还是不够的,只把isa指回去,但对象不会调用真正的dealloc方法,对象不会释放

    出于这种情况,根据iOS之武功秘籍⑩: OC底层题目分析讲过的方法交换进行一波操作

    • 取出基类NSObject的dealloc实现与cj_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)cj_dealloc, deallocTypes);
        static dispatch_once_t onceToken;
        dispatch_once(&onceToken, ^{
            [self cj_methodSwizzlingWithClass:[self class] oriSEL:NSSelectorFromString(@"dealloc") swizzledSEL:@selector(cj_dealloc)];
        });
        
        return newClass;
    }
    
    - (void)cj_dealloc {
        Class superClass = [self class];
        object_setClass(self, superClass);
        [self cj_dealloc];
    }
    
    

    就这样自定义KVO将KVO三部曲用block形式合成一步

    写在后面

    和谐学习,不急不躁.我还是我,颜色不一样的烟火.

    相关文章

      网友评论

        本文标题:iOS之武功秘籍⑫: KVO原理及自定义

        本文链接:https://www.haomeiwen.com/subject/mvaifltx.html