简介:面向 Xamarin.Forms 开发者的相机调用示例项目,使用 C# 与 XAML 演示如何借助 DependencyService 定义统一的相机接口,并在 iOS 端通过 UIImagePickerController、Android 端通过 Intent 与 MediaStore 启动系统相机,完成拍照、图片流读取、ImageSource 转换、运行时权限申请及界面展示。压缩包共 83 个文件,以 23 个 cs 源文件、31 个 png 预览截图、若干 XAML 页面布局、plist 与 config 配置文件以及解决方案文件为主,整体大小约 286KB,目录按共享项目和平台项目分开,便于逐层阅读和对照实现。资源还在共享层抽象出 ICameraService,UI 层只面向接口调用,平台层分别完成原生逻辑;对权限拒绝、相机不可用等情况提供了错误处理思路,并给出通过 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 上你需要UIImagePickerController,Android 上则是Intent+ACTION_IMAGE_CAPTURE,两者返回的数据结构、权限弹窗时机、文件生命周期完全不一样。如果你在共享项目里写DependencyService,然后在每个平台项目里各写一套原生调用,那等于把两个平台的相机流程各维护一遍,而这个示例标题要解决的问题,就是不让业务代码陷入这些平台细节。
MediaPlugin(NuGet 包名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的源码视图里加这几项:
<key>NSCameraUsageDescription</key> <string>需要使用相机来拍摄照片</string> <key>NSPhotoLibraryUsageDescription</key> <string>需要访问相册来选择照片</string> <key>NSMicrophoneUsageDescription</key> <string>拍摄视频时需要麦克风权限</string>Android 端还需要在AndroidManifest.xml里声明CAMERA权限。如果你只拍照不录像,RECORD_AUDIO可以不声明,但CAMERA是必须的。运行时权限由 MediaPlugin 在内部帮你请求,但 manifest 里的静态声明决定了应用安装时是否向系统声明这个能力。
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />这里有个细节:Android 13(API 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 Task<string> 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控件直接读文件;因为一旦你重编码 JPEG,Orientation标签可能被丢弃,方向问题就从“显示歪”变成“文件本身歪”。如果你确实需要压缩或重编码,那就要在读入图片后,先读取 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 就够了,详情页可以用 1600;quality是 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 Task<bool> EnsureCameraPermissionAsync() { var status = await Xamarin.Essentials.Permissions.CheckStatusAsync<Xamarin.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.RequestAsync<Xamarin.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 里把设备相机这条链路做成让业务方省心、让测试放心、让自己有底气的模块,希望帮到你。
本文还有配套的精品资源,点击获取