HarmonyOS 横竖屏与全屏切换:如何避免页面重新排版后控件错位【鸿蒙心迹】

👋 你好,欢迎来到我的博客!我是【菜鸟学鸿蒙】
我是一名在路上的移动端开发者,正从传统“小码农”转向鸿蒙原生开发的进阶之旅。为了把学习过的知识沉淀下来,也为了和更多同路人互相启发,我决定把探索 HarmonyOS 的过程都记录在这里。
🛠️ 主要方向:ArkTS 语言基础、HarmonyOS 原生应用(Stage 模型、UIAbility/ServiceAbility)、分布式能力与软总线、元服务/卡片、应用签名与上架、性能与内存优化、项目实战,以及 Android → 鸿蒙的迁移踩坑与复盘。
🧭 内容节奏:从基础到实战——小示例拆解框架认知、专项优化手记、实战项目拆包、面试题思考与复盘,让每篇都有可落地的代码与方法论。
💡 我相信:写作是把知识内化的过程,分享是让生态更繁荣的方式。
如果你也想拥抱鸿蒙、热爱成长,欢迎关注我,一起交流进步!🚀
前言
做图文阅读、视频播放或图片浏览这类页面时,横竖屏切换几乎是绕不开的问题。旋转设备后,封面图、操作按钮、进度条的位置突然错乱,或者内容区宽度没有随窗口变化——这类问题的根源通常不是 ArkUI 的布局 bug,而是开发者对"窗口尺寸变化时页面到底发生了什么"这件事理解得不够准确。
这篇文章用一个最小化的图文详情页,走完横竖屏切换、全屏进入和退出、业务状态保持这一整条路。
一、横竖屏变化本质上改变了什么
很多开发者第一反应是"屏幕方向变了",但更准确的描述是:页面可用窗口的宽高比发生了变化。
这里有几件事需要明确区分:
屏幕(Display)和窗口(Window)不是同一个概念。
@ohos.display 描述的是物理屏幕的属性,@ohos.window 管理的是应用窗口。横竖屏切换时,真正影响布局的是 window.WindowProperties 中的 windowRect,也就是当前应用窗口的实际矩形区域,而不是屏幕的物理尺寸。在分屏模式下,两者的差距会更加明显。
HarmonyOS Stage 模型下,UIAbility 默认不会因为旋转而重建。
与某些 Android 早期版本不同,ArkUI 框架会在窗口尺寸变化时触发布局重算,但 UIAbility 生命周期和页面状态变量默认会保留。这是好事,但也意味着如果开发者把控件尺寸硬编码为固定数值,布局重算时这些固定值不会自动更新,就会出现错位。
全屏和横屏是两个独立的概念。
横屏是指窗口宽度大于高度;全屏是指应用窗口扩展到整个屏幕区域,通常伴随状态栏和导航栏的隐藏。两者可以同时存在,也可以只存在其中一个。一个常见的理解误区是把"进入横屏"等同于"进入全屏",在具体接口调用上这会导致行为不一致。
二、为什么不能只维护两套固定尺寸
有的做法是:在竖屏状态写一套固定宽高,在横屏状态写另一套固定宽高,靠判断方向切换。
这种方式在手机单机场景可能暂时奏效,但有几个明显问题:
- 平板、折叠屏、分屏模式下,同样是"横屏",窗口宽度可能差距悬殊;
- 分屏模式下,用户可以拖动分割线,窗口宽度是连续变化的,没有固定值;
- HarmonyOS 官方推荐的做法是使用响应式布局,让组件尺寸基于当前窗口的实际宽度计算,而不是依赖预设断点对应的固定像素值。
官方文档中的响应式布局体系基于**断点(Breakpoint)**概念,将窗口宽度划分为若干区间,在不同区间内选用不同的布局策略。断点以 vp 为单位,因此与屏幕像素密度无关。
三、搭建最小示例:一个图文详情页
下面这个示例模拟一个阅读类 App 的文章详情页,包含:
- 顶部标题区
- 封面图
- 正文内容区
- 底部操作栏(点赞、收藏、评论)
目标效果:
- 竖屏:标题、封面图、正文上下堆叠,底部操作栏固定;
- 横屏:封面图和正文左右分栏,扩大内容区;
- 全屏:隐藏顶部标题区和底部操作栏,只保留内容和退出按钮。
先看整体状态结构,这里用 @State 管理三个关键变量:
// 窗口当前宽度(vp)
@State windowWidth: number = 0
// 窗口当前高度(vp)
@State windowHeight: number = 0
// 是否处于全屏状态
@State isFullScreen: boolean = false
windowWidth 和 windowHeight 直接驱动布局,不另外维护一个"方向"枚举——宽大于高就是横向布局,高大于宽就是纵向布局,这样分屏场景也能自然适配。
四、核心代码实现
4.1 监听窗口尺寸变化
在 onWindowStageCreate 中获取主窗口,并注册 on('windowSizeChange') 监听。根据官方 API Reference,Window 类提供 on(type: 'windowSizeChange', callback: Callback<window.Size>): void,从 API 9 起支持。
// EntryAbility.ts
import { UIAbility, Want, AbilityConstant } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import hilog from '@ohos.hilog';
export default class EntryAbility extends UIAbility {
onWindowStageCreate(windowStage: window.WindowStage): void {
windowStage.loadContent('pages/ArticleDetailPage', (err) => {
if (err.code) {
hilog.error(0x0000, 'Ability', 'loadContent failed: %{public}s', JSON.stringify(err));
return;
}
});
// 获取主窗口,注册窗口尺寸变化监听
windowStage.getMainWindow((err, windowObj) => {
if (err.code) {
return;
}
windowObj.on('windowSizeChange', (size: window.Size) => {
// size.width 和 size.height 单位为 px,需转换为 vp
// 使用 AppStorage 在 Ability 与页面之间共享状态
const density = windowObj.getWindowProperties().density;
AppStorage.setOrCreate<number>('windowWidth', size.width / density);
AppStorage.setOrCreate<number>('windowHeight', size.height / density);
});
// 页面初始化时同步一次当前尺寸
const props = windowObj.getWindowProperties();
const density = props.density;
AppStorage.setOrCreate<number>('windowWidth', props.windowRect.width / density);
AppStorage.setOrCreate<number>('windowHeight', props.windowRect.height / density);
});
}
}
window.Size中width和height的单位是 px(物理像素),而 ArkUI 布局使用 vp(虚拟像素),必须除以屏幕密度density转换,否则在高分辨率屏幕上数值会偏大,直接传入会导致控件尺寸不正确。
getWindowProperties()返回WindowProperties,其中windowRect描述窗口在屏幕坐标系中的矩形,density是当前屏幕的像素密度比例。
4.2 页面内响应窗口尺寸
页面通过 @StorageProp 绑定 AppStorage 中的数据,这样 Ability 侧更新后页面会自动触发重新布局。
// ArticleDetailPage.ets
import { window } from '@kit.ArkUI';
@Entry
@Component
struct ArticleDetailPage {
@StorageProp('windowWidth') windowWidth: number = 360
@StorageProp('windowHeight') windowHeight: number = 780
@State isFullScreen: boolean = false
// 判断当前是否为横向布局(宽度大于高度)
get isLandscape(): boolean {
return this.windowWidth > this.windowHeight;
}
build() {
Stack({ alignContent: Alignment.TopStart }) {
if (this.isLandscape) {
// 横屏:左右分栏
this.LandscapeLayout()
} else {
// 竖屏:上下堆叠
this.PortraitLayout()
}
// 全屏时覆盖一个退出按钮
if (this.isFullScreen) {
Button('退出全屏')
.position({ x: 16, y: 16 })
.onClick(() => {
this.exitFullScreen();
})
}
}
.width('100%')
.height('100%')
}
@Builder
PortraitLayout() {
Column() {
// 顶部标题区
if (!this.isFullScreen) {
Row() {
Text('文章标题示例').fontSize(18).fontWeight(FontWeight.Bold)
}
.width('100%')
.height(56)
.padding({ left: 16, right: 16 })
.backgroundColor('#FFFFFF')
}
// 封面图区域
Image($r('app.media.cover'))
.width('100%')
.aspectRatio(16 / 9)
.objectFit(ImageFit.Cover)
.onClick(() => {
if (!this.isFullScreen) this.enterFullScreen();
})
// 正文内容区(竖屏占满剩余高度)
Scroll() {
Text('这里是文章正文内容,模拟长文本段落……')
.fontSize(16)
.lineHeight(26)
.padding(16)
}
.layoutWeight(1)
// 底部操作栏
if (!this.isFullScreen) {
Row({ space: 32 }) {
Button('点赞').type(ButtonType.Normal)
Button('收藏').type(ButtonType.Normal)
Button('评论').type(ButtonType.Normal)
}
.width('100%')
.height(56)
.justifyContent(FlexAlign.Center)
.backgroundColor('#F5F5F5')
}
}
.width('100%')
.height('100%')
}
@Builder
LandscapeLayout() {
Column() {
// 横屏下隐藏顶部标题(或保留,视产品需求)
if (!this.isFullScreen) {
Row() {
Text('文章标题示例').fontSize(16).fontWeight(FontWeight.Bold)
}
.width('100%')
.height(48)
.padding({ left: 16, right: 16 })
.backgroundColor('#FFFFFF')
}
// 横屏主体:封面图 + 正文左右分栏
Row() {
// 左侧封面图,宽度约占 45%
Image($r('app.media.cover'))
.width('45%')
.height('100%')
.objectFit(ImageFit.Cover)
.onClick(() => {
if (!this.isFullScreen) this.enterFullScreen();
})
// 右侧正文,占剩余宽度
Scroll() {
Text('这里是文章正文内容,模拟长文本段落……')
.fontSize(15)
.lineHeight(24)
.padding(16)
}
.layoutWeight(1)
.height('100%')
}
.layoutWeight(1)
// 横屏底部操作栏同样可以保留或隐藏
if (!this.isFullScreen) {
Row({ space: 32 }) {
Button('点赞').type(ButtonType.Normal)
Button('收藏').type(ButtonType.Normal)
Button('评论').type(ButtonType.Normal)
}
.width('100%')
.height(48)
.justifyContent(FlexAlign.Center)
.backgroundColor('#F5F5F5')
}
}
.width('100%')
.height('100%')
}
// 进入全屏:设置沉浸式,隐藏系统栏
enterFullScreen() {
const ctx = getContext(this) as common.UIAbilityContext;
window.getLastWindow(ctx).then((windowObj) => {
// 设置沉浸式布局(状态栏区域归属页面)
windowObj.setWindowLayoutFullScreen(true).then(() => {
// 隐藏状态栏和导航栏
windowObj.setWindowSystemBarEnable([]).then(() => {
this.isFullScreen = true;
});
});
});
}
// 退出全屏:恢复系统栏
exitFullScreen() {
const ctx = getContext(this) as common.UIAbilityContext;
window.getLastWindow(ctx).then((windowObj) => {
windowObj.setWindowSystemBarEnable(['status', 'navigation']).then(() => {
windowObj.setWindowLayoutFullScreen(false).then(() => {
this.isFullScreen = false;
});
});
});
}
}
真正需要关注的几个地方:
layoutWeight(1)是让子组件按比例分配剩余空间的关键,在竖屏的Column和横屏的Row中分别使用,避免把高度或宽度写死;setWindowLayoutFullScreen和setWindowSystemBarEnable都是异步 Promise,需要链式调用,不能只调其中一个;setWindowSystemBarEnable([])传入空数组表示隐藏所有系统栏;恢复时传['status', 'navigation']。
4.3 切换后恢复业务状态
由于 HarmonyOS Stage 模型下 UIAbility 不会因旋转重建,@State 中保存的数据(例如文章阅读进度、点赞状态)在旋转后自然保留。但有一个容易忽略的情况:Scroll 组件的滚动偏移量。
如果竖屏下用户已经滚动到正文中间,横屏后 Scroll 是一个新渲染的实例(因为横屏和竖屏分别是不同的 @Builder 分支),之前的偏移量不会自动传递过去。
可以用 Scroller 控制器保存当前偏移量,在布局切换时恢复:
// 在组件级别持有一个 Scroller 对象
private portraitScroller: Scroller = new Scroller();
private landscapeScroller: Scroller = new Scroller();
// 保存最近一次的滚动偏移(px)
@State savedScrollOffset: number = 0;
// 在 Scroll 组件上绑定 scroller 和 onScroll 回调
Scroll(this.portraitScroller) {
// ...内容
}
.onScroll((xOffset: number, yOffset: number) => {
// 竖屏滚动时记录当前偏移
this.savedScrollOffset = this.portraitScroller.currentOffset().yOffset;
})
切换到横屏布局时,在 aboutToAppear 或通过 @Watch 监听 isLandscape 变化后,用 scrollTo 恢复到对应位置。
这里需要注意:竖屏和横屏下内容区可用宽度不同,文本排版后行数不同,相同的
yOffset不代表相同的阅读位置。更精准的方案是记录"当前可见的第一个段落 ID",而不是原始 px 偏移量,但这属于业务层实现,不在本文范围内展开。
五、几个关键点拆开看
5.1 on('windowSizeChange') 和 onWindowStageEvent 是两件不同的事
onWindowStageEvent 监听窗口的前后台切换(SHOWN、HIDDEN、ACTIVE、INACTIVE),并不会在旋转时触发。如果只监听 onWindowStageEvent,是感知不到横竖屏变化的。
on('windowSizeChange') 才是响应窗口尺寸变化(包括旋转、分屏拖拽)的正确入口。
5.2 设置屏幕方向偏好
如果需要锁定某个页面的方向,可以调用 Window.setPreferredOrientation,API 9 起支持:
import { window } from '@kit.ArkUI';
// 锁定为竖屏
windowObj.setPreferredOrientation(window.Orientation.PORTRAIT);
// 锁定为横屏
windowObj.setPreferredOrientation(window.Orientation.LANDSCAPE);
// 跟随系统传感器旋转
windowObj.setPreferredOrientation(window.Orientation.AUTO_ROTATION);
window.Orientation 枚举值包括 UNSPECIFIED、PORTRAIT、LANDSCAPE、PORTRAIT_INVERTED、LANDSCAPE_INVERTED、AUTO_ROTATION 等。
注意:这个设置作用于窗口,不是全局生效。如果需要从一个页面进入某个横屏子页面后再返回竖屏,需要在对应页面的 aboutToAppear 和 aboutToDisappear 分别设置和还原。
5.3 windowRect 里的 px 与布局 vp 的转换
这是一个非常容易出错的地方。WindowProperties.windowRect 中 width 和 height 是像素单位。ArkUI 所有尺寸属性接受的是 vp。不做转换直接用来驱动布局,在 2× 或 3× 屏幕上会得到两三倍于预期的尺寸。
window.Size 中的数值同理,on('windowSizeChange') 回调里拿到的也是 px。
density 可以从 WindowProperties.density 获取。
5.4 安全区域与全屏的关系
进入全屏后,导航栏和状态栏的区域会被应用内容覆盖,但系统手势区域仍然存在。如果底部操作栏在全屏模式下仍然保留,需要通过 expandSafeArea 或配合 padding 处理底部安全区域,否则控件可能落在手势区域内,点击体验会有问题。
六、容易踩的坑
问题一:布局里写了固定 width 数值
一旦某个容器设置了如 width(375),旋转后这个数值不会变,内容区会溢出或留白。应改为 width('100%') 或借助 layoutWeight。
问题二:只在 aboutToAppear 里读一次窗口尺寸
如果只在页面初始化时读一次 windowRect 就不再更新,旋转后尺寸状态还是旧值,布局不会响应。必须通过 on('windowSizeChange') 持续更新。
问题三:setWindowLayoutFullScreen 和 setWindowSystemBarEnable 顺序颠倒
这两个接口是异步操作,如果只调 setWindowSystemBarEnable([]) 而不调 setWindowLayoutFullScreen(true),系统栏区域会留白而不是被内容覆盖;反之只调 setWindowLayoutFullScreen(true) 而不隐藏系统栏,系统栏还在,内容会被遮住。两者需要配合使用。
问题四:横竖屏分支用了不同的状态变量实例
上面示例中 isFullScreen 是共享的 @State,横屏和竖屏分支共用同一个变量。如果不小心在 @Builder 内部引入了局部变量来控制显隐,切换布局后局部变量会被重置,造成全屏状态丢失。
七、排查顺序
旋转后控件位置异常,建议按这个顺序排查:
- 确认
on('windowSizeChange')有正确注册,且回调中的状态更新路径确实触发了 ArkUI 的重新布局(检查@State或@StorageProp是否被正确更新); - 检查所有容器的宽高设置,找出硬编码的固定尺寸,改为相对值或
layoutWeight; - 确认 px 到 vp 的转换是否正确,尤其是直接使用
windowRect.width驱动布局的地方; - 检查横竖屏切换时的 Builder 分支,确认共享状态变量没有在分支内被局部变量覆盖;
- 如果涉及全屏,检查
setWindowLayoutFullScreen和setWindowSystemBarEnable是否都已调用且顺序正确; - 分屏场景验证:在支持分屏的设备上拖动分割线,观察布局是否能平滑跟随宽度变化,而不是只在两个固定值之间跳变。
开发经验总结
- 横竖屏切换的本质是窗口尺寸变化,正确的感知入口是
Window.on('windowSizeChange'),而不是生命周期回调; - 窗口尺寸数据单位是 px,ArkUI 布局单位是 vp,务必通过
density换算; - 布局驱动变量应来自实时的窗口宽度,而非硬编码的横竖屏枚举,这样分屏场景也能自然适配;
- 全屏需要
setWindowLayoutFullScreen与setWindowSystemBarEnable配合,缺一会有视觉残留; - Stage 模型下旋转不重建 Ability,
@State状态天然保留,但Scroll等有内部偏移状态的组件需要手动保存与恢复。
如果你正在做类似页面,可以重点检查一下:当窗口宽度连续变化(如分屏拖拽)时,你的布局是逐步平滑适应还是只在某个临界点做了一次跳变——这个细节往往比单纯处理横竖屏更能反映布局方案是否健壮。
📝 写在最后
如果你觉得这篇文章对你有帮助,或者有任何想法、建议,欢迎在评论区留言交流!你的每一个点赞 👍、收藏 ⭐、关注 ❤️,都是我持续更新的最大动力!
我是一个在代码世界里不断摸索的小码农,愿我们都能在成长的路上越走越远,越学越强!
感谢你的阅读,我们下篇文章再见~👋
✍️ 作者:菜鸟不学编程
🧵 本文原创,转载请注明出处。
更多推荐




所有评论(0)