mcpField
P
photokit

photokit

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

关于 photokit

PhotoKit 是面向 Swift 开发者的 iOS 照片、相机与图像处理模式指南,涵盖 SwiftUI 原生选图、隐私权限和 AVCaptureSession 生命周期管理。

主要功能

  1. 01SwiftUI 照片单选与多选
  2. 02异步加载所选媒体
  3. 03照片与相机权限处理
  4. 04AVCaptureSession 配置与预览
  5. 05图像缩小采样与显示

适用场景

  1. 01实现照片或视频选择器
  2. 02构建相机拍摄界面
  3. 03显示大量照片缩略图
  4. 04处理拒绝权限后的恢复流程

安装

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

$npx skills add https://github.com/dpearson2699/swift-ios-skills --skill photokit

安装后在 Claude Code 中输入/photokit确认已加载。

包含的文件

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

evals/
evals.json
references/
av-playback.md
camera-capture.md
image-loading-caching.md
photokit-patterns.md

详细介绍

PhotoKit

针对 iOS 26+ 和 Swift 6.3 的现代照片选取、相机拍摄、图像加载和媒体权限模式。除非另有说明,这些模式向后兼容至 iOS 16。完整的选取器示例请参阅 references/photokit-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(.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 组合来限制可选媒体:

// 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录制音频(带声音的视频)

如果缺少必需的键,在本应显示权限对话框时会导致运行时崩溃。

相机拍摄基础知识

在专用控制器中管理每个拍摄会话,并在同一个非主执行器上串行执行配置、startRunning() 和 stopRunning()。切勿混用主执行器隔离的配置与分离的启动/停止任务:beginConfiguration()/commitConfiguration() 和会话状态更改不得发生竞争。可表示视图仅负责显示预览。

最小化的相机管理器

阅读 Camera Capture,了解序列化控制器模式、照片/视频代理、对焦、闪光灯、方向和扫描。关键生命周期如下:

  1. 在会话配置事务之外请求访问权限。
  2. 在采集执行器上调用 beginConfiguration(),并立即设置 defer { commitConfiguration() },确保每次提前退出时都能平衡事务。
  3. 仅在通过 canAddInput/canAddOutput 检查后添加输入和输出。
  4. 在同一执行器上启动或停止会话,然后仅在同步调用返回后才在主执行器上发布 UI 状态。
  5. 发生故障时,停止会话、恢复全新的会话配置,修正配置,然后重新运行授权、后台/前台切换、中断和采集检查。
在 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)
        }
        .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 的缓存。

对大图像进行降采样

将图库中的全分辨率照片加载为适合显示尺寸的 CGImage,以避免内存激增。一张 48MP 的照片在未压缩时可能占用超过 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 进行降采样。一张 48MP 图像在未压缩时超过 200 MB。

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

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

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

不要: 在主线程上调用 session.startRunning()。 应该: 在负责配置和停止操作的同一个专用串行执行器上运行该调用。 原因: startRunning() 是同步阻塞调用,硬件初始化时可能耗时数百毫秒。

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

审查清单

  • 使用 PhotosPicker,而不是已弃用的 UIImagePickerController
  • 在 Info.plist 中为相机和照片图库配置隐私说明
  • 处理异步加载图像/视频时的加载状态
  • 显示前使用 CGImageSource 对大图像进行降采样
  • 在后台线程启动相机会话;在 onDisappear 中停止
  • 处理权限被拒绝的情况,并提供跳转到“设置”的深层链接
  • AVCaptureSession 由模型持有,而不是在 UIViewRepresentable 内创建
  • 媒体资源类型和选择器结果在并发边界之间均为 Sendable

参考资料

评论

支持 Markdown