开发工具: 华为云码道

DevEco Studio: 26.0.0(26.0.0.851)

原始仓库: fluttervn/md5_plugin

本文配套仓库: oh-flutter/md5_plugin

md5_plugin 和上一篇的 md5_file_checksum 做的是同一件事——算文件的 MD5——但两个库的「出身」完全不同,适配时踩的坑也完全不同。

md5_file_checksum 是较新的库,用 Pigeon 描述协议、Dart 3 语法、Android 侧 Kotlin + FlutterPlugin 新接口;md5_plugin 则是 Dart 2 时代的产物:pubspec.yaml 里写的是 sdk: ">=2.1.0 <3.0.0",Android 侧还停在 PluginRegistry.Registrar(v1 embedding),插件声明用的是早已废弃的 androidPackage 单平台写法。

在这里插入图片描述

换句话说:这次适配的第一步不是「加 ohos」,而是「先把一个 Dart 2 插件抬到能编译的 Dart 3 基线」,然后才是原生实现。过程中的核心发现同样是一条跨端格式问题:首版 OHOS 实现用十六进制返回摘要,而 Android 用 Base64.encode、iOS 用 stringWithBase64EncodedData,三端吐出两种字符串。

本文以 md5_plugin 1.0.2 为例,记录从源码准备、插件声明迁移、ArkTS 原生实现、示例重写,到真机上把 0 B / 64 B / 1 KB / 5 MB 四种规模逐个与纯 Dart crypto 对账的全过程。文中的代码以提交 3323038 为参考,TAG 为 1.0.2-ohos-1.0.0。

在这里插入图片描述


一、插件简介与适配目标

md5_plugin 的公开面比 md5_file_checksum 还小:一个类、一个静态方法。

final checksum = await Md5Plugin.getMD5WithPath(filePath);

没有实例、没有配置项、没有流式 API。内部是一条最朴素的 MethodChannel:

要素值
通道名md5_plugin
方法名getMD5
参数{'file_path': <String>}
返回String——16 字节摘要的 Base64(24 字符,含 ==)

演示页初始状态 演示页初始状态 演示页初始状态 演示页初始状态 演示页初始状态 演示页初始状态 演示页初始状态 演示页初始状态

返回 Base64 而不是十六进制这件事,是本文最关键的一条。上游三个平台的实现各不相同,但对「摘要怎么编码成字符串」这件事是一致的:

  • Android:new String(Base64.encode(digest, Base64.DEFAULT)).trim()
  • iOS:[data stringWithBase64EncodedData]
  • OHOS(首版误写):本地 toHex() → 32 字符小写十六进制

于是同一个文件在手机上给出 kJGYRa+3/lU16zo421hC0w==,在鸿蒙设备上给出 90919845afb7fe5535eb3a38db5842d3。两个值其实是同一份摘要的两种编码,但任何做「服务端下发校验和、客户端比对」的业务都会直接判定失败。

这次适配的工作量集中在六处,其中四处是仓库里真实存在的缺陷:

  1. 把插件声明从 androidPackage 迁移到 platforms 映射,同时注册 ohos;
  2. 把 SDK 约束从 >=2.1.0 <3.0.0 抬到 >=3.3.0 <4.0.0——否则新工具链根本不认这个包;
  3. 用 @ohos.security.cryptoFramework + @ohos.file.fs 实现 1MB 分块流式 MD5;
  4. 对齐返回格式:改用 util.Base64Helper,与 Android/iOS 严格一致;
  5. 修掉示例工程里编译不过的模板测试:example/test/widget_test.dart 还在 pumpWidget(MyApp()),而 MyApp 在适配时已被 Md5PluginDemoApp 取代,flutter analyze 直接报 error;
  6. 删掉文档里一行不存在的 API:README 模板残留的 getPlatformVersion 在本插件里从未实现,照文档调用只会得到 MissingPluginException。

二、环境准备

环境搭建参考社区文档:Flutter OH 开发环境搭建,完成 Flutter OH SDK 安装、环境变量和 DevEco Studio 配置。

完成后,在宿主机终端执行以下命令,确认当前选中的是支持 OHOS 的 Flutter 工具链,并能发现目标设备:

flutter --version
flutter doctor -v
hdc list targets

在这里插入图片描述
在这里插入图片描述

编辑用户

2.1 开发套件版本与工程中的 SDK 版本配置

项目版本
Flutter3.47.7-ohos-1.0.0(oh-3.47-dev,framework revision 4b5928c43f)
Dart3.13.3
DevEco Studio26.0.0(26.0.0.851)
OpenHarmony SDK6.1.1(24)
真机TLR-AL00(HUAWEI nova 14),OpenHarmony-6.1.1.120,API 24

宿主工程的产物版本在 example/ohos/build-profile.json5 里:

"products": [
  {
    "name": "default",
    "signingConfig": "default",
    "compatibleSdkVersion": "6.1.1(24)",
    "targetSdkVersion": "26.0.0",
    "runtimeOS": "HarmonyOS",
  }
]

在这里插入图片描述

compatibleSdkVersion 跟真机 ROM 对齐(6.1.1(24))是最省事的选择:装得上、跑得动、hdc install 不会因为 API 级别不匹配而拒绝。

构建环境有一个本机特有的坑要先说清楚:WorkBuddy 注入的 NODE_OPTIONS(safe-delete shim)会打断 hvigor 的构建缓存清理,报 SAFE_DELETE_BULK_CONFIRM_REQUIRED,最后一行是 COMPILE RESULT:FAIL,看起来像 ArkTS 语法错误,其实是环境问题。构建前清空它即可:

