能登 要

札幌在住のiOSアプリ開発者。SwiftUI により分割されたデバイス間を縦横にやりとりできる考え方に転換しています。

iOSアプリ開発者。2009年のiPhoneアプリ開発開始時期から活動。開発言語のアップデートの中でSwiftUIおよび周辺技術に着目中。

カメラアクセス非承認時のクラッシュタイミング(iOS/iPadOSアプリ)

アプリ開発始めた方、カメラアプリを何となく使っているアプリ開発者向け、カメラアクセス承認チェックが実装されていない古いアプリに触る機会を得たタイミングで得られた知見についてのメモ。

端的に云うと

iOS カメラ機能を使う場合コードリーダー機能とカメラ機能でエラーが出るタイミングが異なるので問題切り分け時に注意する。

1) iOSアプリの花形、カメラアプリ

最初にiOSアプリを作る、試しに作ってみるとしたらハードウェアを使って見栄えがするカメラアプリを選択選びたくなるのが開発者の心情(?)である。Webに掲載されている記事としてもカメラアプリを作るのは定番といえる。

2) 併用されるカメラ機能

モバイルデバイスのカメラは、被写体を撮影録画する機能のほか、QRコードなどコードリーダー機能を介してモバイルデバイスに情報を取り込み機能の側面も持っている。カメラのファインダーの役割を果たすプレビュー機能を合わせると、

  1. 被写体の撮影録画
  2. コードスキャン
  3. プレビューの表示

がモバルカメラが提供している一般に考えつく機能だろう。3のプレビューは常に使うとして1と2は個別に使う場合もあるが、アプリ内で個別の機能として使用する、それぞれを組み合わせて使用するなどが考えられる。商品の外観を撮影し、商品のバーコードを読み取ることができるメタ情報を紐づけるような機能は撮影機能とコードスキャンが組み合わされた例といえる。

撮影機能とコードスキャン機能が1つのアプリに含まれている事はそれほど特殊というわけではない。

3) アクセス承認未対応アプリでのクラッシュ挙動の違い

カメラアクセス非承認時を考慮しない古いアプリをメンテナンス機会を得た際に気付いた点として、アプリがクラッシュするタイミングが異なってくる。

カメラアクセス承認についてはこちらの記事が参考になる

【iOS】アプリに各アクセス権限が付与されているか確認する方法 - Qiita

iOSのカメラ機能を使った写真撮影/コードリーダー(ここではQRCode)のシンプルなサンプルコードは以下となる。

GitHub - notoroid/SimpleCamera

import UIKit
import AVFoundation

class SimpleCameraViewController: UIViewController {
    @IBOutlet weak var previewPlaceholderView: UIView!
    @IBOutlet weak var captureButton: UIButton!
    @IBOutlet weak var modeLabel: UILabel!

    let session = AVCaptureSession()
#if QRCODE_READER
    let metadataOutput = AVCaptureMetadataOutput()
#else
    let output = AVCapturePhotoOutput()
#endif
    let settings = AVCapturePhotoSettings()
    var previewLayer: AVCaptureVideoPreviewLayer? = nil

    override func viewDidLoad() {
        super.viewDidLoad()

#if QRCODE_READER
        setupReaderFeature()
#else
        setupCamFeature()
#endif
    }

#if QRCODE_READER
    private func setupReaderFeature() {
        modeLabel.text = "QRCode Reader"
        captureButton.isHidden = true

        guard let captureDevice = AVCaptureDevice.default(for: .video), let input = try? AVCaptureDeviceInput(device: captureDevice) , session.canAddInput(input), session.canAddOutput(metadataOutput) else {
            return
        }
        session.addInput(input)
        session.addOutput(metadataOutput)
        session.startRunning()

        metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
        metadataOutput.metadataObjectTypes = [.qr]

        previewLayer = AVCaptureVideoPreviewLayer(session: session)
        if let previewLayer = previewLayer {
            previewLayer.frame = previewPlaceholderView.bounds
            previewLayer.videoGravity = .resizeAspectFill
            previewPlaceholderView.layer.addSublayer(previewLayer)
        }
        previewPlaceholderView.clipsToBounds = true
    }
#else
    private func setupCamFeature() {
        modeLabel.text = "Camera"

        guard let captureDevice = AVCaptureDevice.default(for: .video), let input = try? AVCaptureDeviceInput(device: captureDevice), session.canAddInput(input), session.canAddOutput(output) else {
            return
        }
        session.addInput(input)
        session.addOutput(output)
        session.startRunning()

        previewLayer = AVCaptureVideoPreviewLayer(session: session)
        if let previewLayer = previewLayer {
            previewLayer.frame = previewPlaceholderView.bounds
            previewLayer.videoGravity = .resizeAspectFill
            previewPlaceholderView.layer.addSublayer(previewLayer)
        }
        previewPlaceholderView.clipsToBounds = true
    }
    @IBAction func onCapture(_ sender: Any) {
        output.capturePhoto(with: AVCapturePhotoSettings(), delegate: self)
    }
#endif
}

#if QRCODE_READER
extension SimpleCameraViewController: AVCaptureMetadataOutputObjectsDelegate {
    func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
        guard !metadataObjects.isEmpty, let metadataObject = metadataObjects.first, let qr = metadataObject as? AVMetadataMachineReadableCodeObject else {
            return
        }
        print("qr=\(qr.stringValue)")
    }
}
#else
extension SimpleCameraViewController: AVCapturePhotoCaptureDelegate {
    func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
        guard let data = photo.fileDataRepresentation(), let image = UIImage(data: data) else {
            return
        }
        print("width=\(image.size.width)")
        print("height=\(image.size.height)")
    }
}
#endif

プレビュー表示を担当するAVCaptureVideoPreviewLayer は、AVFoundationフレームワークが提供するUIKit向けのプレビュー用Layer派生オブジェクトでカメラのプレビュー映像を画面に最適化して表示する。カメラアクセス非承認時にAVCaptureVideoPreviewLayerを使ったプレビューはアプリはクラッシュせずプレビューだけが表示されない。

カメラアクセス非承認時にカメラ撮影は撮影決定時にアプリがクラッシュする。

コードリーダーはカメラ開始直後にクラッシュする。

これらの違いはカメラから入力された映像を出力されるoutputを使用するタイミングが撮影とコードリーダーでタイミングが異なることによる違いと思われる。撮影は決定時までoutputからの情報を必要とせずコードリーダーは起動直後からoutputからの情報を必要とするためアプリクラッシュの発生タイミングが異なってくる。

AVAuthorizationStatus light

カメラアクセス承認未対応の場合のクラッシュタイミング

機能名 クラッシュタイミング 該当オブジェクト
カメラ撮影 撮影タイミング AVCapturePhotoOutput
コードリーダー リーダー機能初期化時 AVCaptureMetadataOutput
プレビュー クラッシュせず AVCaptureVideoPreviewLayer

4) まとめ

Appleのプライバシー重視の方向によりカメラからのプライバシーが流出することを避けるにはアプリを強制終了するのが最適解と思われているところがある。

サンプルコードとしてカメラアクセス承認に対応していない静止画撮影機能とコードリーダー機能のサンプルを確認してみて欲しい。実機のみ確認可能で、Xcode左上のスキーマをSimpleCamera/SimpleReader を切り替えて、カメラ撮影/カードリーダーを切り替えて違いを確認してみて欲しい。

GitHub - notoroid/SimpleCamera