【鸿蒙心迹】闪控窗开发笔记:悬浮窗的权限申请与生命周期管理

摘要:本文围绕鸿蒙闪控窗(系统级悬浮窗)的开发实践展开,先厘清闪控窗与普通窗口在显示层级、生命周期、权限要求上的核心差异,再拆解「安装时声明 + 运行时引导用户手动开启」的两级权限体系与窗口管理核心 API,最后以悬浮操作按钮为例,完整走通权限声明、权限校验、窗口创建、UI 加载、拖动交互、窗口销毁六个步骤,并总结权限被拒、窗口不显示、应用退出后残留、点击事件穿透四类高频坑的排查思路。适合有安卓悬浮窗经验、想在鸿蒙上实现类似能力的开发者参考。

做工具类应用的开发者,迟早会遇到一个需求:在别的应用上面显示一个小窗口——可能是悬浮按钮、可能是通知弹窗、可能是实时数据浮层。在安卓上这叫悬浮窗(System Alert Window),在鸿蒙上叫闪控窗。名字不同,踩的坑一样多:权限申请被拒、窗口创建后不显示、生命周期管理混乱导致窗口泄漏。

如果你有安卓悬浮窗开发经验,或者想在鸿蒙上实现类似功能,这篇笔记会帮你少走很多弯路。读完后你会掌握:闪控窗的概念和权限体系、窗口创建与销毁的完整流程、生命周期管理的最佳实践。

本文分三层:第一层建立闪控窗的整体认知;第二层拆解权限和生命周期原理;第三层是完整的实战落地和踩坑排错。

入门认知:闪控窗是什么

一句话定义

闪控窗是鸿蒙提供的系统级悬浮窗口能力——应用可以在其他应用之上显示一个透明或半透明的窗口,不受当前前台应用的影响。

打个比方:普通应用窗口就像舞台上的演员,只能在轮到自己时才上台表演;闪控窗就像舞台旁边的字幕屏,不管台上谁在表演,字幕始终悬浮在最前面显示。

闪控窗 vs 普通窗口

对比维度普通窗口闪控窗
显示层级应用自己的窗口层级系统最顶层,覆盖所有应用
生命周期随应用前后台切换独立于应用生命周期
用户感知应用内可见全局可见,跨应用
权限要求无特殊权限需要系统悬浮窗权限
典型场景页面、对话框悬浮按钮、通知浮层、实时数据

表1:普通窗口与闪控窗的核心差异

全景图示

闪控窗

创建窗口

设置窗口属性

加载UI内容

显示窗口

跨应用悬浮显示

用户交互

销毁窗口

权限申请

运行时权限检查

引导用户授权

图1:闪控窗的完整生命周期链路

适用边界

闪控窗适合:全局通知、悬浮操作按钮、实时数据监控浮层、来电弹窗。不适合:应用内的对话框(用普通弹窗即可)、全屏覆盖(用Ability切换更合适)、大量交互内容(闪控窗应保持轻量)。

进阶原理:权限体系与窗口管理

闪控窗的权限机制

闪控窗涉及两级权限:

权限级别权限名申请方式说明
安装时ohos.permission.SYSTEM_FLOAT_WINDOWmodule.json5声明基础权限声明
运行时悬浮窗授权设置页引导用户需手动在设置中开启

表2:闪控窗的两级权限

[注意] 鸿蒙的悬浮窗权限和安卓不同——安卓用 SYSTEM_ALERT_WINDOW 可以运行时弹框申请,鸿蒙必须引导用户到系统设置中手动开启,不能弹框直接申请。这是安全策略的差异。

两级权限的申请与校验流程如下:

是,已授权

否,未授权

应用启动

module.json5 声明 SYSTEM_FLOAT_WINDOW

安装时授予基础权限

创建闪控窗前调用 checkAccessToken

返回状态是否为 0

继续 createWindow

提示用户并跳转系统设置页

用户在设置中手动开启悬浮窗权限

图2:闪控窗两级权限的申请与校验流程

窗口管理的核心API

鸿蒙窗口管理通过 window 模块实现,闪控窗相关的核心API:

API作用调用时机
window.createWindow创建窗口初始化闪控窗
window.setWindowProperties设置位置/大小/透明度创建后配置
window.setUIContent加载UI内容创建后加载
window.showWindow显示窗口配置完成后
window.destroyWindow销毁窗口不再需要时

表3:闪控窗核心API

生命周期管理原理

闪控窗的生命周期独立于应用Ability的生命周期,这是它最强大也最危险的特点:

闪控窗生命周期

应用生命周期

必须销毁

onCreate

onForeground

onBackground

onDestroy

createWindow

showWindow

hideWindow