$env:NODE_OPTIONS=""
$env:JAVA_HOME="D:\Program Files\Huawei\DevEco Studio\jbr"
$env:PATH="D:\Program Files\Huawei\DevEco Studio\jbr\bin;" + $env:PATH

注意 PATH 里要加的是 jbr\bin 而不是 jbr,否则 hvigor 调 java 时报 spawn java ENOENT。

还有一个与本库相关的额外前提:上游是个 Dart 2 包。直接用 flutter pub get 拉它会因 SDK 约束失败,所以在改代码之前,第一件事就是把 pubspec.yaml 的 environment.sdk 抬到 Dart 3 区间。

三、从源码仓库开始准备适配工程

3.1 将上游源码同步到 AtomGit

上游是 fluttervn/md5_plugin(MIT License)。按约定,适配后的仓库统一放在 AtomGit 的 oh-flutter 组织下:

https://atomgit.com/oh-flutter/md5_plugin

本地保留两个远端,atomgit 放适配后的代码,origin 保留指向上游方便对比差异:

git remote add atomgit git@atomgit.com:oh-flutter/md5_plugin.git
git remote -v
# atomgit git@atomgit.com:oh-flutter/md5_plugin.git (fetch)
# atomgit git@atomgit.com:oh-flutter/md5_plugin.git (push)
# origin  https://github.com/fluttervn/md5_plugin (fetch)
# origin  https://github.com/fluttervn/md5_plugin (push)

3.2 将代码拉取到宿主机

请添加图片描述

仓库很小,lib/ 只有 22 行,android/ 和 ios/ 各一个实现文件。真正值得注意的是没有任何测试可跑:上游 test/md5_plugin_test.dart 里的两条用例只断言了 mock 的返回值,example/ 里也只有 Flutter 模板自带的 widget_test.dart。

3.3 在仓库根目录确认适配分支与版本标记

请添加图片描述

两点说明:

  • 分支是 master(不是新模板默认的 main),TAG 按「上游版本 + -ohos- + 适配版本」的规则命名,这里是 1.0.2-ohos-1.0.0。适配初稿曾打过 ohos-1.0.0-beta.1,正式交付时统一改名并删掉旧标签,避免文档里出现两个可用的 ref。
  • git status 显示 ## master...origin/master [ahead 3] 是正常的:适配提交只推到 atomgit,没有回推上游 GitHub,所以相对 origin/master 永远领先。

3.4 迁移插件声明并注册 ohos

请添加图片描述

上游的插件声明是 Flutter 1.x 的写法:

plugin:
  androidPackage: com.fluttervn.md5_plugin
  pluginClass: Md5Plugin

这种写法只能声明一个平台,Flutter 2 之后就被 platforms 映射取代。要新增 ohos,必须先迁移:

plugin:
  platforms:
    android:
      package: com.fluttervn.md5_plugin
      pluginClass: Md5Plugin
    ios:
      pluginClass: Md5Plugin
    ohos:
      pluginClass: Md5Plugin

同时把 environment.sdk 从 >=2.1.0 <3.0.0 抬到 >=3.3.0 <4.0.0——Dart 2 时代的约束会让 pub get 直接拒绝解析,也会让 analyzer 用旧语义理解代码。

注意 ohos 段只有 pluginClass,没有 package——这一点和 android 不同,ArkTS 侧的类名是全局唯一的,靠 ohos/index.ets 导出。

3.5 适配后的项目目录

请添加图片描述

四、Dart 接口与实现分析

4.1 跨端架构与调用时序

整条链路只有三段:

Dart: Md5Plugin.getMD5WithPath(path)
        │  MethodChannel('md5_plugin').invokeMethod('getMD5', {'file_path': path})
        ▼
ArkTS: Md5Plugin.onMethodCall → computeFileMd5(filePath)
        │  fs.openSync / readSync(1MB 分块) → cryptoFramework.createMd('MD5').update()
        ▼
Dart: Future<String>  ←  Base64(16 字节摘要),24 字符

对比 md5_file_checksum 的 Pigeon 通道,这里少了一层「Dart 端生成代码 → BasicMessageChannel + 自定义 codec」,直接就是 MethodChannel + StandardMessageCodec。好处是实现简单、出错时错误信息直白;坏处是参数名靠字符串约定,两端写错一个字符不会编译报错,只会在运行时报 MissingPluginException 或拿到 null。

4.2 唯一公开 API

lib/md5_plugin.dart 全文 22 行,去掉注释后只有这么几行:

class Md5Plugin {
  static const MethodChannel _channel = MethodChannel('md5_plugin');

  static Future<String> getMD5WithPath(String filePath) async {
    var map = {
      'file_path': filePath,
    };
    final checksum = await _channel.invokeMethod<String>('getMD5', map);
    if (checksum == null) {
      throw PlatformException(
          code: 'null_return', message: 'getMD5 returned null');
    }
    return checksum;
  }
}

这里有一段适配时新增的判空,值得单独讲,因为它不是「多余的防御」。Android 侧的实现是这样的:

private String getFileChecksum(String filePath) {
  File file = new File(filePath);
  if (file.exists()) { ... return getStreamChecksum(is); }
  return null;   // 文件不存在 → 返回 null
}

