本文记录把 react-native-responsive-screen 适配到 HarmonyOS 的完整过程。

这个库只有 77 行代码、4 个导出函数、零依赖,接入极其简单。但它有两个特别值得写的点:

  1. ★★ 一个会让应用直接抛异常的缺陷:removeOrientationListener() 内部调用的是 Dimensions.removeEventListener('change', () => {}) —— 而这个 API 在 RN 0.84.1 里已经被移除了(改成 addEventListener 返回 EventSubscription)⇒ 调用它会抛 TypeError。而且它传的是一个全新的箭头函数,就算 API 还在也移除不掉。
  2. 一个 UI 层面的坑(本文也把它当成验收项):RNOH 上状态栏是叠在应用窗口上的,页面内容如果不留顶部安全距离,主标题会和状态栏(时钟/电量那一栏)重合。这一条我用"量状态栏高度 + 量标题的窗口坐标"的方式做成了可断言的验收项。

最终结果:设备侧 30 / 30 断言通过,百分比换算逐像素精确(50% → 660px、25% → 330px、10% → 132px),标题窗口坐标 y = 420 > 状态栏 117 ⇒ 不重合 ✓

在这里插入图片描述


一、先说结论

项结果
上游最新版1.4.2;license: MIT;gitHead = 5b01afedf28a8c88eb11435bc34c5b2827637a78
库类型纯 JS 工具库(不是 UI 组件):index.js 仅 77 行,导出 4 个函数
是否需要原生适配不需要(无 harmony/、无 NativeModules)
运行时依赖零(package.json 里没有 dependencies 字段)
prepare / prepublish都没有(scripts 里只有 test)⇒ 安装路径最干净
mainindex.js(源码,不是编译产物)
与 npm 上游的差异31 / 32 个文件逐字节一致;唯一差异是 package.json
需要 HAR / 权限 / ohpm❌ 都不需要
设备侧断言✅ 30 / 30 全部通过
★ UI 验收✅ 状态栏占 0..117px,标题窗口坐标 y = 420 ⇒ 不与电量栏重合
★ 换算验收✅ wp('50%')=660px、wp('25%')=330px、wp('10%')=132px、hp('20%')=149vp —— 逐像素精确
★★ 最重要发现removeOrientationListener() 会抛 TypeError(RN 0.84.1 已移除 Dimensions.removeEventListener),而且它传的是全新的函数引用 ⇒ 双重无效
我自己的两个失误① 用 onLayout 判断状态栏重合(它是相对父容器坐标);② 比较函数把 undefined 用 JSON.stringify 处理(返回真值 undefined,与字符串永不相等)

二、判定与接入

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

⇒ 纯 JS 工具库、零依赖。接入只要三步:

npm install "git+https://atomgit.com/oh-react-native/react-native-responsive-screen.git#1.4.2-ohos-1.0.0" --ignore-scripts
// metro.config.js —— file: / 软链装法需要把真实目录加进 watchFolders
watchFolders: [ path.resolve(__dirname, '../react-native-responsive-screen') ],
import ResponsiveScreenTestApp from './ResponsiveScreenTestApp';
AppRegistry.registerComponent('ResponsiveScreenTestApp', () => ResponsiveScreenTestApp);
hdc shell aa force-stop com.rnoh084.demo
hdc shell aa start -b com.rnoh084.demo -a EntryAbility --ps rnAppKey ResponsiveScreenTestApp

三、它到底是干什么的(源码真值)

整个库就这些(index.js,77 行):

import { Dimensions, PixelRatio } from 'react-native';

let screenWidth  = Dimensions.get('window').width;    // ← 模块加载时取一次
let screenHeight = Dimensions.get('window').height;   // ← 模块加载时取一次

const widthPercentageToDP = widthPercent => {
  const elemWidth = typeof widthPercent === "number" ? widthPercent : parseFloat(widthPercent);
  return PixelRatio.roundToNearestPixel(screenWidth * elemWidth / 100);
};

const heightPercentageToDP = heightPercent => { /* 同上,用 screenHeight */ };

const listenOrientationChange = that => {
  Dimensions.addEventListener('change', newDimensions => {
    screenWidth  = newDimensions.window.width;
    screenHeight = newDimensions.window.height;
    that.setState({ orientation: screenWidth < screenHeight ? 'portrait' : 'landscape' });
  });
};

const removeOrientationListener = () => {
  Dimensions.removeEventListener('change', () => {});   // ⚠️⚠️ 见第五节
};

export { widthPercentageToDP, heightPercentageToDP, listenOrientationChange, removeOrientationListener };

