有关 Objective-C Associated Objects

在 Objective-C 中,我们可以通过 Category 给一个现有的类添加属性,但不能添加实例变量,这似乎成为了 Objective-C 的一个明显短板。但我们可以通过 Associated Objects 来弥补这一不足。本文将结合 runtime 源码深入探究 Objective-C 中 Associated Objects 的实现原理。

1
#import <objc/runtime.h>

我们在遇到 runtime 相关的一些东西时,一般都会很谨慎。因为所有的代码都运行在其之上,弄乱 Objective-C 运行时可能会改变整个实现结构,

一方面: 中的函数可以给应用或者框架增加强大的新特性。但另一方面:它会改变代码的正常运行逻辑和所有与之交互的东西(通常伴随着可怕的副作用)。

Associated Objects

Associated Objects(关联对象)或者叫作关联引用(Associative References),是作为Objective-C 2.0 运行时功能被引入到 Mac OS X 10.6 Snow Leopard(及iOS4)系统。与它相关在中有3个C函数,它们可以让对象在运行时关联任何值:

1
2
3
void objc_setAssociatedObject(id object, const void *key, id value, objc_AssociationPolicy policy);
id objc_getAssociatedObject(id object, const void *key);
void objc_removeAssociatedObjects(id object);
  • objc_setAssociatedObject 用于给对象添加关联对象,传入 nil 则可以移除已有的关联对象;
  • objc_getAssociatedObject 用于获取关联对象;
  • objc_removeAssociatedObjects 用于移除一个对象的所有关联对象。

objc_setAssociatedObject 中

  • object 是用于关联的源对象,换言之,它是将指向其他对象的对象。
  • *key 是该关联的关键,可以是任何空指针,任何恒定​的内存地址。
  • value 是要存储或与源对象关联的对象。
  • policy 是定义引用的类型,类似于你声明的属性时所使用的类型的常数。可能的值是:
1
2
3
4
5
6
7
enum {
OBJC_ASSOCIATION_ASSIGN = 0,
OBJC_ASSOCIATION_RETAIN_NONATOMIC = 1,
OBJC_ASSOCIATION_COPY_NONATOMIC = 3,
OBJC_ASSOCIATION_RETAIN = 01401,
OBJC_ASSOCIATION_COPY = 01403
};

objc_getAssociatedObject 中 key 值

通常推荐 key 使用 static char 类型 – 使用指针或许更好,key值是一个唯一的常量,并只在getters和setters方法内部使用:

static char kAssociatedObjectKey;

