前言

欢迎来到 Flutter三方库适配OpenHarmony 系列文章!本系列围绕 flutter_libphonenumber 这个 电话号码处理库 的鸿蒙平台适配,进行全面深入的技术分享。

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

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

上一篇我们追踪了 format() 异步格式化的完整调用链路。本篇将分析它的"孪生兄弟"——formatNumberSync()。与 format() 需要跨平台通信不同,formatNumberSync() 完全在 Dart 侧执行,无需 MethodChannel,通过预加载的 Mask 数据实现高性能的同步格式化。

formatNumberSync()LibPhonenumberTextFormatter 实时输入格式化的核心。理解它的 Mask 匹配算法,就理解了用户每次按键时号码是如何被实时格式化的。


一、formatNumberSync() 的定位

1.1 为什么需要同步格式化

场景 要求 适用方法
用户按键实时格式化 同步、< 1ms formatNumberSync()
手动点击格式化按钮 异步可接受 format()
批量格式化号码列表 同步更高效 formatNumberSync()

TextInputFormatter.formatEditUpdate() 是同步方法,不能使用 async/await。因此实时输入格式化 必须 使用同步方案。

1.2 执行位置对比

format()(异步):
  Dart → MethodChannel → ArkTS → 格式化 → 回传 → Dart

formatNumberSync()(同步):
  Dart → Mask匹配 → 完成(全程在 Dart 侧)

二、完整源码分析

2.1 方法签名

String formatNumberSync(
  final String number, {
  final CountryWithPhoneCode? country,
  final PhoneNumberType phoneNumberType = PhoneNumberType.mobile,
  final PhoneNumberFormat phoneNumberFormat =
      PhoneNumberFormat.international,
  final bool removeCountryCodeFromResult = false,
  final bool inputContainsCountryCode = true,
})

2.2 参数说明

参数 类型 默认值 说明
number String 必填 待格式化的号码
country CountryWithPhoneCode? null 指定国家(null 则自动检测)
phoneNumberType PhoneNumberType mobile 手机/固话
phoneNumberFormat PhoneNumberFormat international 国际/国内格式
removeCountryCodeFromResult bool false 是否从结果中去掉区号
inputContainsCountryCode bool true 输入是否包含区号

2.3 完整实现

String formatNumberSync(
  final String number, {
  final CountryWithPhoneCode? country,
  final PhoneNumberType phoneNumberType =
      PhoneNumberType.mobile,
  final PhoneNumberFormat phoneNumberFormat =
      PhoneNumberFormat.international,
  final bool removeCountryCodeFromResult = false,
  final bool inputContainsCountryCode = true,
}) {
  // ① 确定国家
  final guessedCountry = country ??
      CountryWithPhoneCode.getCountryDataByPhone(number);

  if (guessedCountry == null) {
    return number;  // 无法识别国家,返回原始输入
  }

  // ② 获取 Mask 并应用
  var formatResult = PhoneMask(
    mask: guessedCountry.getPhoneMask(
      format: phoneNumberFormat,
      type: phoneNumberType,
      removeCountryCodeFromMask: !inputContainsCountryCode,
    ),
    country: guessedCountry,
  ).apply(number);

  // ③ 可选:去掉区号
  if (removeCountryCodeFromResult
      && inputContainsCountryCode) {
    formatResult = formatResult.substring(
        guessedCountry.phoneCode.length + 2);
  }

  return formatResult;
}

三、执行流程详解(5 步)

3.1 步骤 ① — 确定国家

final guessedCountry = country ??
    CountryWithPhoneCode.getCountryDataByPhone(number);

两种方式确定国家:

方式 条件 说明
直接指定 country != null 调用者明确传入国家
自动检测 country == null 通过号码前缀匹配

自动检测使用 getCountryDataByPhone(),它通过递归缩短匹配算法从号码中识别国家区号(详见第 5 篇文章)。

3.2 步骤 ② — 获取 Mask

guessedCountry.getPhoneMask(
  format: phoneNumberFormat,
  type: phoneNumberType,
  removeCountryCodeFromMask: !inputContainsCountryCode,
)

getPhoneMask() 根据 format × type 的组合返回对应的 Mask:

format type CN 的 Mask
international mobile +00 000 0000 0000
national mobile 000 0000 0000
international fixedLine +00 00 0000 0000
national fixedLine 000 0000 0000

inputContainsCountryCode = false 时,removeCountryCodeFromMask = true,Mask 会去掉区号部分:

原始 Mask:  +00 000 0000 0000
去掉区号:   000 0000 0000

3.3 步骤 ③ — 创建 PhoneMask

PhoneMask(
  mask: '+00 000 0000 0000',  // 从步骤②获取
  country: guessedCountry,     // CN
)

PhoneMask 是一个简单的数据类,持有 Mask 字符串和国家信息。

3.4 步骤 ④ — 应用 Mask

phoneMask.apply(number)

这是核心步骤,下一节详细分析。

3.5 步骤 ⑤ — 可选去掉区号

if (removeCountryCodeFromResult
    && inputContainsCountryCode) {
  formatResult = formatResult.substring(
      guessedCountry.phoneCode.length + 2);
}

当同时满足两个条件时,从结果中截掉区号部分:

格式化结果:  +86 131 2345 6789
phoneCode:   86(长度 2)
截取位置:    2 + 2 = 4(+号1 + 区号2 + 空格1)
最终结果:    131 2345 6789

四、PhoneMask.apply() 核心算法

4.1 完整源码

String apply(final String inputString) {
  if (mask.isEmpty) return inputString;

  // ① 清理输入:去掉所有非数字字符
  var cleanedInput =
      inputString.replaceAll(RegExp(r'\D'), '');

  // ② 处理区号不匹配的情况
  if (!mask.startsWith('+')
      && inputString.startsWith('+')) {
    cleanedInput = cleanedInput.replaceFirst(
        RegExp('^${country.phoneCode}'), '');
  }

  final chars = cleanedInput.split('');
  final result = <String>[];
  var index = 0;

  // ③ 逐位匹配 Mask
  for (var i = 0; i < mask.length; i++) {
    if (index >= chars.length) break;

    final curChar = chars[index];
    if (mask[i] == '0') {
      // Mask 位是 '0':用输入的数字替换
      if (_isDigit(curChar)) {
        result.add(curChar);
        index++;
      } else {
        break;
      }
    } else {
      // Mask 位是非数字:直接添加格式字符
      result.add(mask[i]);
    }
  }

  return result.join();
}

4.2 算法步骤

输入: '+8613123456789'
Mask: '+00 000 0000 0000'

步骤①: 清理输入
  '+8613123456789' → '8613123456789'(去掉+)

步骤②: 区号检查
  Mask 以 '+' 开头 → 不需要去掉区号

步骤③: 逐位匹配
  chars = ['8','6','1','3','1','2','3','4','5','6','7','8','9']
  index = 0

  i=0: mask[0]='+' → 非数字 → result=['+']
  i=1: mask[1]='0' → chars[0]='8' → result=['+','8'], index=1
  i=2: mask[2]='0' → chars[1]='6' → result=['+','8','6'], index=2
  i=3: mask[3]=' ' → 非数字 → result=['+','8','6',' ']
  i=4: mask[4]='0' → chars[2]='1' → result=[..,'1'], index=3
  i=5: mask[5]='0' → chars[3]='3' → result=[..,'3'], index=4
  i=6: mask[6]='0' → chars[4]='1' → result=[..,'1'], index=5
  i=7: mask[7]=' ' → 非数字 → result=[..,' ']
  ...依次匹配...

结果: '+86 131 2345 6789'

4.3 区号不匹配的处理

当 Mask 不含区号但输入含区号时:

if (!mask.startsWith('+')
    && inputString.startsWith('+')) {
  cleanedInput = cleanedInput.replaceFirst(
      RegExp('^${country.phoneCode}'), '');
}

示例:

Mask:  '000 0000 0000'(National,不含+)
输入:  '+8613123456789'
清理:  '8613123456789'
去区号: '13123456789'(去掉开头的 '86')
匹配:  '131 2345 6789'

4.4 Mask 字符的两种角色

