mcpField
A
axiom-camera-capture

axiom-camera-capture

github.com/charleswiltgen開発1.2k235 回インストール今日更新
リポジトリ

axiom-camera-capture について

Appleアプリで独自のカメラ機能を開発するためのAVFoundationスキルです。セッション設定、写真・動画撮影、回転、応答性、中断への対処、カメラ切り替えを扱います。

主な機能

  1. 01キャプチャセッションとプレビューの設定
  2. 02写真撮影と音声付き動画の録画
  3. 03RotationCoordinatorによる回転処理
  4. 04撮影の応答性向上
  5. 05中断への対処とカメラ切り替え

活用シーン

  1. 01独自のSwiftUIカメラプレビューの構築
  2. 02速度と画質を調整できる写真撮影の実装
  3. 03音声付き動画の録画
  4. 04回転や中断処理の問題解決

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/charleswiltgen/axiom --skill axiom-camera-capture

インストール後、Claude Code で/axiom-camera-captureを入力して読み込まれたか確認します。

詳細

AVFoundation を使用したカメラキャプチャ

セッションのセットアップ、写真のキャプチャ、ビデオ録画、応答性の高いキャプチャ UX、回転の処理、セッションのライフサイクル管理など、カメラキャプチャの実装方法を説明します。

このスキルを使う場面

次のような場合に使用します。

  • ☑ カスタムカメラ UI を構築する(システムピッカーではないもの)
  • ☑ 品質と速度のトレードオフを考慮して写真をキャプチャする
  • ☑ 音声付きでビデオを録画する
  • ☑ デバイスの回転を正しく処理する(RotationCoordinator)
  • ☑ 応答性の高いキャプチャを実現する(ゼロシャッターラグ)
  • ☑ セッションの中断を処理する(電話の着信、マルチタスキング)
  • ☑ フロントカメラとバックカメラを切り替える
  • ☑ キャプチャの品質と解像度を設定する

プロンプト例

「SwiftUI でカメラプレビューを設定するにはどうすればよいですか?」 「電話がかかってくるとカメラがフリーズします」 「フロントカメラで写真プレビューの向きが正しくありません」 「写真撮影を瞬時に感じさせるにはどうすればよいですか?」 「遅延処理を使うべきですか?」 「カメラの撮影に時間がかかりすぎます」 「フロントカメラとバックカメラを切り替えるにはどうすればよいですか?」 「音声付きでビデオを録画するにはどうすればよいですか?」

注意すべき点

必要以上に複雑にしている兆候:

  • ❌ startRunning() をメインスレッドで呼び出している(数秒間 UI がブロックされます)
  • ❌ RotationCoordinator(iOS 17 以降)ではなく、非推奨の videoOrientation を使用している
  • ❌ セッションの中断を監視していない(電話がかかってくるとアプリがフリーズします)
  • ❌ キャプチャのたびに新しい AVCaptureSession を作成している(コストがかかります)
  • ❌ ビデオに .photo プリセットを使用している(フォーマットが不適切です)
  • ❌ photoQualityPrioritization を無視している(撮影に時間がかかります)
  • ❌ .notAuthorized の許可状態を処理していない
  • ❌ beginConfiguration()/commitConfiguration() を使わずにセッションを変更している
  • ❌ カスタムカメラ UI に UIImagePickerController を使用している(制御が限定されます)

必須の最初の手順

カメラ機能を実装する前に、次の手順を実行してください。

1. キャプチャモードを選択する
What do you need?

┌─ Just let user pick a photo?
│  └─ Don't use AVFoundation - use PHPicker or PhotosPicker
│     See: /skill axiom-photo-library
│
├─ Simple photo/video capture with system UI?
│  └─ UIImagePickerController (but limited customization)
│
├─ Custom camera UI with photo capture?
│  └─ AVCaptureSession + AVCapturePhotoOutput
│     → Continue with this skill
│
├─ Custom camera UI with video recording?
│  └─ AVCaptureSession + AVCaptureMovieFileOutput
│     → Continue with this skill
│
└─ Both photo and video in same session?
   └─ AVCaptureSession + both outputs
      → Continue with this skill
2. カメラの使用許可をリクエストする
import AVFoundation

