axiom-photo-library

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Photo Library Access with PhotoKit

使用PhotoKit访问照片库

Guides you through photo picking, limited library handling, and saving photos to the camera roll using privacy-forward patterns.
本指南将引导你使用注重隐私的模式,完成照片选择、受限图库处理以及将照片保存至相机胶卷的操作。

When to Use This Skill

何时使用本技能

Use when you need to:
  • ☑ Let users select photos from their library
  • ☑ Handle limited photo library access
  • ☑ Save photos/videos to the camera roll
  • ☑ Choose between PHPicker and PhotosPicker
  • ☑ Load images from PhotosPickerItem
  • ☑ Observe photo library changes
  • ☑ Request appropriate permission level
当你需要以下功能时使用:
  • ☑ 允许用户从图库中选择照片
  • ☑ 处理受限照片库访问权限
  • ☑ 将照片/视频保存至相机胶卷
  • ☑ 在PHPicker和PhotosPicker之间做选择
  • ☑ 从PhotosPickerItem加载图片
  • ☑ 监听照片库变更
  • ☑ 请求合适的权限级别

Example Prompts

示例提问

"How do I let users pick photos in SwiftUI?" "User says they can't see their photos" "How do I save a photo to the camera roll?" "What's the difference between PHPicker and PhotosPicker?" "How do I handle limited photo access?" "User granted limited access but can't see photos" "How do I load an image from PhotosPickerItem?"
"如何在SwiftUI中让用户选择照片?" "用户反馈看不到自己的照片" "如何将照片保存到相机胶卷?" "PHPicker和PhotosPicker有什么区别?" "如何处理受限照片访问权限?" "用户授予了受限权限但仍看不到照片" "如何从PhotosPickerItem加载图片?"

Red Flags

注意警示

Signs you're making this harder than it needs to be:
  • ❌ Using UIImagePickerController (deprecated for photo selection)
  • ❌ Requesting full library access when picker suffices (privacy violation)
  • ❌ Ignoring
    .limited
    authorization status (users can't expand selection)
  • ❌ Not handling Transferable loading failures (crashes on large photos)
  • ❌ Synchronously loading images from picker results (blocks UI)
  • ❌ Using PhotoKit APIs when you only need to pick photos (over-engineering)
  • ❌ Assuming
    .authorized
    after user grants access (could be
    .limited
    )
以下迹象表明你把问题复杂化了:
  • ❌ 使用UIImagePickerController(照片选择功能已废弃)
  • ❌ 在仅需选择器时请求完整图库访问权限(违反隐私规范)
  • ❌ 忽略
    .limited
    授权状态(用户无法扩展选择范围)
  • ❌ 未处理Transferable加载失败(大照片会导致崩溃)
  • ❌ 同步加载选择器返回的图片(阻塞UI)
  • ❌ 仅需选择照片时使用PhotoKit API(过度设计)
  • ❌ 假设用户授权后状态为
    .authorized
    (实际可能是
    .limited

Mandatory First Steps

必备前置步骤

Before implementing photo library features:
在实现照片库功能前:

1. Choose Your Approach

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
你的需求是什么?

┌─ 用户仅选择照片(无需浏览图库)?
│  ├─ SwiftUI应用 → PhotosPicker(iOS 16+)
│  └─ UIKit应用 → PHPickerViewController(iOS 14+)
│  └─ 无需图库权限!选择器会自动处理。
├─ 展示用户完整照片库(图库UI)?
│  └─ 需要PHPhotoLibrary授权
│     └─ 请求.readWrite权限以浏览
│     └─ 使用presentLimitedLibraryPicker处理.limited状态
├─ 将照片保存至相机胶卷?
│  └─ 需要PHPhotoLibrary授权
│     └─ 请求.addOnly(最小权限)或.readWrite权限
└─ 仅需相机拍摄?
   └─ 无需使用PhotoKit - 参考camera-capture技能

2. Understand Permission Levels

2. 理解权限级别

LevelWhat It AllowsRequest Method
No permissionUser picks via system pickerPHPicker/PhotosPicker (automatic)
.addOnly
Save to camera roll only
requestAuthorization(for: .addOnly)
.limited
User-selected subset onlyUser chooses in system UI
.authorized
Full library access
requestAuthorization(for: .readWrite)
Key insight: PHPicker and PhotosPicker require NO permission. The system handles privacy.
权限级别允许操作请求方式
无权限用户通过系统选择器选择照片PHPicker/PhotosPicker(自动处理)
.addOnly
仅保存至相机胶卷
requestAuthorization(for: .addOnly)
.limited
仅访问用户选择的子集用户在系统UI中选择
.authorized
完整图库访问权限
requestAuthorization(for: .readWrite)
核心要点:PHPicker和PhotosPicker无需权限,系统会自动处理隐私问题。

3. Info.plist Keys

3. Info.plist配置项

xml
<!-- Required for any PhotoKit access -->
<key>NSPhotoLibraryUsageDescription</key>
<string>Access your photos to share them</string>

<!-- Required if saving photos -->
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Save photos to your library</string>
xml
<!-- 任何PhotoKit访问都需要 -->
<key>NSPhotoLibraryUsageDescription</key>
<string>访问你的照片以进行分享</string>

<!-- 保存照片时需要 -->
<key>NSPhotoLibraryAddUsageDescription</key>
<string>将照片保存至你的图库</string>

Core Patterns

核心实现模式

Pattern 1: SwiftUI PhotosPicker (iOS 16+)

模式1:SwiftUI PhotosPicker(iOS 16+)

Use case: Let users select photos in a SwiftUI app.
swift
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)
        }
    }
}
Multi-selection:
swift
@State private var selectedItems: [PhotosPickerItem] = []

