有了画中画,还能少挨用户骂

大家平时看视频或者开视频会议,突然来个微信消息,你是退出全屏去回,还是忍着?HarmonyOS 上的“闪控窗”(应用内悬浮窗/小窗)就是为了解决这个痛点。让用户在应用内可以“一心二用”,边看视频边记笔记,或者边听语音边刷评论。

听起来很酷,但很多同学真去实现的时候,直接卡在了第一步:“窗口怎么浮不起来?”或者“浮起来了,但滑一下卡得要死,或者拖拽时直接闪退”。

别慌,闪控窗没那么玄乎,但确实有几个必须踩过的坑。这篇就把它的创建、交互、生命周期管理掰开揉碎了讲清楚。

https://placeholder-01.jpg/

一、别搞混了:系统悬浮窗和应用内悬浮窗

首先得澄清一个概念。很多同学一上来就搜“HarmonyOS 悬浮窗权限”,结果找到了 ohos.permission.SYSTEM_FLOAT_WINDOW。这个权限是给“全局悬浮窗”用的,比如你在打游戏,微信弹个小窗出来。这个权限是 system_core 级别,普通应用根本申请不到。

我们大部分应用需要的,是应用内悬浮窗(闪控窗)。也就是说,用户只能在你的应用里看到这个小窗,退出应用它就消失了。这种不需要申请系统级权限,用 window 模块就能实现。

先看核心 API:

typescript

import window from '@ohos.window';

async function createFloatWindow(context: Context) {
  const windowStage = await window.getLastWindow(context);
  // 创建子窗口
  const floatWindow = await windowStage.createSubWindow('myFloatWindow');
  await floatWindow.resize(600, 400); // 宽高
  await floatWindow.moveWindowTo(100, 200); // 位置
  await floatWindow.setWindowMode(window.WindowMode.FLOATING);
  await floatWindow.loadContent('pages/FloatContent');
  floatWindow.showWindow();
}

这里最核心的是 createSubWindow。它会在当前应用内创建一个子窗口,loadContent 加载你的小窗页面内容。WindowMode.FLOATING 表示这是一个浮动的窗口,用户可以拖拽它。

二、拖拽与缩放:别自己写手势,会累死

很多同学以为窗口拖拽要自己写 onTouch 手势,计算 offsetX、offsetY,然后再去 moveWindowTo。这完全是费力不讨好,而且极易导致卡顿和位置跳跃。

实际上,window 模块自带窗口拖拽能力。你只需要在配置里开启,用户按住窗口顶部就可以拖拽了。在子窗口的页面里,设置标题栏区域:

typescript

@Entry
@Component
struct FloatContent {
  build() {
    Column() {
      // 顶部拖拽区域
      Row() {
        Text('小窗标题')
          .fontColor(Color.White)
        Blank()
        Image($r('app.media.close'))
          .onClick(() => {
            // 关闭窗口的逻辑
          })
      }
      .width('100%')
      .height(40)
      .backgroundColor('#333333')
      .gesture(
        PanGesture()
          .onActionUpdate((event: GestureEvent) => {
            // 这里可以配合系统拖拽,或者自己写
          })
      )
      
      // 内容区
      Text('视频播放区')
        .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
  }
}

坑来了:系统级的窗口拖拽是有限制的。 在 HarmonyOS 7 中,子窗口的拖拽通常需要和 window.on('windowEvent') 配合。如果发现自己写拖拽很卡,建议直接用系统默认的窗口装饰条(标题栏)来实现拖拽,不要自己接手势,除非你要做极其复杂的交互(比如吸附边缘、半隐隐藏)。

三、交互:点击穿透与手势冲突

小窗浮在上面,用户点击小窗区域,事件应该被小窗消费;点击小窗以外的区域,应该穿透到下层页面。

这听起来很简单,但实际开发中,最容易出现的问题是:底下的页面被误触,或者小窗无法接收点击。

在 ArkUI 中,窗口默认是会拦截事件流的。如果你的小窗页面里有全屏的透明区域,一定要设置 .hitTestBehavior(HitTestMode.Transparent),让事件穿透过去。

typescript

Stack() {
  // 透明背景层
  Row()
    .width('100%')
    .height('100%')
    .hitTestBehavior(HitTestMode.Transparent) 
  
  // 实际内容
  Column() {
    Text('内容')
  }
}

另外,如果你在小窗里播放视频,用户拖拽小窗和拖动视频进度条可能会冲突。这时候就要区分手势:长按拖拽窗口,短按拖动进度条。别问我怎么知道的,测试小姐姐当时差点把我按在地上摩擦。

https://placeholder-02.png/

四、生命周期:最容易被忽略的“生死”问题

小窗的生命周期和主窗口完全不同步。主窗口在后台时,小窗不一定在后台;主窗口销毁时,小窗必须销毁,否则内存直接泄漏。

在 Ability 的 onWindowStageDestroy 里,一定要清理子窗口:

typescript

export default class EntryAbility extends UIAbility {
  private floatWindow: window.Window | null = null;

  async onWindowStageCreate(windowStage: window.WindowStage) {
    // ...
  }

  onWindowStageDestroy() {
    if (this.floatWindow) {
      this.floatWindow.destroyWindow();
      this.floatWindow = null;
    }
  }
}

还有一个非常致命的坑:用户把应用切到后台,再切回来,小窗不见了。

原因是子窗口在应用切到后台时被系统回收了,或者被隐藏了。你需要监听应用的前后台状态,手动恢复小窗:

typescript

this.context.eventHub.on('windowStageEvent', (event: window.WindowStageEventType) => {
  if (event === window.WindowStageEventType.RESUMED) {
    // 应用回到前台,重新显示小窗
    if (this.floatWindow) {
      this.floatWindow.showWindow();
    }
  }
});

五、常见坑合集

坑一:小窗里的 TextInput 无法输入。

有些同学在闪控窗里放了输入框,结果键盘弹不出来。这是因为子窗口和主窗口的焦点管理是隔离的。在弹窗输入时,需要把焦点显式地传递给子窗口,或者干脆不要在小窗里放复杂输入框。

坑二:频繁 resize 和 moveWindowTo 导致卡顿。

不要在手势的 onActionUpdate 里每一帧都调用 moveWindowTo,那样 UI 线程会炸。正确做法是用节流(Throttle)或者用系统的拖拽 API。如果非要自己写,至少加个 setTimeout 或者 requestAnimationFrame 限制频率。

坑三:小窗内容加载慢,白屏。

loadContent 是异步的。如果你在创建窗口后立刻调用 showWindow,页面可能还没渲染完,就会看到白屏。最好在 loadContent 的回调里再 showWindow。

https://placeholder-03.png/

六、总结

闪控窗(应用内悬浮窗)的核心在于搞清楚 SubWindow 的创建、拖拽和生命周期。记住三点:

  1. 普通应用只能做应用内悬浮窗,别去申请 SYSTEM_FLOAT_WINDOW。

  2. 交互区域要区分清楚,用 hitTestBehavior 控制点击穿透。

  3. 主窗口销毁时,一定要 destroy 子窗口,并在应用切回前台时主动 showWindow。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