屏幕截取与自动布局二三事

前言

在 iOS 上,对某个 view 进行“图片化”操作似乎已经是家常便饭了。而不论是 [CALayer renderInContext:] 还是 [UIView drawViewHierarchyInRect:afterScreenUpdates:] 都有各自的 bug 和局限性。本文将根据实际开发中遇到的问题,着重探讨 [UIView drawViewHierarchyInRect:afterScreenUpdates:] 方法。

drawViewHierarchyInRect: 的意义

[UIView drawViewHierarchyInRect:afterScreenUpdates:]官方文档 中,我们可以看到这个方法的作用及参数的含义:

drawViewHierarchyInRect:afterScreenUpdates:
Renders a snapshot of the complete view hierarchy as visible onscreen into the current context.

afterUpdates
A Boolean value that indicates whether the snapshot should be rendered after recent changes have been incorporated. Specify the value NO if you want to render a snapshot in the view hierarchy’s current state, which might not include recent changes.

描述里有这么一句话非常有意思:“Renders a snapshot of the complete view hierarchy as visible onscreen into the current context.”
也就是说,该函数是重绘一次 当前屏幕上的视图,而参数 afterScreenUpdates: 则是用来决定是否等待 view 再重新渲染一次。

那么如果当前视图不在屏幕上呢?

实践见真知

我写了一个 TestView 类,主要是覆盖了一些函数,打印一些 log,以及实现 snapshot 方法:

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
//TestView.m
- (UIImage *)snapshotAfterScreenUpdates:(BOOL)afterScreenUpdates{
NSLog(@"snapshot with size : %@",NSStringFromCGSize(self.bounds.size));
UIGraphicsBeginImageContext(self.bounds.size);
[self drawViewHierarchyInRect:self.bounds afterScreenUpdates:afterScreenUpdates];
UIImage *img = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
NSLog(@"snapshot complete");
return img;
}

- (void)drawRect:(CGRect)rect{
NSLog(@"%@ drawing rect.",self);
CGContextSetFillColorWithColor(UIGraphicsGetCurrentContext(), self.backgroundColor.CGColor);
CGContextFillRect(UIGraphicsGetCurrentContext(), rect);
}

//ViewController.m
- (void)viewDidLoad {
[super viewDidLoad];

TestView *container = [TestView testViewWithName:@"Container"];
container.backgroundColor = [UIColor redColor];
[self.view addSubview:container];
[container snapshotAfterScreenUpdates:NO];
}

运行程序,我们可以看到控制台输出了错误信息:

1
2
3
4
Container move to super : <UIView: 0x7ffd83405430>
snapshot with size : {100, 100}
[Snapshotting] Drawing a view (0x7fe464f02850, TestView) that has not been rendered at least once requires afterScreenUpdates:YES.
snapshot complete

也就是说当待截取的 view 从未被绘制过的时候,drawViewHierarchy 方法是不能正常工作的(此时拿到的 img 也是一张空图),而系统建议我将 afterScreenUpdates 设置成 YES。

需要注意,尽管我们在调用 snapshot 方法之前已经进行了 addSubview: 的操作,但这只是更改了视图树,而屏幕尚未重绘,故截屏方法依然无法工作。

接下来我们将 afterScreenUpdates 改为 YES,再次运行程序:

1
2
3
4
5
6
7
Container move to super : <UIView: 0x7f97b7e09b40>
snapshot with size : {100, 100}
Container move to super : <_UISnapshotWindow: 0x7f97b7d03860>
Container drawing rect.
Container move to super : (null)
Container move to super : <UIView: 0x7f97b7e09b40>
snapshot complete

这次,snapshot 方法成功地获取到了视图,控制台也输出了一些有趣的东西。当我们在调用 drawViewHierarchyInRect: 的时候,我们希望他能在view 下一次渲染之后进行截取,但我们的视图并不在当前屏幕之上。UIKit 当然不可能粗暴的直接把 view 加到 keywindow 上,然后触发 keywindow 的重绘,但它采取的策略却基本是这个原理,它将目标 view 移动到一个 _UISnapshotWindow 上,然后进行绘制操作,之后再将 view 移动回原先的 superview 之中。作为对比,若是 view 已经在屏幕渲染之后再调用 drawViewHierarchyInRect:,则不会被移动到 _UISnapshotWindow 上。