func requestCameraAccess() async -> Bool {
    let status = AVCaptureDevice.authorizationStatus(for: .video)

    switch status {
    case .authorized:
        return true
    case .notDetermined:
        return await AVCaptureDevice.requestAccess(for: .video)
    case .denied, .restricted:
        // Show settings prompt
        return false
    @unknown default:
        return false
    }
}

Info.plist に必要な項目:

<key>NSCameraUsageDescription</key>
<string>Take photos and videos</string>

音声の場合(ビデオ録画):

<key>NSMicrophoneUsageDescription</key>
<string>Record audio with video</string>
3. セッションのアーキテクチャを理解する
AVCaptureSession
    ├─ Inputs
    │   ├─ AVCaptureDeviceInput (camera)
    │   └─ AVCaptureDeviceInput (microphone, for video)
    │
    ├─ Outputs
    │   ├─ AVCapturePhotoOutput (photos)
    │   ├─ AVCaptureMovieFileOutput (video files)
    │   └─ AVCaptureVideoDataOutput (raw frames)
    │
    └─ Connections (automatic between compatible input/output)

重要なルール:セッションの設定はすべて、メインスレッドではなく専用のシリアルキューで行います。

基本パターン

パターン 1:基本的なセッションのセットアップ

用途:写真キャプチャ機能を備えたカメラプレビューをセットアップします。

import AVFoundation

class CameraManager: NSObject {
    let session = AVCaptureSession()
    let photoOutput = AVCapturePhotoOutput()

    // CRITICAL: Dedicated serial queue for session work
    private let sessionQueue = DispatchQueue(label: "camera.session")

    func setupSession() {
        sessionQueue.async { [self] in
            session.beginConfiguration()
            defer { session.commitConfiguration() }

            // 1. Set session preset
            session.sessionPreset = .photo

            // 2. Add camera input
            guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera,
                                                        for: .video,
                                                        position: .back),
                  let input = try? AVCaptureDeviceInput(device: camera),
                  session.canAddInput(input) else {
                return
            }
            session.addInput(input)

            // 3. Add photo output
            guard session.canAddOutput(photoOutput) else { return }
            session.addOutput(photoOutput)

            // 4. Configure photo output
            photoOutput.isHighResolutionCaptureEnabled = true
            photoOutput.maxPhotoQualityPrioritization = .quality
        }
    }

    func startSession() {
        sessionQueue.async { [self] in
            if !session.isRunning {
                session.startRunning()  // Blocking call - never on main thread!
            }
        }
    }

    func stopSession() {
        sessionQueue.async { [self] in
            if session.isRunning {
                session.stopRunning()
            }
        }
    }
}

所要時間:実装に 30 分

パターン 2:SwiftUI カメラプレビュー

用途:SwiftUI ビューにカメラプレビューを表示します。

import SwiftUI
import AVFoundation

struct CameraPreview: UIViewRepresentable {
    let session: AVCaptureSession

    func makeUIView(context: Context) -> PreviewView {
        let view = PreviewView()
        view.previewLayer.session = session
        view.previewLayer.videoGravity = .resizeAspectFill
        return view
    }

    func updateUIView(_ uiView: PreviewView, context: Context) {}

    class PreviewView: UIView {
        override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
        var previewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
    }
}

// Usage in SwiftUI
struct CameraView: View {
    @StateObject private var camera = CameraManager()

    var body: some View {
        CameraPreview(session: camera.session)
            .ignoresSafeArea()
            .onAppear { camera.startSession() }
            .onDisappear { camera.stopSession() }
    }
}

所要時間:実装に 20 分

パターン 3:RotationCoordinator による回転の処理(iOS 17 以降)

用途:デバイスの回転にかかわらず、プレビューとキャプチャした写真の向きを正しく保ちます。

RotationCoordinator を使う理由:非推奨の videoOrientation では、デバイスの向きを手動で監視する必要があります。RotationCoordinator は重力を自動的に追跡し、角度を提供します。

import AVFoundation

class CameraManager {
    private var rotationCoordinator: AVCaptureDevice.RotationCoordinator?
    private var rotationObservation: NSKeyValueObservation?