result.success(null) 会原样穿过通道变成 Dart 的 null。而上游 Dart 代码的返回类型标注是 Future<String>(非空),在空安全之前它「能跑」,只是调用方会在自己的 null 检查里炸掉,而且栈里看不到任何插件信息。适配时把它改成显式抛 PlatformException('null_return'),调用方至少能定位到「是插件说没结果」,而不是自己业务代码里一个莫名的空指针。

try {
  final md5 = await Md5Plugin.getMD5WithPath(path);
} on PlatformException catch (e) {
  // e.code == 'null_return'(Android 风格的空返回)
  // e.code == 'FILE_ERROR'(OHOS 打开失败)
}

4.3 通道参数与 wire format

端取参方式缺失/为空时的行为
Android(上游)call.argument("file_path")new File(null) 抛 NullPointerException,getFileChecksum 没捕获它,异常会穿出 onMethodCall,由通道层转成一个通用错误回到 Dart
iOS(上游)call.arguments[@"file_path"]fileHandleForReadingAtPath:nil 返回 nil,方法返回 nil → Dart 收到 null
OHOS(本次)call.argument("file_path") as string | null返回 error("INVALID_ARGUMENT", ...),Dart 收到 PlatformException('INVALID_ARGUMENT')

三端对「参数为空」的处理历史遗留各不相同,这也是本次在 OHOS 侧坚持显式校验的原因:宁可给出一个语义清楚的错误码,也不要让 NullPointerException 变成一个看不懂的通用错误。

OHOS 侧额外做了一道显式校验:

const filePath: string | null = call.argument("file_path") as string | null;
if (filePath == null || filePath.length === 0) {
  result.error("INVALID_ARGUMENT",
    "md5_plugin: file_path must not be null or empty", null);
  return;
}

理由是 ArkTS 的 fs.openSync('') 抛出的异常信息很难读(No such file or directory),在原生日志里看不出是「参数没传」还是「路径写错」。

4.4 Android 与 iOS 的实现对照

适配前先把两个上游实现读了一遍,因为返回格式必须照着它们抄。

Android(Md5Plugin.java,8KB 缓冲):

private final int BUFFER_SIZE = 1024 * 8;

byte[] buffer = new byte[BUFFER_SIZE];
int numRead;
while ((numRead = is.read(buffer)) != -1) {
  md.update(buffer, 0, numRead);
}
byte[] digest = md.digest();
String checksum = new String(Base64.encode(digest, Base64.DEFAULT));
return checksum.trim();

iOS(Md5Plugin.m,32KB 缓冲,CommonCrypto):

unsigned int length = 32 * 1024;
while (!done) {
  NSData* fileData = [handle readDataOfLength:length];
  CC_MD5_Update(&context, [fileData bytes], (unsigned int)[fileData length]);
  if ([fileData length] == 0) done = YES;
}
CC_MD5_Final([result mutableBytes], &context);
return [data stringWithBase64EncodedData];

两边有三点一致、一点不一致:

维度AndroidiOS结论
分块大小8 KB32 KB与结果无关,只影响 IO 次数
摘要算法MessageDigest("MD5")CC_MD5一致
输出编码Base64Base64必须一致
文件不存在返回 null返回 nil一致

「输出编码」那一列就是首版 OHOS 实现写错的地方。

4.5 返回格式:为什么必须是 Base64

先把同一份摘要的两种编码摆出来。文件内容是演示页里的默认文本:

Hello OpenHarmony! This file is used to test md5_plugin on OHOS.
MD5(hex)    : 90919845afb7fe5535eb3a38db5842d3     (32 字符)
MD5(Base64) : kJGYRa+3/lU16zo421hC0w==             (24 字符)

十六进制与 Base64 是同一段 16 字节的两种表示,base64.b64decode('kJGYRa+3/lU16zo421hC0w==').hex() 得到的正是 90919845afb7fe5535eb3a38db5842d3。所以两种写法在「正确性」上都没错,但在「兼容性」上只能有一种。

首版 OHOS 实现用了本地 toHex():

function toHex(data: Uint8Array): string {
  let out = '';
  for (let i = 0; i < data.length; i++) {
    const b: number = data[i];
    out = out + b.toString(16).padStart(2, '0');
  }
  return out;
}

它在真机上「看起来是对的」——32 位、小写、长度固定,很像大家熟悉的 md5sum 输出。真正暴露问题的是演示页加的那行对照:原生返回 24 字符,纯 Dart crypto 返回 24 字符,两个值却不相等。顺着这个线索回读 Android/iOS 源码,才确认了格式不一致。

修正后的实现只有三行:

import util from '@ohos.util';

function toBase64(data: Uint8Array): string {
  const helper: util.Base64Helper = new util.Base64Helper();
  return helper.encodeToStringSync(data);
}

这里有个细节:util.Base64Helper 没有 getInstance() 静态方法。首版改写时写成了 util.Base64Helper.getInstance(),ArkTS 编译直接失败(10505001 Property 'getInstance' does not exist on type 'typeof Base64Helper')。正确写法是 new util.Base64Helper()。

4.6 上游是个 Dart 2 时代的插件

这一点在看代码时很容易被忽略,但它决定了「适配」的第一步做什么。

位置上游写法现在的写法不改的后果
pubspec.yamlsdk: ">=2.1.0 <3.0.0">=3.3.0 <4.0.0pub get 直接拒绝解析
pubspec.yamlplugin.androidPackage + 单 pluginClassplugin.platforms 映射无法声明 ohos
lib/md5_plugin.dartvar checksum = ... 直接 return判空后返回空安全下 Future<String> 与 null 冲突
android/.../Md5Plugin.javaimplements MethodCallHandler + RegistrarFlutterPlugin + onAttachedToEnginev1 embedding 已被移除,Android 侧实际不可用

