开发工具: 华为云码道

DevEco Studio: 26.0.0(26.0.0.851)

原始仓库: AbdallahAlsaifi/is_valid

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

is_valid 是一个「一把梭」的表单校验库:一个文件里塞了 49 个 validate* 静态方法,覆盖邮箱、电话号码、URL、密码强度、信用卡、IPv4/IPv6、UUID、语义化版本号、JSON、日期、身份证号、ISBN、MAC 地址、经纬度、IMEI、货币代码等常见与不常见场景,全部用正则和少量纯算术实现,没有任何平台分支。本文以 is_valid 0.0.7 为例,介绍从源码准备、OHOS 工程配置、ArkTS 插件壳,到真机跑通四类校验的完整过程。

在这里插入图片描述

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

在这里插入图片描述


一、插件简介与适配目标

表单校验是个典型的「写起来烦、但不需要什么平台能力」的需求。一个注册页要判邮箱格式、手机号长度、密码复杂度、网址合法性,如果每个项目都自己抄一遍正则,很快就会在不同项目里出现互相矛盾的四五份邮箱正则。is_valid 的存在意义就是把这些判断收敛到一个包里,让 IsValid.validateEmail(...) 这种调用统一所有业务线。

示例首页 示例首页 示例首页 示例首页 示例首页 示例首页 示例首页

它有几个很鲜明的特征:

  • 单文件实现。全部逻辑都在 lib/is_valid.dart 一个文件里,共 1412 行,没有拆分层,也没有抽象基类和依赖注入。
  • 纯 Dart。正则表达式、DateTime 解析、字符串处理,没有 dart:io、没有 dart:ffi、没有 MethodChannel 业务调用,因此任何平台拿到的结果都一样。
  • 联邦结构齐全但只有空壳。为了符合 Flutter 插件规范,仓库仍有 is_valid_platform_interface.dart 和 is_valid_method_channel.dart,但平台侧唯一的方法是模板自带的 getPlatformVersion,与校验逻辑毫无关系。
  • 枚举承担「选项」职责。密码规则、邮编规则、证件类型、国家码这些参数不是用字符串传,而是用 7 个枚举(PasswordValidation、PostalCodeValidation、IDType、CountryCode、CardType、SubdomainType、TimeFormat)表达。

对一个纯 Dart 库来说,「鸿蒙适配」这个词的含义和前几篇不太一样:不需要翻译任何业务通道协议,也不需要把原生控件换成 ArkUI 组件,全部工作只剩三件事——把插件注册进 Flutter 引擎、让示例工程能在鸿蒙上跑起来、确认 49 个校验方法在 OHOS 的 Dart VM 上行为与 Android/iOS 完全一致。