要点:

  1. screenWidth / screenHeight 是模块级的 let,在模块加载时取一次 ⇒ 不调用 listenOrientationChange 就不会更新;
  2. 入参可以是 number 或带 % 的字符串(内部 parseFloat);
  3. 结果经 PixelRatio.roundToNearestPixel 对齐到物理像素;
  4. 没有 default 导出,必须具名导入。

四、★ UI 验收:标题不能和状态栏(电量栏)重合

这是本项目明确提出的 UI 要求,我把它做成了可测量、可断言的验收项。

4.1 先量清楚"状态栏有多高、应用从哪里开始"

用 devecocli ui layout 看根节点:

Custom#View(144)     [0,117,1320,2232]
Scroll#ScrollView(146)[0,117,1320,2232]
Stack#RootView(1)    [0,117,1320,2232]     ← 应用根视图 top = 117

⇒ 状态栏占 0..117px(density 3 ⇒ 约 39vp),应用根视图从 y = 117 开始。

4.2 问题:默认不留白时,标题会落进状态栏区域

不加任何顶部留白时,滚到顶部的实测结果是:

(最靠上的可见文本是【副标题】)
Text#Paragraph(8) [36,117,1284,157] "纯 JS(依赖 lodash)· ……"   ← y=117
(主标题不在这份 dump 里 ⇒ 它被挤到了 y<117 的状态栏区域)

⇒ 主标题确实压进了状态栏,和截图里"标题与电量图标并排"的现象一致。

4.3 做法:加顶部安全留白,并把它写成断言

const STATUS_BAR_H = 117;   // 实测:RootView.top
const TOP_INSET = 48;       // vp ⇒ 144px,留出足够余量

<ScrollView contentContainerStyle={{paddingHorizontal: 12}}>
  <View style={{height: TOP_INSET}} />     {/* ★ 顶部安全留白 */}
  <View ref={titleRef}><Text>…标题…</Text></View>
  …
</ScrollView>

关键是要用 measureInWindow 来验收(而不是 onLayout):

titleRef.current?.measureInWindow((x, y) => {
  // y 是【窗口坐标】,可以直接和状态栏高度比
  record('B', '★ 主标题在状态栏下方(窗口 y ≥ 117)', true, y >= STATUS_BAR_H);
});

4.4 验收结果

项数值
状态栏高度117px
顶部安全留白48vp = 144px
标题窗口坐标 y(measureInWindow)420 ✅
判定420 > 117 ⇒ 与电量栏不重合 ✓

页面上也把这条打成了绿色横幅:★ 标题 y = 420 状态栏高 = 117 ⇒ 不重合 ✓

84%E6%88%AA%E5%9B%BE&pos_id=img-X5Jtrxs4-1791732954133)

给后来者的建议:RNOH 上页面一定要留顶部安全距离(本机状态栏 117px ≈ 39vp,取 48vp 有富余)。
验收时不要用 onLayout(它给的是相对父容器的坐标,本页实测标题 y=48,完全不能用来判断是否压到状态栏),要用 measureInWindow 拿窗口坐标。


五、★★ 最重要发现:removeOrientationListener() 会直接抛异常

5.1 源码

const listenOrientationChange = that => {
  Dimensions.addEventListener('change', newDimensions => {   // ← 返回的 subscription 被丢掉了
    …
  });
};

const removeOrientationListener = () => {
  Dimensions.removeEventListener('change', () => {});        // ← ⚠️⚠️
};

5.2 两条独立的问题

#问题说明
①用的是已被移除的 APIRN 0.65 起 Dimensions.removeEventListener 被废弃、推荐 subscription.remove();到 RN 0.84.1 它已经不存在了(源码里只有 static get / static set / static addEventListener)
②传的是全新的函数引用removeEventListener 必须传同一个函数引用才能移除。这里每次调用都新建一个 () => {} ⇒ 即使 API 还在,也永远移除不掉

另外:listenOrientationChange 没有保存 addEventListener 返回的 EventSubscription ⇒ 即使你想自己移除,也拿不到那个句柄。

5.3 实测(RN 0.84.1)

· typeof Dimensions.removeEventListener = undefined          ← API 已不存在
· typeof Dimensions.addEventListener    = function
· ★ 调用 removeOrientationListener() 的结果 = TypeError: Dimensions.removeEventListener is not a function
· listenOrientationChange(fake) 返回值 = undefined           ← 拿不到 subscription

⇒ removeOrientationListener() 是一个"必崩"的函数:只要被调用就抛 TypeError。

5.4 正确写法(自己接管,绕开这两个有问题的函数)

import {Dimensions} from 'react-native';

