MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Objective-C关联对象(Associated Objects)技术解析

2022-10-312.3k 阅读

一、Objective-C 关联对象基础概念

在 Objective-C 编程中,关联对象(Associated Objects)是一种强大的特性,它允许开发者为已有的类添加额外的属性,即使这些类并非由自己创建,也无需通过继承来实现。这种动态添加属性的方式,为我们在运行时扩展类的功能提供了极大的灵活性。

关联对象本质上是通过运行时(Runtime)机制来实现的。在 Objective-C 中,每个对象都有一个指向类结构的指针,类结构中包含了对象的各种元数据,如属性列表、方法列表等。而关联对象并不存储在对象原本的内存布局中,而是通过一种类似于哈希表的数据结构,在运行时将额外的属性与对象进行关联。

二、关联对象的应用场景

  1. 为系统类添加自定义属性:例如,我们可能想要为 UIView 类添加一个自定义的标识符属性,以便在复杂的视图层级中更方便地查找和管理视图。但 UIView 类是系统提供的,我们无法直接在其源码中添加属性。这时,关联对象就能派上用场,我们可以在运行时为 UIView 对象动态添加这个标识符属性。
  2. 扩展第三方库类的功能:在使用第三方库时,有时我们希望给库中的类添加一些额外的行为或数据。通过关联对象,我们可以在不修改第三方库源码的情况下实现这一目的,避免了因修改源码带来的维护问题和兼容性风险。
  3. 实现 AOP(面向切面编程):关联对象可以用于在不改变原有类结构的前提下,为对象添加一些通用的功能,如日志记录、性能监控等。我们可以通过分类(Category)结合关联对象,在方法调用前后进行一些额外的操作,实现切面功能。

三、关联对象的实现原理

  1. Runtime 相关函数:在 Objective-C 运行时,主要通过以下几个函数来操作关联对象:

    • objc_setAssociatedObject(id object, const void *key, id value, objc_AssociationPolicy policy):这个函数用于将一个值(value)通过一个键(key)关联到指定的对象(object)上,同时指定关联策略(policy)。
    • objc_getAssociatedObject(id object, const void *key):通过指定的键从对象上获取关联的值。
    • objc_removeAssociatedObjects(id object):移除指定对象上所有的关联对象。
  2. 关联策略(Association Policy):关联策略决定了关联对象的内存管理方式,共有以下几种:

    • OBJC_ASSOCIATION_ASSIGN:弱引用关联,类似 assign 属性。关联对象不会增加被关联对象的引用计数,当被关联对象释放时,关联对象不会自动释放。
    • OBJC_ASSOCIATION_RETAIN_NONATOMIC:非原子性强引用关联,类似 strong 属性但不保证原子性。关联对象会增加被关联对象的引用计数。
    • OBJC_ASSOCIATION_COPY_NONATOMIC:非原子性拷贝关联,类似 copy 属性但不保证原子性。关联对象会拷贝被关联对象的值,并增加拷贝后对象的引用计数。
    • OBJC_ASSOCIATION_RETAIN:原子性强引用关联,类似 strong 属性且保证原子性。
    • OBJC_ASSOCIATION_COPY:原子性拷贝关联,类似 copy 属性且保证原子性。

四、代码示例

  1. 为 UIView 添加自定义属性
#import <UIKit/UIKit.h>

// 定义一个全局静态变量作为关联键
static const char *kViewIdentifierKey = "ViewIdentifierKey";

@interface UIView (Identifier)

@property (nonatomic, copy) NSString *viewIdentifier;

@end

@implementation UIView (Identifier)

- (void)setViewIdentifier:(NSString *)viewIdentifier {
    objc_setAssociatedObject(self, kViewIdentifierKey, viewIdentifier, OBJC_ASSOCIATION_COPY_NONATOMIC);
}

- (NSString *)viewIdentifier {
    return objc_getAssociatedObject(self, kViewIdentifierKey);
}

@end

