React Native for OpenHarmony 三方库 expo-localization 57.0.2 适配实战:读取语言、地区和日历设置
本文把一个看似“返回几个字符串”的库拆开讲清楚:系统语言和系统地区不是一回事,日历与时区也不是固定常量,Hook 还涉及订阅和卸载。所有结果均来自最终精简包和鸿蒙真机,不把 mock 结果冒充设备结果。
适配仓库:oh-react-native/expo-localization
交付分支:main
适配 TAG:57.0.2-ohos-1.0.0
受测提交:27f1372368e7023c8a326964a1b29a897d710e49
上游基线:9e5319c0f821a27b7924841903abae50e2b41790(对应 57.0.2 发布源码)
配套源码:expo-localization
一、这个库解决什么问题
国际化不是把界面翻译成几种语言就结束。应用还要知道用户偏好的语言顺序、系统地区、货币符号、千分位和小数点、温度单位、第一天是星期几、当前日历和时区。expo-localization 把这些信息统一成 JavaScript API,公开的四个入口是 getLocales、getCalendars、useLocales 和 useCalendars。前两个适合一次性读取,后两个适合页面长期观察系统设置变化。
语言与地区必须分开理解。用户可能偏好加拿大英语,但系统地区仍是英国;语言标签里的地区是语言偏好上下文,系统 locale 的地区是格式化和货币上下文。适配时把两者都写成同一个国家代码,会在真实用户环境中产生错误金额和日期格式。本次实现还保留 languageRegionCode、languageCurrencyCode 等 57.0.2 字段,不能按老版本的简化字段表硬编码。
编码前检查了 oh-react-native 组织、CPF-RN 组织、归档清单和活动中心 manifest,覆盖去 scope 名称与 rntpc 前缀,没有命中同上游的有效适配。上游 MIT 许可证和 Unicode CLDR 许可证按来源保留。
图 1:真机首选语言顺序与系统地区。语言列表是用户偏好,不是设备支持语言全集。
二、验证环境和交付边界
受测环境为 React Native 0.84.1、React 19.2.3、RNOH 0.84.3、DevEco Studio 26.0.0 Release、HarmonyOS SDK 26.0.0,系统 OpenHarmony-7.0.0.105。精简仓库保留 src/index.ts、ExpoLocalizationTurboModule.ts、TurboModule Spec、harmony/expo_localization/Index.ets、ExpoLocalizationPackage.cpp、LocaleData.cpp、ExpoLocalizationPackage.ets、HAR、双语 README、测试、spec.json 和许可证。完整宿主、签名 HAP、设置前后 JSON、日志和截图不进入公开库,文章中的图片是公开发布后的真机截图。
这类库属于“原生读取 + JS Hook”混合实现。语言和日历查询需要 ArkTS 读取系统配置,货币、符号和分隔符依赖 C++ ICU;Hook 还要管理系统观察器。仅通过 JS mock 能证明类型和失败契约,不能证明手机真的切换了时制后 Hook 会更新。
三、从 API 到系统的调用链
React 组件 -> src/index.ts 的 getLocales/getCalendars 或 Hook -> ExpoLocalizationTurboModule Spec -> C++ Package 与 JSI/ICU -> ArkTS ExpoLocalizationPackage -> HarmonyOS locale、calendar、timeZone 和设置观察getLocales 返回语言数组,每项包含 languageTag、languageCode、languageRegionCode、textDirection、digitGroupingSeparator、decimalSeparator、measurementSystem、currencyCode 和 currencySymbol 等字段。getCalendars 返回 calendar、timeZone、firstWeekday、hourCycle、uses24hourClock、languageTag 等字段。getCalendars 的系统首日枚举从周一为 1,而公开 API 约定周日为 1,所以适配中使用 state.firstWeekday % 7 + 1,并对 1 到 7 之外的值返回 null。
系统地区和语言地区分别计算。语言标签缺少地区时才使用系统地区补全;货币按 locale 的语义读取,不把设备地区的货币强行覆盖到所有首选语言。ICU 查询还覆盖阿拉伯数字分隔符与非法 BCP-47 标签,避免只用中文环境写出“看起来正常”的实现。
图 2:七组 ICU 查询返回货币代码、符号和分隔符,三个非法标签被拒绝。
四、具体改了哪些源码
- src/index.ts 保留 Expo 的公开函数和 Hook,业务侧不需要换 API。
- ExpoLocalizationTurboModule.ts 负责把 JS 调用转到同名 TurboModule,并把原生结果转换为 57.0.2 的字段形状。
- harmony/expo_localization/Index.ets 和 ExpoLocalizationPackage.ets 注册 Package 工厂,保证模块名在 JS、ArkTS、C++ 三处一致。
- LocaleData.cpp 使用 ICU 处理 locale、货币、数字符号与非法标签;不把 ArkTS 的默认显示字符串当成 ICU 结果。
- ArkTS 读取系统语言、地区、日历、时区和 12/24 小时制,首个 Hook 订阅观察器,最后一个 Hook 卸载时移除。
- HAR 随库发布,CMake、oh-package.json5 和自动链接配置保留,避免消费方只能拿到 JS 而缺少原生实现。
两个 Hook 使用 useSyncExternalStore 共享观察结果。快照没有改变时保留相同对象身份,减少无意义重渲染;初始化查询和后续事件存在先后竞争,所以卸载后不再 setState,较晚到达的旧快照也不能覆盖新事件。
图 3:日历、时区、首日、原时制和首选语言由库结果与独立探针交叉核对。
五、用实际 tgz 接入 RNOH 宿主
最终验证使用与文首 TAG 对应的 expo-localization-57.0.2.tgz。把 tgz 放入独立宿主后执行:
export RNOH_HOST="$HOME/rnoh-qa" export EVIDENCE_DIR="$RNOH_HOST/evidence/expo-localization" mkdir -p "$EVIDENCE_DIR" cd "$RNOH_HOST" npm install "$HOME/Downloads/expo-localization-57.0.2.tgz" --save-exact ./node_modules/.bin/react-native link-harmony cd harmony ohpm install --all cd .. ./node_modules/.bin/react-native bundle-harmony --dev false --sourcemap-output "$EVIDENCE_DIR/bundle.map" hvigorw assembleHap --mode module -p product=default --no-daemon等待 npm 完整结束后再运行宿主自己的 CLI;不要用 npx 让工具临时下载另一套 RN。link-harmony 生成 HAR 与 Package 的自动链接,ohpm 安装鸿蒙依赖,bundle-harmony 生成资源 bundle,hvigorw 负责 HAP。只有 source map 指向安装包中的 expo-localization 文件,才能证明本轮真机确实测到了这个 TAG。只读语言信息不需要新增系统写入权限。
安装最终 HAP 后可用下面的命令固定设备、保持屏幕常亮并保存一张现场截图:
hdc list targets export DEVICE_ID="$(hdc list targets | awk 'NF {print $1; exit}')" hdc -t "$DEVICE_ID" install "$RNOH_HOST/output/tested.hap" hdc -t "$DEVICE_ID" shell power-shell wakeup hdc -t "$DEVICE_ID" shell power-shell timeout -o 2147483647 hdc -t "$DEVICE_ID" shell aa start -a EntryAbility -b com.example.rnqa hdc -t "$DEVICE_ID" shell uitest screenCap -p /data/local/tmp/localization.png hdc -t "$DEVICE_ID" file recv /data/local/tmp/localization.png "$EVIDENCE_DIR/localization.png"验证结束后恢复原屏幕超时;设备常亮不是库功能,也不能写进用户的生产配置。
六、业务侧完整调用示例
当前版本的 getLocales 和 getCalendars 是同步 getter:调用时直接返回数组,不需要 await。它们读取的是模块维护的最新快照;需要随着系统设置变化刷新页面时,再使用下面的 Hook。
import * as Localization from 'expo-localization'; export function readLocale() { const locales = Localization.getLocales(); const calendars = Localization.getCalendars(); return { languageTag: locales[0]?.languageTag, region: locales[0]?.regionCode, timeZone: calendars[0]?.timeZone, hourCycle: calendars[0]?.hourCycle, }; }在组件中观察变化:
const locales = Localization.useLocales(); const calendars = Localization.useCalendars(); return ( <Text> {locales[0]?.languageTag} / {calendars[0]?.timeZone} </Text> );Hook 应该由 React 组件调用,不能在普通工具函数里直接调用。组件卸载时由库内部解除观察;如果业务自己封装订阅,也必须在 useEffect 的清理函数中取消。系统变化是异步的,后台调度可能延迟,不应把 500ms 观察周期理解成绝对实时承诺。
七、真机验证如何做
验证宿主先读取系统原始设置,再显示库的 getLocales 与 getCalendars。首轮结果为简体中文、中国、Asia/Shanghai 和 12 小时制。随后打开系统设置,将时制切换为 24 小时,等待页面收到真实变化,再卸载 Hook;最后恢复原来的 12 小时制并重新挂载,检查卸载期间没有旧回调继续更新页面。
图 4:两个 Hook 的初始结果与同步 getter 一致,设置不变时没有重复提交。
图 5:系统设置应用实际切换到 24 小时制,Calendar Hook 更新;随后卸载两个 Hook,事件计数停止。
图 6:恢复原 12 小时制后重新挂载,读到当前系统值,卸载阶段没有组件更新。
本轮还测试了七组 ICU 查询、非法标签、日历字段、Hook 挂载/卸载、重挂载、设置恢复和两个不同进程的离线 bundle 冷启动。–dev false 不是 release HAP 标志,本轮使用签名 debug HAP;发布时应重新生成自己的 HAP 和截图。
八、真实问题和边界
第一版曾把首日星期直接返回鸿蒙枚举,中文环境看不出问题,换到星期日开周的用户就会错一天;最终用完整七天契约测试和真实字段核对修复。第二个问题是独立 C++ 小程序虽能交叉编译,却不能在手机临时目录执行,最后改为在签名宿主内调用 ICU,才真正覆盖 JSI 和动态库接线。首轮日志采集过晚丢失了前半段输出,run-02 改为从受测宿主 PID 启动前持续采集。
语言、地区、温度单位变化以及系统查询失败由 mock 覆盖;真机重点覆盖语言/地区读取、时制变化、Hook 生命周期和冷启动。没有覆盖其他 ROM、长期后台功耗、平板和多用户配置。不要把一次设备结果扩大成所有 HarmonyOS 版本都完全相同。
九、参考链接
- 上游 expo-localization 文档
- 适配仓库首页
- main 分支
- 57.0.2-ohos-1.0.0 TAG
- OpenHarmony 国际化与语言区域 API
- Unicode CLDR 项目
欢迎加入 RN for OpenHarmony 社区。