mcpField
A
axiom-photo-library

axiom-photo-library

github.com/charleswiltgen开发1.2k170 次安装今天更新
代码仓库

关于 axiom-photo-library

面向 Swift 开发者的 PhotoKit 实践指南,涵盖选取和保存媒体、处理有限访问及监听图库变化。它帮助开发者选择合适的 API,并避免常见的权限与加载问题。

主要功能

  1. 01PhotosPicker 与 PHPicker 使用指南
  2. 02处理有限访问权限
  3. 03将照片保存到相机胶卷
  4. 04异步加载选取的图像
  5. 05监听照片图库变化

适用场景

  1. 01为 SwiftUI 或 UIKit 应用添加照片选取
  2. 02将编辑后的图像保存到相机胶卷
  3. 03支持仅授予有限图库访问权限的用户

安装

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

$npx skills add https://github.com/charleswiltgen/axiom --skill axiom-photo-library

安装后在 Claude Code 中输入/axiom-photo-library确认已加载。

详细介绍

使用 PhotoKit 访问照片库

介绍如何使用注重隐私的模式选取照片、处理有限的照片库访问权限,以及将照片保存到相机胶卷。

何时使用此技能

在需要以下功能时使用:

  • ☑ 让用户从照片库中选择照片
  • ☑ 处理有限的照片库访问权限
  • ☑ 将照片/视频保存到相机胶卷
  • ☑ 在 PHPicker 和 PhotosPicker 之间进行选择
  • ☑ 从 PhotosPickerItem 加载图像
  • ☑ 观察照片库的更改
  • ☑ 请求适当级别的权限

示例提示词

“如何在 SwiftUI 中让用户选择照片?” “用户说他们看不到自己的照片” “如何将照片保存到相机胶卷?” “PHPicker 和 PhotosPicker 有什么区别?” “如何处理有限的照片访问权限?” “用户授予了有限访问权限,但看不到照片” “如何从 PhotosPickerItem 加载图像?”

注意事项

以下迹象表明,你把事情弄得比必要的更复杂:

  • ❌ 使用 UIImagePickerController(已弃用,不适用于照片选择)
  • ❌ 在选取器已足够的情况下请求完整照片库访问权限(侵犯隐私)
  • ❌ 忽略 .limited 授权状态(用户无法扩大选择范围)
  • ❌ 未处理 Transferable 加载失败(大尺寸照片可能导致崩溃)
  • ❌ 同步加载选取器结果中的图像(会阻塞 UI)
  • ❌ 只需选取照片时却使用 PhotoKit API(过度设计)
  • ❌ 假设用户授予访问权限后状态为 .authorized(也可能是 .limited)

必须先完成的步骤

实现照片库功能之前:

1. 选择实现方式
What do you need?

┌─ User picks photos (no library browsing)?
│  ├─ SwiftUI app → PhotosPicker (iOS 16+)
│  └─ UIKit app → PHPickerViewController (iOS 14+)
│  └─ NO library permission needed! Picker handles it.
│
├─ Display user's full photo library (gallery UI)?
│  └─ Requires PHPhotoLibrary authorization
│     └─ Request .readWrite for browsing
│     └─ Handle .limited status with presentLimitedLibraryPicker
│
├─ Save photos to camera roll?
│  └─ Requires PHPhotoLibrary authorization
│     └─ Request .addOnly (minimal) or .readWrite
│
└─ Just capture with camera?
   └─ Don't use PhotoKit - see camera-capture skill
2. 了解权限级别
级别允许的操作请求方式
无权限用户通过系统选取器选择PHPicker/PhotosPicker(自动)
.addOnly仅保存到相机胶卷requestAuthorization(for: .addOnly)
.limited仅访问用户选定的项目用户在系统界面中选择
.authorized访问完整照片库requestAuthorization(for: .readWrite)

要点:PHPicker 和 PhotosPicker 无需任何权限。隐私保护由系统处理。

3. Info.plist 键

<key>NSPhotoLibraryUsageDescription</key>
<string>Access your photos to share them</string>


<key>NSPhotoLibraryAddUsageDescription</key>
<string>Save photos to your library</string>

核心模式

模式 1:SwiftUI PhotosPicker(iOS 16+)

适用场景:让用户在 SwiftUI 应用中选择照片。

import SwiftUI
import PhotosUI

struct ContentView: View {
    @State private var selectedItem: PhotosPickerItem?
    @State private var selectedImage: Image?

