6-6.【OC】【KVC/KVO】KVC 能否访问非对象类型(struct / scalar)?底层原理是什么?

2 阅读3分钟

简单来说:可以,完全没问题。 KVC 的强大之处就在于它打破了“对象”与“非对象”之间的边界,让你能像处理对象一样处理 intbool 甚至是自定义的 CGRect

在底层,KVC 靠的是一套严密的 “自动装箱/拆箱(Boxing/Unboxing)” 机制和 Runtime 类型编码解析


1. 访问非对象类型的表现

当你使用 KVC 时,所有的非对象类型都会被自动包装成 NSNumberNSValue

  • 标量 (Scalar): int, float, BOOL, NSInteger 等,会被包装成 NSNumber
  • 结构体 (Struct): CGRect, CGPoint, CGSize 等,会被包装成 NSValue

Objective-C

// 1. 设值:传入的是对象(NSNumber),底层存的是标量(int)
[person setValue:@25 forKey:@"age"]; 

// 2. 取值:底层取的是标量,返回的是对象(NSValue)
NSValue *rectValue = [view valueForKey:@"frame"];
CGRect frame = [rectValue rectValue];

2. 底层原理:自动装箱与拆箱

KVC 能够实现这种“跨界访问”,主要分为三个技术环节:

A. 获取类型编码 (Type Encoding)

当调用 setValue:forKey: 时,KVC 首先通过 Runtime 函数(如 ivar_getTypeEncodingmethod_getTypeEncoding)获取该属性的 Type Encoding

  • 如果属性是 int,编码是 "i"
  • 如果属性是 CGRect,编码是 "{CGRect={CGPoint=dd}{CGSize=dd}}"

B. 拆箱 (Unboxing) —— 设值过程

如果 KVC 发现目标地址是标量或结构体,它会检查你传入的参数:

  1. 校验类型: 检查传入的对象是否是 NSNumberNSValue

  2. 提取原始数据: * 如果是 NSNumber,根据类型编码调用对应的 intValuefloatValue

    • 如果是 NSValue,调用 getValue: 将二进制数据拷贝出来。
  3. 内存写入: 根据 Ivar 的偏移量(Offset),直接将提取出的原始字节写入对象的内存地址。

C. 装箱 (Boxing) —— 取值过程

调用 valueForKey: 时,流程反过来:

  1. 读取原始数据: 根据偏移量从内存中读取对应的字节。
  2. 创建包装对象: 根据类型编码,动态创建一个 NSNumberNSValue,将原始数据封装进去并返回。

3. 特殊边界处理

对于非对象类型,KVC 有两个非常特殊的底层拦截点:

处理 nil 赋值:setNilValueForKey:

如果你尝试 [obj setValue:nil forKey:@"age"]ageint),KVC 会发现无法将 nil 拆箱为基本类型。

  • 默认行为: 抛出 NSInvalidArgumentException 并崩溃。
  • 底层原理: 在崩溃前,它会调用 setNilValueForKey:。你可以通过重写此方法,在给基本类型赋 nil 时提供一个默认值(比如 0),从而避免崩溃。

验证逻辑:validateValue:forKey:error:

这是 KVC 提供的可选接口。在底层,它可以让你在数据真正写入非对象内存之前,检查这个数值是否在合法范围内(例如 age 不能为负数)。


4. 性能考量

虽然 KVC 访问非对象类型非常方便,但它的底层开销比直接访问要大得多:

  1. 创建包装对象: 每次取值都要在堆上创建一个 NSNumber/NSValue
  2. 字符串解析: 每次都要解析 Key 字符串和类型编码字符串。
  3. 反射开销: 涉及多次 Runtime 查找。
访问方式性能转换成本
点语法 / 直接访问极快
KVC较慢需要装箱/拆箱