《HarmonyOS技术精讲-窗口管理》第九篇:实战------视频悬浮窗应用

悬浮窗到底难在哪里
HarmonyOS NEXT 的窗口管理 API 文档写得很清楚,但真动手做一个视频悬浮窗的时候,问题就来了:窗口怎么创建才能不被截断?拖拽时为什么位置跳变?避让区域怎么监听才能应对不同设备的刘海和挖孔屏?
这个需求本身不复杂:一个可以悬浮在任意应用上层的视频播放器,能拖拽、能调透明度、能避开系统状态栏。但真正麻烦的是把这些能力组合起来的时候,生命周期和状态同步怎么处理。
很多人第一次上手会照着官方示例跑一遍,发现确实能浮出来,但放到实际项目里,窗口位置管理、避让区域更新、页面销毁时的资源释放,每一步都有细节。这篇文章就手把手把这个流程走通,把坑填上。
悬浮窗解决什么场景
视频悬浮窗的应用场景很明确:用户在看视频的同时需要操作其他应用,或者视频内容需要持续展示在屏幕固定位置。典型的场景包括直播陪看、视频会议小窗、在线课程的画中画播放。
和普通的应用内 Popup 或对话框不同,悬浮窗具有以下特性:
| 维度 | 悬浮窗 | 应用内组件 |
|---|---|---|
| 层级 | 系统层,可覆盖其他应用 | 应用内,被 Activity 约束 |
| 创建方式 | 系统窗口管理器创建 | 组件树内创建 |
| 生命周期 | 独立管理,与应用页面无关 | 随页面生命周期 |
| 交互事件 | 独立的事件分发 | 受父容器约束 |
表格对比下来,最核心的差异就是生命周期独立。这意味着悬浮窗创建后,用户即使回到桌面,悬浮窗依然存在。这个特性在画中画场景下非常关键,但也带来了管理上的复杂度:页面销毁时,一定要主动销毁悬浮窗,否则会出现资源泄漏。
环境说明
text
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机
核心实现:从零搭建视频悬浮窗
整个实现分成四个步骤:创建悬浮窗、挂载视频播放、实现拖拽、适配避让区域。代码全部可以运行,完整项目结构贴在文章末尾。
第一步:创建悬浮窗
创建悬浮窗需要在 Ability 的 onWindowStageCreate 回调中完成。这里有两个关键点:一是必须等到 windowStage 可用以后才能创建子窗口,二是窗口类型必须设置为 TYPE_FLOAT。
typescript
// src/main/ets/Application/EntryAbility.ts
import UIAbility from '@ohos.app.ability.UIAbility';
import window from '@ohos.window';
export default class EntryAbility extends UIAbility {
private floatWindow: window.Window | undefined = undefined;
onWindowStageCreate(windowStage: window.WindowStage): void {
// 创建悬浮窗
windowStage.createSubWindow('videoFloatWindow', (err, data) => {
if (err.code !== 0) {
console.error('createSubWindow failed: ' + JSON.stringify(err));
return;
}
this.floatWindow = data;
// 设置窗口属性
data.setWindowLayoutMode(window.WindowLayoutMode.FLOAT);
data.setWindowType(window.WindowType.TYPE_FLOAT);
// 设置初始大小和位置
data.setWindowSize(300, 200);
data.setWindowPosition(50, 100);
// 显示窗口
data.showWindow((err) => {
if (err.code !== 0) {
console.error('showWindow failed: ' + JSON.stringify(err));
}
});
});
}
onWindowStageDestroy(): void {
// 页面销毁时必须销毁悬浮窗,否则会造成资源泄漏
this.floatWindow?.destroyWindow();
}
}
这段代码提供了一个基本的悬浮窗骨架。注意 onWindowStageDestroy 中的销毁操作,很多人会漏掉这一步。如果不销毁,悬浮窗会一直存在,即使应用被系统回收,窗口可能也不会自动释放。
第二步:挂载视频播放内容
悬浮窗本质上是一个独立的窗口,需要加载页面内容。我们创建一个独立的组件作为悬浮窗的显示内容。
typescript
// src/main/ets/components/FloatVideoContent.ets
@Component
export default struct FloatVideoContent {
@State videoUrl: string = 'https://example.com/sample.mp4';
private isPlaying: boolean = false;
build() {
Column() {
Video({
src: this.videoUrl,
controller: new VideoController()
})
.width('100%')
.height('100%')
.controls(false) // 隐藏系统控制条,自定义控制
.objectFit(ImageFit.Contain)
Row() {
Button('播放/暂停')
.onClick(() => {
if (this.isPlaying) {
// 暂停
} else {
// 播放
}
this.isPlaying = !this.isPlaying;
})
Button('关闭')
.onClick(() => {
// 关闭悬浮窗
})
}
}
.width('100%')
.height('100%')
}
}
然后将这个组件设置到悬浮窗的内容上。注意需要在 showWindow 之前完成内容设置。
typescript
// 在 createSubWindow 回调中增加内容设置
import FloatVideoContent from '../components/FloatVideoContent';
windowStage.createSubWindow('videoFloatWindow', (err, data) => {
if (err.code !== 0) return;
this.floatWindow = data;
// 设置内容为 FloatVideoContent 组件
data.setUIContent('pages/FloatVideoPage', (err) => {
if (err.code !== 0) {
console.error('setUIContent failed: ' + JSON.stringify(err));
}
});
// 其他属性设置...
});
这里有一个设计取舍:为什么把视频播放逻辑放在独立页面而不是挂在主页面上?因为独立页面有完整的生命周期管理,不会被主页面干扰。如果直接在主页面加载窗口内容,主页面的 @State 变化会导致悬浮窗重建,产生闪烁。
第三步:实现拖拽
拖拽是悬浮窗最基础的能力,但实现时容易遇到位置跳变的问题。本质原因是窗口坐标和触摸事件的坐标系不一致。
typescript
// src/main/ets/components/DragHandler.ets
import window from '@ohos.window';
export class DragHandler {
private floatWindow: window.Window | undefined;
private isDragging: boolean = false;
private startX: number = 0;
private startY: number = 0;
setWindow(win: window.Window): void {
this.floatWindow = win;
}
onTouchStart(event: TouchEvent): void {
if (event.type !== TouchType.Down) return;
this.isDragging = true;
// 记录触摸开始时的窗口位置
this.floatWindow?.getWindowProperties((err, data) => {
if (err.code === 0) {
this.startX = data.windowRect.left;
this.startY = data.windowRect.top;
}
});
}
onTouchMove(event: TouchEvent): void {
if (!this.isDragging || !this.floatWindow) return;
// 计算新的位置:基准位置 + 触摸偏移量
const newX = this.startX + (event.touches[0].x - event.touches[0].x);
const newY = this.startY + (event.touches[0].y - event.touches[0].y);
this.floatWindow.moveWindowTo(newX, newY, (err) => {
if (err.code !== 0) {
console.error('moveWindowTo failed: ' + JSON.stringify(err));
}
});
}
onTouchEnd(): void {
this.isDragging = false;
}
}
这个 DragHandler 的核心思路是:每次拖拽开始时记录窗口的位置,在移动过程中使用「窗口基准位置 + 触摸偏移量」来计算新位置。如果不做这个基准记录,每次移动都直接使用 moveWindowTo,会出现触摸位置和窗口位置不一致导致的跳变。
实际使用的时候,在 FloatVideoContent 中通过 onTouch 事件绑定即可。
第四步:适配避让区域
设备的刘海、挖孔、状态栏、导航栏都会占用屏幕区域,悬浮窗需要主动适配这些避让区域,否则会出现遮挡。
typescript
// 在创建悬浮窗后注册避让区域监听
data.on('avoidAreaChange', (avoidArea: window.AvoidArea) => {
// avoidArea 包含 top、bottom、left、right 四个方向的避让区域
const topAvoid = avoidArea.topRect;
const bottomAvoid = avoidArea.bottomRect;
// 根据避让区域调整窗口位置
this.floatWindow?.getWindowProperties((err, props) => {
if (err.code !== 0) return;
let currentX = props.windowRect.left;
let currentY = props.windowRect.top;
// 如果窗口顶部被避让区域覆盖,自动下移
if (currentY < topAvoid.top + topAvoid.height) {
currentY = topAvoid.top + topAvoid.height;
}
// 如果窗口底部超出底部避让区域,自动上移
if (currentY + props.windowRect.height > bottomAvoid.top) {
currentY = bottomAvoid.top - props.windowRect.height;
}
this.floatWindow?.moveWindowTo(currentX, currentY, (err) => {
if (err.code !== 0) {
console.error('moveWindowTo failed: ' + JSON.stringify(err));
}
});
});
});
这个监听要注册在 showWindow 之后,因为窗口未显示时,避让区域信息可能不准确。另外注意避让区域的变化是异步的,不要频繁调用 moveWindowTo,否则窗口会出现抖动。
常见问题与踩坑记录
问题 1:悬浮窗位置在设备旋转后失效
现象:横竖屏切换后,悬浮窗位置停留在原来的坐标系中,可能偏移到屏幕外。
原因 :moveWindowTo 使用的坐标是屏幕绝对坐标。设备旋转后,屏幕的宽高互换,但窗口仍然使用旧的位置数据。
解决方案:监听屏幕旋转事件,在回调中重新计算窗口位置。
typescript
import display from '@ohos.display';
display.on('foldStatusChange', () => {
// 设备折叠或旋转后重新调整位置
this.floatWindow?.getWindowProperties((err, props) => {
if (err.code !== 0) return;
const winWidth = props.windowRect.width;
const winHeight = props.windowRect.height;
// 保持在屏幕右下方
const screenWidth = display.getDefaultDisplaySync().width;
const screenHeight = display.getDefaultDisplaySync().height;
const newX = screenWidth - winWidth - 20;
const newY = screenHeight - winHeight - 20;
this.floatWindow?.moveWindowTo(newX, newY, (err) => {
if (err.code !== 0) {
console.error('moveWindowTo failed: ' + JSON.stringify(err));
}
});
});
});
问题 2:避让区域监听不触发
现象 :在隐藏挖孔屏或更替导航栏类型后,avoidAreaChange 事件没有触发。
原因 :on 注册的监听在窗口销毁后会失效,如果页面销毁重建,监听需要重新注册。另一个原因是某些设备的避让区域只在特定场景下更新。
解决方案 :统一在 onWindowStageCreate 中注册监听,并确保在窗口显示后先主动获取一次避让区域。
typescript
// 主动获取一次避让区域
data.getAvoidAreaByType(window.AvoidAreaType.TYPE_CUTOUT, (err, avoidArea) => {
if (err.code !== 0) {
console.error('getAvoidAreaByType failed: ' + JSON.stringify(err));
return;
}
// 手动处理避让逻辑
this.handleAvoidArea(avoidArea);
});
问题 3:悬浮窗口无法接收触摸事件
现象:窗口能显示,但点击按钮没有反应,也无法拖拽。
原因 :窗口类型设置不对。TYPE_FLOAT 窗口默认不接收触摸事件,需要设置 setTouchable(true)。
typescript
data.setTouchable(true);
这一行放到 setWindowType 之后即可。很多人会漏掉,以为默认是可触摸的。
最佳实践
1. 不要在悬浮窗内使用过多状态变量
悬浮窗的渲染线程和主线程是独立的。如果在悬浮窗页面的 @State 中放大量数据,每次变化都会触发独立的重渲染。如果数据变化频繁(比如视频播放进度的实时更新),建议通过 @Prop 或 @Observed 做单向数据流,减少不必要的组件树刷新。
2. 拖拽移动使用节流
onTouchMove 事件触发频率很高,如果每个事件都调用 moveWindowTo,会频繁触发系统 IPC,导致卡顿。推荐使用 requestAnimationFrame 或简单的时间节流。
typescript
private lastMoveTime: number = 0;
onTouchMove(event: TouchEvent): void {
const now = Date.now();
if (now - this.lastMoveTime < 16) return; // 60fps 对应约16ms
this.lastMoveTime = now;
// 执行移动逻辑...
}
3. 窗口销毁时清理所有资源
不仅仅是 destroyWindow,还包括传感器监听、定时器、网络请求。建议在 FloatVideoContent 的 aboutToDisappear 中清理所有持有资源。
typescript
aboutToDisappear(): void {
// 清理定时器、释放播放器等
this.videoController?.release();
this.timer?.clear();
}
Demo 完整入口
项目源码放在 GitHub 上,结构如下:
VideoFloatDemo/
├── src/main/ets/
│ ├── Application/
│ │ └── EntryAbility.ets
│ ├── components/
│ │ ├── FloatVideoContent.ets
│ │ └── DragHandler.ets
│ └── pages/
│ └── FloatVideoPage.ets
├── entry/src/main/resources/
└── oh-package.json5
示例代码项目地址:项目地址
FAQ
Q:为什么在模拟器上悬浮窗显示正常,真机上位置偏了?
A:模拟器的分辨率固定,真机的屏幕比例和刘海区域可能不同。建议在真机上用 display.getDefaultDisplaySync() 获取屏幕实际尺寸,然后计算窗口位置,不要硬编码坐标。
Q:悬浮窗最小化后再恢复,黑屏了,怎么处理?
A:检查窗口内容是否在最小化时被释放了。最小化会触发窗口的 onWindowStageHidden,如果在这个回调里清理了 VideoController,恢复时没有重新创建,就会出现黑屏。建议在最小化时只暂停播放,不要销毁组件。
Q:为什么拖拽到屏幕边缘后窗口自动弹回去了?
A:系统默认会对悬浮窗的位置做边缘校验,防止窗口完全移出屏幕。这个规则无法关闭,最佳做法是在拖拽结束后调用一次 moveWindowTo,让窗口吸附到边缘或回到安全区域内。