所以本文的重点会放在「怎么把工程搭起来」和「真机上到底表现如何」,而不是通道协议设计。适配后示例应用在真机上的三类状态如下:

  • 默认值(example@email.com / +8613800138000 / https://www.atomgit.com / 9 位密码)全部 VALID;
  • 把邮箱后缀改成 5 位 TLD、电话号码尾部加一位、URL 尾部插入非法字符、密码缩短到 4 位后,四项全部 INVALID;
  • 只把邮箱换成双段后缀(example@email.com.cn)时,邮箱仍然 VALID。

二、环境准备

环境搭建参考社区文档: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
Engine • revision deb287481e
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。适配提交最初写的是 26.0.0,在 API 24 的真机上装不上,后来单独用一次提交(d6cab59)降到 6.1.1(24),与真机对齐。targetSdkVersion 保持 26.0.0 不影响安装,它只表达「按哪个版本的行为运行」。


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

3.1 将上游源码同步到 AtomGit

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

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

同步方式与前面几篇相同:在 AtomGit 新建空仓库,然后把上游 master 推上去,后续的适配提交都打在 master 分支上。这样做的好处是所有改动都能通过 git diff 上游tag 一眼看全,评审时不需要在两个平台之间来回跳。

3.2 将代码拉取到宿主机

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

克隆配套仓库并查看目录

目录里能看到上游原本的六端结构(android/ios/linux/macos/windows/web),加上本次新增的 ohos,以及三份鸿蒙说明文档 README.OpenHarmony.md、README.OpenHarmony_CN.md、CHANGELOG.OpenHarmony.md。

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

git branch --show-current
git tag --list
git log --oneline -3

确认分支、TAG 与提交历史

四个提交的职责分别是:

提交内容
1f704a7feat: adapt is_valid for OpenHarmony (0.0.7-ohos-1.0.0-beta.1),新增 ohos/、example/ohos/、三份文档,pubspec.yaml 注册 ohos 平台
d6cab59chore: update example ohos compatibleSdkVersion to 6.1.1(24),把 SDK 版本从 26.0.0 降到真机能装的 6.1.1(24)
53fcee5docs(ohos): align API docs with real static methods and repair template tests,修正 README 中并不存在的 API 写法、重写两个模板测试
3fc073bdocs(ohos): record upstream validator quirks in the READMEs,把 validateDate 掩码、URL 协议可选、电话需带国家码三条上游行为写进文档

TAG 0.0.7-ohos-1.0.0 打在 3fc073b 上,对应的 Flutter 框架版本为 3.47,分支为 master——这一点和前面 human_body_selector 用 main 不同,取决于上游仓库本身的主分支名,不必强行统一。

3.4 自动补全 OHOS 适配结构

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

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

补 ohos 平台声明并对齐 SDK 版本

实际新增的内容只有三行:

flutter:
  plugin:
    platforms:
      windows:
        pluginClass: IsValidPluginCApi
      ohos:
        pluginClass: IsValidPlugin
      web:
        pluginClass: IsValidWeb
        fileName: is_valid_web.dart

顺序说明:flutter create 会把新平台插在已有平台之间,而不是简单追加到末尾,所以 diff 里看起来「夹在中间」是正常的。

3.5 适配后的项目目录

适配后的目录结构

关键文件与行数:

文件行数作用
lib/is_valid.dart141249 个 validate* 静态方法 + 7 个枚举,全部校验逻辑
lib/is_valid_platform_interface.dart29联邦接口,getPlatformVersion 抽象
lib/is_valid_method_channel.dart18MethodChannel('is_valid') 的 Dart 侧封装
ohos/src/main/ets/components/plugin/IsValidPlugin.ets47ArkTS 插件壳,实现 getPlatformVersion
example/lib/main.dart161演示页:四个输入框 + 结果卡片列表

四、Dart 接口与实现分析

4.1 跨端架构与调用时序

is_valid 的结构比前几篇简单得多:业务调用直达静态方法,平台层是并行的另一条线,两者之间没有任何交叉。

OHOS 原生

is_valid (纯 Dart)

业务代码

MethodChannel is_valid

TextFormField / 表单

IsValid.validate*
49 个静态方法

正则 / DateTime / 算术

IsValidPlatform
(仅 getPlatformVersion)

MethodChannelIsValid

IsValidPlugin.ets

@ohos.deviceInfo

图里用虚线画出来的部分就是「适配真正涉及的全部内容」:MethodChannel 这条链路只服务于版本查询,与校验无关。

4.1.1 一次校验的时序
RegExp IsValid TextFormField 用户 RegExp IsValid TextFormField 用户 输入 example@email.com validateEmail(text) hasMatch(emailRegex) true / false bool 决定是否显示错误提示

整个链路没有 await、没有平台往返,纯同步返回 bool。这是它能在任何平台保持一致的根本原因,也意味着鸿蒙适配不会引入任何行为差异。

4.2 49 个校验方法的组织方式

全部方法都是 IsValid 类的静态成员,命名统一为 validateXxx,返回 bool。按输入形态可以分成四类:

类型代表方法签名特点
单字符串validateEmail、validateIP4Address、validateUUIDstatic bool f(String input)
命名参数validateURL、validateCreditCard、validateHexColorCodestatic bool f({required String x})
带选项validatePassword、validatePostalCode、validateIDNumber第二个参数是 Map<枚举, dynamic> 或枚举
带掩码/长度validateDate、validateVIN、validateTemperatureUnit第二参数为格式串或数值

几个容易踩坑的签名,直接抄文档会编译不过:

// 密码:第一个参数是密码本身,第二个参数是规则 Map
final ok = IsValid.validatePassword('Abcdef1!', {
  PasswordValidation.minLength: 8,
  PasswordValidation.disallowSpecialChars: false,
});

// URL / 信用卡 / 十六进制颜色:命名参数
IsValid.validateURL(url: 'https://www.atomgit.com');
IsValid.validateCreditCard(cardNumber: '4111111111111111');
IsValid.validateHexColorCode(colorCode: '#F0F');

// 日期:第二参数是掩码,默认值有陷阱,详见 9.4
IsValid.validateDate('2026-10-23', 'yyyy-mm-dd');

配套仓库的 README 在适配初期写的是顶层函数形式(isValidEmail(email)),那是照着包的宣传语猜的,实际并不存在。53fcee5 把两份 README 的示例和 API 表都改成了真实的 IsValid.validate* 静态调用——这类「文档与实现不一致」的问题在小型个人库里很常见,适配时建议以 grep "static bool" 的结果为准。

4.3 正则策略与真机行为边界

49 个方法里有四十多个是「一条正则 + hasMatch」,实现上没什么可分析的空间,但边界行为值得单独记一笔,因为真机验证时最容易在这里产生「是适配引入的 bug 吗」的怀疑。

用 dart run 跑了一组探针(本机 flutter test 不可用,原因见 8.1),结果如下:

方法输入返回说明
validateEmailuser@example.comtrue普通邮箱
validateEmaila@b.cctrue单字符本地部分也通过
validateEmailuser@sub.example.comtrue支持子域
validateEmailuser@example.cfalseTLD 至少 2 位
validateEmailuser@@x.comfalse双 @ 拒绝
validateEmail空串false空值不通过
validateURLhttps://www.atomgit.com:8080/pathtrue支持端口与路径
validateURLatomgit.comtrue协议头可省略
validatePasswordAbcdef1! + minLength: 8true长度达标
validatePasswordabc! + minLength: 8false长度不足
validateCreditCard4111111111111111trueLuhn 校验
validateIP4Address192.168.1.1true正常 IPv4
validateIP4Address999.1.1.1false段值越界拒绝
validateHexColorCode#F0Ftrue三位简写支持
validateUUID550e8400-e29b-41d4-a716-446655440000true标准 UUID
validateSemanticVersioning1.2.3trueSemVer
validateJSONData{"a":1}true合法 JSON
validateDate2026-10-23(默认掩码)false见 9.4,上游缺陷
validateDate2026-10-23(掩码 yyyy-mm-dd)true小写掩码才生效
validatePhoneNumber+8613812345678 + CountryCode.cntrue必须带 +86
validatePhoneNumber13812345678 + CountryCode.cnfalse纯本地号不通过

三条结论:

  1. validateURL 的正则把协议头整体做成了可选组 (http://www\.|https://www\.|...)?,所以 atomgit.com 这种裸域名也会被判为合法。这不是适配问题,也不是 bug,而是上游对「URL」的定义比较宽松;如果需要强制协议,调用前自己判 startsWith('http')。
  2. validateDate 的默认掩码存在真实缺陷,'yyyy-MM-dd' 里的大写 M 不会被替换成 \d,导致最常见的中文日期写法反而判 false。详见第九章。
  3. 电话校验的正则本身包含国家码,+86 是模式的一部分,所以传纯本地号一定失败。这一点在真机上表现与探针一致,说明 OHOS 上没有额外差异。

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

abstract class IsValidPlatform extends PlatformInterface {
  IsValidPlatform() : super(token: _token);
  static final Object _token = Object();
  static IsValidPlatform _instance = MethodChannelIsValid();
  static IsValidPlatform get instance => _instance;
  static set instance(IsValidPlatform instance) {
    PlatformInterface.verifyToken(instance, _token);
    _instance = instance;
  }
  Future<String?> getPlatformVersion() {
    throw UnimplementedError('platformVersion() has not been implemented.');
  }
}

这一层完全是 flutter create --template=plugin 的产物,没有一行与校验相关。它的存在只说明这个包遵循了联邦插件(federated plugin)的写法,不代表它真的有平台差异。对适配工作来说,这意味着:

  • 不需要为 OHOS 单独实现一个 IsValidOhos 类;
  • 不需要考虑「Dart 侧调用哪个实现」的分支;
  • 唯一要在意的,是 pubspec.yaml 里 ohos.pluginClass 指向的类名要能和 ohos/index.ets 的导出对上。

plugin_platform_interface 在 is_valid 里是显式声明的(^2.0.2),所以不会出现上一篇 human_body_selector 那种 Undefined name 'PlatformInterface' 的编译错误。这也是判断一个库「能不能直接编过」的快速方法:先看 pubspec.yaml 有没有这一行。

4.5 这个库为什么没有业务通道

有些读者会问:一个校验库不调用平台能力,为什么还要 MethodChannel?答案是模板惯性。getPlatformVersion 只是让人能验证「插件确实被注册了」,它的返回值 OpenHarmony OpenHarmony-6.1.1.120 在业务里几乎不会被用到。

从适配角度看,这个空壳仍然有价值:它是注册是否成功的唯一可观测信号。如果 GeneratedPluginRegistrant.ets 里没有 new IsValidPlugin(),或者通道名写错,构建不会报错、校验也照常工作,但一旦业务里真去查版本就会拿到 MissingPluginException。所以第五章仍然要认真把这个壳补完。


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

5.1 在 IsValidPlugin.ets 中补全插件壳

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

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

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

  constructor() {
  }

  getUniqueClassName(): string {
    return "IsValidPlugin"
  }

  onAttachedToEngine(binding: FlutterPluginBinding): void {
    this.channel = new MethodChannel(binding.getBinaryMessenger(), "is_valid");
    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") {
        result.success(`OpenHarmony ${deviceInfo.osFullName}`);
      } else {
        result.notImplemented()
      }
    } catch (e) {
      result.error("PLUGIN_ERROR", `is_valid: failed to handle ${call.method}: ${JSON.stringify(e)}`, null)
    }
  }
}

