photokit

photokit

热门

基于 PhotoKit 与 AVFoundation 实现、评审或改进 iOS 应用中的相册选择、相机采集及媒体处理功能。适用于使用 PhotosPicker、PHPickerViewController、相机采集会话(AVCaptureSession)、相册权限访问、图片加载与显示、视频录制或媒体权限配置等场景。在 Swift 应用中处理相册选图、拍照、录像、图像处理或相册/相机隐私权限时同样适用。

961Star
48Fork
更新于 2026/7/31
SKILL.md
只读
名称
photokit
描述

基于 PhotoKit 与 AVFoundation 实现、评审或改进 iOS 应用中的相册选择、相机采集及媒体处理功能。适用于使用 PhotosPicker、PHPickerViewController、相机采集会话(AVCaptureSession)、相册权限访问、图片加载与显示、视频录制或媒体权限配置等场景。在 Swift 应用中处理相册选图、拍照、录像、图像处理或相册/相机隐私权限时同样适用。

PhotoKit

针对 iOS 26+ 与 Swift 6.3 版本的相册选择、相机采集、图片加载及媒体权限处理的现代设计模式。除特殊说明外,相关模式向下兼容至 iOS 16。完整选择器方案详见 references/photokit-patterns.md,AVCaptureSession 模式详见 references/camera-capture.md

目录

PhotosPicker (SwiftUI, iOS 16+)

PhotosPickerUIImagePickerController 的 SwiftUI 原生替代方案。它运行在独立进程中,无需申请相册读取权限即可浏览相册,支持单选与多选,并能按媒体类型过滤。

单选模式

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

多选模式

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(
                "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))
                    }
                }
            }
        }
    }
}

媒体类型过滤

搭配 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 权限时,系统会自动展示受限相册选择器——让用户自行挑选允许共享的照片。

权限级别 说明 Info.plist Key
仅添加(Add-only) 仅将照片写入相册,无读取权限 NSPhotoLibraryAddUsageDescription
读写(Read-write) 完整或受限的读取权限,外加写入权限 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。在配置采集会话前,务必先检查并请求相机权限:

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("Access Denied", systemImage: "lock.shield")
        } description: {
            Text(message)
        } actions: {
            Button("Open Settings") {
                if let url = URL(string: UIApplication.openSettingsURLString) {
                    openURL(url)
                }
            }
        }
    }
}

Info.plist 必须配置的 Key

Key 何时需要配置
NSPhotoLibraryUsageDescription 需要从相册读取照片时
NSPhotoLibraryAddUsageDescription 需要将照片/视频保存到相册时
NSCameraUsageDescription 需要使用相机时
NSMicrophoneUsageDescription 需要录制音频(带声音的视频)时

如果缺少对应的 Key,应用在触发授权弹窗时会直接发生运行时崩溃(Crash)。

相机采集基础

应当在独立的控制器中管理各个采集会话,并将配置变更、startRunning()stopRunning() 全部串行化放在同一个非主线程执行器(non-main executor)中。切勿将主线程上的配置操作与 detached 异步任务中的启动/停止混合使用:beginConfiguration()/commitConfiguration() 与会话状态的变更绝对不能产生并发竞态。表现层视图(Representable view)仅负责渲染预览。

极简相机管理器

关于串行控制器模式、照片/视频 Delegate 实现、对焦、手电筒、方向控制以及扫码等高级用法,请参阅 相机采集指南。核心生命周期规范如下:

  1. 必须在 Session 配置事务之外请求权限。
  2. 在 Capture 专属执行器上,调用 beginConfiguration() 后立即声明 defer { commitConfiguration() },确保所有提前退出路径都能配对提交事务。
  3. 必须先通过 canAddInput/canAddOutput 校验,才能添加输入与输出。
  4. 启动或停止操作必须在同一个执行器上运行,并且只有在同步调用返回后,才在主线程更新 UI 状态。
  5. 出现失败时,停止会话并还原到干净的 Session 实例,修复配置后再重新执行权限、前后台切换、中断恢复与采集校验。

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

在视图中使用相机

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 不会在视图重绘时自动缓存图片。对于包含大量图片的生产环境应用,建议使用专门的图片加载库或基于 URLCache 实现自定义缓存。

大图降采样(Downsampling)

从相册加载原图时,建议直接降采样转换为目标显示尺寸的 CGImage,以防内存暴涨。一张 4800 万像素的照片解码后未压缩体积可超过 200 MB。

import ImageIO
import UIKit

func downsample(data: D

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