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

《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,还包括传感器监听、定时器、网络请求。建议在 FloatVideoContentaboutToDisappear 中清理所有持有资源。

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,让窗口吸附到边缘或回到安全区域内。