被移动的 view

知道我们的 view 可能被系统偷偷修改过层级之后,我们的疑问就来了,原 view 树上的各种信息是否会因此丢失呢?

view 的层级

考虑如下代码:

1
2
3
4
5
6
7
8
9
TestView *frontView = [TestView testViewWithName:@"front"];
TestView *container = [TestView testViewWithName:@"Container"];
TestView *backView = [TestView testViewWithName:@"back"];
[self.view addSubview:frontView];
[self.view addSubview:container];
[self.view addSubview:backView];
NSLog(@"Before snapshot : %@",self.view.subviews);
[container snapshotAfterScreenUpdates:YES];
NSLog(@"After snapshot : %@",self.view.subviews);

运行程序之后,我们会发现,Container 在截图完成之后依然在 subviews 的中间。事实上,如果你打上 [UIView insertSubview:atIndex:] 的符号断点,你会看到系统在调完渲染方法之后,使用了 insert 方法来把我们的 view 放回正确位置。

也就是说,系统是会保留 view 相关的信息的,在私有 window 上绘制完之后,再根据这些信息恢复视图。

view 的 layout

除了视图层级之外,我们自然还关心视图的布局了,除了直接在 IB 里面拖约束,我们还可以选择手写约束。而苹果自家那套 VFL 的可读性实在是低下,因此日常开发中我更偏向于使用 Masonry 来进行视图布局(当然,这并不影响验证本文的问题,只是为了可读性和便捷性)。

让我们为 Container 添加一些约束(如果你没有使用 Masonry 的经验也没关系,我想下面的代码一样非常易懂),为了打印出全量的信息,我们需要改写一下 loadView 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
// ViewController.m
- (void)loadView{
self.view = [TestView testViewWithName:@"Root"];
}
- (void)viewDidLoad{
// ...
[self.view addSubview:container];
[container mas_makeConstraints:^(MASConstraintMaker *make) {
make.center.equalTo(self.view);
make.size.mas_equalTo(CGSizeMake(100, 100));
}];
[container snapshotAfterScreenUpdates:YES];
}

运行程序,得到一系列输出:

1
2
3
4
5
6
7
8
9
10
11
12
Root Add Constraint : <MASLayoutConstraint:0x6080000b6920 TestView:0x7fb4d5d053a0.centerX == TestView:0x7fb4d5d04fa0.centerX>
Root Add Constraint : <MASLayoutConstraint:0x6000000b7940 TestView:0x7fb4d5d053a0.centerY == TestView:0x7fb4d5d04fa0.centerY>
Container Add Constraint : <MASLayoutConstraint:0x6080000b6a40 TestView:0x7fb4d5d053a0.width == 100>
Container Add Constraint : <MASLayoutConstraint:0x6000000b7be0 TestView:0x7fb4d5d053a0.height == 100>
snapshot with size : {100, 100}
Container move to super : <_UISnapshotWindow: 0x7fb4d5e01b80>
Container drawing rect.
Container move to super : (null)
Container move to super : Root
Root Add Constraint : <MASLayoutConstraint:0x6000000b7940 TestView:0x7fb4d5d053a0.centerY == TestView:0x7fb4d5d04fa0.centerY>
Root Add Constraint : <MASLayoutConstraint:0x6080000b6920 TestView:0x7fb4d5d053a0.centerX == TestView:0x7fb4d5d04fa0.centerX>
snapshot complete

可以看到,Container 自身的约束并不随着 superview 的改变而被破坏(事实上如果你在 IB 里面直接拷贝视图,会发现其自身的约束也是会保留的),而它和 superview 之间的约束同样被系统记录了下来,在恢复视图时重新添加约束。

Everything’s fine until it’s not

从上面几个实验看来,对不在屏幕上的视图调用 drawViewHierarchyInRect: 方法似乎是一件足够安全的事情。

你踩到坑另算