三点说明:

  • 通道名必须与 Dart 侧一致。Dart 里是 MethodChannel('is_valid'),这里也必须是 "is_valid",两边一旦不一致,调用会静默失败。
  • 返回值格式对齐 Android/iOS。Android 返回 Android 14,iOS 返回 iOS 17.0,这里返回 OpenHarmony ${deviceInfo.osFullName},osFullName 的形态是 OpenHarmony-6.1.1.120。
  • try/catch 是必要的。deviceInfo 属于系统服务,在极少数场景下可能抛异常;把异常兜成 result.error 比让 Dart 侧永远等不到回包要好。

5.2 声明插件和宿主权限

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

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

没有 requestPermissions,这是正确的:is_valid 不读通讯录、不定位、不访问网络,读一次 deviceInfo.osFullName 属于系统公开信息,不需要授权。宿主工程 example/ohos/entry/src/main/module.json5 同样没有 requestPermissions。

如果业务方在自己的应用里同时用了需要权限的插件(相机、定位等),权限要声明在宿主应用的 module.json5,而不是 HAR 插件里——HAR 只是代码容器,权限归应用所有。

5.3 注册并导出插件

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

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

pubspec.yaml 里对应一行:

flutter:
  plugin:
    platforms:
      ohos:
        pluginClass: IsValidPlugin

