iOS——KVO底层学习

前情回顾

什么是KVO?在之前的博客里我们已经学过:

KVO全称Key Value Observing。KVO传值允许对象监听另一个对象的特定属性,当该属性改变的时候,会触发事件。

KVO不仅可以监听单个属性的变化,也可以监听集合对象的变化。监听集合对象的时候,通过KVC的mutableArrayValueForKey:等方法获得代理对象,当代理对象的内部对象发生改变时,会回调KVO监听的方法。集合对象包含NSArray和NSSet。

  • KVO只是监听setter方法,例如像可变数组添加元素的方法(addObject)它不属于setter方法,所以即使你向数组中add多少个元素也不会有监听反应。
  • 在不使用时一定要移除KVO。

KVO具体的例子看:
IOS------多界面传值

KVO的实现

在同样一个类中,set方法都在该类里,为什么只有被添加为观察者的实例的属性变化会触发观察者的方法,而未被添加为观察者的实例的属性变化则不会?

这里就要涉及到KVO的底层实现了,让我们来了解它是如何监听的:

这里我们先写一个KVO的例子,通过点击button监听nV的str1属性:

objectivec 复制代码
@interface otherViewController : UIViewController

@property (nonatomic, strong) NSString *str1;
@property (nonatomic, strong) NSString *str2;

@end
objectivec 复制代码
#import "ViewController.h"
#import "otherViewController.h"

@interface ViewController ()

@property (nonatomic, strong)otherViewController *nV;
@property (nonatomic, strong)otherViewController *nV2;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    self.view.backgroundColor = [UIColor whiteColor];
    self.nV = [[otherViewController alloc] init];
    self.nV2 = [[otherViewController alloc] init];

    UIButton *button = [UIButton buttonWithType:UIButtonTypeSystem];
    button.frame = CGRectMake(100, 300, 200, 100);
    [button setTitle:@"touch" forState:UIControlStateNormal];
    [self.view addSubview:button];
    [button addTarget:self action:@selector(touchPress) forControlEvents:UIControlEventTouchUpInside];
    
    NSKeyValueObservingOptions optips = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
    
    [self.nV addObserver:self forKeyPath:@"str1" options:optips context:nil];
}

- (void)touchPress {
    self.nV.str1 = @"str1";
    self.nV2.str1 = @"str2";
}

在ViewController中写监听方法,这里顺便再详细解释一下这个方法和它的参数:

objectivec 复制代码
/*
  1. keyPath 是一个 NSString 类型的参数,表示被观察的属性或属性路径。例如,如果你正在观察一个 Person 对象的 name 属性,那么 keyPath 就是 @"name"。
  2. object 是一个 id 类型的参数,表示被观察的对象。
  3. change 是一个字典,键是 NSKeyValueChangeKey 枚举类型中的值,值是 id 类型。这个字典包含了关于属性变化的信息。
常见的 NSKeyValueChangeKey 枚举值有:NSKeyValueChangeKindKey: 表示变化的类型(例如,是否是设置、插入、删除等)。NSKeyValueChangeNewKey: 表示新的值。NSKeyValueChangeOldKey: 表示旧的值。
  4. context 是一个 void * 类型的参数,它是一个通用的指针,允许你传递任何类型的数据。我们可以检查这个 context 值来确定是哪个观察触发了这个方法。
 */
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    NSLog(@"监听到%@的%@属性发生了变化-%@ -%@",object, keyPath, change, context);
}

运行结果:

然后,我们使用lldb,观察nV在被监听的时候的动态变化:

我们可以发现,nV和nV2明明都是同一个类创建出来的实例对象,为什么打印出来它们的类不同呢,而且这个NSKVONotifying_otherViewController明明我们没有创建啊?

这里是因为内部被修改了,那么实际的实现过程是什么呢。我们先来看NSKVONotifying_otherViewController

当一个对象被KVO监听时,其isa指针会被动态地修改,指向一个由runtime创建的新类,这个新类的名称通常以"NSKVONotifying_"作为前缀,后面跟着被监听对象的原始类名。
这个新类是原始类的子类 ,并且系统会为这个新类重写被观察属性的setter方法。setter方法会负责在调用原始setter方法之前和之后,通知所有注册的观察者属性值的更改情况。

当一个对象被KVO监听时,其isa指针会被修改,指向上述提到的动态创建的新类。这样做是为了在不修改原始类的情况下,能够拦截到被观察属性的setter方法调用,从而通知观察者。

