本文记录把react-native-responsive-screen适配到 HarmonyOS 的完整过程。
这个库只有77 行代码、4 个导出函数、零依赖,接入极其简单。但它有两个特别值得写的点:
- ★★ 一个会让应用直接抛异常的缺陷:
removeOrientationListener()内部调用的是Dimensions.removeEventListener('change', () => {})—— 而这个 API在 RN 0.84.1 里已经被移除了(改成addEventListener返回EventSubscription)⇒调用它会抛TypeError。而且它传的是一个全新的箭头函数,就算 API 还在也移除不掉。 - 一个 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)⇒ 安装路径最干净 |
main | index.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 工具库、零依赖。接入只要三步:
npminstall"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: / 软链装法需要把真实目录加进 watchFolderswatchFolders:[path.resolve(__dirname,'../react-native-responsive-screen')],importResponsiveScreenTestAppfrom'./ResponsiveScreenTestApp';AppRegistry.registerComponent('ResponsiveScreenTestApp',()=>ResponsiveScreenTestApp);hdc shell aa force-stop com.rnoh084.demo hdc shell aa start-bcom.rnoh084.demo-aEntryAbility--psrnAppKey ResponsiveScreenTestApp三、它到底是干什么的(源码真值)
整个库就这些(index.js,77 行):
import{Dimensions,PixelRatio}from'react-native';letscreenWidth=Dimensions.get('window').width;// ← 模块加载时取一次letscreenHeight=Dimensions.get('window').height;// ← 模块加载时取一次constwidthPercentageToDP=widthPercent=>{constelemWidth=typeofwidthPercent==="number"?widthPercent:parseFloat(widthPercent);returnPixelRatio.roundToNearestPixel(screenWidth*elemWidth/100);};constheightPercentageToDP=heightPercent=>{/* 同上,用 screenHeight */};constlistenOrientationChange=that=>{Dimensions.addEventListener('change',newDimensions=>{screenWidth=newDimensions.window.width;screenHeight=newDimensions.window.height;that.setState({orientation:screenWidth<screenHeight?'portrait':'landscape'});});};constremoveOrientationListener=()=>{Dimensions.removeEventListener('change',()=>{});// ⚠️⚠️ 见第五节};export{widthPercentageToDP,heightPercentageToDP,listenOrientationChange,removeOrientationListener};要点:
screenWidth/screenHeight是模块级的let,在模块加载时取一次⇒不调用listenOrientationChange就不会更新;- 入参可以是
number或带%的字符串(内部parseFloat); - 结果经
PixelRatio.roundToNearestPixel对齐到物理像素; - 没有 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 源码
constlistenOrientationChange=that=>{Dimensions.addEventListener('change',newDimensions=>{// ← 返回的 subscription 被丢掉了…});};constremoveOrientationListener=()=>{Dimensions.removeEventListener('change',()=>{});// ← ⚠️⚠️};5.2 两条独立的问题
| # | 问题 | 说明 |
|---|---|---|
| ① | 用的是已被移除的 API | RN 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%') | 660px | 660px | ✓精确 |
wp('25%') | 330px | 330px | ✓精确 |
wp('10%') | 132px | 132px | ✓精确 |
wp('100%') | 1320px | 显示 1284px | ✓ 见下注 |
hp('20%') | 2232×0.2 = 446.4px =148.8vp | 149vp | ✓精确 |
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 本次验证的边界
- 只在一台模拟器上验证(
Pura X View,density 3,1320×2232),没有真机;其它设备的状态栏高度可能不同(本页的STATUS_BAR_H = 117是本机实测值,不是通用常量)。 - 没有做真实的横竖屏旋转验证:
listenOrientationChange的回调行为(setState({orientation}))与"旋转后wp()/hp()是否刷新"未验证(只验证了它的返回值和removeOrientationListener抛错)。 removeOrientationListener()抛错后是否留下已注册的监听器、是否会造成内存泄漏,未验证。wp()的取值只核对了 0% / 10% / 25% / 33% / 50% / 100% / 150% 这几个点,没有做更多小数百分比(如'33.33%')的边界核对。- 没有验证
PixelRatio.roundToNearestPixel在非 3 倍密度设备上的取整表现。 - 没有对照 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)); // 需 ≥ 117Q5: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)。
值得记下来的是这几条:
★★ 库里的某个函数可能在当前 RN 上"必崩",一定要真的调用一次。
removeOrientationListener()内部用了RN 0.84.1 已被移除的Dimensions.removeEventListener⇒ 实测抛TypeError。它还同时犯了"传全新函数引用"这个错 ⇒即使 API 还在也移除不掉。⇒凡是有addEventListener/removeEventListener配对的库,都应该真的调一次remove看会不会抛。UI 的安全区要"量出来",不能靠眼估。
RNOH 状态栏叠在窗口上(本机0..117px)。留白用 vp、验收用measureInWindow取窗口坐标:本页标题窗口y = 420 > 117⇒ 不重合 ✓。
⇒千万别用onLayout的y判断这件事—— 它给的是相对父容器的坐标(本页实测48),完全不反映屏幕位置。比较函数要小心
undefined。JSON.stringify(undefined)返回的是真值undefined,不是字符串'undefined'—— 我因此让一条"期望 undefined、实际 undefined"的断言判了不等。⇒ 写断言工具函数时,undefined/NaN/Symbol都要特判。"基准值在模块加载时缓存"是这类工具库的通用陷阱。
screenWidth/screenHeight是模块级let,只在初始化与change事件里赋值 ⇒ 旋转后不刷新、且没有任何提示。⇒ 用这类"全局尺寸工具"时,要确认它到底什么时候更新。交付包里"看着像问题"的地方要先查 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 |
| 适配 TAG | 1.4.2-ohos-1.0.0 |
| 需要 HAR / 权限 / ohpm | 都不需要(纯 JS,零依赖) |
| 库类型 | 纯 JS 工具库:把百分比换算成 dp(widthPercentageToDP/heightPercentageToDP)+ 方向监听 |
| 运行时依赖 | 零 |
prepare/prepublish | 都没有 |
| 许可证 | MIT |
| 上游仓库 | https://github.com/marudy/react-native-responsive-screen |
| 上游基线 | npmgitHead=5b01afedf28a8c88eb11435bc34c5b2827637a78 |
| 宿主工程 | RNOH084Demo(测试页rnAppKey = ResponsiveScreenTestApp) |
# 从适配仓库安装npminstall"git+https://atomgit.com/oh-react-native/react-native-responsive-screen.git#1.4.2-ohos-1.0.0"--ignore-scripts# 本地目录接入(开发常用)npminstall../react-native-responsive-screen --ignore-scripts// metro.config.jswatchFolders:[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-bcom.rnoh084.demo-aEntryAbility--psrnAppKey ResponsiveScreenTestApp# 量状态栏高度(用于安全留白)devecocli ui layout|Select-String'RootView'# 看 [0,<top>,…],本机 top = 117验证环境
| 项 | 版本 |
|---|---|
| React Native | 0.84.1 |
| React | 19.2.3 |
| RNOH(npm / ohpm) | @react-native-oh/react-native-harmony/@rnoh/react-native-openharmony0.84.3 |
| Node.js / npm | v24.14.0/11.9.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),1320×2232,density 3 |
| 本机状态栏高度 | 117px(Stack#RootView.top) |
| 宿主 HAP 产物 | entry-default-signed.hap |
| 本次构建 | assembleHap4 分 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