objc_getAssociatedObject(self, &kAssociatedObjectKey);`

然而,一个更简单的方案是:直接使用选择器(selector)。

因为SEL生成的时候就是一个唯一的常量,你可以使用 _cmd 作为objc_setAssociatedObject()的key。
—— Bill Bumgarner(@bbum) August28, 2009

objc_removeAssociatedObjects 中

objc_removeAssociatedObjects()函数来移除关联对象,然而,根据苹果文档描述,我们一般用不上:

这个函数的主要目的是很容易的让对象恢复成它“原始状态”,你不应该使用它来移除关联的对象,因为它也会移除掉包括其他地方加入的全部的关联对象。所以一般你只需要通过调用objc_setAssociatedObject并传入nil值来清除关联值。

它允许开发者对已经存在的 Category 添加自定义的属性,这几乎弥补了Objective-C最大的缺点。

关联策略

被关联到对象的值根据使用的objc_AssociationPolicy类型不同表现出不同的特性:
Behavior 对应的 @property 类型描述:

First Header Second Header Third Header
OBJC_ASSOCIATION_ASSIGN @property (assign) 或 @property(unsafe_unretained) 给关联对象指定若引用
OBJC_ASSOCIATION_RETAIN_NONATOMIC @property (nonatomic, strong) 给关联对象指定非原子的强引用
OBJC_ASSOCIATION_COPY_NONATOMIC @property (nonatomic, copy) 给关联对象指定非原子的copy特性
OBJC_ASSOCIATION_RETAIN @property (atomic, strong) 给关联对象指定原子的强引用
OBJC_ASSOCIATION_COPY @property (atomic, copy) 给关联对象指定原子copy特性

其中,第 2 种与第 4 种、第 3 种与第 5 种关联策略的唯一差别就在于操作是否具有原子性。由于操作的原子性不在本文的讨论范围内,所以下面的实验和讨论就以前三种以例进行展开。

通过OBJC_ASSOCIATION_ASSIGN分配的弱关联对象并不是完全和weak修饰符引用一样(对象初始化与释放时被置空),反而更像是unsafe_unretained,所以你需要在访问弱关联对象时稍微注意一下。

示例

  • 添加私有变量来帮助实现细节:当拓展一个内置类时,可能有必要跟踪一些额外的状态,这是关联对象最普遍的应用场景。例如:AFNetworking中在UIImageView的分类中使用关联对象来存储一个请求操作对象(operation object),用于异步的从远程获取图片。

  • 为现有的类添加公有属性:有时候,通过添加一个属性让一个分类更加灵活,而不是作为函数参数。这种情况下,使用关联对象作为一个公开的属性是可接受的解决方案。还是拿前面AFNetworking的例子来说,UIImageView的分类中imageResponseSerializer属性允许图片视图随意的使用一个过滤器,或者在图片请求并缓存之前就可以修改它的渲染。

  • 为KVO创建一个关联的观察者(observer):当在一个分类中使用KVO的时候,推荐使用一个自定义的关联对象作为观察者,而不是对象自己观察自己。

反例

  • 在不必要的时候使用关联对象。使用视图时一个常见的情况是通过数据模型或一些复合的值来创建一个便利的方法设置填充字段或属性。如果这些值在后面不会再被使用到,最好就不要使用关联对象了。

  • 使用关联对象来保存一个可以被推算出来的值。例如,有人可能想通过关联对象存储UITableViewCell上一个自定义accessoryView的引用,使用tableView:accessoryButtonTappedForRowWithIndexPath:cellForRowAtIndexPath:即可以达到要求。

  • 使用关联对象来代替X。其中X代表下面的一些项:

    • 子类化,当使用继承比使用组合更合适的时候。
    • Target-Action给响应者添加交互事件。
    • 手势识别,当target-action模式不够用的时候。
    • 代理,当事件可以委托给其他对象。
    • 消息 & 消息中心使用低耦合的方式来广播消息。

关联对象应该被当做最后的手段来使用(不得不用时才用),而不是为了寻求一个解决方案就行(事实上,分类本身就不应该是解决问题优先选择的工具)。

实例1:

1、通常的解决方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@interface MasterViewController () <UIAlertViewDelegate> {
NSMutableArray *_objects;
NSIndexPath *_indexPathToDelete;
}

// ...

- (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath
{
if (editingStyle == UITableViewCellEditingStyleDelete) {
NSString *deleteMessage = @"Are you sure you want to delete this super important thing?";
UIAlertView *deleteConfirmation = [[UIAlertView alloc] initWithTitle:@"Delete Row"
message:deleteMessage
delegate:self
cancelButtonTitle:@"Cancel"
otherButtonTitles:@"Confirm", nil];
_indexPathToDelete = indexPath;
[deleteConfirmation show];
}
}

// ...

- (void)alertView:(UIAlertView *)alertView didDismissWithButtonIndex:(NSInteger)buttonIndex
{
if (buttonIndex == 1) {
[_objects removeObjectAtIndex:_indexPathToDelete.row];
[_tableView deleteRowsAtIndexPaths:@[_indexPathToDelete] withRowAnimation:UITableViewRowAnimationFade];
}
}

2、还可以的解决方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#import <objc/runtime.h>

static char deleteKey;

@interface MasterViewController () &lt;UIAlertViewDelegate&gt; {
NSMutableArray *_objects;
}

// ...

- (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath
{
if (editingStyle == UITableViewCellEditingStyleDelete) {
NSString *deleteMessage = @"Are you sure you want to delete this super important thing?";
UIAlertView *deleteConfirmation = [[UIAlertView alloc] initWithTitle:@"Delete Row"
message:deleteMessage
delegate:self
cancelButtonTitle:@"Cancel"
otherButtonTitles:@"Confirm", nil];
objc_setAssociatedObject(deleteConfirmation, &deleteKey, indexPath, OBJC_ASSOCIATION_RETAIN);
[deleteConfirmation show];
}
}

// ...

- (void)alertView:(UIAlertView *)alertView didDismissWithButtonIndex:(NSInteger)buttonIndex
{
if (buttonIndex == 1) {
NSIndexPath *deletePath = objc_getAssociatedObject(alertView, &deleteKey);
[_objects removeObjectAtIndex:deletePath.row];
[_tableView deleteRowsAtIndexPaths:@[deletePath] withRowAnimation:UITableViewRowAnimationFade];
}
}

我们不再需要的实例变量,用一个新的静态char变量作为键。

3、更好的解决方法

扩充 NSObject 类:

NSObject+AssociatedObjects.h

1
2
3
4
@interface NSObject (AssociatedObjects)
- (void)associateValue:(id)value withKey:(void *)key;
- (id)associatedValueForKey:(void *)key;
@end

NSObject+AssociatedObjects.m

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#import "NSObject+AssociatedObjects.h"
#import <objc/runtime.h>

@implementation NSObject (AssociatedObjects)

- (void)associateValue:(id)value withKey:(void *)key
{
objc_setAssociatedObject(self, key, value, OBJC_ASSOCIATION_RETAIN);
}

- (id)associatedValueForKey:(void *)key
{
return objc_getAssociatedObject(self, key);
}

@end

ViewController:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#import "NSObject+AssociatedObjects.h"
static char deleteKey;

@interface MasterViewController () <UIAlertViewDelegate> {
NSMutableArray *_objects;
}

// ...

- (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath
{
if (editingStyle == UITableViewCellEditingStyleDelete) {
NSString *deleteMessage = @"Are you sure you want to delete this super important thing?";
UIAlertView *deleteConfirmation = [[UIAlertView alloc] initWithTitle:@"Delete Row"
message:deleteMessage
delegate:self
cancelButtonTitle:@"Cancel"
otherButtonTitles:@"Confirm", nil];
[deleteConfirmation associateValue:indexPath withKey:&deleteKey];
[deleteConfirmation show];
}
}

// ...

- (void)alertView:(UIAlertView *)alertView didDismissWithButtonIndex:(NSInteger)buttonIndex
{
if (buttonIndex == 1) {
NSIndexPath *deletePath = [alertView associatedValueForKey:&deleteKey];
[_objects removeObjectAtIndex:deletePath.row];
[_tableView deleteRowsAtIndexPaths:@[deletePath] withRowAnimation:UITableViewRowAnimationFade];
}
}

此时代码可读性更好。

4、最好的解决方法

苹果文档叙述:

UIAlertView类直接使用,不支持子类化。这个类的视图层次是私有的,不能修改。

还有:

编译器将不能合成任何实例变量,也不会合成任何属性访问方法。

唯一方法添加一个传统property-backed新实例变量来现有的类是使用类扩展,所述扩展内部实现类进行扩展。

我们将增加一个新的属性,UIAlertView中没有继承它。当你的文档中看到,这是完全合法的声明类接口的属性,不创建一个新的实例变量。我们不需要一个新的实例变量,我们将只覆盖我们的getter 和 setter方法。

UIAlertView+DeleteConfirmation.h

1
2
3
@interface UIAlertView (DeleteConfirmation)
@property (nonatomic) NSIndexPath *indexPathToDelete;
@end

UIAlertView+DeleteConfirmation.m

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#import "UIAlertView+DeleteConfirmation.h"
#import "NSObject+AssociatedObjects.h"

@implementation UIAlertView (DeleteConfirmation)

- (void)setIndexPathToDelete:(NSIndexPath *)indexPathToDelete
{
[self associateValue:indexPathToDelete withKey:@selector(indexPathToDelete)];
}

- (NSIndexPath *)indexPathToDelete
{
return [self associatedValueForKey:@selector(indexPathToDelete)];
}

ViewController:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#import "UIAlertView+DeleteConfirmation.h"

@interface MasterViewController () <UIAlertViewDelegate> {
NSMutableArray *_objects;
}

// ...

- (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath
{
if (editingStyle == UITableViewCellEditingStyleDelete) {
NSString *deleteMessage = @"Are you sure you want to delete this super important thing?";
UIAlertView *deleteConfirmation = [[UIAlertView alloc] initWithTitle:@"Delete Row"
message:deleteMessage
delegate:self
cancelButtonTitle:@"Cancel"
otherButtonTitles:@"Confirm", nil];
deleteConfirmation.indexPathToDelete = indexPath;
[deleteConfirmation show];
}
}

// ...

- (void)alertView:(UIAlertView *)alertView didDismissWithButtonIndex:(NSInteger)buttonIndex
{
if (buttonIndex == 1) {
NSIndexPath *deletePath = alertView.indexPathToDelete;
[_objects removeObjectAtIndex:deletePath.row];
[_tableView deleteRowsAtIndexPaths:@[deletePath] withRowAnimation:UITableViewRowAnimationFade];
}
}

Wonderful.

参考


以上