手机窗口和 PC 自由窗口为什么不能用同一套假设?先认识 Freeform Window【鸿蒙心迹】

你是不是也在想——“鸿蒙这么火,我能不能学会?”
答案是:当然可以!
这个专栏专为零基础小白设计,不需要编程基础,也不需要懂原理、背术语。我们会用最通俗易懂的语言、最贴近生活的案例,手把手带你从安装开发工具开始,一步步学会开发自己的鸿蒙应用。
不管你是学生、上班族、打算转行,还是单纯对技术感兴趣,只要你愿意花一点时间,就能在这里搞懂鸿蒙开发,并做出属于自己的App!
📌 关注本专栏《零基础学鸿蒙开发》,一起变强!
每一节内容我都会持续更新,配图+代码+解释全都有,欢迎点个关注,不走丢,我是小白酷爱学习,我们一起上路 🚀
全文目录:
前言
写 HarmonyOS 应用时,很多人会在手机上开发到差不多,然后把同一份代码跑到 2in1 设备上,期待它能直接用。结果往往是:布局撑破容器、点击标题栏没反应、窗口拖不动、UI 在缩放时抖个不停。
这不是 Bug,是两种窗口模式从根本上的工作方式就不一样。
手机的窗口基本是固定全屏,应用界面几乎不需要关心"我现在有多宽"这个问题。PC 的自由窗口(Freeform Window)恰好相反,宽高随时变化,位置随时变化,状态随时变化。建立在手机假设上写的 UI,遇到自由窗口就会出问题。
这篇文章从自由窗口的核心机制入手,把标题栏、拖拽、缩放、最大化、最小化、焦点/Z 轴这几个点逐一拆开,最后重点讲一下:应用 UI 为什么必须接受窗口尺寸的动态变化。
一、自由窗口是什么,它在哪里生效
自由窗口(Freeform Window)是 HarmonyOS 在 2in1 等 PC 类形态设备上的默认窗口管理模式。与手机全屏模式不同,自由窗口允许用户自由移动窗口位置、拖拽边缘调整大小、在多个窗口之间切换焦点。
设备和版本要求:
| 能力 | 支持设备 | API Level |
|---|---|---|
| 自由窗口基础能力 | 2in1 设备主窗口 | 12+(HarmonyOS 5.0.0+) |
WindowStatusType / windowStatusChange | 2in1 设备 | 11+ |
maximize() / recover() | 2in1 设备主窗口 | maximize API 12+,recover API 11+ |
startMoving() | 2in1 设备主窗口 | 12+ |
setResizeByDragEnabled() | 2in1 设备主窗口 | 12+ |
setWindowDecorVisible() / setWindowDecorHeight() | 2in1 设备主窗口 | 12+ |
isFocused() / windowFocusChange | Phone / 2in1 | 9+ |
windowSizeChange | Phone / 2in1 | 6+ |
这里有一个边界需要明确:Phone 上也能收到 windowSizeChange 事件(比如折叠屏展开),但自由窗口的核心行为——标题栏、拖拽移动、边缘缩放、maximize()——只在 2in1 设备主窗口有效。在 Phone 上调用这些接口,系统会返回错误码 1300004 Unauthorized operation。
二、把几个核心概念先搞清楚
窗口状态:WindowStatusType
自由窗口在运行中会处于不同状态,通过 WindowStatusType 枚举描述(API 11+):
| 枚举值 | 含义 |
|---|---|
UNDEFINED = 0 | 未定义,不建议业务判断使用 |
FULL_SCREEN = 1 | 全屏模式 |
MAXIMIZE = 2 | 最大化模式 |
MINIMIZE = 3 | 最小化模式(窗口已收入任务栏) |
FLOATING = 4 | 自由浮动模式(可拖拽、可缩放) |
SPLIT_SCREEN = 5 | 分屏模式 |
日常开发中最需要关注的是 FLOATING、MAXIMIZE 和 MINIMIZE 这三个状态之间的切换。
标题栏(Decor)
系统默认标题栏(Decor)在 2in1 设备主窗口上默认可见,包含:
- 应用名称(来自 UIAbility 的
label配置) - 最小化按钮
- 最大化 / 还原按钮
- 关闭按钮
系统标题栏处理了绝大部分拖拽移动和最大化 / 最小化的交互,应用通常不需要额外处理。但如果你需要自定义标题栏样式或把标题栏区域整体替换为业务 UI,就必须了解系统给的这些能力之后自己再去重建。
三、最小实践场景:监听窗口状态和尺寸变化
目标:在 UIAbility 中注册窗口状态监听,能在最大化、还原、最小化时捕获状态变化,同时能在窗口尺寸变化时拿到新的宽高。
工程配置
自由窗口相关 API 来自 @kit.ArkUI 中的 window 模块,无需额外权限配置,直接导入即可。
EntryAbility 中注册监听
import { window } from '@kit.ArkUI';
import { UIAbility, AbilityConstant, Want } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
export default class EntryAbility extends UIAbility {
private mainWindow: window.Window | undefined = undefined;
onWindowStageCreate(windowStage: window.WindowStage): void {
// 获取主窗口
windowStage.getMainWindow((err: BusinessError, win: window.Window) => {
if (err.code !== 0) {
console.error('EntryAbility: Failed to get main window. Code: ' + err.code);
return;
}
this.mainWindow = win;
// 监听窗口尺寸变化(Phone 和 2in1 均支持,API 6+)
win.on('windowSizeChange', (size: window.Size) => {
console.info('windowSizeChange: width=' + size.width + ', height=' + size.height);
// 在这里更新 AppStorage 或 LocalStorage 中的尺寸状态,驱动 UI 更新
});
// 监听窗口模式状态变化(2in1 设备,API 11+)
win.on('windowStatusChange', (statusType: window.WindowStatusType) => {
console.info('windowStatusChange: ' + statusType);
if (statusType === window.WindowStatusType.MINIMIZE) {
// 窗口已最小化,可以暂停播放等耗时操作
console.info('Window minimized, consider pausing background tasks.');
} else if (statusType === window.WindowStatusType.FLOATING) {
// 已还原为自由窗口状态
console.info('Window restored to floating mode.');
}
});
// 监听窗口焦点变化(API 9+)
win.on('windowFocusChange', (hasFocus: boolean) => {
console.info('windowFocusChange: hasFocus=' + hasFocus);
// hasFocus=false 时说明用户点击了其他窗口
});
windowStage.loadContent('pages/Index', (err: BusinessError) => {
if (err.code !== 0) {
console.error('EntryAbility: Failed to load content.');
}
});
});
}
onWindowStageDestroy(): void {
// 页面退出时注销监听,避免内存泄漏
if (this.mainWindow) {
this.mainWindow.off('windowSizeChange');
this.mainWindow.off('windowStatusChange');
this.mainWindow.off('windowFocusChange');
}
}
}
这段代码解决的问题是:让应用在 2in1 设备上感知窗口的状态和尺寸变化,而不是被动等待 UI 布局出问题再排查。
真正需要关注的是三处:
getMainWindow的回调需要确认err.code === 0才能继续使用win对象;- 监听要在
loadContent之前注册,否则页面初始化期间的第一次尺寸事件可能已经触发; onWindowStageDestroy中需要主动调用off注销,否则即使页面退出,监听回调还会继续触发。
四、主动触发最大化和还原
系统标题栏的最大化按钮已经处理了绝大多数场景。但如果你的应用有自己的全屏按钮,可以通过以下方式主动触发:
import { window } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
// 最大化(API 12+,仅 2in1 主窗口)
function maximizeWindow(win: window.Window): void {
win.maximize().then(() => {
console.info('Window maximized.');
}).catch((err: BusinessError) => {
console.error('Failed to maximize window. Code: ' + err.code);
});
}
// 从最大化 / 全屏恢复为自由窗口(API 11+)
function recoverWindow(win: window.Window): void {
win.recover().then(() => {
console.info('Window recovered to floating.');
}).catch((err: BusinessError) => {
console.error('Failed to recover window. Code: ' + err.code);
});
}
maximize() 还支持传入 MaximizePresentation 枚举控制最大化后是否进入沉浸模式。如果不传参数,默认行为由系统决定。
关于最小化:官方当前公开的接口里没有应用侧主动调用最小化的 API。最小化操作由系统标题栏按钮或系统手势触发,应用侧只能通过 windowStatusChange 监听 MINIMIZE 状态来响应,而不能主动触发。
五、自定义标题栏时的拖拽能力
某些场景下需要隐藏系统标题栏,改用自定义 UI。这时窗口的默认拖拽能力会随之消失,需要手动恢复。
import { window } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
// 隐藏系统标题栏(API 12+,仅 2in1 主窗口)
win.setWindowDecorVisible(false);
// 修改系统标题栏高度(API 12+,取值范围 [37, 112] vp)
win.setWindowDecorHeight(50);
隐藏系统标题栏后,在自定义标题栏区域的触摸事件中调用 startMoving() 来重新激活拖拽:
// 自定义标题栏组件(ArkUI)
@Component
struct CustomTitleBar {
private windowRef: window.Window | undefined = undefined;
build() {
Row() {
Text('My App')
.fontSize(16)
.fontColor(Color.Black)
Blank()
// 自定义关闭按钮等
}
.width('100%')
.height(48)
.backgroundColor('#F5F5F5')
.padding({ left: 16, right: 16 })
.onTouch((event: TouchEvent) => {
// 在用户按下时触发窗口移动(API 12+)
if (event.type === TouchType.Down && this.windowRef) {
this.windowRef.startMoving().catch((err: BusinessError) => {
console.error('startMoving failed: ' + err.code);
});
}
})
}
}
关于缩放控制:如果应用需要禁止用户通过拖拽边缘调整窗口大小,可以调用 setResizeByDragEnabled(false)(API 12+)。默认情况下,2in1 主窗口的边缘拖拽缩放是开启的,应用侧只能响应 windowSizeChange 事件,无法通过 API 主动发起缩放操作。
六、焦点和 Z 轴
在自由窗口环境中,多个应用窗口叠放在桌面上,Z 轴顺序(哪个窗口在最上层)由系统统一管理。当用户点击某个窗口时,该窗口获得焦点,系统自动将其提升到 Z 轴顶层。
应用侧没有直接控制 Z 轴顺序或强制抢占焦点的公开 API。能做的是:
- 通过
isFocused()查询当前是否获焦(API 9+,同步调用); - 通过
windowFocusChange监听焦点进出事件(API 9+)。
这两点在手机上其实也存在,只是手机几乎只有一个窗口可见,焦点变化意义不大。到了自由窗口环境,焦点变化会频繁发生,有些业务场景必须响应。
比较常见的需要处理焦点的场景:
- 媒体播放:失焦时暂停,获焦时恢复;
- 快捷键监听:只在获焦状态下生效;
- 输入法弹出控制:在多窗口切换时避免输入框意外获焦弹出键盘。
七、应用 UI 为什么必须接受窗口尺寸动态变化
这是自由窗口和手机窗口差异最大的地方,也是最容易出问题的地方。
手机应用通常建立在这样一个假设上:窗口宽度就是设备屏幕宽度,运行期间基本不变。在这个假设下,很多布局方案会把宽度写死,或者依赖 screen.width 来计算组件尺寸。这在手机上通常没问题。
到了自由窗口,这个假设直接失效。用户可以把窗口拖拽到很小,也可以把它铺满整个屏幕,还可以在这个过程中持续改变大小。windowSizeChange 事件会在这个过程中持续触发,不是触发一次。
如果 UI 依赖固定宽度,两种情况会出问题:
- 固定宽度写死:比如容器
width: 375,窗口缩小到 300 时内容溢出,窗口放大到 800 时右侧留白大片空白; - 在
onCreate或页面初始化时一次性读取宽高:页面加载时的宽高是初始值,用户之后调整窗口大小后,UI 不更新,布局和实际窗口尺寸不一致。
正确的做法是把窗口宽高存到状态变量里,通过 windowSizeChange 回调持续更新,让 UI 依赖这些状态变量进行自适应布局。
官方建议在 2in1 设备上使用 Flex、Grid、Column、Row 等自适应组件,结合百分比尺寸或响应式断点(Breakpoint)处理不同宽度下的布局差异。
八、几个容易出问题的地方
1. 在 Phone 上调用 2in1 专属接口
maximize()、recover()、startMoving()、setWindowDecorVisible()、setResizeByDragEnabled() 这些接口,在非 2in1 设备上调用会返回 1300004 Unauthorized operation。如果你的应用需要同时支持 Phone 和 2in1,要先判断设备类型或者捕获这个错误码,不能直接调用。
2. 注销监听时机
on('windowSizeChange', ...) 和 on('windowStatusChange', ...) 注册的回调需要在 onWindowStageDestroy 中通过 off() 注销。如果只注册不注销,页面多次进出或者应用进入后台时,回调可能重复注册,出现重复触发或内存泄漏的问题。
3. 最小化不等于生命周期销毁
窗口进入 MINIMIZE 状态后,UIAbility 的页面并没有被销毁,组件仍然存在内存中。如果有定时器、媒体播放器等持续消耗资源的业务,不在 MINIMIZE 时暂停,会在后台持续耗电。
4. 自定义标题栏后窗口无法拖动
只调用了 setWindowDecorVisible(false) 但没有实现 startMoving() 的自定义拖拽,窗口就真的无法被移动了。这个问题在真机测试前通常不容易发现,因为模拟器可能行为不一致。
5. windowSizeChange 回调中直接操作 UI
windowSizeChange 的回调不保证在 UI 线程,如果在回调里直接修改 @State 变量应该没问题(ArkUI 的状态系统会处理线程调度),但如果直接调用 DOM 操作或者一些非线程安全的业务方法,可能出现问题。建议只在回调里更新状态变量,把布局逻辑交给 ArkUI 的响应式系统处理。
九、排查思路
遇到自由窗口相关问题,建议按这个顺序排查:
- 先确认设备:自由窗口核心接口仅在 2in1 设备主窗口有效,先确认跑在什么设备上;
- 确认 API Level:大多数自由窗口接口从 API 11 或 12 开始,确认工程的
minPlatformVersion设置; - 检查错误码:
maximize()、setWindowDecorVisible()等接口会通过 Promise reject 或异常返回错误码,不要忽略.catch()分支; - 检查监听注册时机:
windowSizeChange监听是否在loadContent之前注册; - 检查监听是否重复注册:页面切换后是否对同一个 window 对象多次调用
on(); - 检查 UI 是否依赖固定宽度:搜索代码中是否存在硬编码的 px 宽高,替换为百分比或响应断点;
- 检查最小化后的资源管理:确认
MINIMIZE状态下是否有必要暂停耗资源的操作。
开发经验总结
几个真正有价值的结论:
- 自由窗口不是手机全屏的扩展,它是完全不同的窗口工作模式,对应用 UI 的弹性要求更高;
WindowStatusType枚举是理解自由窗口状态机的起点,FLOATING/MAXIMIZE/MINIMIZE三个状态及其之间的切换是需要在业务逻辑中明确处理的;windowSizeChange的设计意图就是驱动 UI 动态更新,不是"变化后一次性读取",而是持续响应;- 自定义标题栏需要主动补上
startMoving()才能恢复拖拽,不能只隐藏系统标题栏就完事; - Phone 和 2in1 共存时,2in1 专属接口调用要做好设备判断或错误处理。
如果你正在做 2in1 适配,可以先检查一下自己的页面在窗口宽度从最小缩到最大的过程中布局是否仍然正常——这个场景往往比状态切换本身更能暴露问题。
❤️ 如果本文帮到了你…
- 请点个赞,让我知道你还在坚持阅读技术长文!
- 请收藏本文,因为你以后一定还会用上!
- 如果你在学习过程中遇到bug,请留言,我帮你踩坑!
更多推荐



所有评论(0)