iOS 应用内容防护(防截屏 防录屏)

背景

App 需要做一个防止用户截屏的功能,在 android 可以通过设置 Activity 的 Flag 来实现。在ios中系统只提供了两个事件:

  • 截屏通知:UIApplicationUserDidTakeScreenshotNotification
  • 录屏通知:UIScreenCapturedDidChangeNotification

通知局限性

  1. app必须处于Active状态,才能接收事件。(如用户双击 home 按钮进入多任务页面,app 处于 Suspended 状态,此时无法处理事件)
  2. 截屏通知是在用户截完后,才触发的事件,此时用户已经保存到了内容,无法防截屏

如何实现防截屏功能?

问题分析

我们知道,页面如果有使用密码框的话,此时截屏是不会带密码框内容的,开个脑洞, 能不能把这个系统能力化为己用呢?

可以看到,我们截图的内容中没有包含密码框

居中

UITextField分析

我们写个demo看看,UITextField是如何做到防截屏录屏的。

把UITextField添加到页面后,视图层级多了一个_UITextFieldCanvasView,这个会不会就是我们需要的防截屏UI呢?

_UITextFieldCanvasView效果验证

验证的方式很简单,我们把自己的UI放到它的身上。Demo如下

1
2
3
4
5
6
7
8
9
UITextField *field = [[UITextField alloc] initWithFrame:CGRectMake(0, 200, UIScreen.mainScreen.bounds.size.width, 200)];
field.backgroundColor = UIColor.redColor;
field.secureTextEntry = YES;
[self.view addSubview:field];

UIView *blueView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 60, 60)];
blueView.backgroundColor = UIColor.blueColor;
field.subviews.firstObject.backgroundColor = UIColor.yellowColor;
[field.subviews.firstObject addSubview:blueView];

下图为页面效果:

居中

下图为截屏效果:

居中

看起来效果不错,黄色区域以及黄色区域的subViews都不能被截屏

页面层级,目前为:

1
2
3
+-- UITextField : 红色
| +-- _UITextFieldCanvasView : 黄色
| +-- | +-- UIView : 蓝色

红色的块是UITextField,_UITextFieldCanvasView是黄色,UIView是蓝色的。系统截屏不会截取屏幕中的_UITextFieldCanvasView以及他的子view

理论存在,实践开始

通过上面的Demo,我们已经可以尝试开发一个防截屏的组件,开始前,我们再做一些准备工作。

分析各个版本的UITextField层级

1
2
3
4
5
6
7
8
9
10
// 可以看到每个大版本,这个类名都不一样
ios 12.4 :
+-- UITextField : subViews
| +-- _UITextFieldContentView
ios 14.6 :
+-- UITextField : subViews
| +-- _UITextFieldCanvasView
ios 15.1 :
+-- UITextField : subViews
| +-- _UITextLayoutCanvasView
  • 私有类的名字经常变换,使用类名反射不太靠谱,而且直接使用私有类容易被拒。

  • 目前各版本的层级都在第一个,我们可以通过firstObject来获取到这个私有类。因为UITextField点击事件比较多,我们直接抛弃这一层,从UITextField获取到私有类后,UITextField就可以抛弃了。
    (因为这个思路,也为后续埋下了点坑,后话)

那以后层级变化了呢?
私有类不在我们控制范围,我们要做好兼容性,在层级变换后能够正常展示UI

通过上面的分析,我们已经可以开发一个防截屏的组件,关键代码如下:

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
@implementation QMSecureView
#pragma mark - Super
- (instancetype)initWithFrame:(CGRect)frame
secureEnabled:(BOOL)secureEnabled
{
self = [super initWithFrame:frame];
if (self)
{
self.backgroundColor = UIColor.clearColor;
_secureEnabled = secureEnabled;
[self buildSubView];
}
return self;
}

- (void)addSubview:(UIView *)view
{
if (view == self.secureView)
{
[super addSubview:view];
}
else
{
[self.secureView addSubview:view];
}
}

- (void)layoutSubviews
{
[super layoutSubviews];
self.secureView.frame = self.bounds;
}

#pragma mark - Private

- (void)buildSubView
{
UIView *secureView = [self makeSecureViewWithSecureEnabled:self.secureEnabled];
[self.secureView.subviews enumerateObjectsUsingBlock:^(__kindof UIView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
// 把原来的view也弄过去
[secureView addSubview:obj];
}];
self.secureView = secureView;
[self addSubview:self.secureView];
}

- (UIView *)makeSecureViewWithSecureEnabled:(BOOL)enabled
{
UITextField *field = [[UITextField alloc] initWithFrame:self.bounds];
field.secureTextEntry = enabled;
UIView *secureView = field.subviews.firstObject;
if (secureView == nil)
{
// 兜底一个普通的view
secureView = [[UIView alloc] initWithFrame:self.bounds];
kSecureViewUnavailableFlag = YES;
}
secureView.userInteractionEnabled = YES;
[secureView.subviews enumerateObjectsUsingBlock:^(__kindof UIView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
[obj removeFromSuperview];
}];
return secureView;
}