最后一行值得强调:上游的 Android 实现其实已经跑不起来了(PluginRegistry.Registrar 在 Flutter 3 里被删除,registerWith(Registrar) 不会被调用)。这也解释了为什么这个包在 pub.dev 上看起来「年久失修」。本次适配没有去重写 Android 侧——那超出了「鸿蒙适配」的范围,只在 README 里如实记了一句,避免有人以为三端都验证过。

五、补全 OHOS 原生实现与工程配置

5.1 插件壳:注册与注销

ohos/src/main/ets/components/plugin/Md5Plugin.ets 的骨架是模板给的,照抄 FlutterPlugin 接口:

export default class Md5Plugin implements FlutterPlugin, MethodCallHandler {
  private channel: MethodChannel | null = null;

  getUniqueClassName(): string {
    return "Md5Plugin"
  }

  onAttachedToEngine(binding: FlutterPluginBinding): void {
    this.channel = new MethodChannel(binding.getBinaryMessenger(), "md5_plugin");
    this.channel.setMethodCallHandler(this)
  }

  onDetachedFromEngine(binding: FlutterPluginBinding): void {
    if (this.channel != null) {
      this.channel.setMethodCallHandler(null)
      this.channel = null
    }
  }
  ...
}

三个要点:

  1. 通道名必须与 Dart 侧完全一致(md5_plugin),一个字符都不能错;
  2. onDetachedFromEngine 里必须把 handler 置空,否则热重启后旧 handler 仍持有已销毁的引擎引用,表现为「第二次启动时方法调用无响应」;
  3. getUniqueClassName() 返回的名字会进 getPlugins() 的注册表,重名会导致后注册的插件覆盖前者——本机上一个 bundle 里同时装了多个示例插件时,这个问题尤其容易出现。

5.2 1MB 分块流式实现

async function computeFileMd5(filePath: string): Promise<string> {
  const file: fs.File = fs.openSync(filePath, fs.OpenMode.READ_ONLY);
  try {
    const md: cryptoFramework.Md = cryptoFramework.createMd('MD5');
    const stat: fs.Stat = fs.statSync(filePath);
    const total: number = stat.size;
    const chunkSize: number = 1024 * 1024;
    let offset: number = 0;
    while (offset < total) {
      const len: number = Math.min(chunkSize, total - offset);
      const buf: ArrayBuffer = new ArrayBuffer(len);
      const readLen: number = fs.readSync(file.fd, buf, { offset: offset });
      if (readLen <= 0) {
        break;
      }
      const blob: cryptoFramework.DataBlob =
        { data: new Uint8Array(buf.slice(0, readLen)) };
      await md.update(blob);
      offset = offset + readLen;
    }
    const digest: cryptoFramework.DataBlob = await md.digest();
    return toBase64(digest.data);
  } finally {
    fs.closeSync(file);
  }
}

这段代码有四个容易写错的点:

  • readSync 的 offset 是文件偏移,不是 buffer 偏移。不传 offset 会一直读到文件开头,结果变成「同一个 1MB 反复 update N 次」,小文件看不出问题,大文件全错;
  • 最后一次 readSync 的返回长度可能小于 len,所以 md.update() 必须用 buf.slice(0, readLen) 而不是整个 buf——否则尾部会掺入上一轮的残留字节;
  • md 对象是一次性的:digest() 之后不能再 update()。若同一个文件要算两次,必须重新 createMd;
  • file 的关闭必须放 finally:openSync 成功之后任何一步抛异常(比如权限、空间)都要保证 fd 被回收,否则连续失败几次就会遇到 Too many open files。

分块大小选 1MB 是个折中:太小则 await md.update() 的调度开销占比升高,太大则单次分配的 ArrayBuffer 会顶到内存峰值。对 5MB 文件,这里是 5 次完整 update 加 1 次尾块。

5.3 方法分发与错误语义

onMethodCall(call: MethodCall, result: MethodResult): void {
  try {
    if (call.method == "getMD5") {
      const filePath: string | null = call.argument("file_path") as string | null;
      if (filePath == null || filePath.length === 0) {
        result.error("INVALID_ARGUMENT",
          "md5_plugin: file_path must not be null or empty", null)
        return
      }
      computeFileMd5(filePath).then((md5: string) => {
        result.success(md5)
      }).catch((e: Error) => {
        result.error("FILE_ERROR",
          `md5_plugin: failed to compute MD5 for ${filePath}: ${e.message}`, null)
      })
    } else {
      result.notImplemented()
    }
  } catch (e) {
    result.error("PLUGIN_ERROR",
      `md5_plugin: failed to handle ${call.method}: ${JSON.stringify(e)}`, null)
  }
}

三点设计取舍:

  1. result 只能回调一次。computeFileMd5 是异步的,then/catch 里各回调一次,但外层 try/catch 只能兜住「同步阶段」的异常(比如 call.argument 类型不对)。如果外层 catch 也回调错误,就会出现「同步抛错后异步又成功」的双回调,Flutter 侧会抛 Reply already submitted。
  2. 错误码分成三类(INVALID_ARGUMENT / FILE_ERROR / PLUGIN_ERROR),比上游 Android 的「吞掉异常返回 null」信息量大得多。这与 Dart 层的 null_return 并不冲突:null_return 只在 Android 那种老实现下出现,OHOS 走的是显式错误码。
  3. notImplemented() 让未实现的方法名在 Dart 侧得到 MissingPluginException,而不是静默挂起。