    var body: some View {
        VStack {
            PhotosPicker(
                selection: $selectedItem,
                matching: .images  // Filter to images only
            ) {
                Label("Select Photo", systemImage: "photo")
            }

            if let image = selectedImage {
                image
                    .resizable()
                    .scaledToFit()
            }
        }
        .onChange(of: selectedItem) { _, newItem in
            Task {
                await loadImage(from: newItem)
            }
        }
    }

    private func loadImage(from item: PhotosPickerItem?) async {
        guard let item else {
            selectedImage = nil
            return
        }

        // Load as Data first (more reliable than Image)
        if let data = try? await item.loadTransferable(type: Data.self),
           let uiImage = UIImage(data: data) {
            selectedImage = Image(uiImage: uiImage)
        }
    }
}

多选:

@State private var selectedItems: [PhotosPickerItem] = []

PhotosPicker(
    selection: $selectedItems,
    maxSelectionCount: 5,
    matching: .images
) {
    Text("Select Photos")
}
高级筛选器(iOS 15+/16+)
// Screenshots only
matching: .screenshots

// Screen recordings only
matching: .screenRecordings

// Slo-mo videos
matching: .sloMoVideos

// Cinematic videos (iOS 16+)
matching: .cinematicVideos

// Depth effect photos
matching: .depthEffectPhotos

// Bursts
matching: .bursts

// Compound filters with .any, .all, .not
// Videos AND Live Photos
matching: .any(of: [.videos, .livePhotos])

// All images EXCEPT screenshots
matching: .all(of: [.images, .not(.screenshots)])

// All images EXCEPT screenshots AND panoramas
matching: .all(of: [.images, .not(.any(of: [.screenshots, .panoramas]))])

成本:实现需 15 分钟,无需权限

模式 1b:内嵌 PhotosPicker(iOS 17+)

适用场景:将选取器内嵌在 UI 中,而不是以工作表形式呈现。

import SwiftUI
import PhotosUI

struct EmbeddedPickerView: View {
    @State private var selectedItems: [PhotosPickerItem] = []

    var body: some View {
        VStack {
            // Your content above picker
            SelectedPhotosGrid(items: selectedItems)

            // Embedded picker fills available space
            PhotosPicker(
                selection: $selectedItems,
                maxSelectionCount: 10,
                selectionBehavior: .continuous,  // Live updates as user taps
                matching: .images
            ) {
                // Label is ignored for inline style
                Text("Select")
            }
            .photosPickerStyle(.inline)  // Embed instead of present
            .photosPickerDisabledCapabilities([.selectionActions])  // Hide Add/Cancel buttons
            .photosPickerAccessoryVisibility(.hidden, edges: .all)  // Hide nav/toolbar
            .frame(height: 300)  // Control picker height
            .ignoresSafeArea(.container, edges: .bottom)  // Extend to bottom edge
        }
    }
}

选取器样式:

样式说明
.presentation默认模态工作表
.inline内嵌在视图层级中
.compact单行显示,尽量减少垂直空间占用

自定义修饰符:

// Hide navigation/toolbar accessories
.photosPickerAccessoryVisibility(.hidden, edges: .all)
.photosPickerAccessoryVisibility(.hidden, edges: .top)  // Just navigation bar
.photosPickerAccessoryVisibility(.hidden, edges: .bottom)  // Just toolbar

// Disable capabilities (hides UI for them)
.photosPickerDisabledCapabilities([.search])  // Hide search
.photosPickerDisabledCapabilities([.collectionNavigation])  // Hide albums
.photosPickerDisabledCapabilities([.stagingArea])  // Hide selection review
.photosPickerDisabledCapabilities([.selectionActions])  // Hide Add/Cancel

// Continuous selection for live updates
selectionBehavior: .continuous

隐私说明:内嵌选取器首次出现时,iOS 会显示引导界面,说明你的应用只能访问选定的照片。隐私标记表示该选取器运行在进程之外。

模式 2:UIKit PHPickerViewController(iOS 14+)

适用场景:在 UIKit 应用中选择照片。

import PhotosUI

class PhotoPickerViewController: UIViewController, PHPickerViewControllerDelegate {

    func showPicker() {
        var config = PHPickerConfiguration()
        config.selectionLimit = 1  // 0 = unlimited
        config.filter = .images    // or .videos, .any(of: [.images, .videos])

        let picker = PHPickerViewController(configuration: config)
        picker.delegate = self
        present(picker, animated: true)
    }

    func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
        picker.dismiss(animated: true)

        guard let result = results.first else { return }

