OpenHarmony电话号码格式化:dlibphonenumber纯Dart适配实践

发布时间:2026/9/23 2:41:37
OpenHarmony电话号码格式化:dlibphonenumber纯Dart适配实践 我先说个结论dlibphonenumber 这套东西放在 OpenHarmony 上不能直接拿去编译就完事。它底层至少有两层东西绕不过去一层是 libphonenumber 的 C 实现另一层是 Dart 侧通过 FFI 调用 .so / .dylib / .dll 的胶水代码。OpenHarmony 的 native 生态和 Linux/Android 不完全兼容意味着这个 FFI 机制本身能用但库文件不能直接抄。这篇文章我会按我的适配思路把整条路走一遍从分析问题、拆解方案到移植核心逻辑、实现输入时自动格式化最后把踩过的坑和排查方法一并交代清楚。1. 项目思路拆解到底在为谁做适配难点在哪1.1 dlibphonenumber 在 OpenHarmony 上卡在哪了先看罪魁祸首。dlibphonenumber 是 Google libphonenumber 的 Dart 包装版pub.dev 上搜 phone number 相关库时它经常出现。它提供了解析号码、校验号码、格式化号码、获取运营商/时区信息等一系列能力其中format系列方法负责把一串数字按国家规则排版成86 138 0013 8000或(415) 555-2671这种样子。但有个关键细节dlibphonenumber 的 Dart 源码只是门面真正干活的是 C 版的 libphonenumber。Dart 侧通过 package:ffi 加载libdlibphonenumber.soLinux、libdlibphonenumber.dylibmacOS、she PHONE_NUMBER_UTIL_...Windows/Android 等然后调用里面的导出函数。你去翻源码会在 lib/src 下看到一堆final _native DynamicLibrary.open(libdlibphonenumber.so)之类的代码。OpenHarmony 能不能加载这样的动态库加载是可以加载前提是这个 .so 是用 OpenHarmony 的编译工具链、匹配 OpenHarmony 的 ABI 和系统依赖打出来的。Android 的 .so 丢进去会直接遇到 dlopen 失败或符号找不到。更麻烦的是dlibphonenumber 官方并不为 OpenHarmony 提供预编译产物。所以从“直接用”到“能跑”中间缺了整整一个 native 移植层。1.2 三条技术路线的取舍我先列了三条路线分别对应不同的风险和成本。路线一把 dlibphonenumber 依赖的 C 源码用 OpenHarmony 的 NDK 交叉编译成 .so同时保留 Dart 侧 FFI 绑定逻辑不变。这条路最“正统”如果你只是想把一个包从 Android 平移到 OpenHarmony优先考虑它能让你保住大量现有 Dart 代码。可实操起来要处理 OpenHarmony 的 musl/glibc 差异、CMake toolchain 适配、符号可见性、内存分配器兼容等一堆问题且 dlibphonenumber 里用了一些比较底层的 C 特性编译期踩坑概率不小。如果团队的 OpenHarmony native 经验一般大概率会卡上几周。路线二扔掉 native 依赖把 libphonenumber 的核心格式化逻辑用纯 Dart 重写实现底层数据结构直接使用 libphonenumber 的官方元数据几百个国家/地区的规则表。这样得到的是一个纯 Dart 包天生跨端OpenHarmony 上不需要任何 FFI稳定性最高。代价是工作量大而且必须保证重写后的行为与原生版本一致尤其正则解析和前缀匹配这种细节非常多。路线三不碰 dlibphonenumber 本身改在 OpenHarmony 上写一个平台通道MethodChannel通过原生系统接口做号码格式化和解析Flutter 侧调用。这条路最省事但问题是 OpenHarmony 的 telephony 接口与 Android 的 PhoneNumberUtils 有差异某些格式化场景比如“输入过程中按国家规则实时格式化”系统能力反而不如 libphonenumber 完整。而且如果你已经有一套基于 dlibphonenumber 的业务逻辑等于推倒重来。我最终选了路线二为主、路线一作为补充验证。原因很直接我要的不是“在 OpenHarmony 能编译通过”而是“在 OpenHarmony 上稳定跑、能自动化测试、行为可预测”。纯 Dart 实现可以完美满足前面几个条件。1.3 我的最终适配方案总结下来适配的核心动作是三个去掉 dlibphonenumber 的 FFI 依赖把它的接口层作为一个不依赖 native 的 Dart 实现重新落地。引入 libphonenumber 的 metadata电话规则数据集解析成 Dart 对象作为格式化引擎的数据来源。在 Flutter 侧写一个TextInputFormatter把格式化引擎接到输入框上实现输入时自动格式化的效果。这套方案做出来以后的调用链是这样的用户输入数字 - TextInputFormatter.formatEditUpdate() - AsYouTypeFormatter.addDigit() - 查国家/地区规则region metadata - 匹配号码前缀、选择格式化模板 - 返回格式化后的字符串 光标位置整条链路全部是 Dart不需要任何 native 代码。换句话说一个纯 Flutter 项目不依赖 OpenHarmony 的 NDK就能实现和原生 libphonenumber 几乎一致的输入体验。这也是我推荐给大多数团队做适配的首选路径。2. 核心细节解析电话号码格式化的底层逻辑2.1 电话号码规则数据 Metadata 的结构libphonenumber 之所以能覆盖全球几百个国家/地区是因为 Google 维护了一套庞大的电话号码规则数据集每个国家/地区记录了自己的国家码、国际长途前缀、国内长途前缀、号码长度范围、匹配正则、格式化正则等。这套数据在官方源码里以 XML 形式存在例如PhoneNumberMetadata.xml。dlibphonenumber 的实际数据路径不是直接读 XML而是通过 C 层把 XML 转成二进制或直接编译进 native 库里。而我的纯 Dart 版本思路更简单保留 XML 原文件作为 assets 资源启动时解析一次构建一个内存中的规则表。每条国家/地区规则的核心字段大概是这些字段名含义示例中国id国家/地区代码CNcountryCode国际拨号代码86internationalPrefix国际呼叫前缀00nationalPrefix国内长途前缀0nationalPrefixFormattingRule国内前缀的格式化规则0$1leadingDigits号码开头若干位用于区分类型1[3-9]generalDesc通用号码规则匹配该地区所有合法号码正则fixedLine / mobile / tollFree按号码类型细分规则正则其中generalDesc里最关键的是一个叫nationalNumberPattern的正则。它描述了这个国家所有合法电话号码的基本形态。比如中国的移动号在 metadata 里可能长这样实际有更细区分1[3-9]\d{9}而格式化模板更复杂。libphonenumber 的格式化规则不只是一个正则而是一个“格式模板”列表。每条格式记录包含pattern用来匹配已输入的号码片段format把号码按模板重新排版用$1、$2这种占位符指代正则捕获组leadingDigitsPattern前缀匹配规则用于快速判断当前输入适用于哪条格式以美国号码为例有一条格式可能是pattern: (\d{3})(\d{3})(\d{4})format: ($1) $2-$3。当输入4155552671时匹配后输出(415) 555-2671。2.2 为什么“输入时格式化”比“整体格式化”难普通格式化简单拿到完整号码匹配一条最终规则格式化输出。但“输入时格式化”难在用户输入过程中号码是不完整的你不能拿不完整的号码去匹配一条完整规则然后报错。比如中国号码用户刚输入到138还差 8 位。这时候如果拿138和1[3-9]\d{9}去匹配会失败。但你不能因此就不做任何处理也不能野蛮地把138当成非法输入直接清空。正确做法是“前缀匹配 逐步格式化”。也就是对当前已输入的数字串尝试把它作为某个规则的前缀去匹配。如果当前长度已经可以匹配某条格式模板中的一部分分组就按模板输出已匹配部分。如果匹配不上任何规则就退化成纯数字输出但保留用户输入不报错。当号码增长到完整长度再按完整规则格式化并校验。这就是 libphonenumber 中AsYouTypeFormatter的设计思路。它维护一个内部状态机每次新增一位数字时看看这位数字是否让当前号码更接近某个合法的号码形态然后增量更新输出。2.3 AsYouTypeFormatter 的核心状态机我借鉴了 libphonenumber 的实现思路简化后的AsYouTypeFormatter核心状态大致是accruedInput用户输入过的所有数字不含分隔符currentOutput当前格式化后的文本currentPosition光标在currentOutput中的位置selectedFormat当前选中正在使用的格式模板defaultCountry默认国家/地区代码每次用户输入一位新数字流程如下把新数字追加到accruedInput末尾。如果accruedInput匹配到国家码比如86或0086就把默认国家/地区切到对应国家。根据当前数字串长度从该国家的格式模板列表里选出第一个前缀匹配的模板。按模板把accruedInput重新排版同时计算新的光标位置通常会把光标放在刚才输入的数字之后。如果清空/退格则回退最后一位重新走上面的逻辑。这个状态机的代码如果完整展示大概一百多行。我这里写了一个核心片段去掉了一些边缘分支方便你理解主干逻辑class AsYouTypeFormatter { final MapString, CountryMetadata _metadataMap; String _defaultCountry; String _accruedInput ; String _currentOutput ; MatchedFormat? _matchedFormat; AsYouTypeFormatter(this._metadataMap, this._defaultCountry); String addDigit(String digit) { _accruedInput digit; _reformat(); return _currentOutput; } String removeLastDigit() { if (_accruedInput.isEmpty) return _currentOutput; _accruedInput _accruedInput.substring(0, _accruedInput.length - 1); _reformat(); return _currentOutput; } void _reformat() { final country _resolveCountry(_accruedInput); if (country null) { _currentOutput _accruedInput; _matchedFormat null; return; } final formats country.formats; for (final format in formats) { if (_isPrefixMatch(format.leadingDigitsPattern, _accruedInput)) { _matchedFormat _chooseBestFormat(format, _accruedInput); break; } } if (_matchedFormat ! null) { _currentOutput _applyFormat(_matchedFormat!, _accruedInput); } else { _currentOutput _accruedInput; } } }_applyFormat内部的逻辑是把_accruedInput按格式模板的 pattern 捕获分组然后按照 template 拼接。这里的细节相当多比如有些号码在格式化前要先剥离国家码、有些模板带括号、有些带空格或短横线而且每一位数字的分组方式可能随着长度变化。完整实现时建议直接用正则捕获组来切片。2.4 正则语法差异Dart RegExp 和 libphonenumber 正则libphonenumber 的正则基于 ICU 正则表达式语法比起 Dart 原生的RegExp支持更多高级特性比如命名捕获组、Unicode 属性、以及一些更复杂的断言。直接拿 metadata 里的正则去RegExp多半会编译失败。我在适配过程中踩过的典型坑有这么几个命名捕获组写法不同。ICU 里是(?name...)Dart 不支持这种写法要用(?Pname...)或者干脆改用非命名捕获组。部分 metadata 正则里带有/i或(?i)这种 flagDartRegExp不支持这种内嵌 flag需要拆分到构造参数里处理。metadata 中某些正则体量非常大比如国家码规则里带了大量分支。DartRegExp编译性能略弱如果每次输入都重新构造RegExpUI 线程会有肉眼可见的卡顿。所以一定要加缓存。我的做法是做一个RegexCache把所有被解析过的正则实例缓存起来key 是正文字符串本身class RegexCache { static final MapString, RegExp _cache {}; static RegExp of(String pattern) { return _cache.putIfAbsent(pattern, () RegExp(pattern)); } }另外要注意metadata 里的正则很多是用来做“校验”的格式是^...$但做“前缀匹配”时不能直接套带$的正则。我的解决办法是写一个isPrefixMatch方法去掉结尾的$后拿当前输入做startsWith匹配而不是让正则去匹配整个字符串。这个细节很多人会忽略但恰恰是它决定了输入过程中格式化能否正常推进。3. 实操过程把格式化引擎接到 Flutter 输入框上3.1 准备 metadata 数据并加载第一步是准备数据。从 libphonenumber 官方仓库拿到PhoneNumberMetadata.xml拷贝到 Flutter 项目的assets目录。这个文件一般两三兆对于纯数据场景完全可接受。接着在pubspec.yaml里声明资源flutter: assets: - assets/PhoneNumberMetadata.xml然后在启动时或首次使用时加载并解析。解析 XML 用xml包就够不用引入重型依赖。解析完的数据结构可以直接放进内存 Map按国家/地区代码索引FutureMapString, CountryMetadata loadMetadata() async { final raw await rootBundle.loadString(assets/PhoneNumberMetadata.xml); final doc XmlDocument.parse(raw); final map String, CountryMetadata{}; for (final node in doc.findAllElements(territory)) { final id node.getAttribute(id); if (id null) continue; map[id] CountryMetadata( id: id, countryCode: node.getAttribute(countryCode), nationalPrefix: node.getAttribute(nationalPrefix), formats: _parseFormats(node), ); } return map; }实际项目中我建议把解析结果再做一层持久化缓存比如存成 JSON 或干脆用shared_preferences存放序列化后的精简数据第二次启动就不用再解析 XML。省下来的时间在一些低端 OpenHarmony 设备上非常明显。3.2 实现“输入时自动格式化”的 TextInputFormatterFlutter 里实现输入框格式化最干净的方式是继承TextInputFormatter实现formatEditUpdate方法。这里的关键是格式化不能破坏用户正在编辑的位置也不能把光标甩到字符串末尾。很多网上的示例在formatEditUpdate里直接对整个文本重新格式化然后返回结果就是用户每次在中间插一个字符光标都会跳回末尾体验奇差。我的做法是用AsYouTypeFormatter.addDigit对“新文本”从左到右重新生成输出同时计算光标位置。但为了兼容中间插入的场景我简化成了一个“仅尾部添加”模式——更适合电话号码输入的实际场景。class PhoneNumberInputFormatter extends TextInputFormatter { final MapString, CountryMetadata metadataMap; final String defaultRegion; PhoneNumberInputFormatter({required this.metadataMap, this.defaultRegion CN}); override TextEditingValue formatEditUpdate( TextEditingValue oldValue, TextEditingValue newValue, ) { final text newValue.text; if (text.isEmpty) return newValue; // 去掉已经存在的格式字符只留数字和 final digitsOnly text.replaceAll(RegExp(r[^\d]), ); final formatter AsYouTypeFormatter(metadataMap, defaultRegion); var output ; for (var i 0; i digitsOnly.length; i) { output formatter.addDigit(digitsOnly[i]); } // 计算光标位置尽量保持在用户输入位之后 final newSelection TextSelection.collapsed(offset: output.length); return TextEditingValue( text: output, selection: newSelection, ); } }这段代码的生产版本还有两个细节必须处理一是多国家支持。默认地区是CN但如果用户输入1Formatter 要自动识别美国地区并切换规则。这个判断要在AsYouTypeFormatter._resolveCountry里做。我看到很多实现只处理固定国家上线后用户在海外输入自己国家号码全部格式化错乱。二是粘贴场景。用户从通讯录复制了一个带空格的号码粘贴进来这时digitsOnly会同时出现多个字符逐个调用addDigit没问题但要注意粘贴文本里的-、(、)、空格都需要被提前过滤掉。我上面的replaceAll只保留了数字和加号正反两个方向都覆盖到了。3.3 通过双向绑定把 Formatter 用起来用的时候很简单在TextField上挂inputFormatters即可TextField( keyboardType: TextInputType.phone, inputFormatters: [ PhoneNumberInputFormatter( metadataMap: metadataMap, defaultRegion: currentRegion, ), ], decoration: InputDecoration( hintText: 输入手机号码, ), )这里有个容易忽略的点currentRegion不能写死。建议通过 OpenHarmony 系统接口读取当前 SIM 卡的国家/地区码或者读取系统 Locale 的国家/地区码。如果读不到再兜底到用户手动选择的国家/地区。我后面会专门说怎么在 OpenHarmony 上获取地区码这里先跳过。3.4 关联默认地区从 OpenHarmony 读取国家/地区码Flutter 侧拿到 OpenHarmony 的地区码一种方案是直接读取 Locale。如果你的应用是国际化的Localizations.localeOf(context)已经给出了用户的语言和国家偏好。这是最简单的方式不需要任何权限String getDefaultRegion(BuildContext context) { final locale Localizations.localeOf(context); return locale.countryCode ?? CN; }更精确的方案是通过 MethodChannel 读取 SIM 卡信息。在 OpenHarmony 上这需要你申请ohos.permission.GET_TELEPHONY_STATE权限然后在原生侧用ohos.telephony.sim拿到当前 SIM 卡的国家码// module.json5 requestPermissions: [ { name: ohos.permission.GET_TELEPHONY_STATE } ]原生侧代码大概这样import { sim } from kit.TelephonyKit; import { BusinessError } from kit.BasicServicesKit; export function getSimCountryCode(slotId: number): Promisestring { return new Promise((resolve, reject) { sim.getSimCountryCode(slotId, (err: BusinessError, data: string) { if (err) { reject(err); } else { resolve(data); } }); }); }然后通过MethodChannel暴露给 Flutterstatic const _channel MethodChannel(phone_formatter/sim); FutureString? getSimCountryCode() async { try { return await _channel.invokeMethod(getSimCountryCode); } on PlatformException { return null; } }拿到地区码后更新到全局状态里下次输入框创建时直接用它。不申请权限也可以只是getSimCountryCode会抛异常那就退回用 Locale 兜底。4. 常见问题与排查技巧实录4.1 问题速查表我把自己在适配和测试中遇到的典型问题整理成了表格方便你对照排查。现象可能原因解决思路编译报错找不到 libdlibphonenumber.so项目中还残留对 dlibphonenumber 的 FFI 依赖检查 pubspec.lock移除 dlibphonenumber 包替换为纯 Dart 适配实现运行期 dlopen 失败 / UnsatisfiedLinkErrorOpenHarmony 无法加载 Android/Linux 版 .so彻底放弃 native 库按本文纯 Dart 方案重写格式化核心输入时号码不格式化直接显示数字AsYouTypeFormatter._resolveCountry没有匹配到目标国家检查地区码传入是否正确检查 metadata 是否成功加载输入到第 11 位后格式化突然回退号码长度超出当前格式模板的匹配范围或正则匹配失败检查format.pattern对完整号码的匹配情况补充generalDesc的边界规则粘贴号码时格式错乱粘贴内容里存在(、)、-、空格等未过滤字符进入addDigit前统一做净化处理光标总是跳到末尾TextSelection计算不到位根据格式化后的输出长度重新计算光标位置不能直接用原 selection输入1后没有切换美国规则国家码判断只处理了默认国家在_resolveCountry里补充开头的国家码解析逻辑第一次加载 metadata 时卡顿XML 解析在 UI 线程执行改为 async 加载并在首次使用前显示 loading后续用序列化缓存正则抛异常 FormatExceptionDart RegExp 不支持 ICU 部分语法改用RegexCache 手动改写正则或去掉命名捕获组4.2 我最想提醒你的三个坑第一个坑是关于“完整格式化”和“输入时格式化”的思维切换。如果你之前在 Android 上用过PhoneNumberUtils.formatNumber很容易想当然地把完整号码格式化逻辑拿来做实时输入。结果就是每次输入一个数字整个号码被重新“排版”但中间的光标位置和未完成的分组状态全乱了。用AsYouTypeFormatter的增量状态机就是从根上避开这个问题。一开始多花点时间把状态机理顺后面能省很多事。第二个坑是 metadata 加载必须做异步和缓存。我在初期调试时每次输入都重新 load XML低端机器上明显卡顿。后来改成启动时加载 内存缓存 本地 JSON 序列化体感好很多。建议你把 metadata 的解析结果直接 build 进 assets 里的二进制 JSON连 XML 解析都省了启动速度能再快一截。第三个坑是国家/地区的“动态切换”。很多业务会设置一个“常用地区”下拉框允许用户手动选择号码归属地。如果 Formatter 只读取默认地区切换后格式化规则不会变。我的做法是让TextInputFormatter在formatEditUpdate里每次通过回调从外部读取当前地区码或者用ChangeNotifier监听地区变化后重建 Formatter。后者的实现更简单代价是输入框需要重新绑定一次但用户基本无感。4.3 调试技巧用官方测试用例做回归libphonenumber 仓库的PhoneNumberUtilTest里有大量格式化测试用例比如美国号码(415) 555-2671、中国号码13800138000这种。我在适配时直接把一部分测试用例搬到了 Dart 侧写成了单元测试。做法很简单读官方测试的输入期望对输入一串数字断言输出是否符合预期。下面是一个例子test(format CN mobile number as you type, () { final formatter AsYouTypeFormatter(metadataMap, CN); expect(formatter.addDigit(1), 1); expect(formatter.addDigit(3), 13); expect(formatter.addDigit(8), 138); expect(formatter.addDigit(0), 138 0); expect(formatter.addDigit(0), 138 00); // ...到完整长度时 expect(formatter.addDigit(0), 138 0013 8000); });这组测试的价值在于只要官方库升级规则你可以拿新的测试用例回来跑一遍看自己的适配实现有没有跟着落后。至少我维护这个适配版本的过程中靠这组测试避免了好几次回归。5. 经验总结适配一个三方库不能只盯着编译最后再分享一点更宏观的感受。做 OpenHarmony 适配很多人第一反应是“能不能编译通过”但实际上对于 dlibphonenumber 这种强依赖 native 的库编译只是入场券。真正的难点在于三点一是要把库的底层依赖关系彻底理清二是要把核心逻辑用目标平台能接受的方式重新落地三是要用测试用例把自己的实现与官方行为对齐。我这次选择纯 Dart 重写格式化核心直接避开了 OpenHarmony NDK 交叉编译的一堆坑。但这个选择不是万能的如果你后续需要用到 libphonenumber 的其他高级能力比如时区查询、运营商判断、短号码校验那么纯 Dart 版本的实现成本会直线上升。到那时候你可能又得考虑路线一交叉编译 native 库。所以项目启动前先做一次能力清单评估比盲目开写重要得多。后续我也会考虑把 metadata 里更多字段扩展出来看看能不能把短号码校验和更多格式化场景也覆盖掉争取把这条路走得再深一点。