简介面向iOS开发者的实用技术文档专门讲解如何基于QBImagePickerController实现相册图片的多选与删除并兼顾拍照、图片压缩及数据管理环节。文档以“晒一晒”发布页为实例围绕RRZShowEditViewController控制器展开清晰展示了遵循UITableViewDataSource、UIImagePickerControllerDelegate、QBImagePickerControllerDelegate等多个协议的写法以及通过ShowEditItem模型保存selectedImages和selectedAssetURLs、同步相册选择状态的过程。从图片选择器初始化、允许最多选择9张图片到删除后再次选图时恢复已选状态再到上传前压缩图片尺寸代码片段与属性初始化方法均有呈现。包体信息PDF格式共1个文件大小仅77KB篇幅紧凑但内容完整适合快速查阅。已有288人学习下载适合有iOS基础、需要实现多图选择和删除功能的开发者参考。文档还涉及删除时通过UIActionSheet或UIAlertController引导确认、优化图片数据量以减轻服务器压力等实用细节可帮助读者在真实项目中少踩坑。1. 做 iOS 开发相册图片多选和删除功能先用 QBImagePickerController 把底层坑挡在外面做 iOS 开发相册图片多选和删除功能很多人第一反应是“系统相册不是自带多选吗”。真动手才发现UIImagePickerController 的多选体验基本没有图片选择、排序、最大数量限制这些都要自己写。QBImagePickerController 把选择器这层补上了但后面的删除、拍照、压缩、上传还得自己在数组上折腾。这套方案适合做社交发布页、图片编辑类应用手头需要一个“最多 9 张、能删能拍能压”的发图组件的同学。核心思路其实一句话所有增删改都在操作数组状态同步做对了功能就成了一半。2. 选型与数据模型为什么“操作数组”是这套逻辑的主线2.1 QBImagePickerController比 UIImagePickerController 更适合多选的几个理由先明确一个前提系统 UIImagePickerController 并不是不能多选iOS 14 之后 PHPicker 也支持多选但你要在旧项目里快速落地或者要做一个类似微信发图那样的九宫格发布页QBImagePickerController 依然是个省事的选择。它内部封装了相册的读取、分组、缩略图展示还直接给了 filterType、allowsMultipleSelection、maximumNumberOfSelection 这些现成开关。看一下初始化代码常见的写法是放在 getter 里避免每次弹相册都重新创建-(QBImagePickerController *)imagePickerController{ if (!_imagePickerController) { _imagePickerController [[QBImagePickerController alloc] init]; _imagePickerController.filterType QBImagePickerControllerFilterTypePhotos; _imagePickerController.delegate self; _imagePickerController.allowsMultipleSelection YES; _imagePickerController.maximumNumberOfSelection 9; } [_imagePickerController.selectedAssetURLs removeAllObjects]; [_imagePickerController.selectedAssetURLs addObjectsFromArray:self.showEditItem.selectedAssetURLs]; return _imagePickerController; }这段代码有两个细节值得注意。第一getter 里每次都会用 showEditItem 里已选的 URL 去同步 QBImagePickerController 的 selectedAssetURLs。这样用户删过照片再重新打开相册之前还没删的图片仍然是选中状态。第二filterType 设成 Photos意味着只显示照片不显示视频。如果需要视频改成 QBImagePickerControllerFilterTypeVideos或者不设。maximumNumberOfSelection 设为 9是发布页里很常见的上限。这个数字不是死规矩你完全可以根据业务改比如 6、12、18但要注意后面九宫格的布局宽度也要跟着调整。如果设成 0 或者不设就不限制数量容易出现用户一次选几十张后面的压缩、上传、数组管理全部压力陡增。我一般会在产品需求明确后把最大数量作为常量提取出来而不是散落在各个控制器里。讲清楚选型理由为什么不用系统相册因为系统相册的 UIImagePickerController 单选居多多选逻辑要自己写而且过去几年它的 API 变化不小iOS 11 之后有 UIDocumentPickeriOS 14 之后有 PHPicker。如果项目还跑在 iOS 12 或更低版本第三方库的兼容性反而更可控。QBImagePickerController 基于 AssetsLibrary虽然 AssetsLibrary 本身被 Photos 框架取代了但这个库在旧项目里存量很多用它来学习“多选状态管理”是很合适的。如果你是新项目可以优先评估 PHPicker但如果你要复用的正是这套代码那就先把 QBImagePickerController 的数组同步逻辑吃透。2.2 ShowEditItem 模型selectedImages 和 selectedAssetURLs 必须成对出现这套方案里所有状态的根基是 ShowEditItem。它不是一个复杂的模型但两个数组的关系必须理清楚。selectedImages 存的是 UIImage用来在 UITableViewCell 的 CollectionView 里直接渲染selectedAssetURLs 存的是 NSURL用来和相册选择器做状态同步以及后续如果需要从相册重新取图时使用。我一般会这样定义它的核心接口interface ShowEditItem : NSObject property (strong, nonatomic) NSMutableArray *selectedImages; property (strong, nonatomic) NSMutableArray *selectedAssetURLs; end实现里初始化时两个数组都必须是 mutable 的- (ShowEditItem *)showEditItem{ if (!_showEditItem) { _showEditItem [[ShowEditItem alloc]init]; _showEditItem.selectedImages [].mutableCopy; _showEditItem.selectedAssetURLs [].mutableCopy; } return _showEditItem; }这里要强调一个容易犯错的点selectedImages 和 selectedAssetURLs 的下标必须一一对应。selectedImages[0] 和 selectedAssetURLs[0] 应该是同一张图片的 UIImage 和 URL。如果中间哪一步只删了一个数组后面刷新 CollectionView 时就会出现图片错位甚至数组越界崩溃。为什么需要 URL 数组而不是只留 UIImage因为 QBImagePickerController 初始化时要用 URL 数组来恢复选中状态。如果只保存 UIImage重新打开相册时无法告诉选择器“哪些是已经选过的”。有些同学一开始只加 selectedImages结果用户删掉一张图后再点“从相册选取”发现相册里所有图片都是未选中状态就是因为 URL 数组缺失。这个坑后文还会展开。数据模型里还可以加一个字段表示“图片是否来自拍照”。拍照返回的图片没有对应的相册 URL除非手动写入相册所以在后期上传时可能要额外处理。我的习惯是拍照图片不写回相册而是在模型里加一个isCameraImage标记这样删除和上传时不会和相册 URL 数组纠缠在一起。这样做有个好处避免 ALAssetsLibrary 异步写入带来的顺序问题这在第 4 章会细讲。2.3 初始化选择器filterType、allowsMultipleSelection、maximumNumberOfSelection 怎么配合三个参数不是孤立存在的。filterType 决定用户能看到什么类型allowsMultipleSelection 决定是否多选maximumNumberOfSelection 决定最多能选几个。实际搭配时常见组合是 filterType Photos allowsMultipleSelection YES maximumNumberOfSelection 9也就是本文方案。还有一种组合是视频多选比如一次选最多 3 个视频只需要把 filterType 改成 VideosmaximumNumberOfSelection 改成 3。但要注意视频的展示和上传逻辑与图片完全不同不能直接套用下面的压缩流程。QBImagePickerController 的 delegate 回调里的 assets 数组元素在图片模式下是 ALAsset在视频模式下拿到的也是 ALAsset但要用 defaultRepresentation 的尺寸和时长做进一步判断。如果你只需要图片就把 filterType 固定为 Photos避免用户误选视频也省掉视频预览的 UI 适配。另一个关键点是 selectedAssetURLs 的同步。每次弹出选择器之前必须把当前已选的 URL 数组赋值进去否则会出现“删掉的图还留在选择器里”的问题。这个同步逻辑写在 getter 里虽然看起来不太优雅但确实能保证每次拿到 imagePickerController 时都是最新状态。如果你改写成懒加载之外的初始化方式务必记得在 present 之前手动调用一次同步。这里顺带提一下权限相册权限和相机权限最好在进入页面时统一申请不要在用户点击加号时才弹授权框。第一次弹授权能容忍但用户拒绝后再点就只能引导去设置里开权限体验很差。我一般在 viewWillAppear 里检查PHAuthorizationStatus未授权时直接弹提示这样后面所有操作都不会卡在权限上。原文没有写权限处理但实际跑真机时会遇到属于必补项。如果项目里有多处都要弹相册不建议把 imagePickerController 做成单例而是像上面这样用 getter 动态创建并同步状态。多页面共用同一个 QBImagePickerController 实例时delegate 容易被覆盖而且 selectedAssetURLs 会串掉排查起来很麻烦。到这里选型和数据模型就立住了。下一章开始搭建页面看看 TableView 和 CollectionView 是怎么把这套数组状态展示出来的。3. 页面搭建TableView CollectionView 组合下的布局与入口控制3.1 两行 TableView 的职责划分文本输入行和图片网格行第一个控制器 RRZShowEditViewController 的页面其实很简单一个 UITableView固定两行。第一行是文本输入第二行是图片宫格。不要因为这个结构简单就忽略它它是很多发布页的标准范式。核心代码-(NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section{ return 2; } -(UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{ if (indexPath.row 0) { RRZSendShowTextCell *cell [tableView dequeueReusableCellWithIdentifier:sendShowTextCellID]; self.textView cell.numberTextView; return cell; }else{ RRZSendShowImageCell *cell [tableView dequeueReusableCellWithIdentifier:sendShowImageCellID]; cell.item self.showEditItem; __weak typeof(self) weakSelf self; cell.addPicturesBlock ^(){ [weakSelf showActionForPhoto]; }; cell.deleteImageBlock ^(ShowEditItem *item){ weakSelf.showEditItem item; [weakSelf.myTableView reloadData]; }; return cell; } }这里有两个 BlockaddPicturesBlock 是点击加号时触发deleteImageBlock 是点击删除按钮时触发。重点在 deleteImageBlock它把 item 整个传回来然后外部重新赋值给 self.showEditItem。这种设计很直接但要注意删除逻辑如果放在 cell 内部完成外部拿到的 item 必须已经正确更新了 selectedImages 和 selectedAssetURLs。如果只是把 item 传出来内部却没动数组那么 reloadData 之后图片原封不动像是删除按钮失灵。我的做法是删除逻辑始终放在模型层也就是 ShowEditItem 的分类或工具方法里cell 只负责调用不直接操作数组。高度方面第一行给 200第二行给 300。这个数字取决于你的 textView 高度和宫格尺寸。如果你把 cell 高度改成自适应就会出现 textView 输入时高度跳动、collectionView 刷新时高度抖动的问题。我一般会用固定高度简单可控。如果你一定要自适应至少要预估 textView 的最大行数并监听 textView 内容变化去更新约束复杂度会高不少。3.2 图片宫格宽度kShowImageCCell_Width 的“三分缝”计算图片网格在 RRZSendShowImageCell 里用 UICollectionView 实现。它的每个 cell 宽度是一个宏#define kShowImageCCell_Width floorf((SCREEN_WIDTH - 15*2 - 10*3)/4)这个公式的含义是屏幕宽度减去左右各 15 的边距再减去 3 个间距因为 4 列之间有 3 个 10pt 的间隔最后除以 4。floorf 向下取整避免因为浮点精度出现半个像素的间隙。如果你把间距改成 8 或者 12宏也要同步重算。如果你把最大选择数量从 9 改成其他数这个宏也要跟着改。例如改成 6 张3 列公式就是floorf((SCREEN_WIDTH - 15*2 - 10*2)/3)改成 12 张4 列间距可以保持不变。隐藏的坑是UICollectionView 的高度 280 是写死的如果宫格超过一行280 放不下就会看到 cell 被截断。想要两行宫格高度至少要cell宽度 * 2 间距。原代码里高度 280 配合 4 列 9 张图3 行其实是放不下的这也是很多拿到代码的人会遇到的问题最后一行的图片被截掉一半。解决办法是动态计算 mediaView 的高度或者允许 collectionView 滚动。cell 数量这里需要注意collectionView 的 numberOfItemsInSection 不是简单的 imageCount而是num 9 ? num 1 : num。也就是说未达到上限时最后一个格子是加号按钮。这个“加号占位”逻辑是整个发布页交互的核心删除图片后数量减少加号会自动补回来。但要注意如果 num 已经等于 9就不应该再显示加号这个条件判断必须放在 cellForItemAtIndexPath 里否则你会多渲染一个空白格点击后数组越界。3.3 拍照与相册入口UIActionSheet 分支处理点击加号后弹出一个底部选择拍照或者从相册选取。原文用的是 UIActionSheet在 iOS 8 之后已经被 UIAlertController 替代但逻辑是相通的。- (void)showActionForPhoto{ UIActionSheet *actionSheet [[UIActionSheet alloc]initWithTitle:nil delegate:self cancelButtonTitle:取消 destructiveButtonTitle:nil otherButtonTitles:拍照,从相册选取, nil]; [actionSheet showInView:self.view]; }拍照分支要先检查设备是否支持摄像头if (![UIImagePickerController isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera]) { UIAlertView *alert [[UIAlertView alloc] initWithTitle:nil message:该设备不支持拍照 delegate:self cancelButtonTitle:确定 otherButtonTitles:NULL]; [alert show]; }else{ UIImagePickerController *picker [[UIImagePickerController alloc] init]; picker.delegate self; picker.allowsEditing NO; picker.sourceType UIImagePickerControllerSourceTypeCamera; [self presentViewController:picker animated:YES completion:nil]; }从相册选取分支要包一层导航控制器因为 QBImagePickerController 的页面需要导航栏才有完成按钮否则只有取消按钮用户选完图不知道怎么确认。UINavigationController *navigationController [[BaseNavigationController alloc] initWithRootViewController:self.imagePickerController]; [self presentViewController:navigationController animated:YES completion:NULL];这里容易踩的坑是拍照和相册两个 UIImagePickerController 共用同一个 delegate而回调的代理方法不同。拍照走的是 imagePickerController:didFinishPickingMediaWithInfo:相册走的是 qb_imagePickerController:didSelectAssets:。一旦把代理方法张冠李戴就会出现“拍完照没有刷新列表”或者“从相册选完没有加到数组里”的现象。我习惯在每个回调入口加日志先确认走的是哪条路径。如果你要改造成 UIAlertController记得把“拍照”和“从相册选取”两个 action 的样式设为 default取消按钮单独一个 action不要让“取消”也触发选中逻辑。另外在 iPad 上 UIActionSheet 需要设置 sourceView否则会崩这也是 iPad 适配必踩的坑。使用 UIAlertController 时同样要在 popoverPresentationController 里设置 sourceView 和 sourceRect。4. 多选回调与删除同步数组状态管理的三个关键点4.1 从相册多选返回先清空再重建还是增量添加这是整套代码里最值得琢磨的一段。看 QBImagePickerController 的 delegate 回调- (void)qb_imagePickerController:(QBImagePickerController *)imagePickerController didSelectAssets:(NSArray *)assets{ [self.showEditItem.selectedImages removeAllObjects]; NSMutableArray *selectedAssetURLs [NSMutableArray new]; [imagePickerController.selectedAssetURLs enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) { [selectedAssetURLs addObject:obj]; }]; self.showEditItem.selectedAssetURLs selectedAssetURLs; for (int i 0; i assets.count; i) { ALAsset *asset assets[i]; UIImage *tempImg [UIImage imageWithCGImage:asset.defaultRepresentation.fullScreenImage]; [self.showEditItem.selectedImages addObject:tempImg]; } dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ dispatch_async(dispatch_get_main_queue(), ^{ [self.myTableView reloadRowsAtIndexPaths:[NSArray arrayWithObject:[NSIndexPath indexPathForRow:1 inSection:0]] withRowAnimation:UITableViewRowAnimationFade]; }); }); [self dismissViewControllerAnimated:YES completion:nil]; }这个回调的意图很明显无论用户之前有没有选过图这次从相册返回后直接把已选的所有图片重建一遍。也就是说它不支持“增量添加”。如果你点加号时 showEditItem 里已经有 3 张图再进相册选 2 张返回后这 5 张是一起重建的不是只加 2 张。为什么这么设计因为 QBImagePickerController 的 selectedAssetURLs 在弹出时被同步成了 showEditItem 里的旧 URL所以用户看到的选中状态是“旧 3 张 新 2 张”。返回时用全部 URL 去重建 selectedImages逻辑上是自洽的。如果弹出时不同步旧 URL用户会发现之前选的图在相册里全部没选中于是重新多选返回后所有旧图被 removeAllObjects 清掉只剩新选的。所以这里有一个很重要的结论如果你要支持“点加号进入相册保留旧选择并追加新图”那么 getter 里的同步和回调里的重建必须配合。少一个都不行。但如果你希望“每次进相册都是全新选择不保留旧状态”那就不用在 getter 里同步 URL直接每次清空即可。这两种交互模式你要先和产品确认不要混着来。4.2 删除图片从两个数组同步移除避免越界和错位删除点击发生在 RRZSendShowImageCell 里它通过 Block 把更新后的 item 抛出来。我的建议是不要只在 cell 里删 selectedImages 就算完。看一下常见实现cell.deleteImageBlock ^(ShowEditItem *item){ weakSelf.showEditItem item; [weakSelf.myTableView reloadData]; };如果你打算把删除逻辑放在 cell 内部Block 拿到的 item 必须已经完成了两个数组的删除。我一般会在 RRZSendShowImageCell 内部调用一个模型方法- (void)deleteImageAtIndex:(NSInteger)index{ if (index 0 index self.item.selectedImages.count) { [self.item.selectedImages removeObjectAtIndex:index]; } if (index 0 index self.item.selectedAssetURLs.count) { [self.item.selectedAssetURLs removeObjectAtIndex:index]; } }这两个 remove 操作必须放在同一个方法里顺序无所谓但条件要一致。有些项目里 selectedImages 和 selectedAssetURLs 个数不一致这个方法就会出问题。比如拍照后只加进 selectedImages 却还没拿到 URL此时如果用户在拿到 URL 之前就点删除数组下标还是对齐的因为 URL 数组里没有这一项你在删除 URL 数组前要用 imageCount 来判断而不是 URL 数组的 count。所以我建议把isCameraImage标记用起来删除时先判断这一项有没有对应的 URL没有就不动 URL 数组。删除完成后立即刷新当前 cell。刷新方式有 reloadData 和 reloadRows 两种。reloadRows 可以带动画但如果你在 Block 里直接把 self.showEditItem 重新赋值了再用旧 index 去 reloadRows 反而会出错因为数据已经变了。我建议简单粗暴地用 reloadData代价是滚动位置会跳一下感知不强。如果你对动画有要求至少要在数据源更新完成后再计算新的 row 位置。4.3 拍照后写回相册ALAssetsLibrary 的坑与 delegate 顺序拍照回调里有一个值得学习的套路拍照拿到图片后先加入 selectedImages然后用 ALAssetsLibrary 把图片写入系统相册在 completionBlock 里把生成的 assetURL 加入 selectedAssetURLs。- (void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary *)info{ UIImage *pickerImage [info objectForKey:UIImagePickerControllerOriginalImage]; [self.showEditItem.selectedImages addObject:pickerImage]; ALAssetsLibrary *assetsLibrary [[ALAssetsLibrary alloc] init]; [assetsLibrary writeImageToSavedPhotosAlbum:[pickerImage CGImage] orientation:(ALAssetOrientation)pickerImage.imageOrientation completionBlock:^(NSURL *assetURL, NSError *error) { [self.showEditItem.selectedAssetURLs addObject:assetURL]; [self.myTableView reloadRowsAtIndexPaths:[NSArray arrayWithObject:[NSIndexPath indexPathForRow:1 inSection:0]] withRowAnimation:UITableViewRowAnimationFade]; }]; [picker dismissViewControllerAnimated:YES completion:^{}]; }这里有两个隐患。第一个隐患writeImageToSavedPhotosAlbum 是异步的completionBlock 可能在 dismissViewControllerAnimated 完成之后才执行。如果用户在 completionBlock 回来之前又拍了一张两个 completionBlock 的执行顺序是无法保证的selectedAssetURLs 的顺序可能和 selectedImages 不一致。第二个隐患ALAssetsLibrary 在 iOS 9 之后被 Photos 框架取代虽然还能用但会在控制台打印废弃警告。如果项目要求支持 iOS 14 以上应该改用 PHPhotoLibrary 的 performChanges。第二个隐患还有一个连带问题模拟器上没有相机isSourceTypeAvailable 检查能挡住大部分情况但部分模拟器即使返回 YESpresent 相机还是会崩溃。最保险的做法是在真机调试或者把拍照入口在模拟器上隐藏。我现在的习惯是在 DEBUG 模式下用TARGET_IPHONE_SIMULATOR宏隐藏拍照按钮在真机上才显示这样测试同学不会误触崩溃。如果拍照图片不需要写回相册可以直接不写 assetURL把 URL 数组里这一项留空但后续要从相册恢复选中状态时这张图片的选中态会丢失。我的做法是产品允许的话拍照图片不写回相册但会单独标记一个来源字段避免 URL 数组错位。这里建议你在接手代码时先确认业务上是否需要“拍照的图片也出现在系统相册里”如果需要才写回否则这个异步操作纯属增加复杂度。5. 常见问题与避坑权限、压缩、相册状态同步的五个翻车现场5.1 现象从相册选完图返回页面图片顺序与系统相册不一致原因是 QBImagePickerController 回调里的 assets 数组顺序以及你遍历 selectedAssetURLs 的顺序可能和相册展示顺序不同。特别是用户选了图片后又取消再选数组顺序可能不是按照点选顺序排列。解决不要把“用户点选顺序”作为业务顺序。如果产品要求用户选择的顺序就是上传顺序你应该在 didSelectAssets 里用 assets 数组的顺序重建并在模型里维护一个 order 字段。我一般会用 asset.defaultRepresentation.url 作为 key 建立字典再按用户选择顺序排序而不是直接依赖系统返回顺序。5.2 现象删除一张照片后重新点加号相册里这张照片还是选中状态原因是 imagePickerController 是同一个实例它的 selectedAssetURLs 是上次会话遗留的。你删除了 showEditItem 里的 URL但没有同步清掉选择器内部的选中记录。解决在 getter 里每次返回前执行 removeAllObjects再把 showEditItem.selectedAssetURLs 加回去。注意这个同步不能放在 viewDidLoad因为 viewDidLoad 在整个控制器生命周期只执行一次而每次弹相册前 getter 都会重新走一遍所以必须放在 getter 里。如果你不在 getter 里创建也要保证每次 present 前执行一次同步代码。5.3 现象图片压缩到 150KB但上传后服务器收到的还是几 MB原因只看 UIImageJPEGRepresentation 的返回结果忽略了图片本身格式。原代码里先是调整分辨率如果原始图片是 PNGUIImagePNGRepresentation 会返回无损数据随后 sizeOriginKB 超过 maxSize 时才用 JPEG 0.5 兜底。但如果原始图片是 PNG 且没那么大就会直接返回 PNG 原始数据体积可能依然不小。解决统一走 JPEG 流程压缩完以后用[imageData length] / 1024判断是否小于 maxSize如果不满足就降低 quality 比例从 0.9 开始递减直到低于阈值或降到 0.1。下面是我常用的压缩片段- (NSData *)compressImage:(UIImage *)image toMaxSizeKB:(NSUInteger)maxSizeKB { CGFloat quality 0.9; NSData *data UIImageJPEGRepresentation(image, quality); while (data.length / 1024 maxSizeKB quality 0.1) { quality - 0.1; data UIImageJPEGRepresentation(image, quality); } if (data.length / 1024 maxSizeKB) { UIImage *scaled [self scaleImage:image toMaxDimension:800]; data UIImageJPEGRepresentation(scaled, 0.5); } return data; }注意这个方法是先压缩后判断不是先判断后压缩。如果图片尺寸很大JPEG 压缩到 0.1 依然超过阈值就需要先缩小尺寸。原代码把尺寸调整放在前面其实是个更合理的方向但它只在一开始调整了一次如果一张 4000x3000 的图等比缩到 1024 宽度后仍然超过 150KB最后只做一次 0.5 的 JPEG很可能还是超。所以我会在 while 循环里同时降质量和缩尺寸并且把最终兜底尺寸从 1024 改成 800很多大图在 800x800 内用 0.5 质量能压到 150KB 以下。5.4 现象拍照返回后selectedImages 里有图但 selectedAssetURLs 里没有导致后续删除错位原因是 ALAssetsLibrary 的 writeImageToSavedPhotosAlbum 回调是异步的返回时你直接 dismiss 掉了相机completionBlock 什么时候执行不可控。如果用户在 completionBlock 执行前又操作了数组下标就乱了。解决把拍照图片单独存到一个 pending 数组等 URL 回调返回后再合并进 showEditItem或者干脆在拍照完成后不写回相册只保留 UIImage。我现在的习惯是除非业务明确要求拍照后同时保存到系统相册否则不写回避免这个异步顺序问题。如果你要写回至少要在 completionBlock 里对 selectedImages 做一次同步校验确认两张数组长度一致后再添加。5.5 现象模拟器上点击拍照直接崩溃检查 isSourceTypeAvailable 也返回 YES原因是模拟器在某些系统版本下对摄像头支持检测不准确即使返回可用present UIImagePickerController 的相机类型也会崩溃。解决在 DEBUG 模式下直接隐藏拍照按钮或者用TARGET_IPHONE_SIMULATOR宏判断强制不展示相机入口。这个属于纯环境坑不用过度设计但如果你给新人看代码最好在 showActionForPhoto 里加上注释避免后续测试又踩一遍。另外真机上也存在系统相机被占用的情况比如正在录制屏幕或后台有相机权限冲突这时isSourceTypeAvailable依然返回 YES但 present 会失败所以最好再包一层 try-catch 或者使用UIImagePickerController.availableMediaTypes做二次判断。6. 进阶把“相册多选删除压缩”封装成一个可复用的发布组件6.1 封装思路回调只暴露 UIImage 数组内部搞定状态同步前面所有代码都耦合在 RRZShowEditViewController 里换一个页面要复制一大段。我后来习惯把“选图宫格展示删除压缩”封装成一个可复用组件外部只需要一个回调选择完成后把压缩后的图片数组和原始图片数组一起抛出去。大概的接口设计typedef void(^RRZImagePickerCompletion)(NSArrayUIImage * *images, NSArrayNSData * *compressedDatas); interface RRZImagePickerHelper : NSObject - (void)showFromViewController:(UIViewController *)vc maxCount:(NSInteger)maxCount completion:(RRZImagePickerCompletion)completion; end内部实现把“弹出 UIActionSheet → 拍照/相册 → QBImagePickerController 回调 → 删除 → 压缩”全部收敛起来。外部控制器不需要再实现 QBImagePickerControllerDelegate只需要持有一个 helper 实例。这样做的收益很明显发布页、头像上传、评论附图都可以复用同一套逻辑不会再出现 A 页面改了压缩逻辑、B 页面没同步的情况。另外建议把最大数量、压缩目标体积、是否允许拍照做成可配置项。比如 maxCount 默认 9maxSizeKB 默认 150。这样不同场景可以传不同参数而不需要复制整个控制器。如果你要适配 iPad还要在 helper 内部处理 popoverPresentationController 的 sourceView否则在 iPad 上弹 ActionSheet 和相册都会崩溃。6.2 一个小技巧用压缩后的 Data 作为唯一上传数据源我在封装时会把压缩后的 NSData 数组单独存一份上传接口只接收这份 Data页面展示依然用原始 UIImage。这样图片不会在展示和上传之间反复压缩性能更好也避免服务器拿到的是高分辨率图。具体做法是在用户点击“发布”时遍历 showEditItem.selectedImages逐一调用 compressImage:toMaxSizeKB:把结果加入 tempImages 数组。上传前还可以加一个校验如果压缩后 Data 数量小于图片数量说明有图片压缩失败这时候不应该继续上传而是提示用户重新选择。这个校验能挡住很多不明不白的网络错误。我在实际项目中还遇到过一种情况用户从相册选了一张 HEIF 格式的图UIImagePickerController 默认返回的 UIImage 能正常展示但直接取UIImageJPEGRepresentation会返回 nil所以压缩方法里要做空值判断返回一个有默认尺寸的占位 Data或者提示用户该图片格式不支持。从那以后我每次写发布器都会把删除逻辑和相册状态同步放在一个方法里强制走一遍“先删数组、再刷新 UI、最后同步 URL”的顺序。这个习惯救过我很多次希望帮到你。本文还有配套的精品资源点击获取