把 Flutter 组件 humanize 往鸿蒙 HarmonyOS 上搬,第一反应往往是“纯 Dart 包,跑起来还不简单”,真正动手才发现,要在鸿蒙端实现人性化数据格式的丝滑语义展示、让国际化数字感知和原生体验对齐,踩的坑比预想多得多。最近我在一个跨平台改造项目里,需要把一套基于 Flutter 的数据展示组件从移动端迁移适配到鸿蒙环境,其中就包括这条叫 humanize 的组件线。它负责把时间戳、大数字、字节数、多语言复数这些“机器数据”翻译成“人话”,是整个界面观感里最不起眼、但一错就很显眼的部分。
humanize 这类组件在 Flutter 生态里不算大,但地位很特殊。你打开任何一个资讯 App、文件管理器、数据看板,看到的“3 分钟前”“1.2 万阅读”“2.5 MB”背后都是它在工作。没有它,界面会直接甩给你一堆1719234567890、12345678这样的原始数据,产品体验瞬间回到上世纪。这次迁移让我把 humanize 的代码逻辑、intl 依赖关系、鸿蒙端的系统能力差异都过了一遍,也踩了不少只在鸿蒙上才会出现的兼容性坑。如果你也在做 Flutter 组件往鸿蒙迁移,这篇实战记录应该能帮你少走几段弯路。
1. 为什么做这次适配:鸿蒙上“能跑”和“跑得对”是两码事
1.1 humanize 组件到底解决了什么问题
先给不熟悉的读者补个背景。humanize 不是一个花哨的 UI 组件,它是数据格式化层的集合,核心能力是把程序内部使用的原始数据,转成人类日常阅读习惯的文本。最常见的四类能力:
- 相对时间:把
DateTime或时间戳变成“刚刚”“5 分钟前”“昨天 14:30”“3 天前” - 数字缩写:把
12345678变成12.3M,在中文环境下应该是1234.5万或1.23亿 - 文件大小:把
1572864变成1.5 MB,而不是「1572864 bytes」 - 复数规则:英文里
1 hour和2 hours的区别,中文没有但英文环境必须有
这种能力在动态列表、个人中心、运营后台里到处都是。我这次迁移的组件库里,humanize 被封装在了一个统一的“展示格式化”服务后面,所有界面文本统一走它,不改底层,几乎不可能单独绕过。
1.2 “跑起来”容易,“语义一致”才是鸿蒙适配的真难点
Flutter 官方支持鸿蒙之后,很多纯 Dart 包理论上是可以直接编译运行的,humanize 也不例外。但“能编译、不崩溃”只代表它跑起来了,不代表它跑得对。鸿蒙端的差异藏得很深:
一是 locale 探测机制不同。Android 上用WidgetsBinding.instance.platformDispatcher.locale拿到的系统语言,在鸿蒙上可能出现小版本差异,特别是在系统语言切换之后,返回值可能不是你以为的那个值,后面我会详细讲这个坑。
二是 intl 数据初始化方式不同。humanize 底层大量依赖intl包里的DateFormat、NumberFormat和plural逻辑。在 Android 上这些依赖跑得很顺,但在鸿蒙上如果代码里只初始化了默认 locale,遇到zh_Hans、zh_Hant、en_US切换时,格式化结果可能直接退化回英文。
三是区域习惯差异。鸿蒙面向的市场更广,设备上的地区格式、数字系统选择、日历偏好都可能和 Android 默认值不一样。比如阿拉伯语区域会期望数字显示成东阿拉伯数字,而不是我们常见的阿拉伯数字。
所以说,把 humanize 迁移到鸿蒙,要做的不只是让代码过编译,而是做一次“语义对齐”——确保它在鸿蒙系统下读到的区域信息、格式化出的文本、处理多语言的方式,都和用户在 Android 上体验到的完全一致。这也是我把这次工作定义成“适配”而不是“移植”的原因。
2. 开工前的准备:环境、依赖与适配面盘点
2.1 搭建鸿蒙 Flutter 开发环境
如果你的主力开发机之前是给 Android 用的,第一件事不是改代码,而是把鸿蒙的 Flutter 工具链准备好。鸿蒙端的 Flutter 支持来自社区维护的flutter_flutter分支,它和官方主分支在工具链层面有区别,不能直接用flutter pub get一把梭。
我当时的搭建步骤大致是这样:
# 拉取鸿蒙 Flutter SDK 分支 git clone -b harmonyos https://github.com/flutter-tizen/flutter.git flutter_ohos # 配置环境变量 export FLUTTER_HOME=/path/to/flutter_ohos export PATH=$FLUTTER_HOME/bin:$PATH # 确认设备能被识别 flutter devices这里有个容易忽略的点:鸿蒙 Flutter 分支要求配套安装鸿蒙官方的集成开发环境和命令行工具,同时工程里需要配置鸿蒙 SDK 路径。建议第一次跑起来后,先创建一个空白 Flutter 工程,跑通flutter run再动真实项目,否则很容易把“环境问题”和“humanize 适配问题”混在一起排查。
提示:优先用真机调试。鸿蒙模拟器在系统语言、时区、区域设置上的行为不如真机完整,而 humanize 恰恰是最依赖这些系统参数的组件,在模拟器上“看起来正常”的格式化结果,到真机上可能就不对了。
2.2 依赖盘点:humanize 是纯 Dart,但它的依赖不是“完全无风险”
动手改代码前,我习惯先花半小时把依赖树看一遍。humanize 的直接依赖只有一个主要外部包:intl。这不是我拍脑袋说的,你可以自己确认:
flutter pub deps | grep -E "humanize|intl"依赖输出里intl的大版本号决定了你能否使用最新的NumberFormat.compact和DateLocale能力。我当时检查下来,humanize 本身不包含任何 Android/原生插件代码,所有逻辑都是纯 Dart,按道理直接就能在鸿蒙上跑。但要注意,intl 包的 locale 数据文件是通过initializeDateFormatting按需加载的,很多项目为了省事直接在main()里初始化了全部语言数据,这在 Android 上没问题,在鸿蒙上会因为语言标签匹配问题多出一些意外。
另外,如果你的项目里还有其他依赖间接引用了 platform channel 插件,要特别留意。下面是通用的依赖风险等级参考:
| 组件类型 | 是否含原生代码 | 鸿蒙适配成本 | 说明 |
|---|---|---|---|
| 纯 Dart 组件 | 否 | 低 | humanize 这类,主要做语义验证 |
| 自带 MethodChannel 插件 | 是 | 中高 | 需要找鸿蒙实现或自己写 PlatformChannel |
| 依赖 FFI 的组件 | 是 | 高 | 需要重新编译 so 库,适配成本最高 |
humanize 属于第一类,这是这次适配能顺利推进的底层前提。
2.3 设计 adapter 层,不要直接改 humanize 源码
很多人在小项目里图省事,直接把 humanize 的源码拷进项目改。我知道你的想法:就改一个方法,拷贝出来改多快。但我建议你不要这么做,原因有三个:
- 后续 humanize 升级,你本地改过的版本无法平滑合并
- 你自己的业务方会越写越依赖改过的私有实现,最后形成技术债
- 鸿蒙和 Android 同时在维护时,两边逻辑容易悄悄分叉
更好的做法是在项目里加一个HumanizeAdapter,把 humanize 的调用全部收口到这一层。这样以后想替换格式化实现、想针对鸿蒙做特殊处理、想统一测试,都有清晰入口。
abstract class HumanizeAdapter { String timeAgo(DateTime time, {String? locale}); String compactNumber(num value, {String? locale}); String fileSize(int bytes, {String? locale}); String plural(int count, String word, {String? locale}); }然后针对不同平台提供实现类,鸿蒙走HarmonyHumanizeAdapter。这样做的另外一个好处是:端上只需要依赖适配器接口,测试时可以方便地注入 mock,不会因为 humanize 版本更新导致全工程眼花缭乱。
3. 核心改造实录:时间、数字与国际化的三条主线
3.1 相对时间格式化:先解决 locale 匹配,再谈“丝滑”
相对时间是 humanize 里最常用的能力,也是鸿蒙适配中第一个出问题的点。核心代码如下,看起来很简单:
class HarmonyHumanizeAdapter implements HumanizeAdapter { @override String timeAgo(DateTime time, {String? locale}) { final now = DateTime.now(); final diff = now.difference(time); if (diff.inSeconds < 60) return '刚刚'; if (diff.inMinutes < 60) return '${diff.inMinutes} 分钟前'; if (diff.inHours < 24) return '${diff.inHours} 小时前'; // ... } }问题出在 locale 参数从哪来。鸿蒙端的platformDispatcher.locale可能返回zh_Hans、zh_Hant_CN这样的完整标签,也可能返回zh-CN这样的短标签。而 intl 包的DateFormat初始化时,需要的是能精确匹配的 locale 字符串。如果直接把系统返回的 locale 传给 intl,在部分鸿蒙版本上会初始化失败,格式化结果回落成英文。
我最终采用了一个显式映射表,把系统 locale 统一归一到 intl 能识别的标准标签:
final Map<String, String> _localeMap = { 'zh-Hans': 'zh', 'zh-CN': 'zh', 'zh-Hant': 'zh_Hant', 'zh-TW': 'zh_Hant', 'en-US': 'en', 'en-GB': 'en_GB', // 按业务需要继续补充 }; String _normalizeLocale(String? locale) { if (locale == null) return 'zh'; return _localeMap[locale] ?? locale.split('_')[0]; }同时记得在main()或 adapter 初始化时,加载你实际会用到的 locale 数据:
await initializeDateFormatting('zh'); await initializeDateFormatting('en');注意:如果你的业务涉及阿拉伯语、印地语这类区域,记得测试数字系统切换。阿拉伯语区域的数字显示会从
1234变成١٢٣٤,这不是 bug,而是 locale 感知的一部分。如果你的产品要覆盖这些市场,humanize 的适配必须顺着系统感知走,不能写死成拉丁数字。
3.2 数字缩写:英文的 K/M 和中文的 万/亿 切换
数字缩写是 humanize 的另一个高频能力。列表页里的“1.5K 次浏览”“3.2M 粉丝”,英文环境下大家看惯了,但中文环境下得显示成“1.5万”“320万”。这里面的核心是NumberFormat.compact,它的行为完全依赖 locale。
@override String compactNumber(num value, {String? locale}) { final normalizedLocale = _normalizeLocale(locale); return NumberFormat.compact(locale: normalizedLocale).format(value); }就是这个看起来人畜无害的NumberFormat.compact,在鸿蒙上暴露了一个细节:NumberFormat.compact内部依赖 intl 的 locale 数据被正确初始化。如果你只在初始化时加载了zh,当系统切成英文时,NumberFormat.compact会静默返回英文格式,不会报错,但这种“不说话”才是最难排查的。
另外,我强烈建议你在 adapter 层把“是否启用缩写”做成开关。原因很简单:数据看板、后台管理页这些 B 端场景,用户往往希望看完整数字,比如1234567,而不是1.2M这种缩写;而 C 端动态流里则相反。这个开关放在 adapter 层,鸿蒙和 Android 两端的视觉才能保持一致。
为了语言切换时能即时刷新,我用一个ValueNotifier<Locale>来广播当前生效的 locale:
class LocaleController { static final ValueNotifier<String> currentLocale = ValueNotifier('zh'); static void syncFromSystem() { final dispatcher = WidgetsBinding.instance.platformDispatcher; final locale = dispatcher.locale.toLanguageTag(); currentLocale.value = _normalizeLocale(locale); } }这样所有依赖 humanize 的界面,只需要监听currentLocale,语言切换后数字自动重算,界面上的“1.2万”瞬间刷新成“12.3K”,不用重启页面。
3.3 文件大小、复数与极端值:不起眼但最容易出丑
文件大小格式化看起来最没技术含量,但鸿蒙上的表现很容易被忽略。原版 humanize 的fileSize方法通常基于 1024 进制计算,输出KB、MB、GB。这里注意两个问题:
- 有的业务要求 1000 进制(存储行业常这么做),有的要求 1024 进制(文件系统这么做),adapter 层要暴露单位制参数
- 鸿蒙系统自带的文件管理 App 使用的是系统级格式化规则,和你组件里格式化出来的文本如果不一致,用户会产生困惑
复数处理也值得多写几行。英文里1 item和2 items不一样,intl提供了Intl.plural逻辑:
String plural(int count, String word, {String? locale}) { if (locale?.startsWith('en') ?? true) { return count == 1 ? '$count $word' : '$count ${word}s'; } return '$count $word'; }极端值更是 humanize 适配中不能轻视的部分。我测试时特别关注了这些边界:
- 0 值:
0 bytes应该显示成0 B,而不是0.00 KB - 负数和 NaN:页面不应该因此崩溃
- 未来时间戳:比如服务器和本地时间不同步时,
timeAgo会算出负数,这种情况下要兜底显示“刚刚”而不是“-5 分钟前”
这些逻辑看着多,但 adapter 层写完后,一次测完,两端通用,整体回报非常高。
4. 踩坑实录:鸿蒙端那些“看着正常实则不对”的问题
4.1 系统语言切换后 locale 不刷新
这是个典型的鸿蒙端专属坑。现象是:用户在系统设置里把语言从中文切成英文,再切回中文,部分应用内文本正常刷新,但 humanize 格式化出来的时间还是英文状态(5 minutes ago而不是5 分钟前)。
排查后发现,问题不在 humanize,而在于onLocaleChanged回调触发的时机。鸿蒙的某些系统版本上,应用从后台恢复时,这个回调不一定触发,或者触发时携带的 locale 仍是被缓存过的旧值。
我的解决方案是在 adapter 层增加一道保险:
class HarmonyHumanizeAdapter implements HumanizeAdapter { static String _lastLocale = ''; void syncLocaleFromSystem() { final dispatcher = WidgetsBinding.instance.platformDispatcher; final current = _normalizeLocale(dispatcher.locale.toLanguageTag()); if (current != _lastLocale) { _lastLocale = current; LocaleController.currentLocale.value = current; } } }然后在 App 生命周期恢复时主动调用一次。你可以在AppLifecycleListener里监听onResume,在每次界面恢复时强制同步一次 locale。这样才能确保 humanize 的格式化结果跟着系统走,而不是停留在上一次启动的值。
4.2 数字字体渲染导致的宽度跳动
这个坑很隐蔽,表现是:同一块区域,中文环境显示“1.2万”,切到英文环境变成“12.3K”后,文本宽度变化导致后面的头像、按钮轻微跳动。看起来像是布局代码写得不稳,实际上是你没有预留给不同 locale 数字格式不同的宽度。
原因在于英文缩写12.3K里的字母 K 宽度较宽,而中文数字1.2万的“万”是全角字符,两者在鸿蒙默认字体下的度量完全不同。解决方式不是去调 Container 宽度,而是从 humanize 输出层面统一控制展示策略:
Text( adapter.compactNumber(value, locale: currentLocale), style: TextStyle( fontFeatures: [FontFeature.tabularFigures()], ), overflow: TextOverflow.ellipsis, )FontFeature.tabularFigures()会让数字按等宽方式渲染,避免数字位数变化时布局抖动。然后给文本区域一个合理的最大宽度约束和省略策略,UI 就不会再跳了。
4.3 HAP 打包体积:intl 语言包被全量打入
鸿蒙发布应用时打包成 HAP,包体积比 Android 的 APK 更敏感。intl 默认会携带大量 locale 数据文件,如果项目里在初始化时图省事加载了所有语言,HAP 体积会明显增大。
解决思路很简单:按需加载,只初始化业务真正用到的语言。我们项目只需中英文,所以只加载两条:
await initializeDateFormatting('zh'); await initializeDateFormatting('en');如果你有动态语言需求,也建议延迟加载,而不是一次性全量初始化。这样操作之后,包体积会有肉眼可见的下降,而且 humanize 的格式化速度也会更快,因为不需要在每次调用时去查找未使用 locale 的数据文件。
5. 验证、发布与后续维护建议
5.1 适配完成后的验收清单
humanize 这类组件,光靠“看页面没报错”根本不够,必须按功能点逐一验证。我整理了一张自用清单,基本每个 migrator 项目都会拿它过一遍:
| 验证项 | 操作方式 | 预期结果 |
|---|---|---|
| 相对时间中文 | 系统语言设为中文,查看动态列表 | 显示“刚刚/5 分钟前/昨天” |
| 相对时间英文 | 系统语言切英文 | 显示“5 minutes ago/yesterday” |
| 数字缩写中文 | 大数值数字展示 | 显示“1.2万/3.4亿” |
| 数字缩写英文 | 系统语言切英文 | 显示“1.2K/3.4M” |
| 数字系统(阿拉伯语) | 系统设为阿拉伯语 | 数字变阿拉伯-印度数字 |
| 时区切换 | 修改设备时区,查看相对时间 | 时间差值符合预期 |
| 极端值 | 传 0、负数、NaN、未来时间戳 | 不崩溃、不显示负数时间 |
| 语言热切换 | 设置里切换语言后回到 App | 文本即时刷新,不重启页面 |
我在实践中的一个体会:极端值这块最不能省。你永远不知道线上会传过来什么数据,尤其是未来时间戳,我见过太多因为服务器时钟偏差导致用户看到“-3 分钟前”的线上事故,这种低级错误一旦出现,对产品口碑伤害非常大。
5.2 打包、版本管理与回归策略
适配完成后,发布流程也有两个注意点。
第一,HAP 打包配置。如果你的项目是混合工程,要确认 Flutter 产物被正确打进 HAP,并在鸿蒙工程配置文件里声明需要的系统能力。我们一开始漏了ohos.permission.INTERNET权限声明,导致打包后真机跑起来网络请求全部失败,查了很久才定位到。
第二,版本管理。humanize 上游会持续更新,你不要长期锁死版本。建议在 adapter 层和 humanize 之间做兼容性测试,每次升级时跑一遍上文提到的验收清单,重点看NumberFormat.compact的输出有没有变化。我在实际项目中就碰到过 intl 包从旧版本升级到新版本后,compact输出规则微调的情况。如果没有 adapter 层收口,这些变化会直接泄漏到业务代码里;有了 adapter 层,改动基本能控制在一天以内。
另外,建议把鸿蒙构建通道加入日常 CI。现在很多团队仍是“Android 构建绿了就当全平台绿了”,这种习惯在跨端项目里很危险。哪怕只是一个空项目跑一遍flutter build的鸿蒙产物,也能提前暴露很多依赖兼容性问题,等需求真正压过来时,至少不会因为“鸿蒙环境第一次配好”而耽搁时间。
写在最后的一个小技巧
这次适配下来,让我印象最深的不是某段代码,而是“调试思路要跟着系统走”。排查 humanize 在鸿蒙上的问题时,别马上看代码逻辑,先把这个命令跑一遍:
adb shell settings get system locale确认系统当前 locale 到底返回的是什么标签,再往下查 intl 是否初始化了这个标签。我那次 locale 不刷新问题,最初查了很久 Dart 代码都没头绪,最后发现是系统返回的 locale 是zh-Hans-CN而映射表没有涵盖这个写法,匹配不上才导致格式化退化。所以如果你也想快速定位这类问题,请记住:humanize 适配的第一性原理只有一个——它到底感知到了什么系统参数。抓住这一点,你就能在鸿蒙上把“人性化数据格式”这件事做得丝滑又从容。