    func setupRotationCoordinator(device: AVCaptureDevice, previewLayer: AVCaptureVideoPreviewLayer) {
        // Create coordinator with device and preview layer
        rotationCoordinator = AVCaptureDevice.RotationCoordinator(
            device: device,
            previewLayer: previewLayer
        )

        // Observe preview rotation changes
        rotationObservation = rotationCoordinator?.observe(
            \.videoRotationAngleForHorizonLevelPreview,
            options: [.new]
        ) { [weak previewLayer] coordinator, _ in
            // Update preview layer rotation on main thread
            DispatchQueue.main.async {
                previewLayer?.connection?.videoRotationAngle = coordinator.videoRotationAngleForHorizonLevelPreview
            }
        }

        // Set initial rotation
        previewLayer.connection?.videoRotationAngle = rotationCoordinator!.videoRotationAngleForHorizonLevelPreview
    }

    func captureRotationAngle() -> CGFloat {
        // Use this angle when capturing photos
        rotationCoordinator?.videoRotationAngleForHorizonLevelCapture ?? 0
    }
}

キャプチャ時:

func capturePhoto() {
    let settings = AVCapturePhotoSettings()

    // Apply rotation angle from coordinator
    if let connection = photoOutput.connection(with: .video) {
        connection.videoRotationAngle = captureRotationAngle()
    }

    photoOutput.capturePhoto(with: settings, delegate: self)
}

所要時間:実装に 45 分。回転に関する問題のデバッグに 2 時間以上かかるのを防げます。

パターン 4:応答性の高いキャプチャパイプライン(iOS 17 以降)

用途:ゼロシャッターラグ、キャプチャの重複、応答性の高いボタン状態により、写真撮影を瞬時に感じさせます。

iOS 17 では、応答性を最大限に高めるため連携して動作する、4 つの補完的な API が導入されました。

4a. ゼロシャッターラグ

直近のフレームを保存するリングバッファを使用し、シャッターをタップした瞬間に「タイムトラベル」します。iOS 17 以降のアプリでは自動的に有効になります。

// Check if supported for current format
if photoOutput.isZeroShutterLagSupported {
    // Enabled by default for apps linking iOS 17+
    // Opt out if causing issues:
    // photoOutput.isZeroShutterLagEnabled = false
}

重要な理由:ZSL がない場合、タップからフレームのキャプチャまでに遅延が生じます。アクションショットでは、その瞬間はすでに過ぎています。

要件:iPhone XS 以降。フラッシュ撮影、手動露出、ブラケット撮影、構成写真の配信には適用されません。

4b. 応答性の高いキャプチャ(キャプチャの重複)

前のキャプチャの処理中に、新しいキャプチャを開始できます。

// Check support first
if photoOutput.isZeroShutterLagSupported {
    photoOutput.isZeroShutterLagEnabled = true  // Required for responsive capture

    if photoOutput.isResponsiveCaptureSupported {
        photoOutput.isResponsiveCaptureEnabled = true
    }
}

トレードオフ:ピーク時のメモリ使用量が増加します。アプリのメモリに制約がある場合は、無効のままにすることを検討してください。

要件:A12 Bionic(iPhone XS)以降。

4c. 高速撮影の優先

複数の写真をすばやく連続して撮影するとき(バーストモードなど)に、品質を自動で調整します。

if photoOutput.isFastCapturePrioritizationSupported {
    photoOutput.isFastCapturePrioritizationEnabled = true
    // When enabled, rapid captures use "balanced" quality instead of "quality"
    // to maintain consistent shot-to-shot time
}

有効にするタイミング:ユーザー向けの切り替え項目です(Camera.app の「撮影速度を優先」)。品質が低下するため、デフォルトではオフです。

4d. レディネスコーディネーター(ボタン状態の管理)

UX にとって重要です:非同期処理による遅延なしで、シャッターボタンの状態を同期的に更新します。

class CameraManager {
    private var readinessCoordinator: AVCapturePhotoOutputReadinessCoordinator!

    func setupReadinessCoordinator() {
        readinessCoordinator = AVCapturePhotoOutputReadinessCoordinator(photoOutput: photoOutput)
        readinessCoordinator.delegate = self
    }

    func capturePhoto() {
        var settings = AVCapturePhotoSettings()
        settings.photoQualityPrioritization = .balanced

        // Tell coordinator to track this capture BEFORE calling capturePhoto
        readinessCoordinator.startTrackingCaptureRequest(using: settings)

        photoOutput.capturePhoto(with: settings, delegate: self)
    }
}