#pragma mark - Set
- (void)setSecureEnabled:(BOOL)secureEnabled
{
if (_secureEnabled != secureEnabled)
{
_secureEnabled = secureEnabled;
[self buildSubView];
}
}
@end

好的,收工!


灰度上线后,出现了好几例闪退,操作系统版本集中在这个区间 15.0 <= osVersion <= 15.1.1


崩溃分析

我们找到一台处于上述操作系统版本区间的机器,使用该组件后,果然闪退了。崩溃堆栈如下:

  • 观察堆栈,在 [tableView reload] 之后,触发了崩溃,并且在系统堆栈中存在防截屏的私有类,基本可以确定是防截屏导致的崩溃。
  • 因为我们把这个私有类做为View加到了TableCell上,所以 [tableView reload] 后触发subViews的layoutSubViews再正常不过了,上半部分的堆栈可以忽略。
  • 要解决该问题,我们聚焦**[_UITextLayoutCanvasView layoutSubviews]** 之后的堆栈。看这几个堆栈,像是TextField布局,我们之前是通过创建TextField,取出firstView后返回,TextField已经被释放了

试试持有textField不释放,结果正常了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
- (UIView *)makeSecureViewWithSecureEnabled:(BOOL)enabled
{
UITextField *field = [[UITextField alloc] initWithFrame:self.bounds];
field.secureTextEntry = enabled;
UIView *secureView = field.subviews.firstObject;
if (secureView == nil)
{
// 兜底一个普通的view
secureView = [[UIView alloc] initWithFrame:self.bounds];
kSecureViewUnavailableFlag = YES;
}
secureView.userInteractionEnabled = YES;
[secureView.subviews enumerateObjectsUsingBlock:^(__kindof UIView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
[obj removeFromSuperview];
}];
+ // 持有UITextField
+ self.field = field;
return secureView;
}

额。。。感觉苹果在私有类内部通过__unsafe_unretained的形式持有了UITextField,因为只是一个连续的版本必现。经过这一茬,已经不敢再乱动原有类的视图层级。 于是修改了防截屏的实现方案。

方案改进

保持UITextField原有UI层级,将来自UITextField的事件全部转发到父级别的UI,把UITextField做为透明层,下面先简单回顾下事件响应流程。

事件响应过程

仅简单介绍下整体的流程

事件产生及处理过程

  1. 发生触摸事件
  2. 事件放入UIApplication管理的事件队列
  3. 从队列中取出事件,将事件按照视图层级由下到上传递(最下是keyWindos)
  4. 传递到最合适的视图后,在从上到下通过touches方法来处理事件。如果不实现touches方法默认转发事件给super,如果实现且没有调用super touches则响应链到此截断。
    (这里的上下关系,可以通过想象xcode的视图层级来理解)

顺便也贴下官网的图:

新的防截屏组件实现

整体思路,整个组件做为一个透明层,不参与任何事件的处理,只负责把上层传递过来的事件传递给下层(即super)。转发事件的核心代码如下:

其实这里也只能这样做,因为UITextField重写了touches相关的方法,事件响应是不会传递给其super方法,我们需要继承UITextField去恢复默认的事件转发行为。

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
@implementation QMSecureTextField

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01)
{
return nil;
}
if ([self pointInside:point withEvent:event])
{
for (UIView *subview in [self.subviews reverseObjectEnumerator])
{
CGPoint convertedPoint = [subview convertPoint:point fromView:self];
UIView *hitTestView = [subview hitTest:convertedPoint withEvent:event];
if (hitTestView)
{
if (hitTestView.superview == self)
{
// self的所有子view都不应该响应事件
return nil;
}
return hitTestView;
}
}
return nil;
}
return nil;
}

- (BOOL)canPerformAction:(SEL)action withSender:(id)sender
{
// 禁用全选,选择,粘贴功能
return NO;
}

- (BOOL)isFirstResponder
{
return NO;
}

- (BOOL)canResignFirstResponder
{
return NO;
}

#pragma mark - 转发事件
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
[self.superview touchesBegan:touches withEvent:event];
}

- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event
{
[self.superview touchesMoved:touches withEvent:event];
}

- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event
{
[self.superview touchesEnded:touches withEvent:event];
}

- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event
{
[self.superview touchesCancelled:touches withEvent:event];
}

- (void)touchesEstimatedPropertiesUpdated:(NSSet<UITouch *> *)touches
{
[self.superview touchesEstimatedPropertiesUpdated:touches];
}

- (void)pressesBegan:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event
{
[self.superview pressesBegan:presses withEvent:event];
}

- (void)pressesChanged:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event
{
[self.superview pressesChanged:presses withEvent:event];
}

- (void)pressesEnded:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event
{
[self.superview pressesEnded:presses withEvent:event];
}

- (void)pressesCancelled:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event
{
[self.superview pressesCancelled:presses withEvent:event];
}
@end

到此,本篇文章结束。
该版本的防截屏功能已经在线上运行了一段时间,目前看还比较稳定,没有出现过闪退。

如要使用,建议做好闪退防护和线上灰度开关。

-------------本文结束感谢您的阅读-------------

欢迎关注我的其它发布渠道