简介这份PDF文档面向iOS开发初学者与中级开发者聚焦相册图片多选、删除及拍照上传这一常见业务场景帮助解决社交、图片编辑类应用中与系统相册交互、数据同步和图片压缩上传等实际问题。资源包共1个PDF文件大小约77KB内容以代码示例与实现思路讲解为主涵盖第三方库QBImagePickerController的集成方式、多选控制器RRZShowEditViewController的协议实现、ShowEditItem数据模型设计以及selectedImages与selectedAssetURLs的同步管理。文档还涉及拍照与相册选取的结合、删除照片后相册状态的维护、图片压缩上传等关键环节并给出最大选择数量、多选开关等配置细节。目前已有288人学习适合需要快速掌握相册多选删除完整实现流程、对照代码查漏补缺的开发者参考。1. 相册多选与删除一个被低估的 iOS 开发高频需求做社交、电商晒单、二手交易这类 App发布页几乎绕不开「选图」这个动作。用户点一下加号弹出来的应该是系统相册那种熟悉的网格能一次勾九张能预览能取消勾选选完回到发布页还能单张删掉重选。听起来简单但真动手写你会发现坑比想象中多拍照进来的图和相册选的图怎么统一管理删掉一张再点加号之前选中的状态还在不在上传前图片不压缩九张原图能把接口撑爆。这篇笔记拆的是一个真实项目里的实现方案核心用QBImagePickerController这个第三方库扛住多选交互配合UIImagePickerController处理拍照数据层用一个ShowEditItem模型统一收口。适合正在做发布器、动态晒图、商品图上传的 iOS 开发者尤其是被「选图状态同步」和「图片压缩」折腾过的同学。下面从选型理由讲到可抄的代码再到几个我踩过的坑一步步来。2. 选型与数据层设计为什么用 QBImagePickerController 而不是自己撸2.1 系统 API 的边界与第三方库的取舍iOS 系统自带的UIImagePickerController在 iOS 14 之后虽然支持了PHPickerViewController但如果你要兼容更低版本或者想要一个开箱即用的多选网格自己用UICollectionView套PHAsset去写工作量不小。QBImagePickerController的价值在于它把「相册列表 → 某个相册 → 多选网格 → 预览」这条链路全封装好了你只需要设置maximumNumberOfSelection和filterType剩下的交互它自己处理。选它还有一个现实原因项目里已经有大量基于ALAsset的老代码QBImagePickerController的代理回调直接给你ALAsset数组拿defaultRepresentation.fullScreenImage就能转成UIImage跟老代码的兼容成本最低。如果你是新项目可以考虑PHPickerViewController但如果是维护老项目或者需要快速出活这个库依然能打。2.2 ShowEditItem把「选中的图」和「选中的 URL」绑在一起整个功能的数据核心是ShowEditItem这个模型。它至少要有两个可变数组selectedImages存UIImage用来在发布页的网格里展示selectedAssetURLs存NSURL用来跟相册选择器的状态做同步。为什么要存 URL因为用户可能先选了五张退出去再点加号进来这时候选择器需要知道「哪几张已经被选过了」才能把勾选状态回显出来。// ShowEditItem.h interface ShowEditItem : NSObject property (strong, nonatomic) NSMutableArray *selectedImages; // UIImage 数组 property (strong, nonatomic) NSMutableArray *selectedAssetURLs; // NSURL 数组 end // ShowEditItem.m - (instancetype)init { self [super init]; if (self) { _selectedImages [].mutableCopy; _selectedAssetURLs [].mutableCopy; } return self; } end逻辑说明selectedImages和selectedAssetURLs必须同步增删。拍照进来的图没有对应的ALAsset所以拍照时要用ALAssetsLibrary的writeImageToSavedPhotosAlbum把图写回相册拿到assetURL再塞进selectedAssetURLs。这样做的代价是相册里会多一张照片但好处是数据源统一删除逻辑不用分两种情况。参数说明writeImageToSavedPhotosAlbum的orientation参数要传pickerImage.imageOrientation否则竖拍的照片写进相册会变成横的。这个坑我在早期项目里踩过用户反馈「拍完照选出来是歪的」排查半天才发现是方向没传对。2.3 选择器的初始化与状态回填QBImagePickerController的实例不要每次点加号都重新创建用懒加载持有它但在每次展示前要把selectedAssetURLs清空再回填当前模型里的 URL。这一步是状态同步的关键漏了就会出现「上次选的图这次没勾上」或者「已经删掉的图还勾着」。- (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; }逻辑说明filterType设为QBImagePickerControllerFilterTypePhotos表示只显示照片不显示视频。maximumNumberOfSelection设为 9 是产品需求你可以按业务改成 1 到 9 之间的任意值。selectedAssetURLs是库内部用来标记勾选状态的属性必须在present之前同步好。参数说明如果你的 App 需要支持视频把filterType改成QBImagePickerControllerFilterTypeAll但要注意视频的ALAsset取缩略图的方式和照片不同得用aspectRatioThumbnail而不是fullScreenImage。3. 拍照与相册选择的融合代理回调里的状态同步3.1 UIActionSheet 触发两条路径发布页的图片网格里最后一个格子是「加号」。点它弹UIActionSheet给「拍照」和「从相册选取」两个选项。这里用UIActionSheet是因为项目年代较早新项目建议直接用UIAlertController的actionSheet样式API 更清晰。- (void)showActionForPhoto { UIActionSheet *actionSheet [[UIActionSheet alloc] initWithTitle:nil delegate:self cancelButtonTitle:取消 destructiveButtonTitle:nil otherButtonTitles:拍照, 从相册选取, nil]; [actionSheet showInView:self.view]; } - (void)actionSheet:(UIActionSheet *)actionSheet clickedButtonAtIndex:(NSInteger)buttonIndex { if (buttonIndex 0) { // 拍照分支 if (![UIImagePickerController isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera]) { // 弹提示设备不支持拍照 return; } UIImagePickerController *picker [[UIImagePickerController alloc] init]; picker.delegate self; picker.allowsEditing NO; picker.sourceType UIImagePickerControllerSourceTypeCamera; [self presentViewController:picker animated:YES completion:nil]; } else if (buttonIndex 1) { // 相册分支 UINavigationController *nav [[BaseNavigationController alloc] initWithRootViewController:self.imagePickerController]; [self presentViewController:nav animated:YES completion:nil]; } }逻辑说明拍照前必须调isSourceTypeAvailable判断模拟器上没相机不判断会直接崩。相册分支把imagePickerController包在一个UINavigationController里再 present因为QBImagePickerController内部依赖导航栏来做「取消」和「完成」按钮。参数说明allowsEditing设为NO表示拍完不进入裁剪界面直接拿原图。如果你需要用户裁剪设为YES但回调里要取UIImagePickerControllerEditedImage而不是OriginalImage。3.2 拍照回调写回相册拿 URL拍照完成后的回调里除了把UIImage塞进selectedImages还要用ALAssetsLibrary把图写回系统相册拿到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) { if (assetURL) { [self.showEditItem.selectedAssetURLs addObject:assetURL]; } // 刷新图片网格那一行 [self.myTableView reloadRowsAtIndexPaths:[[NSIndexPath indexPathForRow:1 inSection:0]] withRowAnimation:UITableViewRowAnimationFade]; }]; [picker dismissViewControllerAnimated:YES completion:nil]; }逻辑说明writeImageToSavedPhotosAlbum是异步的completionBlock回到主线程后再刷新 UI。注意这里刷新的是row 1的那一行因为发布页的UITableView只有两行第 0 行是文本输入第 1 行是图片网格。参数说明orientation参数必须传否则照片方向错乱。completionBlock里的error要判空虽然大多数情况不会失败但用户如果关了相册权限这里会报错得给提示。3.3 相册多选回调先清空再重建QBImagePickerController的多选回调给的是assets数组和imagePickerController.selectedAssetURLs。注意这里的逻辑用户可能在选择器里取消了之前选中的图所以不能往selectedImages里追加而是要先removeAllObjects再根据assets重建。- (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_main_queue(), ^{ [self.myTableView reloadRowsAtIndexPaths:[[NSIndexPath indexPathForRow:1 inSection:0]] withRowAnimation:UITableViewRowAnimationFade]; }); [self dismissViewControllerAnimated:YES completion:nil]; }逻辑说明fullScreenImage返回的是全屏尺寸的图适合展示但内存占用比thumbnail大。如果选九张内存峰值会比较高建议在dispatch_async里做转换避免卡主线程。这里为了代码简洁直接在主线程转了实际项目里可以放到全局队列。参数说明selectedAssetURLs的顺序和assets的顺序不一定一致所以重建selectedImages时以assets为准selectedAssetURLs只用来回填选择器的勾选状态。4. 删除与压缩数组操作背后的状态一致性4.1 删除单张图的回调设计图片网格用UICollectionView实现每个格子右上角有个删除按钮。点击删除时通过 block 把整个ShowEditItem回传给控制器控制器更新模型后刷新表格。为什么回传整个模型而不是索引因为UICollectionView的indexPath在删除后可能失效用模型引用更安全。// RRZSendShowImageCell.h property (copy, nonatomic) void (^deleteImageBlock)(ShowEditItem *item); // 在 cell 内部的删除按钮事件里 - (void)deleteButtonTapped:(UIButton *)sender { NSInteger index sender.tag; if (index self.item.selectedImages.count) { [self.item.selectedImages removeObjectAtIndex:index]; [self.item.selectedAssetURLs removeObjectAtIndex:index]; } if (self.deleteImageBlock) { self.deleteImageBlock(self.item); } } // 控制器里 cell.deleteImageBlock ^(ShowEditItem *item) { weakSelf.showEditItem item; [weakSelf.myTableView reloadData]; };逻辑说明删除时必须同时删selectedImages和selectedAssetURLs的同一索引否则下次进相册选择器勾选状态和实际展示的图会对不上。这个「双数组同步」是整个功能里最容易出 bug 的地方。参数说明sender.tag存的是当前格子在数组里的索引在cellForItemAtIndexPath里设置。注意UICollectionView复用的问题tag必须在每次配置 cell 时重新赋值。4.2 图片压缩分辨率调整加质量压缩九张原图直接上传接口大概率超时。压缩分两步先按最长边不超过 1024 调整分辨率再用UIImageJPEGRepresentation做质量压缩。下面这个resetSizeOfImageData方法是项目里实际用的逻辑是先算缩放比例再画到新的CGContext里。- (NSData *)resetSizeOfImageData:(UIImage *)source_image maxSize:(NSInteger)maxSize { // 第一步调整分辨率 CGSize newSize CGSizeMake(source_image.size.width, source_image.size.height); CGFloat tempHeight newSize.height / 1024; CGFloat tempWidth newSize.width / 1024; if (tempWidth 1.0 tempWidth tempHeight) { newSize CGSizeMake(source_image.size.width / tempWidth, source_image.size.height / tempWidth); } else if (tempHeight 1.0 tempWidth tempHeight) { newSize CGSizeMake(source_image.size.width / tempHeight, source_image.size.height / tempHeight); } UIGraphicsBeginImageContext(newSize); [source_image drawInRect:CGRectMake(0, 0, newSize.width, newSize.height)]; UIImage *newImage UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); // 第二步质量压缩 NSData *imageData UIImageJPEGRepresentation(newImage, 0.5); NSUInteger sizeOriginKB [imageData length] / 1024; if (sizeOriginKB maxSize) { return UIImageJPEGRepresentation(newImage, 0.5); } return imageData; }逻辑说明tempWidth和tempHeight是宽高相对于 1024 的倍数取较大的那个作为缩放因子保证最长边缩到 1024。UIGraphicsBeginImageContext在新尺寸上重绘得到缩放后的UIImage。最后用 0.5 的压缩质量转NSData如果还是超过maxSize单位 KB再压一次。参数说明maxSize传 150 表示目标 150KB。实际测试中一张 4000x3000 的原图压到 1024 宽再 0.5 质量大概在 100 到 200KB 之间九张图总共不到 2MB上传压力小很多。注意UIImageJPEGRepresentation的第二个参数是压缩质量0.5 是经验和清晰度的平衡点再低会出现明显噪点。4.3 上传前的数据组装发布按钮点击后遍历selectedImages逐张压缩把压缩后的UIImage重新组装成数组传给网络层。这里有个细节压缩后的图要重新转成UIImage再传因为网络层可能只接受UIImage数组。- (void)sendClick { NSMutableArray *tempImages [[NSMutableArray alloc] init]; for (int i 0; i self.showEditItem.selectedImages.count; i) { UIImage *tempImage self.showEditItem.selectedImages[i]; NSData *imgData [self resetSizeOfImageData:tempImage maxSize:150]; tempImage [UIImage imageWithData:imgData]; [tempImages addObject:tempImage]; } // 调用网络层上传 tempImages }逻辑说明压缩是同步操作九张图在主线程压会卡顿。常见做法是放到dispatch_async的全局队列里压完再回主线程调上传。这里为了代码直观没做异步实际项目建议加。参数说明maxSize可以根据接口限制调整如果服务端允许 500KB改成 500 能保留更多细节。5. 避坑与排查那些让我加班到凌晨的细节5.1 现象拍照后进相册选择器刚拍的图没被勾上原因拍照回调里只往selectedImages加了图没往selectedAssetURLs加 URL导致选择器不知道这张图已经被选过。解决拍照回调里用ALAssetsLibrary的writeImageToSavedPhotosAlbum拿到assetURL塞进selectedAssetURLs。注意这个操作是异步的要等completionBlock回来再刷新 UI。5.2 现象删掉一张图再点加号选择器里那张图还勾着原因删除时只删了selectedImages没删selectedAssetURLs或者删的索引不一致。解决删除逻辑里两个数组必须用同一个index做removeObjectAtIndex。建议封装一个removeImageAtIndex:方法内部同时操作两个数组避免漏删。5.3 现象选满九张后加号格子还在点进去还能选原因UICollectionView的numberOfItemsInSection返回的是count 1当count 9时应该返回 9 而不是 10。解决return num 9 ? num 1 : num;这行代码里的 9 要和maximumNumberOfSelection保持一致。如果产品改成最多 6 张两个地方都要改建议抽成宏或常量。5.4 现象上传接口返回超时日志显示请求体过大原因图片没压缩或者压缩参数不对九张原图加起来几十 MB。解决上传前强制走一遍resetSizeOfImageDatamaxSize设 150 到 300 之间。另外检查UIImageJPEGRepresentation的压缩质量别传 1.0那等于没压。5.5 现象竖拍的照片在发布页显示是横的原因UIImage的imageOrientation属性在绘制或压缩时丢失了。解决压缩时用drawInRect重绘会保留方向信息但如果你直接拿CGImage去处理方向会丢。拍照回调里写回相册时传orientation参数展示时用UIImage imageWithCGImage:scale:orientation:重建。6. 进阶技巧用 PHAsset 替代 ALAsset 做现代化改造ALAssetsLibrary在 iOS 9 之后就被标记为废弃了虽然还能用但新项目建议迁到PHPhotoLibrary。QBImagePickerController的新版本也支持返回PHAsset数组取图方式从asset.defaultRepresentation.fullScreenImage变成用PHImageManager异步请求。// 用 PHImageManager 取全屏图 PHImageManager *manager [PHImageManager defaultManager]; PHImageRequestOptions *options [[PHImageRequestOptions alloc] init]; options.deliveryMode PHImageRequestOptionsDeliveryModeHighQualityFormat; options.synchronous NO; [manager requestImageForAsset:asset targetSize:CGSizeMake(1024, 1024) contentMode:PHImageContentModeAspectFit options:options resultHandler:^(UIImage *result, NSDictionary *info) { if (result) { [self.showEditItem.selectedImages addObject:result]; dispatch_async(dispatch_get_main_queue(), ^{ [self.myTableView reloadRowsAtIndexPaths:[[NSIndexPath indexPathForRow:1 inSection:0]] withRowAnimation:UITableViewRowAnimationFade]; }); } }];逻辑说明requestImageForAsset是异步的resultHandler可能在主线程也可能在后台线程回调所以刷新 UI 要切回主线程。targetSize设成 1024x1024 配合AspectFit取到的图已经接近压缩后的尺寸省去了手动缩放的步骤。参数说明deliveryMode设为HighQualityFormat表示只要高质量版本不返回模糊的缩略图。synchronous设为NO避免阻塞但如果你的循环里要按顺序取图可以设YES但必须放在后台队列。验证迁移是否成功最简单的办法是选九张图看内存峰值有没有明显下降。ALAsset的fullScreenImage是同步加载九张图同时解码内存会飙到几百 MBPHImageManager是异步按需加载配合targetSize限制内存能控制在 100MB 以内。我现在的习惯是每次接手老项目先把ALAssetsLibrary的调用全部搜出来能迁PHPhotoLibrary的就迁迁不了的至少加个autoreleasepool包住循环。从那以后发布页的 OOM 崩溃率降了一大截。希望帮到你。本文还有配套的精品资源点击获取