Flutter for OpenHarmony 实战:媒体选择库 adaptive_image_picker 的鸿蒙化适配全解读
一、插件简介与适配目标
adaptive_image_picker 是一个"三合一"的 Flutter 媒体选择库:相册选图(Android 13+ Photo Picker / iOS PHPicker,零权限)、相机拍照、纯 Dart 手势裁剪与目标大小压缩。它的特别之处在于分层架构——裁剪、压缩、弹窗 UI 全部是纯 Dart 实现,只有"相册"和"拍照"两个能力真正触碰平台 API。
这个分层直接决定了适配工作量:纯 Dart 层天然跨平台,一行不改;需要动手写的只有 ArkTS 侧的平台通道实现,共三个方法。适配目标很明确:
- 相册选择走鸿蒙系统
PhotoViewPicker,保持零权限特性; - 拍照走系统
cameraPicker,支持前后摄切换; - 取消语义与 Android/iOS 完全一致(相册取消返回空列表、拍照取消返回
null),业务代码零平台分支。
基线信息:上游 adaptive_image_picker 1.0.2(提交 73f8006),适配成果托管在 AtomGit oh-flutter 组织,适配提交 d2a7d81,验证环境为 Flutter 3.41.10-ohos、DevEco Studio 26.0.0 Release、HarmonyOS 7.0.0(API 26)模拟器与 nova 15 真机。
二、从源码仓库开始:保住上游历史
适配的第一步不是写代码,而是建立正确的仓库结构。社区要求适配版本保留原作者的提交历史——这既是尊重上游,也让后续同步上游、提 PR 有据可依。
我们踩的第一个坑就在这里:最初通过 ZIP 快照导入,git log 只剩一个孤零零的根提交,上游从首次提交到 73f8006 的全部历史丢了。正确做法:
git clone https://github.com/Karan8686/adaptive_image_picker.git
cd adaptive_image_picker
git remote add upstream https://github.com/Karan8686/adaptive_image_picker.git
git fetch upstream main
# 以 upstream/main 为基础重建 main,再叠加一个 OHOS 适配提交
最终远程 main 的历史是 d2a7d81 -> 73f8006 -> ... -> 35214fd:顶层是我们唯一的适配提交,往下是完整的原作者历史。之后核对改动范围用 git diff upstream/main...HEAD,一眼可查适配到底动了哪些文件。
另一个 Windows 特有的教训:把工程放在用户目录的深层路径下,Hvigor 构建链路在路径接近 260 字符时会出现各种诡异失败。换到 C:\Dev\flutter\adaptive_image_picker 这样的短 ASCII 路径后一次通过——先换短路径再排查,能省掉大量无效调试。
补全 OHOS 目录用标准命令:
flutter create --platforms ohos .
它会在插件根目录生成 ohos/(插件本体)和 example/ohos/(示例宿主),并在 pubspec.yaml 里注册 ohos 平台。适配后的目录职责如下:
adaptive_image_picker/
├── lib/ # 纯 Dart 层:API、裁剪引擎、压缩引擎(零改动)
├── ohos/ # 本次适配核心:ArkTS 平台实现
│ └── src/main/ets/components/plugin/AdaptiveImagePickerPlugin.ets
├── example/ohos/ # OHOS 示例宿主(自动生成 + 微调)
├── pubspec.yaml # 注册 ohos 平台的 pluginClass
└── README.OpenHarmony*.md # 双语鸿蒙使用说明(交付件)
pubspec.yaml 中新增的平台声明只有三行,鸿蒙工具链构建 HAP 时靠它把 ArkTS 实现注册进引擎:
flutter:
plugin:
platforms:
ohos:
pluginClass: AdaptiveImagePickerPlugin