三者名字必须完全一致: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 IsValidPlugin());
  } catch (e) {
    ...
  }
}

IntegrationTestPlugin 是 integration_test 依赖自动带进来的,正式应用里不会有。看到 new IsValidPlugin() 这一行,就说明注册链路是通的。

5.4 检查 example 的 OHOS 应用结构

example/ohos 是标准的 HarmonyOS 工程:AppScope/app.json5 定义包名与版本,entry/ 是主模块,entry/src/main/ets/entryability/EntryAbility.ets 负责创建 Flutter 引擎。

需要核对的两个字段:

// 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.is_valid_example。这不影响构建和运行,但有两个实际影响,需要提前知道:

  1. 签名用的 Profile 里登记的包名必须与 bundleName 一致,否则 hdc install 会报 signature verify failed;
  2. 同一个设备上装 color_system、human_body_selector、is_valid 等示例时,它们会互相覆盖(包名相同),验证前最好确认当前装的是哪个应用。

本次真机验证时就遇到了第 2 点:设备上新装的 json_translation 示例把 is_valid 覆盖掉了,重新执行一次 hdc install -r 才回到正确界面。正式项目里建议把 bundleName 改成与项目对应的值。


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

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

文件内容
README.OpenHarmony_CN.md中文接入说明:依赖方式、TAG 表、环境版本、API 示例、遗留问题
README.OpenHarmony.md同上的英文版本
CHANGELOG.OpenHarmony.md适配变更记录,按 TAG 分段
README.OpenSource上游来源与开源协议说明
example/ohos/README.md示例工程的独立运行说明

