Flutter OHOS 插件移植实录:md5_plugin 原生 MD5 校验库适配,升级 Dart3 基线、MethodChannel 原生实现、对齐 Base64 返回格式
开发工具: 华为云码道
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。两个值其实是同一份摘要的两种编码,但任何做「服务端下发校验和、客户端比对」的业务都会直接判定失败。
这次适配的工作量集中在六处,其中四处是仓库里真实存在的缺陷:
- 把插件声明从
androidPackage迁移到platforms映射,同时注册ohos; - 把 SDK 约束从
>=2.1.0 <3.0.0抬到>=3.3.0 <4.0.0——否则新工具链根本不认这个包; - 用
@ohos.security.cryptoFramework+@ohos.file.fs实现 1MB 分块流式 MD5; - 对齐返回格式:改用
util.Base64Helper,与 Android/iOS 严格一致; - 修掉示例工程里编译不过的模板测试:
example/test/widget_test.dart还在pumpWidget(MyApp()),而MyApp在适配时已被Md5PluginDemoApp取代,flutter analyze直接报 error; - 删掉文档里一行不存在的 API:README 模板残留的
getPlatformVersion在本插件里从未实现,照文档调用只会得到MissingPluginException。
二、环境准备
环境搭建参考社区文档:Flutter OH 开发环境搭建,完成 Flutter OH SDK 安装、环境变量和 DevEco Studio 配置。
完成后,在宿主机终端执行以下命令,确认当前选中的是支持 OHOS 的 Flutter 工具链,并能发现目标设备:
flutter --version
flutter doctor -v
hdc list targets


2.1 开发套件版本与工程中的 SDK 版本配置
| 项目 | 版本 |
|---|---|
| Flutter | 3.47.7-ohos-1.0.0(oh-3.47-dev,framework revision 4b5928c43f) |
| Dart | 3.13.3 |
| DevEco Studio | 26.0.0(26.0.0.851) |
| OpenHarmony SDK | 6.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];
两边有三点一致、一点不一致:
| 维度 | Android | iOS | 结论 |
|---|---|---|---|
| 分块大小 | 8 KB | 32 KB | 与结果无关,只影响 IO 次数 |
| 摘要算法 | MessageDigest("MD5") | CC_MD5 | 一致 |
| 输出编码 | Base64 | Base64 | 必须一致 |
| 文件不存在 | 返回 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.yaml | sdk: ">=2.1.0 <3.0.0" | >=3.3.0 <4.0.0 | pub get 直接拒绝解析 |
pubspec.yaml | plugin.androidPackage + 单 pluginClass | plugin.platforms 映射 | 无法声明 ohos |
lib/md5_plugin.dart | var checksum = ... 直接 return | 判空后返回 | 空安全下 Future<String> 与 null 冲突 |
android/.../Md5Plugin.java | implements MethodCallHandler + Registrar | FlutterPlugin + onAttachedToEngine | v1 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
}
}
...
}
三个要点:
- 通道名必须与 Dart 侧完全一致(
md5_plugin),一个字符都不能错; onDetachedFromEngine里必须把 handler 置空,否则热重启后旧 handler 仍持有已销毁的引擎引用,表现为「第二次启动时方法调用无响应」;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)
}
}
三点设计取舍:
result只能回调一次。computeFileMd5是异步的,then/catch里各回调一次,但外层try/catch只能兜住「同步阶段」的异常(比如call.argument类型不对)。如果外层 catch 也回调错误,就会出现「同步抛错后异步又成功」的双回调,Flutter 侧会抛Reply already submitted。- 错误码分成三类(
INVALID_ARGUMENT/FILE_ERROR/PLUGIN_ERROR),比上游 Android 的「吞掉异常返回 null」信息量大得多。这与 Dart 层的null_return并不冲突:null_return只在 Android 那种老实现下出现,OHOS 走的是显式错误码。 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.OpenSource | Owner 改 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 复算」的形态,因为只有这样,屏幕上的数字才能自证:
| 动作 | 写入沙箱的文件 | 目的 |
|---|---|---|
| 计算文件 MD5 | cache/md5_demo_input.txt | 用文本内容算一次,验证基本路径 |
| 空文件 (0 B) | 同上,内容为空 | 验证 while (offset < total) 在 total=0 时不进循环 |
| 1KB | 1024 个 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。
预设切换的提示:
滚动到页面底部的实现说明:
| 文件 | 大小 | 原生(OHOS) | 纯 Dart crypto | 一致 |
|---|---|---|---|---|
| 默认文本 | 64 B | kJGYRa+3/lU16zo421hC0w== | 同值 | ✓ |
| 空文件 | 0 B | 1B2M2Y8AsgTpgAmY7PhCfg== | 同值 | ✓ |
| 重复字符 | 1,024 B | yaNM/IXZgmmMasifdgcavQ== | 同值 | ✓ |
| 大文件 | 5,242,880 B | TGwg1YfPDyTYMKXyB4nhtA== | 同值 | ✓ |
| 不存在的路径 | — | 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 差异),所以文章不对这个结论背书,只记录两点可观察到的事实:
- 5MB 文件点击后界面没有出现卡死,结果在数秒内出现——这数秒里还包含了演示页自己拼 5MB 字符串、写盘、以及纯 Dart 侧整文件复算的开销,并不全是插件耗时;
- 原生侧的内存占用与文件大小无关(固定 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)
按这个顺序排查:
example/ohos/entry/src/main/ets/plugins/GeneratedPluginRegistrant.ets里有没有new Md5Plugin()?没有说明pubspec.yaml的platforms.ohos没生效,先flutter clean再构建一次;- 有的话,检查
Md5Plugin.ets里的通道名是不是md5_plugin(少个下划线就匹配不上); - 再检查
onAttachedToEngine是否真的执行了——在方法里加一行Log.i(TAG, ...),启动时看 hilog。
9.2 文件不存在时到底抛哪个异常
取决于平台实现:
| 平台 | 行为 | Dart 侧看到的 |
|---|---|---|
| Android(上游) | getFileChecksum 返回 null | PlatformException('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 在本机一直起不来
本机有两条路都走不通,原因不同:
- 纯 Dart 单测:
flutter test会拉起flutter_tester,直接报Invalid SDK hash。这是工具链与 SDK 版本对不上,与插件代码无关。 - 真机集成测试:
flutter test integration_test/md5_plugin_test.dart -d <设备>会在内部调hvigorw assembleHap,而这一步又被本机的NODE_OPTIONSsafe-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,不要怀疑代码。
相关链接
- 原始仓库:fluttervn/md5_plugin
- 本文配套仓库:oh-flutter/md5_plugin
- 仓库 TAG:
1.0.2-ohos-1.0.0(提交3323038) - cryptoFramework 文档:MD 摘要
- 文件读写文档:@ohos.file.fs
- Base64 工具:@ohos.util.Base64Helper
更多推荐




所有评论(0)