做国际化的时候,第一步总是"先问系统":用户的首选语言是什么、地区在哪、用什么货币、小数点是点还是逗号、一周从周几开始、现在是 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-localization57.0.2(与 npm 上游 latest 一致)
React Native0.84.1
React19.2.3
@react-native-oh/react-native-harmony(npm)0.84.3
@rnoh/react-native-openharmony(ohpm)0.84.3
Compile SDK26.0.0
DevEco Studio26.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

三个要点:

  1. 它是"带原生实现的适配包"。和纯 JS 库不同,它必须编译原生代码,所以不能只 npm install 就完事,还要走 ohpm 和 hvigor。
  2. files 字段里包含 harmony,所以从 git 装依赖时能直接拿到 HAR。
  3. 它没有依赖 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
适配 TAG57.0.2-ohos-1.0.0
ohpm 包名@react-native-ohos/expo-localization
HARharmony/expo_localization.har(4.6 KB)
基线 commit9e5319c0f821a27b7924841903abae50e2b41790
宿主工程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 Native0.84.1
React19.2.3
RNOH(npm / ohpm)@react-native-oh/react-native-harmony / @rnoh/react-native-openharmony 0.84.3
Node.jsv24.14.0
DevEco Studio26.0.0.621
HarmonyOS SDKAPI 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

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