React Native for OpenHarmony 实战:三方库 expo-localization 的鸿蒙化适配指南
做国际化的时候,第一步总是"先问系统":用户的首选语言是什么、地区在哪、用什么货币、小数点是点还是逗号、一周从周几开始、现在是 12 小时制还是 24 小时制。expo-localization 就是干这个的——它是 Expo 体系里的基础库,上层做多语言、货币格式化、日历展示的库经常会依赖它。
这个库在鸿蒙上没有官方实现,所以我做了一版适配。本文把整条链路写清楚:从上游同步、鸿蒙实现怎么写、到接入宿主并跑通,最后把实测读数和一处实测中发现的字段差异如实记录下来。
环境准备:本文不重复环境搭建步骤。RNOH(React Native for OpenHarmony)开发环境的完整配置见官方开发者指南:
https://atomgit.com/CPF-RN/docs/blob/main/开发者指南/02-搭建准备/环境初始化.md
一、版本配套:四件套必须对齐
RNOH 项目有个硬约束:RN 版本、RNOH 的 npm 包、RNOH 的 ohpm 包、DevEco SDK 四者必须对齐,错一个就是编译报错或者白屏。而且版本矩阵只是通用参考,具体库验证过的组合才算数。
我这次锁定的组合:
| 项 | 版本 |
|---|---|
expo-localization | 57.0.2(与 npm 上游 latest 一致) |
| React Native | 0.84.1 |
| React | 19.2.3 |
@react-native-oh/react-native-harmony(npm) | 0.84.3 |
@rnoh/react-native-openharmony(ohpm) | 0.84.3 |
| Compile SDK | 26.0.0 |
| DevEco Studio | 26.0.0 Release |
| 实现方式 | TurboModule + CAPI 架构 |
写文章前我用
npm view expo-localization version核对过,上游 latest 就是57.0.2,和适配 TAG 的上游部分一致。这一步别省——上游一旦发新版,文章里的版本表立刻过期。
二、适配步骤
第一步:上游同步到 AtomGit
expo-localization 不是独立仓库,它是 expo/expo monorepo 里的一个包:packages/expo-localization。
我的做法是在 oh-react-native 组织下建一个独立仓库,把上游这个包的源码同步过来,并锁死基线 commit:
upstreamCommit: 9e5319c0f821a27b7924841903abae50e2b41790
锁 commit 这一步不能省。上游是 monorepo,包目录会跟着主仓一起动;不锁基线的话,以后想复现"这版适配对应上游哪份代码"就说不清了。这条信息我写进了仓库的 spec.json。
第二步:本地克隆
git clone https://atomgit.com/oh-react-native/expo-localization.git
cd expo-localization
第三步:确定交付分支与版本号
适配包和普通库不一样,它是要被别的主程按版本引用的,所以版本号必须能一眼看出"上游版本 + 鸿蒙实现版本"。
我用 main 作开发分支,完成后打 TAG 交付:
git tag 57.0.2-ohos-1.0.0
命名规则是 <上游版本>-ohos-<适配版本>。调用方按 TAG 引用,就不会被后续改动影响到:
"expo-localization": "git+https://atomgit.com/oh-react-native/expo-localization.git#57.0.2-ohos-1.0.0"
有个细节要留意:HAR 工程自己的
oh-package.json5里写的版本是57.0.2-ohos.1,和 git TAG 的57.0.2-ohos-1.0.0写法不同。引用时以 git TAG 为准,HAR 内部那个版本号只是 ohpm 侧的标识。
第四步:适配实现——新增了什么、为什么
这是核心。上游给的是 iOS/Android 实现,鸿蒙侧要从零写。
新增的第一块是 HAR 工程 harmony/expo_localization/:
harmony/expo_localization/
├── Index.ets # 导出 ExpoLocalizationPackage
├── oh-package.json5 # 声明包名 @react-native-ohos/expo-localization
├── build-profile.json5
└── src/main/
├── module.json5
├── cpp/ # CAPI 架构下的 C++ 侧
│ ├── CMakeLists.txt # 链接 rnoh 与 SDK 的 libicu.so
│ ├── ExpoLocalizationPackage.h/.cpp # Package + TurboModule 工厂 + JSI 方法
│ ├── LocaleData.h/.cpp # ★ ICU 查询实现
└── ets/
├── ExpoLocalizationPackage.ets # 把 TurboModule 交给 RNOH
└── ExpoLocalizationTurboModule.ts # ★ ArkTS 侧实现
第二块是 TurboModule 的实现。上游 JS 侧声明了四个原生方法,我逐个落到鸿蒙:
| JS 侧原生方法 | 鸿蒙实现 |
|---|---|
getStateJSON() | 读出系统语言列表、地区、温度单位、日历、时制、每周首日、时区,序列化成 JSON 返回 |
getLocaleData(locale) | 交给 C++ 用 ICU 查货币代码/符号与数字分隔符 |
startObserving() | 起一个 500ms 定时器,状态变了才派发 changed 事件 |
stopObserving() | 停表并清空上次快照 |
上层再包出四个公开 API:getLocales()、getCalendars()、useLocales()、useCalendars()。这个划分是照着上游来的,调用方代码不用改。
第三块是 package.json 里的 autolinking 声明:
"harmony": {
"alias": "expo-localization",
"autolinking": {
"ohPackageName": "@react-native-ohos/expo-localization",
"etsPackageClassName": "ExpoLocalizationPackage",
"cppPackageClassName": "ExpoLocalizationPackage",
"cmakeLibraryTargetName": "rnoh_expo_localization"
}
}
这四个名字是 RNOH 找到这个包的凭据。少一个或者拼错,表现都是"编译过了但模块没注册",运行时才发现,很难查。
第五步:补全适配仓库所需的额外文件
上游 README 原文我没动,适配相关的东西单独成文件:
| 文件 | 作用 |
|---|---|
README.OpenHarmony.md / README.OpenHarmony_CN.md | 适配说明:能力对照、版本配套、接入方式、已知限制 |
spec.json | 机器可读的适配规格:包名、模块名、方法清单、公开 API、版本配套、基线 commit、验证结论 |
RN_expo-localization+代码检查报告.md | 代码检查结论、真机场景与边界说明 |
harmony/expo_localization.har | 预编译产物(4.6 KB),随包分发,装依赖即可拿到 |
__tests__/ | 六项契约与 Hook 测试(node --test)+ 一份 ICU 查询的 C++ 断言测试 |
spec.json 里我记了一份验证数据,方便后来人核对:
"validation": {
"status": "pass",
"date": "2026-09-14",
"tag": "57.0.2-ohos-1.0.0",
"tests": 6,
"deviceScenarios": 6,
"localeVectors": 7,
"invalidLocaleRejections": 3,
"settingsRestored": true,
"rom": "OpenHarmony-7.0.0.105",
"hapSha256": "1debeeb57de25000b3836e5d7692f676d61a1ec19e8e23a6363668b813cd629c"
}
hapSha256 是当时产物的哈希。以后有人怀疑"你验的那版和现在这版是不是同一份",对一下哈希就知道。
第六步:代码推送
git push origin main
git push origin 57.0.2-ohos-1.0.0
三、这个适配包长什么样
克隆下来第一眼会有点意外:它没有 example/,也没有可运行的应用。
expo-localization/
├── package.json # 含 harmony.autolinking
├── spec.json # 适配规格
├── src/
│ ├── index.ts # 四个公开 API + 两个 Hook
│ ├── NativeExpoLocalization.ts # TurboModule 的 TS 声明
│ ├── Localization.types.ts # Locale / Calendar 类型 + 两个枚举
│ └── data/measurementData.json # CLDR 48.2.0 地区默认值(含 Unicode LICENSE)
├── harmony/
│ ├── expo_localization.har # 预编译产物(4.6 KB)
│ └── expo_localization/ # HAR 源码
├── __tests__/
└── README.OpenHarmony*.md
三个要点:
- 它是"带原生实现的适配包"。和纯 JS 库不同,它必须编译原生代码,所以不能只
npm install就完事,还要走 ohpm 和 hvigor。 files字段里包含harmony,所以从 git 装依赖时能直接拿到 HAR。- 它没有依赖
expo-modules-core,是按 RNOH 的 TurboModule + autolinking 规范直接实现的。
四、接入宿主:三处改动面(外加一处自动生成的)
库本身不能独立运行,必须有一个 RNOH 宿主 App。社区已有现成的——oh-react-native/RNOH084Demo 是 RNOH 0.84.3 的多库验证宿主,版本和我这版适配完全一致,而且自带一个很实用的机制:
// harmony/entry/src/main/ets/entryability/EntryAbility.ets
const rnAppKey = want.parameters?.['rnAppKey'] as string | undefined;
AppStorage.setOrCreate('rnAppKey', rnAppKey ?? 'RNOH084Demo');
Index.ets 里 RNApp 的 appKey 取自它,于是一个宿主可以挂很多独立测试页,用命令行参数切换:
hdc shell aa start -b com.rnoh084.demo -a EntryAbility --ps rnAppKey LocalizationTestApp
而且它的 bundle 加载链已经是「Metro 优先 + 静态 bundle 兜底」,调代码不用改原生。
接入要改的地方
第一处:package.json。
"expo-localization": "file:../expo-localization"
也可以按 README 写的方式装:
npm install https://atomgit.com/oh-react-native/expo-localization.git#57.0.2-ohos-1.0.0,再跑./node_modules/.bin/react-native link-harmony。本地file:装的好处是不依赖网络,改完直接生效。
第二处:两级 oh-package.json5 都要写 HAR。
"@react-native-ohos/expo-localization":
"file:../node_modules/expo-localization/harmony/expo_localization.har",
harmony/oh-package.json5 管工程级、harmony/entry/oh-package.json5 管模块级,两处都要加。只加一处会出现"能找到包但链接不上"。
这里有个很容易漏的点:跑 link-harmony 时,它只会自动更新工程级那一份,模块级那份要你自己加。详见第七节坑一。
第三处:在 ETS 侧注册 Package。
// harmony/entry/src/main/ets/RNOHPackagesFactory.ets
import type { RNPackageContext, RNOHPackage } from '@rnoh/react-native-openharmony';
import ExpoLocalizationPackage from '@react-native-ohos/expo-localization';
export function createRNOHPackages(ctx: RNPackageContext): RNOHPackage[] {
return [
new ExpoLocalizationPackage(ctx),
];
}
代码写在哪,这里说清楚:手工改动面就是这三个文件(外加 metro.config.js 的 watchFolders,见坑二)。C++ 侧不用手改——CAPI 架构下 PackageProvider.cpp 会自动消费 autolinking 生成的 RNOHPackagesFactory.h。
那"自动生成的一处"是什么? 执行 link-harmony 时,它会一次性重写这四个文件:
• harmony/entry/src/main/cpp/RNOHPackagesFactory.h # C++ 侧注册
• harmony/entry/src/main/cpp/autolinking.cmake # add_subdirectory + 链接
• harmony/entry/src/main/ets/RNOHPackagesFactory.ets # ETS 侧注册
• harmony/oh-package.json5 # 工程级 HAR 依赖
跑完的日志会明确列出来:
[link] expo-localization
info updated 4 file(s), linked 2 libraries, skipped 1 libraries
这四个文件头部都写着 DO NOT modify it manually, your changes WILL be overwritten.——别手改,改了下次构建也会被覆盖。
五、实现上的设计点
点一:系统值走 ArkTS,货币与分隔符走 C++ 的 ICU
这是这个库和上一个库最不一样的地方:它不是纯 ArkTS 实现,而是 ArkTS + C++ 混合。
系统级的状态,ArkTS 的 @ohos.i18n 直接就能读:
const systemRegion = i18n.System.getSystemRegion();
const preferred = i18n.System.getPreferredLanguageList();
// 时制、每周首日、温度单位、时区同理
uses24hourClock: i18n.System.is24HourClock(),
firstWeekday: i18n.System.getFirstDayOfWeek(),
timeZone: i18n.getTimeZone().getID(),
但货币代码、货币符号、小数分隔符、数字分组分隔符这几项,要按"任意一个 locale 标签"去查,走 ICU 最直接。鸿蒙 SDK 里就带着 ICU,所以 C++ 侧直接链它:
add_library(rnoh_expo_localization SHARED ExpoLocalizationPackage.cpp LocaleData.cpp)
target_include_directories(rnoh_expo_localization PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(rnoh_expo_localization PUBLIC rnoh libicu.so)
查询本身分两步——先把 BCP 47 语言标签解析成 ICU 的 locale,再开一个货币格式化器取符号:
int32_t size = uloc_forLanguageTag(languageTag.c_str(), locale.data(), locale.size(), &parsed, &status);
// parsed != languageTag.size() 说明标签非法,直接拒绝
std::unique_ptr<UNumberFormat, decltype(&unum_close)> formatter(
unum_open(UNUM_CURRENCY, nullptr, 0, locale.data(), nullptr, &status), &unum_close);
data.currencyCode = symbol(formatter.get(), UNUM_INTL_CURRENCY_SYMBOL);
data.currencySymbol = symbol(formatter.get(), UNUM_CURRENCY_SYMBOL);
data.decimalSeparator = symbol(formatter.get(), UNUM_DECIMAL_SEPARATOR_SYMBOL);
data.digitGroupingSeparator = symbol(formatter.get(), UNUM_GROUPING_SEPARATOR_SYMBOL);
入口处做了输入校验,不合格就抛,不返回"看起来正常"的空数据:
if (languageTag.empty() || languageTag.size() > 1024 || languageTag.find('\0') != std::string::npos)
throw std::invalid_argument("Invalid locale tag");
仓库里那份 C++ 测试就是拿七组 locale 对货币代码、再做三种非法标签的拒绝断言:
const char* locales[] = {"en-US","en-GB","en-CA","fr-FR","zh-Hans-CN","ar-EG","ja-JP"};
const char* currencies[] = {"USD","GBP","CAD","EUR","CNY","EGP","JPY"};
还有一个 C++ 侧的接线细节值得说:四个原生方法里,getStateJSON / startObserving / stopObserving 都用标准的 ARK_METHOD_METADATA(name, argc) 注册,但 getLocaleData 需要返回一个结构体,所以它单独用了一个 JSI lambda——直接在 runtime 里建 jsi::Object,并在参数个数/类型不对时抛 JSError:
{"getLocaleData", {1, [](facebook::jsi::Runtime& rt, facebook::react::TurboModule& module,
const facebook::jsi::Value* args, size_t count) -> facebook::jsi::Value {
if (count != 1 || !args[0].isString()) throw facebook::jsi::JSError(rt, "Expected a locale string");
...
}}},
这样 LocaleData 不用经过 ArkTS 的序列化往返,一次调用直接把对象交给 JS。
点二:regionCode 和 languageRegionCode 是两个来源
上游的 Locale 类型里有两个看着很像、语义不同的字段,我按上游的意图分开处理:
| 字段 | 来源 | 本次实测值 |
|---|---|---|
regionCode | 系统「地区」设置(i18n.System.getSystemRegion()) | CN |
languageRegionCode | 该首选语言自带的地区,缺省时补系统地区 | CN |
languageTag | 首选语言 + 补上的地区 | zh-Hans-CN |
补地区的逻辑在这里:
const parsed = new Intl.Locale(language);
const region = parsed.region || systemRegion;
const locale = !parsed.region && region ? new Intl.Locale(language, {region}) : parsed;
实测设备上系统语言是 zh-Hans(不含地区)、地区设置是「中国」,所以 languageTag 被补成 zh-Hans-CN、languageScriptCode 为 Hans——保留脚本子标签、只补地区,这是符合 BCP 47 的做法。
这个"补地区"的细节,恰恰是后面第九节那处字段差异的根因。设计是对的,但系统那一侧的查询用的是没补地区的标签,我在实测里撞上了。
点三:两个 Hook 共享一份原生观察
useLocales() 和 useCalendars() 不是各自起一个定时器,而是共享同一份原生观察:
function subscribe(listener: () => void): () => void {
listeners.add(listener);
if (listeners.size === 1) { // 第一个订阅者才启动原生观察
eventSubscription = DeviceEventEmitter.addListener('ExpoLocalization.changed', () => {
for (const callback of [...listeners]) callback();
});
try { Native.startObserving(); }
catch (error) { // 启动失败要回滚,别留半个订阅
eventSubscription.remove(); eventSubscription = undefined;
listeners.delete(listener); throw error;
}
}
...
if (!listeners.size) { // 最后一个订阅者卸载才停
eventSubscription?.remove(); eventSubscription = undefined;
Native.stopObserving();
}
}
原生侧每 500ms 读一次设置,只有内容真的变了才派发事件:
this.timer = setInterval((): void => {
const next = this.getStateJSON();
if (next === this.previous) return; // 没变就不打扰 JS
this.previous = next;
this.ctx.rnInstance.emitDeviceEvent('ExpoLocalization.changed', {});
}, 500);
JS 侧再用 useSyncExternalStore + 一个按内容比较的稳定快照,避免无变化时重复渲染:
function snapshot<T>(getValue: () => T): () => T {
let previousJSON: string | undefined, previous: T;
return () => {
const next = getValue(), json = JSON.stringify(next);
if (json !== previousJSON) { previousJSON = json; previous = next; }
return previous;
};
}
模块销毁时还会再兜一次底,并加了 destroyed 标志防止销毁后再启动观察:
override __onDestroy__(): void { this.destroyed = true; this.stopObserving(); }
六、构建与运行
# 1) 装 JS 依赖 + 自动链接
npm install
./node_modules/.bin/react-native link-harmony
# 2) 生成调试签名 + 装 ohpm 依赖
cd harmony
devecocli signature generate
ohpm install --all
# 3) 打包 JS bundle(输出到 harmony/entry/src/main/resources/rawfile/)
cd ..
npm run dev
# 4) 编译 HAP
cd harmony
hvigorw --mode module -p product=default -p module=entry@default assembleHap --no-daemon
# 5) 安装 + 启动测试页
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -b com.rnoh084.demo -a EntryAbility --ps rnAppKey LocalizationTestApp
耗时:在已经编译过一次的宿主上增量加入这个原生库,assembleHap 用了 7 分 54 秒(hvigor 总耗时 8 分 21 秒),HAP 从 79.1 MB 涨到 79.4 MB——新库本身只贡献了约 247 KB。
如果宿主是全新 clone(没有原生编译缓存),首次构建会到 30–40 分钟量级,因为 RNOH 的 C++ 体量大、而且为模拟器放开了两个 ABI。
还有个容易误判的地方:hvigor 打完 CompileArkTS 那一行之后就不再逐行输出了,原生阶段可能几十分钟没有新日志。别以为卡死了——去看进程,clang++ 还在跑就是正常的。
看日志:
hdc shell "hilog -x | grep -i 'ExpoLocalization'"
hdc shell "hilog -x | grep -i 'TM created'"
hdc shell "hilog -x | grep -i 'localization-test'"
看界面(读无障碍树,不用截图就能拿到文本):
devecocli ui layout
点击 / 滚动:hdc shell "uinput -T -c <x> <y>"、hdc shell "uinput -T -m <x1> <y1> <x2> <y2> <ms>"。
七、踩坑记录
坑一:link-harmony 不管模块级 oh-package.json5
这条最阴。执行 link-harmony 后日志写得很清楚:
• harmony/entry/src/main/cpp/RNOHPackagesFactory.h
• harmony/entry/src/main/cpp/autolinking.cmake
• harmony/entry/src/main/ets/RNOHPackagesFactory.ets
• harmony/oh-package.json5
info updated 4 file(s), linked 2 libraries, skipped 1 libraries
四个文件里没有 harmony/entry/oh-package.json5。它的 --oh-package-path-relative-to-harmony 参数默认只指向工程级那一份。而前面第四节说过,两级都要写 HAR,缺模块级那一处就是"能找到包但链接不上"这种不好排查的症状。
对策:每次接新库,link-harmony 跑完之后,手工把模块级那份也补上。
坑二:metro.config.js 的 watchFolders 要加库的真实目录
本地 file: 依赖装进 node_modules 之后是个链接(Windows 上是 Junction),不是真目录:
Name : expo-localization
LinkType : Junction
Target : {E:\rnoh-work\expo-localization}
不把库的真实目录加进 watchFolders,metro 解析不到它的源码:
watchFolders: [
path.resolve(__dirname, '../expo-keep-awake'),
path.resolve(__dirname, '../expo-localization'),
],
漏了它的表现是 ENOENT ... skipping 加 Failed to construct transformer,看着像文件丢了,其实是没被 watch 到。
顺带一个可以自检的好信号:bundle 打包成功时,Metro 会打印它重定向到鸿蒙实现的三方包清单——
[INFO] Redirected imports to 2 harmony-specific third-party package(s):
[INFO] • expo-keep-awake → expo-keep-awake
[INFO] • expo-localization → expo-localization
这里没有你的库,就说明 autolinking 没认出来。
坑三:uinput 点整行不切换开关,必须点 Toggle 本体
做验证时会被这个绊一下。系统设置里「24 小时制」那一行在无障碍树里是 clickable checkable:
Row#Setting.date_and_time.Time24HourGroup.Time24HourItem [60,345,1260,489] clickable checkable
Toggle#Setting.date_and_time.Time24HourGroup.Time24HourItem.result [1128,387,1236,447] clickable checkable
我按行的中心点 (660,417) 点了,开关纹丝不动——截图确认还是灰的。改点 Toggle 本体 (1182,417) 才生效。
要复现"设置变化触发 Hook"这类场景,坐标要取 Toggle#... 那一行的中心,不要取整行中心。
坑四:hilog 缓冲会滚动覆盖,TM created 会被冲掉
TM created: ExpoLocalization 只在 TurboModule 第一次创建时打一条。我一开始先跑完所有 UI 场景再回头抓日志,结果那几条早期记录已经被缓冲区挤掉了,只剩下后面的 callSync 耗时。
对策:装完 HAP 启动测试页之后立刻抓一次日志:
hdc shell "hilog -x | grep -i 'TM created'"
需要完整证据链的话,就按 PID 持续采集,别等最后一次性读。
坑五:增量编译 ≠ 全量编译
新增一个带 C++ 的库之后重新 assembleHap,只需要为新库编 C++ 再重新链接,7 分 54 秒;这和全新 clone 的 30–40 分钟差了四五倍。所以验证一个新库时尽量复用已编译过的宿主,能省下大量等待。
八、模拟器验证
验证环境:Pura X View 模拟器,HarmonyOS 7.0.0(26.0.0) Beta2,API 26,ohos-x64。系统状态:语言 zh-Hans、地区「中国」、时区 GMT+08:00 中国标准时间、24 小时制关闭。
我写了一个自检台测试页,覆盖四块:两个同步 getter、两个 Hook、导出面枚举、事件记录。
两个同步 getter
[localization-test] getLocales() -> 1 项;getCalendars() -> 1 项
页面上读到的完整字段:
locales[0] · zh-Hans-CN
languageTag=zh-Hans-CN
languageCode=zh
languageScriptCode=Hans
languageRegionCode=CN
regionCode=CN
textDirection=ltr
currencyCode=¤¤ ← 见第九节,这里有问题
currencySymbol=¤ ← 见第九节,这里有问题
languageCurrencyCode=CNY
languageCurrencySymbol=¥
decimalSeparator=.
digitGroupingSeparator=,
measurementSystem=metric
temperatureUnit=celsius
calendars[0]
calendar=gregory
uses24hourClock=false
firstWeekday=1 (即公开枚举的 SUNDAY)
timeZone=Asia/Shanghai

逐项对照都符合预期:zh-Hans + 地区中国 → zh-Hans-CN;中国用 metric + celsius;firstWeekday=1 是"周日",和系统返回的 7 经过 systemDay % 7 + 1 映射一致;时区来自系统时区对象。
两个 Hook
挂上开关之后:
[localization-test] 两个 Hook 已挂载,取得首个快照
useLocales()[0].languageTag = zh-Hans-CN
useCalendars()[0].uses24hourClock = false
useCalendars()[0].timeZone = Asia/Shanghai
快照长度 = 469 字节
变化次数 = 0(首次不算)· 最近一次 -

Hook 和 getter 读到的值完全一致,说明两者同源。
响应式:改系统设置,Hook 自动刷新
这是这个库最值得验的一条。打开系统设置 →「系统」→「日期和时间」→「24 小时制」:

回到测试页,Hook 已经自己变了:
useCalendars()[0].uses24hourClock = true ← Hook 从 false 变成 true
变化次数 = 1(首次不算)· 最近一次 11:53:53
hilog:
[localization-test] 收到原生通知:系统设置变化,Hook 快照已刷新
9A%84%E6%88%AA%E5%9B%BE&pos_id=img-8aEI5OIP-1790568265375)
再把设置改回去,又收到一次通知,变化次数 = 2、uses24hourClock 回到 false。原生侧的行为符合设计:状态变了才派发,一次变化一条事件。
顺便能看到两种 API 的区别:同一屏上 getCalendars() 那张卡片还停留在 false(同步读取是一次性的,要按「重新读取」才更新),而 Hook 那张卡片已经自动是 true。
卸载清理(负向验证)
把 Hook 开关关掉:
[localization-test] 两个 Hook 已卸载,已释放订阅与 500ms 轮询
卸载之后再去系统设置里改 24 小时制,变化次数 保持 2 不变、hilog 里也没有任何新的原生事件——说明订阅和定时器确实都停了,不是"看着卸载了其实还在跑"。