// 使用示例
int main(int argc, char * argv[]) {
    @autoreleasepool {
        UIView *view = [[UIView alloc] init];
        view.viewIdentifier = @"MyCustomView";
        NSLog(@"View Identifier: %@", view.viewIdentifier);
    }
    return 0;
}

在上述代码中,我们通过分类为 UIView 类添加了一个 viewIdentifier 属性。在属性的设置方法中,使用 objc_setAssociatedObject 函数将字符串值与 UIView 对象进行关联,关联策略为 OBJC_ASSOCIATION_COPY_NONATOMIC,表示会拷贝字符串。在获取属性值的方法中,使用 objc_getAssociatedObject 函数通过关联键获取关联的值。

  1. 实现 AOP 风格的日志记录
#import <Foundation/Foundation.h>

// 定义一个全局静态变量作为关联键
static const char *kLogEnabledKey = "LogEnabledKey";

@interface NSObject (Logging)

@property (nonatomic, assign) BOOL logEnabled;

@end

@implementation NSObject (Logging)

- (void)setLogEnabled:(BOOL)logEnabled {
    objc_setAssociatedObject(self, kLogEnabledKey, @(logEnabled), OBJC_ASSOCIATION_ASSIGN);
}

- (BOOL)logEnabled {
    NSNumber *value = objc_getAssociatedObject(self, kLogEnabledKey);
    return value? value.boolValue : NO;
}

- (void)logMessage:(NSString *)message {
    if (self.logEnabled) {
        NSLog(@"%@: %@", NSStringFromClass([self class]), message);
    }
}

@end

// 示例类
@interface MyClass : NSObject

@end

@implementation MyClass

@end

// 使用示例
int main(int argc, char * argv[]) {
    @autoreleasepool {
        MyClass *obj = [[MyClass alloc] init];
        obj.logEnabled = YES;
        [obj logMessage:@"This is a test log message."];
    }
    return 0;
}

在这段代码中,我们通过分类为 NSObject 类添加了一个 logEnabled 属性和一个 logMessage: 方法。logEnabled 属性用于控制是否开启日志记录,通过关联对象实现。logMessage: 方法会在 logEnabledYES 时打印日志信息,这样我们就为所有继承自 NSObject 的类添加了日志记录的功能,而无需修改它们的原有代码。

五、关联对象的注意事项

  1. 内存管理:选择合适的关联策略至关重要,不当的策略可能导致内存泄漏或悬空指针。例如,如果使用 OBJC_ASSOCIATION_ASSIGN 策略关联一个对象,而该对象在其他地方被释放,那么通过关联获取的指针将成为悬空指针,访问该指针会导致程序崩溃。
  2. 关联键的唯一性:在使用关联对象时,确保关联键的唯一性非常重要。如果在不同的地方使用了相同的关联键,可能会导致数据覆盖或其他意外行为。通常,使用全局静态变量作为关联键是一种有效的保证唯一性的方法。
  3. 性能影响:虽然关联对象提供了很大的灵活性,但由于其是在运行时动态关联的,相比于普通属性,会有一定的性能开销。在性能敏感的场景中,需要谨慎使用。

六、关联对象在实际项目中的优化

  1. 减少不必要的关联:在项目中,只在真正需要为对象添加额外属性时才使用关联对象,避免过度使用导致性能下降和代码复杂度增加。例如,如果某个功能可以通过其他更简单的方式实现,如使用类的实例变量或方法参数,就尽量不要使用关联对象。
  2. 复用关联键:如果在项目中有多个地方需要为不同的对象添加类似的关联属性,可以复用关联键。但要注意确保这些关联属性的含义和用途是一致的,以免引起混淆。例如,在一个视图库中,可能为多个不同的视图类添加 identifier 属性,这时可以复用同一个关联键。
  3. 结合缓存机制:在频繁获取关联对象的场景下,可以结合缓存机制来提高性能。例如,在一个视图控制器中,可能多次需要获取某个视图的自定义关联属性,我们可以在视图控制器中维护一个缓存字典,第一次获取关联属性时,将其存入缓存字典,后续直接从缓存字典中获取,减少 objc_getAssociatedObject 函数的调用次数。

