开发工具: 华为云码道

DevEco Studio: 26.0.0(26.0.0.851)

原始仓库: siddastic/master_validator

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

master_validator 是一个「把校验规则做成可组合函数」的 Flutter 库:Validators.Required()、Validators.Email()、Validators.Number() 这些静态方法返回的都是 String? Function(String?)?,通过 next 参数串成一条校验链,第一条不通过的规则负责给出错误文案。它不含任何 UI,TextFormField.validator 直接接住返回值即可,一个字段要同时满足「必填 + 邮箱格式」时不需要写 if/else。本文以 master_validator 1.3.1 为例,介绍从源码准备、OHOS 工程配置、ArkTS 插件壳,到真机完成五种校验场景的完整过程。

在这里插入图片描述

插件目前支持 android、ios、linux、macos、windows、ohos 平台,源码位于 AtomGit 配套仓库。文中的代码以提交 10df3d8 为参考。

在这里插入图片描述


一、插件简介与适配目标

表单校验在 Flutter 里通常有三种写法:在页面里手写 if(最直白,但规则一多就散落各处)、引入声明式表单框架(reactive_forms 一类,能力和学习成本同时上升)、用纯函数拼装校验器(规则本身是 String? Function(String?),与框架解耦,既能塞进 Form,也能单独调用)。

五项全部合法,提交通过 五项全部合法,提交通过 五项全部合法,提交通过 五项全部合法,提交通过 五项全部合法,提交通过 五项全部合法,提交通过 五项全部合法,提交通过

master_validator 走第三条路。它的公开面很小:

  • Validators:11 个静态工厂方法——Required、Email、Number、LengthBetween、MinLength、MaxLength、Url、Regex、Equals、DirectoryName、FileName;
  • ValidStringExt:6 个 String 扩展开关,'a@b.com'.isEmail、'42'.isInteger、'abcd'.hasLengthBetween(3, 5) 直接返回 bool;
  • MasterValidatorHelpers.checkEmail:唯一一个公开的正则帮手,7 行;
  • MasterValidatorPlatform:联邦插件的平台接口,只有一个 getPlatformVersion()。

工程结构上,它是一个标准的 Flutter 联邦插件:业务逻辑全在 lib/validators.dart(488 行),ohos/ 下是 47 行的 ArkTS 插件壳,只响应 getPlatformVersion。没有任何业务通道——校验是纯 Dart 计算,不碰传感器、不碰文件、不碰网络。

这意味着鸿蒙适配的工作量集中在四处,其中两处是真实缺陷而不是配置:

  1. 把 ohos 平台注册进 pubspec.yaml;
  2. 补上漏声明的 plugin_platform_interface 依赖——不补,flutter analyze 直接报 undefined_identifier PlatformInterface;
  3. 修掉从模板复制过来、引用不存在类的测试文件;
  4. 修掉示例页里触发上游 assert 的参数组合,否则 Form.validate() 会抛异常、提交按钮看起来「没反应」。

适配后真机上的表现如下:

  • 空表单提交:只有 姓名 (Required) 下方出现 Field cannot be empty,其余四个可选字段不报错(这是库的刻意设计,见 4.3);
  • 五项填写合法后提交:底部弹出绿色 表单校验通过 ✔;
  • 邮箱改成 ada@example@163.com、年龄填 30.5:两条错误分别挂在对应字段下,其余字段不受影响。

二、环境准备

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

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

flutter --version
flutter doctor -v
hdc list targets

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

编辑用户

本机输出(节选):

Flutter 3.47.7-ohos-1.0.0 • channel oh-3.47-dev
Framework • revision 4b5928c43f
Dart 3.13.3

[✓] Flutter (Channel oh-3.47-dev, 3.47.7-ohos-1.0.0)
[✓] HarmonyOS toolchain - develop for HarmonyOS devices

hdc list targets 应能看到真机或模拟器的设备 ID,本文实测设备为 4UQ9K25508013016。

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

本次适配与验证使用的工作台版本:

项目版本
Flutter3.47.7-ohos-1.0.0(channel oh-3.47-dev)
Dart3.13.3
DevEco Studio26.0.0(26.0.0.851)
OHOS SDK6.1.1(24)
真机 ROMOpenHarmony-6.1.1.120,API 24
真机型号TLR-AL00

工程侧对应的是 example/ohos/build-profile.json5 里的两行:

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

在这里插入图片描述

compatibleSdkVersion 必须不高于真机 API 版本,否则安装阶段会直接报 device is not compatible。本仓库在适配提交 f30375e 里就对齐成了 6.1.1(24),与实测设备一致;targetSdkVersion 保持 26.0.0 不影响安装,它只表达「按哪个版本的行为运行」。


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

3.1 将上游源码同步到 AtomGit

上游仓库在 GitHub(siddastic/master_validator,MIT 协议),国内访问构建依赖时经常超时,所以适配版本统一镜像到 AtomGit 的 oh-flutter 组织,仓库名与包名保持一致:

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

同步方式与前几篇相同:在 AtomGit 新建空仓库,把上游 main 推上去,后续适配提交都打在 main 分支上。上游自己的 TAG 原样保留,适配 TAG 另起一个 1.3.1-ohos-1.0.0——这样 git diff v1.3.1 就能一眼看全本次适配的全部改动。

3.2 将代码拉取到宿主机

git clone https://atomgit.com/oh-flutter/master_validator.git
cd master_validator
ls

请添加图片描述

目录里能看到上游原本的 android/ios/linux/macos/windows/example/lib/test 结构,加上本次新增的 ohos,以及三份鸿蒙说明文档 README.OpenHarmony.md、README.OpenHarmony_CN.md、CHANGELOG.OpenHarmony.md 和一份 README.OpenSource。

