Xamarin Forms 调用设备相机:插件选型、图片处理与真机避坑
简介面向 Xamarin.Forms 开发者的相机调用示例项目使用 C# 与 XAML 演示如何借助 DependencyService 定义统一的相机接口并在 iOS 端通过 UIImagePickerController、Android 端通过 Intent 与 MediaStore 启动系统相机完成拍照、图片流读取、ImageSource 转换、运行时权限申请及界面展示。压缩包共 83 个文件以 23 个 cs 源文件、31 个 png 预览截图、若干 XAML 页面布局、plist 与 config 配置文件以及解决方案文件为主整体大小约 286KB目录按共享项目和平台项目分开便于逐层阅读和对照实现。资源还在共享层抽象出 ICameraServiceUI 层只面向接口调用平台层分别完成原生逻辑对权限拒绝、相机不可用等情况提供了错误处理思路并给出通过 Button 和 Image 控件快速接入页面的方式。已有 307 人学习下载适合需要了解 Xamarin.Forms 跨平台原生能力封装的中初级移动开发者可直接作为相机功能脚手架也为后续接入自定义图片处理提供了可扩展的基础。1. xamarin-forms-camera 在解决什么跨平台 UI 背后的单平台相机xamarin-forms-camera 这个示例标题指向的是一个很实际的开发痛点Xamarin Forms 已经把 UI 层做到了跨平台但“调用设备相机并取回一张图片”这件事在 iOS 和 Android 上是两种完全不同的系统能力。很多人会误以为“相机按钮 Image 显示”就够了结果一上真机就发现拍照返回的是content://路径、EXIF 方向反了、内存被原图撑爆。标题里的 “检索图片” 对应的正是这条回调链路拍完之后怎样把图片从相机的临时文件里安全地拿回应用私有目录再交给界面去展示或上传。这篇笔记会从选型、最小实现、图片处理一路讲到真机上的坑末尾附一份可以直接落地的验收清单。2. 调起设备相机要先分清两条路MediaPlugin 与 MediaPicker 的取舍2.1 相机是平台专属能力UI 才是跨平台能力Xamarin Forms 的页面和布局是共享的但相机不是。iOS 上你需要UIImagePickerControllerAndroid 上则是IntentACTION_IMAGE_CAPTURE两者返回的数据结构、权限弹窗时机、文件生命周期完全不一样。如果你在共享项目里写DependencyService然后在每个平台项目里各写一套原生调用那等于把两个平台的相机流程各维护一遍而这个示例标题要解决的问题就是不让业务代码陷入这些平台细节。MediaPluginNuGet 包名Xam.Plugin.Media和 Xamarin.Essentials 里的MediaPicker都做了这层封装。它们把“拍照按钮 → 系统相机界面 → 拿回图片文件”收敛成一个async方法返回一个带路径和流的对象。你在共享代码里只需要关心业务逻辑用户拍没拍、图片存在哪、怎么压缩。这里有一个容易混淆的点Xamarin.Essentials 也有MediaPicker而很多老示例用的是 MediaPlugin。两者在很多场景下可以互相替换但底层的意图不同——MediaPlugin 的TakePhotoAsync直接封装系统相机而MediaPicker的CapturePhotoAsync在 Android 上也是调相机但在部分版本上底层实现不同。实际项目里老示例库、网上代码片段、现成模板往往默认 MediaPlugin因为它出现得早、用法直观社区讨论也多而官方后来在 Essentials 中提供 MediaPicker走的是更紧跟系统权限模型的路子。选择哪个要先想清楚你要的是“尽快跑通示例”还是“长期维护的官方路线”。2.2 三个可选方案的维度对比如果你要在这个标题基础上做自己的功能通常会面对三选一。我用一张表把关键差异列出来后面再解释为什么大多数示例默认走 MediaPlugin。维度MediaPlugin (Xam.Plugin.Media)Xamarin.EssentialsMediaPicker原生 API DependencyService初始化要求Android 上要显式Initialize()跟随Platform.Init无但每端要写启动代码权限处理内置大部分权限流程需要 Info.plist / AndroidManifest 配合完全自己写返回类型MediaFile含Path和GetStream()FileResult也含FullPath与OpenReadAsync各自不同需二次统一拍摄参数支持目录、文件名、压缩质量、是否存相册支持PhotoSize等粒度略粗完全自定义维护方向第三方插件更新节奏受外部影响官方 Essentials 维护迁移 MAUI 有路径自己维护系统版本升级自己抗自定义相机界面不支持只能调系统相机不支持只能调系统相机可以完全自定义 UI从表里能看出来MediaPlugin 和 MediaPicker 的界限并没有那么大。真正的分水岭是你的业务需要不需要自定义相机界面。示例标题是“检索图片”也就是把相机当作一个输入设备重点在拿到图片之后怎么处理而不是在相机取景框上叠加滤镜所以没必要直接上原生自定义相机。2.3 示例里我推荐哪条路如果你是在一个已经用 Xamarin Forms 2.x/3.x 维护了两三年的项目里加这个功能我一般会先用 MediaPlugin因为网上现成代码最多、踩坑记录最全跑通成本最低。如果你是新项目且后续计划迁移 MAUI优先用 Xamarin.Essentials 自带的 MediaPicker少引一个第三方依赖后续迁移时改动面也更小。但要注意MediaPicker 在 iOS 和 Android 上对“保存到相册”的支持不如 MediaPlugin 直观。MediaPlugin 的StoreCameraMediaOptions.SaveToAlbum一行就能把照片同步写进系统相册而 MediaPicker 想存相册通常还要额外引Xamarin.Essentials的MediaPicker配合文件写入。所以我的判断标准一向是需要相册副本的选 MediaPlugin只需要应用内私有目录的用 MediaPicker 更干净。后面的实现代码我以 MediaPlugin 为例因为它覆盖了更多参数说明等你换 MediaPicker 时思路也是一样的先调起系统相机拿到文件流复制到私有目录再展示或上传。3. 用 MediaPlugin 在 Xamarin Forms 里跑通拍照最小实现与参数说明3.1 安装与初始化Android 和 iOS 两端的平台准备先安装两个包在 NuGet 包管理器里搜索Xam.Plugin.Media和Xamarin.Essentials或者直接控制台执行Install-Package Xam.Plugin.Media Install-Package Xamarin.EssentialsXamarin.Essentials不是 MediaPlugin 的强制依赖但后面做权限判断时要用到它所以建议一起装。装完包之后平台端的初始化不能漏漏了 Android 上会直接在调用相机时抛异常。Android 端的MainActivity.cs要改成类似这样// MainActivity.cs 中的 OnCreate protected override void OnCreate(Bundle savedInstanceState) { base.OnCreate(savedInstanceState); Xamarin.Essentials.Platform.Init(this, savedInstanceState); // MediaPlugin 在 Android 上必须显式初始化否则 TakePhotoAsync 会抛异常 Plugin.Media.CrossMedia.Current.Initialize(); }iOS 端不需要在AppDelegate里调用Initialize()但必须在Info.plist里声明相机和相册的使用说明。你可以在Info.plist的源码视图里加这几项keyNSCameraUsageDescription/key string需要使用相机来拍摄照片/string keyNSPhotoLibraryUsageDescription/key string需要访问相册来选择照片/string keyNSMicrophoneUsageDescription/key string拍摄视频时需要麦克风权限/stringAndroid 端还需要在AndroidManifest.xml里声明CAMERA权限。如果你只拍照不录像RECORD_AUDIO可以不声明但CAMERA是必须的。运行时权限由 MediaPlugin 在内部帮你请求但 manifest 里的静态声明决定了应用安装时是否向系统声明这个能力。uses-permission android:nameandroid.permission.CAMERA / uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE /这里有个细节Android 13API 33之后如果应用只读取图片不再需要WRITE_EXTERNAL_STORAGE而是用READ_MEDIA_IMAGES。MediaPlugin 的高版本会自己处理但如果你在老版本上遇到权限问题可以先查一查是不是这个原因。3.2 一次拍照的完整调用链与参数取舍在共享项目里写一个CameraService把拍照逻辑收敛在一个方法里。这是这个示例的核心实现也是你后面做“检索图片”的第一环using Plugin.Media; using Plugin.Media.Abstractions; using System; using System.IO; using System.Threading.Tasks; public class CameraService { public async Taskstring TakePhotoAsync() { // 1. 先做能力检测模拟器和没有后置相机的设备会在这里被拦住 if (!CrossMedia.Current.IsCameraAvailable) { throw new NotSupportedException(当前设备没有可用的设备相机); } if (!CrossMedia.Current.IsTakePhotoSupported) { throw new NotSupportedException(当前设备不支持调用相机拍摄); } // 2. 调起系统相机界面等待用户拍照或取消 var file await CrossMedia.Current.TakePhotoAsync(new StoreCameraMediaOptions { Directory DeviceCamera, Name $photo_{DateTime.Now:yyyyMMdd_HHmmss}.jpg, AllowCropping false, SaveToAlbum false, CompressionQuality 92, PhotoSize PhotoSize.Full }); if (file null) { // 用户按了返回键或取消拍照file 会是 null return null; } try { // 3. 立刻把流拷贝到应用私有目录避免系统回收临时文件 var folder Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), DeviceCamera); Directory.CreateDirectory(folder); var destPath Path.Combine(folder, Path.GetFileName(file.Path)); using (var sourceStream file.GetStream()) using (var outputStream File.Create(destPath)) { await sourceStream.CopyToAsync(outputStream); } return destPath; } finally { // MediaFile 内部持有文件句柄用完必须释放 file.Dispose(); } } }逻辑上分三步先检查IsCameraAvailable和IsTakePhotoSupported这是避免在模拟器上翻车的第一道防线然后调用TakePhotoAsync调起系统相机最后把MediaFile里的流复制到私有目录并在finally里释放。参数说明里最值得调的是PhotoSize和CompressionQuality。PhotoSize.Full表示不做任何缩放拿到的文件就是相机原始分辨率适合需要上传原图的场景。如果你的业务流程只是显示一个头像或缩略图建议改成PhotoSize.Medium插件会在平台层做一次缩放内存压力小很多。CompressionQuality 92是在清晰度和体积之间的常用平衡值如果你要存很多图可以压到 80但不要低于 70不然文字边缘会有明显锯齿。SaveToAlbum false表示只保存在应用私有目录不会出现在系统相册里大部分业务场景都应该保持 false避免无感知地污染用户相册。3.3 为什么拿到 MediaFile 后要先落盘再绑定示例里我把MediaFile复制成本地文件后才返回路径这一步不是多余的。MediaFile.Path在 iOS 上往往指向NSTemporaryDirectory下的临时文件系统随时可能清理在 Android 高版本上它返回的可能是content://开头的 Content URI直接丢给ImageSource.FromFile老版本不一定能识别特别是列表滚动刷新后路径可能失效。最常见的做法是像上面代码那样把MediaFile的流复制到应用文档目录或缓存目录。注意是复制文件而不是只保存路径字符串。复制后file.Dispose()释放掉插件持有的底层文件句柄后续所有界面展示和上传都基于你私有目录里的这份拷贝。这个步骤虽然多写了几行代码但能避免掉一大批“刚拍完能显示切后台回来黑屏”的隐性事故。4. 处理设备相机回传的图片临时路径、EXIF 旋转与内存占用的三个坎4.1 临时路径的寿命比你想象得短拿到图片路径之后最容易犯的错误是把这个路径当作长期数据源。相机应用退出后Android 系统会撤销它授予我们应用的临时 URI 权限iOS 的临时目录也会在系统压力大的时候被清空。也就是说你从TakePhotoAsync拿到的MediaFile.Path生命周期可能只有几秒钟到几分钟。所以我前面代码里的落盘动作真正的价值不是“多此一举”而是把图片的所有权从“系统相机临时借给你”变成“你自己应用私有目录里的资产”。落盘之后那个路径才能安全地存数据库、传列表、做缓存清理。这也是“检索图片”这个标题里容易被忽略的一环检索的前提是你知道图片在哪个稳定位置而不是每次都在临时目录里碰运气。4.2 EXIF 方向为什么同一张照片在横竖屏下会歪相机拍出来的 JPEG 文件里有一个 EXIF 的Orientation标签用来记录拍摄时设备的方向。iOS 和 Android 的相册 App 在显示时会自动读取这个标签所以用户看系统相册永远是正的。但当你在 Xamarin Forms 里用ImageSource.FromFile直接显示这张图时部分平台不会自动处理方向就出现了“在相册里是正的在 App 里是躺着的”的诡异现象。处理方向有两个思路。如果你的展示只是临时缩略图优先保持原图不动让Image控件直接读文件因为一旦你重编码 JPEGOrientation标签可能被丢弃方向问题就从“显示歪”变成“文件本身歪”。如果你确实需要压缩或重编码那就要在读入图片后先读取 EXIF 标签再对图像数据进行旋转最后输出新 JPEG。这个步骤没有捷径只能按标签值做矩阵变换。另一个更省心的做法是用SKBitmap.Decode解出来的位图在某些库版本里会自动应用方向但依赖这个行为等于赌版本不推荐在生产环境依赖。4.3 压缩到目标尺寸的通用函数相机原图动辄 4000×3000 像素直接解码进内存按 ARGB 格式算一张图能吃掉 48MB 以上。列表页如果加载几张大图OutOfMemoryError几乎是必然的。下面这个函数基于 SkiaSharp把图片缩到指定最大边长再以 JPEG 输出using SkiaSharp; using System; using System.IO; public static byte[] DownscaleJpeg(Stream input, int maxSide, int quality) { using var bitmap SKBitmap.Decode(input); if (bitmap null) { throw new InvalidOperationException(图片解码失败); } var longestSide Math.Max(bitmap.Width, bitmap.Height); var scale (float)maxSide / longestSide; // 原图边长已经小于目标尺寸不需要放大 if (scale 1f) { using var image SKImage.FromBitmap(bitmap); using var ms new MemoryStream(); image.Encode(SKEncodedImageFormat.Jpeg, quality).SaveTo(ms); return ms.ToArray(); } var newWidth (int)(bitmap.Width * scale); var newHeight (int)(bitmap.Height * scale); var resizedInfo new SKImageInfo(newWidth, newHeight); using var resizedBitmap bitmap.Resize(resizedInfo, SKFilterQuality.Medium); using var resizedImage SKImage.FromBitmap(resizedBitmap); using var outputStream new MemoryStream(); resizedImage.Encode(SKEncodedImageFormat.Jpeg, quality).SaveTo(outputStream); return outputStream.ToArray(); }调用时using var originalStream File.OpenRead(localImagePath); var thumbnailBytes DownscaleJpeg(originalStream, maxSide: 800, quality: 80); var thumbnailPath Path.Combine(cacheFolder, thumb_ Path.GetFileName(localImagePath)); File.WriteAllBytes(thumbnailPath, thumbnailBytes);maxSide表示缩放后最长边的像素数。列表缩略图给 400 就够了详情页可以用 1600quality是 JPEG 压缩质量80 在大多数屏幕上肉眼看不出差别。SKFilterQuality.Medium是缩放时的重采样质量如果图片里有大量文字或边缘细节可以改成High但耗时和内存都会上升。这段代码的边界也要心里有数它不做 EXIF 方向修正所以如果原图方向标签有问题输出的缩略图方向也会有问题。生产环境里我会把方向修正放在DownscaleJpeg前面读取原始 EXIF先旋正bitmap再缩放才能保证“压缩之后不用再操心方向”。5. 避坑笔记xamarin-forms-camera 示例落地时最常见的 5 个事故5.1 模拟器上相机永远“不可用”现象在 Android 模拟器里运行IsCameraAvailable返回 false拍照按钮不可点击或者点击后直接走异常分支。原因模拟器默认没有真实摄像头MediaPlugin 检测不到设备相机硬件。部分模拟器支持虚拟摄像头但需要额外配置而且兼容性参差不齐。解决这个功能从一开始就建议在真机上调试。如果你团队没有真机可以先用模拟器验证 UI 流程和异常提示再找一台 Android 和一台 iOS 真机做拍照链路验证。不要试图在模拟器里把相机跑通那是浪费时间。5.2 iOS 权限弹窗只弹一次拒绝后需要进设置现象第一次点拍照iOS 弹出权限询问用户点了拒绝第二次再点拍照没有任何反应不弹窗也不报错。原因iOS 对相机权限的请求策略是“每轮安装周期内只询问一次”用户拒绝后系统会直接保持Denied状态不再主动弹窗。后续调用TakePhotoAsync时底层拿到的是 Denied直接忽略。解决在调起相机前主动检查权限用 Xamarin.Essentials 的PermissionsAPI。如果状态是Denied界面提示用户“需要去系统设置打开相机权限”然后调用AppInfo.ShowSettingsUI()跳到设置页。注意这里不要直接调用Permissions.RequestAsync反复询问iOS 上第二次请求不会弹窗白白消耗用户耐心。5.3 Android 返回的路径是 content://直接用 Path 会翻车现象Android 10 以上拍完照后MediaFile.Path打印出来是content://media/external/images/media/12345用这个路径直接绑定到Image.Source图片加载不出来或列表刷新后变空白。原因高版本 Android 收紧了文件访问策略系统相机通过 Content URI 返回图片而不是本地文件路径。相册图片的路径也是类似机制直接跨进程打开 FileStream 会抛FileNotFoundException或SecurityException。解决统一用file.GetStream()读取内容复制到应用私有目录后再用本地绝对路径。这也是我在第 3 章强调“先落盘”的原因。如果你是从相册选图同样的逻辑也适用拿到MediaFile立刻复制绝不在界面上直接绑定原始Path。5.4 高分辨率图片导致的内存溢出现象拍照后立即把原图绑定到页面上的Image单张显示正常但列表滚动几下应用就崩溃日志里有OutOfMemoryError。原因相机原图分辨率通常在 12MP 以上加载到内存时位图占用的空间是 宽×高×4 字节一张 4000×3000 的图就是 48MB。列表页同时加载多张很快超过 Android 堆上限。解决所有列表场景用前一章的DownscaleJpeg生成缩略图并保存为缩略图文件。内存里只保留缩略图的路径或ImageSource不保留byte[]和Stream字段。另外要养成长生命周期对象不持有图片流引用的习惯否则页面释放后内存也不会被回收下一轮拍照又把堆拉满。5.5 拍照返回后页面已重建MediaFile 不要跨页存储现象拍照完成返回应用立刻旋转屏幕页面按预期重建但 ViewModel 里原来保存的MediaFile访问GetStream()时报stream already closed或者图片显示不出来。原因拍照会拉起系统相机界面Android 在内存紧张时可能回收原 Activity返回时 Activity 重建原来的MediaFile对象持有的是旧进程里的文件句柄已经失效。解决不在页面字段或 ViewModel 属性里长期保存MediaFile。拍照成功之后立刻复制到私有目录记住新的本地路径字符串。后续页面的旋转、前后台切换、缓存清理都围绕这个字符串路径进行不要把MediaFile当作可持久化对象。这一点在列表页反复拍照的场景下尤其重要我见过不止一次因为字段逃逸生命周期导致的崩溃全部是这个原因。6. 进阶验证把示例改造成生产级检索图片流程的验收清单6.1 封装权限判断与降级请求示例里的TakePhotoAsync没有处理权限判断生产环境不能这么裸奔。我一般会先封装一个权限保证方法再调相机public async Taskbool EnsureCameraPermissionAsync() { var status await Xamarin.Essentials.Permissions.CheckStatusAsyncXamarin.Essentials.Permissions.Camera(); if (status Xamarin.Essentials.PermissionStatus.Granted) { return true; } if (status Xamarin.Essentials.PermissionStatus.Denied) { // 已经拒绝过直接用 UI 引导进设置 return false; } var requestResult await Xamarin.Essentials.Permissions.RequestAsyncXamarin.Essentials.Permissions.Camera(); return requestResult Xamarin.Essentials.PermissionStatus.Granted; }逻辑说明第一次安装后状态通常是Unknown这时候RequestAsync会弹系统权限框如果用户拒绝过状态变成Denied再次RequestAsync在 iOS 上不会弹窗所以要直接返回 false由界面提示用户进设置。Android 和 iOS 的已拒绝行为不同但用同一个状态机处理不会出大错。6.2 真机验收清单5 分钟确定能不能上线功能写完不等于能交付相机相关功能尤其需要在真机上过一遍这些场景第一首次安装后点拍照确认系统权限弹窗出现授予后能正常调起相机。第二拒绝权限再点拍照确认应用没有崩溃、没有黑屏而是弹出“请去设置开启权限”的引导。第三拍一张横图、一张竖图、一张倒置方向拍下的图回到 App 后确认三张显示方向都正确。第四连续拍 20 张并滑到列表底部盯着内存曲线确认没有持续上涨。第五Android 12/13 和 iOS 16/17 各测一遍优先覆盖不同相机制造商的调校差异。我会额外加一条用例拍照返回后立刻旋转屏幕再切到下一页回来确认图片仍然存在且能正常显示。这个用例专门用来验证第 5.5 章提到的生命周期问题。如果这些场景全部通过这个示例改造成的功能才算真正具备上线条件。最后一个习惯我自己落这种功能时一定会把 MediaPlugin 依赖封装在接口后面不直接散落在页面里。这样将来从 Xamarin Forms 迁移到 MAUI或者要从 MediaPlugin 换到 Essentials 的 MediaPicker都只动一个实现类业务层完全不用改。设备相机相关的代码是所有跨平台功能里最容易被“本地好好、线上翻车”的黑匣子所以无论时间多紧我都会在真机上把上面的清单完整走一遍。希望这篇笔记能帮你少踩几个我已经踩过的坑做出来的功能可以稳稳跑在真机上。祝你在 Xamarin Forms 里把设备相机这条链路做成让业务方省心、让测试放心、让自己有底气的模块希望帮到你。本文还有配套的精品资源点击获取