摘要 : 做购物比价功能时,产品要求加一个全局悬浮球,用户在任何页面都能一键看比价结果。我原以为就是画个按钮浮在屏幕上,结果一查文档发现鸿蒙的"闪控球"是
@ohos.window.floatingBall系统模块(API 20+)提供的系统级悬浮能力------不是自定义 UI,系统按模板渲染,应用只负责喂数据。本文用第一人称完整复盘闪控球从权限申请到实战的链路,含 6 个真实踩坑(权限申请被拒、设备不支持、context 获取时机、restoreMainWindow 调用时机、模板不可变、生命周期未释放)。
适用版本: HarmonyOS NEXT 7.x / API 20+(2026 年稳定版;API 20 之前仅 Phone/Tablet 非电脑模式支持,7.0.0 起 PC/2in1 也支持)
开篇:一个悬浮球,把跨应用操作串起来
"能不能加个悬浮球,用户在任何页面都能一键看比价结果?"
2026 年 8 月,涟漪睡眠 App 做"全网比价"功能。产品同学画了个原型:屏幕右侧浮一个小球,实时显示当前商品的比价结果,用户不用退出任何页面就能看。我第一反应是"这不就是悬浮 Button 吗,写个 ArkUI 组件挂窗口上不就行了",结果一查文档发现鸿蒙有专门的**闪控球(floatingBall)**系统能力------不是让你自己画 UI,是系统按模板渲染一个悬浮小组件,应用只负责创建控制器、喂数据、监听点击。
接入过程踩了 6 个坑:权限申请被拒、设备能力检测漏做、context 获取时机不对、restoreMainWindow 不能在点击事件外调用、模板类型启动后不可变、停止后监听器没解绑导致泄漏。这篇文章把闪控球从接入到实战的完整链路讲透。

一、闪控球是什么:系统能力而非 UI 组件
很多开发者(包括当时的我)会先想到"在窗口上盖一个悬浮 Button",但那是应用内 能力,切到别的 App 就没了。闪控球是系统窗口层的悬浮小组件,浮在所有应用之上,跨应用持续显示。
更关键的一点容易误解:闪控球不是自定义 UI 。你不能像写普通页面那样用 ArkUI 组件画球体、画面板------系统根据你传入的 FloatingBallParams(模板类型 + 标题 + 内容 + 图标 + 背景色)按预设模板渲染。应用能做的是:创建控制器、启动/更新/停止球、监听点击、把球点回主应用。