顺带说一句 master_validator.iml:这是 IntelliJ 系列 IDE 的模块文件,上游一直留在仓库里,不影响构建,也不建议在适配提交里顺手删掉——删除它会制造一条与平台适配无关的 diff。

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

git branch --show-current
git tag --list
git log --oneline -3
git remote -v
git status --short --branch

请添加图片描述

三个适配提交的职责分别是:

提交内容
7cc87b8feat: adapt master_validator for OpenHarmony (ohos-1.0.0-beta.1),新增 ohos/、example/ohos/、三份文档,pubspec.yaml 注册 ohos 平台
f30375edocs: add OpenHarmony README and update build profile for example,补全文档并把 compatibleSdkVersion 对齐到真机 API
10df3d8fix(ohos): declare platform_interface dependency and repair template tests,补 plugin_platform_interface、重写模板测试、修示例页的 assert 崩溃

TAG 1.3.1-ohos-1.0.0 打在 10df3d8 上,对应的 Flutter 框架版本为 3.47,分支为 main。

3.4 自动补全 OHOS 适配结构

flutter create --template=plugin --platforms=ohos \
        --project-name master_validator .

这条命令会在仓库根目录生成 ohos/(HAR 形式的插件实现)和 example/ohos/(宿主工程)。因为它同时会重写 pubspec.yaml 的 flutter.plugin.platforms 段,所以执行完必须核对 diff:master_validator 上游已经有 android、ios、linux、macos、windows 五段,命令只会追加 ohos 一段。

请添加图片描述

真正的坑不在模板生成,而在生成之后第一次 flutter analyze。适配提交 7cc87b8 里就带着这个错误:

error - undefined_identifier - PlatformInterface
  - lib/master_validator_platform_interface.dart:22:5

原因是 master_validator_platform_interface.dart 第一行就 import 'package:plugin_platform_interface/plugin_platform_interface.dart';,并用 PlatformInterface 作为基类,但 pubspec.yaml 的 dependencies 里从未声明这个包:

dependencies:
  flutter:
    sdk: flutter
  plugin_platform_interface: ^2.0.2   # 本次补上

为什么上游缺了依赖也能发版?因为这个库的「业务代码」根本不走平台接口——validators.dart 只依赖 helpers.dart,而平台接口文件只在插件注册与 getPlatformVersion 时才会被 import。上游作者没有在自己的应用里调用版本查询,也没有在 CI 里跑 flutter analyze,所以这个缺失一直没暴露。

有两点需要说清楚。第一,没声明的包被直接 import 是编译错误(error),不是「用的时候才出事」的运行时问题,所以这条必须在提交前修掉。第二,这个缺失不是本次适配引入的:flutter create 在新工程里确实会写 plugin_platform_interface: ^2.0.2(可以查 flutter_tools/templates/plugin_shared/pubspec.yaml.tmpl),但对已有工程执行 flutter create --platforms=ohos .,它只往 flutter.plugin.platforms 里追加一段,不会回头补 dependencies。所以上游漏了什么,跑完 create 还是漏什么,只能靠 flutter analyze 兜住。

3.5 适配后的项目目录

请添加图片描述

关键文件与行数:

文件行数作用
lib/validators.dart48811 个组合式校验器的全部实现
lib/extensions.dart336 个 String 扩展,内部复用 Validators
lib/helpers.dart7checkEmail 正则帮手
lib/master_validator.dart5库入口,三行 export
lib/master_validator_platform_interface.dart29联邦接口 + PlatformInterface 校验
lib/master_validator_method_channel.dart19MethodChannel('master_validator')
ohos/src/main/ets/components/plugin/MasterValidatorPlugin.ets47ArkTS 插件壳,实现 getPlatformVersion
example/lib/main.dart158演示页:五个字段、实时校验开关、提交与重置
test/master_validator_test.dart93校验器行为与扩展的单测
test/master_validator_method_channel_test.dart26通道 mock 用例
example/test/widget_test.dart57示例页渲染与校验器组合断言

四、Dart 接口与实现分析

4.1 跨端架构与调用时序

master_validator 的结构可以概括成「一条纯 Dart 的校验链 + 一条只在查版本时才用到的通道」:

master_validator(纯 Dart)

业务代码

validator:

next

next

OHOS 原生

MasterValidatorPlatform

MethodChannel
master_validator

MasterValidatorPlugin.ets

getPlatformVersion

Form + TextFormField

isEmail / isInteger 等扩展

Validators.Required

Validators.Email

Validators.Number

MasterValidatorHelpers
checkEmail 正则

ValidStringExt

图里右下角那条通道与校验无关:MasterValidatorPlatform.instance.getPlatformVersion() 是唯一会真正跨越 Dart/ArkTS 边界的调用,其余全部在 Dart VM 内完成。

4.1.1 一次 Form.validate() 的时序
checkEmail Email Required TextFormField FormState 提交按钮 checkEmail Email Required TextFormField FormState 提交按钮 _formKey.currentState.validate() 逐个字段调用 validator(value) validator(' ') value.trim().isEmpty → true 'Field cannot be empty' validator('ada@example') next('ada@example') checkEmail('ada@example') false 'Invalid email' 收集所有字段的错误 false,界面显示 errorText

关键点在于:Form.validate() 会遍历所有字段并逐个调用 validator,不会在第一个错误处短路。所以「哪个字段报什么错」完全由每个字段各自的校验链决定,字段之间互不影响——真机截图里能同时看到邮箱和年龄两条错误,就是这个机制的直接结果。

4.2 11 个校验器的真实签名

所有工厂方法返回的都是同一个类型:String? Function(String?)?——可空的函数。这个「可空」是理解本库很多怪现象的前提。

