iOS关联对象底层实现与内存管理细节

在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)

底层流程:

  1. 判断object是否为nil:若为nil,直接返回(无法给nil对象设置关联);
  2. 获取全局AssociationsManager实例,加自旋锁(保证线程安全);
  3. 从AssociationsHashMap中,根据object(key)找到对应的ObjectAssociationMap;
  4. 若ObjectAssociationMap不存在,创建一个新的,存入AssociationsHashMap;
  5. 将key和value包装成ObjcAssociation(绑定内存策略policy),存入ObjectAssociationMap;
  6. 释放锁,完成设置。

(2)objc_getAssociatedObject(获取关联对象)

csharp 复制代码
id objc_getAssociatedObject(id object, const void *key)

底层流程:

  1. 判断object是否为nil:若为nil,返回nil;
  2. 获取全局AssociationsManager实例,加自旋锁;
  3. 从AssociationsHashMap中,根据object找到对应的ObjectAssociationMap;
  4. 若ObjectAssociationMap不存在,释放锁,返回nil;
  5. 根据key,从ObjectAssociationMap中找到对应的ObjcAssociation;
  6. 返回ObjcAssociation中的关联值,释放锁。

(3)objc_removeAssociatedObjects(移除对象的所有关联)

csharp 复制代码
void objc_removeAssociatedObjects(id object)

底层流程:

  1. 判断object是否为nil:若为nil,直接返回;
  2. 获取全局AssociationsManager实例,加自旋锁;
  3. 从AssociationsHashMap中,删除object对应的ObjectAssociationMap;
  4. 释放锁,完成所有关联的移除(会根据内存策略,自动释放关联值)。

关键细节:关联对象不存储在被关联对象的内存中,而是存储在全局哈希表中。这意味着,即使被关联对象被释放,若关联值的内存策略设置不当,仍可能导致内存泄漏。

三、内存管理细节:最容易踩坑的核心点(多示例)

关联对象的内存管理,核心取决于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:关联对象的自动释放机制(关键细节)

很多开发者误以为"被关联对象释放时,关联对象会自动释放"------这个说法不完全正确,具体取决于关联策略:

  1. 当被关联对象(object)被释放时,runtime会自动遍历该对象的ObjectAssociationMap,根据关联策略释放关联值:

    1. 强引用策略(RETAIN/COPY):关联值的引用计数-1,若引用计数为0,自动释放;
    2. 弱引用策略(ASSIGN):不做任何操作,关联值若已释放,会变成野指针(这也是ASSIGN容易踩坑的原因)。
  2. 手动移除关联(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唯一、策略匹配、避免循环,就能避开绝大多数坑。

相关推荐
90后的晨仔17 小时前
SwiftUI 高级特性第2章:组合与容器
ios
pop_xiaoli1 天前
【iOS】SDWebImage源码
macos·ios·objective-c·cocoa
MonkeyKing2 天前
消息发送与转发流程
ios
移动端小伙伴2 天前
我受够了 Xcode 的 SPM 网络问题,写了个脚本一劳永逸
ios
人月神话-Lee2 天前
两个改动,让这个iOS OCR SDK识别成功率翻了一倍
ios·ocr·ai编程·身份证识别·银行卡识别
sweet丶3 天前
流程图解:Asset Catalog 的完整生命周期
ios
空中海4 天前
iOS 动态分析、抓包与 Frida Hook
ios·职场和发展·蓝桥杯
空中海4 天前
iOS 静态逆向、IPA 结构与 Mach-O 分析
ios·华为·harmonyos
Mr -老鬼4 天前
EasyClick 双端自动化智能体|Android&iOS 全平台 EC 脚本开发助手
android·ios·自动化·易点云测·#easyclick·#ios自动化