在iOS开发中,我们常常会遇到一个场景:给系统类(如NSString、UIView)或自定义类添加"额外属性",但又不想通过继承、分类重写init方法的方式实现------这时候,关联对象(Associated Object) 就是最优解决方案。
关联对象看似简单,仅通过objc_setAssociatedObject、objc_getAssociatedObject、objc_removeAssociatedObjects三个API就能使用,但它的底层实现、内存管理逻辑却暗藏玄机,稍有不慎就会引发内存泄漏、野指针等问题。本文将从底层原理、实现细节、内存管理、实战示例四个维度,彻底讲透关联对象,帮你避开90%的坑。
前置说明:本文基于Objective-C runtime源码(objc4-818.2)展开,涉及runtime底层数据结构,搭配多个可直接运行的示例,兼顾入门理解与进阶深挖,适合iOS开发者(OC/Swift均可参考,Swift需通过OC桥接使用关联对象)。
一、先搞懂:关联对象解决了什么问题?
在OC中,类的属性本质是"ivar(实例变量)+ setter/getter方法",而分类(Category)中不能直接添加ivar------这是因为分类的结构中没有存放ivar的字段,只能添加方法、协议。
举个直观的例子:我们想给UIView添加一个"是否正在加载"的isLoading属性,直接在分类中写@property是无效的(只会生成setter/getter声明,不会生成ivar和实现):
less
#import <UIKit/UIKit.h>
@interface UIView (Loading)
// 分类中直接声明property,仅生成setter/getter声明,无ivar
@property (nonatomic, assign) BOOL isLoading;
@end
@implementation UIView (Loading)
// 不实现setter/getter,调用时会崩溃(unrecognized selector)
@end
这时候,关联对象就派上用场了:它可以给任意对象(id类型)"动态绑定"一个额外的对象/值,无需修改类的结构、无需继承,相当于给对象"临时扩展"了属性。
补充:Swift中没有分类的概念,但可以通过OC桥接,给Swift类添加关联对象;也可以直接在Swift中使用objc_setAssociatedObject等API(需导入Foundation框架)。
二、底层实现:关联对象的核心数据结构与逻辑
关联对象的底层实现依赖于Objective-C runtime的三个核心数据结构和一套哈希表管理逻辑,我们先拆解核心结构,再讲API的底层调用流程。
1. 核心数据结构(从源码中提取)
关联对象的管理,本质是通过"全局哈希表"存储对象与关联值的映射关系,核心结构有3个:
- AssociationsManager:关联对象的"管理者",全局唯一,负责管理AssociationsHashMap,提供线程安全保障(内部有自旋锁)。
- AssociationsHashMap:全局哈希表,key是"被关联的对象(id)",value是"该对象的所有关联值的哈希表(ObjectAssociationMap)"。
- ObjectAssociationMap:单个对象的关联值哈希表,key是"关联的key(const void *)",value是"关联值的包装对象(ObjcAssociation)"。
- ObjcAssociation:关联值的包装类,存储"关联值(id)"和"内存管理策略(objc_AssociationPolicy)"。
用通俗的话解释:整个iOS系统有一个"大字典"(AssociationsHashMap),里面的每一个key都是一个"被关联的对象",对应的value是这个对象的"小字典"(ObjectAssociationMap);这个"小字典"里,key是我们自己定义的关联key,value是我们要绑定的值(包装成ObjcAssociation,包含值和内存策略)。
2. 核心API的底层调用流程
我们常用的三个API,底层都是通过操作上述哈希表实现的,流程非常清晰,结合源码简化如下:
(1)objc_setAssociatedObject(设置关联对象)
csharp
void objc_setAssociatedObject(id object, const void *key, id value, objc_AssociationPolicy policy)
底层流程:
- 判断object是否为nil:若为nil,直接返回(无法给nil对象设置关联);
- 获取全局AssociationsManager实例,加自旋锁(保证线程安全);
- 从AssociationsHashMap中,根据object(key)找到对应的ObjectAssociationMap;
- 若ObjectAssociationMap不存在,创建一个新的,存入AssociationsHashMap;
- 将key和value包装成ObjcAssociation(绑定内存策略policy),存入ObjectAssociationMap;
- 释放锁,完成设置。
(2)objc_getAssociatedObject(获取关联对象)
csharp
id objc_getAssociatedObject(id object, const void *key)
底层流程:
- 判断object是否为nil:若为nil,返回nil;
- 获取全局AssociationsManager实例,加自旋锁;
- 从AssociationsHashMap中,根据object找到对应的ObjectAssociationMap;
- 若ObjectAssociationMap不存在,释放锁,返回nil;
- 根据key,从ObjectAssociationMap中找到对应的ObjcAssociation;
- 返回ObjcAssociation中的关联值,释放锁。
(3)objc_removeAssociatedObjects(移除对象的所有关联)
csharp
void objc_removeAssociatedObjects(id object)
底层流程:
- 判断object是否为nil:若为nil,直接返回;
- 获取全局AssociationsManager实例,加自旋锁;
- 从AssociationsHashMap中,删除object对应的ObjectAssociationMap;
- 释放锁,完成所有关联的移除(会根据内存策略,自动释放关联值)。
关键细节:关联对象不存储在被关联对象的内存中,而是存储在全局哈希表中。这意味着,即使被关联对象被释放,若关联值的内存策略设置不当,仍可能导致内存泄漏。
三、内存管理细节:最容易踩坑的核心点(多示例)
关联对象的内存管理,核心取决于objc_AssociationPolicy(关联策略) ,不同策略对应不同的内存管理逻辑,这也是开发中最容易出错的地方。先明确所有关联策略,再结合示例讲解细节。
1. 所有关联策略(objc_AssociationPolicy)
OC提供了5种关联策略,对应不同的内存管理方式,其中常用的是前4种:
| 关联策略 | 含义 | 内存管理逻辑 | 类比属性修饰符 |
|---|---|---|---|
| OBJC_ASSOCIATION_ASSIGN | 弱引用 | 不持有关联值,关联值释放后,关联会变成野指针 | assign |
| OBJC_ASSOCIATION_RETAIN_NONATOMIC | 强引用(非原子性) | 持有关联值,引用计数+1,线程不安全 | strong, nonatomic |
| OBJC_ASSOCIATION_COPY_NONATOMIC | 复制(非原子性) | 复制关联值,持有复制后的对象,引用计数+1,线程不安全 | copy, nonatomic |
| OBJC_ASSOCIATION_RETAIN | 强引用(原子性) | 持有关联值,引用计数+1,线程安全(加锁) | strong, atomic |
| OBJC_ASSOCIATION_COPY | 复制(原子性) | 复制关联值,持有复制后的对象,引用计数+1,线程安全(加锁) | copy, atomic |
2. 关键内存管理细节(结合示例)
以下示例均为开发中高频场景,重点讲解"策略选择""内存泄漏规避""野指针避免"三个核心细节。
示例1:基础用法------给UIView分类添加属性(正确示范)
需求:给UIView添加isLoading(BOOL)和loadingView(UIView)两个关联属性,isLoading用assign,loadingView用strong(非原子性)。
objectivec
#import <UIKit/UIKit.h>
@interface UIView (Loading)
@property (nonatomic, assign) BOOL isLoading;
@property (nonatomic, strong) UIView *loadingView;
@end
@implementation UIView (Loading)
// 定义关联key(必须是全局唯一,推荐用static const void *)
static const void *kIsLoadingKey = &kIsLoadingKey;
static const void *kLoadingViewKey = &kLoadingViewKey;
// isLoading的setter/getter
- (void)setIsLoading:(BOOL)isLoading {
// 策略用OBJC_ASSOCIATION_ASSIGN(对应assign修饰符)
objc_setAssociatedObject(self, kIsLoadingKey, @(isLoading), OBJC_ASSOCIATION_ASSIGN);
}
- (BOOL)isLoading {
// 注意:关联值是NSNumber,需要解包
return [objc_getAssociatedObject(self, kIsLoadingKey) boolValue];
}
// loadingView的setter/getter
- (void)setLoadingView:(UIView *)loadingView {
// 策略用OBJC_ASSOCIATION_RETAIN_NONATOMIC(对应strong, nonatomic)
objc_setAssociatedObject(self, kLoadingViewKey, loadingView, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
- (UIView *)loadingView {
return objc_getAssociatedObject(self, kLoadingViewKey);
}
@end
关键细节:
- 关联key必须全局唯一:推荐用static const void *定义(如上述kIsLoadingKey),避免用字符串(如@"isLoading")------字符串可能存在哈希冲突,且效率更低。
- 基本类型(BOOL、int、float等)需包装成对象:关联对象只能存储id类型,所以BOOL要包装成NSNumber,获取时再解包。
- 策略匹配属性修饰符:assign对应OBJC_ASSOCIATION_ASSIGN,strong对应OBJC_ASSOCIATION_RETAIN_NONATOMIC,copy对应OBJC_ASSOCIATION_COPY_NONATOMIC。
示例2:坑点------强引用循环导致内存泄漏(高频错误)
关联对象最容易出现的问题:被关联对象与关联值之间形成强引用循环,导致两者都无法被释放。
错误示例(强引用循环):
objectivec
#import <UIKit/UIKit.h>
@interface ViewController : UIViewController
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
UIView *testView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)];
[self.view addSubview:testView];
// 错误:testView(被关联对象)强引用self(关联值),self又强引用testView(subview),形成循环
objc_setAssociatedObject(testView, @selector(testKey), self, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
@end
问题分析:
testView是self.view的子view,self强引用testView;同时,testView通过关联对象强引用self,形成"self → testView → self"的强引用循环,导致ViewController和testView都无法被释放,引发内存泄漏。
解决方案:用弱引用策略(OBJC_ASSOCIATION_ASSIGN) ,打破强引用循环:
less
// 正确:关联值用弱引用,不持有self
objc_setAssociatedObject(testView, @selector(testKey), self, OBJC_ASSOCIATION_ASSIGN);
补充:若关联值是block,更要注意------block会自动强引用捕获的对象,若block作为关联值,且block中引用了被关联对象,必须用__weak打破循环:
less
__weak typeof(self) weakSelf = self;
void (^testBlock)(void) = ^{
NSLog(@"%@", weakSelf.view); // 用weakSelf打破循环
};
// block作为关联值,用强引用策略(block本身需要被持有)
objc_setAssociatedObject(testView, @selector(blockKey), testBlock, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
示例3:坑点------OBJC_ASSOCIATION_ASSIGN导致野指针(新手常犯)
OBJC_ASSOCIATION_ASSIGN是弱引用,不持有关联值,当关联值被释放后,关联对象中的关联值会变成野指针,访问时会崩溃。
错误示例(野指针):
less
- (void)testAssignCrash {
// 临时变量:栈上的对象,方法结束后会被释放
NSString *tempStr = @"临时字符串";
UIView *testView = [[UIView alloc] init];
// 用assign策略关联tempStr
objc_setAssociatedObject(testView, @selector(strKey), tempStr, OBJC_ASSOCIATION_ASSIGN);
// 方法结束后,tempStr被释放,testView的关联值变成野指针
}
- (void)accessAssociatedValue {
UIView *testView = [[UIView alloc] init];
// 访问野指针,大概率崩溃
NSString *str = objc_getAssociatedObject(testView, @selector(strKey));
NSLog(@"%@", str);
}
问题分析:tempStr是方法内的临时变量,方法执行完毕后会被释放(引用计数为0),而testView的关联值用assign策略,不持有tempStr,因此关联值变成野指针,后续访问时会崩溃。
解决方案:
- 若关联值是"临时对象",且需要长期访问,改用OBJC_ASSOCIATION_RETAIN_NONATOMIC(强引用),持有关联值,避免其被提前释放。
- 若必须用assign(如避免强引用循环),需在关联值释放前,手动移除关联(objc_removeAssociatedObjects或objc_setAssociatedObject设为nil)。
正确示例:
less
- (void)testAssignFix {
NSString *tempStr = @"临时字符串";
UIView *testView = [[UIView alloc] init];
// 方案1:改用强引用策略(适合需要长期持有关联值的场景)
objc_setAssociatedObject(testView, @selector(strKey), tempStr, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
// 方案2:若必须用assign,在关联值释放前移除关联
// objc_setAssociatedObject(testView, @selector(strKey), nil, OBJC_ASSOCIATION_ASSIGN);
}
示例4:关联对象的自动释放机制(关键细节)
很多开发者误以为"被关联对象释放时,关联对象会自动释放"------这个说法不完全正确,具体取决于关联策略:
-
当被关联对象(object)被释放时,runtime会自动遍历该对象的ObjectAssociationMap,根据关联策略释放关联值:
- 强引用策略(RETAIN/COPY):关联值的引用计数-1,若引用计数为0,自动释放;
- 弱引用策略(ASSIGN):不做任何操作,关联值若已释放,会变成野指针(这也是ASSIGN容易踩坑的原因)。
-
手动移除关联(objc_removeAssociatedObjects):会遍历ObjectAssociationMap,根据策略释放所有关联值,然后删除该ObjectAssociationMap。
示例验证(自动释放):
less
- (void)testAutoRelease {
UIView *testView = [[UIView alloc] init];
NSString *testStr = [[NSString alloc] initWithFormat:@"test"]; // 引用计数=1
// 强引用关联:testView持有testStr,testStr引用计数=2
objc_setAssociatedObject(testView, @selector(strKey), testStr, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
// 释放testView:runtime自动释放关联值testStr,引用计数=1
testView = nil;
// 此时testStr仍存在(引用计数=1),手动释放后引用计数=0,被销毁
testStr = nil;
}
示例5:Swift中使用关联对象(OC桥接)
Swift中没有分类添加ivar的限制,但有时需要给系统Swift类(如UIView)添加关联对象,需通过OC桥接实现,或直接使用runtime API(需导入Foundation)。
示例(Swift直接使用关联对象):
swift
import UIKit
import ObjectiveC
// 定义关联key(全局唯一)
private var kIsLoadingKey: Void?
private var kLoadingViewKey: Void?
extension UIView {
// 关联属性isLoading(对应OBJC_ASSOCIATION_ASSIGN)
var isLoading: Bool {
get {
return objc_getAssociatedObject(self, &kIsLoadingKey) as? Bool ?? false
}
set {
objc_setAssociatedObject(self, &kIsLoadingKey, newValue, .OBJC_ASSOCIATION_ASSIGN)
}
}
// 关联属性loadingView(对应OBJC_ASSOCIATION_RETAIN_NONATOMIC)
var loadingView: UIView? {
get {
return objc_getAssociatedObject(self, &kLoadingViewKey) as? UIView
}
set {
objc_setAssociatedObject(self, &kLoadingViewKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}
}
// 使用
let view = UIView()
view.isLoading = true
view.loadingView = UIView(frame: CGRect(x: 0, y: 0, width: 50, height: 50))
关键细节:Swift中关联key用var定义(&获取指针),且必须是全局或文件私有,确保唯一;关联策略用objc_AssociationPolicy枚举(前缀省略OBJC_ASSOCIATION_)。
四、进阶细节:关联对象的隐藏特性与避坑指南
1. 关联对象的线程安全
从底层实现来看,AssociationsManager内部有一个自旋锁(spinlock_t),因此:
- objc_setAssociatedObject、objc_getAssociatedObject、objc_removeAssociatedObjects三个API本身是线程安全的,多线程调用不会导致哈希表错乱;
- 但关联值的访问(如获取后修改)是线程不安全的,需自己加锁(如NSLock)保护。
2. 关联对象与KVO的关系
关联对象不支持KVO------因为关联对象不是类的ivar,不会触发KVO的自动通知机制。若需要监听关联属性的变化,需手动实现通知(如NSNotificationCenter)或回调。
示例(手动监听关联属性变化):
objectivec
// 在setter中发送通知
- (void)setIsLoading:(BOOL)isLoading {
BOOL oldValue = self.isLoading;
objc_setAssociatedObject(self, kIsLoadingKey, @(isLoading), OBJC_ASSOCIATION_ASSIGN);
if (oldValue != isLoading) {
// 发送通知,监听者接收变化
[[NSNotificationCenter defaultCenter] postNotificationName:@"IsLoadingDidChange" object:self userInfo:@{@"isLoading": @(isLoading)}];
}
}
3. 常见避坑指南(总结)
- 避免用字符串作为关联key:容易哈希冲突,推荐用static const void *;
- 避免强引用循环:被关联对象与关联值之间,至少有一方用弱引用(ASSIGN);
- OBJC_ASSOCIATION_ASSIGN慎用:仅用于"不持有关联值"的场景,且需避免访问野指针;
- block作为关联值:必须用__weak打破循环,避免block强引用被关联对象;
- 不要过度使用关联对象:关联对象会增加内存开销(全局哈希表管理),能通过继承、组合实现的,优先不用关联对象。
五、总结
关联对象的底层实现,本质是"全局哈希表管理对象与关联值的映射",核心依赖AssociationsManager、AssociationsHashMap等数据结构,通过自旋锁保证线程安全;而内存管理的核心,是选择合适的关联策略,避免强引用循环和野指针。
开发中,关联对象是"扩展对象属性"的高效方案,但它的灵活性也带来了内存管理的风险------记住三个核心原则:key唯一、策略匹配、避免循环,就能避开绝大多数坑。