HarmonyOS 7 闪控窗实战:给 App 造一个"影分身"——悬浮在其他 App 上持续干活,还能一键缩成闪控球

读者对象:有 ArkTS / Stage 模型基础、想做跨应用轻量交互的 HarmonyOS 开发者。
环境要求:DevEco Studio(需能下载 HarmonyOS SDK 26,compileSdkVersion 26.0.0+)+ 搭载 HarmonyOS 7 的真机(Mate 80 Pro / Mate X7 / Pura 90 等系列)。闪控窗、闪控球是系统托管窗口,预览器和模拟器都不支持,请直接真机调试。
API 起始版本:@ohos.window.floatView 与 @ohos.window.floatingBall 均为 API 26.0.0,仅支持 Stage 模型,适用 Phone / Tablet / 2in1。

一、我们要做什么

HarmonyOS 7(API 26)带来了两个新的系统级悬浮能力:

  • 闪控窗(FloatView):悬浮在桌面或其他应用界面上的小型托管窗口,适合持续展示内容(计时、行情、导航摘要、播放卡片),支持自由拖动(自动避让状态栏/导航条/键盘)、垃圾桶删除、侧边栏暂存。
  • 闪控球(FloatingBall):悬浮在屏幕上的小球,提供跨应用快捷入口(搜题、抢单、记账、盯盘)。
  • 一键互切:把两者 bind 到一起后,点击闪控球展开成闪控窗,点闪控窗的缩小按钮缩回闪控球——这是这对 API 最有"鸿蒙味"的组合技。

本文从零写一个可跑通的 Demo:主界面点一个按钮,屏幕上出现一个持续跳秒的悬浮秒表窗口;再点一个按钮,把它升级为"闪控窗 + 闪控球"绑定模式;退到桌面、打开任意其他 App,验证窗口依然悬浮、可拖、可切。所有接口签名均核验自官方《闪控窗开发指导》《闪控球开发指导》及 API 参考(2026-09 版)。

二、先理清系统规则(这部分最值钱)

官方《闪控窗开发指导》"约束与限制"原文写得很硬:

  1. 前台启动门禁:基于安全考虑,仅允许应用在前台时启动闪控窗,且需持有 ohos.permission.FLOAT_VIEW 权限。后台调 start() 直接抛 1300033。
  2. 单实例:同一个应用只能启动一个闪控窗,启动多个返回 1300033;重复 start 已在启动中的闪控窗返回 1300030。
  3. 互斥:应用已启动闪控球或画中画窗口时,无法启动闪控窗,需先停止(报 1300034)。
  4. 绑定的前提:floatView.bind() 要求两个控制器均未启动时调用;绑定后调用任一控制器的启动接口会同时创建两个窗口(同一时刻只展示其中一个);调用任一停止接口会同时销毁两个窗口。
  5. 最小化按钮的行为由绑定状态决定:未绑定时点最小化 → 收起到侧边栏;绑定后点缩小 → 切换为闪控球。

交互方式官方一共给了五种:拖动热区拖拽(自动避让系统组件)、标题栏按钮、拖到屏幕底部中部垃圾桶删除、进入系统侧边栏暂存、与闪控球点击互切。

三、工程准备

1. 新建工程

DevEco Studio → Create Project → Application → Empty Ability(ArkTS、Stage 模型),compileSdk 选 26.0.0。本文 Bundle Name 用 com.demo.floatkit。

2. 声明权限

闪控窗 start() 需要 ohos.permission.FLOAT_VIEW,闪控球启动/绑定需要 ohos.permission.USE_FLOAT_BALL。两者都是 user_grant 运行时权限:先在 module.json5 声明,再代码里动态申请。

entry/src/main/module.json5:

{
  "module": {
    // ...模板其余内容保持不变
    "requestPermissions": [
      {
        "name": "ohos.permission.FLOAT_VIEW",
        "reason": "$string:float_view_reason",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      },
      {
        "name": "ohos.permission.USE_FLOAT_BALL",
        "reason": "$string:float_ball_reason",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      }
    ]
  }
}

entry/src/main/resources/base/element/string.json 补两条:

{
  "string": [
    { "name": "float_view_reason", "value": "用于在其他应用界面上悬浮展示计时窗口" },
    { "name": "float_ball_reason", "value": "用于悬浮球与计时窗口的一键切换" }
  ]
}

