1. 为什么横竖屏切换在 RNOH 里是个“隐形杀手”
1.1 核心痛点:跨端框架的生命周期断裂
先抛一个结论:React Native for OpenHarmony(下文统一叫 RNOH)的横竖屏切换,比 Android 原生要难处理一个量级。不是因为它框架本身做得多差,而是因为它把“原生系统的屏幕方向变化”翻译成“JS 层的尺寸和密度变化”时,中间隔着两层桥——ArkUI 的布局系统和 RN 的 Yoga 布局引擎。任何一层没对齐,表现出来就是白屏、布局错位、组件状态丢失,而且这种问题在开发机上不一定能稳定复现,一上真机就现原形。
我最早接触 RNOH 是在 RK3568 的板子上调试,当时团队在做一个基于 OpenHarmony 的工业平板应用,要求支持横竖屏自适应。最开始想得很简单:原生 Android 上我们用onConfigurationChanged加ViewConfiguration就能搞定,RN 里不是有useWindowDimensions吗?结果真跑起来才发现,RK3568 上屏幕旋转后,RN 层的Dimensions获取到的宽高值经常还是旧的,甚至有时候直接拿到 0。查了半天,最后定位到是 RNOH 的 display 同步机制跟 ArkUI 的窗口事件没有完全对齐。
这篇文章我就把横竖屏切换的完整处理链路拆开讲,从工程配置到 JS 层适配,再到状态恢复和踩坑实录,尽量让你少走我走过的弯路。
1.2 这套方案解决什么问题
- 尺寸监听失效:旋转后
Dimensions.get('window')返回旧值、或返回 0、或需要延迟几百毫秒才能拿到正确值 - 布局错位:横屏下 SafeArea 计算不对,内容被状态栏/导航栏遮挡
- 状态丢失:旋转导致组件树重建,页面的滚动位置、输入内容、弹窗状态全没了
- 性能问题:旋转瞬间出现白屏、掉帧,尤其在低端 RK3568 上特别明显
- 键盘冲突:横屏时软键盘把输入框完全挡住,调不起来或者弹一下就收回去
我下面说的每个问题,都是我在 RK3568 / RK3588 板子和模拟器上实际验证过的,代码片段可以直接抄。适配的系统版本以 OpenHarmony 4.0/4.1 为主,RN 版本建议用 0.72 以上的稳定分支,RNOH 的版本跟着 RN 主线走。
2. 工程配置:先把原生侧的门打开
2.1 module.json5 里 orientation 的正确姿势
RNOH 的工程结构跟原生 RN 最大的不同在于:它不是一个纯 JS 项目,而是把 RN 的 C++ 核心 + ArkUI 适配层编译成了一个 HarmonyOS 的 HAR/AAR 包,最终塞进一个标准的 OpenHarmony 应用工程里。所以你要做的第一件事,不是在 JS 代码里处理旋转,而是先确保原生窗口允许旋转。
在entry/src/main/module.json5中,找到abilities数组里对应的 MainAbility(通常是EntryAbility),配置orientation字段:
{ "abilities": [ { "name": "EntryAbility", "orientation": "auto", "supportRotation": true } ] }这里有个容易踩坑的地方:OpenHarmony 的 orientation 枚举值跟 Android 不完全一样。Android 里是"unspecified"、"sensor"、"fullSensor"这一套,OpenHarmony 这边实际生效的是"portrait"、"landscape"、"auto"三种主值。"auto"表示跟随系统传感器方向,也就是用户旋转设备时应用会自动旋转。如果你只想要横屏或只想要竖屏,那就固定写"portrait"或"landscape",不要写成"sensor"或"unspecified",某些版本会直接解析失败导致应用起不来。
另外supportRotation这个字段不是所有版本都有,我是在 4.1 release 的 SDK 里看到的。如果你的 SDK 版本比较老,编译报错或者运行时警告,这个字段直接删掉就行,只留"orientation"也能生效。这取决于你工程里 Stage 模型的版本。
2.2 窗口属性:setPreferredOrientation 的优先级
除了在 module.json5 里静态配置,RNOH 应用通常还会在 MainAbility 的onWindowStageCreate里动态设置窗口属性。为什么要动态设置?因为有些场景需要根据业务逻辑在运行时切换方向,而静态配置只能固定一个值。
import { window } from '@kit.ArkUI'; onWindowStageCreate(windowStage: window.WindowStage) { windowStage.getMainWindowSync().setPreferredOrientation(window.Orientation.AUTO); // 其他初始化逻辑 }这里补充一个原理性的东西:module.json5 里的 orientation 是窗口创建时的初始值,setPreferredOrientation是运行时的动态控制,两者是叠加关系。如果 module.json5 里写了"portrait",那你在onWindowStageCreate里即使调了setPreferredOrientation(window.Orientation.LANDSCAPE),最终可能还是不生效——因为应用启动阶段窗口直接按照静态配置创建了,动态设置被忽略。所以我的建议是:静态配置优先给"auto",把方向控制的逻辑完全交给 JS 层或者动态窗口 API。这样整个项目的方向策略是统一的,不会出现“明明代码里写了横屏,真机就是不转”的诡异问题。
2.3 RNOH 侧:确保 display 模块正常初始化
RNOH 的 display 模块负责把 ArkUI 的窗口尺寸、密度、方向变化同步到 RN 的 JS 运行时。如果你发现旋转后 JS 侧收不到事件,先排查一下 RNOH 包的配置。
在工程里的entry/src/main/ets/rn/RNPackageFactory.ets或类似的初始化文件里,确认RNOHCorePackage和DisplayMetrics相关模块已经注册:
import { RNPackageFactory, RNPackage } from 'rnoh'; import { RNOHCorePackage } from 'rnoh'; class MyPackageFactory extends RNPackageFactory { createRNPackages(): RNPackage[] { return [ new RNOHCorePackage(), // 如果你有自定义包,继续往这里加 ]; } }RNOHCorePackage里面自带DisplayMetricsModule,它是Dimensions和useWindowDimensions在 JS 层的数据源。这一步一般不会出问题,除非你用了精简版 RNOH 或者自己魔改了包列表。如果你的工程能正常启动并且显示 RN 页面,那这块大概率是通的。
3. JS 层监听:别再迷信 useWindowDimensions 了
3.1 useWindowDimensions 到底什么时候失效
RN 官方文档里说得很简单:useWindowDimensions会在窗口尺寸变化时自动更新组件。但你在 RNOH 上实测就会发现,它更新的时机大概率比你期望的晚半拍,而且不是所有场景都触发更新。
我遇到过的情况:在 RK3568 上,屏幕从竖屏旋转到横屏,useWindowDimensions的值需要大约 300~500ms 才能更新到正确值。这期间你的组件还拿着旧尺寸渲染,等新尺寸来了再触发一次重渲染,用户看到的就是“先错位一下,然后跳变到正确布局”。
原因在于 RNOH 的DisplayMetricsModule通过 ArkUI 的onWindowSizeChange回调获取尺寸变化,但这个回调的触发时机在窗口动画完成前后都有可能,且RNOH 的 JS 桥是异步的,尺寸采集和 JS 通知之间存在可感知的延迟。
所以我的建议是:不要只用useWindowDimensions作为唯一的数据源,要跟onLayout配合使用。
3.2 用 onLayout 拿到“真·可用区域”
onLayout是 RN 里最可靠的尺寸获取方式,因为它直连 Yoga 布局引擎——只要组件被真实布局了,回调一定会触发,而且拿到的宽高就是当前屏幕实际给到该组件的可用空间。
看一个典型页面容器的写法:
import React, { useState, useCallback } from 'react'; import { View, Text, StyleSheet, LayoutChangeEvent } from 'react-native'; const ScreenContainer = ({ children }) => { const [layout, setLayout] = useState({ width: 0, height: 0 }); const handleLayout = useCallback((event: LayoutChangeEvent) => { const { width, height } = event.nativeEvent.layout; if (width !== layout.width || height !== layout.height) { setLayout({ width, height }); console.log(`[Layout] 新尺寸: ${width} x ${height}`); } }, [layout]); return ( <View style={styles.container} onLayout={handleLayout}> {children} </View> ); }; const styles = StyleSheet.create({ container: { flex: 1, }, });这里有个细节:onLayout的触发时机在 RNOH 上比 Android 原生 RN 更可靠,因为 ArkUI 的布局流程和 RN 的 Yoga 布局在 RNOH 中是深度联动的。实测下来,onLayout拿到的尺寸跟真实窗口几乎零误差,而useWindowDimensions会有一定延迟。所以我做横竖屏适配时,优先用onLayout作为尺寸真相,useWindowDimensions只用来判断方向。
3.3 封装一个 hook:useResponsiveLayout
实际项目中我很少直接用useWindowDimensions或裸onLayout,而是封装成一个统一的响应式布局 hook。这样业务组件不需要关心尺寸获取的细节,只关心“当前是横屏还是竖屏”“可用区域多大”。
import { useState, useEffect, useCallback } from 'react'; import { useWindowDimensions, ScaledSize, LayoutChangeEvent } from 'react-native'; interface IResponsiveLayout { isLandscape: boolean; windowWidth: number; windowHeight: number; contentWidth: number; contentHeight: number; contentX: number; contentY: number; onContentLayout: (event: LayoutChangeEvent) => void; } export const useResponsiveLayout = (): IResponsiveLayout => { const { width: winWidth, height: winHeight } = useWindowDimensions(); const [contentSize, setContentSize] = useState({ width: winWidth, height: winHeight, x: 0, y: 0, }); // 方向判断:宽大于高就是横屏,这是最直观的判定 const isLandscape = winWidth > winHeight; // 优先使用 onLayout 回调,因为它和实际布局零偏差 const onContentLayout = useCallback((event: LayoutChangeEvent) => { const { width, height, x, y } = event.nativeEvent.layout; setContentSize({ width, height, x, y }); }, []); return { isLandscape, windowWidth: winWidth, windowHeight: winHeight, contentWidth: contentSize.width, contentHeight: contentSize.height, contentX: contentSize.x, contentY: contentSize.y, onContentLayout, }; };同时给容器绑定:
import React from 'react'; import { View } from 'react-native'; import { useResponsiveLayout } from './useResponsiveLayout'; const MyScreen = () => { const { isLandscape, windowWidth, windowHeight, onContentLayout, } = useResponsiveLayout(); return ( <View style={{ flex: 1 }} onLayout={onContentLayout}> {/* 横竖屏差异化布局 */} {isLandscape ? ( <View style={{ flexDirection: 'row' }}> {/* 横屏:左右分栏 */} </View> ) : ( <View style={{ flexDirection: 'column' }}> {/* 竖屏:上下排列 */} </View> )} </View> ); };注意 hook 里的contentWidth和contentHeight来自onContentLayout,而isLandscape用useWindowDimensions。为什么要混着用?因为方向判断需要的是全局窗口数据,而布局渲染需要的是组件自身实际占据的空间。这两者的语义完全不同,在横竖屏切换过程中尤其明显——窗口已经转成横向了,但你某个容器的实际布局可能还停留在纵向,直到下一帧 Yoga 重新计算完成。
4. 完整实战:一个横竖屏自适应页面
4.1 场景设定
假设我们要做一个数据监控页面,包含:
- 顶部标题栏(显示当前设备和时间)
- 中间主内容区(一个实时数据表格)
- 底部操作栏(两个按钮:刷新、设置)
竖屏下期望:标题栏 + 表格(可滚动) + 底部操作栏,全部纵向排列,表格区域占满剩余空间。
横屏下期望:左侧是表格,右侧是操作面板(上下堆叠的按钮),整体利用率更高。
4.2 完整代码实现
import React, { useCallback, useMemo } from 'react'; import { View, Text, Button, FlatList, StyleSheet, } from 'react-native'; import { useResponsiveLayout } from '../hooks/useResponsiveLayout'; interface IDataItem { id: string; label: string; value: string; } const mockData: IDataItem[] = Array.from({ length: 50 }, (_, index) => ({ id: String(index), label: `参数${index + 1}`, value: (Math.random() * 100).toFixed(2), })); const MonitorScreen = () => { const { isLandscape, windowWidth, windowHeight, contentWidth, contentHeight, onContentLayout, } = useResponsiveLayout(); const renderItem = useCallback(({ item }: { item: IDataItem }) => ( <View style={styles.row}> <Text style={styles.cellText}>{item.label}</Text> <Text style={styles.cellText}>{item.value}</Text> </View> ), []); const styles = useMemo(() => { // 横竖屏下动态生成不同的样式 if (isLandscape) { return StyleSheet.create({ container: { flex: 1, flexDirection: 'row', padding: 8, }, leftPanel: { flex: 1, marginRight: 8, borderRadius: 8, backgroundColor: '#fff', padding: 8, }, rightPanel: { width: 180, justifyContent: 'center', alignItems: 'center', borderRadius: 8, backgroundColor: '#f0f0f0', padding: 12, }, titleBar: { height: 40, justifyContent: 'center', paddingHorizontal: 8, }, footer: { flexDirection: 'row', justifyContent: 'space-around', paddingVertical: 8, }, row: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#e0e0e0', }, cellText: { fontSize: 14, color: '#333', }, listContent: { paddingBottom: 8, }, }); } // 竖屏样式 return StyleSheet.create({ container: { flex: 1, padding: 8, }, leftPanel: { flex: 1, borderRadius: 8, backgroundColor: '#fff', padding: 8, }, rightPanel: { flexDirection: 'row', justifyContent: 'space-around', marginTop: 8, borderRadius: 8, backgroundColor: '#f0f0f0', padding: 8, }, titleBar: { height: 48, justifyContent: 'center', paddingHorizontal: 8, }, footer: { flexDirection: 'row', justifyContent: 'space-around', paddingVertical: 8, }, row: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#e0e0e0', }, cellText: { fontSize: 14, color: '#333', }, listContent: { paddingBottom: 8, }, }); }, [isLandscape]); return ( <View style={styles.container} onLayout={onContentLayout}> {/* 标题栏 */} <View style={styles.titleBar}> <Text style={{ fontSize: 18, fontWeight: 'bold' }}> 实时数据监控 {isLandscape ? '(横屏模式)' : '(竖屏模式)'} </Text> </View> {/* 主体区域 */} <View style={{ flex: 1, flexDirection: isLandscape ? 'row' : 'column' }}> {/* 左侧表格 */} <View style={styles.leftPanel}> <FlatList data={mockData} renderItem={renderItem} keyExtractor={(item) => item.id} showsVerticalScrollIndicator={false} contentContainerStyle={styles.listContent} /> </View> {/* 右侧面板 */} <View style={styles.rightPanel}> <Button title="刷新" onPress={() => {}} /> <Button title="设置" onPress={() => {}} /> </View> </View> {/* 底部工具栏 */} <View style={styles.footer}> <Text>设备: RK3568</Text> <Text>分辨率: {contentWidth} x {contentHeight}</Text> </View> </View> ); }; export default MonitorScreen;这段代码里有两个关键设计值得展开讲:
第一个是样式对象用useMemo包裹,依赖变量只有isLandscape。横竖屏切换时只会重建一次样式,而不是每次渲染都重新StyleSheet.create。这在 Android 原生 RN 上可能无所谓,但 RNOH 跑在 ArkUI 的渲染管线里,StyleSheet 对象创建次数过多会加剧 JS 和 C++ 之间的序列化开销,明显感觉掉帧。
第二个是contentWidth和contentHeight显示在界面上。这不是我要调试才故意留的,而是这类监控页面的一个实际需求——客户需要直观看到当前应用可用区域大小。你在做适配的时候也可以这么干:把 hook 返回的尺寸临时渲染到屏幕上,旋转设备观察数值变化情况,比自己在那猜“到底拿错了没有”高效得多。
4.3 数据驱动的方向切换:别在 render 里做重活
上面代码里styles的重新计算依赖isLandscape,这本身没问题。但如果你在横竖屏切换时还需要请求接口、重新初始化图表、重建长列表数据,那就不能把这些逻辑直接放在 render 体里——React 的 render 必须是纯函数,不能有副作用。
正确做法是用useEffect监听方向变化:
useEffect(() => { if (isLandscape) { // 横屏:初始化图表、加载完整数据 fetchData('landscape'); } else { // 竖屏:加载精简数据 fetchData('portrait'); } }, [isLandscape]);这里有个容易忽略的性能细节:横竖屏切换时,如果页面原有的数据量很大(比如长列表),直接重建会引发一长串的renderItem调用,低端设备上会白屏半天。你可以在useEffect里加个防抖或者延迟:
useEffect(() => { const timer = setTimeout(() => { // 延迟 300ms 再加载横屏数据,避免和布局切换动画抢资源 fetchData(isLandscape ? 'landscape' : 'portrait'); }, 300); return () => clearTimeout(timer); }, [isLandscape]);300ms 是经验值。多数情况下横竖屏切换动画经过 300ms 已经过半,此时再触发数据加载,不会阻塞布局过渡。RK3568 这种处理器性能有限的板子上,这个延迟能明显降低掉帧概率。
5. 状态保持与性能优化:旋转不白屏的底层逻辑
5.1 白屏的根因:组件树重建
RN 应用在 Android 上旋转时,默认会重建 Activity(如果你的 Manifest 没配configChanges),RN 社区普遍会用android:configChanges="orientation|screenSize"绕过这个问题。RNOH 的情况不太一样,它不会因为旋转就重建 MainAbility,但组件树依然可能被局部重建——只要你某个视图的 key 结构、样式结构在旋转时发生了本质变化。
我遇到的一个典型案例:页面根节点在竖屏时是View,横屏时为了特殊布局换成了ScrollView。旋转之后,RN 的 diff 算法发现根节点类型变了,直接把整棵子树卸载重建。结果就是列表滚动位置清零、输入框内容丢失、页面闪烁白屏。
解决方案有两个思路:
方案一:保持根节点类型不变。不管横竖屏,都用同一个根组件。差异化布局通过内层容器实现,而不是换根节点。这是最稳妥的做法。
方案二:用key控制重建,但有意识地保留必要状态。比如表格数据放在全局 store(zustand、jotai)里,或者用useRef缓存滚动位置,旋转结束后恢复。核心思想是:状态不能放在组件的局部闭包里,要放在能跨渲染周期存活的地方。
5.2 滚动位置恢复的完整实现
我们在平板上做的监控页面,横竖屏切换后最烦人的就是列表滚到顶部。用户的视线本来在第 30 条数据上,旋转一下回到第 1 条,体验极差。
恢复滚动位置的核心是:FlatList的onScroll事件记录当前offsetY,方向切换后通过scrollToOffset恢复。
import { useRef } from 'react'; import { FlatList, NativeScrollEvent, NativeSyntheticEvent } from 'react-native'; const scrollOffsetRef = useRef<number>(0); const listRef = useRef<FlatList<IDataItem>>(null); // 记录滚动位置 const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => { scrollOffsetRef.current = event.nativeEvent.contentOffset.y; }, []); // 方向切换后恢复 useEffect(() => { if (!isLandscape) return; // 等布局稳定后恢复滚动位置 const timer = setTimeout(() => { listRef.current?.scrollToOffset({ offset: scrollOffsetRef.current, animated: false, }); }, 100); return () => clearTimeout(timer); }, [isLandscape]);这里面有个容易被忽略的问题:竖屏和横屏下的contentSize不一样,因为同样的数据在横向布局下每行高度可能不同、可见区域也不同。如果你在横屏恢复一个非常大的offsetY(比如 5000),但横屏列表总共内容只有 2000 高,scrollToOffset会直接滚到底部,视觉上还是“位置不对”。
所以更可靠的恢复策略是:记录“数据项索引”而不是“像素偏移”。先把当前可见的第一项index记下来,旋转后根据数据项索引计算新的像素位置:
const firstVisibleIndexRef = useRef(0); const handleViewableItemsChanged = useRef( ({ viewableItems }: { viewableItems: Array<{ index: number | null }> }) => { if (viewableItems.length > 0 && viewableItems[0].index != null) { firstVisibleIndexRef.current = viewableItems[0].index; } } ).current; const ITEM_HEIGHT = 40; // 每项固定高度,如果你用的是固定高度列表可以用这个方式 useEffect(() => { const timer = setTimeout(() => { const offset = firstVisibleIndexRef.current * ITEM_HEIGHT; listRef.current?.scrollToOffset({ offset, animated: false }); }, 100); return () => clearTimeout(timer); }, [isLandscape]);ITEM_HEIGHT固定的前提是你的renderItem渲染出来的每个 Item 高度一致。如果高度不固定,就退回到像素偏移方案,但要先保证横竖屏下每项高度的方差不能太大,否则恢复会不准。
5.3 白屏期间的过渡 UI:给用户一个“正在处理”的反馈
即使做了这么多优化,横竖屏切换瞬间低端设备依然可能闪白。与其跟白屏硬刚,不如在白屏发生之前主动做一个过渡提示框,让用户感知到“系统正在正常响应”,而不是“应用挂了”。
做法是在检测到方向变化后,立即渲染一个全屏的半透明遮罩:
const [showTransitionMask, setShowTransitionMask] = useState(false); useEffect(() => { if (isLandscape) { setShowTransitionMask(true); // 300ms 后遮罩消失,此时布局已经完成切换 const timer = setTimeout(() => setShowTransitionMask(false), 300); return () => clearTimeout(timer); } }, [isLandscape]);遮罩层用绝对定位覆盖全屏,半透明背景加一个居中的 ActivityIndicator(加载圈)。这个方案听起来有点“土”,但在工业平板这类对稳定性要求极高的场景下,它比白屏好太多——用户不会误以为设备死机。
5.4 使用 InteractionManager 优化复杂布局
RN 的InteractionManager是一个很实用的 API:它允许你把一个耗时的任务排在“所有动画和交互完成之后”再执行。在横竖屏切换这个场景里,我们可以用它来处理那些不急着渲染的模块:
import { InteractionManager } from 'react-native'; useEffect(() => { if (isLandscape) { const interactionTask = InteractionManager.runAfterInteractions(() => { // 这里是横屏模式下需要做的繁琐操作 // 比如初始化 ECharts 图表、构建大数据结构等 initializeChart(); }); return () => interactionTask.cancel(); } }, [isLandscape]);InteractionManager在 RNOH 上是正常工作的,底层由 React Native 的 core 模块实现,不依赖具体平台代码。我在 RNOH 的图表页面中实测,用它把 ECharts 的初始化任务推迟到旋转动画结束后执行,白屏时间从原来的接近 1 秒降到了几乎不可感知。动画和布局渲染不再跟 JS 的耗时任务抢主线程了。
6. 常见问题与排查技巧实录
6.1 尺寸获取不到正确值的排障思路
现象:旋转后,Dimensions.get('window')返回 0 或旧值,useWindowDimensions更新延迟严重。
排查步骤:
先在原生侧确认窗口方向是否真的切换了。可以在
onWindowSizeChange回调里打日志,看 ArkUI 是否收到了尺寸变化事件。如果原生侧压根没回调,说明是窗口配置问题,回到 module.json5 检查 orientation 配置。再看 RNOH 的 DisplayMetricsModule 是否正常注册。检查工程里
RNOHCorePackage是否引入,不要自己精简了包列表导致模块丢失。最后才是 JS 层的问题。如果你 JS 层一直用
Dimensions.get('window')同步读取,旋转后立刻拿到的值大概率是旧的。改用onLayout回调 +useWindowDimensions组合方案。
6.2 RNOH 键盘和横屏的相爱相杀
横屏下软键盘弹出的问题,可以说是除了白屏之外最影响体验的坑。竖屏下键盘占屏幕下方一半左右,输入框基本还能看见;横屏下键盘高度几乎占满整个屏幕高度,输入框直接被顶到看不见。
RNOH 当前的键盘处理,走的是KeyboardAvoidingView加Keyboard模块的 JS 桥。横屏时KeyboardAvoidingView的默认行为有时候不准,因为它拿到的键盘高度是通过keyboardWillShow事件传来的,而 RNOH 这层事件在当前版本(我在 0.72.x 上验证)还偶尔不触发或延迟。
实战中我的处理方式是:横屏时用KeyboardAvoidingView搭配behavior="padding",同时手动监听 Keyboard 事件做二次偏移修正。
import React, { useEffect, useState } from 'react'; import { View, TextInput, KeyboardAvoidingView, Keyboard, Platform, } from 'react-native'; import { useResponsiveLayout } from '../hooks/useResponsiveLayout'; const LoginForm = () => { const { isLandscape } = useResponsiveLayout(); const [keyboardHeight, setKeyboardHeight] = useState(0); useEffect(() => { const showSub = Keyboard.addListener('keyboardDidShow', (e) => { setKeyboardHeight(e.endCoordinates.height); }); const hideSub = Keyboard.addListener('keyboardDidHide', () => { setKeyboardHeight(0); }); return () => { showSub.remove(); hideSub.remove(); }; }, []); return ( <KeyboardAvoidingView style={{ flex: 1 }} behavior={isLandscape ? 'height' : 'padding'} > <View style={{ flex: 1, justifyContent: 'center', padding: 16 }}> <TextInput placeholder="请输入用户名" style={{ height: 18, borderBottomWidth: 1, marginBottom: 16 }} /> <TextInput placeholder="请输入密码" style={{ height: 18, borderBottomWidth: 1, marginBottom: 16 }} /> {isLandscape && keyboardHeight > 0 && ( <Text style={{ textAlign: 'center', fontSize: 12, color: '#999' }}> 当前为横屏模式,请使用外接键盘或旋转设备进入竖屏输入 </Text> )} </View> </KeyboardAvoidingView> ); };这里的关键点:behavior在横竖屏下要用不同的值。横屏下用"height"比"padding"更稳定,因为横屏高度空间本来就紧张,"padding"的算法受安全区影响容易过度偏移。竖屏下"padding"则是最常规的选择。另外我加了一个提示文案,横屏 + 键盘弹出时给用户一个引导——在平板这种宽屏设备上,横屏时弹系统键盘本来就是反人性的,业务上能避免就避免。
6.3 RNOH 下 RK3568 / RK3588 设备的表现差异
有不少人问“为什么我模拟器上测试完全是好的,一上开发板就不行了?”这里面既有性能因素,也有屏幕驱动差异的因素。
RK3568 是四核 A55 处理器,GPU 是 Mali-G52,性能大致相当于手机端的中低端水平。RNOH 的渲染链路中,JS 线程和 ArkUI 渲染线程之间需要数据同步,旋转动画期间两个线程负载骤增,低端设备更容易出现白屏和掉帧。我实测 RK3568 上从横屏切竖屏,useWindowDimensions的更新延迟大约是 300~500ms,而在 RK3588 上大概 100~200ms。
RK3588 的 GPU 更强、内存带宽更大,整体渲染性能好很多,但它在驱动层对“屏幕方向变化”这一事件的响应更加激进——窗口尺寸变化和传感器回调几乎同时触发,容易导致 JS 层收到重复的 resize 事件。如果你发现旋转一次但日志打了两次onLayout,别慌,这是正常的。处理方式是在回调里做“值相等判断”,也就是我前面代码里的if (width !== layout.width || height !== layout.height),避免同一尺寸触发无限重渲染。
还有个坑值得一提:开发板连接 HDMI 显示器时,如果显示器不支持自动旋转,物理旋转设备但屏幕方向可能保持不变。这种情况不是代码 bug,是显示链路不支持。在 RK3568 这种工控板上,如果是固定壁挂式安装,通常建议直接锁定竖屏或者横屏,不要做自动旋转,省得踩这个坑。
6.4 快速排查速查表
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
旋转后Dimensions返回 0 | RNOH DisplayMetricsModule 未注册 | 检查 RNOHCorePackage 是否引入 |
| 旋转后布局延迟 500ms 才变化 | JS 桥异步通知延迟 | 改用onLayout作为布局数据源 |
| 旋转后组件状态丢失 | 根节点类型变化导致树重建 | 统一根节点类型,状态提升到全局 store |
| 旋转后滚动位置回到顶部 | 列表重绘后重新挂载 | 用 viewability 记录索引并恢复 |
| 旋转瞬间白屏 | 低端设备渲染压力大 | InteractionManager + 过渡遮罩 |
| 横屏弹键盘布局错乱 | KeyboardAvoidingView 横竖屏 behavior 差异 | 横屏用height,竖屏用padding |
| 静态配置横屏不生效 | module.json5 orientation 被动态配置覆盖 | 统一用setPreferredOrientation,静态配置给auto |
| 旋转事件重复触发 | 双事件源(传感器 + 窗口回调) | 值相等判断,去抖处理 |
| 调试时真机不旋转 | 显示器或驱动不支持自动旋转 | 检查显示链路,或直接锁定方向 |
6.5 方向锁定的兜底方案
如果你的业务场景其实不需要真正的自动旋转——比如大多数工业平板应用都是固定方向安装——那最省事的方案就是直接锁定应用方向。这样做还能顺带解决一部分性能问题,因为 RNOH 不需要处理方向变化事件,渲染管线更稳定。
在 RNOH 中锁定方向有两个层面。第一层是 module.json5 里写死 orientation:
"orientation": "landscape"第二层是窗口初始化时强制指定:
windowStage.getMainWindowSync().setPreferredOrientation(window.Orientation.LANDSCAPE);双保险,避免某些版本对静态配置解析不彻底的问题。锁定方向后,逻辑上就不再需要useResponsiveLayout了,但保留这个 hook 也没什么坏处——以后如果产品经理突然说要支持自动旋转,你已经有现成的适配逻辑了。
7. 最后再分享几个经验细节
走完整个横竖屏适配的流程,我最大的体会是:RNOH 的横竖屏切换,本质上不是“能不能转”的问题,而是“JS 层如何跟上原生窗口变化的节奏”的问题。RN 的标准 API 在 Android/iOS 上已经高度成熟,但你在新的平台上就要接受它的“心智模型”会有些微变化——useWindowDimensions不再那么实时,onLayout反而成了最可信的伙伴,InteractionManager变成了避免白屏的利器。
有几个细节我是在项目里吃过亏之后才补上的:
FlatList的key属性不要跟方向绑定。如果横竖屏用不同的key,等于强制列表整体卸载重挂,状态一定丢。要让列表保持同一个key,只改内容区的约束。过渡遮罩的
zIndex要足够大,否则某些原生组件会把它盖住。RNOH 中zIndex在 iOS 和 Android 上的行为差异比较大,但在 OpenHarmony 上默认遵循的是 ArkUI 的层级规则,遮罩层建议放在页面最外层并且position: 'absolute'加zIndex: 999。如果你在横竖屏切换后还要重新请求权限或者初始化传感器,注意这些回调可能携带的是旧窗口尺寸。权限弹窗在旋转期间被触发的话,回调里拿到的 width/height 可能是上一方向的值,需要在回调里再次通过
onLayout刷新。测试阶段建议把“自动旋转”和“手动旋转”都覆盖到。手动旋转在模拟器里用 Ctrl 键模拟,真机上通过设置里的“传感器校准”或“方向锁定”开关测试。这两种触发方式在时序上略有差异,手动旋转更慢、更平滑,自动旋转更快、更容易暴露竞态问题。
横竖屏适配不是一个“做完就完事”的功能,它需要你在真机上反复调试、观察数据、调整策略。我的建议是:先在小范围页面上跑通这套逻辑,再逐步推广到整个应用。别一上来就给所有页面套上自动旋转——你会发现,绝大多数页面在你锁定方向之后,用户体验反而更稳。