photos-camera-media

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Photos, Camera & Media

照片、相机与媒体

Modern patterns for photo picking, camera capture, image loading, and media permissions targeting iOS 26+ with Swift 6.2. Patterns are backward-compatible to iOS 16 unless noted.
See
references/photospicker-patterns.md
for complete picker recipes and
references/camera-capture.md
for AVCaptureSession patterns.
基于Swift 6.2、面向iOS 26+版本的照片选择、相机拍摄、图片加载和媒体权限的现代实现模式。除非另有说明,所有模式都向下兼容到iOS 16。
完整的选择器使用方案请查看
references/photospicker-patterns.md
,AVCaptureSession相关模式请查看
references/camera-capture.md

PhotosPicker (SwiftUI, iOS 16+)

PhotosPicker (SwiftUI, iOS 16+)

PhotosPicker
is the native SwiftUI replacement for
UIImagePickerController
. It runs out-of-process, requires no photo library permission for browsing, and supports single or multi-selection with media type filtering.
PhotosPicker
是SwiftUI原生提供的
UIImagePickerController
替代方案。它运行在进程外,浏览时无需申请相册权限,支持单选/多选,还可按媒体类型过滤。

Single Selection

单选

swift
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("Select Photo", 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)
                }
            }
        }
    }
}
swift
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("Select Photo", 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)
                }
            }
        }
    }
}

Multi-Selection

多选

swift
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(RoundedRectangle(cornerRadius: 8))
                    }
                }
            }

            PhotosPicker(
                "Select Photos",
                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))
                    }
                }
            }
        }
    }
}
swift
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(RoundedRectangle(cornerRadius: 8))
                    }
                }
            }

            PhotosPicker(
                "Select Photos",
                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))
                    }
                }
            }
        }
    }
}

Media Type Filtering

媒体类型过滤

Filter with
PHPickerFilter
composites to restrict selectable media:
swift
// Images only
PhotosPicker(selection: $items, matching: .images)

// Videos only
PhotosPicker(selection: $items, matching: .videos)

// Live Photos only
PhotosPicker(selection: $items, matching: .livePhotos)

// Screenshots only
PhotosPicker(selection: $items, matching: .screenshots)

// Images and videos combined
PhotosPicker(selection: $items, matching: .any(of: [.images, .videos]))

// Images excluding screenshots
PhotosPicker(selection: $items, matching: .all(of: [.images, .not(.screenshots)]))
通过
PHPickerFilter
组合进行过滤,限制可选择的媒体类型:
swift
// 仅图片
PhotosPicker(selection: $items, matching: .images)

// 仅视频
PhotosPicker(selection: $items, matching: .videos)

// 仅实况照片
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)]))

Loading Selected Items with Transferable

通过Transferable加载选中项

PhotosPickerItem
loads content asynchronously via
loadTransferable(type:)
. Define a
Transferable
type for automatic decoding:
swift
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
}

// Usage
if let picked = try? await item.loadTransferable(type: PickedImage.self) {
    selectedImage = picked.image
}
Always load in a
Task
to avoid blocking the main thread. Handle
nil
returns and thrown errors -- the user may select a format that cannot be decoded.
PhotosPickerItem
通过
loadTransferable(type:)
异步加载内容。你可以定义自定义的
Transferable
类型实现自动解码:
swift
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
返回和抛出的错误——用户可能选择了无法解码的媒体格式。

Privacy and Permissions

隐私与权限

Photo Library Access Levels

相册访问级别

iOS provides two access levels for the photo library. The system automatically presents the limited-library picker when an app requests
.readWrite
access -- users choose which photos to share.
Access LevelDescriptionInfo.plist Key
Add-onlyWrite photos to the library without reading
NSPhotoLibraryAddUsageDescription
Read-writeFull or limited read access plus write
NSPhotoLibraryUsageDescription
PhotosPicker
requires no permission to browse -- it runs out-of-process and only grants access to selected items. Request explicit permission only when you need to read the full library (e.g., a custom gallery) or save photos.
iOS为相册提供了两种访问级别。当应用申请
.readWrite
权限时,系统会自动弹出受限相册选择器,用户可自主选择要共享的照片。
访问级别描述Info.plist键
仅添加仅可写入相册,无读取权限
NSPhotoLibraryAddUsageDescription
读写完整/受限读取权限+写入权限
NSPhotoLibraryUsageDescription
PhotosPicker
浏览时无需申请任何权限——它运行在进程外,仅会向应用授予用户选中项的访问权限。仅当你需要读取完整相册(例如自定义相册功能)或保存照片时,才需要主动申请权限。