useEffect(() => {
  const sub = Dimensions.addEventListener('change', ({window}) => {
    // 自己维护宽高
    setSize({w: window.width, h: window.height});
  });
  return () => sub.remove();      // ★ 现代写法:用 subscription 移除
}, []);

如果你要用这个库的 wp() / hp() 并希望它在旋转后更新,又不想踩上面这两个坑,可以这样:

import {Dimensions} from 'react-native';
import {widthPercentageToDP as wp, heightPercentageToDP as hp} from 'react-native-responsive-screen';

// ❌ 不要用 removeOrientationListener()(会抛 TypeError)
// ✅ 把订阅自己管起来(这样也能让库内部的 screenWidth 被刷新)
useEffect(() => {
  const sub = Dimensions.addEventListener('change', () => {
    // 触发一次重渲染,让 wp()/hp() 重新取值
    forceUpdate();
  });
  return () => sub.remove();
}, []);

⚠️ 注意:这个库的 wp()/hp() 用的是模块内缓存的 screenWidth/screenHeight,只有走 listenOrientationChange(或你自己调 Dimensions.set)才会刷新。所以"旋转后 wp() 不更新"是这个库的已知设计限制,不是鸿蒙特有的问题。


六、换算精度:逐像素核对

百分比换算的正确性用"渲染真实 View + 量布局"来核对。窗口宽 1320px(440vp):

表达式期望实测(布局 dump)判定
wp('50%')660px660px✓ 精确
wp('25%')330px330px✓ 精确
wp('10%')132px132px✓ 精确
wp('100%')1320px显示 1284px✓ 见下注
hp('20%')2232×0.2 = 446.4px = 148.8vp149vp✓ 精确

wp('100%') 为什么显示 1284px? 因为条形所在的容器有 paddingHorizontal: 12(36px),填充从 x=36 开始、宽 1320px,右端被屏幕边缘裁掉 ⇒ 可见宽度 = 1320 − 36 = 1284px。换算本身仍是 1320px ✓

在这里插入图片描述

其它已核对的换算语义:

  • wp('100%') === PixelRatio.roundToNearestPixel(window.width) ✓ 恒等成立;
  • hp('100%') === PixelRatio.roundToNearestPixel(window.height) ✓;
  • 数字入参与字符串入参等价:wp(50) === wp('50%') ✓、hp(10) === hp('10%') ✓;
  • 结果满足 roundToNearestPixel:wp('33%') × scale 是整数 ✓;
  • 不做入参校验:wp('abc') ⇒ NaN(源码 parseFloat 后直接参与运算);hp(undefined) ⇒ NaN;
  • 不做上限裁剪:wp('150%') > 窗口宽(会溢出);
  • wp('0%') === 0 ✓。
  • Dimensions.get('window') 与物理屏的关系:wp('100%') 恒等于窗口宽,而窗口宽可能与物理屏宽不同(本机 window.width 与 roundToNearestPixel 结果一致,因此 wp('100%') 就是窗口宽)。

七、我自己的两个失误

#失误后果纠正
1用 onLayout 的 y 判断"标题是否压到状态栏"onLayout 给的是相对父容器的坐标(本页实测标题 y=48),不是屏幕/窗口坐标 ⇒ 断言假失败改用 measureInWindow 拿窗口坐标(实测 420),再和状态栏高度比
2比较函数对 undefined 处理错误断言 listenOrientationChange 不返回 subscription(期望 undefined、实际 undefined)却判不等 —— 因为 JSON.stringify(undefined) 返回的是真值 undefined(不是字符串 'undefined'),和字符串比较永远不相等在 S() 里先特判 v === undefined ⇒ 'undefined'

⇒ 两条都是测试写法问题,不是库的问题;修正后 30/30。

在这里插入图片描述


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

8.1 用这个库要知道的

事项说明
不要调用 removeOrientationListener()RN 0.84.1 下必抛 TypeError(API 已移除),且函数引用错误导致即使存在也移除不掉
listenOrientationChange 拿不到 subscription它没有 return,addEventListener 返回的句柄被丢弃 ⇒ 建议自己用 Dimensions.addEventListener + sub.remove()
wp()/hp() 的基准值是模块加载时缓存的不触发 change 事件就不会更新(旋转后不刷新属已知设计限制)
入参不做校验wp('abc') / hp(undefined) 都返回 NaN,不会报错,容易静默出错
不做上限裁剪wp('150%') 会超出屏幕
没有 default 导出必须 import {widthPercentageToDP, heightPercentageToDP} from 'react-native-responsive-screen'
★ 页面要留顶部安全距离RNOH 上状态栏叠在窗口上,本机状态栏 117px ≈ 39vp,建议 ≥ 48vp 留白