3. 登记闪控窗页面

闪控窗加载的页面必须登记在 entry/src/main/resources/base/profile/main_pages.json:

{
  "src": [
    "pages/Index",
    "pages/FloatTimerPage"
  ]
}

四、完整代码

三个文件:权限工具、闪控窗页面、主页面。

1. 权限工具 entry/src/main/ets/common/PermissionUtil.ets

import { abilityAccessCtrl, Permissions, common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

const FLOAT_PERMISSIONS: Permissions[] = [
  'ohos.permission.FLOAT_VIEW',
  'ohos.permission.USE_FLOAT_BALL'
] as Permissions[];

/** 动态申请闪控窗 + 闪控球权限,全部授权返回 true */
export async function ensureFloatPermissions(context: common.UIAbilityContext): Promise<boolean> {
  try {
    const atManager = abilityAccessCtrl.createAtManager();
    const result = await atManager.requestPermissionsFromUser(context, FLOAT_PERMISSIONS);
    // authResults 中 0 表示授权成功
    return result.authResults.every((r: number) => r === 0);
  } catch (err) {
    const e = err as BusinessError;
    console.error(`requestPermissionsFromUser failed, code=${e.code}, msg=${e.message}`);
    return false;
  }
}

2. 闪控窗页面 entry/src/main/pages/FloatTimerPage.ets

被加载进闪控窗的页面。做一个每秒自增的秒表——它会证明"窗口里的页面一直活着":回到桌面、切到其他 App,秒表照跳不误。

@Entry
@Component
struct FloatTimerPage {
  @State seconds: number = 0;
  private timerId: number = -1;

  aboutToAppear(): void {
    this.timerId = setInterval(() => {
      this.seconds++;
    }, 1000);
  }

  aboutToDisappear(): void {
    clearInterval(this.timerId);
  }

  build() {
    Column({ space: 8 }) {
      Text('悬浮计时器')
        .fontSize(14)
        .fontColor('#666666')
      Text(this.format(this.seconds))
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .fontColor('#0A59F7')
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
  }

  private format(total: number): string {
    const m = Math.floor(total / 60);
    const s = total % 60;
    return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
  }
}

3. 主页面 entry/src/main/pages/Index.ets

主页面提供三个按钮:① 启动纯闪控窗(体验拖动 / 侧边栏暂存 / 垃圾桶删除)、② 启动绑定模式(闪控窗 + 闪控球一键互切)、③ 停止并释放。

import { floatView, floatingBall, window } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { ensureFloatPermissions } from '../common/PermissionUtil';

@Entry
@Component
struct Index {
  @State logText: string = '点击按钮开始';
  private fvController: floatView.FloatViewController | undefined = undefined;
  private ballController: floatingBall.FloatingBallController | undefined = undefined;

  build() {
    Column({ space: 16 }) {
      Text('闪控窗 + 闪控球 Demo')
        .fontSize(22)
        .fontWeight(FontWeight.Bold)

      Text(this.logText)
        .fontSize(13)
        .fontColor('#999999')
        .textAlign(TextAlign.Center)
        .width('90%')

      Button('① 启动闪控窗(悬浮秒表)')
        .width('80%')
        .onClick(() => this.startFloatView(false))

      Button('② 启动绑定模式(窗口+闪控球互切)')
        .width('80%')
        .onClick(() => this.startFloatView(true))

      Button('③ 停止并释放')
        .width('80%')
        .backgroundColor('#E84026')
        .onClick(() => this.stopAll())
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }

  /**
   * 启动闪控窗。bindBall=true 时同时创建闪控球并绑定。
   * 注意:必须在应用前台时调用。
   */
  private async startFloatView(bindBall: boolean): Promise<void> {
    const ctx = this.getUIContext().getHostContext() as common.UIAbilityContext;
    if (!await ensureFloatPermissions(ctx)) {
      this.logText = '权限被拒绝,无法启动';
      return;
    }
    try {
      // 0. 若已有旧实例,先停掉(单实例约束,防 1300030)
      await this.stopAll();

      // 1. 创建闪控窗控制器
      let floatConfig: floatView.FloatViewConfiguration = {
        context: ctx,
        templateType: floatView.FloatViewTemplateType.ROUNDED_RECTANGLE
      };
      this.fvController = await floatView.create(floatConfig);
      this.registerStateCallback();

      // 2. 绑定模式:创建闪控球控制器,并在"两者都未启动"时绑定
      if (bindBall) {
        let ballConfig: floatingBall.FloatingBallConfiguration = {
          context: ctx
        };
        this.ballController = await floatingBall.create(ballConfig);
        const ballParams: floatingBall.FloatingBallParams = {
          template: floatingBall.FloatingBallTemplate.EMPHATIC,
          title: '悬浮计时',        // 必填,非空,≤64字节
          content: '点我展开秒表'   // 可选,≤64字节
        };
        await floatView.bind(this.fvController, this.ballController, ballParams);
      }

      // 3. 设置闪控窗加载的页面(页面需登记在 main_pages.json)
      await this.fvController.setUIContext('pages/FloatTimerPage');

      // 4. 按系统限制设置窗口大小(单位 px)
      const limits: floatView.FloatViewLimits =
        floatView.getFloatViewLimits(floatView.FloatViewTemplateType.ROUNDED_RECTANGLE);
      let size: window.Size = {
        width: Math.floor(limits.maxSize.width * 0.8),
        height: Math.floor(limits.maxSize.height * 0.6)
      };
      await this.fvController.setWindowSize(size);

      // 5. 启动(绑定模式下会同时拉起闪控球,同一时刻只展示其中一个窗口)
      await this.fvController.start();
      this.logText = bindBall
        ? '已启动绑定模式,回桌面点闪控球/闪控窗互切试试'
        : '已启动,按 Home 回桌面看看它还在不在';
    } catch (err) {
      const e = err as BusinessError;
      this.logText = `启动失败 code=${e.code}`;
      if (e.code === 1300033) {
        this.logText += ':应用不在前台,或已有闪控窗在运行';
      } else if (e.code === 1300030) {
        this.logText += ':闪控窗正在启动或已启动,勿重复操作';
      } else if (e.code === 1300034) {
        this.logText += ':与闪控球/画中画窗口冲突,先停止它们';
      } else if (e.code === 201) {
        this.logText += ':缺少 FLOAT_VIEW 权限';
      }
    }
  }

  /** 监听闪控窗状态:STARTED=1 HIDDEN=2 STOPPED=3 IN_SIDEBAR=4 IN_FLOATING_BALL=5 ERROR=6 */
  private registerStateCallback(): void {
    this.fvController?.onStateChange((info: floatView.FloatViewStateChangeInfo) => {
      const reason = info.stopReason ? `,原因=${info.stopReason}` : '';
      this.logText = `闪控窗状态=${info.state}${reason}`;
      if (info.state === floatView.FloatViewState.STOPPED) {
        // 官方推荐姿势:注销回调、释放控制器引用
        this.fvController?.offStateChange();
        this.fvController = undefined;
        this.ballController = undefined; // 绑定模式下球已随之销毁
      }
    });
  }

  private async stopAll(): Promise<void> {
    try {
      // 绑定模式下 stop 任一控制器都会同时销毁两个窗口,官方示例是两个都停,这里保持一致
      if (this.fvController) {
        await this.fvController.stop();
      }
      if (this.ballController) {
        await this.ballController.stopFloatingBall();
      }
      // 解绑要求两个控制器均已停止
      if (this.fvController && this.ballController) {
        await floatView.unbind(this.fvController, this.ballController);
      }
      this.fvController = undefined;
      this.ballController = undefined;
      this.logText = '已全部停止';
    } catch (err) {
      const e = err as BusinessError;
      this.logText = `停止失败 code=${e.code}`;
      this.fvController = undefined;
      this.ballController = undefined;
    }
  }
}

五、跑起来:验证清单

  1. 真机连接 DevEco Studio 点 Run。首次启动会连续弹两条权限弹窗(闪控窗、闪控球),都点"允许"。
  2. 点 ①:屏幕出现白色圆角小窗,秒表开始跳动。
  3. 按 Home 回桌面,再打开"设置"等其他 App——小窗依然悬浮在别的应用之上,秒表照跳。
  4. 按住小窗拖到屏幕底部中部:出现垃圾桶区域,松手即删除(对应 DUMPSTER_STOP)。重新点 ① 再来。
  5. 点标题栏最小化按钮:因为没绑球,窗口收进系统侧边栏暂存(状态 IN_SIDEBAR),从侧边栏可再拖出。
  6. 停止后点 ②:屏幕上闪控窗出现,同时边缘出现闪控球。点闪控球 → 展开成闪控窗;点闪控窗缩小按钮 → 缩回闪控球,来回互切(状态 IN_FLOATING_BALL)。此时标题栏最小化不再进侧边栏,而是缩回球。
  7. 点 ③ 全部清理干净。

六、踩坑实录(每一刀都是真金白银换来的)

坑 1:后台调 start() 直接 1300033。
官方约束原文是"仅允许应用在前台时启动闪控窗"。想做"收到推送在后台拉起闪控窗"?此路不通。正确方案:后台只做数据准备,等 onForeground() 回调里再 start()。另外 1300033 是双含义错误码——"启动了多个闪控窗"或"context 主窗口不在前台",排查时两个方向都要看。

坑 2:bind 的时序要求——两个控制器都必须"未启动"。
先 fvController.start() 再补绑球会失败。正确顺序固定为:create 两个控制器 → floatView.bind(fv, ball, ballParams) → setUIContext → setWindowSize → start()。绑定后只调 start() 一次,系统会同时拉起两个窗口。

坑 3:FloatingBallConfiguration 里根本没有 title/content。
这是最容易看串文档的地方:floatingBall.create(config) 的 config 只有 context 一个字段,球的标题、内容、模板、颜色全都在 startFloatingBall(params) 或 floatView.bind() 的第三个参数 FloatingBallParams 里。title 必填且不能为空字符串、不超过 64 字节,违规报 1300019。

坑 4:在 STOPPED 回调里无脑重建,雪崩。
收到 STOPPED(state=3)时先看 stopReason,七种原因对应七种处理姿势:

stopReason含义该不该立刻重建?
APP_STOP应用主动停止按业务需要
TITLE_BAR_STOP_CLICK用户点标题栏关闭尊重用户,不重建
STOP_IN_SIDEBAR在侧边栏被关闭不重建
DUMPSTER_STOP拖入垃圾桶删除尊重用户,不重建
REPLACE_STOP被其他应用的闪控窗挤占可等回前台再重建
FLOATING_BALL_STOP绑定模式下跟随闪控球停止同上
MAIN_WINDOW_DESTROY_STOP主窗口被销毁应用都没了,谈不上重建

此时应用很可能已退到后台,直接 create + start 会再吃一个 1300033。官方示例的姿势值得照抄:收到 STOPPED 就 offStateChange() 注销回调、把控制器引用置 undefined,下次前台重新走一遍启动流程。

坑 5:窗口大小单位是 px,别拍脑袋写死。
setWindowSize 与 getFloatViewLimits 都用 px。如果设了超出限制的尺寸,系统会强制调整到范围内,实际大小要通过 onRectChange() 拿。想自适应就照官方示例:先 getFloatViewLimits(),再按 maxSize 比例缩放。

坑 6:真机才有完整体验。
预览器/模拟器上闪控窗不渲染窗口本体;部分旧设备调用直接返回 801(能力不支持)。拖动、垃圾桶、侧边栏暂存、球窗互切,全部要在 HarmonyOS 7 真机上验证。

七、还能怎么玩

  • HORIZONTAL_BAR 模板:templateType 换成 floatView.FloatViewTemplateType.HORIZONTAL_BAR(水平条状),配合 getFloatViewLimits() 读取该模板的尺寸与宽高比限制,做底部悬浮播放条。
  • 闪控窗回主应用:restoreMainWindow() 可把主窗口拉回前台——要求闪控窗处于 STARTED 且由用户点击闪控窗触发,适合"盯盘小窗点一下回交易页"。
  • 应用内隐藏:setFloatViewVisibilityInApp(false) 让闪控窗在自己的应用内隐藏、离开再显示,避免遮挡自家 UI;updateFloatingBall(params) 可动态改球上的文案(模板类型不可改),适合倒计时/订单状态实时刷新。
  • 独立闪控球:不绑定闪控窗也能单独用。ballController.on('click', cb) 注册点击回调自己处理跳转,on('stateChange', cb) 监听球的启停——搜题、抢单入口就是这么做的。

欢迎在评论区交流你在真机上遇到的闪控窗问题。如果这篇文章帮到了你,点个赞 + 收藏再走~

相关文件下载
downloaded-image (1).jpeg
389.40 KB
下载
Logo

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

更多推荐