校验器必填参数默认错误文案空值行为
Required—Field cannot be empty报错
Email—Invalid email放行
Number—Invalid number放行
LengthBetween(min, max)min、maxField Should be atleast *min_len* and at most *max_len* characters long放行
MinLength(length:)lengthMinimum length is *min_len*放行
MaxLength(length:)lengthMaximum length is *max_len*放行
Url—Invalid url放行
Regex(pattern:)patternInvalid value放行
Equals(value:)valueValue matches to the given value放行
DirectoryName—Invalid Directory Name放行
FileName—Invalid File Name放行

每个方法都接受 String? Function(String value)? next,这就是「链」的实现方式:

static String? Function(String? value)? Required({
  String errorMessage = 'Field cannot be empty',
  String? Function(String value)? next,
}) {
  return (value) {
    if (value == null || value.trim().isEmpty) {
      return errorMessage;
    }
    if (next != null) {
      return next(value);
    }
    return null;
  };
}

next 的类型是 String? Function(String)?(参数非空、返回值可空),而当前校验器接收的是 String?。语义上是「本关通过之后,值一定非空,再交给下一关」——Dart 的空安全把这件事编码进了类型里。

4.3 链式语义与「空值放行」

除 Required 之外,所有校验器遇到空字符串或纯空白都返回 null(视为通过)。 这是刻意的设计,不是疏漏:

// Email
if (value == null || value.trim().isEmpty) {
  return null;
}

它带来的效果是:可选字段不需要额外判断。一个「可以不填、填了就必须是邮箱」的字段,只写 Validators.Email() 就够了——留空时它放行,有值时才校验格式。如果想要「必填且必须是邮箱」,才需要串起来:

Validators.Required(next: Validators.Email())

这一条在真机上能直接看到:空表单提交时,只有姓名下面出现 Field cannot be empty,邮箱、年龄、主页、密码四个字段干干净净(见 8.6 第一组第二张图)。

链的短路顺序也值得记一下:

输入Required(next: Email()) 的结果原因
''Field cannot be emptyRequired 拦下,next 不执行
' 'Field cannot be emptytrim() 后为空
'hello'Invalid emailRequired 通过,交给 Email
'a@b.com'null两关都通过

注意 Required 只在判空时 trim,通过之后传给 next 的是原始值(next(value),不是 next(value.trim()))。所以 ' a ' 会带着空格进入下一关——Required(' a ') 返回 null,但如果后面挂 Regex(pattern: r'^\S+$'),就会因为空格被判为非法。需要修剪业务数据时,应在 onSaved 或提交时自己 trim(),不要指望校验器代劳。

4.4 Number 的 assert:一个会吞掉提交的坑

Number 有三个开关:integerOnly、allowNegative、allowDecimal。它们的默认值是 false / true / true,并且在方法体最开始有一条断言:

assert(
    !integerOnly || (integerOnly && !allowDecimal),
    'integerOnly and allowDecimal cannot both be true. '
    'If you want to allow decimal numbers, set integerOnly to false');

去掉括号里的恒等变换,它的意思是:integerOnly: true 时必须同时给 allowDecimal: false,否则断言失败。

这条断言在 debug 构建下是开启的。示例页最初就是这么写的:

validator: Validators.Number(integerOnly: true, allowNegative: false)?.call(v),

allowDecimal 取了默认值 true,于是点「提交」时 Form.validate() 在第一个字段就抛 Assertion failed,异常一路冒到 onPressed 之外,后面的 ScaffoldMessenger.showSnackBar 根本执行不到。现象是点了按钮什么都没有——不是校验没生效,是整段逻辑中断了。

排查时一个有效信号是 hilog:Flutter 的异常会打到日志里,关键字是 Assertion failed。本次就是先从日志确认断言失败,再回头读源码找到这条 assert,而不是盲猜 UI 问题。

修法是显式补齐两个参数:

validator: (v) => Validators.Number(
        integerOnly: true,
        allowDecimal: false,
        allowNegative: false)
    ?.call(v),

另外要提醒一句 release 行为:release 构建会剥掉 assert,同一份代码不会崩,但 !allowDecimal && value.contains('.') 这条判断依然生效——也就是说 integerOnly: true 配 allowDecimal: true 在 release 下「不崩但行为反直觉」(整数约束靠 int.tryParse 生效,小数仍被拒),在 debug 下直接崩。别依赖这个差异,按断言要求写就对。

顺带纠一个示例页的文案:Number 没有范围校验,它只判断「是不是数字」。所以年龄字段标注的是「仅整数」,如果要限制 1–120,得再挂一个 Regex 或自己写范围判断,别指望 Number 自带上下限。

4.5 Equals:实现与文档相反的一个上游缺陷

Equals 的注释写着「checks if a string equals to given string. It can be used in specific cases like password confirmation」,默认错误文案是 Value matches to the given value。但实现是这样的:

if (v == value) {
  return errorMessage;
}

相等时返回错误,不等时返回 null。 也就是说它校验的是「不相等」。拿它做「两次密码是否一致」会得到完全相反的结果:输入一致时提示错误,输入不一致时反而通过。

本次用探针实测确认,不是读代码的推测:

Equals(abc) <- abc -> Value matches to the given value
Equals(abc) <- xyz -> null

适配时没有改上游代码——改掉它意味着所有依赖「反向行为」的老用户会在升级后集体翻转逻辑,这种破坏性改动不属于平台适配的范围。正确做法是不用它做相等校验,自己包一层:

final confirmValidator = (String? v) =>
    (v ?? '') == password ? null : '两次输入的密码不一致';

或者用 Regex + 转义拼一个完整匹配的正则。这条已经写进 README.OpenHarmony_CN.md 的「已知问题」。

4.6 正则类校验器的边界行为

Email、Url、DirectoryName、FileName 都基于 RegExp(...).hasMatch(value)。hasMatch 的语义是「字符串中存在匹配」,不是「整体匹配」——只有写了 $ 才算整体。这一点决定了它们的实际放行范围,本次逐条实测:

