mcpField
P
photos-camera-media

photos-camera-media

github.com/dpearson2699开发1.2k456 次安装13 周前更新
代码仓库

关于 photos-camera-media

面向 Swift 开发者的 iOS 照片、相机与媒体开发指南,覆盖从选取和拍摄到权限处理及图像展示的常见模式。内容强调异步加载、隐私权限和大图降采样等实践。

主要功能

  1. 01SwiftUI 照片选取与媒体筛选
  2. 02照片及相机权限处理
  3. 03AVCaptureSession 相机拍摄
  4. 04异步图像加载与降采样
  5. 05媒体开发常见问题清单

适用场景

  1. 01添加单张或多张照片选取
  2. 02构建相机预览与拍摄界面
  3. 03请求并处理媒体访问权限
  4. 04在列表中展示大型用户照片

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/dpearson2699/swift-ios-skills --skill photos-camera-media

安装后在 Claude Code 中输入/photos-camera-media确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
av-playback.md
camera-capture.md
image-loading-caching.md
photospicker-patterns.md

详细介绍

照片、相机与媒体

面向 iOS 26+、使用 Swift 6.2 的现代照片选择、相机拍摄、图像加载和媒体权限模式。除非另有说明,这些模式向后兼容 iOS 16。

完整的选择器方案请参阅 references/photospicker-patterns.md,AVCaptureSession 方案请参阅 references/camera-capture.md。

目录

PhotosPicker(SwiftUI,iOS 16+)

PhotosPicker 是原生 SwiftUI 组件,可替代 UIImagePickerController。它在进程外运行,浏览照片库无需权限,并支持按媒体类型筛选单选或多选。

单选
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(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))
                    }
                }
            }
        }
    }
}
媒体类型筛选

使用 PHPickerFilter 组合项限制可选媒体:

// 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)]))
使用 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
}

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

始终在 Task 中加载,以免阻塞主线程。处理返回 nil 和抛出的错误——用户可能会选择无法解码的格式。

隐私与权限

照片库访问级别

iOS 为照片库提供两种访问级别。当应用请求 .readWrite 访问权限时,系统会自动显示有限照片库选择器——用户可选择要分享的照片。

访问级别说明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
    }
}
相机权限

将 NSCameraUsageDescription 添加到 Info.plist。在配置拍摄会话之前,先检查并请求访问权限:

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 键
键适用情况
NSPhotoLibraryUsageDescription从照片库读取照片
NSPhotoLibraryAddUsageDescription将照片/视频保存到照片库
NSCameraUsageDescription访问相机
NSMicrophoneUsageDescription录制音频(有声音的视频)

缺少必需的键会导致权限对话框本应出现时发生运行时崩溃。

相机拍摄基础

使用专用的 @Observable 模型管理相机会话。可表示视图仅用于显示预览。完整方案请参阅 references/camera-capture.md。

最简相机管理器
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
    }
}

在后台队列上启动和停止 AVCaptureSession。startRunning() 和 stopRunning() 方法是同步的,会阻塞调用线程。

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 {
                // 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()
        }
    }
}

务必在 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(RoundedRectangle(cornerRadius: 12))

AsyncImage 不会在视图重绘时缓存图像。对于包含大量图像的生产应用,请使用专用的图像加载库,或实现基于 URLCache 的缓存。

大图降采样

从图库加载全分辨率照片时,将其转换为适合显示尺寸的 CGImage,以避免内存激增。一张 48 MP 照片解压后可能占用超过 200 MB。

import ImageIO
import UIKit

func downsample(data: Data, to pointSize: CGSize, scale: CGFloat = UITraitCollection.current.displayScale) -> 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)
}

在列表、网格或缩略图中显示用户选择的照片时,请始终使用此方法。在创建 UIImage 之前,直接将 PhotosPickerItem 中的原始 Data 传给降采样器。

图像渲染模式
// 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)

照片和艺术作品使用 .original。需要采用当前色调颜色的图标使用 .template。

常见错误

不要: 使用 UIImagePickerController 选择照片。
应该: 使用 PhotosPicker(SwiftUI)或 PHPickerViewController(UIKit)。
原因: UIImagePickerController 是旧版 API,功能有限。PhotosPicker 在进程外运行,支持多选,浏览时无需图库权限。

不要: 仅需让用户选择照片时,却请求完整的照片图库访问权限。
应该: 使用无需权限的 PhotosPicker,或请求 .readWrite 并让系统处理有限访问。
原因: 对于大多数选择并使用的工作流,完整访问并无必要。系统的有限图库选择器尊重用户隐私,同时仍允许访问用户选中的项目。

不要: 将全分辨率图像加载到内存中作为缩略图。
应该: 使用 CGImageSource 和 kCGImageSourceThumbnailMaxPixelSize 进行降采样。一张 48 MP 图像解压后会占用超过 200 MB。

不要: 在主线程上阻塞加载 PhotosPickerItem 数据。
应该: 在 Task 中使用 async loadTransferable(type:)。

不要: 视图消失时忘记停止 AVCaptureSession。
应该: 在 onDisappear 或 dismantleUIView 中调用 session.stopRunning()。

不要: 未经检查就假定已获得相机访问权限。
应该: 检查 AVCaptureDevice.authorizationStatus(for: .video),并处理 .denied / .restricted 状态。

不要: 在主线程上调用 session.startRunning()。
应该: 使用 Task.detached 或专用串行队列调度到后台线程。
原因: startRunning() 是同步阻塞调用,在硬件初始化期间可能耗时数百毫秒。

不要: 在 UIViewRepresentable 中创建 AVCaptureSession。
应该: 在单独的 @Observable 模型中持有该会话。

审查清单

  • 使用 PhotosPicker,而非已弃用的 UIImagePickerController
  • 在 Info.plist 中添加相机 / 照片图库的隐私说明
  • 处理异步图像 / 视频加载时的加载状态
  • 显示大图前,使用 CGImageSource 进行降采样
  • 在后台线程启动相机会话;在 onDisappear 中停止会话
  • 通过指向设置页面的深层链接处理权限拒绝
  • 由模型持有 AVCaptureSession,而非在 UIViewRepresentable 中创建
  • 跨并发边界传递的媒体资源类型和选择器结果均为 Sendable

参考资料

  • references/photospicker-patterns.md — 选择器模式、媒体加载、HEIC 处理
  • references/camera-capture.md — AVCaptureSession、照片 / 视频拍摄、QR 扫描
  • references/image-loading-caching.md — AsyncImage、缓存、降采样
  • references/av-playback.md — AVPlayer、流式传输、音频会话、后台音频

评论

支持 Markdown