        // Load image asynchronously
        result.itemProvider.loadObject(ofClass: UIImage.self) { [weak self] object, error in
            guard let image = object as? UIImage else { return }

            DispatchQueue.main.async {
                self?.displayImage(image)
            }
        }
    }
}

筛选选项:

// Images only
config.filter = .images

// Videos only
config.filter = .videos

// Live Photos only
config.filter = .livePhotos

// Images and videos
config.filter = .any(of: [.images, .videos])

// Exclude screenshots (iOS 15+)
config.filter = .all(of: [.images, .not(.screenshots)])

// iOS 16+ filters
config.filter = .cinematicVideos
config.filter = .depthEffectPhotos
config.filter = .bursts
UIKit 内嵌选取器(iOS 17+)
// Configure for embedded use
var config = PHPickerConfiguration()
config.selection = .continuous  // Live updates instead of waiting for Add button
config.mode = .compact  // Single row layout (optional)
config.selectionLimit = 10

// Hide accessories
config.edgesWithoutContentMargins = .all  // No margins around picker

// Disable capabilities
config.disabledCapabilities = [.search, .selectionActions]

let picker = PHPickerViewController(configuration: config)
picker.delegate = self

// Add as child view controller (required for embedded)
addChild(picker)
containerView.addSubview(picker.view)
picker.view.frame = containerView.bounds
picker.didMove(toParent: self)

显示期间更新选取器(iOS 17+):

// Deselect assets by their identifiers
picker.deselectAssets(withIdentifiers: ["assetID1", "assetID2"])

// Reorder assets in selection
picker.moveAsset(withIdentifier: "assetID", afterAssetWithIdentifier: "otherID")

成本:实现需 20 分钟,无需权限

模式 2b:选项菜单与 HDR 支持(iOS 17+)

选取器现在会显示一个“选项”菜单,让用户选择是否从照片中移除位置元数据。PhotosPicker 和 PHPicker 都会自动支持此功能。

保留 HDR 内容:

默认情况下,选取器可能会将内容转码为 JPEG,从而丢失 HDR 数据。若要接收原始格式:

// SwiftUI - Use .current encoding to preserve HDR
PhotosPicker(
    selection: $selectedItems,
    matching: .images,
    preferredItemEncoding: .current  // Don't transcode
) { ... }

// Loading with original format preservation
struct HDRImage: Transferable {
    let data: Data

    static var transferRepresentation: some TransferRepresentation {
        DataRepresentation(importedContentType: .image) { data in
            HDRImage(data: data)
        }
    }
}

// Request .image content type (generic) not .jpeg (specific)
let result = try await item.loadTransferable(type: HDRImage.self)

UIKit 对应方式:

var config = PHPickerConfiguration()
config.preferredAssetRepresentationMode = .current  // Don't transcode

电影效果模式视频:选取器会返回已渲染的版本,其中已合成景深效果。若要获取包含决策点的原始版本,请改用 PhotoKit 并访问照片库。

模式 3:处理有限图库访问权限

用例:用户授予了有限访问权限;允许他们添加更多照片。

抑制自动提示(iOS 14+):

默认情况下,检测到 .limited 时,iOS 会显示“选择更多照片”提示。要自行处理:


<key>PHPhotoLibraryPreventAutomaticLimitedAccessAlert</key>
<true/>

手动处理有限访问权限:

import Photos

class PhotoLibraryManager {

    func checkAndRequestAccess() async -> PHAuthorizationStatus {
        let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)

        switch status {
        case .notDetermined:
            return await PHPhotoLibrary.requestAuthorization(for: .readWrite)

        case .limited:
            // User granted limited access - show UI to expand
            await presentLimitedLibraryPicker()
            return .limited

        case .authorized:
            return .authorized

        case .denied, .restricted:
            return status

        @unknown default:
            return status
        }
    }

    @MainActor
    func presentLimitedLibraryPicker() {
        guard let windowScene = UIApplication.shared.connectedScenes
            .first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene,
              let rootVC = windowScene.windows.first?.rootViewController else {
            return
        }

        PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: rootVC)
    }
}

观察有限选择的变化:

// Register for changes
PHPhotoLibrary.shared().register(self)

// In delegate
func photoLibraryDidChange(_ changeInstance: PHChange) {
    // User may have modified their limited selection
    // Refresh your photo grid
}

成本:30 分钟实现

模式 4:将照片保存到相机胶卷

用例:保存拍摄或编辑后的照片。

import Photos

