Xamarin.Forms调用设备相机:MediaPicker权限配置与图片持久化避坑指南
简介这是一个用于在 Xamarin.Forms 应用中调用设备相机、拍摄照片并用于应用场景的示例面向使用 C#/XAML 搭建 iOS、Android 跨平台移动应用的开发者。示例围绕 DependencyService 机制清晰演示了如何在共享项目中定义 ICameraService 接口再分别通过 iOS 的 UIImagePickerController 与 Android 的 Intent/MediaStore 完成相机调用和图片返回同时涵盖权限管理、图片流处理及基础 UI 布局适合刚接触 Xamarin.Forms 本地能力封装的读者对照学习。压缩包共 83 个文件以 23 个 .cs 核心源码、3 个 .xaml 界面、31 个 .png 图片资源为主并包含解决方案文件、config/csproj 工程配置、plist 权限配置及 README整体约 286KB结构紧凑便于快速打开项目查看完整调用链。目前已有 307 人浏览学习对于希望掌握跨平台相机功能集成步骤的开发者来说这份可运行示例能直接提供接口设计、平台差异处理与常见配置位置等参考减少自行摸索时间。1. 在 Xamarin Forms 里调“设备相机”先接受一个反直觉的真相再动手先讲一个反直觉的结论用 Xamarin Forms 调用设备相机拍完照MediaPicker 返回给你的不是一张存进系统相册的照片而是 App 缓存目录里一个随时可能被系统回收的临时文件。xamarin-forms-camera 这个方向的示例代码大多只演示到“拍到了”真正让图片活下来的持久化搬运却被留在门外。这篇博文把拍照、从相册检索图片、双端权限配置、临时文件迁移到持久目录的完整链路拆开讲覆盖 Android 和 iOS适合正在做巡检打卡、证件上传、图像采集这类真实业务、并打算在 Xamarin Forms 里落地相机能力的移动开发者。看完可以照抄抄完能跑跑完不会在用户手机上报出一堆黑图。2. 选型与最小实现用 MediaPicker 一次拿捏拍照与相册检索图片2.1 为什么是 MediaPicker三个对比后的结论Xamarin Forms 里做相机有好几条路。原生方案是在 Android 的 MainActivity 里用 Intent 打开系统相机然后把返回的 Bitmap 或 Uri 传给共享层iOS 侧则是挂 UIImagePickerController在回调里接 UIImage。这条路熟悉平台开发的人能写问题是 UI、生命周期全部绑死在各自平台写一遍 Android 还要再来一遍 iOS维护成本直接翻倍。第三方库方案中ZXing.Net.Mobile 这类库能把相机预览画面直接嵌进页面适合扫码、连拍、自定义取景框。但对“拍一张图然后交给后台”这种需求来说引入整个相机预览管线太重包体体积、内存占用和初始化成本都不划算而且和 Xamarin.Essentials 生态的整合度差往往要额外处理一堆平台绑定的脏活。Xamarin.Essentials 的 MediaPicker 是当前“拍照 检索图片”场景的最优解。它把 Android 的 Intent 和 iOS 的 UIImagePickerController 都封装成一个异步方法CapturePhotoAsync 返回 FileResultPickPhotoAsync 打开系统相册也返回 FileResult。FileResult.FullPath 在 Android 上是系统相机返回的临时文件路径在 iOS 上是沙盒内的文件副本路径App 不需要关心底层逻辑差异只需要处理返回的流和文件迁移。什么时候不该用它需要实时预览、要自己控制闪光灯和对焦、要连续连拍并发回帧数据时MediaPicker 做不到——它本质是“把系统相机的成品交给你”不是把相机画面嵌进你的页面。做美颜相机、扫码器、人脸检测框的不要选这条路。但若业务只是“拍一张照片放到单据上”这个选择最可靠也是我做过三个项目后一致保留的方案。2.2 最小可跑代码拍照 相册检索两个入口的实现我一般会建一个 PhotoService 类把拍照和选图分别做成两个方法放到共享项目里。两端平台直接编译不需要写条件编译代码public class PhotoService { /// summary调起系统相机拍摄一张照片返回图片路径/summary public async Taskstring TakePhotoAsync() { try { var result await MediaPicker.Default.CapturePhotoAsync( new MediaCaptureOptions { PhotoSize PhotoSize.Medium, // 系统压缩后返回适合上传与展示 SaveToAlbum false, // 不写系统相册少一层权限门槛 Title 拍摄单据照片 }); return result?.FullPath; } catch (FeatureNotSupportedException) { // 设备没有可用相机如 Android 模拟器未配置相机时走入这里 return null; } catch (PermissionException) { // 用户拒绝了相机权限进入引导设置的处理流程 return null; } } /// summary从系统相册检索图片返回图片路径/summary public async Taskstring PickFromGalleryAsync() { var result await MediaPicker.Default.PickPhotoAsync( new MediaPickerOptions { Title 从相册检索图片 }); return result?.FullPath; } }逻辑说明TakePhotoAsync 走系统相机PickFromGalleryAsync 走系统相册选图。CapturePhotoAsync 在 Android 上会先请求相机权限用户拒绝时抛出 PermissionException所以 catch 里除了放开返回值还要在业务层引导用户去系统设置。FeatureNotSupportedException 是模拟器或廉价 Android 设备没有相机应用时抛出的至少要告诉业务层“这台设备拍不了”。参数说明PhotoSize.Medium 会把图片压缩到短边约 1024 像素量级对上传服务器和界面展示都够用要保留原始尺寸存档改成 PhotoSize.Large但大图在真机上极容易引出下文的内存翻车事故务必权衡。SaveToAlbum 保持 false 时系统只把文件放进缓存目录不触碰系统相册iOS 上就不会额外弹相册写入授权这个开关对隐私权限影响很大非必要别打开。代码跑通只是第一步还有两件事不做真机一测照样翻车Android 端要在清单里声明相机权限并确保 Essentials 已初始化iOS 端要在 Info.plist 里补权限描述。这两件事就是下一章要解决的问题。3. 权限与配置Android 和 iOS 跑通相机前必须做的三件事3.1 Android清单权限与运行时权限是两码事Android 上最容易踩的坑是以为在 AndroidManifest.xml 里写了 CAMERA 权限就万事大吉。Android 6.0API 23以后相机属于“危险权限”除了安装时声明还必须由应用在运行时动态请求一次用户点允许后才真正可用。MediaPicker 内部会自动管理这次动态请求但你最好对“用户拒绝一次”和“用户选了不再询问”两种情况做好区分否则第二次操作时系统直接返回 Denied不再弹窗用户会以为 App 坏了。清单文件至少要有这些声明uses-permission android:nameandroid.permission.CAMERA / uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE android:maxSdkVersion32 /WRITE_EXTERNAL_STORAGE 从 Android 10 的分区存储开始逐步失效Android 13 上完全不生效MediaPicker 写缓存目录不需要它。我这里仍写上是为了兼容老业务在 Android 12 及以下往系统相册写图的场景。如果确认项目最低版本就是 API 33这一行可以去掉。FileProvider 是新版 Xamarin.Essentials 在 Android 上自动注册的authorities 默认是“包名.fileprovider”。它负责把缓存目录里的临时文件授权给系统相机 App让相机能把拍好的内容写回来。如果你的项目是很早以前建的又自己声明了一个 FileProvider运行时可能会遇到 DuplicateProvider 冲突处理办法是删掉旧声明保留 Essentials 自动生成的那份。这是老项目接入 xamarin-forms-camera 时最常撞上的配置事故。给一个权限对照表方便直接自查权限项Android 声明iOS 声明触发时机相机拍照AndroidManifest.xml 里 CAMERA运行时由 MediaPicker 自动请求Info.plist 写 NSCameraUsageDescription调用 CapturePhotoAsync读取相册检索图片Android 10 无需声明Android 9 以下按存储权限归类Info.plist 写 NSPhotoLibraryUsageDescription调用 PickPhotoAsync写入系统相册WRITE_EXTERNAL_STORAGEAPI 32 及以下生效仅当 SaveToAlbumtrue 时需要只有打开 SaveToAlbum 才触发3.2 iOSplist 三行描述缺一个就闪退iOS 和 Android 的权限逻辑差异很大。iOS 上只要 App 试图访问相机或相册而 Info.plist 缺少对应的 UsageDescription 键值系统不是弹窗询问而是直接杀掉进程。这个闪退发生在代码调用之前连 catch 的机会都没有所以必须在打包前把 plist 写清楚。以源代码方式打开 Info.plist加入keyNSCameraUsageDescription/key string需要使用相机拍摄现场照片/string keyNSPhotoLibraryUsageDescription/key string需要从相册选择图片作为附件/string说明NSPhotoLibraryUsageDescription 对应“从相册检索图片”的能力。用系统新版 PHPicker 弹窗选图时苹果对“读取单张图片”的门槛较低但一旦你要读多张或读缩略图缺这一句就会被系统拒之门外。最稳的姿势是拍照和相册的描述一起加上别等以后做多图上传时再补那又要发一次包。另一个非常高频的漏项是麦克风如果业务里同时使用了 CaptureVideoAsync 录视频Info.plist 只写 NSCameraUsageDescription 还不行还要加 NSMicrophoneUsageDescription。系统相机 App 在录视频时会自动申请麦克风plist 里没有对应描述同样闪退。即便你现在只拍照不录视频我也建议先把这条写上防止后续功能扩展时手忙脚乱。注意iOS 的 UsageDescription 缺失时不是弹一个 warning而是直接闪退这是系统层面的硬规则没有任何商量的余地。3.3 页面拉起相机从按钮到服务的调用链路权限配置就绪后让按钮真正把相机调起来。我常用的模式是在 ViewModel 里发起权限检查和拍照逻辑页面只绑一个 Command不在代码后置里堆平台判断public class ReportViewModel { private readonly PhotoService _photoService new(); public string LastPhotoPath { get; private set; } public ICommand TakePhotoCommand new Command(async () await TakePhotoAsync()); private async Task TakePhotoAsync() { try { var status await Permissions.RequestAsyncPermissions.Camera(); if (status ! PermissionStatus.Granted) { await Application.Current.MainPage.DisplayAlert( 需要相机权限, 请在系统设置里允许访问相机, 确定); return; } var path await _photoService.TakePhotoAsync(); if (!string.IsNullOrEmpty(path)) { LastPhotoPath path; // 通知 UI 刷新预览或交给文件持久化服务处理 } } catch (Exception ex) { // 记录日志提示用户重试或走相册检索 } } }逻辑说明Permissions.RequestAsync 是 Xamarin.Essentials 封装的动态权限请求会在系统标准弹窗后返回最终状态。用户选一次允许后下次再调用会直接返回 Granted不会反复弹窗。Denied 状态要区分“拒绝一次”和“不再询问”统一引导到系统设置页手动授权比在 App 内二次弹窗的体验要好得多。Command 里先查权限再拍照是为了避免 MediaPicker 内部抛 PermissionException 后业务层被动救场。权限状态拿在手里UI 可以立刻给用户明确指引这比把异常抛到页面层、只显示“拍照失败”四个字要友好得多。整个调用链路就是Button 点击 → Command → 权限检查 → CapturePhotoAsync → 返回 FileResult → 拿 FullPath。到这一步示例已经从“能点”走到“能拍”。但先别高兴太早下面五条坑全是在这一步之后掉进去的。4. 常见避坑xamarin-forms-camera 最容易翻车的 5 个场景4.1 拍完照片丢文件临时文件被系统清理现象拍照后图片正常显示用户清一次系统缓存或者 App 被杀后冷启动图片路径还在但指向的文件已经不在了界面上一片黑色。原因MediaPicker 返回的 FullPath 指向 App 的缓存目录。Android 的 cache 目录在电量紧张或系统清理时可以被回收iOS 上对应的是 tmp 文件夹虽然 App 运行期间还在但系统随时可能清理。示例工程往往只把 FullPath 存进数据库没做文件搬迁等于把图片放在一个随时会被拆掉的临时房里。解决拿到 FullPath 后立刻做持久化搬运public async Taskstring MakePermanentAsync(string tempPath) { var destDir FileSystem.AppDataDirectory; var destFile Path.Combine(destDir, ${Guid.NewGuid():N}.jpg); using (var src File.OpenRead(tempPath)) using (var dst File.Create(destFile)) { await src.CopyToAsync(dst); } return destFile; }这段代码把缓存文件复制到 AppDataDirectory也就是 App 的私有目录系统不会随便清理。文件名用 Guid 生成可以避开中文或特殊字符路径在 Android 文件系统上引发的一系列问题。务必在拍照成功后立刻调用别等上传完再搬缓存文件可能在上传前就被回收了。4.2 Android 13 相册读取异常FullPath 不再是“真实”路径现象Android 13 手机上从相册选图拿到的路径存在照常 File.Open 却抛 FileNotFoundException 或 SecurityException用户反馈“图片打开失败”。原因Android 10 开始强制分区存储Android 13 更进一步媒体文件的访问按资源 URI 授权系统不再承诺把真实文件路径交给应用。MediaPicker 返回的 FullPath 只是平台尽力拼接的一个临时视图访问这个视图并不等于访问真实文件。解决从 FileResult 读内容永远用 OpenReadAsync不要自己按路径去 File.Openusing var source await result.OpenReadAsync(); using var target File.Create(destFile); await source.CopyToAsync(target);这样把“读源”和“写目标”完全解耦。源端由系统提供流目标端由 App 自己掌控无论 Android 13 还是 iOS都能稳定工作。凡是传给后台的图片路径都应该是你自己创建在 AppDataDirectory 里的那个 destFile而不是从 FileResult 上直接拿的 FullPath。4.3 iOS 模拟器上一按拍照就闪退现象项目在 iOS 模拟器上运行点击拍照按钮App 转圈后直接退出控制台报“Camera not available”。原因iOS 模拟器没有真实相机硬件CapturePhotoAsync 在模拟器上拿不到相机设备直接失败。这在刚接触 Xamarin.Forms 的新手项目里几乎人人都会撞上属于模拟器环境限制不是代码逻辑错误。解决开发阶段区分虚拟设备和真机模拟器上自动降级到相册检索if (DeviceInfo.DeviceType DeviceType.Virtual) { return await PickFromGalleryAsync(); }模拟器系统相册自带几张测试图足够验证选图链路。相机功能不要和模拟器较劲直接换真机调试。4.4 选回的图横过来了EXIF 方向失真现象同一张竖拍照片在 iOS 上显示正常Android 某些品牌机型OPPO、vivo 常见上显示却旋转了 90 度。原因竖拍照片本身是正常存储的但文件头部带了一个 EXIF Orientation 标记。iOS 的 UIImagePickerController 在返回图片时已经消化了这个标记Android 部分相册 Intent 却不解 EXIF直接把像素原点交给 App于是双端行为不一致。这个差异不是 MediaPicker 的问题是平台相册实现的差异。解决在图片持久化前统一做一次 EXIF 方向修正把 Orientation 对应的旋转角度写回文件数据。以下为关键逻辑// 读取 EXIF 旋转角 using (var reader new ExifLib.ExifReader(destFile)) { if (reader.GetTagValueint(ExifLib.ExifTags.Orientation, out var orientation)) { var angle orientation switch { 3 180, 6 90, // 竖拍正常角度需要顺时针 90 度 8 270, _ 0 }; // 用 SkiaSharp 按 angle 旋转像素后重写 destFile } }注意旋转重写后最好把 EXIF Orientation 标记清掉或重置避免下发到服务器后被其他组件二次旋转。如果项目对清晰度要求高旋转和压缩可以共用一条管线先读 EXIF再按角度旋转最后做尺寸压缩一次完成省一次磁盘 I/O。4.5 原图太大界面和上传双双内存溢出现象拍照用的是 PhotoSize.LargeFullPath 直接赋给 Xamarin.Forms 的 Image 控件Android 低端机上一分钟多开几次页面直接无响应日志里一片 OutOfMemoryError。原因原图分辨率动辄四千万像素Bitmap 解码后在内存里按 ARGB 算一张图可能吃掉几十甚至上百 MB。Image 控件绘制时还要再做一次缩放内存碎片积少成多最终触发系统级杀进程。解决显示层永远用缩略图上传层用压缩图原文件留在服务器。最简单的做法是用 PhotoSize.Medium 让系统先压一轮如果业务必须要原始大图读取后用 BitmapFactory.Options 的 InSampleSize 采样压缩到宽边不超过 1920px 再显示和上传。别把 byte[] 一次性抱进内存全部用流式 CopyToAsync 完成。这五条坑基本覆盖了从“刚跑通示例”到“真正上生产”之间的高频事故。每一条背后都是一个线上问题而不是理论风险。避开它们xamarin-forms-camera 的落地就算稳了一半。5. 进阶技巧让 xamarin-forms-camera 从“能用”到“好用”上一章讲的坑让示例能稳定落地但离“好用”还有几道坎。这三件事是我自己在真实项目里踩过一圈后总结出来的按优先级从高到低排。第一件是把“拍照即所得”改成“拍照即入库”。前面用了 MakePermanentAsync 做文件搬家但有个容易被忽略的尾巴缓存目录里的临时文件不会因为你复制了一份就消失连续拍十张能占掉几十 MB 缓存。我一般会在图片成功上传或成功展示后由业务层调一次 File.Delete(tempPath)把这个临时文件释放掉。不要在 MakePermanentAsync 里删因为调用方可能还要在搬迁前后做 EXIF 修正删早了反而把原数据弄丢。第二件是给列表页生成缩略图。移动端图片列表最容易踩的坑就是把原图路径直接丢给 ImageSource.FromFile。正确做法是把旋转和压缩后的结果另存成 256x256 的 JPEG 放缓存目录列表只绑定缩略图详情页再加载原图。这样内存占用基本固定不会随着列表行数线性增长。即使缩略图缓存被系统回收路径还在重新生成一次就行不会让列表空白。第三件是统一相册检索入口。业务上常常是“拍不了就选图”而不是硬性报错。我习惯写一个 ResolveAttachmentAsync 方法public async Taskstring ResolveAttachmentAsync() { if (DeviceInfo.DeviceType DeviceType.Virtual) return await PickFromGalleryAsync(); var path await TakePhotoAsync(); // 用户取消了拍照直接降级到相册检索 return string.IsNullOrEmpty(path) ? await PickFromGalleryAsync() : path; }这个入口把“能拍则拍拍不了或取消则选图”的逻辑收拢在一个方法里UI 层只调它不用关心平台差异和用户操作分支。配合一个在模拟器或设备无相机时直接隐藏拍照按钮的判断用户体感会顺很多。最后说一句血泪经验永远不要在数据库里存从 FileResult 拿到的 FullPath。我第一次做这个功能时图省事直接把 FullPath 写进 SQLite第二天用户反馈图片全空了排查半天才发现缓存目录不是持久目录。从那以后凡是要长期保存的图片路径一律先迁移到 AppDataDirectory 再入库。这个习惯救了我后面好几个项目希望帮到你。本文还有配套的精品资源点击获取