本文记录把 react-native-loading-spinner-overlay 适配到 HarmonyOS 的完整过程。

这个库很小(两个源文件、约 3 KB),也很纯粹:一个全屏的加载遮罩,零运行时依赖、没有 prepare 脚本、连编译产物都与上游逐字符一致。接入本身毫无难度。

但它带来的验证问题比接入问题有意思得多:

  1. 它的所有渲染都在 RN 的 Modal 里(transparent + statusBarTranslucent)⇒ 要测的是 RNOH 的 Modal + 半透明遮罩;
  2. 遮罩一起,它就把页面上所有触摸都吞掉了 —— 这让我原本设计的"遮罩显示期间点按钮触发重渲染"的实验根本无法执行;
  3. 它是半受控的:cancelable 被返回键关闭后,组件内部认为已关闭、父组件的 visible 却还是 true,而库没有任何回调告知父组件。

第 3 条是这轮最有价值的发现:它是一个真实的行为缺陷,而且只靠读源码 + 一个四步实测就能坐实。

在这里插入图片描述


一、先说结论

项结果
上游最新版3.0.1(MIT);gitHead = 84896fa0284b705f136622dcd0d7f17184f747bb
库类型纯 JS 函数组件,渲染在 RN 的 Modal 内
是否需要原生适配不需要(无 harmony/、无 NativeModules)
运行时依赖零(dependencies 字段不存在)
prepare / prepack都没有 ⇒ 安装路径干净
与 npm 上游的差异11 / 12 个文件去掉 CR 后逐字符一致(含 src/** 与 lib/** 编译产物);唯一差异是 package.json
编译assembleHap 3 分 52 秒;HAP 82,083,102 字节(+32,770)
设备侧断言12 / 14(A 10/10、B 1/1、C 1/1、D 0/2 未验证,原因见第八节)
★ 外部实测(全部命中)遮罩文字 Y = 1281(几何预言逐像素吻合);rgba(0,0,0,0.25) 实测亮度 ×0.75;rgba(255,0,0,1) 全屏 (254,0,0)(含状态栏区域);遮罩吞掉全部页面触摸(有对照组)
★ 一个真实的行为缺陷半受控:cancelable 关闭后组件(false) 与父组件 prop(true) 长期不一致,且库不接受 onRequestClose、无任何回调
★ 未验证的一条不传 spinnerKey 时"每次渲染都重挂载 Modal" —— 代码级事实成立,运行时挂载计数本轮没取到(原因正是第 5 节那条)

一句话结论:库本身能用、几何与像素都对;但要记住它会吞掉页面所有交互,以及它的关闭是"半受控"的。


二、判定过程:三步走完,而且是这一系列里最干净的一个

步做法结果
①package.json 里有没有 harmony.autolinking没有
②仓库里有没有 harmony/没有
③代码里有没有 NativeModules / requireNativeComponent全无

dependencies 字段直接不存在 ⇒ 零运行时依赖。

再补两项这个库特有的确认(这两项在前面的库里都出过事):

检查结果为什么值得单独查
scripts 里有没有 prepare / prepack都没有(只有 build / lint / test)有的库在 prepare 里会删掉随包发布的编译产物,导致安装失败、包静默损坏
入口在哪main: lib/index.js,lib/ 已提交这类"入口是编译产物"的包要确认 lib/ 与 src/ 行为一致(否则你读的源码不是真正跑的东西)

关于第二项的核实方式:这个包的 tsconfig.json 写的是 "target": "esnext" 且没有写 module ⇒ 编译产物是 ESM(import / export default)。这意味着在 PC 上用 Node require() 它是不行的,所以我没有做 PC 侧探针,而是直接读 lib/index.js 这份运行时真正执行的代码,并逐行核对它与 src/index.tsx 行为一致 ⇒ 排除"陈旧编译产物"这个风险。


三、交付包质量

3.1 与 npm 上游逐文件比对(先去 CR)

类别结果
一致11 个文件(src/**、lib/** 全部编译产物、README.md、LICENSE、tsconfig.json 等)
不同1 个:package.json
交付包缺少0
交付包多出6 个:.gitignore、两个 README.OpenHarmony*、代码检查报告、spec.json、契约测试

实现与产物双重零改动,而且没有 prepare ⇒ 这是本系列里安装路径最干净的一个。

3.2 一个"看着像错、查证后没错"的地方

spec.json 里的上游地址写的是 git+ssh://git@github.com/...(SSH 形式)。第一眼像是不该这么写 —— 但查 npm 的 repository 字段,上游自己就是这么写的 ⇒ 不是缺陷。

教训:"名字/格式不一致"要先查数据源再定性。

3.3 问题

问题说明
spec.json 缺 upstreamCommit而 npm 明确有 gitHead(84896fa0…)⇒ 属应修项
validation 是裸字符串 "pass"没有可复核的测试项清单
契约测试是包级 smoke test只检查包名 / 版本 / 主入口存在 / 中文 README 存在,一次组件都没渲染过
两个交付 README 都没写三件事① 组件是半受控的;② 遮罩会吞掉页面所有触摸;③ 不传 spinnerKey 会重挂载

四、几何与像素:三条预言全部命中

4.1 遮罩文字的位置(逐像素吻合)

源码里 textContent 的样式是:

textContent: { fontSize: 20, fontWeight: 'bold', height: 50, top: 80 }

而它的容器是全屏绝对定位 + justifyContent: 'center'。

先算一遍(设备 density = 3,屏高 2232px):

文字上边 = 屏幕中心 − 文字半高 + top
        = 2232/2 − (50vp/2 × 3) + (80vp × 3)
        = 1116 − 75 + 240
        = 1281

实测:Text [433,1281,887,1431] "SPIN-TEXT-DIM" ⇒ Y1 = 1281 ✓ 高度 150px 也正好是 50vp × 3 ✓

⇒ 这个库的布局在 RNOH 上与设计完全一致。

4.2 半透明遮罩的"变暗比例"(数值精确)

overlayColor 默认是 'rgba(0, 0, 0, 0.25)',作用在全屏容器上。在同一个像素位置比较"无遮罩 / 有遮罩":

位置(下面的页面元素)无遮罩有遮罩比值
(100,1700) 蓝色按钮 #1f6feb(31,111,234)(23,84,177)0.742 / 0.757 / 0.756
(660,1700) 页面背景 #0f1115(16,17,21)(12,13,17)0.750 / 0.765 / 0.810

⇒ 实测比值 ≈ 0.75 = 1 − 0.25 ✓ 遮罩确实是"整体压暗 25%",而不是别的混合方式。

4.3 纯红遮罩顺带证实了 statusBarTranslucent

把 overlayColor 换成 'rgba(255, 0, 0, 1)':

采样点实测
(100,300) / (660,900) / (1200,2100)(254,0,0)
(60,60) —— 状态栏区域(254,0,0)

遮罩全屏铺满,连状态栏区域都被盖住 —— 这正好证实了源码里的:

<Modal … statusBarTranslucent={true}>

是真的生效的。

在这里插入图片描述
在这里插入图片描述


五、遮罩会吞掉页面所有触摸(带对照组)

这一条我先踩坑、后做对照才定性。

现象:遮罩显示后,用工具按坐标点页面上的按钮,工具报成功,但设备日志里没有任何对应的记录,遮罩也没关。

对照实验:

情形结果是否产生日志
无遮罩时点「显示 纯红遮罩」红遮罩出现✅ 有
遮罩在时点「隐藏全部遮罩」无任何日志,遮罩仍在❌ 无

⇒ Modal 窗口把页面所有触摸都吞掉了。

两个结论:

  1. 遮罩显示期间,用户点不到下面任何东西(这是 loading 遮罩的意图,但写代码时要意识到:"加载中禁止操作"是自动的,不需要额外加 pointerEvents);
  2. ⚠️ 方法论:坐标点击工具报成功只代表"坐标被点到了",不代表按钮被按下。有东西盖着时它照样报成功。 以后凡是"点了没反应",都要先做一次"无遮挡时点同一个按钮"的对照。

六、cancelable 的真实触发源是返回键,不是点遮罩

源码里:

const close = () => { setSpinnerVisibility(false); };
const _handleOnRequestClose = () => { if (cancelable) { close(); } };

<Modal
  onRequestClose={() => { _handleOnRequestClose(); }}
  …
>

两个要点:

  1. 组件根本不接受 onRequestClose prop —— 它内部只有一个私有的 _handleOnRequestClose,从外面传什么都不起作用;
  2. RN 的 Modal.onRequestClose 由返回键 / 返回手势触发,不是点遮罩触发 ⇒ cancelable 必须用返回键来验。

实测:

实例按返回键后结论
cancelable 未设(= false)遮罩仍在✓ 与 if (cancelable) 一致;且返回键没有把应用退出(被 Modal 消费了)
cancelable = true遮罩关闭✓ cancelable 经 Modal.onRequestClose 生效

⚠️ 别把 cancelable 理解成"点遮罩可以关闭"。 它的实际含义是"允许返回键关闭"。


七、半受控语义:本轮最有价值的发现

7.1 源码

const [spinnerVisible, setSpinnerVisibility] = React.useState(visible);   // ① 内部自己存一份
React.useEffect(() => { setSpinnerVisibility(visible); }, [visible]);      // ② 只在 visible 变化时同步
const close = () => { setSpinnerVisibility(false); };                      // ③ 关闭只改内部状态

⇒ cancelable 被返回键关闭时:只改了组件内部状态,没有通知父组件;而 useEffect 的依赖 [visible] 没变 ⇒ 不会重新同步。

7.2 四步实测(全部命中)

步骤观测结论
① 点「显示 cancelable」遮罩出现(文字 Y = 1281)打开正常
② 按返回键遮罩关闭;日志里没有父组件的新动作父组件 prop 仍是 true
③ 让父组件用同样的 visible={true} 重渲染 3 次遮罩没有重新出现✅ 边界坐实:组件(false) 与父组件(true) 长期不一致
④ 把 visible 走一次 false→true遮罩重新出现✅ 与 [visible] 依赖一致

7.3 为什么这是个缺陷

  • 库不提供任何回调告知父组件"我被关掉了"(它连 onRequestClose prop 都不接受);
  • ⇒ 父组件无从得知。如果它一直保持 visible={true},就会长期处于"父组件以为在显示、实际已经关了"的不一致状态;
  • 而且遮罩不会自己回来,除非父组件主动把 visible 走一次 false→true。

规避做法:

// 父组件自己掌握状态;要用 cancelable 就自己监听返回键
const [visible, setVisible] = useState(false);
useEffect(() => {
  const sub = BackHandler.addEventListener('hardwareBackPress', () => {
    if (visible) { setVisible(false); return true; }   // 自己关,自己知道
    return false;
  });
  return () => sub.remove();
}, [visible]);

<Spinner visible={visible} cancelable={false} textContent="加载中" />;

在这里插入图片描述


八、一条"代码级成立、运行时未验证"的行为

8.1 代码级事实

<View style={[styles.container, …]} key={spinnerKey || `spinner_${Date.now()}`}>

Date.now() 每次渲染都不同 ⇒ 不传 spinnerKey 时这个 key 每次渲染都变 ⇒ 按 React 语义,该子树会被卸载再重新挂载。这解释了 spinnerKey 这个 prop 为什么存在:想避免重挂载就得给一个稳定的 key。

8.2 为什么本轮没测到

我原本的设计是:传一个挂载时会打日志的 customIndicator,然后在遮罩显示期间点"重渲染"按钮,数挂载次数。

但这个设计被第 5 节推翻了:遮罩一起就吞掉所有触摸 ⇒ 我没法在遮罩显示期间点到页面按钮 ⇒ 父组件不会重渲染 ⇒ 挂载计数拿不到。

所以这一轮的 D 组两条断言如实失败:

✗ [D] 不传 spinnerKey 的实例挂载次数 > 1(每次渲染都重挂载)|期望 true|实际 false
✗ [D] 固定 spinnerKey 的实例挂载次数更少|期望 true|实际 false
· NO-KEY 挂载次数 = 0 FIXED-KEY 挂载次数 = 0

补测的正确做法:在页面里加一个自驱动计时器(遮罩显示期间自动触发父组件重渲染),这样就不依赖外部点击 —— 但这需要再改一次页面并重编。本轮为控制时长没有做。

⇒ 这一条只能记成"代码级事实成立、运行时未验证",不能写成"已实测"。


九、已知限制与本次验证边界

9.1 用这个库要知道的四件事

事项说明
安装无 prepare,普通 npm install 即可(本系列仍统一加 --ignore-scripts)
遮罩会吞掉页面所有触摸显示期间下面的按钮全点不到;也点不到遮罩来关闭
cancelable = 允许返回键关闭不是"点遮罩关闭";而且不会通知父组件
半受控组件自己关掉后,父组件的 visible=true 不会让它重开;要 false→true
spinnerKey不给的话每次渲染都会重挂载 Modal 子树(代码级;运行时未验)
不接受 onRequestClose prop想在关闭时做事情,只能自己在父组件监听返回键

9.2 本次验证的边界

  1. 只在一台模拟器上验证(Pura X View,density 3,1320×2232),没有真机。
  2. 重挂载行为(D 组)未在运行时验证(原因见第八节)。
  3. animation('none' / 'slide' / 'fade')完全未测 —— 本轮只用默认的 'none';Modal 动画在 RNOH 上的表现未知。
  4. size='small' 与数值型 size 未测(只用了默认 'large');源码注释说不支持 'normal',但没实测传 'normal' 会怎样。
  5. color(指示器颜色)未做像素核对(本轮只核对了遮罩色)。
  6. indicatorStyle / textStyle 自定义未测。
  7. customIndicator / children 自定义内容只用于挂载计数,没有渲染可测内容的布局/交互。
  8. 多个 Spinner 同时 visible 的叠加/层级未测(本轮六个实例每次只显示一个)。
  9. children 与 customIndicator 同时存在时的优先级未做组合实测(源码是 children || _renderDefaultContent()、customIndicator || ActivityIndicator)。
  10. visible 从 true→false 的关闭时序/动画未测。
  11. 点遮罩本身是否有其它副作用未细测(已确认"不会关闭")。
  12. 没有跨平台对照(未在 iOS/Android 上跑同一套用例)。

未改动库代码。


十、常见问题

Q1:怎么接入?

npm install "git+https://atomgit.com/oh-react-native/react-native-loading-spinner-overlay.git#3.0.1-ohos-1.0.0" --ignore-scripts
// metro.config.js —— file: / 软链装法需要把真实目录加进 watchFolders
watchFolders: [ path.resolve(__dirname, '../react-native-loading-spinner-overlay') ],
import Spinner from 'react-native-loading-spinner-overlay';

<Spinner visible={loading} textContent="加载中…" />;

不需要 HAR、权限、ohpm、link-harmony。

Q2:遮罩显示时,我还能操作页面上的按钮吗?

不能。 它渲染在一个全屏的 Modal 窗口里,会吞掉页面所有触摸(实测:有遮罩时点页面按钮,设备日志里没有任何反应)。

这通常正是你要的效果(加载中禁止操作),但要知道:这个"禁止操作"是自动发生的,你不需要另外加 pointerEvents;反过来说,也别指望"点一下遮罩就能关掉它"。

Q3:cancelable 是不是"点遮罩可以关闭"?

不是。 cancelable 的真实含义是"允许返回键关闭":

const _handleOnRequestClose = () => { if (cancelable) { close(); } };
<Modal onRequestClose={() => _handleOnRequestClose()} …>

Modal.onRequestClose 由返回键/返回手势触发,点遮罩不会触发。实测:cancelable=false 时按返回键不关;cancelable=true 时按返回键关闭。

Q4:我想在遮罩被关闭时做点事情,可以传 onRequestClose 吗?

不行,这个组件根本不接受这个 prop —— 它内部只有一个私有的 _handleOnRequestClose。

Q5:我传了 visible={true},用户按返回键关掉之后,为什么它不再出现了?

因为它内部自己存了一份 visible 状态,cancelable 关闭时只改了内部状态:

const [spinnerVisible, setSpinnerVisibility] = useState(visible);
useEffect(() => setSpinnerVisibility(visible), [visible]);

useEffect 的依赖是 [visible],而父组件传的值一直是 true(没变) ⇒ 不会重新同步 ⇒ 遮罩保持关闭。实测已坐实:父组件用同样的 visible={true} 重渲染 3 次,遮罩也不会回来;只有把 visible 走一次 false→true 才会重新出现。

Q6:那正确的用法是什么?

父组件自己掌握状态,并且自己监听返回键(不要依赖库的内部行为):

const [visible, setVisible] = useState(false);

useEffect(() => {
  const sub = BackHandler.addEventListener('hardwareBackPress', () => {
    if (visible) { setVisible(false); return true; }
    return false;
  });
  return () => sub.remove();
}, [visible]);

<Spinner visible={visible} cancelable={false} textContent="加载中…" />;

Q7:spinnerKey 是干什么的?

源码里那一行是:

key={spinnerKey || `spinner_${Date.now()}`}

不传 spinnerKey 时,key 每次渲染都不一样 ⇒ 按 React 语义,这个子树每次渲染都会被卸载再重新挂载(造成不必要的开销/闪烁)。给它一个固定字符串就能避免。

(⚠️ 这条是代码级事实;本轮的运行时挂载计数没能取到,原因见第八节,所以我没有把它写成"已实测"。)

Q8:遮罩的默认样式能改吗?

可以。overlayColor 改全屏底色、color 改指示器颜色、size 改指示器大小、textContent / textStyle 改文字、indicatorStyle 改指示器与文字容器的样式。

实测:overlayColor 默认 'rgba(0, 0, 0, 0.25)' 会把屏幕整体压暗到 75% 亮度;传 'rgba(255, 0, 0, 1)' 则整屏变纯红,连状态栏区域也盖住(因为 statusBarTranslucent 为 true)。

Q9:size 能传 'normal' 吗?

源码注释明确写着 size 不支持 'normal',类型是 'small' | 'large' | number。(本轮只实测了默认的 'large',传 'normal' 的实际表现未验证。)

Q10:遮罩打开后我卡住了,怎么把它弄掉?

  • cancelable=true 的实例:按返回键;
  • cancelable=false 的实例:返回键关不掉(这是设计行为),页面按钮又被吞了 ⇒ 只能重启应用(aa force-stop + 重新 aa start)。

这是设计测试页时就要想好的事:有覆盖层的组件,每种状态都要有"退出来"的路,否则后续步骤会被卡死。


小结

react-native-loading-spinner-overlay 是一个很小、很干净的纯 JS 库:零运行时依赖、没有 prepare、11/12 个文件(含全部编译产物)与上游逐字符一致。几何与像素实测全部命中:遮罩文字位置误差 0 像素、rgba(0,0,0,0.25) 的压暗比例实测 ×0.75、纯红遮罩全屏 (254,0,0) 且覆盖状态栏。

值得记下来的是这几条:

  1. "点了没反应"要先做无遮挡的对照。 遮罩一起,坐标点击工具照样报成功,但点击被 Modal 窗口吞掉、什么都没发生。⇒ 工具报成功 ≠ 按钮被按下;先分离"没点到"和"功能没生效"。

  2. 读源码要读运行时真正执行的那一份。 这个包 main 指向 ESM 的编译产物(target: esnext 且没写 module),Node 端 require 不了 ⇒ PC 探针这条路走不通,只能直接读 lib/ 并核对它与 src/ 一致。这一步顺带排除了"陈旧编译产物"的风险。

  3. cancelable 这类 prop 要先确定"触发源"。 我第一版页面给组件传了 onRequestClose —— 它根本不接受;而 Modal.onRequestClose 是返回键触发的,不是点遮罩。⇒ 写交互断言前先确认"这个交互由什么事件触发",否则会写出一个永远不会触发的断言。

  4. "半受控"是这类组件的隐藏陷阱。 组件内部 useState(props.x) + useEffect([x]) 同步,同时又允许自己改这份状态 ⇒ 它一旦自己改了,父组件就被蒙在鼓里。⇒ 凡是组件内有 useState(props.x),都要专门测"组件自己改了这份状态之后,父组件的 prop 还有效吗"。 这里的答案是:无效,而且库没有任何回调能通知你。

  5. 有覆盖层的组件,测试设计要预留"退出路径"。 遮罩吞触摸 ⇒ 页面按钮全失效 ⇒ 只能靠返回键或重启;而 cancelable=false 的实例返回键也关不掉。⇒ 设计测试页时就要想好每种状态怎么退出来。

  6. "代码级成立"和"已实测"是两件事。 key 每次渲染都变 ⇒ React 必然重挂载,这是确定的语义;但我这一轮没能在运行时把挂载次数测出来(被遮罩吞触摸挡住了)。⇒ 如实写成"代码级事实成立、运行时未验证",而不是含糊地当成已验证。


本篇用到的库

项内容
三方库react-native-loading-spinner-overlay(上游 3.0.1 的鸿蒙适配版)
适配仓库https://atomgit.com/oh-react-native/react-native-loading-spinner-overlay
适配 TAG3.0.1-ohos-1.0.0
需要 HAR / 权限 / ohpm都不需要(纯 JS,零运行时依赖)
库类型纯 JS 函数组件:全屏加载遮罩,渲染在 RN Modal 内
运行时依赖零(dependencies 字段不存在)
prepare / prepack都没有 ⇒ 安装路径干净
许可证MIT
上游仓库https://github.com/joinspontaneous/react-native-loading-spinner-overlay
上游基线npm gitHead = 84896fa0284b705f136622dcd0d7f17184f747bb
宿主工程RNOH084Demo(测试页 rnAppKey = LoadingSpinnerTestApp)
# 从适配仓库安装
npm install "git+https://atomgit.com/oh-react-native/react-native-loading-spinner-overlay.git#3.0.1-ohos-1.0.0" --ignore-scripts

# 或本地 file: 安装(装成软链,需要配 watchFolders)
npm install ../react-native-loading-spinner-overlay --ignore-scripts
// metro.config.js —— file: / 软链装法需要把真实目录加进 watchFolders
watchFolders: [ path.resolve(__dirname, '../react-native-loading-spinner-overlay') ],
// 用法:父组件自己掌握状态 + 自己监听返回键(不要依赖库的内部行为)
import Spinner from 'react-native-loading-spinner-overlay';
import {BackHandler} from 'react-native';

const [loading, setLoading] = useState(false);

useEffect(() => {
  const sub = BackHandler.addEventListener('hardwareBackPress', () => {
    if (loading) { setLoading(false); return true; }
    return false;
  });
  return () => sub.remove();
}, [loading]);

<Spinner
  visible={loading}
  textContent="加载中…"
  overlayColor="rgba(0, 0, 0, 0.25)"   // 实测:屏幕亮度 ×0.75
  color="#ffffff"
  size="large"                        // 不支持 'normal';也可传数值
  spinnerKey="main-spinner"           // 建议给固定值,避免每次渲染重挂载
  cancelable={false}                  // true 时允许返回键关闭(且不会通知父组件)
/>;
# 换页启动测试页(force-stop 不能省,换页参数只在冷启动生效)
hdc shell aa force-stop com.rnoh084.demo
hdc shell aa start -b com.rnoh084.demo -a EntryAbility --ps rnAppKey LoadingSpinnerTestApp

验证环境

项版本
React Native0.84.1
React19.2.3
RNOH(npm / ohpm)@react-native-oh/react-native-harmony / @rnoh/react-native-openharmony 0.84.3
Node.js / npmv24.14.0 / 11.9.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),1320×2232,density 3
宿主 HAP 产物entry-default-signed.hap(82,083,102 字节)
本次增量构建assembleHap 3 分 52 秒,HAP +32,770 字节;打包资源 14(无新增,符合预期)
验证规模设备侧 12 / 14(A 10/10、B 1/1、C 1/1、D 0/2 未验证);外部含几何逐像素核对 1 次、像素取样 7 点、吞触摸对照 2 组、返回键 2 例、半受控四步、上游 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

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

更多推荐