func saveImageToLibrary(_ image: UIImage) async throws {
    // Request add-only permission (minimal access)
    let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly)

    guard status == .authorized || status == .limited else {
        throw PhotoError.permissionDenied
    }

    try await PHPhotoLibrary.shared().performChanges {
        PHAssetCreationRequest.creationRequestForAsset(from: image)
    }
}

// With metadata preservation
func savePhotoData(_ data: Data, metadata: [String: Any]? = nil) async throws {
    try await PHPhotoLibrary.shared().performChanges {
        let request = PHAssetCreationRequest.forAsset()

        // Write data to temp file for addResource
        let tempURL = FileManager.default.temporaryDirectory
            .appendingPathComponent(UUID().uuidString)
            .appendingPathExtension("jpg")
        try? data.write(to: tempURL)

        request.addResource(with: .photo, fileURL: tempURL, options: nil)
    }
}

成本:15 分钟实现

模式 5:从 PhotosPickerItem 加载图像

用例:妥善处理异步图像加载及错误。

问题:默认的 Image Transferable 仅支持 PNG。大多数照片都是 JPEG/HEIF 格式。

// Custom Transferable for any image format
struct TransferableImage: Transferable {
    let image: UIImage

    static var transferRepresentation: some TransferRepresentation {
        DataRepresentation(importedContentType: .image) { data in
            guard let image = UIImage(data: data) else {
                throw TransferError.importFailed
            }
            return TransferableImage(image: image)
        }
    }

    enum TransferError: Error {
        case importFailed
    }
}

// Usage
func loadImage(from item: PhotosPickerItem) async -> UIImage? {
    do {
        let result = try await item.loadTransferable(type: TransferableImage.self)
        return result?.image
    } catch {
        print("Failed to load image: \(error)")
        return nil
    }
}

显示进度的加载方式:

func loadImageWithProgress(from item: PhotosPickerItem) async -> UIImage? {
    let progress = Progress()

    return await withCheckedContinuation { continuation in
        _ = item.loadTransferable(type: TransferableImage.self) { result in
            switch result {
            case .success(let transferable):
                continuation.resume(returning: transferable?.image)
            case .failure:
                continuation.resume(returning: nil)
            }
        }
    }
}

成本:20 分钟实现

模式 6:观察照片图库的变化

用例:让图库 UI 与“照片”应用保持同步。

import Photos

class PhotoGalleryViewModel: NSObject, ObservableObject, PHPhotoLibraryChangeObserver {
    @Published var photos: [PHAsset] = []

    private var fetchResult: PHFetchResult<PHAsset>?

    override init() {
        super.init()
        PHPhotoLibrary.shared().register(self)
        fetchPhotos()
    }

    deinit {
        PHPhotoLibrary.shared().unregisterChangeObserver(self)
    }

    func fetchPhotos() {
        let options = PHFetchOptions()
        options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
        fetchResult = PHAsset.fetchAssets(with: .image, options: options)

        photos = fetchResult?.objects(at: IndexSet(0..<(fetchResult?.count ?? 0))) ?? []
    }

    func photoLibraryDidChange(_ changeInstance: PHChange) {
        guard let fetchResult = fetchResult,
              let changes = changeInstance.changeDetails(for: fetchResult) else {
            return
        }

        DispatchQueue.main.async {
            self.fetchResult = changes.fetchResultAfterChanges
            self.photos = changes.fetchResultAfterChanges.objects(at:
                IndexSet(0..<changes.fetchResultAfterChanges.count)
            )
        }
    }
}

成本:30 分钟实现

反模式

反模式 1:为选择照片请求完整访问权限

错误:

// Over-requesting - picker doesn't need this!
let status = await PHPhotoLibrary.requestAuthorization(for: .readWrite)
if status == .authorized {
    showPhotoPicker()
}

正确:

// Just show the picker - no permission needed
PhotosPicker(selection: $item, matching: .images) {
    Text("Select Photo")
}

原因:PHPicker 和 PhotosPicker 会自动处理隐私问题。如果只需要选择照片,却请求图库访问权限,就是侵犯隐私,可能导致 App Store 拒绝上架。

反模式 2:忽略有限访问状态

错误:

let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
if status == .authorized {
    showGallery()
} else {
    showPermissionDenied()  // Wrong! .limited is valid
}

正确:

let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
switch status {
case .authorized:
    showGallery()
case .limited:
    showGallery()  // Works with limited selection
    showLimitedBanner()  // Explain to user
case .denied, .restricted:
    showPermissionDenied()
case .notDetermined:
    requestAccess()
@unknown default:
    break
}