destroyWindow

图2:闪控窗生命周期与应用生命周期的关系

[坑] 闪控窗不会随应用销毁自动消失!如果应用被杀但闪控窗没销毁,窗口会残留在屏幕上变成"幽灵窗口",只有重启设备才能清除。这是新手最容易犯的严重错误。

常见误区

误区1:闪控窗可以随意创建多个

每创建一个闪控窗都会占用系统窗口资源,创建过多会导致系统窗口管理器压力过大,新窗口可能创建失败。建议同时不超过2-3个闪控窗。

误区2:闪控窗的UI和普通页面一样写

闪控窗的UI应该极简——一个按钮、一段文字、一个小图标。不要在闪控窗里放复杂列表、嵌套滚动、大量动画,这会导致严重的性能问题和用户体验灾难。

高级应用:实现一个悬浮操作按钮

实战步骤

第一步:声明权限

module.json5:

{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.SYSTEM_FLOAT_WINDOW",
        "reason": "$string:float_window_reason",
        "usedScene": {
          "abilities": ["MainAbility"],
          "when": "always"
        }
      }
    ]
  }
}

第二步:检查并引导授权

import { window } from '@kit.ArkUI'
import { abilityAccessCtrl } from '@kit.AbilityKit'

async function checkFloatWindowPermission(): Promise<boolean> {
  const atManager = abilityAccessCtrl.createAtManager()
  const tokenId = await atManager.getAccessTokenId()

  try {
    const status = await atManager.checkAccessToken(tokenId, 'ohos.permission.SYSTEM_FLOAT_WINDOW')
    if (status === 0) {
      return true
    }
    // 权限未授予,引导用户到设置页
    return false
  } catch (error) {
    console.error(`权限检查失败: ${error}`)
    return false
  }
}

[注意] checkAccessToken 返回0表示已授权,非0表示未授权。不要用 grantAccessToken 尝试自动授权——悬浮窗权限不支持运行时弹框授权,必须引导用户手动到设置中开启。

第三步:创建闪控窗

let floatWindow: window.Window | null = null

async function createFloatWindow() {
  // 检查权限
  const hasPermission = await checkFloatWindowPermission()
  if (!hasPermission) {
    // 引导用户到设置页开启悬浮窗权限
    promptAction.showToast({ message: '请到设置中开启悬浮窗权限' })
    return
  }

  // 创建窗口
  const config: window.Configuration = {
    name: 'float_button',
    windowType: window.WindowType.TYPE_FLOAT,
    ctx: this.context
  }
  floatWindow = await window.createWindow(config)

  // 设置窗口属性
  await floatWindow.setWindowProperties({
    width: 80,
    height: 80,
    transparent: true,
    outsideTouchable: true
  })

  // 加载UI内容
  await floatWindow.setUIContent('pages/FloatButtonPage')

  // 显示窗口
  await floatWindow.showWindow()
}

关键参数解释:

  • windowType: TYPE_FLOAT — 声明为闪控窗类型,这是最关键的参数
  • transparent: true — 窗口背景透明,只显示UI内容部分
  • outsideTouchable: true — 窗口外的区域可以触摸穿透,不影响下层应用操作

第四步:闪控窗的UI页面

FloatButtonPage.ets:

@Entry
@Component
struct FloatButtonPage {
  build() {
    Stack() {
      Circle({ width: 60, height: 60 })
        .fill('#007DFF')
        .shadow({ radius: 8, color: '#40007DFF', offsetX: 0, offsetY: 2 })

      Image($r('app.media.float_icon'))
        .width(30)
        .height(30)
    }
    .width(80)
    .height(80)
    .onClick(() => {
      // 点击悬浮按钮,拉起主应用
      // 或者切换悬浮窗内容
    })
  }
}

第五步:窗口拖动

悬浮按钮通常需要支持拖动,让用户调整位置:

let lastX: number = 0
let lastY: number = 0

// 在FloatButtonPage中添加拖动手势
.gesture(
  PanGesture()
    .onActionStart((event: GestureEvent) => {
      lastX = event.fingerList[0].localX
      lastY = event.fingerList[0].localY
    })
    .onActionUpdate((event: GestureEvent) => {
      const deltaX = event.fingerList[0].localX - lastX
      const deltaY = event.fingerList[0].localY - lastY
      // 更新窗口位置
      floatWindow?.setWindowProperties({
        posX: currentPosX + deltaX,
        posY: currentPosY + deltaY
      })
    })
)

第六步:销毁窗口(关键!)

async function destroyFloatWindow() {
  if (floatWindow) {
    await floatWindow.destroyWindow()
    floatWindow = null
  }
}

