☰
Xamarin.Forms 相机拍照指南:MediaPicker 权限与文件处理全解析
2026/9/25 22:44:35 网站建设 项目流程

简介:面向C#移动开发者的Xamarin.Forms相机调用示例,整套资源演示如何借助DependencyService在不同平台接入设备相机并完成拍照取图。工程包含iOS和Android双端实现,覆盖UIImagePickerController、MediaStore.ActionImageCapture等原生API调用,也给出运行时权限申请、图片流转ImageSource及错误交互等关键处理,适合正在学习跨平台相机功能或准备集成拍照模块的开发者。压缩包共83个文件,其中31个png用于界面演示与运行截图,23个cs、3个xaml构成核心源码与布局,另有config、plist、xml等工程配置文件,包体仅286KB,轻量且便于直接查阅。资源已有306人学习,代码结构按CameraApplication组织,辅以README说明,可快速对照理解原生依赖注入和相机调用流程,是一份实用的Xamarin.Forms相机接入参考。

1. xamarin-forms-camera:这个示例解决的就是把照片从相机拿回页面这件事

xamarin-forms-camera 是一个专门处理“Xamarin Forms 应用里用设备相机检索图片”的示例工程。做移动端的人都撞过这个需求:表单里要传证件照、报修单要拍现场图、商城要传商品实拍,而且拍完要立刻在页面里回显。它用 C# 写,核心路径是 Xamarin.Essentials 的 MediaPicker 唤起系统相机,拿到 FileResult 再转成 ImageSource,整个链路从按钮到展示都有对应代码。适合刚接手 Xamarin.Forms、想把拍照功能稳定嵌进现有页面的新手,也适合熟手快速对照权限写法和缓存处理。反直觉的结论先放在这里:iOS 模拟器根本没有相机入口,权限文件写错不会弹权限窗而是直接闪退,Android 上多声明一个 CAMERA 权限反而会让应用跑不起来——这两种现场,都在这个示例的排查范围里。

2. 方案选型与前置配置:为什么用 MediaPicker,以及 Info.plist 里到底填什么

2.1 MediaPlugin 与 MediaPicker:同样的拍照动作,两条不同的维护线

老项目里最常见的相机方案是 Xam.Plugin.Media 这个社区插件,它出现得早,网上能搜到大量配套代码。但我现在接新项目会直接选 Xamarin.Essentials 的 MediaPicker,不是因为它更“新潮”,而是维护状态和 API 设计差别明显。MediaPlugin 的 LastUpdate 停滞在 Xamarin.Forms 为主的年代,对 iOS 14+ 的 PHPicker、Android 10+ 的分区存储适配不积极,遇到新系统弹窗和文件路径规则变化时,排查成本都在你这边。MediaPicker 是官方库里的模块,系统行为变了跟进也快。两者的关键差异可以看这张表:

对比项Xam.Plugin.MediaXamarin.Essentials MediaPicker
维护状态长期停更随 Essentials 持续更新
返回类型自定义 MediaFileFileResult 统一文件句柄
iOS 选图依赖 UIImagePickerController新系统走 PHPicker,不需要完整相册权限
Android 存储依赖外部存储路径基于 FileProvider,适配分区存储
是否要 CAMERA 权限部分版本需要不需要,直接调系统相机 App

选 MediaPicker 还有一个实际好处:它和后来 .NET MAUI 的 MediaPicker API 几乎同构,先写 Forms 版本,以后迁移 MAUI 时改动量比 MediaPlugin 小。这套示例代码走的就是这条路。

2.2 iOS 端两个权限 key,缺一个就等闪退

iOS 上调用相机和相册都必须先在 Info.plist 里声明用途描述。很多人第一步写代码、第二步跑模拟器,第三步真机一点按钮直接闪退,回头看日志才看到This app has crashed because it attempted to access privacy-sensitive data without a usage description。这就是 Info.plist 缺 key 的典型反应:不弹授权框,直接崩。

最少需要两个键,NSCameraUsageDescription 给相机,NSPhotoLibraryUsageDescription 给相册选图。如果你的功能要保存图片到相册,还要补 NSPhotoLibraryAddUsageDescription。XML 写法如下:

<key>NSCameraUsageDescription</key> <string>需要相机权限才能拍摄现场照片</string> <key>NSPhotoLibraryUsageDescription</key> <string>需要相册权限才能选择已有的图片</string> <key>NSPhotoLibraryAddUsageDescription</key> <string>需要相册权限才能把处理后的照片保存到设备</string>

这个描述文本会原样显示在系统授权弹窗里,苹果审核时会看它和你实际调用是否一致。我一般会写成具体功能,比如“拍摄商品照片用于上传”,不要写成“使用相机”这种空泛文案。注意,模拟器上即使配了相机权限,也没有真实摄像头,这是后文避坑章节的问题。

2.3 Android 端:别声明 CAMERA 权限,但要接好生命周期

Android 端有个容易反直觉的配置项:MediaPicker 拍照是调用系统相机 App,不是直接在应用内打开相机预览,所以你的 App 本身不需要 CAMERA 权限。很多人在 AndroidManifest 里手动加<uses-permission android:name="android.permission.CAMERA" />,加了之后如果没配合运行时权限请求,某些国产 ROM 上反而会出现打开拍照就抛 SecurityException 的情况。后面避坑章节会专门展开。

Android 端要做的不是加权限,而是把 Xamarin.Essentials 的生命周期接进 MainActivity。少了下面这段,MediaPicker 在部分机型上会收不到返回结果:

protected override void OnCreate(Bundle savedInstanceState) { base.OnCreate(savedInstanceState); Xamarin.Essentials.Platform.Init(this, savedInstanceState); } public override void OnRequestPermissionsResult(int requestCode, string[] permissions, Permission[] grantResults) { Xamarin.Essentials.Platform.OnRequestPermissionsResult(requestCode, permissions, grantResults); base.OnRequestPermissionsResult(requestCode, permissions, grantResults); }

第一段代码是初始化 Essentials,所有用到 DeviceInfo、FileSystem、MediaPicker 功能的页面,都依赖这个初始化先跑完。第二段代码是权限回调转发,比如后续你自己要处理相册读写权限,回调必须经过 Essentials,否则 Task 永远等不到结果。这两段放在 MainActivity 的对应 override 方法里,位置别写错,初始化一定要在 base 之后。

2.4 NuGet 引用与最小初始化清单

在 Xamarin.Forms 项目里引入这套示例的逻辑很简单:给所有目标平台项目都装上 Xamarin.Essentials 包,Forms 共享项目也要装,因为MediaPicker静态类在共享代码里直接调用。装完包之后再确认三件事:iOS 的 Info.plist 权限描述写全、Android 的 MainActivity 完成 Platform.Init、没有在 Manifest 里多余声明 CAMERA。这三件事做完,属于 MediaPicker 的“前置配置”就结束了,没有别的玄学初始化要做。

3. 拍照、选图与展示:从 CapturePhotoAsync 到 ImageSource 的完整链路与参数说明

3.1 CapturePhotoAsync:一拍一取的调用行为

打开相机拍照的 API 就一行:MediaPicker.CapturePhotoAsync()。它做的工作比你看到的要多:iOS 上它会创建 UIImagePickerController 的相机模式并 present 出来,Android 上它构造一个带有MediaStore.ACTION_IMAGE_CAPTURE的 Intent,并预先配置好 FileProvider 的 Uri,防止拍完大图后系统拿不到文件句柄。这些底层差异都被收在主键调用里了:

var result = await MediaPicker.CapturePhotoAsync(new MediaPickerOptions { Title = "拍摄商品照片" }); if (result == null) { return; } var filePath = result.FullPath; var fileName = result.FileName; var contentType = result.ContentType;

MediaPickerOptions的 Title 在 Android 上会作为系统相机 App 确认界面的标题,在 iOS 上影响有限,但传入总没有坏处。CapturePhotoAsync返回的FileResult不是文件字节本身,而是一个句柄,FullPath在 iOS 上通常是 tmp 目录下的缓存文件路径,Android 上由 FileProvider 生成,FileName是系统按时间生成的带后缀文件名,ContentType是 MIME 类型,通常是image/jpeg或image/heic。用户取消拍照时返回 null,所以空判断不能省。

3.2 PickPhotoAsync:从相册选图的场景差异

