photokit

photokit

熱門

在 iOS 應用程式中使用 PhotoKit 與 AVFoundation 實作、審查或改善相片選取、相機拍攝與媒體處理功能。適用於使用 PhotosPicker、PHPickerViewController、相機擷取會話(AVCaptureSession)、圖庫存取、圖片載入與顯示、影片錄製或媒體權限等情境。亦適用於在 Swift 應用程式中從圖庫選取照片、拍照、錄製影片、處理圖片或處理相片/相機隱私權限。

961星標
48分支
更新於 2026/7/31
SKILL.md
唯讀
名稱
photokit
描述

在 iOS 應用程式中使用 PhotoKit 與 AVFoundation 實作、審查或改善相片選取、相機拍攝與媒體處理功能。適用於使用 PhotosPicker、PHPickerViewController、相機擷取會話(AVCaptureSession)、圖庫存取、圖片載入與顯示、影片錄製或媒體權限等情境。亦適用於在 Swift 應用程式中從圖庫選取照片、拍照、錄製影片、處理圖片或處理相片/相機隱私權限。

PhotoKit

針對 iOS 16+ 與 Swift 6.3 提供相片選取、相機拍攝、圖片載入以及媒體權限的現代化設計模式。除非另有說明,否則相關模式皆向下相容至 iOS 16。完整選取器範例請參閱 references/photokit-patterns.md,AVCaptureSession 模式請參閱 references/camera-capture.md

目錄

PhotosPicker (SwiftUI, iOS 16+)

PhotosPicker 是 SwiftUI 用來替代 UIImagePickerController 的原生元件。它在獨立程序(out-of-process)中執行,瀏覽照片時不需要相片圖庫權限,並支援搭配媒體類型篩選進行單選或多選。

單選

import SwiftUI
import PhotosUI

struct SinglePhotoPicker: View {
    @State private var selectedItem: PhotosPickerItem?
    @State private var selectedImage: Image?

    var body: some View {
        VStack {
            if let selectedImage {
                selectedImage
                    .resizable()
                    .scaledToFit()
                    .frame(maxHeight: 300)
            }

            PhotosPicker("選擇相片", selection: $selectedItem, matching: .images)
        }
        .onChange(of: selectedItem) { _, newItem in
            Task {
                if let data = try? await newItem?.loadTransferable(type: Data.self),
                   let uiImage = UIImage(data: data) {
                    selectedImage = Image(uiImage: uiImage)
                }
            }
        }
    }
}

多選

struct MultiPhotoPicker: View {
    @State private var selectedItems: [PhotosPickerItem] = []
    @State private var selectedImages: [Image] = []

    var body: some View {
        VStack {
            ScrollView(.horizontal) {
                HStack {
                    ForEach(selectedImages.indices, id: \.self) { index in
                        selectedImages[index]
                            .resizable()
                            .scaledToFill()
                            .frame(width: 100, height: 100)
                            .clipShape(.rect(cornerRadius: 8))
                    }
                }
            }

            PhotosPicker(
                "選擇相片",
                selection: $selectedItems,
                maxSelectionCount: 5,
                matching: .images
            )
        }
        .onChange(of: selectedItems) { _, newItems in
            Task {
                selectedImages = []
                for item in newItems {
                    if let data = try? await item.loadTransferable(type: Data.self),
                       let uiImage = UIImage(data: data) {
                        selectedImages.append(Image(uiImage: uiImage))
                    }
                }
            }
        }
    }
}

媒體類型篩選

使用 PHPickerFilter 組合進行篩選,限制可選取的媒體類型:

// 僅限圖片
PhotosPicker(selection: $items, matching: .images)

// 僅限影片
PhotosPicker(selection: $items, matching: .videos)

// 僅限原況照片 (Live Photos)
PhotosPicker(selection: $items, matching: .livePhotos)

// 僅限螢幕截圖
PhotosPicker(selection: $items, matching: .screenshots)

// 結合圖片與影片
PhotosPicker(selection: $items, matching: .any(of: [.images, .videos]))

// 圖片(排除螢幕截圖)
PhotosPicker(selection: $items, matching: .all(of: [.images, .not(.screenshots)]))

使用 Transferable 載入選取的項目

PhotosPickerItem 透過 loadTransferable(type:) 非同步載入內容。定義 Transferable 型別以實現自動解碼:

struct PickedImage: Transferable {
    let data: Data
    let image: Image

    static var transferRepresentation: some TransferRepresentation {
        DataRepresentation(importedContentType: .image) { data in
            guard let uiImage = UIImage(data: data) else {
                throw TransferError.importFailed
            }
            return PickedImage(data: data, image: Image(uiImage: uiImage))
        }
    }
}

enum TransferError: Error {
    case importFailed
}

// 使用方式
if let picked = try? await item.loadTransferable(type: PickedImage.self) {
    selectedImage = picked.image
}

請務必在 Task 中進行載入,避免阻塞主執行緒。請妥善處理回傳 nil 以及拋出的錯誤 —— 使用者可能會選取無法解碼的格式。

隱私與權限

相片圖庫存取層級

iOS 針對相片圖庫提供兩種存取層級。當應用程式要求 .readWrite 存取權限時,系統會自動顯示有限圖庫選取器(limited-library picker)—— 由使用者決定要分享哪些相片。

