HarmonyOS 闪控窗入门:做一个跨应用可查看的任务状态小窗【鸿蒙心迹】

👋 你好,欢迎来到我的博客!我是【菜鸟学鸿蒙】
我是一名在路上的移动端开发者,正从传统“小码农”转向鸿蒙原生开发的进阶之旅。为了把学习过的知识沉淀下来,也为了和更多同路人互相启发,我决定把探索 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 } 确保引用清空是防止这类问题的标准做法。
十一、实际项目中的排查顺序
遇到悬浮窗无法显示或行为异常时,建议按以下顺序排查:
- 权限 — 确认
module.json5里有SYSTEM_FLOAT_WINDOW声明,且运行时已获得用户授权; - API Level — 确认当前 compileSdkVersion 和 targetSdkVersion 满足接口要求(
createWindow需要 API 9+); - Context 类型 — 确认传入的是
UIAbilityContext而不是其他 Context; - 同名窗口 — 确认没有重复创建同名窗口,先尝试
findWindow检查是否已存在; - 页面路径 — 确认
loadContent的路径相对于src/main/ets/正确,无多余后缀; - 坐标和尺寸 — 确认
moveWindowTo/resize传入的是物理像素,位置没有超出屏幕范围; showWindow调用时机 — 确认loadContent完成之后再调用showWindow;- 资源释放 — 确认 Ability 退出时正确调用了
destroyWindow。
开发经验总结
- 闪控窗的核心是独立窗口层级,解决的是 Dialog 无法跨应用存活这一根本限制,理解这个定位之后,使用场景就很自然了;
- 权限申请和声明是最容易被漏掉的第一步,一定要在
module.json5里提前配好; - 数据流用
AppStorage做桥接,主业务推数据,悬浮窗被动响应,这个架构简单且够用; - 窗口的创建和销毁要配对管理,在
onDestroy里做清理,防止孤立窗口和资源泄漏; - 坐标和尺寸单位是 px,这一点和 ArkUI 常用的 vp 不一样,换算时记得用
densityPixels。
如果你正在开发一个有后台长时任务的 App,可以观察一下:当用户切出应用后,你现有的状态展示方案(比如通知栏通知)和悬浮窗各自在哪些场景下更合适——这两者的选择边界,往往是被忽略但值得细想的设计决策。
📝 写在最后
如果你觉得这篇文章对你有帮助,或者有任何想法、建议,欢迎在评论区留言交流!你的每一个点赞 👍、收藏 ⭐、关注 ❤️,都是我持续更新的最大动力!
我是一个在代码世界里不断摸索的小码农,愿我们都能在成长的路上越走越远,越学越强!
感谢你的阅读,我们下篇文章再见~👋
✍️ 作者:菜鸟不学编程
🧵 本文原创,转载请注明出处。
更多推荐




所有评论(0)