输入结果原因
a@b.com通过标准形态
a@bInvalid email正则要求 @ 后必须有点号和 TLD
ada@example@163.comInvalid email@ 后出现第二个 @,不匹配
a@b.com!!!通过正则没写 $,前缀命中即放行
atomgit.com通过(Url)协议部分是可选的
https://atomgit.com通过(Url)标准形态
HTTPS://ATOMGIT.COMInvalid url正则无 i 标志,大小写敏感
https://a.museum通过(Url)[a-z]{2,5} 前缀匹配到 .muse
report.png通过(FileName)主名与扩展名都合法
report.exe + extensionWhereIn: ['png','jpg']Invalid File Name扩展名不在白名单
归档.pngInvalid File Name文件名正则只允许 [a-zA-Z0-9-_]

FileName 还有一个容易误解的点:它只校验第一个点之前的部分:

if (!RegExp(r'^[a-zA-Z0-9-_]+$').hasMatch(v.split('.').first)) {
  return errorMessage;
}

所以 report.tar.gz 是合法的(主名 report,扩展名取 v.split('.').last 即 gz)。extensionWhereIn 的比较也是大小写敏感的字符串比较,PNG 不会匹配 png。

这些边界不是 bug,但会直接影响业务判断。把「邮箱是否真的合法」完全托付给 validators.dart 里的正则是不稳妥的——真正注册时仍应以服务端校验为准,客户端这一层只做「明显不合法」的拦截。

4.7 六个 String 扩展

extensions.dart 把常用判断包成了属性/方法,内部统一走 Validators,因此语义与上表完全一致:

extension ValidStringExt on String {
  bool get isEmail =>
      Validators.Required(next: Validators.Email())!(this) == null;

  bool get isInteger =>
      Validators.Required(
              next: Validators.Number(integerOnly: true, allowDecimal: false))!(this) ==
          null;

  bool get isDouble => ...;

  bool hasLengthBetween(int min, int max) =>
      Validators.LengthBetween(min, max)!(this) == null;
}

两个细节:

  • 扩展统一套了 Required,所以空字符串一律返回 false——与 Validators.Email()('') 的「放行」正好相反。''.isEmail 是 false,Validators.Email()!('') 是 null;
  • isInteger 正确地带上了 allowDecimal: false(说明作者自己知道那条断言),但 isDouble 又显式传 integerOnly: false, allowDecimal: true,属于冗余但无害的写法。

真机验证里没有直接用扩展,而是通过 Dart 探针覆盖('42'.isInteger == true、'4.2'.isInteger == false、'abcd'.hasLengthBetween(3, 5) == true),因为它们与 UI 无关,不需要占用截图。

4.8 平台接口层:为什么它几乎是空的

MasterValidatorPlatform 是标准联邦插件写法:单个 _instance、verifyToken 校验、getPlatformVersion() 抛 UnimplementedError 作为兜底。MethodChannelMasterValidator 只实现那一个方法,通道名 master_validator。

它是注册是否成功的唯一可观测信号:如果 GeneratedPluginRegistrant.ets 里没有 new MasterValidatorPlugin(),校验功能照常工作(纯 Dart),但业务里真去查版本会拿到 MissingPluginException。

至于校验为什么不需要平台分支:RegExp、int.tryParse、num.tryParse、String.trim 都是 Dart 核心库能力,由 Dart VM/引擎统一实现,与宿主系统无关。这个库在鸿蒙上「行为完全一致」不是一句套话,而是由实现方式决定的——真正需要真机验证的只有 TextFormField 的渲染、软键盘与错误文案布局,这些属于 Flutter 框架层。

4.9 为什么要写 !,而 analyzer 又说不需要

Validators.Required() 的返回类型是 String? Function(String?)?,是一个可空的函数。直接交给 TextFormField.validator 没问题,因为 FormField.validator 参数本身就是可空的(FormFieldValidator<T>?)。

但只要你自己调用它,就必须处理可空:

validator('')          // 编译错误:不可调用可空函数
validator!('')         // 正确
validator?.call('')    // 正确,返回 String?

问题出在 analyzer 上:对于这种「可空函数类型」,! 会被误报成 unnecessary_non_null_assertion,可一旦按提示删掉,编译立刻失败:

Can't use an expression of type 'String? Function(String?)?'
as a function because it's potentially null

本次的处理是在测试文件顶部加一条针对性忽略,并写明原因,而不是删掉 !:

// ignore_for_file: unnecessary_non_null_assertion

示例页则改用 ?.call(v) 的写法,既不需要 !,也不会触发误报——这是业务代码里更顺手的形态。


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

5.1 在 MasterValidatorPlugin.ets 中补全插件壳

生成的模板只有空类,需要补成完整的 FlutterPlugin + MethodCallHandler:

import deviceInfo from '@ohos.deviceInfo';
import {
  FlutterPlugin,
  FlutterPluginBinding,
  MethodCall,
  MethodCallHandler,
  MethodChannel,
  MethodResult,
} from '@ohos/flutter_ohos';

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

  constructor() {
  }

  getUniqueClassName(): string {
    return "MasterValidatorPlugin"
  }

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

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

  onMethodCall(call: MethodCall, result: MethodResult): void {
    try {
      if (call.method == "getPlatformVersion") {
        // Mirrors Android ("Android <release>") / iOS ("iOS <systemVersion>"):
        // return "<os> <version>". osFullName is "OpenHarmony-x.x.x.x".
        result.success(`OpenHarmony ${deviceInfo.osFullName}`);
      } else {
        result.notImplemented()
      }
    } catch (e) {
      result.error("PLUGIN_ERROR", `master_validator: failed to handle ${call.method}: ${JSON.stringify(e)}`, null)
    }
  }
}