本次额外修了两份 README 的实质错误:

  • 依赖地址 CPF-Flutter → oh-flutter;
  • TAG 0.0.7-ohos-1.0.0-beta.1(从未发布)→ 0.0.7-ohos-1.0.0;
  • 环境行 DevEco Studio: 26.0.0 release(26.0.0.851), SDK: 26.0.0(26), ROM: 6.1.0 → DevEco Studio: 26.0.0(26.0.0.851), SDK: 6.1.1(24), ROM: 6.1.0.145;
  • API 示例从并不存在的顶层函数 isValidEmail(...) 改成真实的 IsValid.validateEmail(...)。

6.2 提交前检查

flutter analyze
flutter pub get
git status --short

flutter analyze 在插件目录与 example 目录要分别跑,两者的分析配置不同。本次结果:

  • 插件:0 error,22 条 info(21 条 constant_identifier_names + 1 条 dart:html 废弃),18 条 warning(15 条 equal_keys_in_map、2 条 unnecessary_null_comparison、1 条 unreachable_switch_default);
  • example:No issues found!。

warning 全部来自上游实现,未在适配提交里改动:

  • equal_keys_in_map:出现在电话国家码、邮政编码、证件长度等多个 map 字面量里。电话表共 204 个键,去重后 202 个,cy(塞浦路斯)与 gt(危地马拉)各出现两次,后写入的覆盖前者。属于数据冗余,不导致运行错误。
  • unnecessary_null_comparison:对非空类型做了 != null 判断。
  • unreachable_switch_default:validateCurrencyCode 的 default 分支已被前面的 case 覆盖。

把这些如实写进文章而不是顺手改掉,是因为适配提交的边界应当清晰:只做平台相关改动,风格问题留给上游或独立 PR。

6.3 提交并推送到 AtomGit

git add -A
git commit -m "docs(ohos): align API docs with real static methods and repair template tests"
git tag 0.0.7-ohos-1.0.0
git push origin master
git push origin 0.0.7-ohos-1.0.0

推送后可用 git ls-remote --tags origin 确认标签已到远端:

3fc073bc1a401a3ab356adfaa163f21dc3cc32c9  refs/tags/0.0.7-ohos-1.0.0

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


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

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

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

dependencies:
  flutter:
    sdk: flutter
  is_valid:
    path: ../

然后:

cd example
flutter pub get

7.2 通过 AtomGit 引入插件

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

dependencies:
  is_valid:
    git:
      url: https://atomgit.com/oh-flutter/is_valid
      ref: 0.0.7-ohos-1.0.0

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

7.3 调用接口实现表单校验

演示页 example/lib/main.dart 有四个输入框,分别对应邮箱、电话、URL、密码,点「Run validation」后把四个结果渲染成带 VALID / INVALID 的卡片:

void _runValidation() {
  final results = <({String label, bool ok})>[
    (
      label: 'Email: ${_emailCtrl.text}',
      ok: IsValid.validateEmail(_emailCtrl.text),
    ),
    (
      label: 'Phone (CN): ${_phoneCtrl.text}',
      ok: IsValid.validatePhoneNumber(
          phoneNumber: _phoneCtrl.text, countryCode: CountryCode.cn),
    ),
    (
      label: 'URL: ${_urlCtrl.text}',
      ok: IsValid.validateURL(url: _urlCtrl.text),
    ),
    (
      label: 'Password: ${_passwordCtrl.text.length} chars',
      ok: IsValid.validatePassword(_passwordCtrl.text, {
        PasswordValidation.minLength: 8,
      }),
    ),
  ];
  setState(() => _results
    ..clear()
    ..addAll(results));
}