存取層級 說明 Info.plist 鍵值
僅限新增 寫入相片至圖庫但無法讀取 NSPhotoLibraryAddUsageDescription
讀寫 完整或受限的讀取權限加上寫入權限 NSPhotoLibraryUsageDescription

PhotosPicker 瀏覽照片時不需要權限 —— 它在獨立程序中執行,且僅授予對已選取項目的存取權。只有在需要讀取完整圖庫(例如自訂相簿介面)或儲存相片時,才需要要求明確權限。

檢查與要求相片圖庫權限

import Photos

func requestPhotoLibraryAccess() async -> PHAuthorizationStatus {
    let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)

    switch status {
    case .notDetermined:
        return await PHPhotoLibrary.requestAuthorization(for: .readWrite)
    case .authorized, .limited:
        return status
    case .denied, .restricted:
        return status
    @unknown default:
        return status
    }
}

相機權限

在 Info.plist 中新增 NSCameraUsageDescription。在設定擷取會話(capture session)之前,先檢查並要求存取權限:

import AVFoundation

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

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

處理遭拒絕的權限

當使用者拒絕授權時,引導他們前往「設定」。切勿重複提示或無聲無息地隱藏功能。

struct PermissionDeniedView: View {
    let message: String
    @Environment(\.openURL) private var openURL

    var body: some View {
        ContentUnavailableView {
            Label("存取遭拒", systemImage: "lock.shield")
        } description: {
            Text(message)
        } actions: {
            Button("開啟設定") {
                if let url = URL(string: UIApplication.openSettingsURLString) {
                    openURL(url)
                }
            }
        }
    }
}

必要的 Info.plist 鍵值

鍵值 必要時機
NSPhotoLibraryUsageDescription 從圖庫讀取相片
NSPhotoLibraryAddUsageDescription 儲存相片/影片至圖庫
NSCameraUsageDescription 存取相機
NSMicrophoneUsageDescription 錄製音訊(含聲音的影片)

若漏掉必要的鍵值,當權限對話框預期要顯示時會導致應用程式崩潰(runtime crash)。

相機拍攝基礎

請在專用的控制器中管理各個擷取會話(capture session),並將設定、startRunning() 以及 stopRunning() 序列化(serialize)在同一個非主執行緒執行器(non-main executor)上。切勿混合主 Actor(main-actor)設定與分離的 start/stop 任務:beginConfiguration()/commitConfiguration() 與會話狀態變更絕不能發生競態條件(race condition)。Representable 視圖僅用於顯示預覽畫面。

最小化相機管理器

完整序列化控制器模式、相片/影片代理(delegates)、對焦、閃光燈/手電筒、方向與掃描功能,請載入 相機拍攝。關鍵生命週期如下:

  1. 在擷取會話設定事務(transaction)之外要求授權。
  2. 在擷取執行器(capture executor)上,呼叫 beginConfiguration() 並立即加入 defer { commitConfiguration() },確保每次提前退出都能平衡事務。
  3. 務必在 canAddInput/canAddOutput 檢查通過後才新增輸入與輸出。
  4. 在同一個執行器上啟動或停止,且僅在同步呼叫回傳後,才於主 Actor 發布 UI 狀態。
  5. 發生失敗時,停止執行、還原全新的會話實例(fixture)、修正設定,並重新執行授權、背景/前景、中斷以及拍攝檢查。

在 SwiftUI 中使用相機預覽

AVCaptureVideoPreviewLayer 包裹在 UIViewRepresentable 中。覆寫 layerClass 以實現自動縮放尺寸:

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) {
        if uiView.previewLayer.session !== session {
            uiView.previewLayer.session = session
        }
    }
}

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

在 View 中使用相機

struct CameraScreen: View {
    @State private var cameraManager = CameraManager()

    var body: some View {
        ZStack(alignment: .bottom) {
            CameraPreview(session: cameraManager.session)
                .ignoresSafeArea()

            Button {
                // 拍攝相片 -- 參閱 references/camera-capture.md
            } label: {
                Circle()
                    .fill(.white)
                    .frame(width: 72, height: 72)
                    .overlay(Circle().stroke(.gray, lineWidth: 3))
            }
            .padding(.bottom)
        }
        .task {
            await cameraManager.configure()
            cameraManager.start()
        }
        .onDisappear {
            cameraManager.stop()
        }
    }
}

請務必在 onDisappear 中呼叫 stop()。運作中的擷取會話會獨占相機並快速消耗電量。

圖片載入與顯示

用於遠端圖片的 AsyncImage

AsyncImage(url: imageURL) { phase in
    switch phase {
    case .empty:
        ProgressView()
    case .success(let image):
        image
            .resizable()
            .scaledToFill()
    case .failure:
        Image(systemName: "photo")
            .foregroundStyle(.secondary)
    @unknown default:
        EmptyView()
    }
}
.frame(width: 200, height: 200)
.clipShape(.rect(cornerRadius: 12))

AsyncImage 不會在 View 重新繪製時快取圖片。對於包含大量圖片的正式版應用程式,請使用專用的圖片載入套件,或是基於 URLCache 的快取機制。

降採樣大尺寸圖片

從圖庫載入全解析度相片時,請將其降採樣(downsample)為顯示尺寸的 CGImage,以避免記憶體暴增。一張 48MP 的未壓縮相片可能會消耗超過 200 MB 記憶體。

import ImageIO
import UIKit

func downsample(data: D

<!-- truncated for translation batch; full body continues in source -->