三点说明:

  • 通道名必须与 Dart 侧一致。 Dart 里是 MethodChannel('master_validator'),这里也必须是 "master_validator";
  • 返回值格式对齐 Android/iOS。 deviceInfo.osFullName 的形态是 OpenHarmony-6.1.1.120,拼上前缀后为 OpenHarmony OpenHarmony-6.1.1.120——看起来重复,但与 Android 返回 Android 14 的「系统名 + 版本」约定是一致的;
  • try/catch 是必要的。 deviceInfo 属于系统服务,异常要兜成 result.error,否则 Dart 侧会一直等回包。

5.2 声明插件和宿主权限

ohos/src/main/module.json5 只有最基础的三段:

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

没有 requestPermissions,这是正确的:插件只读一次 deviceInfo.osFullName,属于系统公开信息,不需要授权;校验逻辑完全没有系统能力依赖。

宿主工程 example/ohos/entry/src/main/module.json5 里有一条 ohos.permission.INTERNET,那不是本库要的,而是 Flutter debug 引擎需要——热重载与 DevTools 走网络。正式打包 release 时应按业务实际需要决定是否保留。

5.3 注册并导出插件

ohos/index.ets 负责把实现类导出给 Flutter 工具链识别:

import MasterValidatorPlugin from './src/main/ets/components/plugin/MasterValidatorPlugin';
export default MasterValidatorPlugin;

pubspec.yaml 里对应一段:

flutter:
  plugin:
    platforms:
      ohos:
        pluginClass: MasterValidatorPlugin

三者名字必须完全一致:pubspec.yaml 的 pluginClass → ohos/index.ets 的默认导出 → 类名。构建期 flutter build hap 会据此生成 GeneratedPluginRegistrant.ets,本次构建产物内容为:

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

IntegrationTestPlugin 是 integration_test 依赖自动带进来的,正式应用里不会有。看到 new MasterValidatorPlugin() 这一行,就说明注册链路是通的——这一条不需要真机也能验证,看构建产物即可。

5.4 检查 example 的 OHOS 应用结构

example/ohos 是标准的 HarmonyOS 工程:AppScope/app.json5 定义包名与版本,entry/ 是主模块。需要核对的字段:

// AppScope/app.json5
{
  "app": {
    "bundleName": "com.example.color_system_example",
    "versionCode": 1000000,
    "versionName": "1.0.0"
  }
}
// build-profile.json5
"compatibleSdkVersion": "6.1.1(24)",
"targetSdkVersion": "26.0.0",

bundleName 是从 color_system 示例模板复制过来的,没有改成 com.example.master_validator_example。这不影响构建和运行,但有两个实际影响:

  1. 签名用的 Profile 里登记的包名必须与 bundleName 一致,否则 hdc install 报 signature verify failed;
  2. 同一台设备上装 color_system、is_valid、json_translation、master_validator 这些示例时会互相覆盖(包名相同)。

本次真机验证就撞上了第 2 点:设备上原本装的是前一篇的示例,新 HAP 一装就把它顶掉了。判断「当前跑的是不是本库」的可靠办法是看首屏标题(本篇是 master_validator Demo),不要靠「我刚刚装过」的记忆。排查「界面和代码对不上」时,先确认装的是哪个 HAP,再去怀疑代码。


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

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

文件内容
README.OpenHarmony_CN.md中文接入说明:依赖方式、TAG 表、环境版本、11 个校验器的完整表格、已知问题
README.OpenHarmony.md同上的英文版本
CHANGELOG.OpenHarmony.md适配变更记录,按 TAG 分段
README.OpenSource上游来源(siddastic/master_validator)、MIT 协议、镜像地址
example/README.md示例工程的独立运行说明与演示点清单

10df3d8 这次提交修正了五类实质问题,都是适配初期留下的:

  • 编译级:plugin_platform_interface 未声明(见 3.4);
  • 测试失效:test/master_validator_test.dart 里的 MasterValidator 类根本不存在,example/test/widget_test.dart 与 integration_test 里引用的还是模板的 MyApp——这类文件能「通过存在性检查」,但跑起来必挂;
  • 运行期崩溃:示例页的 Number(integerOnly: true) 触发上游 assert(见 4.4);
  • 文案错误:年龄字段原本写「Number, 1-120」,而 Number 没有范围校验,改成「仅整数」;
  • 文档坐标:CPF-Flutter → oh-flutter、TAG 从 -beta.1 收敛为正式版、分支 master → main、环境版本改成真机实测值。

6.2 提交前检查

flutter pub get
flutter analyze
git status --short

flutter analyze 在插件目录与 example 目录要分别跑。本次结果:

  • 插件 + example 一起分析:4 issues found.,0 error、0 warning,全是 info;
  • 这 4 条全部落在上游自带的 example/lib/widgets/ 里:input.dart 3 条 deprecated_member_use(withOpacity 已废弃)、space.dart 1 条 strict_top_level_inference。

处理原则与前面几篇一致:上游代码的风格问题不动,自己写的代码不留提示。example/lib/widgets/input.dart 是上游仓库 1.3.1 就存在的组件,改它会让适配 diff 里混进与平台无关的改动;而 validators.dart、extensions.dart、test/、example/lib/main.dart 这些「本仓维护」的部分,已经做到零提示。

6.3 提交并推送到 AtomGit

git add -A
git commit -m "fix(ohos): declare platform_interface dependency and repair template tests"
git tag 1.3.1-ohos-1.0.0
git push origin main
git push origin 1.3.1-ohos-1.0.0

适配初期曾打过一个 ohos-1.0.0-beta.1 标签并推到远端,正式版定型后需要清掉,避免使用者照着旧名字拉代码:

git tag -d ohos-1.0.0-beta.1
git push origin :refs/tags/ohos-1.0.0-beta.1

推送后用 git ls-remote --tags origin 确认远端只剩正式标签:

10df3d83ad77f938079ea3c2cd7f02fef69d9ef6  refs/tags/1.3.1-ohos-1.0.0

TAG 名遵循 上游版本-ohos-1.0.0 的格式,1.0.0 是适配迭代号,后续适配修复用 ohos-1.0.1 递增,不要改上游版本号。


七、使用根目录 example 演示接入

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

example/pubspec.yaml 默认就是路径依赖,改完插件代码立刻生效:

dependencies:
  flutter:
    sdk: flutter
  master_validator:
    path: ../
cd example
flutter pub get

7.2 通过 AtomGit 引入插件

真实项目里用 TAG 锁定版本:

dependencies:
  master_validator:
    git:
      url: https://atomgit.com/oh-flutter/master_validator
      ref: 1.3.1-ohos-1.0.0

ref 也可以用分支名(main),但生产环境建议用 TAG,避免上游一提交就解析出不同的代码。

7.3 五个字段对应的校验器

示例页把 11 个校验器里最常用的 5 个放进了同一个 Form:

字段校验器演示的能力
姓名Validators.Required()必填、trim 后判空
邮箱Validators.Email()空值放行 + 格式校验
年龄Validators.Number(integerOnly: true, allowDecimal: false, allowNegative: false)整数、禁负、小数拒绝
个人主页Validators.Url()协议可选
密码Validators.MinLength(length: 6)最短长度 + 文案占位符替换
TextFormField(
  controller: _emailCtrl,
  keyboardType: TextInputType.emailAddress,
  decoration: const InputDecoration(
    labelText: '邮箱 (Email)',
    prefixIcon: Icon(Icons.email),
    border: OutlineInputBorder(),
  ),
  validator: Validators.Email(),
),

validator 直接接工厂方法的返回值——因为 FormField.validator 参数本身可空,这里不需要 !(见 4.9)。

7.4 提交流程与结果提示

void _submit() {
  final ok = _formKey.currentState?.validate() ?? false;
  ScaffoldMessenger.of(context).showSnackBar(SnackBar(
    content: Text(ok ? '表单校验通过 ✔' : '表单存在错误,请检查输入'),
    backgroundColor: ok ? Colors.green : Colors.red,
  ));
}

validate() 返回 bool,同时会让每个 TextFormField 显示各自的 errorText。两条信息是互补的:SnackBar 给整体结论,字段下方的红字给定位。

7.5 实时校验开关与重置

AppBar 上有一个开关,控制 Form.autovalidateMode:

Switch(
  value: _autoValidate,
  onChanged: (v) => setState(() => _autoValidate = v),
),
Form(
  key: _formKey,
  autovalidateMode: _autoValidate
      ? AutovalidateMode.always
      : AutovalidateMode.disabled,
  child: ListView(...),
)

AutovalidateMode.always 会在每次输入后立刻重跑校验;disabled 则只在 validate() 时校验。两者对业务的影响很实际:实时校验适合「边填边提示」,但错误文案会在用户还没输完时就跳出来,体验上要权衡。

「重置」按钮调用 _formKey.currentState?.reset(),它会清空各字段的值与错误状态,但不会重置 _autoValidate——这是示例页的刻意选择,方便在同一个开关状态下反复试。


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

8.1 分别验证插件与 example

本机 flutter test 依旧跑不起来,测试套件连加载阶段都过不了:

00:00 +0 -2: loading test/master_validator_test.dart [E]
  Failed to load "test/master_validator_test.dart":
  Connection closed before test suite loaded.

这是本机工具链问题(与此前几篇 Invalid SDK hash 同一类),不是库的问题。所以验证走两条路:

一、dart run 探针复算纯逻辑。 在 example 目录写临时脚本,把 11 个校验器和 6 个扩展逐个调用,共 29 组断言,全部通过:

--- 链式 ---
PASS  Required("") -> Field cannot be empty
PASS  Required("   ") -> Field cannot be empty
PASS  Required(hello) -> Invalid email
PASS  Required(a@b.com) -> null
--- 空值放行 ---
PASS  Email("") -> null
PASS  Url("") -> null
PASS  MinLength("") -> null
--- Number ---
PASS  Number(12.5) -> null
PASS  Number(int,12.5) -> Invalid number
PASS  Number(noNeg,-1) -> Invalid number
PASS  Number(int,42) -> null
--- 长度 ---
PASS  LengthBetween(ab) -> Field Should be atleast 3 and at most 5 characters long
PASS  MinLength(12345) -> Minimum length is 6
PASS  MaxLength(abcdef) -> Maximum length is 5
--- 文件名 ---
PASS  FileName(report.png) -> null
PASS  FileName(ext,report.exe) -> Invalid File Name
PASS  DirectoryName(a_b-c) -> null
PASS  DirectoryName(a b) -> Invalid Directory Name
--- 上游 Equals 的真实行为 ---
PASS  Equals(abc) <- abc -> Value matches to the given value
PASS  Equals(abc) <- xyz -> null
--- 扩展 ---
PASS  a@b.com.isEmail -> true
PASS  42.isInteger -> true
PASS  4.2.isInteger -> false
PASS  abcd.hasLengthBetween(3,5) -> true

其中 Equals 那两行是本节最有价值的输出——它把「实现与文档相反」这件事从读代码的推测变成了可复现的事实。探针跑完立即删除,不留在仓库里。

二、真机跑一遍。 TextFormField 的 errorText 渲染、五个字段的纵向布局、软键盘弹起时是否溢出,这些在 Dart 探针里都覆盖不到,必须上设备。

