《HarmonyOS技术精讲-窗口管理》第九篇实战——视频悬浮窗应用悬浮窗到底难在哪里HarmonyOS NEXT 的窗口管理 API 文档写得很清楚但真动手做一个视频悬浮窗的时候问题就来了窗口怎么创建才能不被截断拖拽时为什么位置跳变避让区域怎么监听才能应对不同设备的刘海和挖孔屏这个需求本身不复杂一个可以悬浮在任意应用上层的视频播放器能拖拽、能调透明度、能避开系统状态栏。但真正麻烦的是把这些能力组合起来的时候生命周期和状态同步怎么处理。很多人第一次上手会照着官方示例跑一遍发现确实能浮出来但放到实际项目里窗口位置管理、避让区域更新、页面销毁时的资源释放每一步都有细节。这篇文章就手把手把这个流程走通把坑填上。悬浮窗解决什么场景视频悬浮窗的应用场景很明确用户在看视频的同时需要操作其他应用或者视频内容需要持续展示在屏幕固定位置。典型的场景包括直播陪看、视频会议小窗、在线课程的画中画播放。和普通的应用内 Popup 或对话框不同悬浮窗具有以下特性维度悬浮窗应用内组件层级系统层可覆盖其他应用应用内被 Activity 约束创建方式系统窗口管理器创建组件树内创建生命周期独立管理与应用页面无关随页面生命周期交互事件独立的事件分发受父容器约束表格对比下来最核心的差异就是生命周期独立。这意味着悬浮窗创建后用户即使回到桌面悬浮窗依然存在。这个特性在画中画场景下非常关键但也带来了管理上的复杂度页面销毁时一定要主动销毁悬浮窗否则会出现资源泄漏。环境说明DevEco Studio 版本DevEco Studio 6.1.0 及以上 HarmonyOS SDK 版本HarmonyOS 6.1.0(23) 及以上 目标设备手机核心实现从零搭建视频悬浮窗整个实现分成四个步骤创建悬浮窗、挂载视频播放、实现拖拽、适配避让区域。代码全部可以运行完整项目结构贴在文章末尾。第一步创建悬浮窗创建悬浮窗需要在 Ability 的onWindowStageCreate回调中完成。这里有两个关键点一是必须等到windowStage可用以后才能创建子窗口二是窗口类型必须设置为TYPE_FLOAT。// src/main/ets/Application/EntryAbility.tsimportUIAbilityfromohos.app.ability.UIAbility;importwindowfromohos.window;exportdefaultclassEntryAbilityextendsUIAbility{privatefloatWindow:window.Window|undefinedundefined;onWindowStageCreate(windowStage:window.WindowStage):void{// 创建悬浮窗windowStage.createSubWindow(videoFloatWindow,(err,data){if(err.code!0){console.error(createSubWindow failed: JSON.stringify(err));return;}this.floatWindowdata;// 设置窗口属性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中的销毁操作很多人会漏掉这一步。如果不销毁悬浮窗会一直存在即使应用被系统回收窗口可能也不会自动释放。第二步挂载视频播放内容悬浮窗本质上是一个独立的窗口需要加载页面内容。我们创建一个独立的组件作为悬浮窗的显示内容。// src/main/ets/components/FloatVideoContent.etsComponentexportdefaultstruct FloatVideoContent{StatevideoUrl:stringhttps://example.com/sample.mp4;privateisPlaying:booleanfalse;build(){Column(){Video({src:this.videoUrl,controller:newVideoController()}).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之前完成内容设置。// 在 createSubWindow 回调中增加内容设置importFloatVideoContentfrom../components/FloatVideoContent;windowStage.createSubWindow(videoFloatWindow,(err,data){if(err.code!0)return;this.floatWindowdata;// 设置内容为 FloatVideoContent 组件data.setUIContent(pages/FloatVideoPage,(err){if(err.code!0){console.error(setUIContent failed: JSON.stringify(err));}});// 其他属性设置...});这里有一个设计取舍为什么把视频播放逻辑放在独立页面而不是挂在主页面上因为独立页面有完整的生命周期管理不会被主页面干扰。如果直接在主页面加载窗口内容主页面的State变化会导致悬浮窗重建产生闪烁。第三步实现拖拽拖拽是悬浮窗最基础的能力但实现时容易遇到位置跳变的问题。本质原因是窗口坐标和触摸事件的坐标系不一致。// src/main/ets/components/DragHandler.etsimportwindowfromohos.window;exportclassDragHandler{privatefloatWindow:window.Window|undefined;privateisDragging:booleanfalse;privatestartX:number0;privatestartY:number0;setWindow(win:window.Window):void{this.floatWindowwin;}onTouchStart(event:TouchEvent):void{if(event.type!TouchType.Down)return;this.isDraggingtrue;// 记录触摸开始时的窗口位置this.floatWindow?.getWindowProperties((err,data){if(err.code0){this.startXdata.windowRect.left;this.startYdata.windowRect.top;}});}onTouchMove(event:TouchEvent):void{if(!this.isDragging||!this.floatWindow)return;// 计算新的位置基准位置 触摸偏移量constnewXthis.startX(event.touches[0].x-event.touches[0].x);constnewYthis.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.isDraggingfalse;}}这个DragHandler的核心思路是每次拖拽开始时记录窗口的位置在移动过程中使用「窗口基准位置 触摸偏移量」来计算新位置。如果不做这个基准记录每次移动都直接使用moveWindowTo会出现触摸位置和窗口位置不一致导致的跳变。实际使用的时候在FloatVideoContent中通过onTouch事件绑定即可。第四步适配避让区域设备的刘海、挖孔、状态栏、导航栏都会占用屏幕区域悬浮窗需要主动适配这些避让区域否则会出现遮挡。// 在创建悬浮窗后注册避让区域监听data.on(avoidAreaChange,(avoidArea:window.AvoidArea){// avoidArea 包含 top、bottom、left、right 四个方向的避让区域consttopAvoidavoidArea.topRect;constbottomAvoidavoidArea.bottomRect;// 根据避让区域调整窗口位置this.floatWindow?.getWindowProperties((err,props){if(err.code!0)return;letcurrentXprops.windowRect.left;letcurrentYprops.windowRect.top;// 如果窗口顶部被避让区域覆盖自动下移if(currentYtopAvoid.toptopAvoid.height){currentYtopAvoid.toptopAvoid.height;}// 如果窗口底部超出底部避让区域自动上移if(currentYprops.windowRect.heightbottomAvoid.top){currentYbottomAvoid.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使用的坐标是屏幕绝对坐标。设备旋转后屏幕的宽高互换但窗口仍然使用旧的位置数据。解决方案监听屏幕旋转事件在回调中重新计算窗口位置。importdisplayfromohos.display;display.on(foldStatusChange,(){// 设备折叠或旋转后重新调整位置this.floatWindow?.getWindowProperties((err,props){if(err.code!0)return;constwinWidthprops.windowRect.width;constwinHeightprops.windowRect.height;// 保持在屏幕右下方constscreenWidthdisplay.getDefaultDisplaySync().width;constscreenHeightdisplay.getDefaultDisplaySync().height;constnewXscreenWidth-winWidth-20;constnewYscreenHeight-winHeight-20;this.floatWindow?.moveWindowTo(newX,newY,(err){if(err.code!0){console.error(moveWindowTo failed: JSON.stringify(err));}});});});问题 2避让区域监听不触发现象在隐藏挖孔屏或更替导航栏类型后avoidAreaChange事件没有触发。原因on注册的监听在窗口销毁后会失效如果页面销毁重建监听需要重新注册。另一个原因是某些设备的避让区域只在特定场景下更新。解决方案统一在onWindowStageCreate中注册监听并确保在窗口显示后先主动获取一次避让区域。// 主动获取一次避让区域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)。data.setTouchable(true);这一行放到setWindowType之后即可。很多人会漏掉以为默认是可触摸的。最佳实践1. 不要在悬浮窗内使用过多状态变量悬浮窗的渲染线程和主线程是独立的。如果在悬浮窗页面的State中放大量数据每次变化都会触发独立的重渲染。如果数据变化频繁比如视频播放进度的实时更新建议通过Prop或Observed做单向数据流减少不必要的组件树刷新。2. 拖拽移动使用节流onTouchMove事件触发频率很高如果每个事件都调用moveWindowTo会频繁触发系统 IPC导致卡顿。推荐使用requestAnimationFrame或简单的时间节流。privatelastMoveTime:number0;onTouchMove(event:TouchEvent):void{constnowDate.now();if(now-this.lastMoveTime16)return;// 60fps 对应约16msthis.lastMoveTimenow;// 执行移动逻辑...}3. 窗口销毁时清理所有资源不仅仅是destroyWindow还包括传感器监听、定时器、网络请求。建议在FloatVideoContent的aboutToDisappear中清理所有持有资源。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示例代码项目地址项目地址FAQQ为什么在模拟器上悬浮窗显示正常真机上位置偏了A模拟器的分辨率固定真机的屏幕比例和刘海区域可能不同。建议在真机上用display.getDefaultDisplaySync()获取屏幕实际尺寸然后计算窗口位置不要硬编码坐标。Q悬浮窗最小化后再恢复黑屏了怎么处理A检查窗口内容是否在最小化时被释放了。最小化会触发窗口的onWindowStageHidden如果在这个回调里清理了VideoController恢复时没有重新创建就会出现黑屏。建议在最小化时只暂停播放不要销毁组件。Q为什么拖拽到屏幕边缘后窗口自动弹回去了A系统默认会对悬浮窗的位置做边缘校验防止窗口完全移出屏幕。这个规则无法关闭最佳做法是在拖拽结束后调用一次moveWindowTo让窗口吸附到边缘或回到安全区域内。