PhotosPicker(
    selection: $selectedItems,
    maxSelectionCount: 5,
    matching: .images
) {
    Text("Select Photos")
}
适用场景:在SwiftUI应用中允许用户选择照片。
swift
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  // 仅筛选图片
            ) {
                Label("选择照片", 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
        }

        // 先加载为Data(比直接加载Image更可靠)
        if let data = try? await item.loadTransferable(type: Data.self),
           let uiImage = UIImage(data: data) {
            selectedImage = Image(uiImage: uiImage)
        }
    }
}
多选功能:
swift
@State private var selectedItems: [PhotosPickerItem] = []

PhotosPicker(
    selection: $selectedItems,
    maxSelectionCount: 5,
    matching: .images
) {
    Text("选择多张照片")
}

Advanced Filters (iOS 15+/16+)

高级筛选(iOS 15+/16+)

swift
// 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]))])
Cost: 15 min implementation, no permissions required
swift
// 仅截图
matching: .screenshots

// 仅屏幕录制
matching: .screenRecordings

// 慢动作视频
matching: .sloMoVideos

// 电影模式视频(iOS 16+)
matching: .cinematicVideos

// 景深效果照片
matching: .depthEffectPhotos

// 连拍照片
matching: .bursts

// 使用.any、.all、.not组合筛选
// 视频和实况照片
matching: .any(of: [.videos, .livePhotos])

// 所有图片(除截图外)
matching: .all(of: [.images, .not(.screenshots)])

// 所有图片(除截图和全景照外)
matching: .all(of: [.images, .not(.any(of: [.screenshots, .panoramas]))])
实现成本:15分钟,无需权限

Pattern 1b: Embedded PhotosPicker (iOS 17+)

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