七、关联对象与其他扩展技术的对比

  1. 与继承的对比:继承是一种常见的扩展类功能的方式,但继承有其局限性。继承是一种静态的扩展方式,在编译时就确定了类之间的关系。而关联对象是动态的,在运行时才进行属性的关联。使用继承可能会导致类层次结构变得复杂,出现“类爆炸”的问题,而关联对象则可以在不改变类层次结构的前提下为对象添加属性。例如,在一个庞大的 iOS 项目中,如果使用继承为 UIView 类添加大量不同功能的子类,会使代码结构变得难以维护,而关联对象则可以更灵活地为 UIView 对象添加功能。
  2. 与分类的对比:分类也是一种扩展类功能的常用技术,分类可以为类添加方法,但不能添加实例变量。关联对象则弥补了分类的这一不足,它可以在分类中为类添加“伪实例变量”,即关联属性。分类和关联对象常常结合使用,在分类中使用关联对象来实现一些需要存储额外数据的功能。例如,我们可以在一个 NSString 的分类中,使用关联对象为 NSString 对象添加一个自定义的标记属性,以便在特定的业务场景中使用。

八、关联对象在不同 iOS 版本中的兼容性

  1. 早期版本:Objective-C 的关联对象特性在早期的 iOS 版本中就已经存在,并且其基本的使用方式和运行时函数在各个版本中保持相对稳定。然而,在早期版本中,对关联对象的文档说明可能不如现在详细,开发者需要更多地通过探索和实践来掌握其用法。
  2. 现代版本:随着 iOS 版本的不断更新,关联对象的使用更加稳定和便捷。苹果也在不断优化运行时系统,使得关联对象的性能和内存管理更加可靠。同时,在 Xcode 的开发环境中,对关联对象的代码提示和错误检测也更加完善,有助于开发者更准确地使用这一特性。

在实际项目开发中,由于 iOS 应用需要兼容多个版本的 iOS 系统,开发者在使用关联对象时无需过多担心兼容性问题,但仍需对不同版本的特性差异进行了解,以确保应用在各个版本上都能正常运行。例如,在某些旧版本中,可能对关联策略的实现细节略有不同,虽然这种差异通常不会影响正常使用,但在处理复杂的内存管理场景时,需要特别留意。

九、关联对象在多线程环境下的处理

  1. 线程安全问题:由于关联对象的操作涉及到内存读写,在多线程环境下,如果多个线程同时对同一个对象的关联对象进行读写操作,可能会导致数据竞争和不一致的问题。例如,一个线程正在设置关联对象的值,而另一个线程同时尝试获取该值,可能会获取到不完整或错误的数据。
  2. 解决方法
    • 使用锁机制:可以使用 NSLockNSRecursiveLock@synchronized 等锁机制来保证在同一时间只有一个线程能够操作关联对象。例如:
#import <Foundation/Foundation.h>

// 定义一个全局静态变量作为关联键
static const char *kUserDataKey = "UserDataKey";

@interface MyObject : NSObject

@property (nonatomic, strong) id userData;

@end

@implementation MyObject

