简介:这份资源是面向 C# 与 Xamarin.Forms 开发者的相机集成示例,适合正在构建跨平台移动应用、需要调用设备原生拍照能力的中级开发者参考。示例围绕 DependencyService 接口设计展开,在共享项目中定义 ICameraService,再分别用 UIImagePickerController 与 MediaStore.ACTION_IMAGE_CAPTURE 完成 iOS、Android 平台实现,并覆盖图片流处理、运行时权限申请、XAML 布局与异常反馈等环节,帮助读者理解共享代码与平台特定代码的协作方式。压缩包共 83 个文件,约 286KB,以 31 个 png 截图、23 个 cs 源码、3 个 xaml 界面文件及 csproj、sln、plist 等工程配置为主,另含 README 说明,结构完整可直接运行。目前已有 307 人学习,适合作为跨平台相机功能落地的参考范例。
1. 从一次“拍完照找不到图”的翻车说起:Xamarin Forms 里调用设备相机到底难在哪
很多做 Xamarin Forms 的团队都遇到过这个场景:需求文档上写着“用户点击按钮,调起设备相机拍照,拍完把图片显示在页面上并上传”。听起来三行代码的事,真上手才发现——Android 上拍完照返回的Intent里拿不到完整图片,iOS 上权限弹窗没配描述直接闪退,模拟器上相机根本打不开,拍完的图存哪、怎么读、怎么在Image控件里显示,每一步都能卡住半天。这个标题讲的正是这件事:在 Xamarin Forms 应用程序里,用设备相机检索(拍摄并获取)图片的完整示例。它解决的不是“怎么调一个 API”,而是“跨 Android 和 iOS 两个平台,怎么把拍照、存文件、读文件、显示图片这条链路跑通”。适合正在用 Xamarin Forms 做业务 App、需要接入拍照上传功能的开发者,也适合想搞清楚MediaPlugin这类插件背后到底干了什么的人。下面我按自己踩过的顺序,把选型、配置、代码和坑一条条讲清楚。
2. 选型先立住:为什么我最终用 MediaPlugin 而不是自己写平台代码
2.1 三种常见做法的取舍
在 Xamarin Forms 里调相机,业内常见做法有三条路。第一条是纯依赖注入加平台特定代码,Android 写Intent加OnActivityResult,iOS 写UIImagePickerController,通过DependencyService暴露给共享层。第二条是用Xamarin.Essentials里的MediaPicker,它在新版本里已经能拍照和选图。第三条是用社区维护的Xamarin.Plugin.Media(常叫 MediaPlugin),一行CrossMedia.Current.TakePhotoAsync就能拿到文件。
我一般会先看项目的最低支持版本。如果项目已经全面用Xamarin.Essentials,优先用它自带的MediaPicker,少引一个包。但很多存量项目还停在较早的 Forms 版本,Xamarin.Essentials的MediaPicker在部分 Android 机型上对存储路径处理不一致,这时候 MediaPlugin 反而更稳。自己写平台代码不是不行,但 Android 的FileProvider、AndroidManifest权限、iOS 的Info.plist描述,两套都要维护,团队里没人愿意长期背这个包袱。
| 方案 | 代码量 | 跨平台一致性 | 维护成本 | 适用场景 |
|---|---|---|---|---|
| 平台特定代码 + DependencyService | 大 | 需自己保证 | 高 | 有特殊定制需求 |
| Xamarin.Essentials MediaPicker | 小 | 较好 | 低 | 新项目、版本较新 |
| Xamarin.Plugin.Media | 小 | 好 | 低 | 存量项目、需要稳定拍照 |
选型理由说白了就一句:拍照这件事的复杂度不在“调起相机”,而在“拿到一个两个平台都能读的文件对象”。MediaPlugin 帮你把这层抹平了,代价是引入一个第三方依赖,需要留意它的版本和 Forms 版本匹配。
2.2 权限与清单配置:不配这两处,代码写得再对也白搭
Android 侧,AndroidManifest.xml里至少要声明相机和存储相关权限。注意 Android 6.0 以后是运行时权限,清单声明只是第一步,真正弹窗要靠代码请求。下面是我项目里常用的清单片段。
<!-- Platforms/Android/AndroidManifest.xml --> <manifest xmlns:android="http://schemas.android.com/apk/res/android"> <uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-feature android:name="android.hardware.camera" android:required="false" /> <application android:label="CameraDemo"> <!-- Android 7.0+ 必须配置 FileProvider,否则拍照后写文件会抛异常 --> <provider android:name="androidx.core.content.FileProvider" android:authorities="${applicationId}.fileprovider" android:exported="false" android:grantUriPermissions="true"> <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> </provider> </application> </manifest>uses-feature里required="false"很关键,写成true会让没有相机的设备(比如部分平板、模拟器)在应用商店里被过滤掉。FileProvider的authorities用${applicationId}.fileprovider是常见写法,保证不同包名不冲突。对应的file_paths.xml放在Resources/xml下,内容大致是:
<!-- Platforms/Android/Resources/xml/file_paths.xml --> <paths xmlns:android="http://schemas.android.com/apk/res/android"> <external-path name="external_files" path="." /> <cache-path name="cache_files" path="." /> </paths>iOS 侧,Info.plist必须加相机和相册的使用描述,缺一个,调用时直接崩溃,而且崩溃日志不一定直观。
<!-- Platforms/iOS/Info.plist --> <key>NSCameraUsageDescription</key> <string>需要使用相机拍摄照片用于上传</string> <key>NSPhotoLibraryUsageDescription</key> <string>需要访问相册以选择照片</string> <key>NSPhotoLibraryAddUsageDescription</key> <string>需要保存拍摄的照片到相册</string>提示:
NSCameraUsageDescription的文案会直接显示在系统权限弹窗上,写清楚用途,别写“测试”两个字,审核和用户信任都过不去。
2.3 共享层调用相机的最小代码
配置齐了,共享层代码其实很短。下面这段是我常用的拍照方法,包含权限检查、拍照、拿文件、显示到Image控件。
// 共享层 CameraService.cs using Plugin.Media; using Plugin.Media.Abstractions; using Xamarin.Forms; public async Task<ImageSource> TakePhotoAsync() { // 先初始化,某些平台首次调用必须执行 await CrossMedia.Current.Initialize(); // 检查设备是否支持拍照,模拟器上通常为 false if (!CrossMedia.Current.IsCameraAvailable || !CrossMedia.Current.IsTakePhotoSupported) { await Application.Current.MainPage.DisplayAlert("提示", "当前设备不支持拍照", "确定"); return null; } // 关键参数:CompressionQuality 控制压缩质量,PhotoSize 控制分辨率 var file = await CrossMedia.Current.TakePhotoAsync(new StoreCameraMediaOptions { Directory = "CameraDemo", Name = $"photo_{DateTime.Now:yyyyMMddHHmmss}.jpg", PhotoSize = PhotoSize.Medium, // Small/Medium/Large,按上传带宽选 CompressionQuality = 75, // 0-100,75 是清晰度和体积的平衡点 SaveToAlbum = false // 是否同时存到系统相册 }); if (file == null) return null; // 用户取消拍照 // file.Path 是临时文件路径,file.GetStream() 可直接读流上传 return ImageSource.FromStream(() => file.GetStream()); }逻辑说明:Initialize()在部分平台首次调用必须执行,否则后续方法可能抛空引用。IsCameraAvailable在模拟器上返回 false,这是正常的,别以为是代码错了。StoreCameraMediaOptions里PhotoSize和CompressionQuality是最该调的两个参数——PhotoSize.Large在低端 Android 机上可能直接内存溢出,CompressionQuality设 100 会让一张图好几 MB,上传慢还费流量。file.GetStream()每次调用返回新流,ImageSource.FromStream里用 lambda 包一层是标准写法,避免流被提前释放。
3. 把图片检索链路跑通:从拍照到显示到上传的完整步骤
3.1 页面层怎么接:按钮、预览、状态管理
共享层方法有了,页面层要处理的是“点了按钮之后界面别卡死、拍完能预览、失败有提示”。我一般把拍照逻辑放在 ViewModel 里,页面只负责绑定。下面是一个精简的 ViewModel 片段。
// ViewModel/MainViewModel.cs public class MainViewModel : INotifyPropertyChanged { private ImageSource _photo; public ImageSource Photo { get => _photo; set { _photo = value; OnPropertyChanged(); } } public ICommand TakePhotoCommand { get; } public MainViewModel() { TakePhotoCommand = new Command(async () => await ExecuteTakePhoto()); } private bool _isBusy; private async Task ExecuteTakePhoto() { if (_isBusy) return; // 防止连点导致多次调起相机 _isBusy = true; try { var service = new CameraService(); Photo = await service.TakePhotoAsync(); } catch (Exception ex) { // 真机上权限被拒、存储满都会走到这里 await Application.Current.MainPage.DisplayAlert("拍照失败", ex.Message, "确定"); } finally { _isBusy = false; } } // INotifyPropertyChanged 实现略 }_isBusy这个标志位是血泪经验:不加它,用户快速连点按钮,Android 上会连续调起多个相机 Activity,返回时OnActivityResult对不上号,拿到的文件可能是上一次的。try/catch也不能省,权限被用户拒绝时抛的异常信息虽然不友好,但至少能让你在日志里定位。
3.2 上传环节:流怎么读、什么时候释放
拍完照最终要上传。MediaFile提供了GetStream()和Path两种方式。用HttpClient上传时,常见做法是构造MultipartFormDataContent。
// 上传示例 public async Task<bool> UploadAsync(MediaFile file, string url) { using (var client = new HttpClient()) using (var content = new MultipartFormDataContent()) using (var stream = file.GetStream()) { var fileContent = new StreamContent(stream); fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/jpeg"); content.Add(fileContent, "file", Path.GetFileName(file.Path)); var response = await client.PostAsync(url, content); return response.IsSuccessStatusCode; } }参数说明:MultipartFormDataContent的字段名"file"要和后端接口约定一致,很多上传失败是字段名对不上。MediaTypeHeaderValue写image/jpeg,如果拍照存的是 png 要相应改。using包住 stream 很重要,MediaFile底层是临时文件,流不释放,Android 上临时文件删不掉,拍几十张后存储就告急。上传大图前建议先压缩,CompressionQuality在拍照阶段就压过一轮,上传阶段一般不用再压。
3.3 检索已拍图片:从相册选图与路径读取
标题里的“检索图片”除了拍照,往往还包括从相册选已有图片。MediaPlugin 同样支持PickPhotoAsync。
var file = await CrossMedia.Current.PickPhotoAsync(new PickMediaOptions { PhotoSize = PhotoSize.Medium, CompressionQuality = 80 }); if (file != null) { Photo = ImageSource.FromStream(() => file.GetStream()); }PickMediaOptions和拍照的StoreCameraMediaOptions参数含义类似,但注意选图时PhotoSize会影响返回文件是否被重新编码——设成Medium时插件会把原图压缩后给你,如果你需要保留原图,设成PhotoSize.Full或直接用file.Path读原始文件。这里有个容易忽略的点:PickPhotoAsync在 Android 上返回的路径可能是content://开头的 URI 而非文件路径,直接拿Path去File.ReadAllBytes会失败,必须用GetStream()。这是新手最常翻车的地方之一。
4. 避坑与排查:相机功能在真机上翻车的 5 个典型场景
4.1 拍完照返回,图片是黑的或者空的
现象:调起相机拍完,TakePhotoAsync返回的MediaFile不为 null,但GetStream()读出来是 0 字节,或者显示一片黑。
原因:Android 上临时文件写入失败,最常见是FileProvider的authorities和代码里不一致,或者file_paths.xml没包含实际写入目录。另一个原因是存储权限被拒后插件没抛异常,而是返回了一个空文件。
解决:先确认AndroidManifest里authorities的值和file_paths.xml的路径配置;再在拍照前显式请求Permissions.Camera和Permissions.StorageWrite,用Xamarin.Essentials的Permissions.RequestAsync检查返回值,被拒就提示用户去设置里开。
4.2 iOS 上一点按钮就闪退
现象:Android 正常,iOS 调TakePhotoAsync直接崩溃,日志里能看到NSCameraUsageDescription相关字样。
原因:Info.plist缺NSCameraUsageDescription,iOS 在访问相机前会检查这个键,没有就直接终止进程,这是系统行为,不是异常,catch 不住。
解决:补齐NSCameraUsageDescription、NSPhotoLibraryUsageDescription、NSPhotoLibraryAddUsageDescription三个键。改完要清理重新编译,增量编译有时不生效。
4.3 模拟器上相机打不开
现象:IsCameraAvailable返回 false,或者调起后黑屏。
原因:Android 模拟器默认没有相机硬件,iOS 模拟器也不支持相机。这是预期行为。
解决:用真机调试。如果必须在模拟器上验证 UI 流程,可以在代码里判断DeviceInfo.DeviceType == DeviceType.Virtual时走一个假数据分支,但别把这个分支带到生产。
4.4 连续拍照后应用变卡甚至被杀
现象:拍了几张后应用响应变慢,Android 上被系统回收。
原因:MediaFile的流没释放,临时文件堆积;或者PhotoSize.Large在低内存设备上解码大图导致 OOM。
解决:所有GetStream()用using包住;PhotoSize按业务需要选Medium;Image控件显示大图前先降采样,别直接把原图塞进去。
4.5 上传接口报 413 或超时
现象:小图能传,大图报 413 Request Entity Too Large 或直接超时。
原因:CompressionQuality设太高,或者PhotoSize用了Full,单张图好几 MB。
解决:拍照阶段CompressionQuality控制在 70-80,PhotoSize用Medium;服务端如果有限制,上传前再压一轮,或者改用分片上传。别指望用户网络都好,移动网络下 2MB 以上的图失败率明显上升。
5. 进阶技巧:把拍照封装成可测试、可替换的服务
5.1 用接口隔离插件依赖
直接在 ViewModel 里new CameraService()能跑,但没法单元测试,也没法在模拟器上替换成假实现。我一般抽一个接口。
public interface IPhotoService { Task<MediaFile> TakePhotoAsync(); Task<MediaFile> PickPhotoAsync(); } public class MediaPluginPhotoService : IPhotoService { public async Task<MediaFile> TakePhotoAsync() { await CrossMedia.Current.Initialize(); if (!CrossMedia.Current.IsCameraAvailable) return null; return await CrossMedia.Current.TakePhotoAsync(new StoreCameraMediaOptions { PhotoSize = PhotoSize.Medium, CompressionQuality = 75 }); } // PickPhotoAsync 类似 }这样在测试项目里注入一个返回固定MediaFile的假实现,就能在不依赖真机的情况下测 ViewModel 逻辑。参数集中在一个类里,以后要换Xamarin.Essentials的MediaPicker,只改这一个实现类。
5.2 验证清单:上线前逐条过一遍
| 检查项 | Android | iOS | 说明 |
|---|---|---|---|
| 相机权限声明 | 是 | 是 | 清单/plist 都要 |
| 运行时权限请求 | 是 | 系统自动 | Android 6.0+ 必须代码请求 |
| FileProvider 配置 | 是 | 不适用 | authorities 要对上 |
| 权限描述文案 | 不适用 | 是 | 三个 key 都要 |
| 真机测试 | 是 | 是 | 模拟器测不了相机 |
| 取消拍照处理 | 是 | 是 | 返回 null 要判断 |
| 流释放 | 是 | 是 | using 包住 |
| 大图压缩 | 是 | 是 | 控制质量和尺寸 |
这张表我每次接相机功能都会过一遍,漏一条就可能在某个机型上翻车。尤其是取消拍照返回 null 这一条,很多代码没判断,用户点取消后直接空引用崩溃。
5.3 一个我自己的习惯
我现在接任何 Xamarin Forms 的硬件相关功能,第一件事不是写代码,而是先把权限清单和真机调试环境准备好。相机这个功能,代码量其实不大,翻车基本都翻在配置和平台差异上。把IPhotoService抽出来、把参数集中、把验证清单过一遍,后面换插件或者加新功能都省事。希望帮到你。
本文还有配套的精品资源,点击获取