三、Dart 接口与平台通道契约分析
写 ArkTS 之前先做契约表——这是整个适配里最重要的习惯。通读 Dart 侧的平台接口层,梳理出跨通道的完整约定:
| 通道方法 | Dart 侧调用 | 参数 | 返回契约 |
|---|---|---|---|
getPlatformVersion | 诊断接口 | 无 | 平台版本字符串 |
pickImages | 相册选图(单选/多选) | maxCount、mediaType | List<AdaptiveFileMap>;取消返回空列表 |
takePhoto | 拍照 | preferredCameraDevice(front/rear) | 单个 AdaptiveFileMap 或 null;取消返回 null |
其中 AdaptiveFileMap 是跨通道的文件元数据结构,五个字段是硬契约:
interface AdaptiveFileMap {
path: string; // 本地可读路径(不是临时 URI!后文详解)
name: string; // 文件名含扩展名
size: number; // 字节数
mimeType: string; // MIME 类型
lastModified: number; // 毫秒时间戳(注意单位!)
}
两个容易踩的细节值得提前标注:一是 lastModified,Dart 侧期望毫秒,而鸿蒙 fs.Stat.mtime 是秒,直传会导致所有文件的修改时间显示为 1970 年;二是 mediaType,Dart 枚举序列化后是 'image' | 'video' | 'all' 三个字符串,需要映射到鸿蒙 Picker 的 PhotoViewMIMETypes。
契约表确认后,Dart 侧代码零改动,所有适配工作收敛到 AdaptiveImagePickerPlugin.ets 一个文件里。
四、OHOS 原生实现:逐段解读 ArkTS 插件
4.1 插件骨架与 Ability 绑定
标准 FlutterPlugin 三件套之外,关键在于实现 AbilityAware——鸿蒙的 Picker 和相机都需要 UIAbilityContext 才能拉起:
export default class AdaptiveImagePickerPlugin implements FlutterPlugin, MethodCallHandler, AbilityAware {
private channel: MethodChannel | null = null;
private context: common.UIAbilityContext | null = null;
private operationActive: boolean = false;
onAttachedToEngine(binding: FlutterPluginBinding): void {
this.channel = new MethodChannel(binding.getBinaryMessenger(), 'adaptive_image_picker');
this.channel.setMethodCallHandler(this);
}
onAttachedToAbility(binding: AbilityPluginBinding): void {
this.context = binding.getAbility().context; // Picker/相机必需
}
// ...
}
通道名 adaptive_image_picker 必须与 Dart 侧完全一致。operationActive 是一个简单的并发保护:媒体选择会话进行中再收到新请求,直接返回 ALREADY_ACTIVE 错误,避免两个系统 Picker 叠加把页面状态搞乱。
4.2 相册选择:PhotoViewPicker 与取消语义
private handlePickImages(call: MethodCall, result: MethodResult): void {
const maxCount = Math.max(1, Math.min(500, Math.trunc(call.argument('maxCount') ?? 1)));
const mediaType: string = call.argument('mediaType') ?? 'image';
const options = new photoAccessHelper.PhotoSelectOptions();
options.maxSelectNumber = maxCount;
options.MIMEType = this.toPickerMimeType(mediaType); // image/video/all 三态映射
const picker = new photoAccessHelper.PhotoViewPicker();
picker.select(options).then(async (selection) => {
const files: Array<AdaptiveFileMap> = [];
for (const uri of selection.photoUris) {
const copied = this.copyUriToCache(uri, mediaType); // 核心步骤,见 4.3
if (copied !== null) files.push(copied);
}
result.success(files);
}).catch((_error: BusinessError) => {
// 用户取消时 select 的 promise 会被 reject——按契约返回空列表而不是错误
result.success([]);
});
}
最值得划重点的是最后那个 catch:鸿蒙 PhotoViewPicker.select() 在用户点返回取消时是 promise reject,而不是返回空结果。如果按直觉把 reject 转成 result.error,Flutter 侧就会对一次正常的用户取消抛异常。对照 Android/iOS 的行为(取消 = 空列表),我们在 reject 分支显式返回 result.success([]),取消语义就此对齐。
4.3 URI 复制:适配中最核心的一段
系统 Picker 返回的 photoUris 是临时 URI,对 Dart 侧不是稳定可读的文件路径。必须在原生侧把它复制成普通文件再返回——这是整个适配里最核心的设计决策:
private copyUriToCache(uri: string, mediaType: string): AdaptiveFileMap | null {
let source: fs.File | null = null;
try {
source = fs.openSync(uri, fs.OpenMode.READ_ONLY);
const extension = this.extensionFromUri(uri, mediaType);
const fileName = `adaptive_picker_${util.generateRandomUUID()}.${extension}`;
const outputPath = `${this.context.cacheDir}/${fileName}`;
// 先创建目标文件,再复制源 FD——顺序不能反
const output = fs.openSync(outputPath, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE);
fs.closeSync(output);
fs.copyFileSync(source.fd, outputPath);
return this.fileMapFromPath(outputPath, this.mimeTypeForExtension(extension));
} catch (_error) {
return null;
} finally {
if (source !== null) fs.closeSync(source);
}
}
三个实战细节:第一,必须先在缓存目录创建目标文件、再执行复制——直接 copyFileSync 到一个不存在的路径会静默失败,产出 0 字节文件;第二,复制完成后用 fs.statSync 校验 size,非空才返回,异常情况返回 null 让上层过滤;第三,扩展名从 URI 尾段解析,解析不出时按媒体类型兜底(图片 jpg、视频 mp4),MIME 类型按扩展名白名单映射,避免把任意字符串透传给 Dart。
实测证据:模拟器选图后,应用缓存目录生成 24,225 字节的 adaptive_picker_*.jpg,stat 校验通过——"URI 复制 + 非空校验"链路闭环。
4.4 拍照:cameraPicker 与输出落盘
const profile: cameraPicker.PickerProfile = {
cameraPosition: preferredDevice === 'front'
? camera.CameraPosition.CAMERA_POSITION_FRONT
: camera.CameraPosition.CAMERA_POSITION_BACK,
saveUri: fileUri.getUriFromPath(outputPath), // 预创建输出文件,拍照直接写入
};
cameraPicker.pick(this.context, [cameraPicker.PickerMediaType.PHOTO], profile)
.then((cameraResult: cameraPicker.PickerResult) => {
if (cameraResult.resultCode !== 0) {
this.removeIfPresent(outputPath);
result.success(null); // 用户取消 → null,与上游契约一致
return;
}
const captured = this.fileMapFromPath(outputPath, 'image/jpeg');
if (captured === null || captured.size === 0) {
this.removeIfPresent(outputPath);
result.success(null); // 空文件也按取消处理,不返回脏数据
} else {
result.success(captured);
}
});
拍照与选图共用 fileMapFromPath(内含秒转毫秒的 mtime * 1000 修正),取消路径同样严格:非 0 结果码或 0 字节输出都清理残留文件后返回 null。另外 getPlatformVersion 返回 `HarmonyOS ${deviceInfo.displayVersion}`,保持字符串契约不变,联调时先调它确认通道已通。
4.5 权限声明:唯一需要改的工程配置
选图和拍照走系统 Picker,不需要任何敏感权限。示例工程唯一声明的是联网权限——因为插件还提供 URL 导图能力(走 Dart 侧 http):
// example/ohos/entry/src/main/module.json5
"requestPermissions": [
{ "name": "ohos.permission.INTERNET" }
]


