ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

HarmonyOS 6横竖屏切换实战:窗口旋转与页面跳转的完整方案

HarmonyOS 6横竖屏切换实战:窗口旋转与页面跳转的完整方案

HarmonyOS 6 开发中的横竖屏切换,听起来就是改个配置的事,但真正要在窗口旋转和页面跳转之间做取舍时,坑比想象中多。这篇文章我打算用一套完整源码,把从窗口旋转到页面跳转的链路拆开讲清楚,涵盖屏幕方向配置、窗口级旋转接口、页面路由传递、布局自适应和状态恢复。整个过程基于 ArkTS 和 ArkUI 实现,适合正在做鸿蒙应用适配、或者想把视频类页面做成横屏全屏的开发者参考。熟悉 Stage 模型的开发者能更快理解,新人照着步骤也能把代码跑起来,再按自己的业务逻辑改改就能用。

1. 方案设计:先想清楚旋转窗口还是跳转页面

1.1 横竖屏适配的本质

先回到一个基本问题:横竖屏切换到底在切什么?很多人以为是简单地把宽高换一下,但实际是窗口坐标系、布局约束、安全区域这三件事同时发生了变化。以常见的手机窗口为例,竖屏时可用宽度通常是 360vp、高度 780vp,旋转到横屏后可用宽度变成 780vp、高度变成 360vp。ArkUI 的布局引擎会重新执行测量和排列,所以自适应布局写得好不好,直接决定了旋转后是“无缝切换”还是“东倒西歪”。

除此之外,安全区域的位置也会变。竖屏时状态栏在顶部,刘海区域在正上方;横屏时状态栏可能在左侧或右侧,底部手势条也会横向移动。如果页面里有悬浮按钮、底部导航栏、全屏播放控制层,这部分必须单独处理。否则你会看到按钮跑到挖孔区域里,或者播放器的控制条被手势条挡住。

我把这一层拆开理解之后才发现,横竖屏适配不应该只盯着“旋转”这个动作,而是要把窗口变化当成一次完整的布局状态迁移:方向决定窗口宽高,宽高决定安全区域和安全边距,安全边距又决定内容容器的真实可用区域。

1.2 窗口旋转 vs 页面跳转:两个场景的取舍

在 HarmonyOS 6 里,实现横屏播放有两条明确的技术路线。

第一条是窗口旋转。通过窗口对象调用方向设置接口,把整个应用窗口的物理方向切到横屏,当前页面还在同一个页面实例里,只是触发尺寸变化和布局重排。这种方式适合“同一个页面内容需要适应横竖屏”的场景,比如阅读器、聊天界面、相册浏览。优点是页面状态连续,不需要额外处理路由栈;缺点是所有页面都会跟着转,如果只有播放页需要横屏,就控制不住全局体验。

第二条是页面跳转。不旋转当前窗口,而是通过路由跳到一个独立的横屏页面,在那个页面的生命周期里锁定横屏方向,等用户退出时再恢复竖屏。这种方式适合“横屏是一个独立业务场景”的情况,比如视频播放页、AR 展示页、横屏刮刮乐。优点是不同页面可以拥有完全独立的布局代码,业务隔离好;缺点是要额外处理页面跳转、参数传递、返回后的方向恢复。

我实际做的时候没有只选一边,而是结合了:主页保持竖屏锁定,播放页通过路由跳过去,播放页内部再支持窗口旋转来切换“小窗播放”和“全屏横屏”。这样既隔离了业务,又在横屏页面内部保留了窗口旋转的灵活性。

1.3 整体思路:两条链路,一套状态同步

这套方案的核心链路可以拆成三条:

  • 竖屏主页:固定竖屏,展示视频列表,点击列表项后跳转到播放页。
  • 横屏播放页:跳转成功后设置窗口为横屏,页面内支持全屏/退出全屏两种布局,退出全屏后直接路由返回。
  • 方向状态同步:用全局存储记录当前窗口方向,保证从播放页返回主页时,主页能主动把方向恢复成竖屏,避免“横屏卡在主页”的尴尬。