Checking and Requesting Photo Library Permission

检查与申请相册权限

swift
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
    }
}
swift
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
    }
}

Camera Permission

相机权限

Add
NSCameraUsageDescription
to Info.plist. Check and request access before configuring a capture session:
swift
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
    }
}
在Info.plist中添加
NSCameraUsageDescription
配置。配置拍摄会话前请先检查并申请访问权限:
swift
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
    }
}

Handling Denied Permissions

处理权限被拒绝的场景

When the user denies access, guide them to Settings. Never repeatedly prompt or hide functionality silently.
swift
struct PermissionDeniedView: View {
    let message: String

    var body: some View {
        ContentUnavailableView {
            Label("Access Denied", systemImage: "lock.shield")
        } description: {
            Text(message)
        } actions: {
            Button("Open Settings") {
                if let url = URL(string: UIApplication.openSettingsURLString) {
                    UIApplication.shared.open(url)
                }
            }
        }
    }
}
当用户拒绝权限申请时,引导用户前往系统设置开启权限,请勿重复弹窗或静默隐藏功能。
swift
struct PermissionDeniedView: View {
    let message: String

    var body: some View {
        ContentUnavailableView {
            Label("Access Denied", systemImage: "lock.shield")
        } description: {
            Text(message)
        } actions: {
            Button("Open Settings") {
                if let url = URL(string: UIApplication.openSettingsURLString) {
                    UIApplication.shared.open(url)
                }
            }
        }
    }
}

Required Info.plist Keys

必备的Info.plist键

KeyWhen Required
NSPhotoLibraryUsageDescription
Reading photos from the library
NSPhotoLibraryAddUsageDescription
Saving photos/videos to the library
NSCameraUsageDescription
Accessing the camera
NSMicrophoneUsageDescription
Recording audio (video with sound)
Omitting a required key causes a runtime crash when the permission dialog would appear.
适用场景
NSPhotoLibraryUsageDescription
从相册读取照片
NSPhotoLibraryAddUsageDescription
保存照片/视频到相册
NSCameraUsageDescription
访问相机
NSMicrophoneUsageDescription
录制音频(含声音的视频)
如果遗漏了必填的配置键,系统弹出权限弹窗时应用会直接发生运行时崩溃。

Camera Capture Basics

相机拍摄基础

Manage camera sessions in a dedicated
@Observable
model. The representable view only displays the preview. See
references/camera-capture.md
for complete patterns.
请在独立的
@Observable
模型中管理相机会话,代表视图仅负责展示预览。完整的实现模式请查看
references/camera-capture.md

Minimal Camera Manager

极简相机管理器

swift
import AVFoundation

@available(iOS 17.0, *)
@Observable
@MainActor
final class CameraManager {
    let session = AVCaptureSession()
    private let photoOutput = AVCapturePhotoOutput()
    private var currentDevice: AVCaptureDevice?

    var isRunning = false
    var capturedImage: Data?

    func configure() async {
        guard await requestCameraAccess() else { return }

        session.beginConfiguration()
        session.sessionPreset = .photo

        // Add camera input
        guard let device = AVCaptureDevice.default(.builtInWideAngleCamera,
                                                    for: .video,
                                                    position: .back) else { return }
        currentDevice = device

        guard let input = try? AVCaptureDeviceInput(device: device),
              session.canAddInput(input) else { return }
        session.addInput(input)

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

        session.commitConfiguration()
    }

    func start() {
        guard !session.isRunning else { return }
        Task.detached { [session] in
            session.startRunning()
        }
        isRunning = true
    }

    func stop() {
        guard session.isRunning else { return }
        Task.detached { [session] in
            session.stopRunning()
        }
        isRunning = false
    }

    private func requestCameraAccess() async -> Bool {
        let status = AVCaptureDevice.authorizationStatus(for: .video)
        if status == .notDetermined {
            return await AVCaptureDevice.requestAccess(for: .video)
        }
        return status == .authorized
    }
}
Start and stop
AVCaptureSession
on a background queue. The
startRunning()
and
stopRunning()
methods are synchronous and block the calling thread.
swift
import AVFoundation

