慎用 awakeFromNib 进行初始化

尽管使用 Storyboard / XIB 来构建应用界面的做法饱受批判,但不得不承认,一个可视化的界面,其在易读性、创建原型的速度、对于新手的友好度等等方面的优势还是非常明显的。我也是一名 Storyboard / XIB 的重度用户,尽管我知道 Autolayout 布局效率很低,也见过极其复杂的 XIB ,删一个约束就满屏的红色,但在版本快速迭代中、界面随时要调整的情况下,我还是更偏爱可视化布局控件(更别提有时候看别人的代码,其命名与对应控件天差地别的情况了)。正好今天遇到一个相关的问题,因此写文章来记录一下。

一般的套路

如果你和我一样,在实现一个较为复杂的 UIView 子类时,也习惯创建 XIB 来布局,那么你对以下代码一定不陌生:

1
2
3
4
5
6
7
8
9
10

-(void)awakeFromNib{
[super awakeFromNib];
self.backgroundColor = xxx;
self.aaa = valueA;
self.bbb= valueB;

...
}

通常,我们会把 awakeFromNib 方法作为第一入口,来对我们的 View 进行初始化,这也没什么不对,毕竟就连 官方文档 也是这么建议的:

Typically, you implement awakeFromNib for objects that require additional set up that cannot be done at design time.

所以我也就没有多想,自从接触到这个概念,就一直把它当作进行初始初始化的入口。

你也一定这样做过

现在,回想一些更为普遍的场景,你也一定做过类似事情:

1
2
3
4
5
6
-(instancetype)init{
if (self = [super init]){
_varA = [SomeClass new];
//.. customization for _varA
}
}

更一般的时候,我们会在 init 方法中,对自己的成员变量进行初始化,但需要注意,此处如果访问的是定义在类中的成员变量(而不是继承下来的成员变量),苹果建议我们不要使用 Getter/Setter 方式进行存取。大致原因就是,我们都知道 [[SomeClass alloc] init] 的两段式构造都执行完毕后,一个对象初始化才真正完成,而在这之前,使用 Getter/Setter 实质是在调用未初始化完成的对象方法,或许会有一些无法预测的后果。

这里先抛出一点:我们在 init 方法中所做的初始化,是“真正的初始化”,我们要自己为成员变量分配好内存、赋上默认值。

惯性思维

于是今天,我大概干了这么一件事:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@interface MySuperview : UIView
@property (weak, nonatomic) IBOutlet MySubview *mySub;
@end

@implementation
-(void)awakeFromNib{
self.mySub.str = @"valueFromSuper";
}
@end


@interface MySubview : UIView
@property (nonatomic ,strong) NSString *str;
@end

@implementation
-(void)awakeFromNib{
self.str = @"DefaultValue";
}
@end

注意,我是在 MySuperview.xib 中,拖入 UIView ,随后将它的类改为 MySubview 的。

接着,我就在后续的开发中发现,我凡是在 MySuperviewawakeFromNib 中所做的对 MySubview 的初始化,一律没有生效。

经过几轮测试,我发现,[MySubview awakeFromNib] 总是在 [MySuperview awakeFromNib] 之后执行。我赶紧去翻阅文档,看到下面这段话:

The nib-loading infrastructure sends an awakeFromNib message to each object recreated from a nib archive, but only after all the objects in the archive have been loaded and initialized.

Important
Because the order in which objects are instantiated from an archive is not guaranteed, your initialization methods should not send messages to other objects in the hierarchy. Messages to other objects can be sent safely from within an awakeFromNib method.

原来文档中早就说明,awakeFromNib 方法,是在将同一归档中的所有对象都读取并初始化完成后才会被调用的,并且由于从归档中初始化对象的顺序并不固定,因此,我们不应该在此处的初始化逻辑中对任何层级中的对象发消息。
意思就是,awakeFromNib 的调用顺序,与这些 View 的父子、前后层级关系毫不相关。所以上述代码才会不能正常工作。

所以,重新审视一下初始化逻辑吧

由于 awakeFromNib 调用顺序并不可靠,我们希望找到一个更为可靠的初始化方式,我重写了 MySuperview , MySubview 的一系列方法,最终得到如下控制台输出:

1
2
3
4
5
6
7
8
MySuperview alloc
MySuperview initWithCoder before calling [super initWithCoder:]
MySubview alloc
MySubview initWithCoder
MySubview didMoveToSuperview : <MySuperview: 0x7f95a2a0ae10; frame = (0 0; 375 667); opaque = NO; layer = <CALayer: 0x600000029960>>
MySuperview initWithCoder after calling [super initWithCoder:]
MySuperview awakeFromNib
MySubview awakeFromNib

并且,我在 MySubview+(id)alloc 以及 -(instancetype)init 中都打了断点,可以清晰的看到,MySubview 实例的空间分配、初始化逻辑均是由 MySuperviewinitWithCoder: 调起的,更准确地说,是 [super initWithCoder:] 调起的。


不过需要注意,此时视图层级虽然初始化完毕,但你的 Outlet 却还没被正确设置!

至此,整个从 NIB 文件中读取 View 的流程就很清楚了:

1.根视图被 alloc ,并且 UIView 的初始化方法会帮你初始化好各种子视图,并布局好视图层级( add subviews )。
2.在 [super initWithCoder:] 结束后,你所有在 XIB 里面拖入的子视图都被初始化完毕,但此时,Outlet 等属性还未被设置(子视图初始化完毕,但自己还没呢!)。
3.所有 NIB 中的视图都跑完 [[Class alloc] initWithCoder:] 流程后,按一定顺序调 awakeFromNib (这里看起来像是根据初始化开始的顺序调用)。

结语

当 XIB 中的元素均由系统控件构成时,你在哪儿初始化都不是什么大问题。但当出现嵌套的情况时,或许你就该思考在 awakeFromNib 中初始化是否真的合适了。

替代方案?或许你应该在 initWithCoder: 中初始化自己,而在 awakeFromNib 中,“初始化”子视图(此处并非初始化,而是根据自己的需要赋上新的值)。