Use case: Embed picker inline in your UI instead of presenting as sheet.
swift
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
        }
    }
}
Picker Styles:
StyleDescription
.presentation
Default modal sheet
.inline
Embedded in your view hierarchy
.compact
Single row, minimal vertical space
Customization modifiers:
swift
// 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
Privacy note: First time an embedded picker appears, iOS shows an onboarding UI explaining your app can only access selected photos. A privacy badge indicates the picker is out-of-process.
适用场景:在UI中内嵌选择器,而非以模态表单形式展示。
swift
import SwiftUI
import PhotosUI

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

    var body: some View {
        VStack {
            // 选择器上方的自定义内容
            SelectedPhotosGrid(items: selectedItems)

            // 内嵌选择器填充可用空间
            PhotosPicker(
                selection: $selectedItems,
                maxSelectionCount: 10,
                selectionBehavior: .continuous,  // 用户点击时实时更新选择
                matching: .images
            ) {
                // 内联样式下标签会被忽略
                Text("选择")
            }
            .photosPickerStyle(.inline)  // 内嵌展示而非模态弹出
            .photosPickerDisabledCapabilities([.selectionActions])  // 隐藏添加/取消按钮
            .photosPickerAccessoryVisibility(.hidden, edges: .all)  // 隐藏导航栏/工具栏
            .frame(height: 300)  // 控制选择器高度
            .ignoresSafeArea(.container, edges: .bottom)  // 延伸至底部边缘
        }
    }
}
选择器样式:
样式描述
.presentation
默认模态表单
.inline
内嵌至视图层级中
.compact
单行布局,占用最小垂直空间
自定义修饰符:
swift
// 隐藏导航/工具栏附件
.photosPickerAccessoryVisibility(.hidden, edges: .all)
.photosPickerAccessoryVisibility(.hidden, edges: .top)  // 仅隐藏导航栏
.photosPickerAccessoryVisibility(.hidden, edges: .bottom)  // 仅隐藏工具栏

// 禁用功能(隐藏对应UI)
.photosPickerDisabledCapabilities([.search])  // 隐藏搜索
.photosPickerDisabledCapabilities([.collectionNavigation])  // 隐藏相册
.photosPickerDisabledCapabilities([.stagingArea])  // 隐藏选择预览
.photosPickerDisabledCapabilities([.selectionActions])  // 隐藏添加/取消

// 实时更新选择
selectionBehavior: .continuous
隐私说明:首次展示内嵌选择器时,iOS会显示引导UI,说明你的应用仅能访问用户选择的照片。隐私标识会显示选择器处于进程外。

Pattern 2: UIKit PHPickerViewController (iOS 14+)

模式2:UIKit PHPickerViewController(iOS 14+)

Use case: Photo selection in UIKit apps.
swift
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)
            }
        }
    }
}
Filter options:
swift
// 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应用中选择照片。
swift
import PhotosUI

class PhotoPickerViewController: UIViewController, PHPickerViewControllerDelegate {

    func showPicker() {
        var config = PHPickerConfiguration()
        config.selectionLimit = 1  // 0 = 无限制
        config.filter = .images    // 或.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 }

        // 异步加载图片
        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)
            }
        }
    }
}
筛选选项:
swift
// 仅图片
config.filter = .images

// 仅视频
config.filter = .videos

// 仅实况照片
config.filter = .livePhotos

// 图片和视频
config.filter = .any(of: [.images, .videos])

// 排除截图(iOS 15+)
config.filter = .all(of: [.images, .not(.screenshots)])

// iOS 16+筛选器
config.filter = .cinematicVideos
config.filter = .depthEffectPhotos
config.filter = .bursts

UIKit Embedded Picker (iOS 17+)

UIKit内嵌选择器(iOS 17+)

swift
// 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)
Updating picker while displayed (iOS 17+):
swift
// Deselect assets by their identifiers
picker.deselectAssets(withIdentifiers: ["assetID1", "assetID2"])

// Reorder assets in selection
picker.moveAsset(withIdentifier: "assetID", afterAssetWithIdentifier: "otherID")
Cost: 20 min implementation, no permissions required
swift
// 配置内嵌使用
var config = PHPickerConfiguration()
config.selection = .continuous  // 实时更新而非等待添加按钮
config.mode = .compact  // 单行布局(可选)
config.selectionLimit = 10