结果用 record 列表存,是为了让「一次校验、一次刷新」的语义更清楚——校验是同步的,不存在中间态。

7.4 与 TextFormField 组合的实际写法

演示页为了直观把结果集中展示,真实表单里更常见的是 validator 回调:

TextFormField(
  controller: _emailCtrl,
  decoration: const InputDecoration(labelText: 'Email'),
  validator: (value) {
    final text = value?.trim() ?? '';
    if (text.isEmpty) return '请输入邮箱';
    return IsValid.validateEmail(text) ? null : '邮箱格式不正确';
  },
)

密码强度校验用 Map<PasswordValidation, dynamic> 传规则,注意 minLength 是必填项之一而不是唯一项:

validator: (value) {
  final text = value ?? '';
  if (!IsValid.validatePassword(text, {
    PasswordValidation.minLength: 8,
    PasswordValidation.maxLength: 32,
    PasswordValidation.disallowSpecialChars: false,
  })) {
    return '密码需 8–32 位';
  }
  return null;
},

这里有一个容易误解的地方:disallowSpecialChars: false 表示「不禁止特殊字符」,而不是「必须有特殊字符」。这个库只做「禁止类」规则,没有「必须包含大写/数字/符号」这类正向规则,如果业务需要强制复杂度,要在 validatePassword 之外自己补两条正则。


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

8.1 分别验证插件与 example

flutter analyze 只能保证静态层面没有错误,它对「正则写得对不对」毫无帮助。本机 flutter test 仍然无法运行——flutter_tester 启动即报 Invalid SDK hash,与前面几篇的现象一致,属于本机工具链问题,不是库的问题。所以改用 dart run 写临时探针复算纯逻辑:

cd example
dart run tool/probe.dart

探针跑了 21 组输入,覆盖上表列出的边界,全部与预期一致(validateDate 默认掩码那一组是上游缺陷,不是适配引入)。验证完成后探针立即删除,不留在仓库里——它依赖 example 的包解析,放进仓库反而会增加维护负担。

test/ 目录下的两个上游用例保持原样:

文件状态
test/is_valid_test.dartgetPlatformVersion 的断言被上游注释掉,只保留了「默认实例是 MethodChannelIsValid」这一条
test/is_valid_method_channel_test.dart用 mock handler 验证通道名与返回值,与平台无关,逻辑仍然成立

example 下两个模板测试则必须重写,因为它们根本跑不起来:

  • example/test/widget_test.dart 是 flutter create 残留,断言被注释、还引用了不存在的 MyApp;
  • example/integration_test/plugin_integration_test.dart 把一个 widget 当插件对象调用 getPlatformVersion(),类型都不对。

53fcee5 把前者改成直接断言四类校验函数的真值,后者改成面向 IsValidPlatform 的用例,这样即使将来本机工具链修好,测试也有意义。

8.2 确认设备连接

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

本机返回:

4UQ9K25508013016
24
TLR-AL00

API 24 与 compatibleSdkVersion: "6.1.1(24)" 对应,这是 d6cab59 那次提交的直接原因。

8.3 配置签名

example/ohos/build-profile.json5 里已有 debug 签名配置:

"signingConfigs": [
  {
    "name": "default",
    "type": "HarmonyOS",
    "material": {
      "certpath": "C:////Users////PC////.ohos////config////default_ohos_xxx.cer",
      "keyAlias": "debugKey",
      "profile": "C:////Users////PC////.ohos////config////default_ohos_xxx.p7b",
      "storePassword": "...",
      "keyPassword": "..."
    }
  }
]

这份配置是从 DevEco Studio 的自动签名里导出的,只在本机有效。换一台机器构建时,需要用自己的证书重签,或者直接在 DevEco Studio 里点 File > Project Structure > Signing Configs > Automatically generate signature。

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

8.4 运行示例

宿主机上直接 flutter build hap --debug 在本机会卡在 ohpm install(报 00306053 Specification Limit Violation 与 BATCH RECURSION exceeds STACK limits),所以采用「手动装依赖 + hvigor 构建」的方式:

cd example/ohos
"D:/Program Files/Huawei/DevEco Studio/tools/ohpm/bin/ohpm" install

