☰
Xamarin.Forms调用设备相机:MediaPicker权限配置与图片持久化避坑指南
2026/10/13 14:14:56 网站建设 项目流程

简介:这是一个用于在 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 返回 FileResult,PickPhotoAsync 打开系统相册也返回 FileResult。FileResult.FullPath 在 Android 上是系统相机返回的临时文件路径,在 iOS 上是沙盒内的文件副本路径,App 不需要关心底层逻辑差异,只需要处理返回的流和文件迁移。

什么时候不该用它?需要实时预览、要自己控制闪光灯和对焦、要连续连拍并发回帧数据时,MediaPicker 做不到——它本质是“把系统相机的成品交给你”,不是把相机画面嵌进你的页面。做美颜相机、扫码器、人脸检测框的不要选这条路。但若业务只是“拍一张照片放到单据上”,这个选择最可靠,也是我做过三个项目后一致保留的方案。

2.2 最小可跑代码:拍照 + 相册检索两个入口的实现

我一般会建一个 PhotoService 类,把拍照和选图分别做成两个方法,放到共享项目里。两端平台直接编译,不需要写条件编译代码:

public class PhotoService { /// <summary>调起系统相机拍摄一张照片,返回图片路径</summary> public async Task<string> 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 Task<string> 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.0(API 23)以后,相机属于“危险权限”,除了安装时声明,还必须由应用在运行时动态请求一次,用户点允许后才真正可用。MediaPicker 内部会自动管理这次动态请求,但你最好对“用户拒绝一次”和“用户选了不再询问”两种情况做好区分,否则第二次操作时系统直接返回 Denied,不再弹窗,用户会以为 App 坏了。

清单文件至少要有这些声明:

<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" />

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_STORAGE(API 32 及以下生效)仅当 SaveToAlbum=true 时需要只有打开 SaveToAlbum 才触发

3.2 iOS:plist 三行描述,缺一个就闪退

iOS 和 Android 的权限逻辑差异很大。iOS 上,只要 App 试图访问相机或相册,而 Info.plist 缺少对应的 UsageDescription 键值,系统不是弹窗询问,而是直接杀掉进程。这个闪退发生在代码调用之前,连 catch 的机会都没有,所以必须在打包前把 plist 写清楚。

以源代码方式打开 Info.plist,加入:

<key>NSCameraUsageDescription</key> <string>需要使用相机拍摄现场照片</string> <key>NSPhotoLibraryUsageDescription</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.RequestAsync<Permissions.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 Task<string> 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.Open:

using 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.GetTagValue<int>(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.Large,FullPath 直接赋给 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 Task<string> 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 再入库。这个习惯救了我后面好几个项目,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询