方向状态同步是我这次最看重的地方。由于窗口旋转是异步过程,页面跳转又是独立的业务行为,如果不去统一维护方向状态,很容易出现用户都回到主页了,传感器还转着手机,页面依然横屏。我在代码里用一个方向管理单例去记录当前期望方向和实际方向,并在每个关键生命周期里做校对。

1.4 工程结构规划

在动手写代码前,我先把这个模拟播放器 Demo 的工程结构列一下,后面所有源码都按照这个结构来组织:

  • entry/src/main/ets/entryability/EntryAbility.ets:应用入口,在这里初始化窗口能力。
  • entry/src/main/ets/common/WindowUtil.ets:窗口工具封装,所有旋转操作都走这个工具类。
  • entry/src/main/ets/common/OrientationMgr.ets:方向状态单例,维护当前方向、期望方向。
  • entry/src/main/ets/pages/Index.ets:竖屏主页,视频列表。
  • entry/src/main/ets/pages/PlayerPage.ets:播放页,支持横屏全屏和退出。

这样做的好处是上层页面不直接依赖 window API,方向变化统一由工具类管理,后续如果要接入传感器自动旋转或者多窗口适配,只需要改工具类和状态单例,页面代码不用大动。

2. 基础配置与窗口旋转:把旋转控制权拿回来

2.1 module.json5 中的屏幕方向配置

在 HarmonyOS 6 的 Stage 模型里,应用的屏幕方向可以在两个层级配置:应用级和 Ability 级。我的习惯是前期把 Ability 级配置成auto_rotation,让方向控制完全交给代码,而不是在配置文件里写死。配置文件位于entry/src/main/module.json5,核心片段如下:

{ "module": { "abilities": [ { "name": "EntryAbility", "srcEntry": "./ets/entryability/EntryAbility.ets", "orientation": "auto_rotation", "exported": true, "skills": [ { "entities": ["entity.system.home"], "actions": ["action.system.home"] } ] } ] } }

这里orientation字段我解释一下:portrait是强制竖屏,landscape是强制横屏,auto_rotation是跟随系统传感器方向。如果只在单页面对方向有要求,不要直接在 Ability 这层写死,否则代码里调用窗口旋转接口很可能不生效。我踩过这个坑:配置文件写了landscape,后面在代码里怎么调setPreferredOrientation都转不回来,因为配置层面的优先级是最高约束。

2.2 代码级窗口旋转:WindowStage 获取与 setPreferredOrientation

窗口旋转的核心接口是通过 WindowStage 拿到主窗口,然后调用方向设置方法。HarmonyOS 6 里常用的写法如下:

import { window } from '@kit.ArkUI'; export function setOrientation(orientation: window.Orientation) { window.getLastWindow((err, win) => { if (err) { console.error('Get window failed: ', JSON.stringify(err)); return; } win.setPreferredOrientation(orientation, (setErr) => { if (setErr) { console.error('Set orientation failed: ', JSON.stringify(setErr)); return; } console.info('Orientation set to: ' + orientation); }); }); }

在页面里调用时,我习惯放在onPageShow而不是aboutToAppear。原因是aboutToAppear触发时页面还没真正完成挂载,窗口也不一定处于可用状态,方向设置请求可能被丢弃。onPageShow时页面已经可见,窗口尺寸已经确定,这时候设置成功率最高。

如果你的工程还在用旧版本 SDK,也可以换成@ohos.window的导入方式,API 名称基本一致。我这里以 HarmonyOS 6 的 API 风格为准。

2.3 监听旋转结果与页面布局自适应

方向设置是异步接口,不能在调用后立刻假设布局已经切过去。正确做法是监听窗口尺寸变化,在变化完成后再做布局同步。窗口对象提供了on('windowSizeChange')监听:

win.on('windowSizeChange', (size: window.Size) => { console.info(`Window size changed: ${size.width} x ${size.height}`); AppStorage.setOrCreate('windowWidth', size.width); AppStorage.setOrCreate('windowHeight', size.height); });

