React Native for OpenHarmony 实战:三方库 expo-system-ui 的鸿蒙化适配指南
expo-system-ui 解决的是一个很具体的问题:设置 RN 根窗口的背景色。它不只是改一个 View 的背景——它改的是应用主窗口这一层的背景,所以能做"页面内容和窗口底色融为一体"的效果,常见于启动闪屏过渡、深色/浅色主题切换、以及让 WebView、相机预览这类原生视图和 RN 内容视觉上接得上。
这个库在鸿蒙上没有官方实现,所以我做了一版适配。本文把整条链路写清楚:从上游同步、鸿蒙实现怎么写、到接入宿主并跑通。
并且这次验证挖出了一个真问题:库内部的颜色字节序转换方向反了,导致读回值是对的、屏幕上的颜色是错的。这个缺陷靠读接口值永远发现不了,只有把"输入颜色"和"屏幕实际颜色"对照才会暴露。详细结论在第五、八、九节。
环境准备:本文不重复环境搭建步骤。RNOH(React Native for OpenHarmony)开发环境的完整配置见官方开发者指南:
https://atomgit.com/CPF-RN/docs/blob/main/开发者指南/02-搭建准备/环境初始化.md

一、版本配套:四件套必须对齐
RNOH 项目有个硬约束:RN 版本、RNOH 的 npm 包、RNOH 的 ohpm 包、DevEco SDK 四者必须对齐,错一个就是编译报错或者白屏。而且版本矩阵只是通用参考,具体库验证过的组合才算数。
我这次锁定的组合:
| 项 | 版本 |
|---|---|
expo-system-ui | 57.0.4(与 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 latest 是
57.0.4,不是同批 Expo 库常见的57.0.2。写文章前我用npm view expo-system-ui version核对过,和适配 TAG 的上游部分一致。别照着印象套版本号,每个库都单独查一次。
二、适配步骤
第一步:上游同步到 AtomGit
expo-system-ui 不是独立仓库,它是 expo/expo monorepo 里的一个包:packages/expo-system-ui。
我的做法是在 oh-react-native 组织下建一个独立仓库,把上游这个包的源码同步过来,并锁死基线 commit:
upstreamCommit: 9e5319c0f821a27b7924841903abae50e2b41790
锁 commit 这一步不能省。上游是 monorepo,包目录会跟着主仓一起动;不锁基线的话,以后想复现"这版适配对应上游哪份代码"就说不清了。这条信息我写进了仓库的 spec.json。
第二步:本地克隆
git clone https://atomgit.com/oh-react-native/expo-system-ui.git
cd expo-system-ui
第三步:确定交付分支与版本号
适配包和普通库不一样,它是要被别的主程按版本引用的,所以版本号必须能一眼看出"上游版本 + 鸿蒙实现版本"。
我用 main 作开发分支,完成后打 TAG 交付:
git tag 57.0.4-ohos-1.0.0
命名规则是 <上游版本>-ohos-<适配版本>。调用方按 TAG 引用,就不会被后续改动影响到:
"expo-system-ui": "git+https://atomgit.com/oh-react-native/expo-system-ui.git#57.0.4-ohos-1.0.0"
HAR 工程自己的
oh-package.json5里写的是57.0.4-ohos.1,和 git TAG 的57.0.4-ohos-1.0.0写法不同。引用时以 git TAG 为准。
第四步:适配实现——新增了什么、为什么
上游给的是 iOS/Android 实现,鸿蒙侧要从零写。这个库很小,只有两个方法,但每一处都有取舍。
新增的第一块是 HAR 工程 harmony/system_ui/:
harmony/system_ui/
├── Index.ets # 导出 ExpoSystemUIPackage
├── oh-package.json5 # 声明包名 @react-native-ohos/expo-system-ui
├── build-profile.json5
└── src/main/
├── module.json5
├── cpp/ # CAPI 架构下的 C++ 侧
│ ├── CMakeLists.txt
│ ├── ExpoSystemUIPackage.h # Package + TurboModule 工厂
│ └── ExpoSystemUIPackage.cpp
└── ets/
├── ExpoSystemUIPackage.ets # 把 TurboModule 交给 RNOH
└── ExpoSystemUITurboModule.ts # ★ 真正的实现
第二块是 TurboModule 的实现。上游 JS 侧声明了两个方法,都是异步的:
| JS 侧原生方法 | 鸿蒙实现 |
|---|---|
setBackgroundColorAsync(color) | 颜色转成窗口要的十六进制串,写 Preferences 持久化,再设置主窗口背景 |
getBackgroundColorAsync() | 读 Preferences 里本库写入的值,没有就返回 null |
C++ 侧两个方法都按异步 Promise 登记:
methodMap_ = {
ARK_ASYNC_METHOD_METADATA(setBackgroundColorAsync, 1),
ARK_ASYNC_METHOD_METADATA(getBackgroundColorAsync, 0),
};
第三块是 package.json 里的 autolinking 声明:
"harmony": {
"alias": "expo-system-ui",
"autolinking": {
"ohPackageName": "@react-native-ohos/expo-system-ui",
"etsPackageClassName": "ExpoSystemUIPackage",
"cppPackageClassName": "ExpoSystemUIPackage",
"cmakeLibraryTargetName": "rnoh_system_ui"
}
}
这四个名字是 RNOH 找到这个包的凭据。少一个或者拼错,表现都是"编译过了但模块没注册",运行时才发现,很难查。
第五步:补全适配仓库所需的额外文件
上游 README 原文我没动,适配相关的东西单独成文件:
| 文件 | 作用 |
|---|---|
README.OpenHarmony.md / README.OpenHarmony_CN.md | 适配说明:能力对照、版本配套、接入方式、已知限制 |
spec.json | 机器可读的适配规格:包名、模块名、方法清单、版本配套、基线 commit、验证结论 |
RN_expo-system-ui+代码检查报告.md | 代码检查结论与限制说明 |
harmony/system_ui.har | 预编译产物(3.4 KB),随包分发,装依赖即可拿到 |
__tests__/system-ui.test.cjs | 五项契约测试(node --test) |
那份契约测试写得挺讲究——它不是打桩,而是真的把 RN 的颜色解析器加载进来跑:
// Execute RN's actual color parser, including its RGBA -> ARGB conversion.
function reactNativeColorParser() {
const root = path.dirname(require.resolve('react-native/package.json'));
const normalizeColor = load('Libraries/StyleSheet/normalizeColor.js', {...});
return load('Libraries/StyleSheet/processColor.js', {
'../Utilities/Platform': {default: {OS: 'harmony'}},
'./normalizeColor': normalizeColor,
...
}).default;
}
它把 RNOH 平台上 processColor 的真实输出固定了下来,这一份数据后面成了我定位缺陷的关键线索:
'#dd5a4e' → 0xffdd5a4e
'#2a9d8f' → 0xff2a9d8f
'red' → 0xffff0000
'#123' → 0xff112233
'rgba(20, 40, 60, 0.5)' → 0x8014283c
'#12345680' → 0x80123456 ← RN 按 #RRGGBBAA 解析,再打包成 ARGB
'transparent' → 0
注意最后两行:RN 在 JS 侧把 #RRGGBBAA 解析成整数时,会把 alpha 挪到高字节。也就是说 processColor 交给原生的是 ARGB 整数。
第六步:代码推送
git push origin main
git push origin 57.0.4-ohos-1.0.0
三、这个适配包长什么样
expo-system-ui/
├── package.json # 含 harmony.autolinking
├── spec.json # 适配规格
├── src/
│ ├── index.ts # 两个公开 API
│ └── NativeExpoSystemUI.ts # TurboModule 的 TS 声明
├── harmony/
│ ├── system_ui.har # 预编译产物(3.4 KB)
│ └── system_ui/ # HAR 源码
├── __tests__/
└── README.OpenHarmony*.md
要点:
- 它是"带原生实现的适配包",必须编译原生代码,不能只
npm install就完事,还要走 ohpm 和 hvigor。 files字段里包含harmony,所以从 git 装依赖时能直接拿到 HAR。- 它没有依赖
expo-modules-core,是按 RNOH 的 TurboModule + autolinking 规范直接实现的。
JS 侧那一层很薄,但有一层输入校验值得注意:
export async function setBackgroundColorAsync(color: ColorValue | null): Promise<void> {
if (color == null) return NativeExpoSystemUI.setBackgroundColorAsync(null);
const resolved = processColor(color);
if (typeof resolved !== 'number' || !Number.isInteger(resolved)) {
throw new TypeError('Expected a color that React Native resolves to a numeric ARGB value');
}
return NativeExpoSystemUI.setBackgroundColorAsync(resolved);
}
processColor 解析不出来的颜色会返回 undefined/null,这里直接抛 TypeError,不让非法值摸到原生。实测这一层很管用(见第八节)。
四、接入宿主:三处改动面(外加一处自动生成的)
库本身不能独立运行,必须有一个 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 SystemUITestApp
接入要改的地方
第一处:package.json。
"expo-system-ui": "file:../expo-system-ui"
第二处:两级 oh-package.json5 都要写 HAR。
"@react-native-ohos/expo-system-ui":
"file:../node_modules/expo-system-ui/harmony/system_ui.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 ExpoSystemUIPackage from '@react-native-ohos/expo-system-ui';
export function createRNOHPackages(ctx: RNPackageContext): RNOHPackage[] {
return [
new ExpoSystemUIPackage(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 依赖
这四个文件头部都写着 DO NOT modify it manually, your changes WILL be overwritten.——别手改。
五、实现上的设计点
点一:颜色字节序——这一处方向反了
这是本篇最值得看的地方,因为它是"看起来完全正常、实际效果全错"的典型。
先看实现:
function colorToHex(value: number): string {
const unsigned = value < 0 ? value + 4294967296 : value;
const bytes = Math.trunc(unsigned).toString(16).padStart(8, '0').toUpperCase();
// RNOH processColor supplies the packed bytes in RGBA order; Window expects ARGB.
return `#${bytes.slice(2)}${bytes.slice(0, 2)}`;
}
先把整数按大端展开成 8 位十六进制,再把前两个字符挪到末尾:
| 输入颜色 | processColor 输出 | colorToHex 输出 |
|---|---|---|
#2a9d8f | 0xFF2A9D8F | #2A9D8FFF |
red | 0xFFFF0000 | #FF0000FF |
#12345680 | 0x80123456 | #12345680 |
也就是说它把 ARGB 整数变成了 #RRGGBBAA 字符串。
问题在于:鸿蒙 window.setWindowBackgroundColor 要的是 #AARRGGBB(AA 在最高位,FF 不透明、00 全透明)。把 #RRGGBBAA 喂进去,解析出来就是:
#FF0000FF→ A=FF、RGB=(00,00,FF)→ 不透明纯蓝(而输入是红色)#2A9D8FFF→ A=2A(约 16% 不透明)、RGB=(9D,8F,FF)→ 近透明的蓝紫,透出后面的壁纸
所以 setBackgroundColorAsync('red') 之后,屏幕是蓝色。
为什么这个缺陷能一直藏着? 两个巧合叠在一起:
- 默认色/重置色是白色。
null重置走的是#FFFFFFFF——白色在#RRGGBBAA和#AARRGGBB两种解释下都是白色,所以"重置"这条路径永远看不出问题,而它恰好是最常被点到的那条。 - 读回值和写入值用的是同一套(错误)格式。
getBackgroundColorAsync()返回的是 Preferences 里存的、也就是colorToHex的产物,所以set(x)→get()是自洽的,断言全过。
换句话说:只对着接口返回值做验证,这个 bug 永远不会暴露。必须把"你传进去的颜色"和"屏幕上真实的颜色"放在一起看。这也是第八节验证里我专门加了"输入 → 读回 → 屏幕实际颜色"三列对照的原因。
顺带一个连带问题:即使把写入修成 #AARRGGBB,getBackgroundColorAsync() 也不能直接把那个串返回给 JS——RN 的 processColor 会把 #AARRGGBB 当 #RRGGBBAA 解析,应用如果做 set(await get()) 就会再次翻转。正确的做法是两种格式分开:写给窗口用 ARGB、返回给 JS 用 RN 语义(#RRGGBBAA 或 rgba()),Preferences 里存窗口格式。详见第九节。
点二:写操作串行化,但失败不堵后续
连续调用 setBackgroundColorAsync 时,谁先谁后必须确定。实现用一个 Promise 链把写操作串起来:
private pending: Promise<void> = Promise.resolve();
async setBackgroundColorAsync(color: number | null): Promise<void> {
const request = this.pending.then(async (): Promise<void> => {
if (this.destroyed) throw new Error('ERR_SYSTEM_UI_DESTROYED');
const target = this.ctx.uiAbilityContext.windowStage.getMainWindowSync();
const prefs = await preferences.getPreferences(
this.ctx.uiAbilityContext.getApplicationContext(), PREFERENCES_NAME);
if (color === null) {
target.setWindowBackgroundColor('#FFFFFFFF');
await prefs.delete(BACKGROUND_KEY);
} else {
const hex = colorToHex(color);
await prefs.put(BACKGROUND_KEY, hex);
target.setWindowBackgroundColor(hex);
}
await prefs.flush();
});
this.pending = request.catch((): void => {}); // ← 关键:链接用的是"吞掉失败"的版本
return request; // ← 返回给调用方的仍是"会 reject"的版本
}
this.pending = request.catch(() => {}) 这一行很妙,值得单独说:
- 链上挂的是吞掉失败的版本——某一次写失败,不会让后续的写全部卡死或连带失败,调用方可以重试;
- 返回给调用方的是原始
request——失败会正常 reject,错误不会被悄悄吃掉。
实测连续发起三次写入(不 await 中间步骤),最终读回等于最后一次的颜色,顺序没有乱。
点三:构造时就恢复持久化颜色
constructor(ctx: ConstructorParameters<typeof UITurboModule>[0]) {
super(ctx);
// Match Expo's cold-start behavior: restore the library-owned color before reads.
this.pending = this.restorePersistedBackground().catch(() => {});
}
private async restorePersistedBackground(): Promise<void> {
const target = this.ctx.uiAbilityContext.windowStage.getMainWindowSync();
const prefs = await preferences.getPreferences(..., PREFERENCES_NAME);
const value = await prefs.get(BACKGROUND_KEY, '');
target.setWindowBackgroundColor(
typeof value === 'string' && value.length > 0 ? value : '#FFFFFFFF');
}
把恢复动作塞进 pending 链的头部,于是:
- 恢复一定发生在任何一次
set之前,不会互相覆盖; getBackgroundColorAsync()里await this.pending,所以读到的必然是恢复之后的状态;- 恢复失败也只是被吞掉,不影响后续读写。
这是对齐 Expo 上游"冷启动时先恢复上次的颜色"的行为。实测量启后确实读回了上次设置的值(第八节)。
点四:销毁标记
override __onDestroy__(): void { this.destroyed = true; }
销毁后任何读写都直接抛 ERR_SYSTEM_UI_DESTROYED,避免在已经没了的窗口上操作。
六、构建与运行
# 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
# 换页参数只在「冷启动」时生效:先 force-stop 再起(见坑三)
hdc shell aa force-stop com.rnoh084.demo
hdc shell aa start -b com.rnoh084.demo -a EntryAbility --ps rnAppKey SystemUITestApp
耗时:在已有原生缓存的宿主上增量加入这个原生库,assembleHap 用了 7 分 12 秒,HAP 从 79.4 MB 涨到 79.5 MB——新库本身只贡献了约 162 KB。
如果宿主是全新 clone(没有原生编译缓存),首次构建会到 30–40 分钟量级;即使宿主已编译过、只改 JS 重新打包,hvigor 也要把整套流水线走一遍(约 6–7 分钟)。别把 UI 微调留到最后做。
看日志:
hdc shell "hilog -x | grep -i 'ExpoSystemUI'"
hdc shell "hilog -x | grep -i 'TM created'"
hdc shell "hilog -x | grep -i 'system-ui-test'"
看界面(读无障碍树,不用截图就能拿到文本):
devecocli ui layout
点击 / 滚动 / 截图:
hdc shell "uinput -T -c <x> <y>" # 点击
hdc shell "uinput -T -m <x1> <y1> <x2> <y2> <ms>" # 滚动
hdc shell snapshot_display -f /data/local/tmp/s.jpeg # 截图
hdc file recv /data/local/tmp/s.jpeg .\s.jpeg
七、踩坑记录
坑一:主窗口内容是透明背景,才能看见窗口色
这条是这个库最容易让人误判的地方,也写在库自己的 README 里:窗口背景色只在 RN 根内容透明时才透得出来。如果你的测试页根节点是 #f2f2f7 这种不透明底色,那么 setBackgroundColorAsync 设什么都不会有视觉变化——看起来就像"设了没反应"。
我的测试页因此这样处理:
- 根节点
SafeAreaView/ScrollView完全不设backgroundColor; - 专门留一块虚线框「预览区」,它自己不设任何背景色,用来直接暴露窗口色;
- 其余卡片用白底,保证文字在任何窗口色下都读得清。
坑二:鸿蒙上 SafeAreaView 必须套,否则内容和状态栏重合
页面第一版做完,标题直接压在状态栏的时间、电量上。原因是 react-native 导出的 SafeAreaView 长这样:
const SafeAreaView = Platform.select({
ios: require('./RCTSafeAreaViewNativeComponent').default,
default: View, // ← 非 iOS 平台退化成裸 View
});
也就是说在鸿蒙上 import { SafeAreaView } from 'react-native' 本来是不起作用的。RNOH 的解法是另加了一个平台扩展文件 Libraries/Components/SafeAreaView/SafeAreaView.harmony.tsx(走 SafeAreaTurboModule.getInitialInsets() + 订阅 SAFE_AREA_INSETS_CHANGE),Metro 的 harmony 平台解析会优先取它,所以老实套一层就生效:
<SafeAreaView style={styles.safeArea}>
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
...
</ScrollView>
</SafeAreaView>
自检不用肉眼,看布局 dump 里标题的矩形就行:
修复前: Text [60,117,1260,127] ← 高度只有 10,说明被状态栏压住裁切了
修复后: Text [60,177,1260,244] ← y 让开了,高度也正常
坑三:rnAppKey 换页只在冷启动生效
RNOH084Demo 的换页机制是靠 EntryAbility.onCreate 里读 want.parameters['rnAppKey']。onCreate 只在 ability 冷启动时走一次,所以应用已经在运行时,再 aa start ... --ps rnAppKey <另一个页名> 只会把已有实例拉到前台,页面不会切——命令还报 start ability successfully,很容易被误导。
对策:换页前先强停,再带参数冷启动:
hdc shell aa force-stop com.rnoh084.demo
hdc shell aa start -b com.rnoh084.demo -a EntryAbility --ps rnAppKey SystemUITestApp
坑四: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 3 libraries, skipped 1 libraries
四个文件里没有 harmony/entry/oh-package.json5——它的 --oh-package-path-relative-to-harmony 参数默认只指向工程级那一份。而前面第四节说过,两级都要写 HAR,缺模块级那处就是"能找到包但链接不上"这种不好查的症状。
同一个坑还有一个小兄弟:metro.config.js 的 watchFolders 要加新库的真实目录。file: 依赖装进 node_modules 之后是个链接(Windows 上是 Junction),不是真目录,不加就解析不到源码。
顺带一个可以自检的好信号:bundle 打包成功时,Metro 会打印它重定向到鸿蒙实现的三方包清单——
[INFO] Redirected imports to 3 harmony-specific third-party package(s):
[INFO] • expo-keep-awake → expo-keep-awake
[INFO] • expo-localization → expo-localization
[INFO] • expo-system-ui → expo-system-ui
这里没有你的库,就说明 autolinking 没认出来。
坑五:hilog 缓冲会滚动覆盖
TM created: <模块名> 只在 TurboModule 第一次创建时打一条。等把所有 UI 场景跑完再回头抓,早期记录已经被缓冲区挤掉了。装完启动测试页之后立刻抓一次:
hdc shell "hilog -x | grep -i 'TM created'"
八、模拟器验证
验证环境:Pura X View 模拟器,HarmonyOS 7.0.0(26.0.0) Beta2,API 26,ohos-x64。
测试页覆盖四块:两个 getter/setter、设置→读取对照矩阵、边界(非法输入 / null 重置 / 连续写入)、事件记录。
初始状态与 TurboModule 注册
[system-ui-test] 初始读取 -> getBackgroundColorAsync() = null
未设置过时返回 null,符合库的声明。原生侧确认模块注册成功:
RNInstance::TurboModuleProvider TM created: ExpoSystemUI

输入 → 读回 → 屏幕实际颜色(三列对照)
这是本次验证的核心。每个用例点一次按钮,记录读回值,再看屏幕。
| 输入 | processColor | get() 读回 | 读回是否符合推断 | 屏幕实际颜色 |
|---|---|---|---|---|
#2a9d8f | 0xFF2A9D8F | #2A9D8FFF | ✅ | ❌ 近透明蓝紫(不是青绿) |
red | 0xFFFF0000 | #FF0000FF | ✅ | ❌ 纯蓝(不是红) |
#123 | 0xFF112233 | #112233FF | ✅ | ❌ 近透明蓝 |
rgba(20,40,60,0.5) | 0x8014283C | #14283C80 | ✅ | ❌ 偏色 |
#12345680 | 0x80123456 | #12345680 | ✅ | ❌ 偏色 |
transparent | 0x00000000 | #00000000 | ✅ | ⚠️ 全透明(两种解释一致) |

读回全对,屏幕全错。 决定性的一击是 red 这个用例:processColor('red') 是 0xFFFF0000,转出来 #FF0000FF。如果窗口按 #RRGGBBAA 解析,应该是红色;按 #AARRGGBB 解析,就是 A=FF、RGB=(00,00,FF),即纯蓝。屏幕上出现的是纯蓝。

再对一次 #2a9d8f:#2A9D8FFF 按 ARGB 解析是 A=2A(约 16% 不透明)、RGB=(9D,8F,FF),所以会透出后面的壁纸,看起来是一层蓝紫——与截图完全吻合。
边界
非法输入(not-a-color / 空串 / #12345 / false / NaN):
[system-ui-test] 非法输入「not-a-color」-> TypeError(JS 侧拦下,未调原生)
[system-ui-test] 非法输入「空串」-> TypeError(JS 侧拦下,未调原生)
[system-ui-test] 非法输入「#12345」-> TypeError(JS 侧拦下,未调原生)
[system-ui-test] 非法输入「false」-> TypeError(JS 侧拦下,未调原生)
[system-ui-test] 非法输入「NaN」-> TypeError(JS 侧拦下,未调原生)
五种全部在 JS 侧被 processColor 拦下并抛 TypeError,没有一个触达原生。这一层校验是有效的。
null 重置:
[system-ui-test] set(null) 重置 -> getBackgroundColorAsync() = null
窗口回到白色,持久化值被删除。注意:白色在两种字节序下都是白色,所以这条路径看不出前面的缺陷——这也是缺陷能藏住的原因之一。
连续写入:
[system-ui-test] 连续写入 #dd5a4e -> #2a9d8f -> #457b9d(不 await 中间步骤)
[system-ui-test] 连写结束 -> get() = #457B9DFF(原生侧按顺序执行,应等于最后一次 #457b9d)
三次写入不 await 中间步骤,最终读回等于最后一次的颜色,顺序没有乱。
冷启动持久化
先 set('#2a9d8f'),读回 #2A9D8FFF;然后 force-stop 冷启动(模拟应用重启):
[system-ui-test] set(#2a9d8f) 成功;get() = #2A9D8FFF ✓ 符合推断
--- force-stop + 重新启动 ---
[system-ui-test] 初始读取 -> getBackgroundColorAsync() = #2A9D8FFF
重启后没有任何 set 调用,get() 直接读回上次的值,且屏幕上的窗口色和重启前一致——构造时那条 restorePersistedBackground() 确实生效了。

能力对照
| 能力 | 结果 |
|---|---|
setBackgroundColorAsync(color) | ⚠️ 调用成功、读回符合实现,但屏幕颜色错误(字节序转换方向反了,见第五节) |
setBackgroundColorAsync(null) | ✅ 窗口回白并删除持久化值,get() 返回 null |
getBackgroundColorAsync() | ✅ 未设置返回 null;已设置返回 8 位 #RRGGBBAA;每次都查原生 |
| 非法输入拦截 | ✅ 五种全部在 JS 侧抛 TypeError,未触达原生 |
| 写操作串行化 | ✅ 连续三次不 await,最终等于最后一次 |
| 冷启动持久化 | ✅ force-stop 重启后读回上次的值,窗口色保持 |
| TurboModule 注册 | ✅ TM created: ExpoSystemUI |
九、已知限制
一、setBackgroundColorAsync 的屏幕效果不正确(本文实测发现,第五节有完整推导)。
根因是 colorToHex 把 processColor 的 ARGB 整数转成了 #RRGGBBAA 字符串,而鸿蒙 window.setWindowBackgroundColor 接受的是 #AARRGGBB。表现是:
setBackgroundColorAsync('red')→ 屏幕变成纯蓝;setBackgroundColorAsync('#2a9d8f')→ 屏幕变成近透明蓝紫,透出壁纸;setBackgroundColorAsync(null)→ 屏幕回白(唯一"碰巧正确"的路径)。
为什么读接口测不出来:getBackgroundColorAsync() 返回的就是写进去的那个(错误格式的)串,set/get 自洽;而默认色白色在两种解释下都是白色。只做接口级验证会全绿通过。
修复方向(两种格式必须分开,不能共用一个串):
// 写给窗口:ARGB
const argb = `#${bytes}`;
// 返回给 JS:RN 语义(#RRGGBBAA),否则应用 set(await get()) 会再次翻转
const rnFacing = `#${bytes.slice(2)}${bytes.slice(0, 2)}`;
Preferences 里应存窗口格式(因为恢复时直接喂给 setWindowBackgroundColor),getBackgroundColorAsync() 返回前再转成 RN 语义。
应用侧临时规避:如果只关心"窗口变成某个颜色"而不关心读回值,可以自己把目标色先按 #AARRGGBB 拼好——但注意 JS 侧 processColor 会把 8 位串按 #RRGGBBAA 解析,所以最稳妥的是传 rgba(r,g,b,a) 这种函数式写法并自行核对屏幕效果。
这次没有改库代码(任务边界是"适配到模拟器 + 写文章"),本节如实记录。
二、只持久化本库写入的颜色。 它不读取系统设置或其他应用改过的窗口背景。换句话说 getBackgroundColorAsync() 返回的是"本库上次设的值",不是"窗口当前真实的颜色"。窗口层面若被别处改过,本库读不出来。
三、只有主窗口。 多窗口、悬浮窗、以及其他 Window 对象未覆盖。
四、需要透明根内容才能观察。 这是库的行为特性而非缺陷:RN 根视图不透明时,窗口色被完全盖住,改了也看不见(见坑一)。
五、进程崩溃恢复未覆盖。 本次验证的是正常 force-stop + 冷启动的持久化;崩溃后由系统恢复进程的场景没有单独验证。
六、其他 ROM / 真机未验证。 适配方记录的是 OpenHarmony-7.0.0.105;本次在 HarmonyOS 7.0.0(26.0.0) Beta2 模拟器上通过。
十、常见问题
Q:为什么不能直接 npm install expo-system-ui?
A:npm 上那个包只有 iOS/Android 实现,没有鸿蒙原生代码。本仓库是独立的鸿蒙实现,要按 git+...#57.0.4-ohos-1.0.0 或者本地 file: 的方式装。README 里也写明了这一点。
Q:为什么版本是 57.0.4,不是 57.0.2?
A:因为 npm 上 expo-system-ui 的 latest 就是 57.0.4。同批 Expo 库版本号并不一致,每个库都要单独 npm view 核对,别按印象套。
Q:需要额外依赖 expo-modules-core 吗?
A:不需要。这版是按 RNOH 的 TurboModule + autolinking 规范实现的,package.json 里的 harmony 字段就是它接入 RNOH 的全部凭据。
Q:我调了 setBackgroundColorAsync,但屏幕上什么都没变?
A:先确认你的 RN 根内容是透明背景。窗口背景色只在根内容透明时才透得出来;根节点铺了不透明底色的话,窗口色被完全盖住。本文测试页专门留了一块不设背景色的虚线「预览区」来观察它。
Q:getBackgroundColorAsync() 返回 #2A9D8FFF,为什么跟我传进去的 #2a9d8f 不一样?
A:因为它返回的是原生窗口格式的 8 位串,不是你输入的 RN 颜色值。#2a9d8f 会被 processColor 补成全不透明的 ARGB,再转成 8 位串返回。所以 set(x) 之后 get() 不等于 x——想比较颜色不要用字符串相等。另外这个 8 位串目前是 #RRGGBBAA 形态,与写进窗口的格式不一致,见第九节第一条。
Q:为什么我设了 red,屏幕是蓝的?
A:这是一个真实缺陷。processColor('red') 得到 0xFFFF0000(ARGB),实现把它转成了 #FF0000FF,而窗口按 #AARRGGBB 解析就成了 A=FF, RGB=(00,00,FF) = 纯蓝。完整推导与修复方向见第五节和第九节。
Q:怎么复现"读到值但屏幕颜色错"的截图?
A:① 先 hdc shell aa force-stop com.rnoh084.demo,再带 --ps rnAppKey SystemUITestApp 冷启动(否则换页不生效,见坑三);② 点「设置 具名色 red」,看虚线预览区——会变成纯蓝;③ 点「设置 6 位 #2a9d8f」,会变成近透明蓝紫;④ 点「setBackgroundColorAsync(null) 重置为白色」恢复。
Q:为什么我在模拟器上编译要这么久?
A:看是不是首次编译。已有原生缓存的宿主增量加一个库是 7–8 分钟;全新 clone 的宿主首次编译要 30–40 分钟,因为 RNOH 的 C++ 体量大,而且为了跑 ohos-x64 模拟器放开了两个 ABI。只上真机的话去掉 x86_64 会明显缩短。
Q:怎么确认库真的生效了,而不是只是没报错?
A:这次的经验是接口级验证不够。至少三条一起看:① 原生 hilog 里的 TM created: ExpoSystemUI(证明模块注册成功);② 读回值与实现推导一致;③ 屏幕/截图上的实际颜色,与你传进去的颜色对照。这个库的缺陷恰好是前两条全过、第三条才露。
小结
这个库只有两个方法、一个 3.4 KB 的 HAR,但它是这三篇里最有收获的一篇,原因是它把"验证"这件事的薄弱处暴露得很干净:
- 读回值正确 ≠ 功能正确。 set/get 用同一套格式,所以自洽;默认色是白色,两种格式下都对。两个巧合叠在一起,把一处颜色字节序错误藏得严严实实。只有把"输入"和"屏幕"放在一起对照,才会露馅。
- 一个转换函数服务两个消费者时,要先问清楚两边要的格式是否相同。
colorToHex的输出对"返回给 JS"其实是对的(RN 语义就是#RRGGBBAA),错在它同时被拿去喂给了需要#AARRGGBB的窗口 API。修复不是"把字节序掉个头"这么简单——两个出口必须分开。 - 平台差异要查文档、也要实测。 鸿蒙
setWindowBackgroundColor的颜色格式是#AARRGGBB,和 RN 的#RRGGBBAA正好相反,这种"两边都叫 8 位十六进制、含义不同"的地方最容易翻车。
适配链路本身,还是那几条老规律在起作用:
- autolinking 的四个身份名(ohpm 包名、ETS 包类、C++ 包类、CMake 目标),少一个都是"编译过了但模块没注册";
- HAR 的工程级与模块级双重声明,而且
link-harmony只会自动写工程级那一份,模块级要自己加; - 版本四件套必须对齐,且以实测库自己声明的组合为准。
验证手段上,devecocli ui layout + uinput + hilog + snapshot_display 这一套组合,能不看屏幕就把界面状态读全。但这次的教训是:无障碍树读不出颜色。文本、坐标、clickable/checkable 都能读到,可"这块区域到底是什么颜色"只能靠截图。所以该截图的时候一定要截图——本次那处缺陷就是这样被抓出来的。
本篇用到的库
| 项 | 内容 |
|---|---|
| 三方库 | expo-system-ui(上游 57.0.4 的鸿蒙适配版) |
| 适配仓库 | https://atomgit.com/oh-react-native/expo-system-ui |
| 适配 TAG | 57.0.4-ohos-1.0.0 |
| ohpm 包名 | @react-native-ohos/expo-system-ui |
| HAR | harmony/system_ui.har(3.4 KB) |
| 基线 commit | 9e5319c0f821a27b7924841903abae50e2b41790 |
| 宿主工程 | RNOH084Demo(测试页 rnAppKey = SystemUITestApp) |
"expo-system-ui": "git+https://atomgit.com/oh-react-native/expo-system-ui.git#57.0.4-ohos-1.0.0"
// harmony/oh-package.json5 与 harmony/entry/oh-package.json5 都要加
"@react-native-ohos/expo-system-ui":
"file:../node_modules/expo-system-ui/harmony/system_ui.har",
# 换页启动测试页(force-stop 不能省,换页参数只在冷启动生效)
hdc shell aa force-stop com.rnoh084.demo
hdc shell aa start -b com.rnoh084.demo -a EntryAbility --ps rnAppKey SystemUITestApp
验证环境
| 项 | 版本 |
|---|---|
| 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.5 MB) |
| 本次增量构建 | assembleHap 7 分 12 秒 |
欢迎加入 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)