extension CameraManager: AVCapturePhotoOutputReadinessCoordinatorDelegate {
    func readinessCoordinator(_ coordinator: AVCapturePhotoOutputReadinessCoordinator,
                              captureReadinessDidChange captureReadiness: AVCapturePhotoOutput.CaptureReadiness) {
        DispatchQueue.main.async {
            switch captureReadiness {
            case .ready:
                self.shutterButton.isEnabled = true
                self.shutterButton.alpha = 1.0

            case .notReadyMomentarily:
                // Brief delay - disable to prevent double-tap
                self.shutterButton.isEnabled = false

            case .notReadyWaitingForCapture:
                // Flash is firing - dim button
                self.shutterButton.alpha = 0.5

            case .notReadyWaitingForProcessing:
                // Processing previous photo - show spinner
                self.showProcessingIndicator()

            case .sessionNotRunning:
                self.shutterButton.isEnabled = false

            @unknown default:
                break
            }
        }
    }
}

レディネスコーディネーターを使う理由:使用しない場合、撮影状態を手動で追跡する必要があり、処理中にユーザーがシャッターボタンを連打する可能性があります。

品質の優先(基本)

新しい API を使わない場合でも役立ちます。

func capturePhoto() {
    var settings = AVCapturePhotoSettings()

    // Speed vs Quality tradeoff
    // .speed     - Fastest capture, lower quality
    // .balanced  - Good default
    // .quality   - Best quality, may have delay
    settings.photoQualityPrioritization = .speed

    // For specific use cases:
    // - Social sharing: .speed (users expect instant)
    // - Document scanning: .quality (accuracy matters)
    // - General photography: .balanced

    photoOutput.capturePhoto(with: settings, delegate: self)
}

処理の延期(iOS 17 以降):

応答性を最大限に高めるため、撮影処理はプロキシ画像をすぐに返し、完全な Deep Fusion 処理はバックグラウンドで行われます。

// Check support and enable deferred processing
if photoOutput.isAutoDeferredPhotoDeliverySupported {
    photoOutput.isAutoDeferredPhotoDeliveryEnabled = true
}

処理を延期する場合のデリゲートコールバック:

// Called for BOTH regular photos AND deferred proxies
func photoOutput(_ output: AVCapturePhotoOutput,
                 didFinishProcessingPhoto photo: AVCapturePhoto,
                 error: Error?) {
    guard error == nil else { return }

    // Non-deferred photo - save directly
    if !photo.isRawPhoto, let data = photo.fileDataRepresentation() {
        savePhotoToLibrary(data)
    }
}

// Called ONLY for deferred proxies - save to PhotoKit for later processing
func photoOutput(_ output: AVCapturePhotoOutput,
                 didFinishCapturingDeferredPhotoProxy deferredPhotoProxy: AVCaptureDeferredPhotoProxy,
                 error: Error?) {
    guard error == nil else { return }

    // CRITICAL: Save proxy to library ASAP before app is backgrounded
    // App may be force-quit if memory pressure is high during backgrounding
    guard let proxyData = deferredPhotoProxy.fileDataRepresentation() else { return }

    Task {
        try await PHPhotoLibrary.shared().performChanges {
            let request = PHAssetCreationRequest.forAsset()
            // Use .photoProxy resource type - triggers deferred processing in Photos
            request.addResource(with: .photoProxy, data: proxyData, options: nil)
        }
    }
}

最終処理が行われるタイミング:

  • PhotoKit から画像が要求されたときにオンデマンドで行われます
  • または、デバイスがアイドル状態のとき(電源に接続され、使用されていないとき)に自動で行われます

処理の延期を考慮した画像の取得:

// Request with secondary degraded image for smoother UX
let options = PHImageRequestOptions()
options.allowSecondaryDegradedImage = true  // New in iOS 17

PHImageManager.default().requestImage(
    for: asset,
    targetSize: targetSize,
    contentMode: .aspectFill,
    options: options
) { image, info in
    let isDegraded = info?[PHImageResultIsDegradedKey] as? Bool ?? false

    if isDegraded {
        // First: Low quality (immediate)
        // Second: Medium quality (new - while processing)
        // Third callback will be final quality
        self.showTemporaryImage(image)
    } else {
        // Final quality - processing complete
        self.showFinalImage(image)
    }
}