因此这就是为什么我们在之前学习的博客中提到:修改被观察对象的属性时,应该使用属性的setter方法(如self.property = newValue;),而不是直接访问实例变量(如_property = newValue;)。

形象的实现逻辑我们可以看下面两张图:

所以现在我们可以得知,加了kvo和没有加kvo走的并不是一套,加了kvo的走的是子类NSKVONotifying_otherViewController的set方法,并达到监听的目的。

KVO源码

在分析KVO的内部实现之前,先来分析一下KVO的存储结构,主要用到了以下几个类:

  • GSKVOInfo
  • GSKVOPathInfo
  • GSKVOObservation

GSKVOInfo

GSKVOInfo 类用于保存和管理监控特定对象的观察者及其观察路径的信息。这个类的设计目的是为了支持键值观察(KVO)机制的实现:

objectivec 复制代码
/*
 * 这个类的实例用于保存监控特定对象的观察者的信息。
 */
@interface GSKVOInfo : NSObject
{
  NSObject *instance;          // 被观察的对象实例(不被保留)。
  NSRecursiveLock *iLock;      // 递归锁,用于线程安全的访问。
  NSMapTable *paths;           // 存储观察路径的映射表。
}
@end
  • 它保存了一个对象的实例,但是它没有持有,也不是weak,所以当释放之后,在调用会崩溃,需要在对象销毁前,移除所有观察者
  • paths 用于保存keyPath 到 GSKVOPathInfo 的映射:

GSKVOPathInfo

objectivec 复制代码
/*
 * 这个类的实例记录了某个键路径的观察者以及发送通知过程中的递归状态。
 */
@interface GSKVOPathInfo : NSObject
{
@public
  unsigned recursion;               // 递归计数器,跟踪发送通知的递归深度。
  unsigned allOptions;              // 所有观察选项的位掩码,用于存储观察选项。
  NSMutableArray *observations;     // 观察者数组,存储所有与键路径相关的观察者。
  NSMutableDictionary *change;      // 变化字典,用于存储属性变化的信息。
}
@end
  • 它保存了一个keypath对应的所有观察者
  • observations保存了所有的观察者(GSKVOObservation 类型)
  • allOptions保存了观察者的options集合
  • change 保存了KVO触发要传递的内容

GSKVOObservation

objectivec 复制代码
/*
 * 这个类的实例记录了单个观察的所有信息。
 */
@interface GSKVOObservation : NSObject
{
@public
  NSObject *observer;  // 观察者对象(不被保留,使用零弱引用指针)
  void *context;       // 上下文信息,用户在添加观察者时传递的上下文
  int options;         // 观察选项,指定观察的行为
}
@end

它保存了单个观察的所有信息

  • observer保存观察者 注意这里也是 Not retained
  • context options 都是添加观察者时传入的参数

这三个类的调用流程

  1. 添加观察者

    当向一个对象添加观察者时,会创建或获取该对象对应的 GSKVOInfo 实例。

    GSKVOInfo 实例中,为特定的属性路径创建或获取一个 GSKVOPathInfo 实例。

    创建一个新的 GSKVOObservation 实例,存储观察者的信息,并将其添加到 GSKVOPathInfoobservations 数组中。

  2. 发送通知

    当属性值发生变化时,GSKVOPathInfo 实例会遍历其 observations 数组,向所有观察者发送通知。

    在通知发送过程中,会更新 recursion 属性以跟踪递归状态,确保线程安全。

  3. 移除观察者

    当移除观察者时,会从 GSKVOPathInfo 实例的 observations 数组中删除相应的 GSKVOObservation 实例。

    如果某个属性路径不再有观察者,则从 GSKVOInfo 实例的 paths 表中移除对应的 GSKVOPathInfo 实例。

KVO是通过isa-swizzling技术实现的(这句话是整个KVO实现的重点)。==在运行时根据原类创建一个中间类,这个中间类是原类的子类,并动态修改当前对象的isa指向中间类。并且将class方法重写,返回原类的Class。==所以苹果建议在开发中不应该依赖isa指针,而是通过class实例方法来获取对象类型。

为什么要重写class方法呢?

如果没有重写class方法,当该对象调用class方法时,会在自己的方法缓存列表,方法列表,父类缓存,方法列表一直向上去查找该方法,因为class方法是NSObject中的方法,如果不重写最终可能会返回NSKVONotifying_类名,就会将该类暴露出来。

由于 KVO 使用了 isa-swizzling 技术,苹果建议在开发中不应该直接依赖 isa 指针来判断对象的类型,而是应该通过 class 实例方法来获取对象的类型。这样可以避免因为 isa-swizzling 而导致的类型判断错误。