@available(iOS 17.0, *)
@Observable
@MainActor
final class CameraManager {
    let session = AVCaptureSession()
    private let photoOutput = AVCapturePhotoOutput()
    private var currentDevice: AVCaptureDevice?

    var isRunning = false
    var capturedImage: Data?

    func configure() async {
        guard await requestCameraAccess() else { return }

        session.beginConfiguration()
        session.sessionPreset = .photo

        // 添加相机输入
        guard let device = AVCaptureDevice.default(.builtInWideAngleCamera,
                                                    for: .video,
                                                    position: .back) else { return }
        currentDevice = device

        guard let input = try? AVCaptureDeviceInput(device: device),
              session.canAddInput(input) else { return }
        session.addInput(input)

        // 添加照片输出
        guard session.canAddOutput(photoOutput) else { return }
        session.addOutput(photoOutput)

        session.commitConfiguration()
    }

    func start() {
        guard !session.isRunning else { return }
        Task.detached { [session] in
            session.startRunning()
        }
        isRunning = true
    }

    func stop() {
        guard session.isRunning else { return }
        Task.detached { [session] in
            session.stopRunning()
        }
        isRunning = false
    }

    private func requestCameraAccess() async -> Bool {
        let status = AVCaptureDevice.authorizationStatus(for: .video)
        if status == .notDetermined {
            return await AVCaptureDevice.requestAccess(for: .video)
        }
        return status == .authorized
    }
}
请在后台队列中启动和停止
AVCaptureSession
startRunning()
stopRunning()
是同步方法,会阻塞调用线程。

Camera Preview in SwiftUI

SwiftUI中的相机预览

Wrap
AVCaptureVideoPreviewLayer
in a
UIViewRepresentable
. Override
layerClass
for automatic resizing:
swift
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 }
}
AVCaptureVideoPreviewLayer
封装在
UIViewRepresentable
中,重写
layerClass
实现自动适配尺寸:
swift
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 }
}

Using the Camera in a View

在视图中使用相机

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

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

            Button {
                // Capture photo -- see references/camera-capture.md
            } label: {
                Circle()
                    .fill(.white)
                    .frame(width: 72, height: 72)
                    .overlay(Circle().stroke(.gray, lineWidth: 3))
            }
            .padding(.bottom, 32)
        }
        .task {
            await cameraManager.configure()
            cameraManager.start()
        }
        .onDisappear {
            cameraManager.stop()
        }
    }
}
Always call
stop()
in
onDisappear
. A running capture session holds the camera exclusively and drains battery.
swift
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, 32)
        }
        .task {
            await cameraManager.configure()
            cameraManager.start()
        }
        .onDisappear {
            cameraManager.stop()
        }
    }
}
请务必在
onDisappear
中调用
stop()
,运行中的拍摄会话会独占相机资源并持续消耗电量。

Image Loading and Display

图片加载与展示

AsyncImage for Remote Images

加载远程图片的AsyncImage

swift
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(RoundedRectangle(cornerRadius: 12))
AsyncImage
does not cache images across view redraws. For production apps with many images, use a dedicated image loading library or implement
URLCache
-based caching.
swift
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(RoundedRectangle(cornerRadius: 12))
AsyncImage
不会在视图重绘时缓存图片,对于有大量图片的生产环境应用,建议使用专门的图片加载库或实现基于
URLCache
的缓存逻辑。

Downsampling Large Images

大图片下采样

Load full-resolution photos from the library into a display-sized
CGImage
to avoid memory spikes. A 48MP photo can consume over 200 MB uncompressed.
swift
import ImageIO
import UIKit

func downsample(data: Data, to pointSize: CGSize, scale: CGFloat = UIScreen.main.scale) -> UIImage? {
    let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale

    let options: [CFString: Any] = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceShouldCacheImmediately: true,
        kCGImageSourceCreateThumbnailWithTransform: true,
        kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels
    ]

    guard let source = CGImageSourceCreateWithData(data as CFData, nil),
          let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
        return nil
    }

    return UIImage(cgImage: cgImage)
}
Use this whenever displaying user-selected photos in lists, grids, or thumbnails. Pass the raw
Data
from
PhotosPickerItem
directly to the downsampler before creating a
UIImage
.
将相册中的全分辨率照片加载为适配展示尺寸的
CGImage
,避免内存峰值。一张4800万像素的照片未压缩时会占用超过200MB内存。
swift
import ImageIO
import UIKit

