一、系统背景

  • iOS 对「安全输入」相关视图(例如开启安全输入的文本框及其内部系统私有子视图)在截屏与录屏时会自动排除,这些视图不会出现在截图或录屏画面中。
  • 项目利用这一机制,将整屏应用内容放入系统认定的「安全视图」层级内,从而实现防截屏/防录屏下的内容保护。

二、实现原理概览

2.1 安全容器从何而来

  • 使用一个透明的、开启安全输入的文本框(系统会为其创建内部私有子视图用于安全渲染)。
  • 取该文本框的第一个子视图(即系统为安全输入创建的内容视图)作为「安全容器」。
  • 将该安全容器从文本框上移除并清空其原有子视图,再作为窗口的唯一直接子视图添加到窗口上,设全屏 frame 与自动布局,使窗口的直接子视图只有一个:安全容器本身。

示意代码(Swift):

1
2
3
4
5
6
7
8
9
10
11
12
13
// 安全窗口内:构建安全容器并挂到窗口上
private func setupSecureContainer() {
let textField = SecureOverlayTextField(frame: .zero) // 透明、isSecureTextEntry = true
guard let secureView = textField.subviews.first else { return }
secureView.removeFromSuperview()
secureView.subviews.forEach { $0.removeFromSuperview() }
secureView.frame = bounds
secureView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
secureView.isUserInteractionEnabled = true
super.addSubview(secureView) // 安全容器成为窗口唯一直接子视图
secureTextField = textField // 强引用保留,避免部分系统版本异常
secureContentView = secureView
}

2.2 内容如何进入安全容器

  • 重写窗口的 addSubview:除「安全容器自身」外,所有通过该窗口添加的视图都不再加在窗口上,而是统一加到安全容器内
  • 这样,无论是根界面还是后续加在「窗口」上的浮层(弹窗、蒙层、设置面板等),最终都在安全容器内,从而在截屏/录屏时被系统整体排除。

示意代码(Swift):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 安全窗口:重写 addSubview,将内容重定向到安全容器
override func addSubview(_ view: UIView) {
if view === secureContentView {
super.addSubview(view)
} else if let content = secureContentView {
let isRootContent = content.subviews.isEmpty
if isRootContent {
view.frame = content.bounds
view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
}
content.addSubview(view) // 根内容与浮层都加在安全容器内
} else {
super.addSubview(view)
}
}

2.3 根内容与浮层的区别对待

  • 根内容:第一个被加入安全容器的视图(通常为根控制器的 view)。对其单独处理:设置 frame = 安全容器.bounds 并设置自动调整大小,保证全屏且随窗口变化。
  • 后续浮层:不再改 frame,保持调用方传入的 frame,避免把弹窗、小视图等误设为全屏。

2.4 文本框的保留

  • 安全容器来自文本框的内部子视图,文本框本身不再挂在视图层级上,但需在窗口侧强引用保留该文本框,避免在部分系统版本上被释放导致异常。

三、注意事项(原理层面)

3.1 按 tag 查找「加在窗口上的浮层」

  • 现象:在启用安全窗口后,窗口的直接子视图只有安全容器这一项,原先加在「窗口」上的浮层实际都在安全容器的子视图里。
  • 注意:凡是通过「遍历窗口的直接子视图」按 tag 查找浮层的逻辑,会找不到目标。
  • 正确做法:若当前为安全窗口,应使用安全窗口提供的「用于 overlay 查找的子视图列表」(即安全容器内的子视图数组)进行遍历与按 tag 查找;非安全窗口时仍使用窗口的 subviews。这样无论是普通窗口还是安全窗口,都能正确找到浮层并执行移除、拖拽等逻辑。

示意代码(Objective-C):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 按 tag 查找加在「窗口」上的浮层时,兼容安全窗口
id window = [AppDelegate shared].window;
NSArray<UIView *> *subviewsToSearch = [window isKindOfClass:[SecureWindow class]]
? [(SecureWindow *)window subviewsForOverlayTagLookup]
: [window subviews];

UIView *overlay = nil;
for (UIView *view in subviewsToSearch) {
if (view.tag == kOverlayTag) {
overlay = view;
break;
}
}
if (overlay) {
// 执行移除、更新 frame、拖拽逻辑等
}

3.2 Toast、Loading 等「以窗口为父视图」的展示

  • 现象:安全容器对应的系统私有视图,对后加在其上的部分子视图可能存在渲染或命中测试上的差异,导致 Toast、Loading 等加在窗口上时显示异常或尺寸被错误修改(若曾对「所有加在窗口的视图」统一设全屏 frame,会加剧该问题)。
  • 注意:若 Toast / Loading 在「未指定父视图」时默认加在窗口上,在安全窗口下可改为加在顶层控制器视图等稳定容器上,避免依赖安全容器对后加子视图的渲染行为;同时仅对「根内容」设全屏 frame,不对后续 overlay 改 frame。

示意代码(选择展示容器):

1
2
3
4
5
6
7
8
// 当 inView 为空时,决定 Toast/Loading 加在哪个视图上
func resolveToastContainerView() -> UIView? {
guard let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) else { return nil }
if window is SecureWindow, let topVC = topViewController(for: window), let v = topVC.view {
return v // 安全窗口下用顶层 VC.view,避免加在安全容器内导致渲染异常
}
return window
}

3.3 浮层点击、拖拽「不响应」

  • 可能原因一:事件与查找无关,而是查找失败。拖拽/点击的处理逻辑里若仍通过「窗口的直接子视图」按 tag 查找浮层,在安全窗口下会找不到,逻辑提前 return,表现为「不响应」。
  • 可能原因二:安全容器为系统私有视图,其子视图的 hitTest / 事件传递在个别场景下可能与预期不符,需结合具体视图层级与手势配置排查。
  • 建议:先统一将「在窗口上按 tag 找浮层」改为使用上述 overlay 查找方式,再视情况排查 hitTest、手势冲突、userInteractionEnabled 等。

3.4 坐标转换、只读使用窗口

  • 仅读取窗口、做坐标转换(如 convertRect:fromView:)、访问 rootViewController 等与 addSubview 规则无关的用法,无需因防截屏而修改

3.5 防截屏的边界

  • 防截屏仅影响被放入安全容器的内容在截屏/录屏画面中的可见性(被系统排除)。
  • 应用内仍可监听系统截屏/录屏通知(如 UIScreenCapturedDidChangeNotificationUIScreen.isCaptured)做业务逻辑:例如提示用户、显示自定义遮挡层等,与「安全容器内不参与截屏」是互补关系。

四、小结

要点 说明
核心思路 用系统「安全输入」视图的私有子视图作为安全容器,重写窗口 addSubview 使所有内容进该容器,从而被截屏/录屏排除。
根内容 仅第一个加入安全容器的视图设全屏 frame;其余 overlay 保持原 frame。
查找浮层 安全窗口下必须用「overlay 查找接口」返回的列表(安全容器子视图),不能再用窗口的直接 subviews。
Toast/Loading 可考虑以顶层 VC.view 等为展示容器,避免安全容器对后加子视图的渲染/尺寸问题。
不响应交互 先检查是否因「仍用窗口 subviews 查找」导致找不到视图;再排查 hitTest、手势与层级。