补进仓库的三个测试文件是这样写的:

  • test/master_validator_test.dart:默认平台实例断言、getPlatformVersion 走平台实现、Required 链式、空值放行、Number 三个开关、LengthBetween 占位符替换、FileName 扩展名白名单、isEmail/isInteger/isDouble/hasLengthBetween;
  • test/master_validator_method_channel_test.dart:用 setMockMethodCallHandler 模拟通道返回 '42',验证 MethodChannelMasterValidator 的封送;
  • example/integration_test/plugin_integration_test.dart:真机上断言 getPlatformVersion() 非空且以 OpenHarmony 开头,再断言 Required(next: Email()) 在设备上的行为与单测一致。

三个文件都按真实 API 写,但在本机无法执行——文章不把它们写成「已通过」,只写「真机手工验证了同样的行为,纯逻辑由 dart run 探针覆盖」。

8.2 确认设备连接

hdc list targets
hdc -t 4UQ9K25508013016 shell param get const.ohos.apiversion
hdc -t 4UQ9K25508013016 shell param get const.product.model
hdc -t 4UQ9K25508013016 shell param get const.ohos.fullname

本机返回:

4UQ9K25508013016
24
TLR-AL00
OpenHarmony-6.1.1.120

API 24 与 compatibleSdkVersion: "6.1.1(24)" 对应。

8.3 配置签名

example/ohos/build-profile.json5 里已有 debug 签名配置(signingConfigs 的 material 段指向本机的 .cer 与 .p7b)。这份配置只在本机有效,换机器构建需要重签,或在 DevEco Studio 里点 File > Project Structure > Signing Configs > Automatically generate signature。

路径里的 //// 不是笔误,是 JSON 转义后的 \\,DevEco 生成时就长这样。

8.4 构建 HAP

flutter build hap --debug 在本机可以跑通,前提是清掉 NODE_OPTIONS 并给 PATH 补上 jbr\bin。

在 Bash 里直接跑,flutter 调用 hvigorw 的阶段会失败,原因是 WorkBuddy 注入的 safe-delete shim 会在批量清理构建缓存时触发策略拦截。改到 PowerShell 里执行:

$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
Set-Location "D:\flutter\lib2\master_validator\example"
& "D:\flutter\flutter_flutter\bin\flutter.bat" build hap --debug

三个要点:

  • 必须先做一次 ohpm install。 在 example/ohos 下执行 "D:\Program Files\Huawei\DevEco Studio\tools\ohpm\bin\ohpm" install,否则 flutter build hap 可能在 ohpm install failed 处卡住,并伴随 00306053 Specification Limit Violation 之类的报错;
  • $env:PATH 里要加 jbr\bin,不是只加 jbr。 只设 JAVA_HOME 而不加 bin 会得到 spawn java ENOENT,看起来像 ArkTS 语法错误,实际只是找不到 java.exe;
  • 构建末尾可能因调用 wmic.exe 被拦截而返回非零退出码,但此时 HAP 已产出,以产物文件为准。

本次产物:

example/build/ohos/hap/entry-default-signed.hap    103,498,868 字节(约 98.7 MiB)

安装并启动:

cd example/build/ohos/hap
hdc -t 4UQ9K25508013016 install -r entry-default-signed.hap
hdc -t 4UQ9K25508013016 shell aa start -a EntryAbility -b com.example.color_system_example

8.5 在设备上测试校验行为

测试步骤(每步之间用 uitest dumpLayout 确认控件坐标,不要凭截图目测):

  1. 启动应用,确认五个字段与「提交」「重置」按钮完整渲染,标题为 master_validator Demo;
  2. 不填任何内容直接点「提交」,确认只有「姓名」下方出现 Field cannot be empty,底部弹出红色 表单存在错误,请检查输入;
  3. 依次填入 Ada、ada@example.com、30、https://atomgit.com、secret123,确认五项都无错误提示;
  4. 再点「提交」,确认底部变成绿色 表单校验通过 ✔;
  5. 把邮箱改成 ada@example@163.com(多点一个 @),提交,确认邮箱下方出现 Invalid email;
  6. 把年龄改成 30.5,确认整数校验器给出 Invalid number;
  7. 点「重置」,确认五个字段清空、错误提示消失;
  8. 点密码框聚焦,确认软键盘弹起时页面无 overflow 条纹。

坐标这一环值得单独提一句:设备分辨率 1084×2412,而在对话里预览截图时会被缩到约 485 宽,按「看起来的位置」去点会系统性偏小约 2.2 倍,结果点到相邻控件上。可靠做法是 uitest dumpLayout 拿 bounds 再算中心点——本次「提交」按钮在键盘收起时的实测中心是 (286, 1542);键盘弹起时同一按钮会整体上移,坐标必须重新取,不能沿用。

这几步里第 2 步和第 6 步最容易漏:第 2 步验证的是「非 Required 校验器放行空值」这一设计,第 6 步验证的是 Number 的整数约束。两个行为光看代码不容易建立信心,真机上跑一遍就清楚了。

8.6 鸿蒙设备运行效果

左图是启动后的初始状态:五个字段、AppBar 右侧的「实时校验」开关、底部并排的「提交」「重置」。中图是空表单直接提交的结果——只有「姓名」下面出现 Field cannot be empty,邮箱、年龄、主页、密码四行依旧是干净的灰色边框,这正是 4.3 说的「空值放行」。底部红色 SnackBar 是整体结论。右图是五项都填好之后的状态,字段边框回到默认色,没有任何错误文案。

左图是第 4 步的结果:表单校验通过 ✔,SnackBar 变绿。中图把邮箱改成 ada@example@163.com,只有邮箱这一行变红并给出 Invalid email,其余四个字段的错误状态没有被牵连——Form.validate() 是逐字段独立调用的。右图把年龄从 30 改成 30.5,整数校验器立即给出 Invalid number,同一帧里邮箱的错误也还在,说明两条规则互不干扰。

五项全部合法,提交通过 五项全部合法,提交通过 五项全部合法,提交通过 五项全部合法,提交通过 五项全部合法,提交通过 五项全部合法,提交通过 五项全部合法,提交通过