页面里可以通过@StorageLink或@StorageProp绑定这两个值,驱动布局切换。比如播放页里有个控制栏,竖屏时放在底部,横屏时放在右侧,我就在 build 方法里根据宽高对比判断:

@StorageLink('windowWidth') windowWidth: number = 360; @StorageLink('windowHeight') windowHeight: number = 780; isLandscape(): boolean { return this.windowWidth > this.windowHeight; }

这只是最基础的宽高对比法,适合快速验证。如果涉及更精细的断点适配,建议用mediaquery监听条件,例如在竖屏页面监听(orientation: portrait),横屏页面监听(orientation: landscape)。媒体查询的优点是系统已经帮你处理了折叠屏、平板等特殊形态,不只是手机。

2.4 旋转过程中的生命周期与状态保持

窗口旋转会不会把页面重建一次?在 Stage 模型下,旋转默认会触发窗口尺寸变化,但页面实例不一定销毁。不过如果你在旋转过程中依赖了设备方向传感器,会收到能力变化通知,需要正确处理。我的做法是:

  • 业务数据用@State或全局AppStorage持有,不要只存在局部变量里;
  • 旋转后调用的逻辑统一放在窗口尺寸监听回调里,而不是散落在各个按钮事件里;
  • 不要在onPageHide里去重置方向,因为从播放页跳回主页时也会触发,容易误伤主页方向。

状态保持的关键是“方向状态必须和数据状态解耦”。数据状态属于业务,方向状态属于窗口,两者混在一起,排查问题时会非常痛苦。

3. 页面跳转实现:不同页面承载不同方向的布局

3.1 场景设计:竖屏主页到横屏播放页

模拟项目“某播放器Demo”的业务场景是这样的:主页是一列视频卡片,全部竖屏展示;点击任意卡片,跳转到播放页;播放页先以横屏全屏形态出现,加载视频并自动播放;点击返回按钮,先切回竖屏并退出全屏,再回到主页。

这个场景几乎覆盖了大多数视频类应用的核心需求:主页列表、详情播放、横竖屏全屏切换。它比单纯的“窗口旋转 demo”更有参考价值,因为涉及到了两个页面的方向不一致,需要处理好“谁负责设置方向、谁负责恢复方向”的问题。

3.2 页面路由与参数传递

页面跳转我用的是router模块,因为它足够轻量,适合播放页这种独立页面。路由参数传递也很直接:

import { router } from '@kit.ArkUI'; function goToPlayer(videoId: string) { router.pushUrl({ url: 'pages/PlayerPage', params: { videoId: videoId, title: '视频标题' } }); }

在播放页的aboutToAppear里读取参数:

import { router } from '@kit.ArkUI'; const params = router.getParams() as Record<string, string>; this.videoId = params['videoId']; this.title = params['title'];

使用router的时候要注意一点:路由栈会一直保留主页,如果业务逻辑复杂,页面间通信变多,建议还是切到Navigation。但这里为了把“窗口旋转 + 页面跳转”的主线讲清楚,我用router做演示,代码读起来更直观。

3.3 目标页面锁定方向与返回恢复方向

这是整篇文章的难点。播放页要横屏,主页要竖屏,方向必须跟着页面走。我在播放页的onPageShow里强制横屏:

import { window } from '@kit.ArkUI'; import { setOrientation } from '../common/WindowUtil'; onPageShow(): void { setOrientation(window.Orientation.LANDSCAPE); }

返回时,我推荐在播放页的返回监听里先恢复竖屏,再执行路由返回,而不是等主页的onPageShow再恢复。原因是主页onPageShow触发时机在路由返回之后,如果窗口还横着,主页会先以横屏闪现一帧,然后才切到竖屏,视觉上非常突兀。

在播放页里处理返回的逻辑:

onBackPress(): boolean { setOrientation(window.Orientation.PORTRAIT); router.back(); return true; }