五、踩坑实录:五个真实问题与解法
坑 1:ZIP 快照丢上游历史。现象与解法见第二节。教训一句话:远程仓库再空,也不能拿 ZIP 解压目录当仓库起点,必须 clone/fetch 重建历史后再提交适配。
坑 2:Windows 长路径构建失败。现象是深层路径下 Hvigor 阶段报路径/工具链错误,Dart 检查却正常。解法是把工程迁到 C:\Dev\<项目> 短路径。排查心法:先在短 ASCII 路径复现构建,再判断是代码问题还是环境路径问题。
坑 3:URI 直接复制得到空文件。现象是 Picker 有返回但 Dart 拿到 0 字节文件。根因是目标文件未预创建就执行复制。解法即 4.3 节的"先创建、再复制、后校验"三步。教训:不要把系统 URI 当持久路径,复制后必须校验非空。
坑 4:Dart 插件注册解析与 fileName 配置不一致。现象是运行时 MissingPluginException。根因是鸿蒙生成的注册链路按包入口文件解析 Dart 插件类,而插件把桌面实现放在独立文件并通过 fileName 声明。解法是在包入口显式 export 该插件类,重新 flutter pub get 生成 registrant。教训:遇到 MissingPluginException 先查声明与导出,不要手改生成的注册文件。
坑 5:AtomGit HTTPS 推送被拒。报 HTTP Basic: Access denied——AtomGit 已移除密码认证。解法是配置 SSH key 并把远程换成 git@atomgit.com:org/repo.git,推送前用 ssh -T git@atomgit.com 验证。

