简介:这份PDF文档面向iOS开发初学者与中级开发者,聚焦相册图片多选、删除及拍照上传这一常见业务场景,帮助解决社交、图片编辑类应用中与系统相册交互、数据同步和图片压缩上传等实际问题。资源包共1个PDF文件,大小约77KB,内容以代码讲解与实现思路为主,便于对照阅读和快速检索。文档围绕QBImagePickerController第三方库展开,涵盖多选配置、最大选择数量控制、已选图片URL同步、代理回调处理、删除确认交互以及图片压缩上传等关键环节,并给出RRZShowEditViewController等控制器的完整实现结构。目前已有288人学习,适合需要快速搭建相册多选删除功能、理解第三方库集成与数据管理流程的开发者参考借鉴。
1. 相册多选与删除:从 Photos 框架到用户手指的那 0.5 秒
做 iOS 相册功能,最容易被低估的就是「多选 + 删除」这一对组合。单张选择谁都会写,可一旦用户长按进入多选、手指在九宫格里来回勾选、最后点下那个红色删除按钮,背后牵扯的是 PHPhotoLibrary 的变更请求、PHAsset 的批量引用、系统删除确认弹窗的二次授权,以及删除后 UICollectionView 的索引错位。我见过太多项目在单张场景跑得好好的,一上多选就出现「删了三张只掉了一张」「删完还剩占位图」「第二次进入选择态崩溃」这类玄学问题。
这篇要解决的就是这条完整链路:怎么用 Photos 框架拿到相册资源、怎么设计一套稳定的多选状态机、怎么把选中的 PHAsset 批量删除并让 UI 和数据源保持一致。适合已经能跑通单张选图、准备把相册模块做扎实的 iOS 开发者,也适合正在被「删除后列表错乱」折磨的同学。下面按「先立住原理、再动手复现、最后讲坑」的顺序推,代码可以直接抄进工程改。
2. Photos 框架的权限与资源获取:先把地基打对
2.1 权限申请的三个层级与 Info.plist 配置
Photos 框架的权限不是「给 / 不给」这么简单。iOS 14 之后新增了「受限访问」(Limited),用户可以选择只授权部分照片。如果你的代码只判断.authorized,那在受限模式下会直接判定为无权限,用户明明选了照片你的 App 却显示空白,这是最常见的翻车点之一。
需要在 Info.plist 里配置两个 key,缺一个都会在调用时直接崩溃:
<key>NSPhotoLibraryUsageDescription</key> <string>需要访问相册以便您选择并管理图片</string> <key>NSPhotoLibraryAddUsageDescription</key> <string>需要保存图片到您的相册</string>读取权限用PHPhotoLibrary.authorizationStatus(for: .readWrite),注意参数是.readWrite而不是.read,删除操作属于写权限范畴。判断逻辑要覆盖四种状态:
import Photos func ensurePhotoPermission(completion: @escaping (Bool) -> Void) { let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) switch status { case .authorized: completion(true) case .limited: // 受限访问:仍可读取用户选中的资源,删除需谨慎 completion(true) case .notDetermined: PHPhotoLibrary.requestAuthorization(for: .readWrite) { newStatus in DispatchQueue.main.async { completion(newStatus == .authorized || newStatus == .limited) } } case .denied, .restricted: completion(false) @unknown default: completion(false) } }逻辑说明:.limited必须当作可用状态处理,否则受限用户完全无法使用相册功能。参数上.readWrite决定了后续能否执行删除,如果只申请.read,performChanges里的删除请求会直接失败并抛出无权限错误。回调必须切回主线程,因为后续要更新 UI。
提示:受限模式下如果用户想扩大授权范围,可以调用
PHPhotoLibrary.shared().presentLimitedLibraryPicker(from:)弹出系统选择器,不要自己造轮子。
2.2 用 PHAsset 拉取相册资源与排序
拿到权限后,用PHAsset.fetchAssets拉取资源。这里有个性能细节:不要在主线程同步遍历几万张照片,PHAsset是轻量引用,但PHImageManager请求缩略图才是真正的开销。
func fetchAllPhotos() -> PHFetchResult<PHAsset> { let options = PHFetchOptions() // 按创建时间倒序,符合相册「最新在前」的直觉 options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] // 只取图片,过滤掉视频 options.predicate = NSPredicate(format: "mediaType == %d", PHAssetMediaType.image.rawValue) return PHAsset.fetchAssets(with: options) }逻辑说明:sortDescriptors用creationDate倒序是相册类 App 的通用做法,用户预期最新照片在最上面。predicate过滤mediaType可以避免把视频混进来导致缩略图请求失败。PHFetchResult是懒加载的,不会一次性把所有资源读进内存,遍历时用object(at:)按需取。
参数上,如果要做分页加载,可以配合PHFetchOptions.fetchLimit,但相册场景一般不做分页,因为用户会快速滑动,分页反而增加复杂度。缩略图请求用PHImageManager.default().requestImage(for:targetSize:contentMode:options:),targetSize设成 cell 尺寸乘以屏幕 scale,deliveryMode设为.opportunistic兼顾速度和质量。
3. 多选状态机的设计:别用数组存选中项
3.1 用 Set 管理选中索引而不是 Array
多选最核心的数据结构选择,直接决定了删除时会不会出错。很多人第一反应是用[IndexPath]或[Int]数组存选中项,然后在删除时遍历数组去删数据源。这个做法在「删除后索引变化」的场景下必然翻车,因为删掉前面的元素后,后面所有索引都失效了。
正确做法是用Set<PHAsset>或Set<String>(存localIdentifier)来管理选中项。Set的查找是 O(1),判断某个 cell 是否选中不需要遍历,而且删除时按对象删而不是按索引删,天然规避索引错位。
final class PhotoPickerModel { private(set) var assets: [PHAsset] = [] private(set) var selectedIds = Set<String>() // 存 localIdentifier var isSelecting = false // 是否处于多选态 func loadAssets() { let result = fetchAllPhotos() var temp: [PHAsset] = [] result.enumerateObjects { asset, _, _ in temp.append(asset) } assets = temp } func toggleSelection(_ asset: PHAsset) { let id = asset.localIdentifier if selectedIds.contains(id) { selectedIds.remove(id) } else { selectedIds.insert(id) } } func isSelected(_ asset: PHAsset) -> Bool { selectedIds.contains(asset.localIdentifier) } }逻辑说明:selectedIds存localIdentifier而不是PHAsset本身,是因为PHAsset的isEqual依赖底层对象,跨PHFetchResult重新拉取后引用可能不同,而localIdentifier是稳定唯一的字符串。isSelecting控制是否显示勾选框,单击进入预览还是切换选中状态由它决定。
参数上,如果要做「最多选 9 张」的限制,在toggleSelection里加判断:if !selectedIds.contains(id) && selectedIds.count >= 9 { return },同时给用户一个轻提示。不要用assets.firstIndex(of:)去反查索引,那是 O(n),列表长了会卡。
3.2 UICollectionView 的选中态刷新与批量更新
数据源用Set管好之后,UI 刷新要避免全量reloadData。全量刷新会导致正在滑动的列表跳动,而且丢失动画。正确做法是只刷新状态变化的 cell。
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { let asset = model.assets[indexPath.item] if !model.isSelecting { // 非多选态:进入预览或大图 openPreview(asset) return } model.toggleSelection(asset) // 只刷新当前 cell,带动画 collectionView.reloadItems(at: [indexPath]) updateBottomBar() } func updateBottomBar() { let count = model.selectedIds.count deleteButton.isEnabled = count > 0 deleteButton.setTitle("删除(\(count))", for: .normal) }逻辑说明:reloadItems(at:)只刷新指定 indexPath,比reloadData轻量得多,且保留滚动位置。updateBottomBar根据选中数量更新底部删除按钮的可用状态和文案,这是多选交互的标准反馈。
参数上,如果 cell 数量很大(比如上千张),reloadItems依然只处理可见区域,性能没问题。但要注意indexPath.item和model.assets的对应关系必须稳定,所以assets数组在删除前不要做任何排序变化,否则 indexPath 会错位。
4. 批量删除的实现:performChanges 与 UI 同步
4.1 用 PHPhotoLibrary.performChanges 批量删除
删除相册资源必须通过PHPhotoLibrary.shared().performChanges,不能直接操作文件。系统会弹出确认弹窗,用户确认后才真正删除。批量删除就是把所有选中的PHAsset一次性放进PHAssetChangeRequest.deleteAssets。
func deleteSelectedAssets(completion: @escaping (Bool) -> Void) { let assetsToDelete = model.assets.filter { model.isSelected($0) } guard !assetsToDelete.isEmpty else { completion(false) return } PHPhotoLibrary.shared().performChanges({ // 批量删除,传 NSArray 形式的 PHAsset PHAssetChangeRequest.deleteAssets(assetsToDelete as NSArray) }) { success, error in DispatchQueue.main.async { if success { // 先从数据源移除,再刷新 UI self.model.assets.removeAll { self.model.isSelected($0) } self.model.selectedIds.removeAll() self.collectionView.reloadData() self.updateBottomBar() } else { print("删除失败: \(String(describing: error))") } completion(success) } } }逻辑说明:deleteAssets接收NSArray,Swift 数组需要桥接。删除成功后必须先从model.assets移除对应元素,再reloadData,顺序反了会崩溃(数据源和 UI 不一致)。selectedIds要清空,否则下次进入多选态会残留选中状态。
参数上,performChanges的 completion 回调不在主线程,所有 UI 操作必须DispatchQueue.main.async包起来。错误处理里error可能是PHPhotosError,常见的是用户取消或权限不足,不要只打印,最好给用户一个 toast 提示。
4.2 删除后列表索引与占位图的处理
删除后最容易出的问题是「列表还剩一个空白 cell」或「删完滚动位置跳到顶部」。前者是因为数据源移除了但 cell 没刷新,后者是因为reloadData重置了 contentOffset。
// 更平滑的做法:用 performBatchUpdates 做局部删除 func deleteWithAnimation(at indexPaths: [IndexPath]) { collectionView.performBatchUpdates { // 先更新数据源 let idsToRemove = indexPaths.map { model.assets[$0.item].localIdentifier } model.assets.removeAll { idsToRemove.contains($0.localIdentifier) } model.selectedIds.subtract(idsToRemove) // 再删 cell collectionView.deleteItems(at: indexPaths) } completion: { _ in self.updateBottomBar() } }逻辑说明:performBatchUpdates里数据源变更和deleteItems必须成对出现,且顺序是先改数据源再删 cell。subtract从选中集合里移除已删除的 id,避免残留。这样滚动位置不会跳,动画也自然。
参数上,indexPaths需要按降序排列再删,否则删了前面的会导致后面的 indexPath 失效。如果嫌麻烦,直接用reloadData也能跑,但体验差一截。占位图问题通常是 cell 复用时没重置imageView.image = nil,在prepareForReuse里清空即可。
5. 避坑与排查:多选删除最容易翻车的 5 个点
5.1 删除后崩溃:数据源与 UI 不同步
现象:点删除后 App 直接 crash,日志显示Invalid update: invalid number of items。原因是performBatchUpdates里数据源减少的数量和deleteItems的数量不一致,或者先删了 cell 再改数据源。解决:严格保证「先改数据源、再删 cell」,且两者数量完全对应。用Set存选中项能大幅降低这类错误。
5.2 受限权限下删除失败
现象:用户授权了「受限访问」,选中照片后点删除没反应或报错。原因是受限模式下 App 只能读取用户指定的资源,删除这些资源需要额外确认。解决:在删除前判断authorizationStatus == .limited,如果是,先提示用户「受限模式下删除可能需要在系统相册中确认」,或者引导用户扩大授权。不要静默失败。
5.3 选中状态在复用 cell 时错乱
现象:滑动列表后,没选中的 cell 显示成选中状态,或者选中的变回未选中。原因是 cell 复用时没有根据model.isSelected重置勾选框。解决:在cellForItemAt里每次都根据model.isSelected(asset)设置勾选 UI,不要依赖 cell 自身的状态。prepareForReuse里也要重置。
5.4 删除大量照片时卡顿或超时
现象:一次删几百张照片,界面卡住几秒甚至被系统杀掉。原因是performChanges里一次性提交太多资源,系统处理慢。解决:分批删除,每批 50 到 100 张,用递归或串行队列逐批提交。同时给用户一个 loading 指示,避免以为卡死。
5.5 删除后相册缓存未更新
现象:删除成功,但重新进入相册页面还能看到已删除的照片。原因是PHFetchResult有缓存,或者用了PHCachingImageManager没清缓存。解决:删除后重新执行fetchAllPhotos()拉取最新数据,或者监听PHPhotoLibraryChangeObserver的photoLibraryDidChange回调,在回调里增量更新。不要依赖旧的PHFetchResult。
6. 进阶:用 PHPhotoLibraryChangeObserver 做增量刷新
前面删除后直接reloadData是最省事的做法,但列表长了会闪一下。更专业的做法是注册PHPhotoLibraryChangeObserver,让系统在相册变化时主动通知你,然后做增量更新。这个技巧在「用户在系统相册里删了照片,你的 App 要同步」的场景下尤其有用。
final class PhotoObserver: NSObject, PHPhotoLibraryChangeObserver { weak var model: PhotoPickerModel? weak var collectionView: UICollectionView? func startObserving() { PHPhotoLibrary.shared().register(self) } func photoLibraryDidChange(_ changeInstance: PHChange) { guard let model = model, let collectionView = collectionView else { return } // 用旧的 fetchResult 对比变化 let oldResult = PHAsset.fetchAssets(with: model.fetchOptions) guard let changes = changeInstance.changeDetails(for: oldResult) else { return } DispatchQueue.main.async { // 重新拉取最新数据 model.loadAssets() collectionView.reloadData() model.selectedIds.removeAll() } } deinit { PHPhotoLibrary.shared().unregisterChangeObserver(self) } }逻辑说明:changeDetails(for:)会返回插入、删除、移动的详细信息,理论上可以做精细的增量更新。但相册场景下变化往往涉及多个资源,直接重新拉取再reloadData更稳妥,代价是丢失滚动位置。如果要做极致体验,可以结合changeDetails的removedIndexes和insertedIndexes做performBatchUpdates。
参数上,fetchOptions必须和拉取数据源时用的一致,否则changeDetails对比会失败。deinit里一定要unregister,否则观察者泄漏会导致崩溃。这个方案适合相册模块作为独立页面、生命周期明确的场景。
我自己的习惯是:删除操作永远走performChanges,UI 刷新优先用performBatchUpdates做局部删除,只有跨页面同步才上ChangeObserver。多选状态一律用Set<String>存localIdentifier,这条血泪经验帮我省了至少三次线上崩溃。相册功能不难,难的是边界情况,把权限、索引、复用这三件事盯死,基本就稳了。希望帮到你。
本文还有配套的精品资源,点击获取