// 隐藏边距
config.edgesWithoutContentMargins = .all  // 选择器无外边距

// 禁用功能
config.disabledCapabilities = [.search, .selectionActions]

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

// 添加为子视图控制器(内嵌必需)
addChild(picker)
containerView.addSubview(picker.view)
picker.view.frame = containerView.bounds
picker.didMove(toParent: self)
展示时更新选择器(iOS 17+):
swift
// 通过标识符取消选择资源
picker.deselectAssets(withIdentifiers: ["assetID1", "assetID2"])

// 重新排序选择的资源
picker.moveAsset(withIdentifier: "assetID", afterAssetWithIdentifier: "otherID")
实现成本:20分钟,无需权限

Pattern 2b: Options Menu & HDR Support (iOS 17+)

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

The picker now shows an Options menu letting users choose to strip location metadata from photos. This works automatically with PhotosPicker and PHPicker.
Preserving HDR content:
By default, picker may transcode to JPEG, losing HDR data. To receive original format:
swift
// 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 equivalent:
swift
var config = PHPickerConfiguration()
config.preferredAssetRepresentationMode = .current  // Don't transcode
Cinematic mode videos: Picker returns rendered version with depth effects baked in. To get original with decision points, use PhotoKit with library access instead.
选择器现在会显示选项菜单,允许用户选择移除照片中的位置元数据。此功能在PhotosPicker和PHPicker中自动生效。
保留HDR内容:
默认情况下,选择器可能会转码为JPEG,丢失HDR数据。要接收原始格式:
swift
// SwiftUI - 使用.current编码保留HDR
PhotosPicker(
    selection: $selectedItems,
    matching: .images,
    preferredItemEncoding: .current  // 不转码
) { ... }

// 加载时保留原始格式
struct HDRImage: Transferable {
    let data: Data

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

// 请求.image通用类型而非.jpeg特定类型
let result = try await item.loadTransferable(type: HDRImage.self)
UIKit等效实现:
swift
var config = PHPickerConfiguration()
config.preferredAssetRepresentationMode = .current  // 不转码
电影模式视频:选择器返回已渲染的版本,深度效果已固化。如需获取带决策点的原始视频,请使用带图库访问权限的PhotoKit。

Pattern 3: Handling Limited Library Access

模式3:处理受限图库访问

Use case: User granted limited access; let them add more photos.
Suppressing automatic prompt (iOS 14+):
By default, iOS shows "Select More Photos" prompt when
.limited
is detected. To handle it yourself:
xml
<!-- Info.plist - Add this to handle limited access UI yourself -->
<key>PHPhotoLibraryPreventAutomaticLimitedAccessAlert</key>
<true/>
Manual limited access handling:
swift
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)
    }
}
Observe limited selection changes:
swift
// Register for changes
PHPhotoLibrary.shared().register(self)

// In delegate
func photoLibraryDidChange(_ changeInstance: PHChange) {
    // User may have modified their limited selection
    // Refresh your photo grid
}
Cost: 30 min implementation
适用场景:用户授予了受限权限,允许他们添加更多照片。
禁用自动提示(iOS 14+):
默认情况下,检测到.limited状态时iOS会显示"选择更多照片"提示。如需自行处理:
xml
<!-- Info.plist - 添加此项以自行处理受限访问UI -->
<key>PHPhotoLibraryPreventAutomaticLimitedAccessAlert</key>
<true/>
手动处理受限访问:
swift
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:
            // 用户授予了受限权限 - 展示UI以扩展选择
            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)
    }
}
监听受限选择变更:
swift
// 注册变更通知
PHPhotoLibrary.shared().register(self)

// 在代理方法中
func photoLibraryDidChange(_ changeInstance: PHChange) {
    // 用户可能修改了受限选择
    // 刷新你的照片网格
}
实现成本:30分钟

Pattern 4: Saving Photos to Camera Roll

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