isa-swizzling

isa-swizzling技术主要通过以下几个类实现:

  • GSKVOReplacement
  • GSKVOBase
  • GSKVOSetter

GSKVOReplacement

objectivec 复制代码
/*
 * 这个类保存了关于一个类被观察时其替代子类的信息。
 */
@interface GSKVOReplacement : NSObject
{
  Class original;          /* 原始类 */
  Class replacement;       /* 替代类 */
  NSMutableSet *keys;      /* 被观察的属性键集合 */
}

- (id) initWithClass: (Class)aClass;
- (void) overrideSetterFor: (NSString*)aKey;
- (Class) replacement;
@end
  • original:保存被观察对象的原始类。
  • replacement:保存替代原始类的中间类。在 KVO 机制中,KVO 会动态创建一个中间类来替代原始类,以便拦截和处理属性变化通知。
  • keys:保存所有被观察的属性键集合。这些键表示哪些属性被观察者监听。
objectivec 复制代码
// 创建
- (id) initWithClass: (Class)aClass
{
  NSValue       *template;
  NSString      *superName;
  NSString      *name;

  original = aClass;

  /*
   * Create subclass of the original, and override some methods
   * with implementations from our abstract base class.
   */
  superName = NSStringFromClass(original);      // original == Temp
  name = [@"GSKVO" stringByAppendingString: superName];    // name = GSKVOTemp
  template = GSObjCMakeClass(name, superName, nil);   // template = GSKVOTemp
  GSObjCAddClasses([NSArray arrayWithObject: template]);
  replacement = NSClassFromString(name);
  GSObjCAddClassBehavior(replacement, baseClass);

  /* Create the set of setter methods overridden.
   */
  keys = [NSMutableSet new];

  return self;
}
  • 该方法是 GSKVOReplacement 类的初始化方法,接受一个类作为参数。
  • 使用 NSStringFromClass 函数获取原始类的类名,并生成一个新的类名(例如在原类名之前加上 GSKVO 前缀)。
  • 使用 GSObjCMakeClass 函数创建一个新的类模板,该类继承自原始类。
  • 注册新的类(替代类)并获取其 Class 对象。使用 GSObjCAddClassBehavior 函数为替代类添加行为(通常是方法实现)。
  • 初始化 keys 集合,用于存储被重写的 setter 方法。并返回初始化后的实例。

GSKVOBase

这个类默认提供了几个方法,都是对NSObject方法的重写,而从上面得知,这些方法都要拷贝到新创建的替换类中。也就是被观察者会拥有这几个方法的实现

objectivec 复制代码
@implementation	GSKVOBase

- (void) dealloc
{
  // Turn off KVO for self ... then call the real dealloc implementation.
  //对象释放后,移除KVO数据,将对象重新指向原始类
  [self setObservationInfo: nil];
  object_setClass(self, [self class]);
  [self dealloc];
  GSNOSUPERDEALLOC;
}

//此方法用来隐藏替换类信息,应用层获取类的信息,仍然是原始类的信息. 所以苹果建议在开发中不应该依赖isa指针,而是通过class实例方法来获取对象类型。
- (Class) class
{
  return class_getSuperclass(object_getClass(self));
}

/*这个方法是属于KVC中的,重写这个方法,实现在原始类KVC调用前后添加[self willChangeValueForKey: aKey]和[self didChangeValueForKey: aKey],而这两个方法是触发KVO通知的关键。
所以说KVO是基于KVC的,而KVC正是KVO触发的入口。*/
- (void) setValue: (id)anObject forKey: (NSString*)aKey
{
  Class		c = [self class];
  void		(*imp)(id,SEL,id,id);

  imp = (void (*)(id,SEL,id,id))[c instanceMethodForSelector: _cmd];

  if ([[self class] automaticallyNotifiesObserversForKey: aKey])
    {
      [self willChangeValueForKey: aKey];
      imp(self,_cmd,anObject,aKey);
      [self didChangeValueForKey: aKey];
    }
  else
    {
      imp(self,_cmd,anObject,aKey);
    }
}

//此方法和class方法原理相同
- (Class) superclass
{
  return class_getSuperclass(class_getSuperclass(object_getClass(self)));
}
@end

GSKVOSetter

