iOS图片选取避坑指南:UIImagePickerController实战与迁移 1. 为什么UIImagePickerController至今仍是绕不开的坎做iOS开发的人几乎都经历过这样一个阶段产品经理跑过来跟你说“加个选照片的功能要能拍照、能选相册、能裁剪”你心想这不就是调个系统API的事半小时搞定。结果真动手的时候发现事情远没有想象中那么简单——权限弹窗的时机、iPad上的崩溃、裁剪框的适配、大图的内存占用、HEIC格式的兼容性一个接一个地冒出来。UIImagePickerController是iOS里最老牌的图像选取控件从iOS 2.0时代就存在了算下来已经服役了十几年。虽然苹果后来推出了PHPickerViewControlleriOS 14和PhotosPickerSwiftUI但在很多场景下UIImagePickerController依然是首选方案。原因很直接它同时覆盖了相机拍摄和相册选取两条路径而且自带裁剪功能一个控件解决三个需求这在快速迭代的项目里非常省事。这篇文章适合谁看如果你正在做需要用户上传头像、发布带图内容、扫描证件之类的功能或者你接手了一个老项目发现里面全是UIImagePickerController的调用但代码写得很乱那这篇内容应该能帮到你。我会从实际项目出发把选型逻辑、权限处理、相机与相册的差异化配置、裁剪的坑、内存优化、iPad适配这些点全部拆开讲清楚最后再聊聊什么时候该考虑迁移到新API。需要提前说明的是UIImagePickerController在iOS 14之后被标记为“deprecated”状态但注意这个标记更多是苹果在引导开发者往PHPickerViewController迁移它本身依然可以正常工作并没有被移除。截至我最近一次在iOS 17设备上测试功能完全正常。所以如果你维护的是需要兼容iOS 12甚至更低版本的项目它仍然是唯一的选择。2. 选型之前先想清楚你到底需要哪条路径2.1 相机、相册、还是两者都要很多开发者一上来就写代码结果写到一半发现需求没理清楚。UIImagePickerController的sourceType属性决定了它打开的是哪个界面一共有三个可选值sourceType打开的界面可用性.camera系统相机拍摄界面模拟器不可用真机才支持.photoLibrary相册浏览界面模拟器和真机都可用.savedPhotosAlbum相机胶卷已废弃不推荐使用这里有个新手经常踩的坑在模拟器上测试.camera会直接崩溃或者弹出“Source type 1 not available”的警告。这不是代码写错了是模拟器根本没有摄像头硬件。所以你在开发阶段一定要用真机测相机路径或者提前做好isSourceTypeAvailable的判断。if UIImagePickerController.isSourceTypeAvailable(.camera) { picker.sourceType .camera } else { // 降级到相册或者提示用户 picker.sourceType .photoLibrary }我个人的习惯是如果产品需求是“拍照或从相册选”我会先弹一个UIAlertController让用户选而不是直接打开某个界面。这样用户有明确的预期也避免了在模拟器上调试时的尴尬。2.2 裁剪功能到底要不要开allowsEditing这个属性是很多人纠结的点。设为true的话选完照片后会进入一个正方形的裁剪界面用户可以拖动和缩放。听起来很美好但实际用起来有几个问题第一裁剪框是正方形的如果你的UI需要圆形头像或者16:9的封面图用户裁出来的方图你还得自己再处理一遍。第二裁剪界面的交互是系统固定的你没法自定义裁剪比例、没法加网格线、没法限制最小尺寸。第三在iPad上裁剪界面的表现和iPhone上不太一样需要额外测试。所以我的建议是如果只是做个简单的头像上传allowsEditing true够用了省得自己写裁剪页面。但如果对图片比例有严格要求比如电商商品图必须是1:1、封面图必须是16:9那不如关掉编辑功能拿到原图后自己用UIGraphicsImageRenderer或者第三方库来做裁剪控制力更强。2.3 媒体类型的选择mediaTypes属性决定了用户能选什么类型的文件。默认值是[public.image]也就是只能选图片。如果你还想让用户选视频可以加上public.movie。不过要注意一旦支持了视频回调里拿到的可能是URL而不是UIImage处理逻辑完全不同。picker.mediaTypes [public.image] // 只选图片 // picker.mediaTypes [public.image, public.movie] // 图片和视频都支持在Swift里更推荐的写法是用UTTypeimport UniformTypeIdentifiers picker.mediaTypes [UTType.image.identifier]用UTType的好处是类型安全编译器能帮你检查拼写错误而且苹果在逐步用UTType替代字符串字面量。3. 权限这关过不去后面全是白搭3.1 相机和相册的权限描述必须写对从iOS 10开始苹果强制要求所有访问隐私数据的App在Info.plist里声明用途描述。如果你忘了加App会直接崩溃而且崩溃日志里不会告诉你缺了哪个key只会给一个模糊的错误。这是很多新手最容易被卡住的地方。需要加的两个key是NSCameraUsageDescription相机权限描述NSPhotoLibraryUsageDescription相册读取权限描述如果你还需要把图片保存到相册那还要加NSPhotoLibraryAddUsageDescription。描述文案不能随便写苹果审核时会看。我见过有人写“需要相机权限”这种敷衍的文案被拒的。正确的写法是具体说明用途比如“用于拍摄商品照片并上传到您的店铺”。这样既通过了审核用户看到弹窗时也知道为什么要给权限。3.2 权限请求的时机很关键UIImagePickerController有个很“贴心”的设计当你设置好sourceType并present之后如果还没有权限系统会自动弹出权限请求框。但这个“贴心”恰恰是问题所在——权限弹窗和界面切换同时发生用户体验很割裂。更合理的做法是提前请求权限。用AVCaptureDevice.requestAccess(for: .video)请求相机权限用PHPhotoLibrary.requestAuthorization请求相册权限。拿到授权后再打开选取器整个流程会顺畅很多。AVCaptureDevice.requestAccess(for: .video) { granted in DispatchQueue.main.async { if granted { self.presentCamera() } else { self.showPermissionAlert() } } }注意这里必须切回主线程因为UI操作不能在后台线程执行。这个细节很多人会忽略结果界面半天不弹出来。3.3 用户拒绝权限之后怎么办用户一旦点了“不允许”下次你再调用相机或相册系统不会再弹权限框了而是直接回调失败或者什么都不发生。这时候你需要在代码里判断权限状态引导用户去设置里手动开启。let status AVCaptureDevice.authorizationStatus(for: .video) switch status { case .authorized: presentCamera() case .notDetermined: requestCameraPermission() case .denied, .restricted: showGoToSettingsAlert() unknown default: break }showGoToSettingsAlert里用UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!)跳转到系统设置页。这个体验虽然不算完美但比什么都不提示要好得多。注意跳转设置页的URL在iOS的不同版本里可能有细微差异建议用UIApplication.openSettingsURLString这个常量不要自己拼字符串。4. 从present到回调一条完整的代码链路4.1 创建和配置选取器先看一段完整的配置代码我把它拆成了几个部分方便理解func openImagePicker(sourceType: UIImagePickerController.SourceType) { guard UIImagePickerController.isSourceTypeAvailable(sourceType) else { print(当前设备不支持该来源) return } let picker UIImagePickerController() picker.delegate self picker.sourceType sourceType picker.allowsEditing true picker.mediaTypes [UTType.image.identifier] // 如果是相机可以设置是否显示相机控制 if sourceType .camera { picker.showsCameraControls true picker.cameraCaptureMode .photo } present(picker, animated: true) }这里有几个点值得展开说。isSourceTypeAvailable的判断前面提过了模拟器上必须做。allowsEditing根据需求决定。mediaTypes用UTType更安全。showsCameraControls默认就是true显示系统自带的拍照按钮、闪光灯切换、前后摄像头切换等控件。如果你要做自定义相机界面可以设为false然后通过cameraOverlayView叠加自己的UI。但说实话自定义相机界面是个大工程除非有特殊需求否则不建议折腾。4.2 代理方法的实现UIImagePickerControllerDelegate有两个必须实现的方法分别对应“选完了”和“取消了”extension ViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate { func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { // 先dismiss再做后续处理 picker.dismiss(animated: true) // 获取图片 if let editedImage info[.editedImage] as? UIImage { // 用户裁剪过的图 handleImage(editedImage) } else if let originalImage info[.originalImage] as? UIImage { // 原图 handleImage(originalImage) } // 如果是视频 if let mediaURL info[.mediaURL] as? URL { handleVideo(mediaURL) } } func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { picker.dismiss(animated: true) } }这里有个细节info字典里同时包含.editedImage和.originalImage。如果allowsEditing true且用户做了裁剪.editedImage有值如果用户没裁剪直接确认.editedImage可能是nil这时候要回退到.originalImage。所以判断顺序很重要先查.editedImage再查.originalImage。另外dismiss的时机也有讲究。我习惯在代理方法的第一行就dismiss然后再处理图片。因为图片处理可能耗时如果先处理再dismiss用户会看到界面卡在那里不动体验很差。4.3 图片的后续处理拿到UIImage之后通常要做几件事压缩、裁剪成目标比例、上传。这里不展开讲上传重点说压缩和裁剪。压缩用jpegData(compressionQuality:)func compressImage(_ image: UIImage, maxSizeKB: Int 500) - Data? { var compression: CGFloat 1.0 let maxBytes maxSizeKB * 1024 guard var data image.jpegData(compressionQuality: compression) else { return nil } // 逐步降低质量直到满足大小要求 while data.count maxBytes compression 0.1 { compression - 0.1 if let newData image.jpegData(compressionQuality: compression) { data newData } } return data }这个循环压缩的思路很实用但要注意如果图片本身尺寸特别大比如4000x3000光靠降低JPEG质量可能压不到目标大小还需要先缩小分辨率。缩小分辨率用UIGraphicsImageRendererfunc resizeImage(_ image: UIImage, targetWidth: CGFloat) - UIImage { let scale targetWidth / image.size.width let targetHeight image.size.height * scale let targetSize CGSize(width: targetWidth, height: targetHeight) let renderer UIGraphicsImageRenderer(size: targetSize) return renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: targetSize)) } }先缩分辨率再压质量两步配合基本能把任何图片压到合理大小。5. 那些文档里不会写的坑5.1 iPad上的崩溃popover必须指定锚点这是iPad开发中最经典的崩溃之一。在iPhone上present一个UIImagePickerController是模态弹出没问题。但在iPad上系统默认用popover的形式展示而popover必须有一个锚点sourceView和sourceRect否则直接崩溃报错信息是Terminating app due to uncaught exception NSGenericException, reason: Your application has presented a UIImagePickerController... The modalPresentationStyle of a UIImagePickerController with this style is UIModalPresentationPopover. You must provide location information for this popover through the image pickers popoverPresentationController.解决办法是在present之前配置popoverPresentationControllerif let popover picker.popoverPresentationController { popover.sourceView sender // 触发按钮 popover.sourceRect sender.bounds popover.permittedArrowDirections .any }这个问题在iPhone上完全不会出现所以如果你只在iPhone上测试上线后iPad用户一用就崩。我建议在项目里封装一个统一的present方法把这段逻辑写进去避免每次调用都忘记。5.2 大图导致的内存警告iPhone拍出来的照片分辨率动辄4000x3000解压成UIImage后占用的内存是宽 × 高 × 4字节算下来一张图就是48MB左右。如果你在列表里同时加载多张这样的图内存瞬间飙升系统直接给你发内存警告严重的话App会被杀掉。处理思路有两个一是拿到图片后立即压缩不要保留原图二是用autoreleasepool包裹图片处理代码让临时对象及时释放。autoreleasepool { if let image info[.originalImage] as? UIImage { let compressed compressImage(image) // 使用compressed } }另外如果只是要显示缩略图可以用PHImageManager的requestImage方法指定targetSize系统会帮你做降采样比你自己压缩效率高得多。不过这就涉及到Photos框架了超出了UIImagePickerController的范围后面讲迁移的时候再提。5.3 HEIC格式的兼容性问题从iPhone 7开始苹果默认用HEIC格式存储照片。UIImagePickerController返回的UIImage已经帮你解码好了显示没问题。但如果你要把图片转成Data上传到服务器用jpegData转出来的就是JPEG格式兼容性没问题。真正会出问题的是如果你从info[.imageURL]拿到原始文件URL直接读取Data上传那上传的就是HEIC格式。有些老版本的后端或者第三方服务不认HEIC会导致图片显示不出来。所以稳妥的做法是统一转成JPEG再上传不要直接传原始文件。5.4 横屏拍摄的照片方向不对这是个很隐蔽的坑。iPhone竖着拍的照片UIImage的imageOrientation属性可能是.right或.left而不是.up。你在UIImageView里显示的时候系统会自动处理方向看起来正常。但如果你把UIImage转成Data上传或者用Core Graphics做裁剪方向信息可能会丢失导致图片旋转了90度。解决办法是在处理图片之前先把方向“拍平”func normalizeImageOrientation(_ image: UIImage) - UIImage { guard image.imageOrientation ! .up else { return image } UIGraphicsBeginImageContextWithOptions(image.size, false, image.scale) image.draw(in: CGRect(origin: .zero, size: image.size)) let normalized UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() return normalized ?? image }这段代码的原理是把图片重新绘制一遍绘制过程中系统会自动应用方向变换出来的新图imageOrientation就是.up了。虽然多了一次绘制开销但能避免后续一堆麻烦。6. 什么时候该考虑换掉它6.1 PHPickerViewController的优势iOS 14引入的PHPickerViewController解决了UIImagePickerController的几个核心痛点第一不需要相册权限。PHPickerViewController运行在独立进程里用户选了什么照片App只能拿到选中的那几张拿不到整个相册。这意味着你不需要请求NSPhotoLibraryUsageDescription用户隐私更有保障App的权限弹窗也少了一个。第二支持多选。UIImagePickerController一次只能选一张虽然可以通过allowsMultipleSelection在iOS 14上开启多选但体验一般。PHPickerViewController原生支持多选配置selectionLimit就行。第三内置搜索和智能相册。用户可以直接搜索“猫”“海滩”来找到照片体验好很多。第四支持直接获取Data不用先转成UIImage再转Data减少了内存占用。6.2 迁移的时机和策略如果你的App最低支持版本是iOS 14及以上那新功能建议直接用PHPickerViewController。但如果你的App还要兼容iOS 12或13那就只能继续用UIImagePickerController。迁移的时候不需要一次性全换掉。可以按功能模块逐步替换先换相册选取的部分相机拍摄继续用UIImagePickerController因为PHPickerViewController不支持相机。等最低支持版本提上去之后再考虑全面替换。还有一点要注意PHPickerViewController没有内置裁剪功能。如果你需要裁剪得自己写或者用第三方库。这也是很多人继续用UIImagePickerController的原因之一——它的裁剪功能虽然简单但胜在免费且稳定。6.3 一个折中的封装思路我在实际项目里的做法是封装一个ImagePickerService对外暴露统一的接口内部根据iOS版本和需求选择用哪个实现protocol ImagePickerServiceDelegate: AnyObject { func imagePickerDidSelectImage(_ image: UIImage) func imagePickerDidCancel() } class ImagePickerService: NSObject { weak var delegate: ImagePickerServiceDelegate? func present(from viewController: UIViewController, source: ImageSource, allowsEditing: Bool) { if #available(iOS 14.0, *), source .photoLibrary, !allowsEditing { presentPHPicker(from: viewController) } else { presentUIImagePicker(from: viewController, source: source, allowsEditing: allowsEditing) } } }这样调用方不需要关心底层用的是哪个API以后要替换实现也只改这一个文件。这个模式在维护老项目的时候特别有用能有效控制技术债务。7. 几个实战中总结的小技巧第一个技巧是关于相机界面的。UIImagePickerController的相机预览在iPhone X及以后的机型上顶部和底部会有黑边因为相机传感器的比例和屏幕比例不一致。如果你想让预览铺满屏幕可以设置cameraViewTransform做一个缩放变换但要注意这样会裁掉一部分画面。我的建议是不要折腾保持系统默认就好用户已经习惯了。第二个技巧是关于图片命名的。从info[.imageURL]拿到的URL里包含原始文件名但如果你要上传到服务器建议自己生成一个唯一文件名比如时间戳随机字符串不要用原始文件名。因为原始文件名可能包含特殊字符或者重复导致服务器端存储冲突。第三个技巧是关于回调线程的。imagePickerController(_:didFinishPickingMediaWithInfo:)这个代理方法是在主线程回调的但如果你在里面做了耗时的图片处理会阻塞UI。正确的做法是把处理逻辑放到后台队列处理完再切回主线程更新UI。但注意UIImage的创建和绘制最好在主线程做因为UIKit不是线程安全的。func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { picker.dismiss(animated: true) guard let image (info[.editedImage] ?? info[.originalImage]) as? UIImage else { return } DispatchQueue.global(qos: .userInitiated).async { let normalized self.normalizeImageOrientation(image) let compressed self.compressImage(normalized) DispatchQueue.main.async { self.uploadImage(compressed) } } }第四个技巧是关于测试的。相机功能在模拟器上测不了但你可以通过UIImagePickerController.isSourceTypeAvailable的返回值来模拟不同场景。另外Xcode的模拟器支持拖拽图片到相册你可以提前往模拟器相册里放几张不同尺寸、不同方向的测试图覆盖各种边界情况。第五个技巧是关于内存的。如果你在didFinishPickingMediaWithInfo里同时持有originalImage和editedImage内存会翻倍。所以拿到需要的那个之后尽快让另一个释放。用autoreleasepool包裹是最简单有效的方式。8. 写在最后的一点个人体会UIImagePickerController这个控件说简单也简单present出来、实现两个代理方法就能跑。但要说复杂上面列出来的这些坑每一个都够你调半天的。我见过不少项目图片选取功能上线后各种奇葩bugiPad上闪退、安卓用户传的图在iOS上显示旋转、大图上传导致App被系统杀掉归根结底都是对这些细节不够重视。我的建议是在项目初期就把图片选取这块封装好把权限判断、来源选择、压缩处理、方向修正这些逻辑都收拢到一个独立的模块里。不要在每个需要选图的地方都写一遍UIImagePickerController的创建代码那样后期维护会非常痛苦。至于要不要迁移到PHPickerViewController我的看法是新项目、最低支持iOS 14以上的直接用新API体验和隐私都更好。老项目不用急着换等最低支持版本自然提升上去再说。技术选型要看投入产出比为了用新API而强行提升最低支持版本可能会流失一部分用户得不偿失。最后分享一个我常用的调试方法在didFinishPickingMediaWithInfo里把info字典的所有key和value类型打印出来看看系统到底给了你哪些数据。不同iOS版本、不同来源相机vs相册、不同编辑状态裁剪vs未裁剪info里的内容是不一样的。把这个搞清楚了很多问题自然就迎刃而解了。