我正在参加「掘金·启航计划」
公司的 APP 主要是面向企业用户的。需要防止使用 APP 的员工随便截图导致数据泄露,但是iOS系统不支持屏蔽截图,所以需要在全局打上用户名和时间的水印,时刻提醒用户不能截图。
我测试了两种实现方案:
- 使用现有的 UIWindow
- 使用自定义的 UIWindow
如何绘制水印
首先我们看看如何绘制水印。
我们可以继承UIView类,然后重写 draw(_ rect: CGRect)
方法。使用CoreGraphic 绘制水印。
除此之外,还可以继承CALayer,重写
draw(in ctx: CGContext)
方法。效果也差不多,我之所以选择UIView,是因为可以方便使用自动布局省掉一些布局代码。
为什么不使用UILabel呢?
因为UILabel不能设置混合模式只能通过透明度实现简单的叠加效果,而且对于不需要交互和需要大量绘制的水印来说,UILabel 太重了。
具体代码和代码的说明如下:
// WatermarkView.swift
import UIKit
// 继承自UIView
class WatermarkView: UIView {
var watermark: String = "水印测试" {
didSet {
// 水印文字有更新,应该重新绘制
setNeedsDisplay()
}
}
/// 时间格式化
private var dateFormater = DateFormatter()
override init(frame: CGRect) {
super.init(frame: frame)
dateFormater.dateFormat = "yyyy-MM-dd HH:mm"
// 让背景透明,水印视图不能遮挡下面的其他页面
backgroundColor = .clear
// 设置定时器,每隔一段时间刷新水印上的时间,因为水印会伴随整个APP的生命周期,所以没有考虑回收timer。
Timer.scheduledTimer(withTimeInterval: 30, repeats: true, block: { [weak self] _ in
self?.setNeedsDisplay()
})
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
// 布局有变动,更新水印
self.setNeedsDisplay()
}
// 绘制水印
override func draw(_ rect: CGRect) {
// 获取上下文对象
let context = UIGraphicsGetCurrentContext()
// 保存上下文状态
context?.saveGState()
defer {
// 绘制完成后,恢复状态
context?.restoreGState()
}
// 设置填充颜色为透明
UIColor.clear.setFill()
// 用透明色清除整个视图,重新绘制内容
context?.clear(rect)
// 设置混合模式为柔光,能更好的让水印文字与内容混合,减少水印的突兀感。
context?.setBlendMode(.softLight)
// 平移和旋转画布,目的是绘制倾斜的水印
context?.translateBy(x: rect.width / 2, y: rect.height / 2)
context?.rotate(by: -(CGFloat.pi / 4))
context?.translateBy(x: -rect.width / 2, y: -rect.height / 2)
// 构造水印内容
let date = Date()
let textAttribute:[NSAttributedString.Key : Any] = [.foregroundColor:UIColor.black.withAlphaComponent(0.05),
.font:UIFont.systemFont(ofSize: 15)]
let text = "\(watermark) \(dateFormater.string(from: date))" as NSString
let textSize = text.boundingRect(with: CGSize(width: .max, height: .max), options: .usesLineFragmentOrigin, attributes: textAttribute, context: nil)
// 每个水印的横向距离
let stepX: CGFloat = textSize.width + 20
// 每个水印的纵向距离
let stepY: CGFloat = textSize.height + 20
let w = (sqrt(pow(rect.width, 2)+pow(rect.height, 2)))
var y: CGFloat = -w
// 让相邻两行的水印交错排列
var doOffset = false
// 循环绘制水印,充满屏幕,之所以是两倍宽度,是为了让屏幕边缘也有一些被裁切了一般的水印,让水印填充得更满
while y < 2*w {
defer {
y += stepY
doOffset.toggle()
}
var x: CGFloat = -2*w
if doOffset {
x -= stepX/2
}
while x < w {
defer { x += stepX }
let p = CGPoint(x: x, y: y)
// 绘制文字
text.draw(at: p, withAttributes:textAttribute)
}
}
}
}
这样我们就得到了一个显示水印的视图。
接下来考虑如何让它永远在视图的最上方,且不影响操作。
使用现有的UIWindow
第一种方式,可以将它添加到现有的UIWindow上。
在AppDelegete 或者 如果你的APP使用 Scene 就在 SceneDelegate上 启动APP或者有新的 Scene 连接时,将水印视图添加到window上。
guard let window = window else { return }
let waterMark = WatermarkView(frame: window.bounds)
// 添加到window中
window?.addSubview(waterMark)
// 让水印视图不响应用户操作
waterMark.isUserInteractionEnabled = false
// 设置水印layer的视图层级为最大值。
waterMark.layer.zPosition = .greatestFiniteMagnitude
这里最关键的两句代码在于 waterMark.isUserInteractionEnabled = false
和 waterMark.layer.zPosition = .greatestFiniteMagnitude
isUserInteractionEnabled = false决定了水印不响应用户操作,可以不影响水印下的视图的操作。
设置layer的 zPosition 为最大值保证了即使水印视图不在 window 的 subviews 的最顶层也能让它始终显示在最顶层。
让我们看看最终效果,水印铺满全屏幕,且不影响按钮的点击。
这样做,适合只有一个window的APP,如果在APP生命周期中会创建多个UIWindow,如果不在新的UIWindow上添加水印视图,且新的window层级较高,那么新window上将没有水印。
所以、有了第二种实现方式。
创建新的UIWindow 来添加水印
首先,我们创建一个 WatermarkViewController。重写它的 loadView
方法:
// WatermarkViewController.swift
import UIKit
class WatermarkViewController: UIViewController {
override func loadView() {
self.view = WatermarkView(frame: .zero)
}
}
这样,这个Controller的 self.view 就是我们的水印视图了。它的大小会自动撑满整个controller
然后,我们在APP启动时创建一个新的 UIwindow ,如果你使用 Scene 则应该在 SceneDelegate 的 func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions)
方法中做这些操作。
// AppDelegate.swift 或者 SceneDelegate.swift
var watermarkWindow: UIWindow?
// 或者 func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) 方法。
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// 你的初始化代码
// .....
// 如果使用 Scene 则创建window应该使用如下代码
// guard let scene = (scene as? UIWindowScene) else { return }
// watermarkWindow = UIWindow(windowScene: scene)
watermarkWindow = UIWindow(frame: UIScreen.main.bounds)
watermarkWindow?.rootViewController = WatermarkViewController()
watermarkWindow?.isUserInteractionEnabled = false
watermarkWindow?.windowLevel = .init(.greatestFiniteMagnitude)
watermarkWindow?.isHidden = false
return true
}
步骤主要是:
创建 新的window并将它保存到delegate的一个属性上。
设置window的rootViewController 为水印 controller。
设置window不响应用户事件,目的是让水印不影响下面的视图的操作。
设置window的windowLevel 为最大值,目的是让水印window始终保持在最顶端
设置window的isHiden属性为false,让window显示出来。
完成以上步骤,就得到了最终的水印效果,和第一种方式在效果上基本相同,就不再放截图了。
踩坑
以上两种方式好像我们完美的解决了水印的问题,你会高兴的发现,在大部分页面它们都工作正常。
直到你 present 了一个 UIImagePickerViewController 。你会发现,它完全无法操作。同样的问题也出现在 UIDocumentPickerViewController 中。
经过我的不完全测试,同样的问题也会出现在 使用系统的 短信、邮件、联系人 等 Controller 组件上。猜测的原因是苹果对这些复用系统自带页面的ViewController 做了特殊检测,一旦有内容覆盖在其上面,就不响应事件。
要验证这个想法,我们只需要将 WatermarkView 的 draw(_ rect: CGRect)
方法注释掉,并将它的背景颜色设置成透明。理论上,从视图层级和响应者链上来说和之前绘制水印的时候是完全一致的,绘制水印只是在layer上绘制文字而已,并不影响事件传递,但我们注释掉绘制方法后,上面提到的视图控制器就都可以正常工作了。
目前我的解决方案是,在使用上述系统自带的视图控制器时,隐藏掉水印。除此之外我没有找到更好的解决方案,如果大家有更好的解决方案,欢迎在评论区讨论。