最后一张是密码框聚焦、软键盘弹起时的布局。ListView 会随可视区域收缩,五个字段与按钮都还在,没有出现 Flutter 常见的黄黑 overflow 条纹——这一点在 OHOS 上值得单独确认一次,因为软键盘高度由系统决定,与 Android/iOS 并不相同。

七张图连起来,覆盖了四条链路:空值语义(只有 Required 拦)、格式校验(Email 与 Number 各自定位到字段)、整体结论(红/绿 SnackBar)、布局与 IME(键盘弹起不溢出)。


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

9.1 Undefined name 'PlatformInterface' / undefined_identifier

error - undefined_identifier - PlatformInterface
  - lib/master_validator_platform_interface.dart:22:5

pubspec.yaml 漏了 plugin_platform_interface。补上即可:

dependencies:
  flutter:
    sdk: flutter
  plugin_platform_interface: ^2.0.2

这是上游 pubspec.yaml 漏声明的依赖,而 flutter create --platforms=ohos . 不会替已有工程补 dependencies(它只追加平台段)。所以这类错误在适配里很容易被「反正 create 过了」的心态跳过。适配任何联邦插件时,先跑一次 flutter analyze 比先跑构建更省时间——构建报的是 ArkTS 侧的错误,往往掩盖了 Dart 侧的编译问题。

9.2 点「提交」没有任何反应,也没有 SnackBar

先看日志有没有 Assertion failed。本次的根因是 Validators.Number(integerOnly: true) 没有同时给 allowDecimal: false,断言在 Form.validate() 里抛出,异常中断了 onPressed 的后续逻辑。

按 4.4 补齐参数即可。排查顺序建议是:日志 → 校验器参数 → UI 代码。反过来先改 UI,很容易在错误的方向上绕圈。

9.3 空的非必填字段为什么没有报错

这是设计而非 bug:除 Required 外,所有校验器对空字符串/纯空白返回 null。想要必填就串 Required:

Validators.Required(next: Validators.Email())

9.4 用 Equals 做密码确认,结果正好相反

上游实现是「相等才报错」,与注释和默认文案的意图相反(见 4.5)。不要用它做相等校验,自己写:

final confirmValidator = (String? v) =>
    (v ?? '') == password ? null : '两次输入的密码不一致';

9.5 邮箱明明不合法却通过了

Email 用的是 RegExp(...).hasMatch(),正则只锚定了开头(^),没有 $,所以 a@b.com!!! 这种「前缀合法 + 尾巴乱写」的输入会通过。业务上如果需要严格匹配,可以在 Email 之后再挂一个 Regex:

Validators.Required(
  next: Validators.Email(
    next: Validators.Regex(pattern: r'^[\w.+-]+@[\w-]+\.[\w.]+$'),
  ),
)

9.6 Url 拒绝了大写域名

正则没有 i 标志,HTTPS://ATOMGIT.COM 会被判为非法。输入前统一转小写,或自己写一条带 caseSensitive: false 的正则:

RegExp(r'^https?://', caseSensitive: false).hasMatch(value)

9.7 中文文件名过不了 FileName

文件名主名部分的正则是 ^[a-zA-Z0-9-_]+$,只接受 ASCII 字母、数字、下划线和连字符。归档.png 必然失败。如果业务需要中文文件名,不要用 FileName,自己写一条允许 Unicode 的正则。

9.8 自定义 errorMessage 后占位符没有被替换

LengthBetween、MinLength、MaxLength 的占位符替换发生在工厂方法内部,且只替换 *min_len*、*max_len* 这两个标记:

if (errorMessage.contains('*min_len*')) {
  errorMessage = errorMessage.replaceAll('*min_len*', min.toString());
}

所以自定义文案里写了这两个标记才会被替换,写 {min}、%d 之类都不会。想让提示里带边界值,就照它的标记写;否则干脆自己拼字符串:

Validators.MinLength(length: 6, errorMessage: '密码至少 6 位')

9.9 MissingPluginException(No implementation found for method getPlatformVersion on channel master_validator)

说明插件没注册成功。按顺序排查:

  1. example/ohos/entry/src/main/ets/plugins/GeneratedPluginRegistrant.ets 里有没有 new MasterValidatorPlugin();没有就重新 flutter pub get + 重新构建(该文件是构建期生成的);
  2. pubspec.yaml 的 ohos.pluginClass 与 ohos/index.ets 的默认导出、类名是否三者一致;
  3. MasterValidatorPlugin.ets 里的通道名是否也是 "master_validator";
  4. 安装后是否真的启动了新包(bundleName 相同导致被别的示例覆盖时,跑的其实是另一个应用)。

这个异常不影响任何校验功能,因为校验全在 Dart 层。它只会在业务代码真的去查版本时出现。

9.10 uitest uiInput text 输入中文没反应

uitest uiInput text 只处理 ASCII,输入 张三 不会生效(命令返回成功,但输入框内容不变)。本次验证全部使用 ASCII 输入(Ada、ada@example.com、30、https://atomgit.com、secret123),因此没有受影响。若要覆盖「中文输入 + 中文校验」,得改用系统输入法或换一种自动化通道。

另外,uiInput text 是插入而不是覆盖:光标位置决定插入点,点输入框中间会插到中间。要整体替换内容,先点输入框最右侧把光标落到末尾再退格,或干脆点「重置」清空后重填。

9.11 编译成功但安装失败

error: device is not compatible.

compatibleSdkVersion 高于设备 API。本机设备是 API 24,工程必须写 6.1.1(24),写 26.0.0 装不上。另一类:

error: signature verification failed.

检查 AppScope/app.json5 的 bundleName 与 Profile 里登记的是否一致,以及 build-profile.json5 里证书路径是否还在(从别的机器拷过来基本都会失效)。


相关链接

Logo

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

更多推荐