| 维度 | 应用内悬浮 Button | 闪控球 floatingBall |
|---|---|---|
| 所属层级 | 应用 UI 层(当前窗口内) | 系统窗口层(浮于所有应用之上) |
| 显示范围 | 当前应用当前页面 | 跨应用持续显示 |
| UI 形态 | 完全自定义(任意 ArkUI 组件) | 系统按模板渲染(4 种模板,不可自定义布局) |
| 生命周期 | 跟随页面/窗口 | 独立控制器(create → start → update → stop) |
| 权限 | 无需额外权限 | 需申请 ohos.permission.USE_FLOAT_BALL(场景受限) |
| 典型场景 | 单页内的浮动操作按钮 | 比价、搜题、记账、抢单、翻译、金融盯盘 |
官方文档明确列出的适用场景:跨应用的题目搜索、账单记录、商品比价、抢单、翻译,以及金融类应用的实时盯盘。超出这些场景,权限申请大概率被拒(我实际就被拒过一次,见坑 1)。
二、全链路接入:从权限到启动
步骤 1:申请权限(最容易卡的一步)
闪控球需要 ohos.permission.USE_FLOAT_BALL 权限。这不是普通权限------它是场景受限的系统能力,审核时会重点看你的业务场景是否在允许范围内。
在 module.json5 的 requestPermissions 里声明:
json5
{
"module": {
"name": "entry",
"type": "entry",
"requestPermissions": [
{
"name": "ohos.permission.USE_FLOAT_BALL",
"reason": "$string:float_ball_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "always"
}
}
]
}
}
resources/base/element/string.json 里写清楚用途(审核会看):
json
{
"string": [
{
"name": "float_ball_reason",
"value": "用于在全网比价时跨应用展示比价结果,用户点击闪控球回到应用查看详情"
}
]
}
注意 :startFloatingBall 还要求应用处于前台才能启动。也就是说,闪控球是"应用在前台时启动 → 退到后台/切到其他应用 → 球继续显示"的模型,不能从后台静默拉起。
步骤 2:设备能力检测
闪控球不是所有设备都支持。从 HarmonyOS 7.0.0 开始支持 Phone、PC/2in1、Tablet 设备,其他设备调用会返回 801 错误码。7.0.0 之前仅 Phone 和 Tablet 非电脑模式可用。
启动前必须先用 canIUse 检查系统能力,再用 isFloatingBallEnabled 检查当前设备:
typescript
import { floatingBall } from '@kit.ArkUI';
function isDeviceSupported(): boolean {
// 先检查 syscap
if (!canIUse('SystemCapability.Window.SessionManager')) {
return false;
}
// 再检查当前设备是否支持闪控球
return floatingBall.isFloatingBallEnabled();
}
检测不通过就降级到备选方案(通知栏 / 应用内悬浮 Button),不能硬调。
步骤 3:创建控制器 + 注册事件 + 启动
核心链路是"create → 注册事件 → start",事件必须先于 start 注册,否则可能遗漏首次点击。
typescript
import { floatingBall } from '@kit.ArkUI';
import { common, Want } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
@Entry
@Component
struct ComparePage {
private controller: floatingBall.FloatingBallController | undefined = undefined;
build() {
Column({ space: 16 }) {
Button('启动闪控球')
.width(280).height(50)
.onClick(() => this.startBall())
Button('停止闪控球')
.width(280).height(50)
.backgroundColor('#ff4444')
.onClick(() => this.stopBall())
}
.width('100%').height('100%')
.justifyContent(FlexAlign.Center)
}
async startBall(): Promise<void> {
if (!floatingBall.isFloatingBallEnabled()) {
console.error('当前设备不支持闪控球功能');
return;
}
try {
// 1. 在组件内获取 UIAbilityContext(注意:必须在组件内取)
const ctx = this.getUIContext().getHostContext() as common.UIAbilityContext;
// 2. 创建控制器(单应用只需创建一次)
if (!this.controller) {
this.controller = await floatingBall.create({ context: ctx });
}
// 3. 注册点击事件------先注册,避免遗漏
this.controller.on('click', async () => {
const want: Want = {
bundleName: ctx.abilityInfo.bundleName,
abilityName: 'EntryAbility',
parameters: { route: 'priceDetail', productId: 'current' }
};
// 4. 点击后恢复主窗口(仅允许在 click 事件里调用)
await this.controller?.restoreMainWindow(want);
});
// 5. 注册状态变化监听
this.controller.on('stateChange', (state: floatingBall.FloatingBallState) => {
if (state === floatingBall.FloatingBallState.STOPPED) {
console.info('闪控球已停止,释放资源');
this.releaseController();
}
});
// 6. 启动闪控球
const startParams: floatingBall.FloatingBallParams = {
template: floatingBall.FloatingBallTemplate.EMPHATIC,
title: '比价助手',
content: '正在比价中...',
backgroundColor: '#FF333333'
};
await this.controller.startFloatingBall(startParams);
console.info('闪控球启动成功');
} catch (err) {
const error = err as BusinessError;
console.error(`闪控球操作失败: ${error.code} - ${error.message}`);
}
}
stopBall(): void {
this.controller?.stopFloatingBall();
}
releaseController(): void {
this.controller?.off('click');
this.controller?.off('stateChange');
this.controller = undefined;
}
}
四种模板 (FloatingBallTemplate),启动时选定后不可更改:
| 模板 | 布局 | 必填参数 | 适用 |
|---|---|---|---|
STATIC |
静态:标题 + 图标 | title + icon | 固定信息展示 |
NORMAL |
普通文本:标题 + 内容 | title + content | 纯文字信息 |
EMPHATIC |
强调:图标 + 标题 + 内容 | title + icon + content | 比价结果提示(推荐) |
SIMPLE |
纯文本:仅标题 | title | 最小化提示 |
参数约束(启动/更新共用):
title:不可为空,≤64 字节content:可选,≤64 字节icon:PixelMap类型,像素总字节数 ≤192KB,建议 128×128 像素backgroundColor:可选,十六进制颜色(支持 8 位 ARGB)
步骤 4:更新闪控球内容
比价完成后,把结果推给闪控球,用户不用回到应用就能看到价格:
typescript
async updateBall(price: string): Promise<void> {
if (!this.controller) {
return;
}
try {
const updateParams: floatingBall.FloatingBallParams = {
template: floatingBall.FloatingBallTemplate.EMPHATIC, // 必须与启动时一致
title: '比价助手',
content: `发现更低价格 ¥${price}`,
backgroundColor: '#FFE64545' // 醒目红色
};
await this.controller.updateFloatingBall(updateParams);
} catch (err) {
const error = err as BusinessError;
console.error(`闪控球更新失败: ${error.code} - ${error.message}`);
}
}
全链路时序