Use case: Save captured or edited photos.
swift
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)
    }
}
Cost: 15 min implementation
适用场景:保存拍摄或编辑后的照片。
swift
import Photos

func saveImageToLibrary(_ image: UIImage) async throws {
    // 请求add-only权限(最小权限)
    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)
    }
}

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

        // 将数据写入临时文件以用于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分钟

Pattern 5: Loading Images from PhotosPickerItem

模式5:从PhotosPickerItem加载图片

Use case: Properly handle async image loading with error handling.
The problem: Default
Image
Transferable only supports PNG. Most photos are JPEG/HEIF.
swift
// 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
    }
}
Loading with progress:
swift
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)
            }
        }
    }
}
Cost: 20 min implementation
适用场景:通过错误处理正确处理异步图片加载。
问题:默认的
Image
Transferable仅支持PNG格式,而大多数照片是JPEG/HEIF格式。
swift
// 自定义Transferable以支持任何图片格式
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
    }
}

// 使用方式
func loadImage(from item: PhotosPickerItem) async -> UIImage? {
    do {
        let result = try await item.loadTransferable(type: TransferableImage.self)
        return result?.image
    } catch {
        print("加载图片失败:\(error)")
        return nil
    }
}
带进度的加载:
swift
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分钟

Pattern 6: Observing Photo Library Changes

模式6:监听照片库变更

Use case: Keep your gallery UI in sync with Photos app.
swift
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)
            )
        }
    }
}
Cost: 30 min implementation
适用场景:保持图库UI与Photos应用同步。
swift
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分钟

Anti-Patterns

反模式

Anti-Pattern 1: Requesting Full Access for Photo Picking

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

Wrong:
swift
// Over-requesting - picker doesn't need this!
let status = await PHPhotoLibrary.requestAuthorization(for: .readWrite)
if status == .authorized {
    showPhotoPicker()
}
Right:
swift
// Just show the picker - no permission needed
PhotosPicker(selection: $item, matching: .images) {
    Text("Select Photo")
}
Why it matters: PHPicker and PhotosPicker handle privacy automatically. Requesting library access when you only need to pick photos is a privacy violation and may cause App Store rejection.
错误做法:
swift
// 过度请求权限 - 选择器不需要这个!
let status = await PHPhotoLibrary.requestAuthorization(for: .readWrite)
if status == .authorized {
    showPhotoPicker()
}
正确做法:
swift
// 直接展示选择器 - 无需权限
PhotosPicker(selection: $item, matching: .images) {
    Text("选择照片")
}
原因:PHPicker和PhotosPicker会自动处理隐私问题。仅需选择照片时请求图库权限属于隐私违规,可能导致App Store审核被拒。

Anti-Pattern 2: Ignoring Limited Status

反模式2:忽略受限状态

Wrong:
swift
let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
if status == .authorized {
    showGallery()
} else {
    showPermissionDenied()  // Wrong! .limited is valid
}
Right:
swift
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
}
Why it matters: iOS 14+ users can grant limited access. Treating it as denied frustrates users.
错误做法:
swift
let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
if status == .authorized {
    showGallery()
} else {
    showPermissionDenied()  // 错误!.limited是有效状态
}
正确做法:
swift
let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
switch status {
case .authorized:
    showGallery()
case .limited:
    showGallery()  // 可配合受限选择使用
    showLimitedBanner()  // 向用户说明
case .denied, .restricted:
    showPermissionDenied()
case .notDetermined:
    requestAccess()
@unknown default:
    break
}
原因:iOS 14+用户可授予受限权限,将其视为拒绝会影响用户体验。

Anti-Pattern 3: Synchronous Image Loading

反模式3:同步加载图片