六、验证:编译通过不等于功能通过
适配的验证分三层,缺一不可:
第一层:Dart 静态检查与单测。flutter analyze 0 问题、flutter test 28/28 通过。这一层保证纯 Dart 层没被误伤。
第二层:HAP 构建。flutter build hap --debug 通过,且用 devecocli run 安装启动后确认插件注册成功(先调 getPlatformVersion 能返回 HarmonyOS 7.0.0)。
第三层:真机/模拟器行为验证。模拟器(HarmonyOS 7.0.0 / API 26)覆盖相册链路:Picker 拉起 → 单选 → 返回 Dart → 缓存复制(hdc shell 检查缓存目录确认生成 24,225 字节文件);真机(nova 15)覆盖相机链路——模拟器没有摄像头,相机功能只有真机能证明,两类设备的证据不能互相替代。
工具链优先级也固化成了团队规范:devecocli 优先(设备管理、安装、日志、UI 检查都齐),只有它不覆盖的能力(比如应用沙箱缓存文件列表检查)才回退 hdc,并记录回退原因。


七、FAQ:给正在做适配的你
Q1:模拟器上能验证完整功能吗?
相册链路可以,相机不行(无摄像头, 我这个Windows测试是调研不了摄像头的,Mac上面是可以有摄像头。这个具体要看设备支持情况)。适配验证的证据要分设备记录:模拟器证据 + 真机硬件证据分开列清单,都过了才叫闭环。
Q2:Picker 返回的 URI 能直接存起来以后用吗?
不能。临时 URI 随时失效,正确姿势是适配层当场复制进应用缓存(见 4.3),把普通文件路径交给 Dart。这是所有媒体类插件适配的通用模式。
Q3:运行时 MissingPluginException 怎么排查?
按顺序查三处:依赖是否解析到适配版本(看 pubspec.lock 来源)→ 是否重新执行过 flutter pub get(生成 registrant)→ Dart 插件类是否从包入口导出。先修声明,别手改生成文件。
Q4:lastModified 显示 1970 年?
秒/毫秒单位问题。鸿蒙 Stat.mtime 是秒,Dart 侧契约是毫秒,原生层乘 1000 再返回。
Q5:发现库的问题如何提 issue?
到适配仓库提:https://atomgit.com/oh-flutter/adaptive_image_picker/issues 。附上 flutter doctor -v、设备与系统版本、最小复现代码、预期/实际行为和相关日志,能附截图尽量附。
Q6:我能修,怎么提 PR?
Fork 仓库 → 从 main 拉功能分支 → 修改后本地过 flutter analyze(0 问题)、flutter test(全绿)、flutter build hap --debug,涉及设备行为的必须在模拟器或真机实测 → 推送到 Fork 并对源仓库 main 发 PR,描述里写清问题、改动点与验证证据。
八、总结
回看这次适配,真正的工作量分布是:仓库与工程结构 20%、契约分析 20%、ArkTS 实现 30%、验证与踩坑记录 30%。最有价值的两个习惯:一是动手前先写契约表,把取消语义、字段单位这些"隐含约定"显式化;二是每个坑都当场记录现象、排查、根因、解法与可复用经验——本文五分之四的内容就来自那份适配日志。
鸿蒙生态的三方库版图还缺很多块,而像 adaptive_image_picker 这样"纯 Dart 为主、平台通道面小"的库,恰好是入手适配的最佳对象:风险面小、验证路径清晰、成果立刻可用。欢迎参照本文的路径,把你在用的库也适配一遍。
欢迎加入 Flutter 鸿蒙化社区(CPF-Flutter 组织):https://atomgit.com/CPF-Flutter
更多推荐




所有评论(0)