👋 你好,欢迎来到我的博客!我是【菜鸟学鸿蒙】
   我是一名在路上的移动端开发者,正从传统“小码农”转向鸿蒙原生开发的进阶之旅。为了把学习过的知识沉淀下来,也为了和更多同路人互相启发,我决定把探索 HarmonyOS 的过程都记录在这里。
  
  🛠️ 主要方向:ArkTS 语言基础、HarmonyOS 原生应用(Stage 模型、UIAbility/ServiceAbility)、分布式能力与软总线、元服务/卡片、应用签名与上架、性能与内存优化、项目实战,以及 Android → 鸿蒙的迁移踩坑与复盘。
  🧭 内容节奏:从基础到实战——小示例拆解框架认知、专项优化手记、实战项目拆包、面试题思考与复盘,让每篇都有可落地的代码与方法论。
  💡 我相信:写作是把知识内化的过程,分享是让生态更繁荣的方式。
  
   如果你也想拥抱鸿蒙、热爱成长,欢迎关注我,一起交流进步!🚀

前言

下载任务进行到一半,用户切到另一个 App 看视频——进度就消失了。订单等待支付中,用户跳出去查地址——状态也不见了。这类场景里,应用自带的 Dialog 或 Toast 完全没有用武之地,因为它们的生命周期绑定在当前窗口上,一旦用户离开,就随之消亡。

HarmonyOS 提供的解决方案是系统级悬浮窗口,官方叫做 Float Window(悬浮窗),本文里为了和题目对齐,也叫"闪控窗"。它属于一种独立的窗口类型,可以叠加在其他应用之上,不随应用切换而消失。这篇文章从最小示例出发,把创建、展示、更新内容、关闭资源这条线路完整走一遍。

一、普通 Dialog 为什么解决不了跨应用查看

Dialog、Toast、Popup 这些组件,挂载的对象是当前 UIAbility 的 Window 实例。用户一旦通过多任务切换到别的 App,或者直接按 Home 键,这个 Window 就进入后台,绘制停止,组件自然也不可见了。

更准确地说:这些组件都是"页面内组件",它们的显示范围上限就是它们所在的那个 Window,无法跨出去。想要在其他 App 运行时仍然可见,必须持有一个独立的系统级窗口,有独立的绘制层级,才能浮在最上面。这正是 window.WindowType.TYPE_FLOAT 存在的原因。

二、什么是 Float Window(悬浮窗口)

在 HarmonyOS Stage 模型下,@ohos.window 模块(即 window 模块)支持创建多种类型的子窗口。其中 WindowType 枚举里的 TYPE_FLOAT,代表系统浮动窗口。它有几个关键特征:

  • 独立于 UIAbility 页面层级,有自己的 Z 序;
  • 可以叠加在其他应用或桌面之上;
  • 需要用户授权 ohos.permission.SYSTEM_FLOAT_WINDOW 权限;
  • 内容通过加载一个 .ets 页面文件来填充(loadContent);
  • 生命周期由开发者手动管理,包括创建、显示、内容更新和销毁。

注:TYPE_FLOAT 属于 WindowType 枚举,API Level 7 起支持,此后版本持续可用。createWindow 接口自 API 9 引入。

三、官方约束先看清楚

在开始写代码之前,有几条约束必须清楚,否则代码写对了也跑不起来。

1. 权限声明与授权

Float Window 必须在 module.json5 中声明权限,并且这条权限属于 user_grant(用户授权),需要在运行时主动向用户申请。

// module.json5 → module → requestPermissions
{
  "name": "ohos.permission.SYSTEM_FLOAT_WINDOW",
  "reason": "$string:reason_float_window",
  "usedScene": {
    "abilities": ["EntryAbility"],
    "when": "inuse"
  }
}

reason 字段必须填写,否则审核阶段会被拒,运行时也无法正常弹出授权弹框。usedScene.when 填 "inuse" 表示仅在前台使用时请求。

2. 创建上下文必须是 UIAbility 的 Context

window.createWindow 需要传入 BaseContext,这里必须使用 UIAbilityContext(即 this.context),不能使用 AppContext 或其他子 Context,否则无法关联到正确的 Window 管理层。

3. 设备限制