Wrong:
swift
// Blocks UI thread
let data = try! selectedItem.loadTransferable(type: Data.self)
Right:
swift
Task {
    if let data = try? await selectedItem.loadTransferable(type: Data.self) {
        // Use data
    }
}
Why it matters: Large photos (RAW, panoramas) take seconds to load. Blocking UI causes ANR.
错误做法:
swift
// 阻塞UI线程
let data = try! selectedItem.loadTransferable(type: Data.self)
正确做法:
swift
Task {
    if let data = try? await selectedItem.loadTransferable(type: Data.self) {
        // 使用数据
    }
}
原因:大照片(RAW、全景照)加载需要数秒时间,阻塞UI会导致应用无响应(ANR)。

Anti-Pattern 4: Using UIImagePickerController for Photo Selection

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

Wrong:
swift
let picker = UIImagePickerController()
picker.sourceType = .photoLibrary
present(picker, animated: true)
Right:
swift
var config = PHPickerConfiguration()
config.filter = .images
let picker = PHPickerViewController(configuration: config)
present(picker, animated: true)
Why it matters: UIImagePickerController is deprecated for photo selection. PHPicker is more reliable, handles large assets, and provides better privacy.
错误做法:
swift
let picker = UIImagePickerController()
picker.sourceType = .photoLibrary
present(picker, animated: true)
正确做法:
swift
var config = PHPickerConfiguration()
config.filter = .images
let picker = PHPickerViewController(configuration: config)
present(picker, animated: true)
原因:UIImagePickerController的照片选择功能已废弃。PHPicker更可靠,能处理大资源,且隐私性更好。

Pressure Scenarios

压力场景

Scenario 1: "Just Get Photo Access Working"

场景1:"快速实现照片访问功能"

Context: Product wants photo import feature. You're considering requesting full library access "to be safe."
Pressure: "Users will just tap Allow anyway."
Reality: Since iOS 14, users can grant limited access. Full access request triggers additional privacy prompt. App Store Review may reject unnecessary permission requests.
Correct action:
  1. Use PhotosPicker or PHPicker (no permission needed)
  2. Only request .readWrite if building a gallery browser
  3. Only request .addOnly if just saving photos
Push-back template: "PHPicker works without any permission request - users can select photos directly. Requesting library access when we only need picking is a privacy violation that App Store Review may flag."
背景:产品需要照片导入功能,你考虑请求完整图库权限"以确保可用"。
压力:"用户反正会点击允许的。"
实际情况:自iOS 14起,用户可授予受限权限。请求完整权限会触发额外隐私提示,App Store审核可能因不必要的权限请求而拒绝。
正确操作:
  1. 使用PhotosPicker或PHPicker(无需权限)
  2. 仅在构建图库浏览器时请求.readWrite权限
  3. 仅在保存照片时请求.addOnly权限
反驳模板:"PHPicker无需任何权限请求即可工作 - 用户可直接选择照片。仅需选择照片时请求图库权限属于隐私违规,App Store审核可能会标记此问题。"

Scenario 2: "Users Say They Can't See Their Photos"

场景2:"用户反馈看不到自己的照片"

Context: Support tickets about "no photos available" even though user granted access.
Pressure: "Just ask for full access again."
Reality: User likely granted
.limited
access and selected 0 photos initially.
Correct action:
  1. Check for
    .limited
    status
  2. Show
    presentLimitedLibraryPicker()
    to let user add photos
  3. Explain in UI: "Tap here to add more photos"
Push-back template: "The user has limited access - they need to expand their selection. I'll add a button that opens the limited library picker so they can add more photos."
背景:支持工单显示用户"已授予权限但看不到照片"。
压力:"直接再次请求完整权限。"
实际情况:用户很可能授予了
.limited
权限,且初始选择了0张照片。
正确操作:
  1. 检查是否为
    .limited
    状态
  2. 展示
    presentLimitedLibraryPicker()
    让用户添加照片
  3. 在UI中说明:"点击此处添加更多照片"
反驳模板:"用户拥有受限权限 - 他们需要扩展选择范围。我会添加一个按钮,打开受限图库选择器让他们添加更多照片。"

Scenario 3: "Photo Loads Taking Forever"