8.2 本次验证的边界

  1. 只在一台模拟器上验证(Pura X View,density 3,1320×2232),没有真机;其它设备的状态栏高度可能不同(本页的 STATUS_BAR_H = 117 是本机实测值,不是通用常量)。
  2. 没有做真实的横竖屏旋转验证:listenOrientationChange 的回调行为(setState({orientation}))与"旋转后 wp()/hp() 是否刷新"未验证(只验证了它的返回值和 removeOrientationListener 抛错)。
  3. removeOrientationListener() 抛错后是否留下已注册的监听器、是否会造成内存泄漏,未验证。
  4. wp() 的取值只核对了 0% / 10% / 25% / 33% / 50% / 100% / 150% 这几个点,没有做更多小数百分比(如 '33.33%')的边界核对。
  5. 没有验证 PixelRatio.roundToNearestPixel 在非 3 倍密度设备上的取整表现。
  6. 没有对照 iOS/Android 的同名行为。

未改动库代码。


九、常见问题

Q1:wp('50%') 到底是多少像素?

window.width × 50 / 100,再经 PixelRatio.roundToNearestPixel 对齐物理像素。本机窗口宽 1320px ⇒ 660px(实测精确吻合)。

Q2:调用 removeOrientationListener() 报 Dimensions.removeEventListener is not a function?

因为 RN 0.84.1 已经移除了这个 API(0.65 起就废弃了)。这个函数在当前 RN 上不可用,请自己接管订阅:

useEffect(() => {
  const sub = Dimensions.addEventListener('change', handler);
  return () => sub.remove();     // ✅ 现代写法
}, []);

Q3:旋转屏幕后 wp() 的值没变?

因为 screenWidth/screenHeight 是模块加载时缓存的。官方给的做法是调 listenOrientationChange(this)(它会在回调里刷新缓存并 setState 触发重渲染)。但注意它的配套 removeOrientationListener 在 RN 0.84.1 上会抛错 —— 所以要么不卸载、要么自己维护尺寸。

Q4:为什么我的页面标题和状态栏(时钟/电量)重叠了?

RNOH 上状态栏是叠在应用窗口上的。实测本机状态栏占 0..117px。给页面顶部留出安全距离即可:

<ScrollView contentContainerStyle={{paddingTop: 48}}>   {/* 48vp = 144px */}

验收要用 measureInWindow 拿窗口坐标(onLayout 给的是相对父容器的坐标,判断不了):

ref.current?.measureInWindow((x, y) => console.log('窗口 y =', y));   // 需 ≥ 117

Q5:wp('abc') 为什么不报错也不生效?

源码里是 parseFloat(widthPercent),没有做任何入参校验 ⇒ 非法值得到 NaN,然后一路传进样式里,表现为"这个尺寸无效/为 0",不会给你任何提示。⇒ 传参时自己保证格式。

Q6:wp('100%') 宽度超出了容器,正常吗?

正常 —— 它是屏幕宽度的百分比,不是"父容器宽度的百分比"。如果容器的可用宽度小于屏幕宽(比如外面有 padding),wp('100%') 就会溢出并被裁掉。要"相对父容器"请用 width: '100%'。

Q7:这个库需要鸿蒙侧做什么特殊处理吗?

不需要。 纯 JS、零依赖、无原生模块、不需要 HAR / 权限 / ohpm。唯一要注意的是本文说的两个方向监听函数的缺陷和页面顶部安全距离。

Q8:hp() 会不会把状态栏高度也算进去?

会。它用的是 Dimensions.get('window').height(本机 2232px 对应的 vp 值),这是应用窗口的高度。如果你需要"排除状态栏后的可用高度",得自己减去状态栏高度(本机 117px ≈ 39vp)。


小结

react-native-responsive-screen 只有 77 行、4 个导出、零依赖,交付包实现零改动(31/32 文件与上游逐字节一致),接入不需要任何鸿蒙侧处理。设备侧 30 / 30 断言通过,百分比换算逐像素精确(50% → 660px、25% → 330px、10% → 132px、hp('20%') → 149vp)。

