【iOS】全局水印的实现和踩坑

2,019 阅读4分钟

我正在参加「掘金·启航计划」


公司的 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 = falsewaterMark.layer.zPosition = .greatestFiniteMagnitude

isUserInteractionEnabled = false决定了水印不响应用户操作,可以不影响水印下的视图的操作。

设置layer的 zPosition 为最大值保证了即使水印视图不在 window 的 subviews 的最顶层也能让它始终显示在最顶层。

让我们看看最终效果,水印铺满全屏幕,且不影响按钮的点击。

image-20221027233458997.png

这样做,适合只有一个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上绘制文字而已,并不影响事件传递,但我们注释掉绘制方法后,上面提到的视图控制器就都可以正常工作了。

目前我的解决方案是,在使用上述系统自带的视图控制器时,隐藏掉水印。除此之外我没有找到更好的解决方案,如果大家有更好的解决方案,欢迎在评论区讨论。