onBackPress返回true表示消费掉这次返回事件,自己处理返回逻辑;返回false则执行默认行为。这里有个细节:调用setOrientation到窗口真正切到竖屏还有异步延迟,如果立刻router.back(),主页可能还是会闪一下横屏。稳妥的做法是用一个延时或者监听竖屏完成后再返回。我实际测试下来,主流的做法是延迟 100 到 200 毫秒,视觉上基本无感。

3.4 旋转动画与页面转场协调

页面跳转和窗口旋转同时发生时,最怕出现转场动画和旋转动画互相打架。页面刚从右边滑入,窗口又旋转了一下,控制台里全是布局警告。我的优化方案是:进入播放页时先跳转,然后在页面onPageShow里设置方向;退出时先恢复方向,再执行返回。也就是“进入先跳转后旋转,退出先旋转后跳转”。这样能最大限度避免两个动画同时抢主线程。

转场动画也可以配合方向调整。播放页进入时从右侧滑入,退出时可以加一个专属的退场动画。HarmonyOS 6 的 router 支持自定义转场:

router.pushUrl({ url: 'pages/PlayerPage', params: { videoId: this.videoId } }, { duration: 300, curve: Curve.EaseInOut });

如果场景更复杂,也可以用Navigation的NavPathStack控制转场,支持按路径配置转场效果。这里我就不展开了,核心原则是:方向切换不要和路由转场同时触发,用时序错开。

4. 完整源码拆解:一个可跑的播放器 Demo

4.1 核心代码:窗口旋转工具封装

先看WindowUtil.ets,这里拿到的窗口对象是所有旋转操作的基础:

import { window } from '@kit.ArkUI'; export function setOrientation(orientation: window.Orientation): void { window.getLastWindow((err, win) => { if (err) { console.error('Get last window failed: ' + JSON.stringify(err)); return; } win.setPreferredOrientation(orientation, (setErr) => { if (setErr) { console.error('Set preferred orientation failed: ' + JSON.stringify(setErr)); return; } console.info('Set orientation success: ' + orientation); }); }); } export function getCurrentOrientation(): window.Orientation { const win = window.getLastWindowSync(); return win.getPreferredOrientation(); } export function listenWindowSizeChange(callback: (width: number, height: number) => void): void { window.getLastWindow((err, win) => { if (err) { console.error('Get last window failed: ' + JSON.stringify(err)); return; } win.on('windowSizeChange', (size) => { callback(size.width, size.height); }); }); }

getLastWindowSync是同步版本,适合在按钮事件这种肯定有窗口存在的时机调用。不要在aboutToAppear里用同步版本,容易拿到空窗口。

再来看方向状态单例OrientationMgr.ets:

import { window } from '@kit.ArkUI'; export class OrientationMgr { private static instance: OrientationMgr | null = null; private currentOrientation: window.Orientation = window.Orientation.PORTRAIT; static getInstance(): OrientationMgr { if (OrientationMgr.instance === null) { OrientationMgr.instance = new OrientationMgr(); } return OrientationMgr.instance; } setCurrent(orientation: window.Orientation): void { this.currentOrientation = orientation; AppStorage.setOrCreate('currentOrientation', orientation); } getCurrent(): window.Orientation { return this.currentOrientation; } }

这个单例看起来简单,但很有用。页面跳转后,目标页通过它判断前一个页面处于什么方向;返回时,也通过它决定要不要恢复方向。没有这一层,方向状态只能靠页面之间互相传参,非常脆弱。

4.2 页面代码:方向控制与布局

主页Index.ets的关键逻辑是:进入主页时确保竖屏,提供一个列表和几个调试按钮。