5.4 声明插件与宿主配置

ohos/oh-package.json5:

{
  "name": "md5_plugin",
  "version": "1.0.0",
  "description": "md5_plugin OpenHarmony plugin",
  "main": "index.ets",
  "author": "",
  "license": "Apache-2.0",
  "dependencies": {}
}

ohos/src/main/module.json5:

{
  "module": {
    "name": "md5_plugin",
    "type": "har",
    "deviceTypes": ["default", "tablet"]
  }
}

type: "har" 表示这是一个静态共享包(HAR),会被打进宿主 HAP;不需要单独安装,也不需要在 EntryAbility 里做任何手工注册。

5.5 注册产物与真机日志

flutter build hap 会根据 pubspec.yaml 的 platforms.ohos 生成 example/ohos/entry/src/main/ets/plugins/GeneratedPluginRegistrant.ets:

import { FlutterEngine, Log } from '@ohos/flutter_ohos';
import IntegrationTestPlugin from 'integration_test';
import Md5Plugin from 'md5_plugin';

export class GeneratedPluginRegistrant {
  static registerWith(flutterEngine: FlutterEngine) {
    try {
      flutterEngine.getPlugins()?.add(new IntegrationTestPlugin());
      flutterEngine.getPlugins()?.add(new Md5Plugin());
    } catch (e) {
      Log.e(TAG, "Tried to register plugins with FlutterEngine (" + flutterEngine + ") failed.");
      Log.e(TAG, "Received exception while registering", e);
    }
  }
}

这个文件是判断「插件到底有没有被注册」的最快证据:只要没出现 new Md5Plugin(),后面所有方法调用都注定 MissingPluginException,不必再去查 ArkTS 代码。上面这份产物里还多了一行 IntegrationTestPlugin,是因为 example/pubspec.yaml 引入了 integration_test,属于正常现象。

EntryAbility 只做一件事:

import { FlutterAbility, FlutterEngine } from '@ohos/flutter_ohos';
import { GeneratedPluginRegistrant } from '../plugins/GeneratedPluginRegistrant';

export default class EntryAbility extends FlutterAbility {
  configureFlutterEngine(flutterEngine: FlutterEngine) {
    super.configureFlutterEngine(flutterEngine)
    GeneratedPluginRegistrant.registerWith(flutterEngine)
  }
}

六、补全交付文件并提交适配分支

6.1 除代码外还要补全哪些文件

文件内容
README.OpenHarmony_CN.md中文说明:安装段落(oh-flutter 地址 + TAG)、兼容性、权限、API 表、目录结构
README.OpenHarmony.md英文版,与中文版信息一致
CHANGELOG.OpenHarmony.md按版本记录适配内容,含 Base64 对齐与实测环境
README.OpenSourceOwner 改 oh-flutter、Version Number 改 1.0.2-ohos-1.0.0、Repo Address 改 AtomGit
example/ohos/宿主工程(AppScope + entry),含签名与 compatibleSdkVersion
example/ohos/README.OpenHarmony*.md示例工程的运行说明

这些文件最容易出的错是**「版本号写了三处、改了两处」**:CHANGELOG 的标题、README 的表格、README.OpenSource 的 Version Number 必须同时改,否则文档里会同时存在 beta 和正式两个 ref。

6.2 修掉失效的示例与测试(这次的真实缺陷)

上游 example/test/widget_test.dart 是 Flutter 模板原样留下的:

await tester.pumpWidget(MyApp());
expect(
  find.byWidgetPredicate((Widget widget) =>
      widget is Text && widget.data.startsWith('Running on:')),
  findsOneWidget,
);

它引用了两个在本工程里不存在的东西:MyApp(适配时入口已改为 Md5PluginDemoApp)和 Running on: 文本(那是 getPlatformVersion 时代的产物)。flutter analyze 因此报两个 error:

error - The function 'MyApp' isn't defined - example\test\widget_test.dart:16:29
error - The method 'startsWith' can't be unconditionally invoked because the
        receiver can be 'null' - example\test\widget_test.dart:22:40

改成针对真实界面的断言,不触发任何通道:

testWidgets('demo home renders and shows the idle state',
    (WidgetTester tester) async {
  await tester.pumpWidget(const Md5PluginDemoApp());
  expect(find.text('md5_plugin Demo'), findsOneWidget);
  expect(find.text('尚未计算'), findsNWidgets(2));
  expect(find.text('计算文件 MD5(原生 cryptoFramework)'), findsOneWidget);
});

test/md5_plugin_test.dart 也重写了。上游那两条用例叫 getMD5WithPathOnAndroid / OniOS,实际上只断言 mock 的返回值,既没验证通道参数,也没覆盖空返回:

test('getMD5WithPath sends file_path over the md5_plugin channel', () async {
  final md5 = await Md5Plugin.getMD5WithPath('/sandbox/input.txt');
  expect(md5, 'M78Wt4LB0sUBb+LcR8HfRg==');
  expect(log.single.method, 'getMD5');
  expect(log.single.arguments['file_path'], '/sandbox/input.txt');
});

注意 mock 的返回值 M78Wt4LB0sUBb+LcR8HfRg== 本身就是 24 字符 Base64——上游测试里其实早就写明了「这个插件返回 Base64」,只是没人拿它去对照 OHOS 实现。这也是本文的一个反面教材:测试里的期望值有时比文档更可信。