值得记下来的是这几条:

  1. ★★ 库里的某个函数可能在当前 RN 上"必崩",一定要真的调用一次。
    removeOrientationListener() 内部用了 RN 0.84.1 已被移除的 Dimensions.removeEventListener ⇒ 实测抛 TypeError。它还同时犯了"传全新函数引用"这个错 ⇒ 即使 API 还在也移除不掉。⇒ 凡是有 addEventListener/removeEventListener 配对的库,都应该真的调一次 remove 看会不会抛。

  2. UI 的安全区要"量出来",不能靠眼估。
    RNOH 状态栏叠在窗口上(本机 0..117px)。留白用 vp、验收用 measureInWindow 取窗口坐标:本页标题窗口 y = 420 > 117 ⇒ 不重合 ✓。
    ⇒ 千万别用 onLayout 的 y 判断这件事 —— 它给的是相对父容器的坐标(本页实测 48),完全不反映屏幕位置。

  3. 比较函数要小心 undefined。
    JSON.stringify(undefined) 返回的是真值 undefined,不是字符串 'undefined' —— 我因此让一条"期望 undefined、实际 undefined"的断言判了不等。⇒ 写断言工具函数时,undefined / NaN / Symbol 都要特判。

  4. "基准值在模块加载时缓存"是这类工具库的通用陷阱。
    screenWidth/screenHeight 是模块级 let,只在初始化与 change 事件里赋值 ⇒ 旋转后不刷新、且没有任何提示。⇒ 用这类"全局尺寸工具"时,要确认它到底什么时候更新。

  5. 交付包里"看着像问题"的地方要先查 npm。
    本库 spec.json 缺 upstreamCommit,但 npm 的 gitHead 是有的(5b01afed…)⇒ 这一条属应修项。(注意:是否"属应修"要逐库查 npm 的 gitHead,不能一概而论。)


本篇用到的库

项内容
三方库react-native-responsive-screen(上游 1.4.2 的鸿蒙适配版)
适配仓库https://atomgit.com/oh-react-native/react-native-responsive-screen
适配 TAG1.4.2-ohos-1.0.0
需要 HAR / 权限 / ohpm都不需要(纯 JS,零依赖)
库类型纯 JS 工具库:把百分比换算成 dp(widthPercentageToDP / heightPercentageToDP)+ 方向监听
运行时依赖零
prepare / prepublish都没有
许可证MIT
上游仓库https://github.com/marudy/react-native-responsive-screen
上游基线npm gitHead = 5b01afedf28a8c88eb11435bc34c5b2827637a78
宿主工程RNOH084Demo(测试页 rnAppKey = ResponsiveScreenTestApp)
# 从适配仓库安装
npm install "git+https://atomgit.com/oh-react-native/react-native-responsive-screen.git#1.4.2-ohos-1.0.0" --ignore-scripts

# 本地目录接入(开发常用)
npm install ../react-native-responsive-screen --ignore-scripts
// metro.config.js
watchFolders: [ path.resolve(__dirname, '../react-native-responsive-screen') ],
// 用法:把设计稿百分比直接写成 dp
import {widthPercentageToDP as wp, heightPercentageToDP as hp} from 'react-native-responsive-screen';

const styles = StyleSheet.create({
  container: {width: wp('90%'), paddingVertical: hp('2%')},
  card:      {width: wp('45%'), height: hp('20%')},
  title:     {fontSize: wp('5%')},
});

// ★ 页面顶部留出状态栏安全距离(本机状态栏 117px ≈ 39vp,取 48vp)
<ScrollView contentContainerStyle={{paddingTop: 48}}>
  <View ref={titleRef}><Text>标题</Text></View>
  …
</ScrollView>

// ★ 验收:用 measureInWindow 拿【窗口坐标】,onLayout 不行
titleRef.current?.measureInWindow((x, y) => console.log('窗口 y =', y));   // 需 ≥ 117

// ⚠️ 不要调用它(RN 0.84.1 下抛 TypeError):
// removeOrientationListener();
// ✅ 要监听方向变化就自己管订阅:
useEffect(() => {
  const sub = Dimensions.addEventListener('change', () => forceUpdate());
  return () => sub.remove();
}, []);
# 换页启动测试页(force-stop 不能省,换页参数只在冷启动生效)
hdc shell aa force-stop com.rnoh084.demo
hdc shell aa start -b com.rnoh084.demo -a EntryAbility --ps rnAppKey ResponsiveScreenTestApp
# 量状态栏高度(用于安全留白)
devecocli ui layout | Select-String 'RootView'      # 看 [0,<top>,…],本机 top = 117

验证环境

项版本
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
本机状态栏高度117px(Stack#RootView.top)
宿主 HAP 产物entry-default-signed.hap
本次构建assembleHap 4 分 06 秒;改断言后重编 4 分 07 秒
验证规模设备侧 30 / 30 断言;百分比条 4 处像素核对(660 / 330 / 132 / 149vp)+ 100% 恒等式;UI 安全区 1 项(标题窗口 y = 420 > 117);removeOrientationListener 真实调用 1 次;上游 32 个文件逐字节比对

欢迎加入 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

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

更多推荐