objectivec 复制代码
@interface	GSKVOSetter : NSObject
- (void) setter: (void*)val;
- (void) setterChar: (unsigned char)val;
- (void) setterDouble: (double)val;
- (void) setterFloat: (float)val;
- (void) setterInt: (unsigned int)val;
- (void) setterLong: (unsigned long)val;
#ifdef  _C_LNG_LNG
- (void) setterLongLong: (unsigned long long)val;
#endif
- (void) setterShort: (unsigned short)val;
- (void) setterRange: (NSRange)val;
- (void) setterPoint: (NSPoint)val;
- (void) setterSize: (NSSize)val;
- (void) setterRect: (NSRect)rect;
@end

这个类和上面重写KVC方法原理相同,将来会替换被观察者keypath的setter方法实现。会在原始setter方法前后添加[self willChangeValueForKey: aKey][self didChangeValueForKey: aKey]

KVO流程总结

添加观察者

我们使用其源码说明:

objectivec 复制代码
@implementation	 GSKVOInfo

/* 添加观察者方法 */
- (void) addObserver: (NSObject*)anObserver
      forKeyPath: (NSString*)aPath
         options: (NSKeyValueObservingOptions)options
         context: (void*)aContext
{
  GSKVOPathInfo         *pathInfo;
  GSKVOObservation      *observation;
  unsigned              count;

  // 确认观察者实现了 observeValueForKeyPath:ofObject:change:context: 方法
  if ([anObserver respondsToSelector:
    @selector(observeValueForKeyPath:ofObject:change:context:)] == NO)
    {
      return; // 如果没有实现该方法,直接返回
    }
  
  [iLock lock]; // 加锁
  pathInfo = (GSKVOPathInfo*)NSMapGet(paths, (void*)aPath); // 获取路径信息
  if (pathInfo == nil) // 如果路径信息为空
    {
      pathInfo = [GSKVOPathInfo new]; // 创建新的路径信息
      aPath = [aPath copy]; // 复制路径字符串,使用不可变对象作为键
      NSMapInsert(paths, (void*)aPath, (void*)pathInfo); // 插入路径信息到路径映射表
      [pathInfo release]; // 释放路径信息
      [aPath release]; // 释放路径字符串
    }

  observation = nil;
  pathInfo->allOptions = 0;
  count = [pathInfo->observations count]; // 获取观察者数量
  while (count-- > 0) // 遍历所有观察者
    {
      GSKVOObservation      *o;

      o = [pathInfo->observations objectAtIndex: count]; // 获取观察者
      if (o->observer == anObserver) // 如果观察者匹配
        {
          o->context = aContext; // 更新上下文
          o->options = options; // 更新选项
          observation = o; // 记录当前观察者
        }
      pathInfo->allOptions |= o->options; // 更新路径信息的所有选项
    }
  
  if (observation == nil) // 如果当前观察者不存在
    {
      observation = [GSKVOObservation new]; // 创建新的观察者
      GSAssignZeroingWeakPointer((void**)&observation->observer, (void*)anObserver); // 分配零弱指针
      observation->context = aContext; // 设置上下文
      observation->options = options; // 设置选项
      [pathInfo->observations addObject: observation]; // 添加观察者到路径信息
      [observation release]; // 释放观察者
      pathInfo->allOptions |= options; // 更新路径信息的所有选项
    }

  if (options & NSKeyValueObservingOptionInitial) // 如果选项包含 NSKeyValueObservingOptionInitial
    {
      /* 如果设置了 NSKeyValueObservingOptionInitial 选项,
       * 必须立即发送包含现有值的通知。
       */
      [pathInfo->change setObject: [NSNumber numberWithInt: 1]
                           forKey: NSKeyValueChangeKindKey]; // 设置更改类型为插入
      if (options & NSKeyValueObservingOptionNew) // 如果选项包含 NSKeyValueObservingOptionNew
        {
          id value;

          value = [instance valueForKeyPath: aPath]; // 获取当前值
          if (value == nil) // 如果值为空
            {
              value = null; // 设置为 null
            }
          [pathInfo->change setObject: value
                               forKey: NSKeyValueChangeNewKey]; // 设置新值
        }
      [anObserver observeValueForKeyPath: aPath
                                ofObject: instance
                                  change: pathInfo->change
                                 context: aContext]; // 发送通知给观察者
    }
  [iLock unlock]; // 解锁
}
  • 检查观察者是否实现了 observeValueForKeyPath:ofObject:change:context: 方法。如果没有实现,直接返回。
  • 调用 [iLock lock]进行加锁,确保线程安全。
  • 从 paths 映射表中获取指定键路径的 GSKVOPathInfo 对象。如果不存在,则创建新的 GSKVOPathInfo 对象,并插入到映射表中。
  • 遍历 GSKVOPathInfo 对象中的所有观察者,检查是否已经存在相同的观察者。如果存在,则更新其上下文和选项,并记录下当前观察者;否则,创建新的观察者对象 GSKVOObservation 并添加到 GSKVOPathInfo 中。
  • 如果选项包含 NSKeyValueObservingOptionInitial,则立即发送包含当前值的通知给观察者。首先设置通知类型为 NSKeyValueChangeKindKey,然后根据选项设置新值(NSKeyValueChangeNewKey),并调用观察者的 observeValueForKeyPath:ofObject:change:context: 方法发送通知。
  • 调用 [iLock unlock] 进行解锁。