另外新增了 example/integration_test/md5_plugin_test.dart,用真实文件覆盖 1MB 分块边界:

test('1MB - 1 bytes (chunk boundary)', () async {
  await hashAndCompare('c', repeat: 1024 * 1024 - 1);
});

test('exactly 1MB (single chunk)', () async {
  await hashAndCompare('d', repeat: 1024 * 1024);
});

test('1MB + 1 bytes (two chunks)', () async {
  await hashAndCompare('e', repeat: 1024 * 1024 + 1);
});

每个用例都把原生结果与纯 Dart crypto 复算值比对,并把「24 字符」作为硬断言——这样即使将来又有人改动编码方式,测试会直接失败。

6.3 提交前检查

flutter analyze
flutter analyze example

插件根目录的结果是 6 个 warning、0 个 error:

warning - 'avoid_returning_null' was removed in Dart '3.3.0' - analysis_options.yaml:25:7
warning - 'invariant_booleans' was removed in Dart '3.0.0' - analysis_options.yaml:41:7
warning - 'iterable_contains_unrelated_type' was removed... - analysis_options.yaml:42:7
warning - 'list_remove_unrelated_type' was removed... - analysis_options.yaml:45:7
warning - 'prefer_equal_for_default_values' was removed... - analysis_options.yaml:62:7
warning - The 'author' field is no longer used - pubspec.yaml:4:1
6 issues found. (ran in 7.4s)

这 6 条全是上游遗留:前五条是 analysis_options.yaml 引用了 Dart 3 已移除的 lint 规则,最后一条是 pubspec.yaml 的 author 字段弃用。按本文的约定,这类 warning 不随手改——改了会让适配 diff 失真,也让「上游更新时能不能直接 rebase」变得不可判断。

6.4 提交并推送

git add -A
git commit -m "fix(ohos): return the digest as Base64 like Android and iOS"
git tag 1.0.2-ohos-1.0.0
git push atomgit master
git push atomgit 1.0.2-ohos-1.0.0
git push atomgit :refs/tags/ohos-1.0.0-beta.1   # 清掉旧标签

推送后 git log 上有三条本次适配相关的提交:

3323038 fix(ohos): return the digest as Base64 like Android and iOS
d433c4d docs: add OpenHarmony README for example (CN/EN) and update build profile
7b5d8fb feat: adapt md5_plugin for OpenHarmony (ohos-1.0.0-beta.1)

第一条是本次的核心修复(含文档与测试),第二条补示例工程的 README,第三条是最初的适配骨架。保留这条历史而不是 squash 成一个提交,是因为「首版写错了返回格式、后来发现并修正」这件事本身就是文章要讲的内容。

七、使用 example 演示接入

7.1 本地适配时使用路径依赖

# example/pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  md5_plugin:
    path: ../
  crypto: ^3.0.3          # 仅用于演示页做对照,插件本身不依赖

dev_dependencies:
  flutter_test:
    sdk: flutter
  integration_test:
    sdk: flutter

crypto 只出现在示例工程里,插件本体没有任何第三方依赖——这是 md5_plugin 相对 md5_file_checksum 的一个优势:不会因为引入 crypto 而让宿主 App 的包体变大。

7.2 通过 AtomGit 引入插件

dependencies:
  md5_plugin:
    git:
      url: https://atomgit.com/oh-flutter/md5_plugin
      ref: 1.0.2-ohos-1.0.0

ref 建议写死 TAG 而不是分支,避免上游适配提交变动导致构建不可复现。

7.3 演示页的四个动作与数据

演示页(example/lib/main.dart,308 行)刻意做成「先造文件、再算摘要、同时用纯 Dart 复算」的形态,因为只有这样,屏幕上的数字才能自证:

动作写入沙箱的文件目的
计算文件 MD5cache/md5_demo_input.txt用文本内容算一次,验证基本路径
空文件 (0 B)同上,内容为空验证 while (offset < total) 在 total=0 时不进循环
1KB1024 个 a验证不到一个分块的情况
5MB 大文件5,242,880 字节的重复串验证 5 次满块 + 1 次尾块
不存在的文件不创建验证错误路径

关键实现是「同一个文件算两遍」:

final native = await Md5Plugin.getMD5WithPath(file.path);
final digest = await crypto.md5.bind(file.openRead()).first;
// Digest.toString() 给的是 32 位十六进制,插件给的是 24 位 Base64,
// 所以要先把原始字节编码成 Base64 再比。
final dart = base64Encode(digest.bytes);
setState(() {
  _nativeMd5 = native;
  _dartMd5 = dart;
  _match = native == dart;
});

这里的 base64Encode(digest.bytes) 是第二次踩坑:第一版写的是 digest.toString(),屏幕上并排显示 kJGYRa+3/lU16zo421hC0w== 和 90919845afb7fe5535eb3a38db5842d3,两个长度都不一样,一眼就能看出对照写错了。crypto 包的 Digest.toString() 返回十六进制,Digest.bytes 才是原始 16 字节。

7.4 沙箱路径的处理

演示页用 Directory.systemTemp 而不是 path_provider,原因是少一个依赖。OHOS 上它指向应用沙箱的 cache 目录:

/data/storage/el2/base/haps/entry/cache/md5_demo_input.txt

有个细节:Directory.systemTemp.path 在 OHOS 上自带结尾斜杠,直接拼 '${dir.path}/文件名' 会得到 cache//md5_demo_input.txt(双斜杠)。虽然 fs.openSync 能容忍,但显示在界面上很难看,所以加了一步归一化:

String _sandboxDir() {
  final path = Directory.systemTemp.path;
  return path.endsWith(Platform.pathSeparator)
      ? path.substring(0, path.length - 1)
      : path;
}

八、验证、构建与鸿蒙设备运行效果

8.1 分别验证插件与 example

cd D:\flutter\lib2\md5_plugin
flutter analyze              # 6 issues(全部上游遗留)

cd example
flutter analyze              # 0 issues

插件根目录与示例工程要分别跑。示例工程的 analysis_options.yaml 是 Flutter 模板给的,约束比插件根目录松,很多 example/ 下的问题只有单独跑才能发现(widget_test.dart 的 MyApp 报错就是这么找出来的)。

8.2 确认设备连接

hdc list targets
# 4UQ9K25508013016

8.3 配置签名

example/ohos/build-profile.json5 里的 signingConfig: "default" 指向 DevEco Studio 自动生成的调试签名。首次构建若报签名缺失,用 DevEco 打开 example/ohos,在 File > Project Structure > Signing Configs 勾选 Automatically generate signature 即可,签名信息会写入 build-profile.json5 的 signingConfigs 段。

8.4 构建 HAP

本机上 flutter build hap --debug 会在 ohpm 阶段失败(00306053 Specification Limit Violation),所以采用「先手动装依赖,再直接调 hvigor」的路径:

cd example/ohos
ohpm install
$env:NODE_OPTIONS=""
$env:JAVA_HOME="D:\Program Files\Huawei\DevEco Studio\jbr"
$env:PATH="D:\Program Files\Huawei\DevEco Studio\jbr\bin;D:\Program Files\Huawei\DevEco Studio\tools\node;D:\Program Files\Huawei\DevEco Studio\tools\hvigor\bin;" + $env:PATH
hvigorw.bat assembleHap -p product=default -p buildMode=debug --no-daemon

成功时尾部输出:

> hvigor Finished :entry:default@PackageHap... after 1 s 384 ms
> hvigor Finished :entry:default@SignHap... after 4 s 10 ms
> hvigor BUILD SUCCESSFUL in 41 s 210 ms
50 tasks in total: 26 executed, 24 up-to-date

若终端最后出现 PROGRAM BLOCKED BY SECURITY POLICY ... wmic.exe,且退出码是 1,不要以为构建失败——那是本机安全策略拦截了 hvigor 内部的 wmic 查询,BUILD SUCCESSFUL 与 HAP 产物都已经生成。判断标准永远是产物:

ls entry/build/default/outputs/default/
# entry-default-signed.hap
# entry-default-unsigned.hap

8.5 在设备上测试 MD5 行为

安装与启动:

hdc install -r entry-default-signed.hap
hdc shell aa start -b com.example.color_system_example -a EntryAbility

hdc install 的路径不能直接给 Windows 绝对路径(会被拼成 c:\...\D:/... 而失败),必须先 cd 到产物目录再用相对文件名。

初始界面(未计算):

点「计算文件 MD5」,原生结果与纯 Dart 对照同时出现,两者一致:

屏幕上的关键信息:文件 64 字节,原生 kJGYRa+3/lU16zo421hC0w==,纯 Dart 同值,底部「两者一致 ✓(24 字符 Base64)」。

空文件(0 字节):

1B2M2Y8AsgTpgAmY7PhCfg== 是空内容的 MD5 摘要 Base64,与 md5sum 的 d41d8cd98f00b204e9800998ecf8427e 是同一个值——这一屏是「格式已对齐」最直观的证据。

1KB(1024 个 a):

5MB 大文件(5,242,880 字节,跨 5 个完整分块):

不存在的文件:

界面上直接暴露了原始错误信息,包含路径与系统 errno 描述(No such file or directory),排查时不用再去捞 hilog。

预设切换的提示:

滚动到页面底部的实现说明:

演示页初始状态 演示页初始状态 演示页初始状态 演示页初始状态 演示页初始状态 演示页初始状态 演示页初始状态 演示页初始状态

### 8.6 真机数据汇总
文件大小原生(OHOS)纯 Dart crypto一致
默认文本64 BkJGYRa+3/lU16zo421hC0w==同值✓
空文件0 B1B2M2Y8AsgTpgAmY7PhCfg==同值✓
重复字符1,024 ByaNM/IXZgmmMasifdgcavQ==同值✓
大文件5,242,880 BTGwg1YfPDyTYMKXyB4nhtA==同值✓
不存在的路径—PlatformException(FILE_ERROR)抛异常,不产生值—

其中 0 B 与 64 B 两个值可以用任意工具交叉验证,例如:

import base64, hashlib
print(base64.b64encode(hashlib.md5(b'').digest()).decode())
# 1B2M2Y8AsgTpgAmY7PhCfg==

8.7 关于「比 crypto 包更快」这个说法

上游 README 的第一句是 “It’s faster when compared with using the crypto package”。本次适配没有做严格的基准测试(那需要同设备、同文件、多次取中位数,且要排除 Dart 侧 JIT/AOT 差异),所以文章不对这个结论背书,只记录两点可观察到的事实:

  1. 5MB 文件点击后界面没有出现卡死,结果在数秒内出现——这数秒里还包含了演示页自己拼 5MB 字符串、写盘、以及纯 Dart 侧整文件复算的开销,并不全是插件耗时;
  2. 原生侧的内存占用与文件大小无关(固定 1MB 缓冲),而纯 Dart 侧 crypto.md5.bind(file.openRead()) 走的是流式读,内存同样可控——两者的差异主要在 CPU 归属(ArkTS vs Dart isolate),而不在峰值内存。