- (void)setUserData:(id)userData {
    @synchronized(self) {
        objc_setAssociatedObject(self, kUserDataKey, userData, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }
}

- (id)userData {
    @synchronized(self) {
        return objc_getAssociatedObject(self, kUserDataKey);
    }
}

@end
- **使用原子性关联策略**:选择原子性的关联策略,如 `OBJC_ASSOCIATION_RETAIN` 和 `OBJC_ASSOCIATION_COPY`,可以在一定程度上保证关联对象的读写操作是原子性的,减少数据竞争的风险。但需要注意的是,原子性操作并不能完全替代锁机制,在复杂的多线程场景中,仍可能需要结合锁来保证数据的一致性。

十、关联对象在内存分析中的表现

  1. ** Instruments 工具分析**:在使用 Instruments 工具进行内存分析时,关联对象的内存占用和生命周期管理可以通过工具进行观察。例如,使用 Leaks 工具可以检测是否存在因关联对象导致的内存泄漏。如果使用了不当的关联策略,如强引用循环,可能会导致对象无法释放,在 Leaks 工具中会显示相应的内存泄漏信息。
  2. 内存布局分析:虽然关联对象并不存储在对象原本的内存布局中,但通过一些底层的内存分析工具,如 malloc_history 等,可以了解关联对象在内存中的存储位置和分配情况。这对于深入理解关联对象的实现原理和优化内存使用非常有帮助。例如,通过分析内存分配的大小和频率,可以判断关联对象的创建和销毁是否合理,是否存在频繁的内存分配和释放操作导致的性能问题。

十一、关联对象在不同应用场景下的性能测试

  1. 简单应用场景:在一个简单的应用场景中,如为少量视图添加简单的关联属性,并偶尔进行读取和设置操作,关联对象的性能开销几乎可以忽略不计。通过使用 CACurrentMediaTime() 等函数进行性能测试,可以发现每次操作的时间开销非常小,对应用的整体性能影响不大。
  2. 复杂应用场景:在复杂的应用场景下,如在一个大型的游戏项目中,大量的游戏对象需要频繁地读写关联对象,性能开销就需要重点关注。通过性能测试发现,随着关联对象操作频率的增加,程序的 CPU 使用率和内存占用会有一定程度的上升。在这种情况下,可以通过优化关联策略、减少不必要的关联操作等方式来降低性能开销。例如,将非必要的强引用关联改为弱引用关联,避免不必要的对象引用计数增加,从而减少内存管理的开销。

十二、关联对象与 KVO(Key - Value Observing)的结合使用

  1. 原理:KVO 是一种基于观察者模式的机制,它允许开发者监听对象属性值的变化。当一个被观察对象的属性值发生改变时,系统会自动通知观察者。而关联对象虽然不是对象的原生属性,但同样可以利用 KVO 机制来监听其变化。这是因为关联对象在本质上也是一种对象间的关系,通过合理的设置,可以将关联对象的变化纳入 KVO 的监听范围。
  2. 代码示例
#import <Foundation/Foundation.h>

// 定义一个全局静态变量作为关联键
static const char *kCustomValueKey = "CustomValueKey";

@interface MyObservableObject : NSObject

@end

@implementation MyObservableObject

- (void)setCustomValue:(NSString *)customValue {
    objc_setAssociatedObject(self, kCustomValueKey, customValue, OBJC_ASSOCIATION_COPY_NONATOMIC);
    [self willChangeValueForKey:@"customValue"];
    [self didChangeValueForKey:@"customValue"];
}

- (NSString *)customValue {
    return objc_getAssociatedObject(self, kCustomValueKey);
}

@end

@interface MyObserver : NSObject

@end

@implementation MyObserver

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    if ([keyPath isEqualToString:@"customValue"]) {
        NSLog(@"Custom value has changed: %@", change[NSKeyValueChangeNewKey]);
    }
}

@end

// 使用示例
int main(int argc, char * argv[]) {
    @autoreleasepool {
        MyObservableObject *observable = [[MyObservableObject alloc] init];
        MyObserver *observer = [[MyObserver alloc] init];
        [observable addObserver:observer forKeyPath:@"customValue" options:NSKeyValueObservingOptionNew context:nil];

        observable.customValue = @"New custom value";

        [observable removeObserver:observer forKeyPath:@"customValue"];
    }
    return 0;
}