原因:iOS 14+ 用户可以授予有限访问权限。将其视为拒绝访问会让用户感到沮丧。

反模式 3:同步加载图像

错误:

// Blocks UI thread
let data = try! selectedItem.loadTransferable(type: Data.self)

正确:

Task {
    if let data = try? await selectedItem.loadTransferable(type: Data.self) {
        // Use data
    }
}

原因:大型照片(RAW、全景照片)需要数秒才能加载。阻塞 UI 会导致 ANR。

反模式 4:使用 UIImagePickerController 选择照片

错误:

let picker = UIImagePickerController()
picker.sourceType = .photoLibrary
present(picker, animated: true)

正确:

var config = PHPickerConfiguration()
config.filter = .images
let picker = PHPickerViewController(configuration: config)
present(picker, animated: true)

原因:UIImagePickerController 已弃用于照片选择。PHPicker 更可靠,能处理大型资源,并提供更好的隐私保护。

压力场景

场景 1:“让照片访问功能先跑起来”

背景:产品需要照片导入功能。你正在考虑请求完整图库访问权限,“以防万一”。

压力:“用户反正会点‘允许’。”

现实情况:从 iOS 14 开始,用户可以授予有限访问权限。请求完整访问权限会触发额外的隐私提示。App Store 审核可能会拒绝不必要的权限请求。

正确做法:

  1. 使用 PhotosPicker 或 PHPicker(无需权限)
  2. 只有在构建图库浏览器时才请求 .readWrite
  3. 只有在仅保存照片时才请求 .addOnly

回应模板:“PHPicker 无需任何权限请求即可使用,用户可以直接选择照片。我们只需要选择照片,却请求图库访问权限,这属于侵犯隐私,可能会被 App Store 审核标记。”

场景 2:“用户说看不到自己的照片”

背景:用户已授予访问权限,却仍有关于“没有可用照片”的支持工单。

压力:“再请求一次完整访问权限就行。”

现实情况:用户很可能授予了 .limited 访问权限,而且最初没有选择任何照片。

正确做法:

  1. 检查是否为 .limited 状态
  2. 显示 presentLimitedLibraryPicker(),让用户添加照片
  3. 在 UI 中说明:“点此添加更多照片”

回应模板:“用户拥有有限访问权限,需要扩大选择范围。我会添加一个按钮,打开有限图库选择器,让他们添加更多照片。”

场景 3:“照片加载太慢”

背景:用户抱怨照片选择器显示选中的图片速度太慢。

压力:“能不能缓存或预加载?”

实际情况:大尺寸照片(RAW、全景照片、实况照片)解码较慢。解决方案在于优化用户体验,而非缓存。

正确做法:

  1. 立即显示加载占位内容
  2. 先加载缩略图,再加载完整图片
  3. 为大文件显示进度指示器
  4. 使用 async/await 避免阻塞

应对话术模板:“大尺寸照片需要时间加载,这是由其本身特性决定的。我会立即显示占位内容,并逐步加载。对于选择器界面,系统已经优化了缩略图加载。”

检查清单

发布照片库功能之前:

权限策略:

  • ☑ 简单选择时使用 PHPicker/PhotosPicker(无需权限)
  • ☑ 仅在构建图库界面时请求 .readWrite
  • ☑ 仅在只需保存照片时请求 .addOnly
  • ☑ 已提供 Info.plist 使用说明

有限照片库:

  • ☑ 处理 .limited 状态(不将其视为拒绝)
  • ☑ 提供 presentLimitedLibraryPicker(),供用户添加照片
  • ☑ 界面向用户说明访问权限有限

图片加载:

  • ☑ 所有加载均为异步(不阻塞 UI)
  • ☑ 自定义 Transferable 支持 JPEG/HEIF(而不只是 PNG)
  • ☑ 妥善处理加载失败
  • ☑ 为大文件显示加载指示器

保存照片:

  • ☑ 无需完整访问权限时使用 .addOnly
  • ☑ 使用 performChanges 执行原子操作
  • ☑ 妥善处理保存失败

照片库变更:

  • ☑ 显示照片库时注册为 PHPhotoLibraryChangeObserver
  • ☑ 变更后在主线程更新 UI
  • ☑ 在 deinit 中注销观察者

资源

WWDC:2020-10652、2020-10641、2022-10023、2023-10107

文档:/photosui/phpickerviewcontroller、/photosui/photospicker、/photos/phphotolibrary

技能:axiom-photo-library-ref、axiom-camera-capture

评论

支持 Markdown