原生侧确认 TurboModule 真的注册了
RNInstance::TurboModuleProvider TM created: ExpoLocalization
TurboModuleFactory.cpp:54> Creating Turbo Module: ExpoLocalization
ArkTSTurboModule.cpp:133> ArkTSTurboModule::callSync: execution time — 59 ms (ExpoLocalization::getStateJSON)
ArkTSTurboModule.cpp:133> ArkTSTurboModule::callSync: execution time — 7 ms (ExpoLocalization::startObserving)
6%88%AA%E5%9B%BE&pos_id=img-LahIePgd-1790568265375)
这条很关键:它证明走的是真实实现,不是空壳。callSync 的耗时也顺带说明——首次 getStateJSON 要 59 ms(要遍历语言列表、开 ICU 格式化器),之后稳定在 3–7 ms。
能力对照
| 能力 | 结果 |
|---|---|
getLocales() | ✅ 返回首选语言列表,zh-Hans 补地区为 zh-Hans-CN,14 个字段齐全 |
getCalendars() | ✅ gregory / Asia/Shanghai / uses24hourClock / firstWeekday 均与系统设置一致 |
useLocales() / useCalendars() | ✅ 与 getter 同源;系统时制变化后自动刷新,一次变化一次通知 |
| 卸载清理 | ✅ 最后一个 Hook 卸载后停止轮询;卸载期间系统设置变化不再产生事件 |
Weekday / CalendarIdentifier 枚举 | ✅ 完整保留(SUNDAY=1 … SATURDAY=7;日历标识 19 个成员) |
currencyCode / currencySymbol | ⚠️ 本次实测返回 ICU 占位符 ¤¤ / ¤,见第九节 |
九、已知限制
一、currencyCode / currencySymbol 在这次实测里退化了(本文实测发现)。
先看实测值:
currencyCode=¤¤
currencySymbol=¤
languageCurrencyCode=CNY ← 同一个 locale,这两个是对的
languageCurrencySymbol=¥
¤ 是 ICU 的"未指定货币"占位符。根因是这两组字段的数据来源不同:
languageCurrencyCode/languageCurrencySymbol:用补过地区的语言标签zh-Hans-CN去查 ICU → 正确得到CNY/¥;currencyCode/currencySymbol:用系统 locale 原样去查,而本机上i18n.System.getSystemLocaleInstance()返回的是不含地区的zh-Hans,ICU 没有地区就推不出货币,于是返回占位符。
地区信息其实是另外单独取到的(i18n.System.getSystemRegion() → CN,所以 regionCode=CN 是对的),只是没有参与系统那一侧的货币查询。另外 JS 侧只把字面量 'XXX' 归一成 null,ICU 这次返回的是 ¤¤,所以也没被兜住,直接透传到了界面上。
实用建议:在鸿蒙上判断货币,用 languageCurrencyCode / languageCurrencySymbol;或者干脆按 regionCode 自己查表。
适配方自己的验证记录里,系统 locale 那台设备的 zh-Hans 补地区行为不同(7 组 ICU 查询用的是显式带地区的标签),所以这条路没有被覆盖到——这也是我把它写出来的原因:换 ROM、换系统语言设置,这个字段的表现可能不一样。
二、Hook 是 500ms 轮询,不是事件订阅。 鸿蒙没有对普通应用开放"系统设置变更"的订阅接口,所以实现是定时读取 + 内容比对 + 变化才派发。代价是每条订阅常驻一个 500ms 定时器;收益是空闲时不产生任何 JS 侧通知。后台运行的频率受系统调度限制,不承诺后台实时通知(本次测试中应用在后台时仍收到了通知,但这是调度允许的结果,不是保证)。
三、只返回一个日历。 鸿蒙侧返回当前生效的日历设置,未知日历类型返回 null,不伪装成公历。
四、measurementSystem 是"推导值",不是独立偏好。 优先读系统 locale 的 Unicode ms 扩展;没有就用 CLDR 48.2.0 的地区默认值(数据在 src/data/measurementData.json,随附 Unicode LICENSE)。它不代表用户在某个应用里的独立选择。
五、只读,不写。 这个库不修改系统语言、时区或时制,读取这些设置也不需要申请写系统设置的权限,作用域最小。
六、语言 / 地区 / 温度变化的 Hook 刷新未逐一验证。 本次实测打通并验证了「24 小时制」这一条最容易触发的路径(含正向刷新、恢复、卸载后不再通知)。语言、地区、温度变化理论上走同一条 changed 事件,但没有逐个实测。
七、非法语言标签的拒绝只能在原生层触发。 getLocaleData 的入参校验(空串、超长、内嵌 NUL、解析长度不匹配)有 C++ 断言测试覆盖,但它不是公开 JS API,应用层无法直接构造这种调用。
八、其他 ROM / 设备未验证。 适配方记录的是 OpenHarmony-7.0.0.105;本次在 HarmonyOS 7.0.0(26.0.0) Beta2 模拟器上通过。第九节第一条已经说明,货币字段在不同 ROM 上可能表现不同。
十、常见问题
Q:为什么不能直接 npm install expo-localization?
A:npm 上那个包只有 iOS/Android 实现,没有鸿蒙原生代码。本仓库是独立的鸿蒙实现,要按 git+...#57.0.2-ohos-1.0.0 或者本地 file: 的方式装。README 里也写明了这一点。
Q:需要额外依赖 expo-modules-core 吗?
A:不需要。这版是按 RNOH 的 TurboModule + autolinking 规范实现的,package.json 里的 harmony 字段就是它接入 RNOH 的全部凭据。
Q:为什么 currencyCode 显示成 ¤¤?
A:见第九节第一条。简单说:系统 locale 在这台设备上不含地区,ICU 推不出货币,就返回了"未指定货币"占位符。货币字段请用 languageCurrencyCode / languageCurrencySymbol(实测为 CNY / ¥)。
Q:Hook 为什么要用 500ms 轮询?
A:鸿蒙没有开放给普通应用的系统设置变更订阅。轮询 + 内容比对 + 变化才派发是折中方案:空闲时 JS 侧收不到任何通知,但确实有一个常驻定时器。不承诺后台实时性。
Q:getLocales() / getCalendars() 是同步的,读不到会怎样?
A:抛错,不返回空数组冒充成功。观察期间偶发的查询失败会记一条警告并在下一轮重试,保留最后已知状态。这一点比"静默返回默认值"安全——调用方能明确知道读取失败了。
Q:库里为什么不带 example/?我怎么跑起来?
A:这是 RN 适配包的常态——只有 src/ + harmony/。真正跑起来要靠 RNOH 宿主,本文用的是社区那个 RNOH084Demo 宿主,自带 rnAppKey 多测试页切换,--ps rnAppKey LocalizationTestApp 就能启动本库的测试页。
Q:怎么复现"设置变化触发 Hook"的截图?
A:① 打开测试页,把 Hook 开关打开(Toggle#Switch 中心约 (172,2131));② 去「设置 → 系统 → 日期和时间」,点「24 小时制」的 Toggle 本体(约 (1182,417)),不要点整行;③ 回到测试页,变化次数 会 +1、uses24hourClock 跟着变。测完记得改回去。
Q:怎么确认库真的生效了,而不是只是没报错?
A:四条证据一起看:① 界面上两个 getter 的完整字段;② Hook 与 getter 同源且能响应系统设置变化;③ 原生 hilog 里的 TM created: ExpoLocalization(证明 TurboModule 注册成功);④ callSync 的耗时日志证明真在走原生调用。只有第一条的话,看不出是不是空实现。
Q:为什么我编译要几十分钟?
A:看是不是首次编译。已有原生缓存的宿主增量加一个库是 7–8 分钟;全新 clone 的宿主首次编译要 30–40 分钟,因为 RNOH 的 C++ 体量大,而且为了跑 ohos-x64 模拟器放开了两个 ABI。只上真机的话去掉 x86_64 会明显缩短。
小结
这个库和纯 ArkTS 的适配包不太一样,最值得记的是三点:
- 拿系统值走 ArkTS,拿"按 locale 查询"走 C++ 的 ICU。
@ohos.i18n能直接读系统级状态(语言列表、地区、时制、每周首日、时区、温度单位),但货币符号、小数与分组分隔符这类"给定 locale 查 ICU"的活,直接链libicu.so最省事——代价是引入了 C++ 编译,也顺带把入参校验放到了原生层。 - Hook 用
useSyncExternalStore+ 共享原生观察:第一个订阅者启动 500ms 轮询、最后一个卸载时停掉,原生侧"内容变了才派发",JS 侧用稳定快照避免无谓渲染。这套组合让"响应式"是有代价但是可控的。 regionCode与languageRegionCode语义分离:一个来自系统地区设置,一个来自该语言自带地区(缺省补系统地区)。设计是对的,但系统那一侧的货币查询用的是没补地区的标签——这就是第九节那处¤¤的根因,也是我这次实测最大的收获。
适配链路本身,还是那几条老规律在起作用:
- autolinking 的四个身份名(ohpm 包名、ETS 包类、C++ 包类、CMake 目标),少一个都是"编译过了但模块没注册";
- HAR 的工程级与模块级双重声明,而且
link-harmony只会自动写工程级那一份,模块级要自己加; - 版本四件套必须对齐,且以实测组合为准。
最后说一句验证方法上的事:devecocli ui layout + uinput + hilog + snapshot_display 这一套组合,能不看屏幕就把界面状态读全(无障碍树里有全部文本、有坐标、有 clickable/checkable)。这次那处货币字段的差异,就是靠"界面读数 + 代码路径对照"才发现的——如果只跑一遍不报错就收工,这个字段会一直带着错值上线。
本篇用到的库
| 项 | 内容 |
|---|---|
| 三方库 | expo-localization(上游 57.0.2 的鸿蒙适配版) |
| 适配仓库 | https://atomgit.com/oh-react-native/expo-localization |
| 适配 TAG | 57.0.2-ohos-1.0.0 |
| ohpm 包名 | @react-native-ohos/expo-localization |
| HAR | harmony/expo_localization.har(4.6 KB) |
| 基线 commit | 9e5319c0f821a27b7924841903abae50e2b41790 |
| 宿主工程 | RNOH084Demo(测试页 rnAppKey = LocalizationTestApp) |
"expo-localization": "git+https://atomgit.com/oh-react-native/expo-localization.git#57.0.2-ohos-1.0.0"
// harmony/oh-package.json5 与 harmony/entry/oh-package.json5 都要加
"@react-native-ohos/expo-localization":
"file:../node_modules/expo-localization/harmony/expo_localization.har",
# 换页启动测试页
hdc shell aa start -b com.rnoh084.demo -a EntryAbility --ps rnAppKey LocalizationTestApp
验证环境
| 项 | 版本 |
|---|---|
| React Native | 0.84.1 |
| React | 19.2.3 |
| RNOH(npm / ohpm) | @react-native-oh/react-native-harmony / @rnoh/react-native-openharmony 0.84.3 |
| Node.js | v24.14.0 |
| DevEco Studio | 26.0.0.621 |
| HarmonyOS SDK | API 26(26.0.0.32) |
| 设备 | HarmonyOS 7.0.0(26.0.0) Beta2 模拟器 Pura X View(ohos-x64) |
| 宿主 HAP 产物 | entry-default-signed.hap(79.4 MB) |
| 本次增量构建 | assembleHap 7 分 54 秒 |
欢迎加入 CPF-RN 鸿蒙社区:https://atomgit.com/CPF-RN
React Native for OpenHarmony 组织:https://atomgit.com/oh-react-native
RN 三方库鸿蒙适配清单:https://atomgit.com/oh-react-native/rn-ohos-adaptation-overview
更多推荐



所有评论(0)