移除观察者

objectivec 复制代码
/*
 * 移除观察者
 */
- (void) removeObserver: (NSObject*)anObserver forKeyPath: (NSString*)aPath
{
  GSKVOPathInfo *pathInfo;

  [iLock lock]; // 加锁,确保线程安全
  pathInfo = (GSKVOPathInfo*)NSMapGet(paths, (void*)aPath); // 获取路径信息
  if (pathInfo != nil) // 如果路径信息不为空
    {
      unsigned  count = [pathInfo->observations count]; // 获取观察者数量

      pathInfo->allOptions = 0; // 重置路径信息的所有选项
      while (count-- > 0) // 反向遍历观察者列表
        {
          GSKVOObservation      *o;

          o = [pathInfo->observations objectAtIndex: count]; // 获取当前观察者
          if (o->observer == anObserver || o->observer == nil) // 如果观察者匹配或已释放
            {
              [pathInfo->observations removeObjectAtIndex: count]; // 移除观察者
              if ([pathInfo->observations count] == 0) // 如果观察者列表为空
                {
                  NSMapRemove(paths, (void*)aPath); // 从路径映射表中删除该键路径
                }
            }
          else // 如果观察者不匹配
            {
              pathInfo->allOptions |= o->options; // 更新路径信息的所有选项
            }
        }
    }
  [iLock unlock]; // 解锁
}
  • removeObserver:forKeyPath: 方法用于从指定键路径的观察者列表中移除指定的观察者。
  • 如果观察者列表为空,则从路径映射表中删除该键路径。
  • 该方法通过加锁来确保线程安全,防止在多线程环境中出现数据竞争问题。

面试题

  1. ios用什么方式实现对一个对象的kvo?(kvo的本质是什么?)

    利用runtimeAPI动态生成一个子类,并让instance对象的isa指向这个全新的类,当修改instance对象的属性时,会调用Foundation的------NSSetXXXValueAndNotify函数:(willChangeValueForKey; 父类原来的setter方法;didChangeValueForKey(内部会触发监听器(observer)的监听方法(observerValueForKeyPath:)))

  2. 如何手动触发kvo?

    (不调用setage方法)

    手动调用willChangeValueForKey 和didChangeValueForKey方法,即可直接触发kvo。

  3. 直接修改成员变量会触发KVO么?

    不会触发KVO,(添加kvo的person实例,其实是NSKVONotyfing_person类,再调用setter方法,不是调用person的setter方法,而是NSKVONotyfing_person的setter方法,因为修改成员变量不是setter方法赋值self.person->age=@"12", 所以就无所谓调用NSKVONotyfing_person类的setter方法,也就不会实现kvo。)

相关推荐
醉陌离19 分钟前
渗透测试学习笔记——shodan(3)
笔记·学习
流着口水看上帝1 小时前
JavaScript学习路线
学习
Ting丶丶1 小时前
安卓应用安装过程学习
android·学习·安全·web安全·网络安全
被猫枕的咸鱼1 小时前
项目学习:仿b站的视频网站项目03-注册功能
学习
@小博的博客1 小时前
C++初阶学习第十三弹——容器适配器和优先级队列的概念
开发语言·数据结构·c++·学习
山山而川粤2 小时前
废品买卖回收管理系统|Java|SSM|Vue| 前后端分离
java·开发语言·后端·学习·mysql
TensorFlowGAN2 小时前
华三预赛从零开始学习笔记(每日编辑,复习完为止)
笔记·学习·华三
Mephisto.java2 小时前
【大数据学习 | Spark-Core】RDD的缓存(cache and checkpoint)
大数据·学习·spark
zmd-zk3 小时前
flink学习(3)——方法的使用—对流的处理(map,flatMap,filter)
java·大数据·开发语言·学习·flink·tensorflow
安和昂3 小时前
【iOS】bug调试技巧
ios·bug·cocoa