func downsample(data: Data, to pointSize: CGSize, scale: CGFloat = UIScreen.main.scale) -> UIImage? {
    let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale

    let options: [CFString: Any] = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceShouldCacheImmediately: true,
        kCGImageSourceCreateThumbnailWithTransform: true,
        kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels
    ]

    guard let source = CGImageSourceCreateWithData(data as CFData, nil),
          let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
        return nil
    }

    return UIImage(cgImage: cgImage)
}
在列表、网格或缩略图场景展示用户选中的照片时请使用该方法,将
PhotosPickerItem
返回的原始
Data
直接传入下采样方法,再创建
UIImage

Image Rendering Modes

图片渲染模式

swift
// Original: display the image as-is with its original colors
Image("photo")
    .renderingMode(.original)

// Template: treat the image as a mask, colored by foregroundStyle
Image(systemName: "heart.fill")
    .renderingMode(.template)
    .foregroundStyle(.red)
Use
.original
for photos and artwork. Use
.template
for icons that should adopt the current tint color.
swift
// 原始模式:按照片原始颜色展示
Image("photo")
    .renderingMode(.original)

// 模板模式:将图片视为遮罩,使用foregroundStyle设置的颜色渲染
Image(systemName: "heart.fill")
    .renderingMode(.template)
    .foregroundStyle(.red)
照片和美术资源使用
.original
模式,需要适配当前主题色的图标使用
.template
模式。

Common Mistakes

常见错误

DON'T: Use
UIImagePickerController
for photo picking. DO: Use
PhotosPicker
(SwiftUI) or
PHPickerViewController
(UIKit). Why:
UIImagePickerController
is legacy API with limited functionality.
PhotosPicker
runs out-of-process, supports multi-selection, and requires no library permission for browsing.
DON'T: Request full photo library access when you only need the user to pick photos. DO: Use
PhotosPicker
which requires no permission, or request
.readWrite
and let the system handle limited access. Why: Full access is unnecessary for most pick-and-use workflows. The system's limited-library picker respects user privacy and still grants access to selected items.
DON'T: Load full-resolution images into memory for thumbnails. DO: Use
CGImageSource
with
kCGImageSourceThumbnailMaxPixelSize
to downsample. Why: A 48MP image occupies over 200 MB uncompressed. Loading multiple at full resolution causes memory pressure warnings and termination.
DON'T: Block the main thread loading
PhotosPickerItem
data. DO: Use
async loadTransferable(type:)
in a
Task
. Why: Photo data loading involves disk I/O and potential format conversion. Blocking the main thread causes UI hangs and watchdog kills.
DON'T: Forget to stop
AVCaptureSession
when the view disappears. DO: Call
session.stopRunning()
in
onDisappear
or
dismantleUIView
. Why: A running session holds the camera exclusively, preventing other apps from using it, and drains battery continuously.
DON'T: Assume camera access is granted without checking. DO: Check
AVCaptureDevice.authorizationStatus(for: .video)
and handle
.denied
and
.restricted
with appropriate UI. Why: Attempting to add a camera input without authorization silently fails. The user sees a blank preview with no explanation.
DON'T: Call
session.startRunning()
on the main thread. DO: Dispatch to a background thread with
Task.detached
or a dedicated serial queue. Why:
startRunning()
is a synchronous blocking call that can take hundreds of milliseconds while the hardware initializes.
DON'T: Create
AVCaptureSession
inside a
UIViewRepresentable
. DO: Own the session in a separate
@Observable
model and pass it to the representable. Why:
updateUIView
runs on every state change. Creating a session there destroys and recreates the capture pipeline repeatedly.
不要: 使用
UIImagePickerController
实现照片选择功能。 推荐: 使用
PhotosPicker
(SwiftUI)或
PHPickerViewController
(UIKit)。 原因:
UIImagePickerController
是功能受限的老旧API,
PhotosPicker
运行在进程外,支持多选,浏览时无需申请相册权限。
不要: 仅需要用户选择照片时就申请完整相册权限。 推荐: 使用无需权限的
PhotosPicker
,或申请
.readWrite
权限让系统处理受限访问逻辑。 原因: 大多数选图使用场景不需要全量相册访问权限,系统的受限相册选择器既尊重用户隐私,也能保证应用获取选中项的访问权限。
不要: 加载全分辨率图片作为缩略图。 推荐: 使用
CGImageSource
配合
kCGImageSourceThumbnailMaxPixelSize
进行下采样。 原因: 4800万像素的图片未压缩时占用超过200MB内存,同时加载多张全分辨率图片会触发内存压力警告甚至应用被系统杀死。
不要: 阻塞主线程加载
PhotosPickerItem
数据。 推荐:
Task
中使用
async loadTransferable(type:)
加载。 原因: 照片数据加载涉及磁盘I/O和格式转换,阻塞主线程会导致UI卡顿甚至被看门狗机制杀死。
不要: 视图消失时忘记停止
AVCaptureSession
推荐:
onDisappear
dismantleUIView
中调用
session.stopRunning()
原因: 运行中的会话会独占相机,阻止其他应用使用相机,同时持续消耗电量。
不要: 不检查权限就默认相机访问已授权。 推荐: 检查
AVCaptureDevice.authorizationStatus(for: .video)
,针对
.denied
.restricted
状态展示对应的提示UI。 原因: 未授权时添加相机输入会静默失败,用户只会看到空白预览而没有任何提示。
不要: 在主线程调用
session.startRunning()
推荐: 通过
Task.detached
或专门的串行队列调度到后台线程执行。 原因:
startRunning()
是同步阻塞调用,硬件初始化过程可能耗时数百毫秒。
不要:
UIViewRepresentable
中创建
AVCaptureSession
推荐: 在独立的
@Observable
模型中持有会话,再传递给代表视图。 原因:
updateUIView
会在每次状态变化时执行,在其中创建会话会导致拍摄管道被反复销毁重建。