然后用 PowerShell 调 hvigorw(Bash 里路径带空格会失败):

$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\is_valid\example\ohos"
& "D:\Program Files\Huawei\DevEco Studio\tools\hvigor\bin\hvigorw.bat" assembleHap `
  -p product=default -p buildMode=debug --no-daemon

两点必须注意:

  • $env:NODE_OPTIONS="" 不能省。WorkBuddy 会注入一个 safe-delete shim,hvigor 编译期批量清理缓存时触发 SAFE_DELETE_BULK_CONFIRM_REQUIRED,最终显示 COMPILE RESULT:FAIL,很容易被误判成 ArkTS 语法错误;
  • 构建末尾可能因为调用 wmic.exe 被安全策略拦截而返回非零退出码,但此时 BUILD SUCCESSFUL 已经打印、HAP 也已产出,以产物文件为准。

产物路径:

example/ohos/entry/build/default/outputs/default/entry-default-signed.hap

大小 145,129,166 字节(约 138 MiB,debug 包,体积主要来自 Flutter debug 引擎)。安装:

cd example/ohos/entry/build/default/outputs/default
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. 启动应用,确认四个输入框的默认值:example@email.com、+8613800138000、https://www.atomgit.com、9 个圆点的密码;
  2. 点「Run validation」,确认四条结果都是 VALID;
  3. 把邮箱改为 example@emaxxil.comxx(5 位 TLD),重新校验,确认邮箱变为 INVALID;
  4. 把电话尾部再加一位、URL 尾部插入非法字符、密码删到 4 位,重新校验,确认四项全部 INVALID;
  5. 邮箱改为 example@email.com.cn(双段后缀),确认邮箱回到 VALID;
  6. 清空邮箱后校验,确认空值判 INVALID;
  7. 聚焦任意输入框,确认软键盘弹出后布局正常、结果卡片可以继续滚动查看。

第 3–4 步的输入方式有个细节:uitest uiInput text 是在光标处插入,点输入框中间会把字符插到中间,得到 wordd 之类的结果。可靠做法是点输入框最右侧(x≈950)让光标落到末尾,再追加字符。

8.6 鸿蒙设备运行效果

左图是启动后的初始状态,底部提示「Tap “Run validation” to see results.」;中图四项全部 VALID,URL 自动换行成两行;右图把邮箱后缀改成 5 位 TLD 后,只有邮箱一项变红。

左图是四项同时无效的组合;中图把邮箱换成 example@email.com.cn,双段后缀仍然 VALID;右图清空邮箱后,空值判 INVALID 且不抛异常。

示例首页 示例首页 示例首页 示例首页 示例首页 示例首页 示例首页

软键盘弹出后页面整体上移,输入框、按钮、结果区都没有被遮挡,说明演示页的 ListView 在没有额外配置的情况下已经能正确响应键盘 insets。这一点在鸿蒙上与 Android 表现一致,不需要像某些原生页面那样手动 expandSafeArea。


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

9.1 Missing SDK components

报错形态:

Missing SDK components: [ohos-sdk]

说明 DevEco Studio 的 OpenHarmony SDK 没装全或路径没配。打开 Settings > SDK 确认 API 12+ 与 Toolchains 都已下载,再检查环境变量 OHOS_SDK_HOME(或 HOS_SDK_HOME)是否指向 sdk/default。

9.2 DevEco Studio 中看不到 entry 模块

打开 example/ohos 后只看到工程根节点。原因通常是 .hvigor 缓存与当前 DevEco 版本不匹配。删除 example/ohos/.hvigor 与 example/ohos/build 后重新打开,或在 DevEco 里执行 File > Sync and Refresh Project。

9.3 无法手动签名

hdc install 报:

error: failed to install bundle. code:9568320
error: signature verification failed.

检查两处:AppScope/app.json5 的 bundleName 与 Profile 里登记的是否一致;build-profile.json5 里 certpath / profile 指向的文件是否还在(从别的机器拷过来的配置,路径基本都会失效)。

9.4 validateDate('2026-10-23') 返回 false

这是上游实现的真实缺陷,不是适配引入。_buildDateRegex 的实现是:

static String _buildDateRegex(String mask) {
  String regexPattern = mask
      .replaceAllMapped('d', (_) => r'\d')
      .replaceAllMapped('m', (_) => r'\d')
      .replaceAllMapped('y', (_) => r'\d');
  return '^$regexPattern\$';
}

只替换了小写 d/m/y。默认掩码是 'yyyy-MM-dd',其中月份是大写 M,不会被替换,最终正则变成 ^\d\d\d\d-MM-\d\d$——要求字符串里真的出现字面量 MM。所以:

IsValid.validateDate('2026-10-23');                 // false
IsValid.validateDate('2026-10-23', 'yyyy-mm-dd');   // true
IsValid.validateDate('2026-MM-23');                 // true(荒谬但符合实现)

三种处理方式,按侵入性排序:

  1. 调用方传小写掩码:validateDate(text, 'yyyy-mm-dd'),不动上游代码;
  2. 自己判:DateTime.tryParse(text) != null,更符合直觉;
  3. 给上游提 PR:把 replaceAllMapped 改成大小写都处理,并校验月份、日期范围。

本次适配选择第 1 种,在 README 的使用说明里写明了掩码必须小写,避免使用者踩第二次。

9.5 validateURL('atomgit.com') 也返回 true

正则里协议头是可选组:

r'^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$'

所以裸域名、ftp:// 之外的任何「域名 + 可选端口 + 可选路径」都会通过。如果业务要求必须带协议:

bool isStrictUrl(String input) =>
    (input.startsWith('http://') || input.startsWith('https://')) &&
    IsValid.validateURL(url: input);

另外注意 TLD 段是 [a-z]{2,5},所以 .museum(6 位)、.travel(6 位)都会被判为非法,这与 9.4 的邮箱 TLD 限制是同一个量级的问题。

9.6 validatePhoneNumber 传本地号总是 false

国家码表里的正则把国家码写在模式内部,例如中国是 r'^\+86[0-9]{11}$'。传入 13812345678 永远不匹配。两个选择:

// 1. 调用前补国家码
final ok = IsValid.validatePhoneNumber(
  phoneNumber: '+86${input.trim()}',
  countryCode: CountryCode.cn,
);

// 2. 自己判本地号,把国家码表当参考
final ok = RegExp(r'^1[3-9]\d{9}$').hasMatch(input);

还有一处命名容易写错:枚举成员是 CountryCode.cn(中国)与 CountryCode.ch(瑞士),CountryCode.In(印度)首字母大写因为 in 是 Dart 关键字。写成 CountryCode.ch 会拿到瑞士的 +41 规则。

9.7 MissingPluginException(No implementation found for method getPlatformVersion on channel is_valid)

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

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

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

9.8 编译成功但安装失败

error: install failed due to grant request permissions failed.

或

error: device is not compatible.

前者检查 module.json5 里有没有声明了但未在 Profile 中授权的权限;后者就是 compatibleSdkVersion 高于设备 API,改回 6.1.1(24) 即可。

9.9 AtomGit 依赖提示找不到分支或无权限

fatal: couldn't find remote ref 0.0.7-ohos-1.0.0

说明 TAG 没推上去。在仓库里执行 git push origin 0.0.7-ohos-1.0.0,再用 git ls-remote --tags origin 确认。如果是私有仓库,还需要配置 AtomGit 的 SSH key 或 access token。

9.10 flutter analyze 报了一堆 equal_keys_in_map,要不要改

不用改,也不建议在适配提交里改。这些 warning 分布在上游的电话国家码、邮政编码、证件长度等多个 map 里,电话表 204 个键去重后 202 个(cy、gt 重复),属于数据冗余。改它会让适配 diff 里混进大量与平台无关的改动,评审时反而看不出真正的适配内容。要改就单独开 PR 给上游。

9.11 uiInput text 输入中文没有反应

uitest uiInput text 只处理 ASCII,输入 张三@例子.中国 这类内容不会生效(命令返回成功,但输入框内容不变)。真机验证中文化场景需要改用系统输入法手动输入,或把验证点换成「输入含中文的字符串后 validateEmail 返回 false」这类逻辑验证。

本次真机验证因此没有覆盖中文邮箱/中文域名,文章里也不写成「已通过」。


相关链接

Logo

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

更多推荐