[坑] destroyWindow 是异步的,调用后不要立即创建同名窗口——系统需要时间清理窗口资源。建议销毁后至少等待100ms再创建新窗口,否则可能报 “window already exists”。

把六个步骤串起来,完整的创建与销毁流程如下:

否

是

开始创建闪控窗

第一步:module.json5 声明权限

第二步:checkAccessToken 检查权限

权限是否已授予

引导用户到设置页开启

第三步:createWindow 创建窗口

setWindowProperties 设置尺寸与透明

setUIContent 加载 FloatButtonPage

showWindow 显示窗口

第四步:UI 渲染悬浮按钮

第五步:PanGesture 拖动调整位置

第六步:destroyWindow 销毁窗口

引用置 null,等待 100ms 再重建

图3:悬浮操作按钮的完整实现流程

踩坑排错

坑1:createWindow报 “permission denied”

现象:调用 window.createWindow 直接抛权限错误。

原因:SYSTEM_FLOAT_WINDOW 权限未授予。

解决:先调用 checkAccessToken 检查权限,未授权时引导用户到设置页。不要假设权限已授予。

坑2:窗口创建成功但不显示

现象:createWindow 和 showWindow 都没报错,但屏幕上看不到任何东西。

排查步骤:

  1. 检查 transparent 是否设为true——如果窗口透明但UI内容也是透明的,就什么都看不到
  2. 检查窗口位置 posX/posY——可能位置在屏幕外
  3. 检查 setUIContent 的页面路径是否正确——路径错误时窗口存在但内容为空
  4. 检查UI页面是否有实际可见内容——Stack里如果只有透明元素也看不到

坑3:应用退出后闪控窗残留

现象:用户从最近任务列表划掉应用,但悬浮按钮还在屏幕上。

原因:应用被系统杀死时,onDestroy 不一定被调用,闪控窗没有被销毁。

解决:在 onBackground 中隐藏窗口(而非销毁),在应用重新前台时再显示。同时注册 application.onWindowStageDestroy 回调,在窗口阶段销毁时清理闪控窗:

windowStage.getMainWindow().on('windowStageDestroy', () => {
  destroyFloatWindow()
})

坑4:闪控窗内点击事件穿透到下层应用

现象:点击悬浮按钮时,下层应用也响应了点击。

原因:outsideTouchable 设为true导致整个窗口区域都允许穿透,包括UI内容区域。

解决:outsideTouchable 只控制窗口透明区域的穿透行为。UI内容区域的点击不会穿透。如果仍然穿透,检查UI页面是否正确设置了 onClick 事件——没有onClick的区域才会穿透。

四个高频坑的排查路径可以归纳为下面这棵决策树:

createWindow 抛错

创建成功但看不见

应用退出后仍残留

点击穿透到下层

闪控窗出现异常

异常表现

坑1:permission denied

checkAccessToken 检查权限

未授权则引导设置页开启

坑2:窗口不显示

检查 transparent 与 UI 是否都透明

检查 posX/posY 是否在屏幕外

检查 setUIContent 页面路径

检查 UI 是否有可见内容

坑3:幽灵窗口

onBackground 隐藏窗口

注册 windowStageDestroy 回调销毁

坑4:事件穿透

确认 outsideTouchable 语义

为 UI 内容区补 onClick

图4:闪控窗常见问题的排查决策树

最佳实践

  • 权限检查先行:每次创建闪控窗前检查权限,不要假设已授权
  • UI保持极简:闪控窗只放一个按钮或一段文字,不要复杂交互
  • 生命周期绑定:在onBackground隐藏、onForeground显示、onWindowStageDestroy销毁
  • 拖动支持:悬浮按钮必须支持拖动,否则遮挡用户操作
  • 销毁要彻底:destroyWindow后置null,避免引用残留
  • 窗口数量控制:同时不超过2-3个闪控窗

总结

核心要点:

  1. 闪控窗是系统级悬浮窗口,独立于应用生命周期,跨应用显示
  2. 权限分两级:安装时声明 + 运行时引导用户手动开启,不能弹框直接申请
  3. 闪控窗不会随应用销毁自动消失——必须在生命周期回调中主动销毁,否则产生幽灵窗口
  4. UI必须极简,复杂交互会导致性能问题和体验灾难

最佳实践清单:

  • 创建前检查权限,未授权引导到设置页
  • UI只放一个按钮或一段文字
  • onBackground隐藏、onForeground显示、onWindowStageDestroy销毁
  • 悬浮按钮支持拖动
  • destroyWindow后置null
  • 同时不超过2-3个闪控窗

你在闪控窗开发中遇到过什么坑?评论区分享,一起把悬浮窗做稳。

Logo

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

更多推荐