import { window } from '@kit.ArkUI'; import { router } from '@kit.ArkUI'; import { setOrientation, listenWindowSizeChange } from '../common/WindowUtil'; @Entry @Component struct Index { @State videoList: string[] = ['视频A', '视频B', '视频C']; onPageShow(): void { setOrientation(window.Orientation.PORTRAIT); } build() { Column({ space: 12 }) { Text('视频列表') .fontSize(24) .fontWeight(FontWeight.Bold) .width('100%') .padding(16) List({ space: 12 }) { ForEach(this.videoList, (item: string) => { ListItem() { Row() { Text(item) .fontSize(18) .layoutWeight(1) Button('播放') .onClick(() => { router.pushUrl({ url: 'pages/PlayerPage', params: { videoId: item } }); }) } .width('100%') .padding(16) .backgroundColor('#f5f5f5') .borderRadius(12) } }, (item: string) => item) } .layoutWeight(1) .width('100%') } .width('100%') .height('100%') .backgroundColor('#fff') } }

播放页PlayerPage.ets就比较关键了。它要先锁定横屏,然后根据窗口宽高动态调整播放器布局:

import { window } from '@kit.ArkUI'; import { router } from '@kit.ArkUI'; import { setOrientation, listenWindowSizeChange } from '../common/WindowUtil'; @Entry @Component struct PlayerPage { @State videoId: string = ''; @State isLandscape: boolean = true; @StorageLink('windowWidth') windowWidth: number = 780; @StorageLink('windowHeight') windowHeight: number = 360; aboutToAppear(): void { const params = router.getParams() as Record<string, string>; if (params) { this.videoId = params['videoId']; } } onPageShow(): void { setOrientation(window.Orientation.LANDSCAPE); listenWindowSizeChange((width, height) => { this.windowWidth = width; this.windowHeight = height; this.isLandscape = width > height; }); } exitPlay() { setOrientation(window.Orientation.PORTRAIT); setTimeout(() => { router.back(); }, 150); } build() { Column() { Text('正在播放: ' + this.videoId) .fontSize(20) .padding(16) .width('100%') Row() { // 模拟视频区域 Text('视频画面区域') .width(this.isLandscape ? '100%' : '100%') .height(this.isLandscape ? '70%' : '50%') .backgroundColor('#333') .fontColor('#fff') .textAlign(TextAlign.Center) } .layoutWeight(1) .width('100%') Button('退出全屏') .width('80%') .margin({ bottom: 20 }) .onClick(() => this.exitPlay()) } .width('100%') .height('100%') .backgroundColor('#000') } onBackPress(): boolean { this.exitPlay(); return true; } }

这里只是搭建出可控的骨架。真实播放器里,视频控件、控制条、清晰度选择都要根据横竖屏重新摆放,这套宽高对比逻辑可以平移到任何复杂布局上。

4.3 页面跳转与旋转联动代码

细心的读者应该发现了,从主页跳播放页,我没有在跳转前预先旋转,而是让播放页自己在onPageShow里设置横屏;退出播放页时,先恢复竖屏再router.back()。这样用户看到的顺序是:

  1. 点击列表“播放”,页面滑入播放页;
  2. 播放页出现,窗口跟随切换到横屏;
  3. 点击“退出全屏”或系统返回键,窗口先切回竖屏;
  4. 竖屏稳定一瞬后,路由返回主页;

这个时序保证了主页和播放页各自持有正确的方向,不会再出现“返回主页后画面横着”的问题。

有一个容易被忽略的点:系统返回键触发顺序。当用户滑动手势或点击系统返回键时,onBackPress里如果直接返回true,可以实现自定义退出。但如果你在onBackPress里调用异步方向恢复,又立刻router.back(),窗口方向可能还没生效。我这里的延时方案,是在真实设备上验证过得过去的折中方案。

4.4 运行效果与参数说明

这套 Demo 在模拟器和部分真机上我都跑了一遍,整理了几个关键参数给大家做参考:

项目推荐值备注
测试设备分辨率1080 x 2400 或 1260 x 2720物理像素
主页方向portrait强制竖屏
播放页方向landscape强制横屏
退出全屏恢复延时150ms避免页面闪横屏
窗口尺寸监听windowSizeChange比 sensor 方向监听更稳定
路由参数类型Record<string, string>需要自行做好类型转换

模拟器和真机对旋转的表现不完全一样。模拟器上没有真实传感器,auto_rotation可能依赖虚拟重力感应,有时需要手动切换设备方向。真机上则要关注系统自动旋转开关是否打开,如果系统开关关闭,强制横屏仍然可以生效,但传感器方向的自动旋转不会触发。

5. 常见问题与排查技巧实录

5.1 旋转后布局错乱或宽高比不对

这是最常见的问题。很多开发者写的布局直接固定了宽高,比如Button().width(100).height(50),旋转后坐标轴互换,固定宽高就会超出屏幕。我的做法是尽量使用layoutWeight、相对定位和百分比宽度,避免依赖像素级固定值。

还有一种情况是,页面虽然用了百分比,但外层容器没有设置自适应,导致内层组件测量不到真正的窗口宽高。检查一下从根节点到目标组件的每一层是否都设置了width('100%').height('100%'),尤其是Row和Column嵌套时。

5.2 setPreferredOrientation 不生效

如果调用后方向完全没变化,优先检查两个地方:

  • 检查module.json5里 Ability 是否存在orientation字段并设置了固定值;
  • 检查是否在aboutToAppear里调用,而不是onPageShow。

如果配置文件写死了"orientation": "portrait",代码里再调LANDSCAPE也是无效的,因为配置项优先级更高。我把这里改成auto_rotation之后,代码控制才生效。另外,如果你是在某个组件里调用window.getLastWindow,需要确保组件已经挂载到窗口上。

5.3 返回键退出横屏时状态丢失

播放页退出时容易把主页的布局状态带坏。比如主页原本有一个横向滚动的图片列表,横屏返回后列表还停留在横屏坐标系里。解决方法是把布局状态和业务状态分离:业务数据用全局缓存,布局状态在onPageShow时根据当前方向重新初始化。方向恢复不能只依赖页面onPageShow,还要结合OrientationMgr里的状态做校正。

5.4 性能与功耗:频繁旋转的代价

窗口旋转不是免费的。每一次方向切换都会触发整棵组件树的重新测量、布局和绘制,如果页面里还有视频纹理渲染、高分辨率图片,卡顿会非常明显。我在实际项目中总结了几条经验:

  • 不要用传感器频繁自动旋转来切换页面里的组件状态,优先响应windowSizeChange;
  • 如果要支持自动旋转,请加一个方向锁定按钮,避免用户躺床上看视频时画面跟着乱转;
  • 全屏切换时,可以先用一个纯色占位框替代复杂组件,等窗口尺寸稳定后再渲染真实内容。

5.5 常见问题速查表

问题现象可能原因解决办法
调用接口后方向不变配置文件固定方向改为auto_rotation
页面旋转后内容溢出固定宽高或缺少自适应容器使用layoutWeight和相对布局
返回主页时横屏闪一下先路由返回再恢复方向先恢复方向,延时后再返回
模拟器上无法旋转模拟器缺少传感器切换模拟器方向或改用真机
窗口监听不触发调用了同步获取窗口导致空引用使用异步getLastWindow回调
router.getParams()为空页面已经被预加载在aboutToAppear里读取参数

这些问题的根子大多不在 API 本身,而在生命周期和状态同步。代码跟着生命周期走,方向状态跟着页面栈走,横竖屏切换就成功了一半。

我个人在实际操作中的体会是,千万不要试图用一个全局开关控制所有页面的方向。每个页面要有自己独立的方向需求声明,再通过单例和生命周期去协调。这个“某播放器Demo”后续如果要做成真正的产品,我还会往里加三块内容:一是系统自动旋转开关和 App 内锁定按钮的联动;二是折叠屏和 Pad 多窗口尺寸下的断点适配;三是在横屏页面里缓存竖屏页面滚动位置。希望这份从窗口旋转到页面跳转的完整方案能帮你少踩一些坑,在你自己的鸿蒙应用里跑出更顺滑的横竖屏切换体验。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进