要件:iPhone 11 Pro 以降。フラッシュ撮影や、拡張処理の恩恵を受けないフォーマットでは使用されません。

重要な考慮事項:

  • 処理が延期された写真には、ピクセルバッファーのカスタマイズ(フィルター、メタデータの変更)を適用できません
  • 編集には、処理後に PhotoKit の調整機能を使用してください
  • バックグラウンドに移行した後に利用できる時間は限られているため、プロキシ画像をできるだけ早くライブラリに保存してください

コスト:実装に 1 時間。「カメラが遅い」という不満を防げます

パターン 5:セッションの中断処理

ユースケース:電話の着信、マルチタスク、システムカメラの使用に対応します。

class CameraManager {
    private var interruptionObservers: [NSObjectProtocol] = []

    func setupInterruptionHandling() {
        // Session was interrupted
        let interruptedObserver = NotificationCenter.default.addObserver(
            forName: .AVCaptureSessionWasInterrupted,
            object: session,
            queue: .main
        ) { [weak self] notification in
            guard let reason = notification.userInfo?[AVCaptureSessionInterruptionReasonKey] as? Int,
                  let interruptionReason = AVCaptureSession.InterruptionReason(rawValue: reason) else {
                return
            }

            switch interruptionReason {
            case .videoDeviceNotAvailableInBackground:
                // App went to background - normal, will resume
                self?.showPausedOverlay()

            case .audioDeviceInUseByAnotherClient:
                // Another app using audio
                self?.showInterruptedBanner("Audio in use by another app")

            case .videoDeviceInUseByAnotherClient:
                // Another app using camera
                self?.showInterruptedBanner("Camera in use by another app")

            case .videoDeviceNotAvailableWithMultipleForegroundApps:
                // Split View/Slide Over - camera not available
                self?.showInterruptedBanner("Camera unavailable in Split View")

            case .videoDeviceNotAvailableDueToSystemPressure:
                // Thermal state - reduce quality or stop
                self?.handleThermalPressure()

            @unknown default:
                self?.showInterruptedBanner("Camera interrupted")
            }
        }
        interruptionObservers.append(interruptedObserver)

        // Session interruption ended
        let endedObserver = NotificationCenter.default.addObserver(
            forName: .AVCaptureSessionInterruptionEnded,
            object: session,
            queue: .main
        ) { [weak self] _ in
            self?.hideInterruptedBanner()
            self?.hidePausedOverlay()
            // Session automatically resumes - no need to call startRunning()
        }
        interruptionObservers.append(endedObserver)
    }

    deinit {
        interruptionObservers.forEach { NotificationCenter.default.removeObserver($0) }
    }
}

コスト:実装に 30 分。「カメラがフリーズする」というバグ報告を防げます

パターン 6:カメラの切り替え(前面/背面)

ユースケース:前面カメラと背面カメラを切り替えます。

func switchCamera() {
    sessionQueue.async { [self] in
        guard let currentInput = session.inputs.first as? AVCaptureDeviceInput else {
            return
        }

        let currentPosition = currentInput.device.position
        let newPosition: AVCaptureDevice.Position = currentPosition == .back ? .front : .back

        guard let newDevice = AVCaptureDevice.default(
            .builtInWideAngleCamera,
            for: .video,
            position: newPosition
        ) else {
            return
        }

        session.beginConfiguration()
        defer { session.commitConfiguration() }

        // Remove old input
        session.removeInput(currentInput)

        // Add new input
        do {
            let newInput = try AVCaptureDeviceInput(device: newDevice)
            if session.canAddInput(newInput) {
                session.addInput(newInput)

                // Update rotation coordinator for new device
                if let previewLayer = previewLayer {
                    setupRotationCoordinator(device: newDevice, previewLayer: previewLayer)
                }
            } else {
                // Fallback: restore old input
                session.addInput(currentInput)
            }
        } catch {
            session.addInput(currentInput)
        }
    }
}

前面カメラのミラーリング:前面カメラのプレビューはデフォルトでミラーリングされます(ユーザーの期待に沿っています)。撮影した写真はミラーリングされません(共有に適した状態です)。これは意図的な動作です。

コスト:実装に 20 分

パターン 7:ビデオ録画

ユースケース:音声付きの動画をファイルに録画します。

class CameraManager: NSObject {
    let movieOutput = AVCaptureMovieFileOutput()
    private var currentRecordingURL: URL?