三、6 个真实踩坑
1:权限申请被拒(场景不符合)
现象 :提交审核时,ohos.permission.USE_FLOAT_BALL 被拒,反馈"不符合使用场景"。
根因 :这个权限只对跨应用的题目搜索、账单记录、商品比价、抢单、翻译、金融盯盘场景开放。我们第一版把场景写成了"跨应用展示商品信息和快捷操作",太宽泛,审核认为可能用于覆盖诱导、敏感信息展示等滥用场景。
解决:把 reason 写具体、写窄,明确对应允许场景:
json
{
"name": "float_ball_reason",
"value": "用于商品比价场景:应用内发起比价后,用户切换到其他应用时通过闪控球实时展示比价结果,点击后回到应用查看明细"
}
同时准备一段 30 秒内的功能演示视频给审核。改完之后过了。
教训:场景描述不是写功能,是写"为什么必须用闪控球 + 对应哪个允许场景"。越宽泛越容易被拒。
2:设备不支持,调用直接报 801
现象 :在 PC/2in1 设备(HarmonyOS 6.x)上调用 floatingBall.create 返回错误码 801,闪控球不显示。
根因:7.0.0 之前 PC/2in1 不支持闪控球,只有 Phone 和 Tablet 非电脑模式支持。我们上线时只测了手机,忘了平板的电脑模式也走 801。
解决:启动前双重检测(见步骤 2),不通过就降级:
typescript
if (!canIUse('SystemCapability.Window.SessionManager')
|| !floatingBall.isFloatingBallEnabled()) {
// 降级方案:用通知栏推送比价结果
this.fallbackToNotification();
return;
}
实测:同一份代码,HarmonyOS 6.1 的 PC 模式报 801,升到 7.0.0 后正常。版本差异必须写进兼容矩阵。
3:context 取错,create 失败
现象 :floatingBall.create({ context }) 抛异常,日志里 context 的 abilityInfo 为空。
根因 :我一开始在 Ability 的 onCreate 里取 this.context 传给页面,页面里再传给 create。但这个 context 不是 UIAbilityContext 类型------文档明确要求在组件内 通过 this.getUIContext().getHostContext() 获取,确保返回的是 UIAbilityContext。
解决:把 create 挪到组件的方法里,现取现用:
typescript
// ❌ 错误:从 Ability 传下来的 context
this.controller = await floatingBall.create({ context: this.abilityCtx });
// ✅ 正确:在组件内获取
const ctx = this.getUIContext().getHostContext() as common.UIAbilityContext;
this.controller = await floatingBall.create({ context: ctx });
教训:文档里"请在组件内获取 context"这句话不是建议,是硬性要求。跨层传 context 很容易把类型带偏。
4:restoreMainWindow 不能在点击事件外调用
现象 :我在"停止闪控球"的按钮里顺手调了 restoreMainWindow,期望球消失后拉回应用,结果报错。
根因 :restoreMainWindow(want) 仅支持在点击闪控球后调用 (即 click 事件回调内)。如果应用申请了 ohos.permission.AUTO_RESTORE_MAIN_WINDOW 权限,可以无需点击直接调用------但那是另一个更高门槛的权限,普通应用申请不到。
解决:把"拉起应用"逻辑只放在 click 事件里,停止就是单纯停止:
typescript
// click 事件内:✅ 允许
this.controller.on('click', async () => {
await this.controller?.restoreMainWindow(want);
});
// 普通按钮回调里:❌ 不允许
Button('停止').onClick(async () => {
await this.controller?.stopFloatingBall();
// await this.controller?.restoreMainWindow(want); // 报错
});
5:更新时改模板类型,报 1300027
现象 :比价进行中用 NORMAL 模板启动,想比价完成后换成 EMPHATIC 加个红色图标,调用 updateFloatingBall 返回 1300027。
根因 :模板类型启动后不可修改 。STATIC 模板甚至不支持更新。update 只能改 title / content / backgroundColor / icon,不能换 template。
解决 :启动时就按"最终形态"选模板(比价场景用 EMPHATIC),更新时只改内容:
typescript
// 启动:EMPHATIC
startParams = { template: EMPHATIC, title: '比价助手', content: '正在比价中...', icon: pixelMap };
// 更新:template 必须保持一致
updateParams = { template: EMPHATIC, title: '比价助手', content: '发现更低价格 ¥899', backgroundColor: '#FFE64545' };
6:停止后监听器没解绑,控制器泄漏
现象:反复启动/停止闪控球几次后,点"停止"没有反应,日志里能看到旧回调还在执行。
根因 :stopFloatingBall() 之后控制器对象还活着,on('click') / on('stateChange') 注册的监听没有 off 掉,重复 start 时又注册一遍,回调叠加。
解决 :在 stateChange 收到 STOPPED 时统一释放(见步骤 3 的 releaseController),不要只调 stop:
typescript
releaseController(): void {
this.controller?.off('click');
this.controller?.off('stateChange');
this.controller = undefined;
}
实测:10 次启停循环后,未解绑的写法内存占用持续增长(hitrace 看 ArkTS heap 约 +1.2MB/次),解绑后稳定。
四、闪控球 vs 其他悬浮方案
选型时对比过 4 种方案,最终选了闪控球:
| 方案 | 层级 | 跨应用 | UI 自定义 | 权限 | 适用场景 |
|---|---|---|---|---|---|
| 闪控球 floatingBall | 系统窗口 | ✅ | ❌(系统模板) | USE_FLOAT_BALL(场景受限) | 比价、搜题、记账、抢单、盯盘 |
| 闪控窗 floatingWindow | 系统窗口 | ✅ | ❌(系统模板) | 类似闪控球 | 金融盯盘、直播、歌词等高频展示 |
| 画中画 PiP | 系统窗口 | ✅ | ✅(自定义节点) | 系统能力,无需额外权限 | 视频、导航等持续播放场景 |
| 应用内悬浮 Button | 应用 UI 层 | ❌ | ✅(完全自定义) | 无 | 单页内浮动操作 |
选型决策树:

关键取舍:闪控球 UI 不可自定义,如果你的"悬浮球"需要画自定义面板、放多个按钮,它做不了------那种需求走画中画 PiP(可传自定义节点)或干脆留在应用内。闪控球的价值恰恰在"系统统一形态 + 跨应用",不是自由度。
五、性能与稳定性实测
在真机(Mate 60 Pro,HarmonyOS 7.0.0)上做了几组实测,供参考:
| 指标 | 数据 | 说明 |
|---|---|---|
| create → start 启动耗时 | 约 180ms(中位数) | 含系统渲染,首次略慢(约 260ms) |
| update 内容刷新 | 约 80ms | 用户切到其他应用后能看到价格更新 |
| 常驻内存开销 | 约 30MB(球显示期间) | 系统窗口独立进程,计入应用 footprint |
| 10 次启停循环 | 解绑监听后内存稳定 | 未解绑约 +1.2MB/次(hitrace 采样) |
| 801 设备兼容 | PC 模式 6.1 报错 / 7.0.0 正常 | 必须写进兼容矩阵 |
几个工程上的稳定化实践:
- 控制器单例:一个应用生命周期内只 create 一次(单应用仅允许 1 个闪控球,单设备最多 2 个,超出的会替换最早启动的),重复 create 是浪费。
- 事件先于 start 注册:避免首次点击丢失。
- STOPPED 时统一释放 :收到
stateChange(STOPPED)(包括被系统替换、用户主动收起)时off()解绑并置空控制器,不要假设只有自己的 stop 才会让球消失。 - 启动前检测 + 降级兜底 :
canIUse+isFloatingBallEnabled双检测,不通过走通知栏,保证比价功能本身不丢。
六、避坑清单
| # | 检查项 | 要求 |
|---|---|---|
| 1 | USE_FLOAT_BALL 权限 reason | 写具体场景(比价/搜题/记账/抢单/翻译/盯盘),越宽泛越容易被拒 |
| 2 | 设备检测 | canIUse + isFloatingBallEnabled 双检测,不通过降级通知栏 |
| 3 | context 获取 | 必须在组件内 this.getUIContext().getHostContext(),不要跨层传 |
| 4 | 事件注册时机 | on('click') / on('stateChange') 必须早于 startFloatingBall |
| 5 | restoreMainWindow | 仅允许在 click 事件内调用(或持 AUTO_RESTORE_MAIN_WINDOW 权限) |
| 6 | 模板类型 | 启动后不可修改,STATIC 不支持 update;按最终形态选模板 |
| 7 | 参数限制 | title/content ≤64 字节,icon ≤192KB(建议 128×128px) |
| 8 | 启停释放 | STOPPED 时 off() 解绑 + 控制器置空,避免回调叠加、内存泄漏 |
| 9 | 启动前提 | 应用必须在前台,不能从后台静默启动 |
| 10 | 数量限制 | 单应用仅 1 个,单设备最多 2 个,超出替换最早的 |
边界与已知限制
| 限制项 | 具体表现 | 规避方式 |
|---|---|---|
| 权限申请 | 需 ohos.permission.USE_FLOAT_BALL,场景说明不具体会被拒 |
写明具体使用场景与触发时机 |
| 数量限制 | 单应用仅 1 个,单设备最多 2 个 | 超出会替换最早的那个,设计上要接受被替换 |
| 启动前提 | 应用必须在前台,不能从后台静默启动 | 只在用户交互后启动 |
| 模板固定 | 启动后模板类型不可修改 | 按最终形态选模板,STATIC 不支持 update |
| 参数上限 | title/content 有字节数限制,icon 有大小限制 | 超限制直接失败,出包前校验 |
| 资源释放 | STOPPED 时不解绑会回调叠加、内存泄漏 |
off() 解绑并把控制器置空 |
| 版本依赖 | 部分能力依赖系统版本 | 接入前做能力检测并降级 |
小结
闪控球的接入难点不在 API,而在生命周期与限制:它数量有限、必须前台启动、参数有硬上限、模板启动后不可改,任何一条没考虑都会在线上表现为"时灵时不灵"。
落地时把三件事做死就不会出问题:权限场景写具体、STOPPED 时彻底解绑、参数在出包前校验。剩下的能力边界(数量、前台限制)要在产品层面就接受,别指望技术上绕过。
评论区交流: 你的闪控球权限申请被拒过吗?场景是怎么写的?评论区交流。