如果你要拿它替换 crypto,建议先在自己的目标文件规模上量一次。尤其是「只算小文件字符串摘要」的场景,纯 Dart 反而可能更快,因为省掉了一次跨语言通道往返。

九、FAQ:适配过程与使用问题

9.1 MissingPluginException(No implementation found for method getMD5 on channel md5_plugin)

按这个顺序排查:

  1. example/ohos/entry/src/main/ets/plugins/GeneratedPluginRegistrant.ets 里有没有 new Md5Plugin()?没有说明 pubspec.yaml 的 platforms.ohos 没生效,先 flutter clean 再构建一次;
  2. 有的话,检查 Md5Plugin.ets 里的通道名是不是 md5_plugin(少个下划线就匹配不上);
  3. 再检查 onAttachedToEngine 是否真的执行了——在方法里加一行 Log.i(TAG, ...),启动时看 hilog。

9.2 文件不存在时到底抛哪个异常

取决于平台实现:

平台行为Dart 侧看到的
Android(上游)getFileChecksum 返回 nullPlatformException('null_return')(适配时补的判空)
iOS(上游)返回 nil同上
OHOS(本次)fs.openSync 抛异常 → result.error("FILE_ERROR", ...)PlatformException('FILE_ERROR')

所以不要只 catch 一种 code,写成 on PlatformException catch (e) 再按 e.code 分支最稳。

9.3 大文件会不会卡住界面

不会。computeFileMd5 是 async,md.update() 每块都 await,ArkTS 侧的 IO 与摘要计算不占 Dart isolate。但主线程仍可能被 fs.readSync 的同步读影响,所以:

  • 单文件建议不超过几百 MB;
  • 需要算多个大文件时,请串行 await,不要并发发起多个 getMD5WithPath——每个调用都会自己分配 1MB 缓冲,并发 8 个就是 8MB 峰值外加 8 个打开的 fd。

9.4 能不能读沙箱外的文件

能不能读取决于宿主 App 有没有该路径的权限,插件本身不做任何权限申请。读应用沙箱内(/data/storage/el2/base/...)不需要额外权限;读用户目录(如相册、下载)需要申请对应的受限权限(ohos.permission.READ_MEDIA 等)并经用户授权,且路径必须是 URI 转换后的真实路径。

9.5 为什么 flutter test 在本机一直起不来

本机有两条路都走不通,原因不同:

  1. 纯 Dart 单测:flutter test 会拉起 flutter_tester,直接报 Invalid SDK hash。这是工具链与 SDK 版本对不上,与插件代码无关。
  2. 真机集成测试:flutter test integration_test/md5_plugin_test.dart -d <设备> 会在内部调 hvigorw assembleHap,而这一步又被本机的 NODE_OPTIONS safe-delete shim 与 wmic.exe 安全策略拦住,报 BUILD FAILED in 5 s。

所以本文的验证走的是「真机演示页 + 纯 Dart crypto 复算」这条路径,没有把任何测试结果写成「已通过」。两套测试文件(test/md5_plugin_test.dart、example/integration_test/md5_plugin_test.dart)都已提交,在工具链与安全策略正常的机器上可以直接执行;它们覆盖的正是本文在界面上逐个手测的那些边界(0 B / 1 B / 1 KB / 1MB±1)。

9.6 util.Base64Helper.getInstance() 编译报错

10505001 ArkTS Compiler Error
Property 'getInstance' does not exist on type 'typeof Base64Helper'

Base64Helper 不是单例,用构造函数:

const helper: util.Base64Helper = new util.Base64Helper();
const text: string = helper.encodeToStringSync(bytes);

9.7 hdc install 报 Error opening file: no such file or directory

hdc 会把传入的路径当相对路径拼到当前工作目录后面,Windows 绝对路径(D:\...)会被拼成 c:\...\D:\...。做法是先 cd 到 HAP 所在目录,再传文件名:

cd example/ohos/entry/build/default/outputs/default
hdc install -r entry-default-signed.hap

9.8 构建报 SAFE_DELETE_BULK_CONFIRM_REQUIRED

见 2.1:$env:NODE_OPTIONS="" 清空后再构建。这个错误的迷惑之处在于最后一行是 COMPILE RESULT:FAIL,会被误判成 ArkTS 语法问题。

9.9 返回值是 Base64 还是十六进制

Base64,24 字符。这是三端统一后的结论。判断方法:

final md5 = await Md5Plugin.getMD5WithPath(path);
assert(md5.length == 24);   // 十六进制会是 32

如果你手上有 md5sum 给出的 32 位十六进制值要做比对,先转换:

import base64
print(base64.b64encode(bytes.fromhex('90919845afb7fe5535eb3a38db5842d3')).decode())
# kJGYRa+3/lU16zo421hC0w==

9.10 为什么演示 App 的 bundleName 是 com.example.color_system_example

这批 lib2 的示例工程都复用了同一个 bundleName(com.example.color_system_example)和同一个调试签名,好处是配置一次就能反复装;代价是设备上后装的会覆盖前一个。如果启动后发现界面不是你以为的那个库,先 hdc install -r 重装本库的 HAP,不要怀疑代码。

相关链接

Logo

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

更多推荐