    func setupVideoRecording() {
        sessionQueue.async { [self] in
            session.beginConfiguration()
            defer { session.commitConfiguration() }

            // Set video preset
            session.sessionPreset = .high  // Or .hd1920x1080, .hd4K3840x2160

            // Add microphone input
            if let microphone = AVCaptureDevice.default(for: .audio),
               let audioInput = try? AVCaptureDeviceInput(device: microphone),
               session.canAddInput(audioInput) {
                session.addInput(audioInput)
            }

            // Add movie output
            if session.canAddOutput(movieOutput) {
                session.addOutput(movieOutput)
            }
        }
    }

    func startRecording() {
        guard !movieOutput.isRecording else { return }

        let outputURL = FileManager.default.temporaryDirectory
            .appendingPathComponent(UUID().uuidString)
            .appendingPathExtension("mov")

        currentRecordingURL = outputURL

        // Apply rotation
        if let connection = movieOutput.connection(with: .video) {
            connection.videoRotationAngle = captureRotationAngle()
        }

        movieOutput.startRecording(to: outputURL, recordingDelegate: self)
    }

    func stopRecording() {
        guard movieOutput.isRecording else { return }
        movieOutput.stopRecording()
    }
}

extension CameraManager: AVCaptureFileOutputRecordingDelegate {
    func fileOutput(_ output: AVCaptureFileOutput,
                    didFinishRecordingTo outputFileURL: URL,
                    from connections: [AVCaptureConnection],
                    error: Error?) {
        if let error = error {
            print("Recording error: \(error)")
            return
        }

        // Video saved to outputFileURL
        saveVideoToPhotoLibrary(outputFileURL)
    }
}

コスト:実装に 45 分

アンチパターン

アンチパターン 1:メインスレッドでのセッション処理

誤り:

func startCamera() {
    session.startRunning()  // Blocks UI for 1-3 seconds!
}

正解:

func startCamera() {
    sessionQueue.async { [self] in
        session.startRunning()
    }
}

重要な理由:startRunning() はブロッキング処理です。メインスレッドで実行すると、UI がフリーズします。

アンチパターン 2:非推奨の videoOrientation を使う

誤り(iOS 17 より前):

// Manually tracking orientation
NotificationCenter.default.addObserver(
    forName: UIDevice.orientationDidChangeNotification,
    object: nil,
    queue: .main
) { _ in
    // Manual rotation logic...
}

正解(iOS 17 以降):

let coordinator = AVCaptureDevice.RotationCoordinator(device: camera, previewLayer: preview)
// Automatically tracks gravity, provides angles

重要な理由:RotationCoordinator は、手動での追跡では見落とす、画面を上向きまたは下向きにした状態などのエッジケースにも対応します。

アンチパターン 3:セッションの中断を無視する

誤り:

// No interruption handling - camera freezes on phone call

正解:

NotificationCenter.default.addObserver(
    forName: .AVCaptureSessionWasInterrupted,
    object: session,
    queue: .main
) { notification in
    // Show UI feedback
}

重要な理由:中断を処理しないと、中断中にカメラがフリーズしたように見えます。

アンチパターン 4:構成ブロックを使わずにセッションを変更する

誤り:

session.removeInput(oldInput)
session.addInput(newInput)  // May fail mid-stream

正解:

session.beginConfiguration()
session.removeInput(oldInput)
session.addInput(newInput)
session.commitConfiguration()  // Atomic change

重要な理由:構成ブロックを使わないと、呼び出しの合間にセッションが無効な状態になる可能性があります。

想定されるプレッシャーのかかる状況

シナリオ 1:「金曜日までにカメラを動くようにするだけでいい」

状況:製品チームはカメラ機能のリリースを求めています。中断処理を省略することを検討しています。

プレッシャー:「テストでは動いているから、リリースしよう。」

現実:カメラの使用中に電話の着信を受けた最初のユーザーには、フリーズした UI が表示されます。App Store の審査で発覚する可能性もあります。

適切な対応:

  1. 中断処理を実装する(30 分)
  2. カメラの使用中にテスト用デバイスへ電話をかけてテストする
  3. UI に適切なフィードバックが表示されることを確認する

反論の例:「カメラでの撮影はできますが、電話がかかってくるとアプリがフリーズします。中断を適切に処理して星 1 のレビューを避けるために、30 分必要です。」

