21
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

iOSでスクショ、画面録画防止するためのベストプラクティス

Posted at

この記事はand factory.inc Advent Calendar 2023 22日目の記事です。
昨日は @yuu__uuki さんのSwiftUIでのタップ処理の実装と応用でした。

はじめに

著作権的に慎重に扱うべきコンテンツをモバイルアプリでサービス提供する際に、ユーザーのスクリーンショット操作をどう扱うかという問題は避けて通れないと思います。

たとえば動画サービスの大手Netflixさんのアプリでは、動画再生中にスクショを撮ると下のように作品部分が黒塗りされて作品画像が保存されないような仕組みになっています。

IMG_2805.jpeg

 
ではこういう仕様を実装するのも簡単だろう、と思ったのですが意外と一筋縄では行かなかったので簡単にまとめることにしました。

NotificationCenterによるスクショ検知

iOSには画面回転などの端末のイベントを検知したり、ある画面から別の画面にイベントを伝播させたりできるNotificationCenterという仕組みが用意されています。
スクショや画面録画が行われた時に何かしら処理を実行したいとなった時も、まずはこれを試してみる流れになるかと思います。

スクリーンショットを撮ったときはuserDidTakeScreenshotNotificationが、スクリーンレコーディングを開始した時はcapturedDidChangeNotificationが流れてきます。

override func viewDidLoad() {
    super.viewDidLoad()
    
    // スクショを監視
    NotificationCenter.default.addObserver(
        self,
        selector: #selector(didTakeScreenCaptured(_:)),
        name: UIApplication.userDidTakeScreenshotNotification,
        object: nil
    )
    
    // スクリーンレコーディング(の状態変化)を監視
    NotificationCenter.default.addObserver(
        self,
        selector: #selector(didStartScreenRecording(_:)),
        name: UIApplication.capturedDidChangeNotification,
        object: nil
    )
}

@objc private func didTakeScreenCaptured(_ notification: Notification) {
    blackView.isHidden = UIScreen.main.isCaptured
}

@objc private func didStartScreenRecording(_ notification: Notification) {
    blackView.isHidden = UIScreen.main.isCaptured
}

参考:https://qiita.com/takashings/items/3fad553fc97375ec7d9f

これにより画面録画中はコンテンツに目隠しをかけるなどコンテンツの複製をある程度防ぐことはできますが、スクリーンショットに関してはこのイベント通知を受け取るタイミングは撮影が始まった(終わった)後なので静止画コンテンツは保護することができません。

では、スクリーンショットからコンテンツを保護することは不可能なのかというと、必ずしもそうでもないようです。裏技があります。

UITextFieldの_UITextLayoutCanvasViewを応用する

UITextFieldはユーザーからのテキスト入力を受け付けるUIViewです。

世の中の多くのアプリにはパスワード欄など入力しているところを他人にうっかり録画されると困るUIがあるかと思いますが、このUITextFieldの.isSecureTextEntryプロパティをtrueにすることで入力中の情報が録画されないようになります。

この手法自体は開発者コミュニティの間では知る人ぞ知るやり方として共有されていたので、本記事はこれらを参考にして書いています。

実装の方針としては、UITextFieldを構成するSubviewsの一つ、_UITextLayoutCanvasViewがスクリーンショットや画面録画を回避するUIViewになっているので、このUIViewの中にスクリーンショットで撮られたくないUIViewやCALayerを子ビューとして埋め込むという感じです。

実装例

先人の例を参考に、より具体的なコードを見ていきます。

こちらの記事では既存のUIViewの親CALayerに_UITextLayoutCanvasViewのlayerを追加して、そこに対象UIView自身のCALayerを入れるという方法が取られてます。

UIViewの拡張として定義されているので使い回ししやすく、コスパの良いやり方だと思います。

抜粋
import UIKit

extension UIView {
    func makeSecure() {
        DispatchQueue.main.async {
            let field = UITextField()
            field.isSecureTextEntry = true
            self.addSubview(field)
            field.translatesAutoresizingMaskIntoConstraints = false
            field.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true
            field.centerXAnchor.constraint(equalTo: self.centerXAnchor).isActive = true
            self.layer.superlayer?.addSublayer(field.layer)
            field.layer.sublayers?.first?.addSublayer(self.layer)
        }
    }
}

下の記事ではUITextFieldから_UITextLayoutCanvasViewを取り出して独自のクラスSecureFieldを定義し、これをUIViewController.viewaddSubviewすることでスクショ防止機能を実現しています。個人的にはこちらの方が直感的で好みです。

抜粋
class SecureField : UITextField {

    override init(frame: CGRect) {
        super.init(frame: .zero)
        self.isSecureTextEntry = true
        self.translatesAutoresizingMaskIntoConstraints = false
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    weak var secureContainer: UIView? {
        let secureView = self.subviews.filter({ subview in
            type(of: subview).description().contains("CanvasView")
        }).first
        secureView?.translatesAutoresizingMaskIntoConstraints = false
        secureView?.isUserInteractionEnabled = true //To enable child view's userInteraction in iOS 13
        return secureView
    }
    
    override var canBecomeFirstResponder: Bool {false}
    override func becomeFirstResponder() -> Bool {false}
}
class SampleViewController: UIViewController {

    @IBOutlet weak var containerView: UIView!
    
    private var imageView = UIImageView()

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        
        imageView.image = UIImage(systemName: "camera")
        imageView.contentMode = .scaleAspectFit
        
        guard let secureView = SecureField().secureContainer else {
            return
        }
        secureView.addSubview(imageView)
        imageView.matchParent(secureView)
        containerView.addSubview(secureView)
        secureView.matchParent(containerView)
    }
}

こちらをシミュレーターで撮影してみた様子が以下のgifです。

Dec-22-2023 02-16-19.gif

課題

以上で無事にスクショ防止機能が実現できてめでたしめでたし…としたいところですが課題は残っています。

まず、安定して成功確認が取れているOSバージョンが限られているという問題があります。
先程挙げたこちらの記事ではiOS13以下だとマイナーバージョンによって結果が異なるとのことです。

もう一つ問題なのが、そもそも正規のやり方ではないため、今後のOSのアップデート内容いかんによっては突然予想外の挙動になるリスクがあるということです。

とはいえ、基本的にiOS14からiOS17の間で変更点があったという経緯も無いので、万が一動きがおかしくなった時に早急に復旧できるようサーバーサイドやFirebase Remote Configなどからフラグを返すようにするといった工夫をしておけば吉かと思います。

おわりに

うっかり書くタイミングを見失ったのですが、以上をまとめて即席で実装できるOSSライブラリもありましたので、手っ取り早く試してみたい方はこちらを触ってみても良いかもしれません。

 
明日のAdvent Calenderもお楽しみに!

21
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?