背景
App 需要做一个防止用户截屏的功能,在 android 可以通过设置 Activity 的 Flag 来实现。在ios中系统只提供了两个事件:
- 截屏通知:
UIApplicationUserDidTakeScreenshotNotification
- 录屏通知:
UIScreenCapturedDidChangeNotification
通知局限性
- app必须处于Active状态,才能接收事件。(如用户双击 home 按钮进入多任务页面,app 处于 Suspended 状态,此时无法处理事件)
- 截屏通知是在用户截完后,才触发的事件,此时用户已经保存到了内容,无法防截屏
如何实现防截屏功能?
问题分析
我们知道,页面如果有使用密码框的话,此时截屏是不会带密码框内容的,开个脑洞, 能不能把这个系统能力化为己用呢?
可以看到,我们截图的内容中没有包含密码框
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
|
那以后层级变化了呢?
私有类不在我们控制范围,我们要做好兼容性,在层级变换后能够正常展示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) { [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) { 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) { 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]; }]; + + self.field = field; return secureView; }
|
额。。。感觉苹果在私有类内部通过__unsafe_unretained的形式持有了UITextField,因为只是一个连续的版本必现。经过这一茬,已经不敢再乱动原有类的视图层级。 于是修改了防截屏的实现方案。
方案改进
保持UITextField原有UI层级,将来自UITextField的事件全部转发到父级别的UI,把UITextField做为透明层,下面先简单回顾下事件响应流程。
事件响应过程
仅简单介绍下整体的流程
事件产生及处理过程
- 发生触摸事件
- 事件放入UIApplication管理的事件队列
- 从队列中取出事件,将事件按照视图层级由下到上传递(最下是keyWindos)
- 传递到最合适的视图后,在从上到下通过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) { 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
|
到此,本篇文章结束。
该版本的防截屏功能已经在线上运行了一段时间,目前看还比较稳定,没有出现过闪退。
如要使用,建议做好闪退防护和线上灰度开关。