Kealdish's Studio.

利用 KVO 解决 swizzle 失效问题

字数统计: 1.5k阅读时长: 5 min
2018/09/12 Share

最近碰到一个需求,类似于下图所示,几个 UIButton 排列在一起,在某个事件触发时,隐藏或者显示中间的 button ,重点是隐藏中间的 button 时,上下 button 之间的间距保持为原来的间距。比较好的做法是重写 alignmentRectInsets 的 get 方法,当然更新约束的方法也是 OK 的。直接创建子类去重写 alignmentRectInsets 的 get 方法不够优雅,因而想到创建分类, swizzle UIViewalignmentRectInsets 的 get 方法,在自己的自定义方法中去完成真正的修改逻辑。

原以为写完分类,在项目中调用再 run 一下就大功告成了,然而,我还是 naive 啊。运行项目后,发现竟然没有 work ,打断点调试,发现 swizzle method 没有调用。没有调用的原因在于 UIButton 类本身的实现已经重写了 alignmentRectInsets 的 get 方法,所以,不会再调用 UIView 的相同方法了。那怎么办呢?最简单的办法就是给 UIButton 创建分类而不是 UIView ,但是感觉这样写不是很好,有些冗余。

经过一番研究后,想到可以利用 KVO 的机制来解决这个问题。

思路

关于 KVO 的具体细节不是本文的重点,如果不是很了解,可以参考这篇文章。这里提一点,苹果在 KVO 的底层实现上,会创建一个子类对象,类名类似于 NSKVONotifying_xxxxx ,并将原来对象的 isa 指针指向这个子类对象。有了这个特性,就可以解决我们之前的问题啦!

那解决的思路是怎么样的呢?主要还是围绕 KVO 会动态创建子类的特性。具体思路可以分为以下几步:

  1. 让苹果为我们的目标类的对象动态创建子类对象
  2. 获取目标对象真正的类
  3. 拿到需要 swizzle 的 alignmentRectInsets 方法的函数签名
  4. 在 KVO 子类添加该方法和对应的 IMP

第一步很简单,只要一个对象观察某个键值,苹果就会为这个对象动态创建子类对象。但这里有个地方需要注意,当某个对象观察某个键值时,需要在该对象 dealloc 方法中移除对该键值的观察,否则,会导致 KVO still registering when deallocated 的 crash 。第二步依赖于第一步中的动态创建的子类对象,调用 class_getSuperclass() 方法来获取目标对象真正的类。第三步承接第二步,知道了目标对象的真正类,则可以拿到 swizzle 的 alignmentRectInsets 的 get 方法的函数签名,这没什么问题。第四步将完整的 selector 、函数签名以及自己实现的 IMP 添加到 KVO 子类的方法列表中即可。

如何解决 KVO 属性移除

刚才有提到,KVO 观察的键值,需要在对象 dealloc 时移除,但是,在 category 中是无法重写 dealloc 方法的。那怎么解决这个问题呢?我们知道,一个对象 A 持有另一个对象 B ,且 B 仅被 A 持有,那么当对象 A 释放时,也会释放对 B 的持有,也就表示在 A 的 dealloc 触发时,B 的 dealloc 方法也会触发。按照这个思想,我们可以创建一个单例对象作为 observer ,再实例化一个对象 remover 去持有该单例对象,而让目标对象去持有该实例化的对象 remover,在 removerdealloc 方法中执行移除 KVO 键值的操作。当目标对象销毁时,就会释放 remover ,也就会触发 removerdealloc 方法。对这两个类的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ICFakeKVOObject.h
@interface ICFakeKVOObserver : NSObject
+ (instancetype)shared;
@end
@interface ICFakeKVORemover : NSObject
/// unsafe_unretained makes sure that targe can call method in dealloc()
@property (nonatomic, unsafe_unretained) id target;
@property (nonatomic, copy) NSString *keyPath;
@end
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
// ICFakeKVOObject.m
#import "ICFakeKVOObject.h"
@implementation ICFakeKVOObserver
+ (instancetype)shared {
static id sharedInstance;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
sharedInstance = [[self alloc] init];
});
return sharedInstance;
}
@end
@implementation ICFakeKVORemover
- (void)dealloc {
[_target removeObserver:[ICFakeKVOObserver shared] forKeyPath:_keyPath];
_target = nil;
}
@end

注意,ICFakeKVORemover 中的 target 的修饰符要用 unsafe_unretained 而不能用 weak ,否则,在 dealloc 方法执行时, _target 已经置为 nil 而无法移除键值观察。至于其中的详细细节,可以参考我的另一篇文章Weak 属性在 dealloc 背后的逻辑。这样当 ICFakeKVOObserver 单例对象销毁时,ICFakeKVORemover 对象的 dealloc 方法也会触发。

在 KVO 子类添加 alignmentRectInsets 方法和对应的 IMP

如何在子类中添加目标方法以及相应的 IMP 已经在上面提过,核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
/// NSKVONotifying_xxxxx
Class kvoCls = object_getClass(self);
/// original class
Class originCls = class_getSuperclass(kvoCls);
/// get original type encoding
const char *originAlignmentRectInsetsEncoding = method_getTypeEncoding(class_getInstanceMethod(originCls, @selector(alignmentRectInsets)));
/// add IMP for KVO class
class_addMethod(kvoCls, @selector(alignmentRectInsets),
(IMP)ic_alignmentRectInsets,
originAlignmentRectInsetsEncoding);

这里面有个需要注意的点,那就是如何创建符合要求的 IMP ?我们知道 OC 语言的核心在于消息传递,我们调用的方法本质上是会转变成消息发送给目标对象,依据消息中的 selector 找到对应的 IMP ,然后调用它。我们所写的方法实现在经过 clang 编译之后会变成 C 函数,而函数参数也会发生变化。比如 - (void)viewWillAppear:(BOOL)animated 编译后会变成 static void viewWillAppear(UIViewController *self, SEL sel, bool animated) ,其中函数名不变,返回值则会在 OC 和 C 之间做转换,而参数列表则多了两个参数 selfsel 分别代表了当前方法的调用者和对应的方法名。编译后的函数实现可以理解为就是真正的 IMP 。那么我们要创建自己的 IMP ,则只需要按照要求在函数参数中加上 selfsel 即可。这里的 ic_alignmentRectInsets IMP 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static UIEdgeInsets ic_alignmentRectInsets(UIView *kvo_self, SEL _sel) {
Class kvo_cls = object_getClass(kvo_self);
Class origin_cls = class_getSuperclass(kvo_cls);
IMP origin_imp = method_getImplementation(class_getInstanceMethod(origin_cls, _sel));
assert(origin_imp != NULL);
UIEdgeInsets (*func)(UIView *, SEL) = (UIEdgeInsets (*)(UIView *, SEL))origin_imp;
AlignmentRectInsetsBlock block = kvo_self.yc_alignmentRectInsetsBlock;
if (block) {
return block();
} else {
return func(kvo_self, _sel);;
}
}

写完 category 再调用一下,大功告成!没想到由于 UIButtonalignmentRectInsets 在 category 中的失效问题竟然学习到了这么多知识,酥服。

CATALOG
  1. 1. 思路
  2. 2. 如何解决 KVO 属性移除
  3. 3. 在 KVO 子类添加 alignmentRectInsets 方法和对应的 IMP