从相册选图调的是MediaPicker.PickPhotoAsync(),调用方式几乎一样,但底层逻辑不同。iOS 上它会选择 PHPicker 或 UIImagePickerController 的相册模式,Android 上走系统文件选择器。这个差异带来的实际影响是:选图不需要请求相机权限,但部分 Android 系统版本需要你有存储读取能力,而 iOS 的新版 PHPicker 在授权前也能选部分照片。选图代码:

var result = await MediaPicker.PickPhotoAsync(new MediaPickerOptions { Title = "选择已经拍好的图片" }); if (result == null) { return; } using (var stream = await result.OpenReadAsync()) { // 在这里读取文件流,见 3.3 的说明 }

PickPhotoAsync返回的同样是FileResult,处理方式与拍照一致。区别在于使用场景:拍照适合现场取证、报修单、证件照,选图适合从已有图库里选头像、商品图。在这个示例里,我会把两个入口都暴露在页面上,因为模拟器上没有相机时,选图是唯一能跑通主流程的路径。

3.3 FileResult 的流只能读一次:从 FileResult 到 ImageSource 的坑

这是整套示例里最容易翻车的地方。FileResult.OpenReadAsync()返回的 Stream 是系统给的一次性数据源,读完一次之后,文件指针就停在尾部,再读不会报错,但返回的数据是空的。而ImageSource.FromStream()是延迟加载的:它不会立刻读流,而是在 Image 控件去渲染图片时才调用你传入的 lambda。如果你把同一个 Stream 实例塞进 lambda,第一次渲染成功、第二次刷新页面时,流已经耗尽,图片就是空白。

正确做法是把文件流完整读成 byte[],再在 lambda 里用 MemoryStream 包一层:

byte[] photoBytes; using (var stream = await result.OpenReadAsync()) using (var memoryStream = new MemoryStream()) { await stream.CopyToAsync(memoryStream); photoBytes = memoryStream.ToArray(); } ImageSource source = ImageSource.FromStream(() => new MemoryStream(photoBytes));

这段代码里,CopyToAsync把原始流全部拷贝到内存流,ToArray()得到独立副本。lambda 每次被调用都会new MemoryStream(photoBytes),所以图片可以反复渲染,不受原始流的位置影响。代价是内存里驻留一份图片字节,对单张照片来说完全可接受。注意不要写成FromStream(() => stream),那是把这个示例里最典型的空白图问题写死在自己代码里。

3.4 一个完整的页面链路:不依赖 MVVM 框架的 ViewModel 示例

把上面的调用串起来,就是一个完整可跑的页面链路。下面的 ViewModel 只依赖System.ComponentModel,没有引入 Prism 或 MVVM Toolkit,复制到任何 Forms 项目里都能编译:

public class CameraViewModel : INotifyPropertyChanged { private ImageSource _photoSource; public ImageSource PhotoSource { get => _photoSource; set { _photoSource = value; OnPropertyChanged(); } } public Command TakePhotoCommand => new Command(async () => await TakePhotoAsync()); public Command PickPhotoCommand => new Command(async () => await PickPhotoAsync()); private async Task TakePhotoAsync() { var result = await MediaPicker.CapturePhotoAsync(new MediaPickerOptions { Title = "拍一张" }); if (result == null) { return; } await ApplyPhotoAsync(result); } private async Task PickPhotoAsync() { var result = await MediaPicker.PickPhotoAsync(new MediaPickerOptions { Title = "选一张" }); if (result == null) { return; } await ApplyPhotoAsync(result); } private async Task ApplyPhotoAsync(FileResult result) { byte[] photoBytes; using (var stream = await result.OpenReadAsync()) using (var memoryStream = new MemoryStream()) { await stream.CopyToAsync(memoryStream); photoBytes = memoryStream.ToArray(); } PhotoSource = ImageSource.FromStream(() => new MemoryStream(photoBytes)); } public event PropertyChangedEventHandler PropertyChanged; private void OnPropertyChanged([CallerMemberName] string propertyName = null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } }

页面里的绑定很简单,一个Image Source="{Binding PhotoSource}",两个按钮分别绑定TakePhotoCommand和PickPhotoCommand。ApplyPhotoAsync把拍照和选图的公共逻辑抽出来了,两个入口的差异只在于调用哪个 MediaPicker 方法。这个结构的好处是:后面如果要加压缩、方向修正,只需要改ApplyPhotoAsync一处。Command来自 Xamarin.Forms 自带的命令类,不需要额外包。

4. 照片压缩与缓存管理:EXIF 方向、JPEG 质量与临时文件清理

4.1 压缩前先看方向:竖拍横摆的元凶是 EXIF Orientation

手机拍出来的原图很容易竖拍变横:物理像素确实是横着存的,设备靠 EXIF 里的 Orientation 字段来提示显示时应该旋转多少度。MediaPicker 拿到的原始文件不会帮你转正,Image 控件也不会自动读 EXIF,所以方向错乱的图就直接上了页面。这个问题在 Android 上尤其明显,因为各家相机 App 写入 EXIF Orientation 的习惯不一样。

读取 EXIF 方向的标准做法是平台原生 API。Android 上可以用 AndroidX.ExifInterface,代码需要放在条件编译块里:

#if ANDROID using global::AndroidX.ExifInterface; public static int ReadExifOrientation(string filePath) { var exif = new ExifInterface(filePath); return exif.GetAttributeInt(ExifInterface.TagOrientation, 1); } #endif

方法返回的 int 遵循 EXIF 规范:1 是正常,6 是顺时针旋转 90 度,3 是旋转 180 度,8 是旋转 270 度。拿到之后,你得在压缩或者绘制阶段把旋转应用到像素上,单纯改文件名没用。iOS 上 UIImagePickerController 多数情况下已经处理过方向,但如果你把照片存成文件再自己读,还是可能遇到,所以我建议跨平台方案里统一在压缩阶段处理,见下一节。

4.2 用 SkiaSharp 压到 1280 宽:可调的压缩函数

原图直接传服务器是灾难,一张 iPhone 拍的照片 12MP 起步,体积 3MB 到 6MB。移动端常见的缩图策略是把最长边控制在 1200 到 1600 像素,JPEG 质量放在 70 到 85。SkiaSharp 在 Xamarin.Forms 里做这件事比较顺手,一个可复用的压缩函数如下:

using SkiaSharp; public static byte[] CompressJpeg(byte[] source, int maxWidth = 1280, int quality = 80) { using var input = new MemoryStream(source); using var sourceBitmap = SKBitmap.Decode(input); if (sourceBitmap == null) { return source; } var scale = (double)maxWidth / sourceBitmap.Width; if (scale > 1) { scale = 1; } var newWidth = (int)(sourceBitmap.Width * scale); var newHeight = (int)(sourceBitmap.Height * scale); using var scaledBitmap = new SKBitmap(newWidth, newHeight); using (var canvas = new SKCanvas(scaledBitmap)) { canvas.DrawImage(SKImage.FromBitmap(sourceBitmap), new SKRect(0, 0, newWidth, newHeight)); } using var image = SKImage.FromBitmap(scaledBitmap); using var data = image.Encode(SKEncodedImageFormat.Jpeg, quality); return data.ToArray(); }

参数含义直接看maxWidth和quality就够。maxWidth = 1280表示最长边压到 1280 像素,宽度小于这个值则原样保留scale = 1的分支,避免把小图强行放大。quality = 80是 JPEG 编码质量,这个档位肉眼几乎看不出压缩痕迹,但体积能降到原图的五分之一上下。SKBitmap.Decode直接把 byte[] 里的图片解码成原始像素;SKCanvas.DrawImage在目标尺寸画布上重绘,等于做了一次高质量缩放。返回的 byte[] 可以直接用来替换 3.3 节里的photoBytes,链路不用改。如果你还要做 EXIF 旋转,就在canvas.DrawImage之前按方向调用canvas.RotateDegrees(),方向和质量的参数调整建议如下:

场景maxWidthquality说明
头像上传51275体积优先,加载快
报修单/工单128080细节与体积平衡
商品展示160085保留质感,体积略增

4.3 临时文件与系统相册:谁产生谁清理

MediaPicker 拍照后会在应用缓存目录里留下一个临时文件,iOS 在 tmp 下,Android 在 cache 目录下。这些文件系统会定期回收,但相机是高频操作,拍一次留一张,积累多了就是几百 MB。我一般会在照片处理完成后,主动扫一遍缓存目录,把 24 小时前的图片文件清掉:

public static void CleanupCache(int olderThanHours = 24) { var cacheDirectories = new[] { Path.GetTempPath(), FileSystem.CacheDirectory }; foreach (var directory in cacheDirectories.Where(Directory.Exists)) { foreach (var file in Directory.GetFiles(directory)) { if (!file.EndsWith(".jpg") && !file.EndsWith(".jpeg") && !file.EndsWith(".png")) { continue; } var info = new FileInfo(file); if (DateTime.UtcNow - info.LastWriteTimeUtc > TimeSpan.FromHours(olderThanHours)) { try { File.Delete(file); } catch (IOException) { // 文件被占用时跳过,下次再清 } } } } }

Path.GetTempPath()覆盖 iOS 的 tmp 目录,FileSystem.CacheDirectory来自 Xamarin.Essentials,是跨平台的缓存目录,Android 上对应/data/data/包名/cache。LastWriteTimeUtc取文件最后写入时间,超过 24 小时的都算过期。删除时包了 try-catch,因为如果用户正在预览某张图,文件可能还在被系统组件引用,强行删除会抛 IOException。这个清理函数放在App.OnSleep里,或者每次进入拍照页面时调一次,都行。

5. 相机模块常见问题排查:五个真机踩坑记录

5.1 iOS 授权弹窗没弹,应用直接闪退

现象:真机上点拍照按钮,App 瞬间闪退,没有出现任何授权弹窗。看系统日志能看到privacy-sensitive data without a usage description一类的错误。

原因:Info.plist 缺少 NSCameraUsageDescription 或 NSPhotoLibraryUsageDescription。iOS 要求访问任何敏感数据前,必须有对应的 usage key 的声明,缺了就认为开发者没有合法用途,直接终止进程。这个检查发生在权限弹窗之前,所以你的代码里即使等授权结果也没有意义。

解决:把第 2.2 节里的三个 key 按实际功能补全,特别注意描述文本不能空。补齐之后删掉模拟器上的旧 App,重新部署再试。真机上闪退最常发生在刚接手别人项目时,顺手删掉了一个看似没用的 key,建议每次改 Info.plist 都用diff看一眼改动。

5.2 Android 上多声明了 CAMERA 权限,反而打不开相机

现象:Android 真机上,拍照功能在开发期正常,打包释放或装到部分国产机型后,一进拍照页面抛 SecurityException,或者直接黑屏退出。

原因:AndroidManifest 里存在<uses-permission android:name="android.permission.CAMERA" />。MediaPicker 调系统相机不需要应用持有相机权限,但一旦声明了这个权限,Android 6+ 要求运行时动态申请,没申请就在访问摄像头时报错。很多人的 CAMERA 权限是从旧代码、扫码组件、第三方 SDK 合并进来的,自己并不知道。

解决:打开 AndroidManifest.xml,删掉 CAMERA 权限声明。如果确认是某个 SDK 合并进来的,就在 manifest 里用 tools:node 移除:<uses-permission android:name="android.permission.CAMERA" tools:node="remove" xmlns:tools="http://schemas.android.com/tools" />。如果业务上确实需要摄像头,那就别用 MediaPicker,而是自己写权限请求逻辑,两条路线不能混。

5.3 模拟器上拍照黑屏或返回 null:降级走相册

现象:iOS 模拟器上点拍照,界面变黑或直接返回;Android 模拟器部分虚拟相机设备也一样,CapturePhotoAsync返回 null,页面没有任何反应。

原因:模拟器没有真实的摄像头硬件,iOS 模拟器完全不支持相机调用,Android 模拟器可以配置虚拟摄像头,但 Avd 镜像老旧时经常不可用。MediaPicker 在相机不可用时会返回 null,有些系统版本会抛异常,表现不稳定。

解决:在调用拍照前检测是否模拟器,是模拟器就自动降级到相册选图,顺手给用户一句提示。Xamarin.Essentials 的 DeviceInfo 可以直接拿设备类型:

FileResult result; if (DeviceInfo.DeviceType == DeviceType.Virtual) { result = await MediaPicker.PickPhotoAsync(new MediaPickerOptions { Title = "模拟器无相机,请从相册选择" }); } else { result = await MediaPicker.CapturePhotoAsync(new MediaPickerOptions { Title = "拍摄照片" }); }

这段代码让模拟器调试时至少能跑通“拿到一张图 → 回显”的主流程,不至于卡在拍照入口。放到真实项目里,这个降级判断还可以配合一个开关,让内部测试包强制走模拟器模式。

5.4 照片拍回来了,页面显示却是空白

现象:拍照、授权都正常,调试时能看到 FileResult 不为 null,但页面上的 Image 控件就是一片空白,有时刷新一次又好了。

原因:流生命周期问题。OpenReadAsync()返回的流被读取一次后位置到末尾,ImageSource.FromStream(() => stream)是延迟加载,渲染时才真正读流,流已被用过或释放,自然渲染不出来。

解决:按第 3.3 节的做法,先把流CopyToAsync到 MemoryStream,再用FromStream(() => new MemoryStream(bytes))。关键点是 lambda 每次都要 new 一个新流,不能用同一个流实例。这个问题的隐蔽之处在于 Debug 下可能正常,因为 Image 控件在调试模式渲染时机不同,Release 或页面刷新时才暴露,所以看到空白图先别怀疑权限,排查流。

5.5 竖拍变横图:EXIF Orientation 没人帮你读

现象:真机上竖着拍一张照片,拍完在页面里预览,图片横过来了;把同一张照片发给微信又是正的。

原因:系统相机和其他 App 做了解码后的方向修正,而 MediaPicker 只是把原图文件交给你,不带任何方向处理。Android 上竖拍时像素确实是横的,方向信息写在 EXIF 的 Orientation 字段里,UWP 或部分预览组件不读这个字段。

解决:压缩或展示前读 EXIF 方向,在绘制时旋转。第 4.1 节给了 Android 读取的代码,第 4.2 节的压缩函数里就可以在DrawImage之前按方向旋转 Canvas。iOS 多数场景下系统已经处理,但建议也走同一套压缩流程,因为一旦你自定义了相机风格或用了后处理,方向问题一样会出现。方向代码一旦写进工具类,后续所有照片入口都能复用。

6. 验证与进阶习惯:十分钟在一台新机器上跑通这个示例

6.1 一套可复用的复验清单

拿到这套示例代码,我建议按下面的顺序验证,而不是先翻代码。这能最快暴露环境配置问题:

步骤操作预期结果
1还原 NuGet 包无红色波浪线,Xamarin.Essentials 已引用
2iOS 模拟器运行页面显示选图入口,拍照入口自动降级
3iPhone 真机运行点拍照弹权限窗,拍完回显正常
4Android 真机运行Manifest 无 CAMERA 权限,拍照返回正常
5Release 构建测试图片显示正常,方向正确

第五步最容易忽略。Release 下出问题多是链接器把 Xamarin.Essentials 里的反射调用剥掉了,真机拍照或选图返回后直接抛 MissingMethodException。遇到时,在 Android 项目的 csproj 里加上:

<ItemGroup> <AndroidLinkPreserve Include="Xamarin.Essentials" /> </ItemGroup>

这个配置是给 Xamarin.Android 链接器看的,意思是不裁剪 Xamarin.Essentials 这个程序集。如果你在使用 R8 混淆,还要额外确认 ProGuard 规则里保留 Essentials 的类和成员。这套验证清单我自己每接手一个新项目都会跑一遍,跑通了再往上叠业务逻辑。

6.2 集成时期的一个长期习惯

从那以后,我每次接相机需求,都会把“权限清单、模拟器降级方案、流生命周期”这三件事写进任务单再动手。权限清单保证不闪退,降级方案保证在模拟器和无相机平板上还能调试,流生命周期保证照片能稳定回显——这三件事是相机模块 80% 的线上问题的根源。你拿到这套示例后,先在真机上把主流程跑通,再按自己的业务场景去改压缩参数,最后往共享层里加一个日志埋点,记录每次拍照耗时和返回的文件大小,基本就能在公司内部把相机需求谈下来了。希望帮到你。

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

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

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

立即咨询