Float Window 当前适用于 Phone 和 Tablet 设备。在 2in1 设备(如 MatePad Pro)上行为可能有差异,建议在目标设备上单独验证。

四、创建最小闪控窗

先把窗口创建出来,只显示一个"任务进行中"的文字。

4.1 申请权限

在入口 Ability 启动后,先做权限检查和申请。

// EntryAbility.ets
import { abilityAccessCtrl, Permissions } from '@kit.AbilityKit';

async function requestFloatPermission(context: UIAbilityContext): Promise<boolean> {
  const permission: Permissions = 'ohos.permission.SYSTEM_FLOAT_WINDOW';
  const atManager = abilityAccessCtrl.createAtManager();

  // 先检查是否已授权
  const grantStatus = await atManager.checkAccessToken(
    context.applicationInfo.accessTokenId,
    permission
  );

  if (grantStatus === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
    return true;
  }

  // 未授权则发起请求
  const result = await atManager.requestPermissionsFromUser(context, [permission]);
  return result.authResults[0] === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED;
}

这段代码解决的问题是:在创建悬浮窗之前确认已获得系统授权。如果直接跳过这一步调 createWindow,会因为缺少权限而抛出错误。

关键点:checkAccessToken 用的是 accessTokenId,从 context.applicationInfo 获取,不是硬编码的 token 值。

4.2 定义悬浮窗内容页面

这是一个普通的 ArkTS 组件页面,只负责展示内容。

// pages/FloatPage.ets
@Entry
@Component
struct FloatPage {
  @StorageLink('taskProgress') taskProgress: number = 0;
  @StorageLink('taskLabel') taskLabel: string = '任务等待中';

  build() {
    Row() {
      Image($r('app.media.ic_task'))
        .width(20)
        .height(20)
        .margin({ right: 6 })

      Column() {
        Text(this.taskLabel)
          .fontSize(12)
          .fontColor('#FFFFFF')
        Progress({ value: this.taskProgress, total: 100, type: ProgressType.Linear })
          .width(100)
          .height(4)
          .color('#4FC3F7')
      }
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .height('100%')
    .padding(8)
    .backgroundColor('#CC1A1A2E')
    .borderRadius(12)
  }
}

这个页面通过 AppStorage 中的 taskProgress 和 taskLabel 两个 key 读取数据。之所以用 AppStorage 而不是传参,是因为悬浮窗和主 Ability 处于不同的渲染上下文中,通过组件属性传参的方式行不通——AppStorage 是进程级共享存储,两端都可以访问。

4.3 创建并显示悬浮窗

// FloatWindowManager.ets
import window from '@ohos.window';
import { UIAbilityContext } from '@kit.AbilityKit';

let floatWin: window.Window | null = null;

export async function createFloatWindow(context: UIAbilityContext): Promise<void> {
  // 如果已创建则直接返回
  if (floatWin !== null) {
    return;
  }

  const config: window.Configuration = {
    name: 'taskFloatWindow',          // 窗口唯一名称
    windowType: window.WindowType.TYPE_FLOAT,
    ctx: context
  };

  floatWin = await window.createWindow(config);

  // 设置窗口位置和尺寸(单位:px,需要根据屏幕密度换算)
  await floatWin.moveWindowTo(100, 200);
  await floatWin.resize(300, 60);

  // 加载内容页面
  await floatWin.loadContent('pages/FloatPage');

  // 显示窗口
  await floatWin.showWindow();
}

这里有几个需要关注的地方:

  • name 字段是窗口的唯一标识符,同一进程内不能重复创建同名窗口;
  • moveWindowTo 和 resize 的参数单位是物理像素(px),不是 vp。如果直接写 300 在高分屏上会很小,实际开发时需要用 display.getDefaultDisplaySync().densityDPI 换算;
  • loadContent 传入的路径是相对 src/main/ets/ 的页面路径,不需要写 .ets 后缀;
  • showWindow 必须在 loadContent 之后调用,否则显示的是空窗口。

五、展示任务状态

悬浮窗显示出来之后,需要把初始状态写进 AppStorage,页面才能读到值。

// 在创建悬浮窗之前或之后初始化状态
AppStorage.setOrCreate<number>('taskProgress', 0);
AppStorage.setOrCreate<string>('taskLabel', '正在准备...');

调用 createFloatWindow 之后,FloatPage 中通过 @StorageLink 绑定的两个属性会自动响应 AppStorage 中的初始值,无需额外触发。

六、更新窗口内容

任务状态变化时,只需要更新 AppStorage 中的值,@StorageLink 的双向绑定会自动驱动 UI 刷新,不需要持有窗口引用来主动刷新界面。

// 模拟任务进度更新
export function updateTaskStatus(progress: number, label: string): void {
  AppStorage.set<number>('taskProgress', progress);
  AppStorage.set<string>('taskLabel', label);
}

// 使用示例:在下载任务的回调中调用
// updateTaskStatus(45, '下载中 45%');
// updateTaskStatus(100, '下载完成');

这段代码的核心不是更新逻辑本身,而是数据流向:主线程业务逻辑 → AppStorage → @StorageLink 响应 → 悬浮窗 UI 刷新。整个过程不需要窗口句柄参与,也不需要跨 Ability 通信。

七、关闭与资源释放

任务完成或用户主动关闭时,需要销毁悬浮窗并释放引用,否则窗口实例会一直驻留。

export async function destroyFloatWindow(): Promise<void> {
  if (floatWin === null) {
    return;
  }
  try {
    await floatWin.destroyWindow();
  } catch (err) {
    console.error(`destroyWindow failed: ${JSON.stringify(err)}`);
  } finally {
    floatWin = null;
  }
}

关键点:destroyWindow 是异步操作,要用 try/finally 包住,确保无论销毁是否成功,floatWin 都被置 null,避免后续调用 createFloatWindow 时因为引用未清空而跳过创建流程。

在 UIAbility 的 onDestroy 回调里也要记得调用 destroyFloatWindow,防止 Ability 退出后悬浮窗成为孤立窗口。

// EntryAbility.ets
async onDestroy(): Promise<void> {
  await destroyFloatWindow();
}

八、几个关键点拆开看

8.1 同名窗口不能重复创建

window.createWindow 传入的 name 在同一进程内必须唯一。如果应用在后台时系统把 Ability 重建了,而 floatWin 引用已经丢失,重新调用 createWindow 会因为同名窗口已存在而抛出错误。

处理方式:可以在创建前先尝试 window.findWindow(name),如果找到了就直接复用,找不到再创建。

try {
  floatWin = window.findWindow('taskFloatWindow');
} catch {
  // findWindow 找不到会抛异常,走创建逻辑
  floatWin = await window.createWindow(config);
}

8.2 坐标和尺寸的单位问题

这里比较容易出现一个理解偏差:moveWindowTo 和 resize 的参数是物理像素,而 ArkUI 布局里通常用 vp(虚拟像素)。在 2x 密度屏幕上,100vp = 200px,如果直接把 vp 值填进去,在高分屏上窗口会偏小。

换算方法:

import display from '@ohos.display';

const densityPixels = display.getDefaultDisplaySync().densityPixels;
const widthPx = Math.round(200 * densityPixels);  // 200vp → px
const heightPx = Math.round(60 * densityPixels);

8.3 @StorageLink 和 @StorageProp 的选择

如果悬浮窗内容只需要主 Ability 单向推送数据给悬浮窗,用 @StorageProp 更合适(单向,AppStorage → 组件)。如果悬浮窗上有按钮需要反向影响状态,再用 @StorageLink(双向)。不加区分全用 @StorageLink,容易引发非预期的双向更新。

8.4 权限申请的时机

SYSTEM_FLOAT_WINDOW 属于 user_grant,必须在调用 createWindow 之前完成授权。如果用户拒绝了授权,需要给出合理的降级方案(比如在应用内用通知代替),不能强制依赖悬浮窗功能。

九、什么信息适合放进闪控窗

悬浮窗对用户来说是一个"侵入式"的存在,占据屏幕空间,遮挡其他应用内容。所以适合放进去的内容需要满足几个条件:

适合放进去的:

  • 用户主动发起的、有时间维度的任务状态(下载进度、上传进度、计时器);
  • 需要实时感知的订单状态(外卖配送、打车等待);
  • 用户明确开启的监控类信息(运动数据、录屏倒计时);
  • 状态变化频率适中(不要每秒更新多次,闪烁会让用户不适)。

不适合放进去的:

  • 纯粹的广告或推广内容(审核直接拒);
  • 静态通知(用系统通知栏更合适);
  • 密集的交互操作(悬浮窗面积小,不适合复杂 UI);
  • 长期驻留且没有明确关闭入口(用户体验差,且审核会关注是否提供关闭方式)。

官方审核规范要求悬浮窗必须为用户提供可以关闭的方式,建议在悬浮窗 UI 上至少放一个关闭按钮,调用 destroyFloatWindow。

十、容易踩坑的地方

1. 忘记在 module.json5 里声明权限

代码里申请了权限,但 module.json5 里没有 requestPermissions 声明,运行时 requestPermissionsFromUser 直接失败,且错误信息并不直观。

2. loadContent 路径写错

路径是从 src/main/ets/ 开始算的相对路径,不需要加 @,不需要加 .ets 后缀。比如文件在 src/main/ets/pages/FloatPage.ets,路径就写 'pages/FloatPage'。写错会导致悬浮窗显示空白,没有明显报错。

3. 在 Service Extension 里创建悬浮窗

部分场景下开发者希望在后台 Service 中创建悬浮窗。这里需要注意,window.createWindow 传入的 ctx 必须来自有效的 Ability Context,ServiceExtension 的 Context 在某些情况下会导致窗口创建失败或权限检查失败。官方建议悬浮窗的创建在前台 UIAbility 存活时进行。

4. 窗口销毁后继续调用 loadContent 或 showWindow

如果在 destroyWindow 完成后没有清空 floatWin 引用,后续调用时会抛出"窗口已不存在"的错误。用 finally { floatWin = null } 确保引用清空是防止这类问题的标准做法。

十一、实际项目中的排查顺序

遇到悬浮窗无法显示或行为异常时,建议按以下顺序排查:

  1. 权限 — 确认 module.json5 里有 SYSTEM_FLOAT_WINDOW 声明,且运行时已获得用户授权;
  2. API Level — 确认当前 compileSdkVersion 和 targetSdkVersion 满足接口要求(createWindow 需要 API 9+);
  3. Context 类型 — 确认传入的是 UIAbilityContext 而不是其他 Context;
  4. 同名窗口 — 确认没有重复创建同名窗口,先尝试 findWindow 检查是否已存在;
  5. 页面路径 — 确认 loadContent 的路径相对于 src/main/ets/ 正确,无多余后缀;
  6. 坐标和尺寸 — 确认 moveWindowTo / resize 传入的是物理像素,位置没有超出屏幕范围;
  7. showWindow 调用时机 — 确认 loadContent 完成之后再调用 showWindow;
  8. 资源释放 — 确认 Ability 退出时正确调用了 destroyWindow。

开发经验总结

  • 闪控窗的核心是独立窗口层级,解决的是 Dialog 无法跨应用存活这一根本限制,理解这个定位之后,使用场景就很自然了;
  • 权限申请和声明是最容易被漏掉的第一步,一定要在 module.json5 里提前配好;
  • 数据流用 AppStorage 做桥接,主业务推数据,悬浮窗被动响应,这个架构简单且够用;
  • 窗口的创建和销毁要配对管理,在 onDestroy 里做清理,防止孤立窗口和资源泄漏;
  • 坐标和尺寸单位是 px,这一点和 ArkUI 常用的 vp 不一样,换算时记得用 densityPixels。

如果你正在开发一个有后台长时任务的 App,可以观察一下:当用户切出应用后,你现有的状态展示方案(比如通知栏通知)和悬浮窗各自在哪些场景下更合适——这两者的选择边界,往往是被忽略但值得细想的设计决策。

📝 写在最后

如果你觉得这篇文章对你有帮助,或者有任何想法、建议,欢迎在评论区留言交流!你的每一个点赞 👍、收藏 ⭐、关注 ❤️,都是我持续更新的最大动力!

我是一个在代码世界里不断摸索的小码农,愿我们都能在成长的路上越走越远,越学越强!

感谢你的阅读,我们下篇文章再见~👋

✍️ 作者:菜鸟不学编程
🧵 本文原创,转载请注明出处。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