基于 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+)
PhotosPicker 是 UIImagePickerController 的 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 实现、对焦、手电筒、方向控制以及扫码等高级用法,请参阅 相机采集指南。核心生命周期规范如下:
- 必须在 Session 配置事务之外请求权限。
- 在 Capture 专属执行器上,调用
beginConfiguration()后立即声明defer { commitConfiguration() },确保所有提前退出路径都能配对提交事务。 - 必须先通过
canAddInput/canAddOutput校验,才能添加输入与输出。 - 启动或停止操作必须在同一个执行器上运行,并且只有在同步调用返回后,才在主线程更新 UI 状态。
- 出现失败时,停止会话并还原到干净的 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 -->