在项目中,一位同事遇到了类似如下的场景:

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
- (void)viewDidLoad {
[super viewDidLoad];

BOOL willBreak = YES;
TestView *container = [TestView testViewWithName:@"Container"];
container.backgroundColor = [UIColor redColor];
TestView *inner = [TestView testViewWithName:@"Inner"];
inner.backgroundColor = [UIColor cyanColor];
// add inner & layout
[container addSubview:inner];
[inner mas_makeConstraints:^(MASConstraintMaker *make) {
make.center.equalTo(container);
make.size.mas_equalTo(CGSizeMake(100, 100));
}];
// add container
[self.view addSubview:container];

if (!willBreak) {
[container mas_makeConstraints:^(MASConstraintMaker *make) {
make.center.equalTo(self.view);
make.size.mas_equalTo(CGSizeMake(200, 200));
}];
[inner snapshotAfterScreenUpdates:YES]; //wont break
}else {
[inner snapshotAfterScreenUpdates:YES]; // will break
[container mas_makeConstraints:^(MASConstraintMaker *make) {
make.center.equalTo(self.view);
make.size.mas_equalTo(CGSizeMake(200, 200));
}];
}
}

其中,willBreak 是一个调试标记,willBreak 为 YES 时,inner 在其被重新加入 container 后,其 center 约束不会被重新添加。
可以看到,inner 的 center 约束是否生效的关键区别在于 container 约束的添加时机。

想弄清楚为什么这里的约束会失效,首先我们要知道 AutoLayout 与手动算 frame 是不能兼容的。

考虑如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
// "部分" autolayout
[container addSubview:inner];
[inner mas_makeConstraints:^(MASConstraintMaker *make) {
make.center.equalTo(container);
make.size.mas_equalTo(CGSizeMake(50, 50));
}];
[self.view addSubview:container];

// 纯 frame 布局
[container addSubview:inner];
inner.frame = CGRectMake(50, 50, 50, 50);
[self.view addSubview:container];

运行程序,利用 View Debugging 检视视图,会发现,当视图“部分”使用 Autolayout 的时候,与该视图相关的视图也自动添加了必要的约束。

container 的位置和大小被自动加上了约束

如果都是手动设置 frame,则不会有约束

回想一下日常使用 IB 进行视图布局时,若为 subview 添加了约束,而不对其 superview 添加约束,是会出现 error 的:

理清头绪

结合我们遇到的问题,我们可以得到一个有点道理的猜想:
首先,我们将 inner 添加到 container 之中,并添加约束 [inner mas_makeConstraints:^{...}],此时,inner 和 container 的状态就像上图 IB 中的视图状态一样,是一个 error 状态,若此时我们将 container 直接添加到 keywindow 上进行绘制,系统会自动为 container 添加约束,保证大家都使用 Autolayout 布局。

但我们先进行了 snapshot 操作,inner 此时被移出了 container,照理说,inner 与 container 之间的约束关系应该被系统保存下来,用于后续状态恢复。但此时,它们之间存在的实质上是错误的布局关系,而系统这里似乎是忽略了错误关系。但 inner 本身宽高的约束并不会被破坏,同时这两个约束的存在也标志着 inner 视图使用的是 Autolayout 而非 frame 计算,最后当绘制方法结束、inner 重新被添加至 container 中时,就会因为缺少位置约束而出现位置错乱。

两个看似毫不相关的机制,在特殊的场景下竟然会相互冲突。系统内部的实现我们不得而知,但至少我们得到了一些启示:由于 Autolayout 是相对布局,视图之间的依赖繁复,因此其中的关系尚未完成建立完全时,调用可能影响到视图布局的逻辑是存在风险的。而 drawViewHierarchyInRect: 作为使用最广泛的截屏方法,它会擅自挪动视图、选择性忽略 layout 信息,甚至 打断系统动画,是一个需要谨慎使用的 API。

延伸阅读

最近查了好几个 bug,学到了不少新的 debug 姿势,本来还想写一篇文章分享,但是事实上早就有很完备的参考了:
各种实用的 lldb debug 技巧
lldb 各种命令