シナリオ 2:「カメラの動作が遅すぎます」

状況:QA から、写真の撮影がもたつくとの報告がありました。PM は「システムカメラのように瞬時に撮影できるようにしてほしい」と要望しています。

プレッシャー:「とにかく何とか速くしてほしい。」

実情:デフォルト設定では速度よりも画質が優先されます。システムカメラでは処理が遅延されます。

適切な対応:

  1. SNS での利用や共有を想定する場合は、photoQualityPrioritization = .speed を設定します
  2. 応答性を最大限に高めるため、処理の遅延を検討します
  3. 処理の完了前に、撮影アニメーションをすぐに表示します

押し返すときの例文:「現在は画質を優先しています。速度を優先し、バックグラウンドで処理を続けながらプレビューをすぐに表示することで、撮影が瞬時に感じられるようにできます。これはシステムのカメラアプリと同じ動作です。」

シナリオ 3:「なぜフロントカメラの写真は左右反転しているのですか?」

状況:デザイナーから、フロントカメラで撮った写真がプレビューのように左右反転しておらず、「違和感がある」と報告がありました。

プレッシャー:「プレビューと同じように写真も表示されるべきです。」

実情:プレビューは鏡のように左右反転しています(ユーザーの期待に合わせた動作です)。写真は左右反転していません(共有時に文字が正しく読めるようにするためです)。これは意図された動作で、システムカメラと同じです。

適切な対応:

  1. これは Apple の標準的な動作だと説明します
  2. 事業上、左右反転した写真が必要な場合(自撮りアプリなど)は、後処理で手動で左右反転します
  3. プレビューを想定と異なる方法で左右反転しないでください

押し返すときの例文:「これは Apple が意図した動作です。ユーザーが自分をフレーミングしやすいよう、プレビューは鏡のように左右反転していますが、撮影した写真は、共有時に文字が正しく読めるよう左右反転していません。ユースケースに応じて、後処理で任意の左右反転を追加できます。」

チェックリスト

カメラ機能をリリースする前に確認してください。

セッションのセットアップ:

  • ☑ セッション関連の処理はすべて専用のシリアルキューで実行する
  • ☑ startRunning() をメインスレッドで呼び出さない
  • ☑ セッションプリセットがユースケースに合っている(写真には .photo、動画には .high)
  • ☑ 設定変更を beginConfiguration()/commitConfiguration() で囲む

権限:

  • ☑ セッションのセットアップ前にカメラの権限を要求する
  • ☑ Info.plist に NSCameraUsageDescription がある
  • ☑ 音声を録音する場合は NSMicrophoneUsageDescription がある
  • ☑ 権限が拒否された場合に適切に処理する

回転:

  • ☑ 非推奨の videoOrientation ではなく RotationCoordinator を使用する
  • ☑ 監視を通じてプレビューレイヤーの回転を更新する
  • ☑ 写真の撮影時に撮影角度を適用する
  • ☑ すべての向き(縦向き、横向き、画面を上にした状態)でテストする

応答性:

  • ☑ ユースケースに応じて photoQualityPrioritization を設定する
  • ☑ 撮影ボタンですぐにフィードバックを表示する
  • ☑ 速度を最大限に高めるため、処理の遅延を検討する

中断:

  • ☑ セッション中断オブザーバーを登録する
  • ☑ 中断時に UI フィードバックを表示する
  • ☑ 電話の着信時にテストする
  • ☑ Split View(iPad)でテストする

カメラの切り替え:

  • ☑ フロント/バックの切り替え時に RotationCoordinator を更新する
  • ☑ セッションキュー上で切り替える
  • ☑ 新しいカメラを使用できない場合のフォールバックを用意する

動画の録画(該当する場合):

  • ☑ マイク入力を追加する
  • ☑ 録画デリゲートで完了を処理する
  • ☑ 一時録画ファイルを削除する

リソース

WWDC:2021-10247、2023-10105

ドキュメント:/avfoundation/avcapturesession、/avfoundation/avcapturedevice/rotationcoordinator、/avfoundation/avcapturephotosettings、/avfoundation/avcapturephotooutputreadinesscoordinator

スキル:axiom-camera-capture-ref、axiom-camera-capture-diag、axiom-photo-library

コメント