Review Checklist

评审检查清单

  • PhotosPicker
    used instead of deprecated
    UIImagePickerController
  • Privacy description strings set in Info.plist for camera and/or photo library
  • Loading states handled for async image/video loading from
    PhotosPickerItem
  • Large images downsampled with
    CGImageSource
    before display
  • Camera session properly started on background thread and stopped in
    onDisappear
  • Permission denial handled with Settings deep link and
    ContentUnavailableView
  • Memory pressure considered for multi-photo selection (sequential loading, downsampling)
  • AVCaptureSession
    owned by a model, not created inside
    UIViewRepresentable
  • Camera preview uses
    layerClass
    override for automatic resizing
  • NSMicrophoneUsageDescription
    included if recording video with audio
  • Media asset types and picker results are
    Sendable
    when passed across concurrency boundaries
  • 使用
    PhotosPicker
    替代已废弃的
    UIImagePickerController
  • Info.plist中已配置相机和/或相册的隐私描述字符串
  • 已处理
    PhotosPickerItem
    异步加载图片/视频的加载态
  • 大图片展示前已通过
    CGImageSource
    下采样
  • 相机会话正确在后台线程启动,并在
    onDisappear
    中停止
  • 权限被拒绝时已提供跳转设置的入口和
    ContentUnavailableView
    提示
  • 多选照片场景已考虑内存压力(顺序加载、下采样)
  • AVCaptureSession
    由模型持有,而非在
    UIViewRepresentable
    中创建
  • 相机预览通过重写
    layerClass
    实现自动适配尺寸
  • 如需录制带音频的视频,已添加
    NSMicrophoneUsageDescription
    配置
  • 跨并发边界传递的媒体资源类型和选择器结果遵循
    Sendable
    协议

References

参考资料

  • references/photospicker-patterns.md
    — Picker patterns, media loading, thumbnail generation, HEIC handling.
  • references/camera-capture.md
    — AVCaptureSession setup, photo/video capture, QR scanning, orientation.
  • apple-docs MCP:
    /documentation/PhotosUI/PhotosPicker
    ,
    /documentation/AVFoundation/AVCaptureSession
  • references/photospicker-patterns.md
    — 选择器模式、媒体加载、缩略图生成、HEIC处理
  • references/camera-capture.md
    — AVCaptureSession配置、照片/视频拍摄、二维码扫描、方向适配
  • 苹果官方文档 MCP:
    /documentation/PhotosUI/PhotosPicker
    ,
    /documentation/AVFoundation/AVCaptureSession