场景3:"照片加载太慢"

Context: Users complain photo picker is slow to display selected images.
Pressure: "Can you cache or preload somehow?"
Reality: Large photos (RAW, panoramas, Live Photos) are slow to decode. Solution is UX, not caching.
Correct action:
  1. Show loading placeholder immediately
  2. Load thumbnail first, full image second
  3. Show progress indicator for large files
  4. Use async/await to avoid blocking
Push-back template: "Large photos take time to load - that's physics. I'll show a placeholder immediately and load progressively. For the picker UI, thumbnail loading is already optimized by the system."
背景:用户反馈照片选择器显示所选图片的速度很慢。
压力:"能否缓存或预加载?"
实际情况:大照片(RAW、全景照、实况照片)解码需要时间,解决方案在于用户体验而非缓存。
正确操作:
  1. 立即展示加载占位符
  2. 先加载缩略图,再加载完整图片
  3. 为大文件显示进度指示器
  4. 使用async/await避免阻塞UI
反驳模板:"大照片加载需要时间 - 这是客观事实。我会立即展示占位符并逐步加载。对于选择器UI,系统已优化了缩略图加载。"

Checklist

检查清单

Before shipping photo library features:
Permission Strategy:
  • ☑ Using PHPicker/PhotosPicker for simple selection (no permission needed)
  • ☑ Only requesting .readWrite if building gallery UI
  • ☑ Only requesting .addOnly if only saving photos
  • ☑ Info.plist usage descriptions present
Limited Library:
  • ☑ Handling
    .limited
    status (not treating as denied)
  • ☑ Offering
    presentLimitedLibraryPicker()
    for users to add photos
  • ☑ UI explains limited access to users
Image Loading:
  • ☑ All loading is async (no UI blocking)
  • ☑ Custom Transferable handles JPEG/HEIF (not just PNG)
  • ☑ Error handling for failed loads
  • ☑ Loading indicator for large files
Saving Photos:
  • ☑ Using .addOnly when full access not needed
  • ☑ Using performChanges for atomic operations
  • ☑ Handling save failures gracefully
Photo Library Changes:
  • ☑ Registered as PHPhotoLibraryChangeObserver if displaying library
  • ☑ Updating UI on main thread after changes
  • ☑ Unregistering observer in deinit
发布照片库功能前:
权限策略:
  • ☑ 简单选择使用PHPicker/PhotosPicker(无需权限)
  • ☑ 仅在构建图库UI时请求.readWrite权限
  • ☑ 仅在保存照片时请求.addOnly权限
  • ☑ 已添加Info.plist权限说明
受限图库:
  • ☑ 处理
    .limited
    状态(不视为拒绝)
  • ☑ 提供
    presentLimitedLibraryPicker()
    让用户添加照片
  • ☑ UI中向用户说明受限访问
图片加载:
  • ☑ 所有加载均为异步(无UI阻塞)
  • ☑ 自定义Transferable支持JPEG/HEIF(不仅是PNG)
  • ☑ 处理加载失败的错误
  • ☑ 为大文件显示加载指示器
保存照片:
  • ☑ 无需完整权限时使用.addOnly
  • ☑ 使用performChanges进行原子操作
  • ☑ 优雅处理保存失败
照片库变更:
  • ☑ 展示图库时已注册PHPhotoLibraryChangeObserver
  • ☑ 变更后在主线程更新UI
  • ☑ 在deinit中注销观察者

Resources

资源

WWDC: 2020-10652, 2020-10641, 2022-10023, 2023-10107
Docs: /photosui/phpickerviewcontroller, /photosui/photospicker, /photos/phphotolibrary
Skills: axiom-photo-library-ref, axiom-camera-capture
WWDC:2020-10652, 2020-10641, 2022-10023, 2023-10107
文档:/photosui/phpickerviewcontroller, /photosui/photospicker, /photos/phphotolibrary
相关技能:axiom-photo-library-ref, axiom-camera-capture