Mask 字符 角色 处理方式
0 数字占位符 用输入的下一个数字替换
其他(+, , -, (, ) 格式字符 直接添加到输出

五、inputContainsCountryCode 的影响

5.1 两种输入模式

模式 inputContainsCountryCode 输入示例 说明
含区号 true +8613123456789 用户输入完整国际号码
不含区号 false 13123456789 用户只输入国内号码

5.2 对 Mask 选择的影响

guessedCountry.getPhoneMask(
  format: phoneNumberFormat,
  type: phoneNumberType,
  removeCountryCodeFromMask: !inputContainsCountryCode,
  //                         ↑ 取反!
)
inputContainsCountryCode removeCountryCodeFromMask Mask(CN, mobile, intl)
true false +00 000 0000 0000
false true 000 0000 0000

5.3 完整示例

含区号模式:

输入:  '+8613123456789'
Mask:  '+00 000 0000 0000'
结果:  '+86 131 2345 6789'

不含区号模式:

输入:  '13123456789'
Mask:  '000 0000 0000'
结果:  '131 2345 6789'

六、多国同步格式化结果对比

6.1 国际格式(含区号)

以下是 10 个主要国家使用 formatNumberSync() 的实际格式化结果:

国家 输入 Mask 输出
CN +8613123456789 +00 000 0000 0000 +86 131 2345 6789
US +12015550123 +0 000-000-0000 +1 201-555-0123
GB +447400123456 +00 0000 000000 +44 7400 123456
JP +819012345678 +00 000 0000 0000 +81 901 2345 6789
DE +4915123456789 +00 0000 0000 0000 +49 1512 3456 789
FR +33612345678 +00 0 00 00 00 00 +33 6 12 34 56 78
AU +61412345678 +00 000 000 000 +61 412 345 678
BR +5511912345678 +00 (00) 00000-0000 +55 (11) 91234-5678
IN +918123456789 +00 00000 00000 +91 81234 56789
RU +79123456789 +0 000 000-00-00 +7 912 345-67-89

6.2 国内格式(不含区号)

inputContainsCountryCode = false 时,使用去掉区号的 Mask:

国家 输入 Mask(去区号后) 输出
CN 13123456789 000 0000 0000 131 2345 6789
US 2015550123 (000) 000-0000 (201) 555-0123
GB 7400123456 0000 000000 7400 123456
JP 9012345678 000 0000 0000 901 2345 6789
FR 612345678 0 00 00 00 00 6 12 34 56 78

6.3 手机号 vs 固话号

同一个国家,手机号和固话号使用不同的 Mask:

国家 类型 输入 Mask 输出
CN mobile +8613123456789 +00 000 0000 0000 +86 131 2345 6789
CN fixedLine +861012345678 +00 00 0000 0000 +86 10 1234 5678
GB mobile +447400123456 +00 0000 000000 +44 7400 123456
GB fixedLine +441212345678 +00 000 000 0000 +44 121 234 5678
JP mobile +819012345678 +00 000 0000 0000 +81 901 2345 6789
JP fixedLine +81312345678 +00 0 0000 0000 +81 3 1234 5678

关键观察:手机号和固话号的 Mask 差异主要体现在 分组方式 上。例如中国手机号是 3-4-4 分组,固话是 2-4-4 分组(区号2位 + 号码8位)。


七、getCountryDataByPhone() 自动国家检测

7.1 当 country 参数为 null 时

如果调用 formatNumberSync() 时没有指定 country,会通过 getCountryDataByPhone() 自动检测:

final guessedCountry = country ??
    CountryWithPhoneCode.getCountryDataByPhone(number);

7.2 自动检测的匹配过程

getCountryDataByPhone() 使用递归缩短匹配算法:

输入: '+8613123456789'

第 1 次: phoneCode = '+8613123456789'
  → 提取数字 '8613123456789' → 无匹配
第 2 次: phoneCode = '+861312345678'
  → 提取数字 '861312345678' → 无匹配
...
第 12 次: phoneCode = '+86'
  → 提取数字 '86' → 匹配 CN ✅

7.3 自动检测的局限性

局限 说明 示例
共享区号 US 和 CA 都是 +1 +12015550123 → 匹配到先注册的国家
无区号输入 不以 + 开头的号码 13123456789 → 无法匹配
短号码 输入太短无法匹配 +8 → 无匹配

最佳实践:在已知用户所在国家的情况下,始终传入 country 参数,避免自动检测的不确定性。


八、removeCountryCodeFromResult 参数

8.1 参数作用

removeCountryCodeFromResult 用于从格式化结果中 去掉区号部分

if (removeCountryCodeFromResult
    && inputContainsCountryCode) {
  formatResult = formatResult.substring(
      guessedCountry.phoneCode.length + 2);
}

8.2 使用场景

场景 removeCountryCodeFromResult 输入 输出
显示完整国际号码 false +8613123456789 +86 131 2345 6789
只显示国内号码 true +8613123456789 131 2345 6789

8.3 与 inputContainsCountryCode 的配合

这两个参数必须配合使用:

inputContainsCC removeCC 行为
true false 输入含区号,输出含区号
true true 输入含区号,输出去掉区号
false false 输入不含区号,输出不含区号
false true 无效组合(条件不满足,不执行截取)
// 只有同时满足两个条件才截取
if (removeCountryCodeFromResult
    && inputContainsCountryCode) {
  // 执行截取
}

注意:当 inputContainsCountryCode = false 时,即使 removeCountryCodeFromResult = true,也不会执行截取。因为输入本身就不含区号,结果中也不会有区号。


九、getPhoneMask() 方法详解

9.1 方法签名

String getPhoneMask({
  required final PhoneNumberFormat format,
  required final PhoneNumberType type,
  final bool removeCountryCodeFromMask = false,
})

9.2 内部实现

String getPhoneMask({
  required final PhoneNumberFormat format,
  required final PhoneNumberType type,
  final bool removeCountryCodeFromMask = false,
}) {
  late String returnMask;

  if (format == PhoneNumberFormat.international) {
    if (type == PhoneNumberType.mobile) {
      returnMask = phoneMaskMobileInternational;
    } else {
      returnMask = phoneMaskFixedLineInternational;
    }
  } else {
    if (type == PhoneNumberType.mobile) {
      returnMask = phoneMaskMobileNational;
    } else {
      returnMask = phoneMaskFixedLineNational;
    }
  }

  if (removeCountryCodeFromMask
      && returnMask.startsWith('+')) {
    returnMask =
        returnMask.substring(phoneCode.length + 2);
  }

  return returnMask;
}

9.3 四种组合的 Mask 选择

getPhoneMask() 根据 format × type 的组合,从 4 个 Mask 字段中选择一个:

                    mobile                  fixedLine
              ┌──────────────────┐   ┌──────────────────┐
international │ phoneMaskMobile  │   │ phoneMaskFixedLine│
              │ International    │   │ International     │
              └──────────────────┘   └──────────────────┘
national      │ phoneMaskMobile  │   │ phoneMaskFixedLine│
              │ National         │   │ National          │
              └──────────────────┘   └──────────────────┘

以中国为例:

format type Mask
international mobile +00 000 0000 0000
international fixedLine +00 00 0000 0000
national mobile 000 0000 0000
national fixedLine 000 0000 0000

以美国为例:

format type Mask
international mobile +0 000 000 0000
international fixedLine +0 000 000 0000
national mobile (000) 000-0000
national fixedLine (000) 000-0000

9.4 removeCountryCodeFromMask 的截取逻辑

removeCountryCodeFromMask = true 且 Mask 以 + 开头时:

returnMask = returnMask.substring(phoneCode.length + 2);

截取位置的计算:

Mask:      +00 000 0000 0000
           │││
           │││
           ││└── 空格(1个字符)
           │└─── 区号(phoneCode.length 个字符)
           └──── +号(1个字符)

截取位置 = 1(+) + phoneCode.length + 1(空格)
         = phoneCode.length + 2

不同国家的截取示例:

国家 phoneCode 长度 截取位置 原始 Mask 截取后
CN 86 2 4 +00 000 0000 0000 000 0000 0000
US 1 1 3 +0 000 000 0000 000 000 0000
GB 44 2 4 +00 0000 000000 0000 000000
HK 852 3 5 +000 0000 0000 0000 0000

十、在 LibPhonenumberTextFormatter 中的完整使用

10.1 TextInputFormatter 的工作原理

Flutter 的 TextInputFormatter 是一个同步拦截器,在用户每次输入时被调用:

用户按键
    │
    ↓ TextField 接收输入
    │
    ↓ TextInputFormatter.formatEditUpdate(oldValue, newValue)
    │   ├── 同步处理(不能 async)
    │   └── 返回修改后的 TextEditingValue
    │
    ↓ TextField 显示格式化后的文本

10.2 LibPhonenumberTextFormatter 的完整实现

class LibPhonenumberTextFormatter
    extends TextInputFormatter {
  final CountryWithPhoneCode country;
  final PhoneNumberType phoneNumberType;
  final PhoneNumberFormat phoneNumberFormat;
  final bool inputContainsCountryCode;
  final bool shouldKeepCursorAtEndOfInput;

  LibPhonenumberTextFormatter({
    required this.country,
    this.phoneNumberType = PhoneNumberType.mobile,
    this.phoneNumberFormat =
        PhoneNumberFormat.international,
    this.inputContainsCountryCode = true,
    this.shouldKeepCursorAtEndOfInput = true,
  });

  
  TextEditingValue formatEditUpdate(
    TextEditingValue oldValue,
    TextEditingValue newValue,
  ) {
    final formatted =
        FlutterLibphonenumberPlatform.instance
            .formatNumberSync(
      newValue.text,
      country: country,
      phoneNumberType: phoneNumberType,
      phoneNumberFormat: phoneNumberFormat,
      inputContainsCountryCode:
          inputContainsCountryCode,
    );

    return TextEditingValue(
      text: formatted,
      selection: shouldKeepCursorAtEndOfInput
          ? TextSelection.collapsed(
              offset: formatted.length)
          : newValue.selection,
    );
  }
}

10.3 formatNumberSync 在 TextFormatter 中的调用频率

每次按键都会触发一次 formatEditUpdate(),进而调用一次 formatNumberSync()

用户输入 '+8613123456789'(14 个字符):

按键 1:  '+' → formatNumberSync('+')
  → getCountryDataByPhone('+') → null
  → return '+'

按键 2:  '8' → formatNumberSync('+8')
  → getCountryDataByPhone('+8') → null
  → return '+8'

按键 3:  '6' → formatNumberSync('+86')
  → getCountryDataByPhone('+86') → CN ✅
  → mask = '+00 000 0000 0000'
  → PhoneMask.apply('+86') → '+86'

按键 4:  '1' → formatNumberSync('+861')
  → CN, mask = '+00 000 0000 0000'
  → PhoneMask.apply('+861') → '+86 1'

...

按键 14: '9' → formatNumberSync('+8613123456789')
  → CN, mask = '+00 000 0000 0000'
  → PhoneMask.apply('+8613123456789')
  → '+86 131 2345 6789'

共调用 14 次 formatNumberSync()
每次耗时 < 0.1ms,总计 < 1.4ms

10.4 光标位置控制

shouldKeepCursorAtEndOfInput 参数控制格式化后光标的位置:

return TextEditingValue(
  text: formatted,
  selection: shouldKeepCursorAtEndOfInput
      ? TextSelection.collapsed(offset: formatted.length)
      // 强制光标到末尾
      : newValue.selection,
      // 保持原始光标位置
);
shouldKeepCursorAtEndOfInput 光标行为 适用场景
true(默认) 每次格式化后光标跳到末尾 顺序输入
false 保持用户的光标位置 中间编辑

注意:当 shouldKeepCursorAtEndOfInput = false 时,如果格式化改变了文本长度(如插入空格),光标位置可能不准确。这是一个已知的限制。


十一、与 format() 的深度对比

11.1 执行路径对比

format()(异步):
  App → Dart format()
    → MethodChannel.invokeMapMethod()
    → StandardMessageCodec 编码
    → BinaryMessenger 传输
    → ArkTS onMethodCall()
    → handleFormat()
    → AsYouTypeFormatter.inputDigit() × N
    → Map → Record → result.success()
    → BinaryMessenger 回传
    → StandardMessageCodec 解码
    → Future<Map> 完成
  总步骤: ~12 步

formatNumberSync()(同步):
  App → Dart formatNumberSync()
    → getCountryDataByPhone()(可选)
    → getPhoneMask()
    → PhoneMask.apply()
    → return String
  总步骤: ~4 步

11.2 格式化精度对比

对比项 format() formatNumberSync()
格式化引擎 AsYouTypeFormatter PhoneMask
国家专用格式 10 国有专用函数 统一 Mask 匹配
分隔符 按国家规则(空格/连字符/括号) 由 Mask 决定
部分号码 支持(逐字符) 支持(Mask 截断)

11.3 结果差异示例

对于大多数国家,两者结果一致。但某些情况下可能有细微差异:

美国号码 +12015550123:
  format():           '+1 201 555 0123'
  formatNumberSync(): '+1 201-555-0123'(如果 Mask 含连字符)

差异来源:format() 使用 AsYouTypeFormatter 的通用 formatPartialNumber()(用空格分隔),而 formatNumberSync() 使用预定义的 Mask(可能含连字符或括号)。


十二、在 LibPhonenumberTextFormatter 中的使用

12.1 调用位置

class LibPhonenumberTextFormatter
    extends TextInputFormatter {

  
  TextEditingValue formatEditUpdate(
    TextEditingValue oldValue,
    TextEditingValue newValue,
  ) {
    // 每次按键都调用 formatNumberSync
    final formatted =
        FlutterLibphonenumberPlatform.instance
            .formatNumberSync(
      newValue.text,
      country: country,
      phoneNumberType: phoneNumberType,
      phoneNumberFormat: phoneNumberFormat,
      inputContainsCountryCode:
          inputContainsCountryCode,
    );
    // ...
  }
}

12.2 调用频率

用户输入 '+8613123456789'(14 个字符):

按键 1: '+' → formatNumberSync('+') → '+'
按键 2: '8' → formatNumberSync('+8') → '+8'
按键 3: '6' → formatNumberSync('+86') → '+86'
按键 4: '1' → formatNumberSync('+861') → '+86 1'
...
按键 14: '9' → formatNumberSync('+8613123456789')
              → '+86 131 2345 6789'

共调用 14 次 formatNumberSync()

每次调用都必须在 微秒级 完成,否则用户会感受到输入延迟。这就是为什么必须使用同步方案。


十三、边界情况处理

13.1 空输入

formatNumberSync('')getCountryDataByPhone('')nullreturn ''  // 返回原始空字符串

13.2 无法识别的国家

formatNumberSync('+999123456')getCountryDataByPhone('+999123456')nullreturn '+999123456'  // 返回原始输入

13.3 输入比 Mask 短

Mask:  '+00 000 0000 0000'
输入:  '+861312'
清理:  '861312'

匹配到 i=7 时 index >= chars.length → break
结果:  '+86 131 2'

13.4 输入比 Mask 长

Mask:  '+00 000 0000 0000'(14个数字位)
输入:  '+86131234567890000'
清理:  '86131234567890000'

匹配到 i=mask.length 时循环结束
多余的数字被忽略
结果:  '+86 131 2345 6789'

总结

本文深入分析了 formatNumberSync() 同步格式化与 Mask 匹配原理。关键要点回顾:

  1. formatNumberSync() 完全在 Dart 侧执行,无需 MethodChannel 通信,耗时 < 0.1ms
  2. 执行流程分 5 步:确定国家 → 获取 Mask → 创建 PhoneMask → 应用 Mask → 可选去区号
  3. PhoneMask.apply() 的核心算法是 逐位匹配:Mask 中的 0 用输入数字替换,其他字符直接输出
  4. inputContainsCountryCode 参数决定是否使用含区号的 Mask,影响格式化结果
  5. format() 相比,formatNumberSync() 更快(~30 倍)但格式化精度依赖预定义 Mask
  6. 它是 LibPhonenumberTextFormatter 实时输入格式化的核心,每次按键都会调用

下一篇我们将分析 parse() 号码解析与元数据提取实现,了解如何从一个电话号码字符串中提取出 country_code、e164、national、international、type 等 7 个字段。

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!


相关资源:

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