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-ui57.0.4(与 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 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

要点:

  1. 它是"带原生实现的适配包",必须编译原生代码,不能只 npm install 就完事,还要走 ohpm 和 hvigor。
  2. files 字段里包含 harmony,所以从 git 装依赖时能直接拿到 HAR。
  3. 它没有依赖 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 输出
#2a9d8f0xFF2A9D8F#2A9D8FFF
red0xFFFF0000#FF0000FF
#123456800x80123456#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') 之后,屏幕是蓝色。

为什么这个缺陷能一直藏着? 两个巧合叠在一起:

  1. 默认色/重置色是白色。null 重置走的是 #FFFFFFFF——白色在 #RRGGBBAA 和 #AARRGGBB 两种解释下都是白色,所以"重置"这条路径永远看不出问题,而它恰好是最常被点到的那条。
  2. 读回值和写入值用的是同一套(错误)格式。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

在这里插入图片描述

输入 → 读回 → 屏幕实际颜色(三列对照)

这是本次验证的核心。每个用例点一次按钮,记录读回值,再看屏幕。

输入processColorget() 读回读回是否符合推断屏幕实际颜色
#2a9d8f0xFF2A9D8F#2A9D8FFF✅❌ 近透明蓝紫(不是青绿)
red0xFFFF0000#FF0000FF✅❌ 纯蓝(不是红)
#1230xFF112233#112233FF✅❌ 近透明蓝
rgba(20,40,60,0.5)0x8014283C#14283C80✅❌ 偏色
#123456800x80123456#12345680✅❌ 偏色
transparent0x00000000#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
适配 TAG57.0.4-ohos-1.0.0
ohpm 包名@react-native-ohos/expo-system-ui
HARharmony/system_ui.har(3.4 KB)
基线 commit9e5319c0f821a27b7924841903abae50e2b41790
宿主工程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 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.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

Logo

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

更多推荐