一、插件简介与适配目标

adaptive_image_picker 是一个"三合一"的 Flutter 媒体选择库:相册选图(Android 13+ Photo Picker / iOS PHPicker,零权限)、相机拍照、纯 Dart 手势裁剪与目标大小压缩。它的特别之处在于分层架构——裁剪、压缩、弹窗 UI 全部是纯 Dart 实现,只有"相册"和"拍照"两个能力真正触碰平台 API。

这个分层直接决定了适配工作量:纯 Dart 层天然跨平台,一行不改;需要动手写的只有 ArkTS 侧的平台通道实现,共三个方法。适配目标很明确:

  1. 相册选择走鸿蒙系统 PhotoViewPicker,保持零权限特性;
  2. 拍照走系统 cameraPicker,支持前后摄切换;
  3. 取消语义与 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相册选图(单选/多选)maxCountmediaTypeList<AdaptiveFileMap>取消返回空列表
takePhoto拍照preferredCameraDevice(front/rear)单个 AdaptiveFileMapnull取消返回 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_*.jpgstat 校验通过——"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" }
]

> 📷 **图片占位 2**:系统 Picker 拉起界面(来源:docs/adaptations/assets/adaptive_image_picker/final-gallery-result.png)

在这里插入图片描述

五、踩坑实录:五个真实问题与解法

坑 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,并记录回退原因。

> 📷 **图片占位 5**:模拟器上示例应用运行的 Studio 配置页(来源:docs/.../app-screen.png)

在这里插入图片描述

七、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

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