【共创稿事节】HarmonyOS 7 闪控窗实战:给 App 造一个"影分身"——悬浮在其他 App 上持续干活,还能一键缩成闪控球
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 版)。
二、先理清系统规则(这部分最值钱)
官方《闪控窗开发指导》"约束与限制"原文写得很硬:
- 前台启动门禁:基于安全考虑,仅允许应用在前台时启动闪控窗,且需持有
ohos.permission.FLOAT_VIEW权限。后台调start()直接抛1300033。 - 单实例:同一个应用只能启动一个闪控窗,启动多个返回
1300033;重复 start 已在启动中的闪控窗返回1300030。 - 互斥:应用已启动闪控球或画中画窗口时,无法启动闪控窗,需先停止(报
1300034)。 - 绑定的前提:
floatView.bind()要求两个控制器均未启动时调用;绑定后调用任一控制器的启动接口会同时创建两个窗口(同一时刻只展示其中一个);调用任一停止接口会同时销毁两个窗口。 - 最小化按钮的行为由绑定状态决定:未绑定时点最小化 → 收起到侧边栏;绑定后点缩小 → 切换为闪控球。
交互方式官方一共给了五种:拖动热区拖拽(自动避让系统组件)、标题栏按钮、拖到屏幕底部中部垃圾桶删除、进入系统侧边栏暂存、与闪控球点击互切。
三、工程准备
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;
}
}
}
五、跑起来:验证清单
- 真机连接 DevEco Studio 点 Run。首次启动会连续弹两条权限弹窗(闪控窗、闪控球),都点"允许"。
- 点 ①:屏幕出现白色圆角小窗,秒表开始跳动。
- 按 Home 回桌面,再打开"设置"等其他 App——小窗依然悬浮在别的应用之上,秒表照跳。
- 按住小窗拖到屏幕底部中部:出现垃圾桶区域,松手即删除(对应
DUMPSTER_STOP)。重新点 ① 再来。 - 点标题栏最小化按钮:因为没绑球,窗口收进系统侧边栏暂存(状态
IN_SIDEBAR),从侧边栏可再拖出。 - 停止后点 ②:屏幕上闪控窗出现,同时边缘出现闪控球。点闪控球 → 展开成闪控窗;点闪控窗缩小按钮 → 缩回闪控球,来回互切(状态
IN_FLOATING_BALL)。此时标题栏最小化不再进侧边栏,而是缩回球。 - 点 ③ 全部清理干净。
六、踩坑实录(每一刀都是真金白银换来的)
坑 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)监听球的启停——搜题、抢单入口就是这么做的。
欢迎在评论区交流你在真机上遇到的闪控窗问题。如果这篇文章帮到了你,点个赞 + 收藏再走~
更多推荐

所有评论(0)