在上述代码中,我们为 MyObservableObject 类通过关联对象添加了一个 customValue 属性,并在设置属性值时手动调用 willChangeValueForKey:didChangeValueForKey: 方法,以触发 KVO 通知。MyObserver 类作为观察者,监听 customValue 的变化,并在变化时打印日志。这样就实现了对关联对象变化的监听,结合了 KVO 和关联对象的优势,为应用开发提供了更强大的功能。

十三、关联对象在类的生命周期中的作用

  1. 对象初始化阶段:在对象初始化时,可以通过关联对象为对象设置一些初始的额外属性值。例如,在一个自定义的视图类的 init 方法中,可以使用关联对象为该视图设置一个唯一的标识符,方便在后续的视图管理中使用。
  2. 对象使用阶段:在对象的使用过程中,关联对象可以随时被读写,为对象提供动态变化的额外数据。比如,在一个网络请求类中,可以使用关联对象存储请求的进度信息,在请求过程中不断更新这个关联属性,其他模块可以随时获取这个进度信息。
  3. 对象销毁阶段:当对象被销毁时,关联对象会根据其关联策略进行相应的内存管理。如果使用的是强引用关联策略,关联对象会随着主对象的销毁而自动释放其引用;如果是弱引用关联策略,关联对象不会阻止主对象的销毁,并且在主对象销毁后,关联对象不会自动释放(除非其引用计数在其他地方变为 0)。合理设置关联策略可以确保在对象生命周期结束时,关联对象的内存得到正确的管理,避免内存泄漏。

十四、关联对象在代码维护和重构中的考量

  1. 维护便利性:在代码维护过程中,关联对象的使用需要清晰的文档说明。由于关联对象不是对象的原生属性,其他开发者在阅读代码时可能不容易理解其用途和关联方式。因此,在使用关联对象的地方,应该添加详细的注释,说明关联键的含义、关联策略以及该关联对象的作用。例如,在为 UIViewController 类添加一个关联对象用于存储当前视图的加载状态时,应该在代码注释中明确说明这个关联对象的用途,以及为什么选择特定的关联策略。
  2. 重构影响:在进行代码重构时,关联对象的存在可能会对重构工作产生一定影响。如果需要对使用关联对象的类进行重构,比如修改类的继承关系或结构,需要特别注意关联对象的迁移和兼容性。例如,如果将一个类从某个继承体系中移除并移动到另一个继承体系中,需要确保关联对象的功能仍然能够正常实现,关联键的唯一性不会受到影响,并且关联策略在新的环境下仍然适用。在重构过程中,可能需要对关联对象的代码进行相应的调整和优化,以保证整个系统的稳定性和功能完整性。

十五、关联对象在不同平台(iOS、macOS 等)上的差异

  1. iOS 平台:在 iOS 平台上,关联对象是一种非常常用的技术,广泛应用于各种 iOS 应用的开发中。iOS 的运行时系统对关联对象的支持非常完善,开发者可以方便地使用关联对象为各种 UI 相关类(如 UIViewUIViewController 等)以及其他自定义类添加额外属性。同时,iOS 的开发工具和文档对关联对象的介绍也比较详细,有助于开发者快速掌握和使用这一技术。
  2. macOS 平台:在 macOS 平台上,关联对象同样可用,其基本原理和使用方式与 iOS 平台类似。然而,由于 macOS 应用的开发场景和类库与 iOS 有所不同,关联对象在 macOS 上的应用场景可能会有所差异。例如,在 macOS 应用中,可能更多地用于为 NSViewNSWindow 等类添加额外属性,以实现一些特定的界面交互或功能扩展。在使用关联对象时,需要注意 macOS 平台的特性,如内存管理机制在某些方面与 iOS 略有不同,虽然关联对象的内存管理策略在两个平台上基本一致,但在实际应用中仍需根据具体情况进行调整和优化。

虽然关联对象在 iOS 和 macOS 等平台上的核心功能相同,但开发者在跨平台开发中,仍需根据不同平台的特点和需求,合理地使用关联对象,以确保应用在各个平台上都能达到最佳的性能和功能表现。