HarmonyOS应用《民族图鉴》开发第80篇:闪控悬浮窗——悬浮球与后台播放控制

📖 引言
你有没有过这样的经历:在用「民族图鉴」App听民族音乐的时候,想切歌,但又不想切回App——你可能正在刷别的页面,或者正在看别的App。这时候,如果有一个小小的悬浮球一直飘在屏幕边上,点一下就能暂停/切歌,那该多方便?
这就是**悬浮窗(Floating Window)**要解决的问题——让应用的核心功能,在不打开应用的情况下也能快速使用。
悬浮窗大家都不陌生——微信的视频通话悬浮窗、音乐播放器的迷你悬浮球、系统的悬浮球快捷操作……但过去,在鸿蒙上做悬浮窗比较麻烦,要自己处理很多事情:权限、拖动、吸附、窗口管理……
但鸿蒙7(API 26)带来了全新的**闪控悬浮窗(Floating Control)**能力:
- 系统级支持:一套API,快速实现悬浮窗
- 拖动吸附:支持拖动、边缘吸附、自动隐藏
- 展开/收起:小球展开成大面板,收起成小球
- 低开销:系统优化,不卡、不费电
- 权限统一管理:统一的悬浮窗权限模型
对于「民族图鉴」来说,悬浮窗有什么用?
- 音乐悬浮球:听音乐时,随时暂停/切歌,不用切回App
- 迷你播放器:小窗口显示正在播放的音乐,还有封面和进度
- 快捷入口:一键回到首页、一键搜索、收藏夹
- 后台播放控制:应用退到后台后,依然可以控制播放
本文将从悬浮窗的基本概念讲起,深入到权限管理、拖动交互、展开收起、状态同步、性能优化等关键技术,并结合「民族图鉴」项目,带你实现一个音乐悬浮播放球。
🎯 学习目标
完成本文后,你将能够:
- ✅ 理解悬浮窗的类型与适用场景
- ✅ 掌握全局悬浮窗、应用内悬浮窗、迷你控制栏的区别
- ✅ 了解悬浮窗的权限申请与系统限制
- ✅ 学会使用鸿蒙7的闪控悬浮窗API
- ✅ 掌握悬浮窗的拖动、吸附、展开/收起交互
- ✅ 理解悬浮窗与应用的通信:状态同步、操作回调
- ✅ 掌握悬浮窗的性能优化:渲染开销、内存占用
- ✅ 实现「民族图鉴」的音乐悬浮播放球
- ✅ 解决权限被拒、悬浮窗消失、拖动不跟手等常见问题
💡 需求分析
什么是悬浮窗?
悬浮窗,就是悬浮在其他应用之上的小窗口。它不受应用页面的限制,可以一直显示在屏幕上,用户可以随时和它交互。
悬浮窗的核心价值:
- 快捷操作:不用打开App,就能用核心功能
- 多任务:一边用别的App,一边用这个功能
- 状态可见:随时知道当前状态(比如正在播放什么歌)
- 快速返回:点一下就能回到App
悬浮窗的类型
悬浮窗不是只有一种。根据使用场景和权限等级,可以分成几类:
| 类型 | 说明 | 权限要求 | 示例 |
|---|---|---|---|
| 应用内悬浮窗 | 只在应用内显示,退出应用就消失 | 不需要特殊权限 | 应用内的悬浮按钮、迷你播放器 |
| 全局悬浮窗 | 在任何应用上面都能显示 | 需要悬浮窗权限 | 微信视频通话悬浮窗、音乐播放器悬浮球 |
| 系统级悬浮窗 | 系统级别的,权限最高 | 系统应用才能用 | 系统悬浮球、无障碍悬浮窗 |
| 迷你控制栏 | 在通知栏/控制中心的快捷控件 | 媒体会话权限 | 音乐通知栏控制器 |
「民族图鉴」主要用两种:
- 应用内悬浮窗:用户在App里浏览时,右下角有个小悬浮球,可以快速控制音乐
- 全局悬浮窗:用户退出App后,悬浮球还在,可以继续控制播放
「民族图鉴」的悬浮窗应用场景
场景1:音乐悬浮球——播放控制
这是最常见的悬浮窗场景。用户听音乐的时候:
- 悬浮球一直飘在屏幕边缘
- 点一下:展开成迷你控制面板(暂停/上一首/下一首)
- 点封面:跳转到音乐页
- 拖动:可以拖到屏幕任意位置,松手自动吸附到边缘
- 应用退到后台:悬浮球还在,可以继续控制
比通知栏控制好在哪里?
- 更近:就在屏幕边缘,手指一够就到
- 更快:点一下就操作,不用拉通知栏
- 更直观:有封面图,一眼就知道在放什么
场景2:迷你播放器——小窗口播放
比悬浮球大一点,可以显示更多信息:
- 歌曲封面(大图)
- 歌曲名、歌手
- 播放进度条
- 完整的控制按钮
- 可以拖动位置
适合用户想"看着歌词/封面听歌",但又不想占满整个屏幕的时候。
场景3:快捷入口——一键直达
悬浮球展开后,可以有几个快捷按钮:
- 🏠 回到首页
- 🔍 快速搜索
- ❤️ 我的收藏
- ⚙️ 设置
用户在任何页面,点一下悬浮球就能快速跳转,不用一层层返回。
悬浮窗的设计原则
悬浮窗虽然方便,但也很容易打扰用户。设计时要遵循这些原则:
原则1:不遮挡核心内容
- 默认贴边,尽量少占空间
- 用户可以拖动位置
- 可以自动缩小/半透明
原则2:轻量、克制
- 不要什么功能都往悬浮窗里塞
- 只放最常用的2-3个功能
- 不要弹广告、弹通知
原则3:可以关闭
- 用户不想用的时候,能一键关掉
- 不要关不掉、藏得很深
- 给用户控制权
原则4:性能友好
- 不能卡(特别是拖动的时候)
- 不能太耗电
- 不能占太多内存
💡 核心洞察:悬浮窗是"便利"和"打扰"的平衡点。做好了,用户觉得"真方便";做不好,用户觉得"真烦"。关键在于:用户需要的时候就在,不需要的时候不打扰。
🛠️ 核心实现
步骤1:鸿蒙7的悬浮窗能力
1.1 闪控悬浮窗(Floating Control)
鸿蒙7提供了 FloatingControl 模块,专门用来做悬浮窗。
核心特性:
- 拖动+吸附:手指拖动,松手自动吸附到最近的边缘
- 展开/收起:小球可以展开成面板,面板可以收起成小球
- 多种形态:圆形小球、圆角矩形面板、自定义布局
- 边界检测:自动检测屏幕边界,不会拖出屏幕
- 系统优化:GPU加速渲染,拖动流畅
- 权限统一管理:和系统悬浮窗权限打通
两种模式:
| 模式 | 说明 | 适用场景 |
|---|---|---|
| 应用内模式(In-App) | 只在应用内显示,随应用生命周期 | 应用内的快捷操作 |
| 全局模式(Global) | 在任何应用上都能显示 | 后台播放、全局快捷操作 |
1.2 基本用法——应用内悬浮球
先看一个最简单的应用内悬浮球:
import { FloatingControl } from '@kit.ArkUI';
@Entry
@Component
struct FloatingDemoPage {
@State isShow: boolean = true;
@State isExpanded: boolean = false;
build() {
Stack() {
// 页面内容
Column() {
Text('这是页面内容')
.fontSize(20)
}
.width('100%')
.height('100%')
// 悬浮窗
if (this.isShow) {
FloatingControl({
// 收起状态(小球)
collapsedContent: () => {
Column() {
Image($r('app.media.music_note'))
.width(24)
.height(24)
.fillColor(Color.White)
}
.width(56)
.height(56)
.backgroundColor($r('app.color.primary_color'))
.borderRadius(28)
.justifyContent(FlexAlign.Center)
},
// 展开状态(面板)
expandedContent: () => {
Row({ space: 16 }) {
Image($r('app.media.music_cover'))
.width(40)
.height(40)
.borderRadius(8)
Column({ space: 2 }) {
Text('苗族飞歌')
.fontSize(14)
.fontColor(Color.White)
.maxLines(1)
Text('阿幼朵')
.fontSize(12)
.fontColor('rgba(255,255,255,0.7)')
.maxLines(1)
}
.layoutWeight(1)
Image($r('app.media.play'))
.width(28)
.height(28)
.fillColor(Color.White)
}
.width(240)
.height(56)
.padding({ left: 8, right: 16 })
.backgroundColor($r('app.color.primary_color'))
.borderRadius(28)
.alignItems(VerticalAlign.Center)
},
// 初始位置
initialPosition: { x: '85%', y: '70%' },
// 是否可拖动
draggable: true,
// 是否自动吸附
autoAdsorb: true,
// 吸附方向
adsorbDirection: AdsorbDirection.LEFT_AND_RIGHT,
// 展开/收起状态
isExpanded: this.isExpanded,
// 点击事件
onClick: () => {
this.isExpanded = !this.isExpanded;
},
// 模式:应用内
mode: FloatingMode.IN_APP
})
}
}
.width('100%')
.height('100%')
}
}
就这么简单!定义好收起和展开两种状态的布局,其他的(拖动、吸附、动画)系统都帮你做好了。
1.3 详细属性说明
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| collapsedContent | CustomBuilder | - | 收起状态的内容 |
| expandedContent | CustomBuilder | - | 展开状态的内容 |
| initialPosition | {x, y} | 右下角 | 初始位置,支持百分比和vp |
| draggable | boolean | true | 是否可拖动 |
| autoAdsorb | boolean | true | 是否自动吸附到边缘 |
| adsorbDirection | AdsorbDirection | LEFT_AND_RIGHT | 吸附方向 |
| isExpanded | boolean | false | 是否展开 |
| mode | FloatingMode | IN_APP | 模式:应用内/全局 |
| dragMargin | number | 0 | 拖动时距离边缘的最小边距 |
| adsorbAnimDuration | number | 300 | 吸附动画时长(ms) |
| expandAnimDuration | number | 200 | 展开动画时长(ms) |
| autoHideEdge | boolean | false | 是否自动贴边半隐藏 |
| autoHideDelay | number | 3000 | 自动隐藏延迟(ms) |
AdsorbDirection 枚举:
enum AdsorbDirection {
LEFT, // 只吸附左边
RIGHT, // 只吸附右边
LEFT_AND_RIGHT, // 左右两边(默认)
TOP_AND_BOTTOM, // 上下两边
ALL_EDGES, // 四边都吸附
NONE // 不吸附
}
FloatingMode 枚举:
enum FloatingMode {
IN_APP, // 应用内模式
GLOBAL // 全局模式(需要权限)
}
1.4 事件回调
| 事件 | 说明 |
|---|---|
| onClick | 点击悬浮窗时触发 |
| onDragStart | 开始拖动时触发 |
| onDragMove | 拖动中触发 |
| onDragEnd | 拖动结束时触发 |
| onExpand | 展开时触发 |
| onCollapse | 收起时触发 |
| onAdsorb | 吸附到边缘时触发 |
步骤2:权限申请——全局悬浮窗
应用内悬浮窗不需要权限,但全局悬浮窗需要用户授权。因为全局悬浮窗会显示在其他应用上面,可能会影响用户使用其他App。
2.1 权限声明
在 module.json5 中声明权限:
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.SYSTEM_FLOAT_WINDOW",
"reason": "$string:float_window_permission_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
}
]
}
}
在 string.json 中加上权限说明:
{
"float_window_permission_reason": "用于显示音乐播放悬浮窗,方便您在使用其他应用时控制音乐播放"
}
2.2 动态申请权限
不能只在清单里声明,还要动态申请,让用户手动授权:
import { abilityAccessCtrl, Permissions } from '@kit.AbilityKit';
async function requestFloatWindowPermission(): Promise<boolean> {
const atManager = abilityAccessCtrl.createAtManager();
try {
const result = await atManager.requestPermissionsFromUser(
getContext(),
['ohos.permission.SYSTEM_FLOAT_WINDOW']
);
const granted = result.authResults[0] === 0; // 0 = 授权,-1 = 拒绝
if (granted) {
console.info('悬浮窗权限已授权');
} else {
console.warn('悬浮窗权限被拒绝');
}
return granted;
} catch (e) {
console.error('申请悬浮窗权限失败:', JSON.stringify(e));
return false;
}
}
2.3 权限检查
使用全局悬浮窗前,先检查有没有权限:
async function hasFloatWindowPermission(): Promise<boolean> {
const atManager = abilityAccessCtrl.createAtManager();
try {
const result = await atManager.checkAccessToken(
getContext(),
'ohos.permission.SYSTEM_FLOAT_WINDOW'
);
return result === 0; // 0 = 已授权
} catch (e) {
console.error('检查悬浮窗权限失败:', JSON.stringify(e));
return false;
}
}
2.4 权限被拒的处理
用户拒绝了权限怎么办?不要慌,也不要一直弹——那样会惹烦用户。
正确的做法:
- 第一次使用时,说明为什么需要这个权限,再申请
- 如果用户拒绝了,提供一个"去设置开启"的入口
- 不要每次都弹,用户拒绝了就记住,等用户主动点某个按钮再弹
- 降级方案:没有权限就用应用内悬浮窗,或者用通知栏控制
async function showFloatWindow(): Promise<void> {
const hasPermission = await hasFloatWindowPermission();
if (hasPermission) {
// 有权限,显示全局悬浮窗
showGlobalFloatWindow();
} else {
// 没权限,显示对话框说明
AlertDialog.show({
title: '开启悬浮窗权限',
message: '开启后,您可以在使用其他应用时快速控制音乐播放。悬浮窗仅显示播放控制,不会收集任何信息。',
primaryButton: {
value: '暂不开启',
action: () => {
// 用应用内悬浮窗代替
showInAppFloatWindow();
}
},
secondaryButton: {
value: '去开启',
action: async () => {
const granted = await requestFloatWindowPermission();
if (granted) {
showGlobalFloatWindow();
} else {
showInAppFloatWindow();
}
}
}
});
}
}
💡 权限申请的艺术:
- 先说清楚为什么要:用户知道了用处,才更愿意授权
- 不要一上来就弹:等用户用到相关功能再弹,转化率更高
- 给用户选择权:不想开也没关系,有降级方案
- 不要反复弹:用户拒绝了就别一直弹,会被拉黑的
好的权限体验,是让用户觉得"这个权限我愿意给",而不是"不给就不让用"。
步骤3:拖动与吸附交互
悬浮窗最重要的交互就是拖动和吸附。鸿蒙7的FloatingControl已经内置了这些功能,但要做得好,还有很多细节要注意。
3.1 拖动的手感
好的拖动手感是什么样的?
- 跟手:手指移到哪,悬浮窗就跟到哪,没有延迟
- 不抖动:拖动过程中不抖、不跳
- 有惯性:快速滑动时,松手后能滑一段距离(可选)
- 边界检测:不会拖出屏幕
鸿蒙7的FloatingControl默认就做得不错,但你可以通过一些参数调整手感:
FloatingControl({
// ...
draggable: true,
dragMargin: 8, // 距离边缘8vp,不要贴太近
dragFollowSpeed: 1.0, // 跟随速度,1.0=完全跟手
})
3.2 吸附的策略
松手之后,悬浮窗要自动吸附到边缘。怎么吸附才舒服?
常见的吸附策略:
| 策略 | 说明 | 适用场景 |
|---|---|---|
| 吸附到最近的边 | 哪边近就吸到哪边 | 大多数场景 |
| 只吸附左右边 | 只能吸到左边或右边 | 视频悬浮窗、音乐悬浮球 |
| 只吸到右边 | 永远吸到右边 | 右手操作友好 |
| 吸附到角上 | 吸到四个角 | 小球类型的悬浮窗 |
对于音乐悬浮球,推荐左右两边吸附:
- 用户可以选择放左边还是右边
- 不挡底部的导航栏
- 不挡顶部的状态栏
3.3 边缘自动隐藏
为了减少遮挡,可以让悬浮窗在静止几秒后,自动"藏"一半到屏幕外面:
FloatingControl({
// ...
autoHideEdge: true, // 开启自动贴边隐藏
autoHideDelay: 3000, // 3秒后自动隐藏
autoHideRatio: 0.5, // 隐藏一半
})
效果:
- 用户拖动后,悬浮窗吸附到边缘
- 3秒不操作,自动缩一半到屏幕外面
- 用户点一下,又完整显示出来
- 既减少了遮挡,又保留了可访问性
这种设计在视频类App里很常见——比如视频悬浮窗,不操作的时候就缩到一边,不挡画面。
3.4 拖动阈值
有时候用户点悬浮窗,不小心拖动了一点点,结果算成了"拖动"而不是"点击"。这时候就需要拖动阈值——移动距离小于某个值,算点击,不算拖动。
FloatingControl({
// ...
dragThreshold: 10, // 拖动超过10vp才算拖动,否则算点击
})
10vp是一个比较合理的值——手指点下去难免有一点点移动,太小了容易误判为拖动,太大了又不跟手。
3.5 实战:带吸附的音乐悬浮球
// components/MusicFloatBall.ets
import { FloatingControl, FloatingMode, AdsorbDirection } from '@kit.ArkUI';
import { MusicInfo } from '../models/MusicModels';
import { MusicService } from '../services/MusicService';
@Component
export struct MusicFloatBall {
@State isExpanded: boolean = false;
@State isPlaying: boolean = false;
@State currentMusic: MusicInfo | null = null;
@State show: boolean = false;
private musicService: MusicService = MusicService.getInstance();
aboutToAppear(): void {
// 监听播放状态变化
this.musicService.onPlayStateChange((isPlaying) => {
this.isPlaying = isPlaying;
});
this.musicService.onMusicChange((music) => {
this.currentMusic = music;
this.show = !!music;
});
}
build() {
if (this.show) {
FloatingControl({
collapsedContent: () => {
this.buildCollapsedContent()
},
expandedContent: () => {
this.buildExpandedContent()
},
initialPosition: { x: '85%', y: '65%' },
draggable: true,
autoAdsorb: true,
adsorbDirection: AdsorbDirection.LEFT_AND_RIGHT,
isExpanded: this.isExpanded,
dragMargin: 8,
dragThreshold: 10,
adsorbAnimDuration: 250,
expandAnimDuration: 200,
autoHideEdge: true,
autoHideDelay: 4000,
mode: this.isGlobalMode() ? FloatingMode.GLOBAL : FloatingMode.IN_APP,
onClick: () => {
this.handleClick();
},
onDragStart: () => {
// 拖动开始时,如果是展开状态,先收起来
if (this.isExpanded) {
this.isExpanded = false;
}
}
})
}
}
@Builder
buildCollapsedContent(): void {
Column() {
if (this.currentMusic?.coverUrl) {
Image(this.currentMusic.coverUrl)
.width(40)
.height(40)
.borderRadius(20)
.objectFit(ImageFit.Cover)
} else {
Image($r('app.media.music_note'))
.width(24)
.height(24)
.fillColor(Color.White)
}
}
.width(48)
.height(48)
.backgroundColor($r('app.color.primary_color'))
.borderRadius(24)
.justifyContent(FlexAlign.Center)
.shadow({ radius: 8, color: '#40000000', offsetY: 2 })
}
@Builder
buildExpandedContent(): void {
Row({ space: 12 }) {
if (this.currentMusic?.coverUrl) {
Image(this.currentMusic.coverUrl)
.width(40)
.height(40)
.borderRadius(8)
.objectFit(ImageFit.Cover)
}
Column({ space: 2 }) {
Text(this.currentMusic?.title || '未知歌曲')
.fontSize(13)
.fontColor(Color.White)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.currentMusic?.artist || '未知艺术家')
.fontSize(11)
.fontColor('rgba(255,255,255,0.7)')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width(100)
.alignItems(HorizontalAlign.Start)
Row({ space: 12 }) {
Image($r('app.media.prev'))
.width(20)
.height(20)
.fillColor(Color.White)
.onClick((e) => {
e.stopPropagation();
this.playPrev();
})
Image(this.isPlaying ? $r('app.media.pause') : $r('app.media.play'))
.width(24)
.height(24)
.fillColor(Color.White)
.onClick((e) => {
e.stopPropagation();
this.togglePlay();
})
Image($r('app.media.next'))
.width(20)
.height(20)
.fillColor(Color.White)
.onClick((e) => {
e.stopPropagation();
this.playNext();
})
}
}
.width(260)
.height(56)
.padding({ left: 8, right: 12 })
.backgroundColor($r('app.color.primary_color'))
.borderRadius(28)
.alignItems(VerticalAlign.Center)
.shadow({ radius: 12, color: '#40000000', offsetY: 4 })
}
handleClick(): void {
// 如果是收起状态,点击展开
if (!this.isExpanded) {
this.isExpanded = true;
}
// 如果是展开状态,点击封面区域跳转到音乐页
// (这里简化为点击就跳转,实际可以更精细)
}
togglePlay(): void {
this.musicService.togglePlay();
}
playPrev(): void {
this.musicService.playPrev();
}
playNext(): void {
this.musicService.playNext();
}
isGlobalMode(): boolean {
// 实际项目中根据权限和设置判断
return false;
}
}
设计要点:
- 收起状态:小球显示封面,一目了然
- 展开状态:迷你控制面板,有完整的播放控制
- 自动吸附:左右两边,用户可以选择位置
- 自动隐藏:4秒不操作,缩到边缘
- 拖动时收起:拖动的时候自动收起,不挡视线
- 事件冒泡:按钮的点击事件要 stopPropagation,不然会触发父级的点击
步骤4:悬浮窗与应用的通信
悬浮窗不是孤立的——它需要和应用通信:
- 应用切歌了,悬浮窗要更新歌曲信息
- 用户点了悬浮窗的暂停,应用要暂停播放
- 用户点了悬浮窗的封面,要跳转到应用的音乐页
4.1 应用内悬浮窗的通信
应用内悬浮窗比较简单——它就在页面组件树里,直接用状态管理就行:
方案1:@State/@Link + 事件回调
// 父组件
@Entry
@Component
struct HomePage {
@State currentMusic: MusicInfo | null = null;
@State isPlaying: boolean = false;
build() {
Stack() {
// 页面内容
// ...
// 悬浮窗
MusicFloatBall({
currentMusic: this.currentMusic,
isPlaying: this.isPlaying,
onTogglePlay: () => {
this.isPlaying = !this.isPlaying;
},
onPlayNext: () => {
// 切下一首
}
})
}
}
}
方案2:Service单例 + 事件订阅
如果悬浮窗在很多页面都要用,用Service更方便:
// services/MusicService.ts
export class MusicService {
private static instance: MusicService | null = null;
private listeners: Map<string, Function> = new Map();
private currentMusic: MusicInfo | null = null;
private isPlaying: boolean = false;
static getInstance(): MusicService {
if (!MusicService.instance) {
MusicService.instance = new MusicService();
}
return MusicService.instance;
}
getCurrentMusic(): MusicInfo | null {
return this.currentMusic;
}
getIsPlaying(): boolean {
return this.isPlaying;
}
togglePlay(): void {
this.isPlaying = !this.isPlaying;
this.notifyPlayStateChange();
}
playNext(): void {
// 切下一首逻辑
this.notifyMusicChange();
}
// 订阅播放状态变化
onPlayStateChange(callback: (isPlaying: boolean) => void): string {
const id = Date.now().toString();
this.listeners.set('playState_' + id, callback);
return id;
}
// 订阅歌曲变化
onMusicChange(callback: (music: MusicInfo | null) => void): string {
const id = Date.now().toString();
this.listeners.set('musicChange_' + id, callback);
return id;
}
// 取消订阅
off(listenerId: string): void {
this.listeners.delete(listenerId);
}
private notifyPlayStateChange(): void {
this.listeners.forEach((callback, key) => {
if (key.startsWith('playState_')) {
callback(this.isPlaying);
}
});
}
private notifyMusicChange(): void {
this.listeners.forEach((callback, key) => {
if (key.startsWith('musicChange_')) {
callback(this.currentMusic);
}
});
}
}
悬浮窗组件订阅这些事件,状态变了就自动更新。
4.2 全局悬浮窗的通信
全局悬浮窗就复杂一点——它运行在独立的窗口里,和应用的页面不在同一个进程/组件树里。
通信方式:
| 方式 | 说明 | 适用场景 |
|---|---|---|
| EventHub | 事件总线,发布订阅 | 简单的事件通知 |
| AppStorage/LocalStorage | 全局状态存储 | 状态共享 |
| IPC(进程间通信) | 复杂的跨进程通信 | 复杂数据交互 |
| 通知(Notification) | 系统通知 | 不要求实时的 |
推荐方案:EventHub + AppStorage
简单的状态同步用 AppStorage,事件通知用 EventHub。
// 在应用启动时初始化
AppStorage.setOrCreate('currentMusic', null);
AppStorage.setOrCreate('isPlaying', false);
// 悬浮窗里读取
@StorageLink('currentMusic') currentMusic: MusicInfo | null = null;
@StorageLink('isPlaying') isPlaying: boolean = false;
4.3 从悬浮窗跳转到应用
用户点了悬浮窗,要跳转到应用的指定页面,怎么做?
用深链接(Deep Link)或者路由:
// 悬浮窗里
router.pushUrl({
url: 'pages/MusicPage',
params: { musicId: this.currentMusic?.id }
});
如果应用在后台,就把应用拉到前台,再跳转。
全局悬浮窗打开应用页面:
import { common } from '@kit.AbilityKit';
async function openAppPage(pageUrl: string, params?: Record<string, string>): Promise<void> {
const context = getContext();
try {
// 启动应用(如果在后台就拉到前台)
await context.startAbility({
bundleName: 'com.example.ethnic',
abilityName: 'EntryAbility',
parameters: {
targetPage: pageUrl,
...params
}
});
} catch (e) {
console.error('打开应用失败:', JSON.stringify(e));
}
}
然后在 EntryAbility 里接收参数,跳转到对应页面。
步骤5:性能优化
悬浮窗虽然小,但一直显示在屏幕上,如果做得不好,也会影响性能和续航。
5.1 性能开销分析
悬浮窗的开销来自哪里?
| 开销来源 | 影响 | 说明 |
|---|---|---|
| 渲染开销 | 中 | 一直要渲染,虽然小但一直存在 |
| 动画开销 | 低 | 拖动、吸附、展开收起的动画 |
| 内存占用 | 低 | 悬浮窗本身不大,但如果持有了大对象就不好 |
| 触摸事件处理 | 低 | 监听触摸事件,开销很小 |
总体来说,一个设计良好的悬浮窗开销很小,用户基本感觉不到。但如果乱写,也可能出问题。
5.2 优化策略
策略1:内容要简单,布局要轻量
悬浮窗就那么小,不要搞复杂的布局:
- 不要嵌套太多层
- 不要用重的组件(比如List、Grid)
- 图片不要太大,提前压缩好
- 不要有复杂的动画
策略2:更新频率要控制
比如进度条,不要每秒更新60次——每秒更新1次就够了。
// ❌ 不好:每帧都更新
onFrame(() => {
this.progress = getCurrentProgress();
})
// ✅ 好:每秒更新一次
setInterval(() => {
this.progress = getCurrentProgress();
}, 1000);
策略3:不可见时暂停更新
如果悬浮窗被完全挡住了(比如有应用全屏挡在前面),可以暂停更新。
或者更简单:应用退到后台后,降低更新频率。
策略4:图片要优化
悬浮窗里的封面图:
- 不要用原图,用缩略图
- 大小刚好,不要比显示尺寸大很多
- 做好缓存,不要每次都重新加载
策略5:内存泄漏检查
悬浮窗容易犯的错误:
- 注册了事件但不取消
- 持有Context引用不释放
- 定时器不清理
组件销毁时,一定要清理:
aboutToDisappear(): void {
// 取消事件订阅
if (this.playStateListenerId) {
this.musicService.off(this.playStateListenerId);
}
if (this.musicChangeListenerId) {
this.musicService.off(this.musicChangeListenerId);
}
// 清理定时器
if (this.progressTimer) {
clearInterval(this.progressTimer);
}
// 释放图片资源
// ...
}
💡 性能原则:
悬浮窗是"一直存在"的东西,所以性能要求比普通页面更高——普通页面卡一点,用户退出就好了;悬浮窗卡,用户一直难受。记住:悬浮窗越轻量越好。只放最必要的东西,其他的点击跳转去App里看。
步骤6:实战——音乐悬浮播放球
现在,把前面讲的内容整合起来,在「民族图鉴」里实现一个完整的音乐悬浮球。
6.1 功能清单
核心功能:
- ✅ 显示当前播放歌曲封面(小球状态)
- ✅ 展开显示歌曲信息和播放控制
- ✅ 支持拖动、边缘吸附
- ✅ 自动贴边隐藏
- ✅ 暂停/播放、上一首/下一首
- ✅ 点击跳转到音乐页
- ✅ 应用内/全局两种模式
6.2 完整实现
// components/MusicFloatingWindow.ets
import { FloatingControl, FloatingMode, AdsorbDirection } from '@kit.ArkUI';
import { MusicInfo } from '../models/MusicModels';
import { MusicService } from '../services/MusicService';
import { router } from '@kit.ArkUI';
import { abilityAccessCtrl } from '@kit.AbilityKit';
@Component
export struct MusicFloatingWindow {
@State isExpanded: boolean = false;
@State isPlaying: boolean = false;
@State currentMusic: MusicInfo | null = null;
@State show: boolean = false;
@State isGlobalMode: boolean = false;
private musicService: MusicService = MusicService.getInstance();
private playStateListenerId: string = '';
private musicChangeListenerId: string = '';
aboutToAppear(): void {
this.playStateListenerId = this.musicService.onPlayStateChange((isPlaying) => {
this.isPlaying = isPlaying;
});
this.musicChangeListenerId = this.musicService.onMusicChange((music) => {
this.currentMusic = music;
this.show = !!music;
});
// 初始化状态
this.isPlaying = this.musicService.getIsPlaying();
this.currentMusic = this.musicService.getCurrentMusic();
this.show = !!this.currentMusic;
// 检查全局悬浮窗权限
this.checkGlobalPermission();
}
aboutToDisappear(): void {
if (this.playStateListenerId) {
this.musicService.off(this.playStateListenerId);
}
if (this.musicChangeListenerId) {
this.musicService.off(this.musicChangeListenerId);
}
}
async checkGlobalPermission(): Promise<void> {
try {
const atManager = abilityAccessCtrl.createAtManager();
const result = await atManager.checkAccessToken(
getContext(),
'ohos.permission.SYSTEM_FLOAT_WINDOW'
);
this.isGlobalMode = result === 0;
} catch (e) {
console.error('检查悬浮窗权限失败:', JSON.stringify(e));
this.isGlobalMode = false;
}
}
build() {
if (this.show) {
FloatingControl({
collapsedContent: () => {
this.buildCollapsedView()
},
expandedContent: () => {
this.buildExpandedView()
},
initialPosition: { x: '85%', y: '65%' },
draggable: true,
autoAdsorb: true,
adsorbDirection: AdsorbDirection.LEFT_AND_RIGHT,
isExpanded: this.isExpanded,
dragMargin: 8,
dragThreshold: 10,
adsorbAnimDuration: 250,
expandAnimDuration: 200,
autoHideEdge: true,
autoHideDelay: 4000,
autoHideRatio: 0.5,
mode: this.isGlobalMode ? FloatingMode.GLOBAL : FloatingMode.IN_APP,
onClick: () => {
this.handleMainClick();
},
onDragStart: () => {
if (this.isExpanded) {
this.isExpanded = false;
}
}
})
}
}
@Builder
buildCollapsedView(): void {
Stack() {
// 背景
Circle()
.width(48)
.height(48)
.fill($r('app.color.primary_color'))
.shadow({ radius: 8, color: '#40000000', offsetY: 2 })
// 封面或图标
if (this.currentMusic?.coverUrl) {
Image(this.currentMusic.coverUrl)
.width(44)
.height(44)
.borderRadius(22)
.objectFit(ImageFit.Cover)
} else {
Image($r('app.media.music_note'))
.width(24)
.height(24)
.fillColor(Color.White)
}
// 播放状态指示(小圆点)
if (this.isPlaying) {
Circle()
.width(10)
.height(10)
.fillColor('#4CAF50')
.border({ width: 2, color: Color.White })
.translate({ x: 14, y: -14 })
}
}
.width(48)
.height(48)
}
@Builder
buildExpandedView(): void {
Row({ space: 10 }) {
// 封面
if (this.currentMusic?.coverUrl) {
Image(this.currentMusic.coverUrl)
.width(40)
.height(40)
.borderRadius(8)
.objectFit(ImageFit.Cover)
.onClick((e) => {
e.stopPropagation();
this.navigateToMusicPage();
})
}
// 歌曲信息
Column({ space: 2 }) {
Text(this.currentMusic?.title || '未知歌曲')
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor(Color.White)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.currentMusic?.artist || '未知艺术家')
.fontSize(11)
.fontColor('rgba(255,255,255,0.7)')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width(80)
.alignItems(HorizontalAlign.Start)
// 控制按钮
Row({ space: 10 }) {
Image($r('app.media.skip_previous'))
.width(20)
.height(20)
.fillColor(Color.White)
.onClick((e) => {
e.stopPropagation();
this.handlePrev();
})
Image(this.isPlaying ? $r('app.media.pause_circle') : $r('app.media.play_circle'))
.width(28)
.height(28)
.fillColor(Color.White)
.onClick((e) => {
e.stopPropagation();
this.handleTogglePlay();
})
Image($r('app.media.skip_next'))
.width(20)
.height(20)
.fillColor(Color.White)
.onClick((e) => {
e.stopPropagation();
this.handleNext();
})
}
}
.width(240)
.height(52)
.padding({ left: 6, right: 12 })
.backgroundColor($r('app.color.primary_color'))
.borderRadius(26)
.alignItems(VerticalAlign.Center)
.shadow({ radius: 12, color: '#40000000', offsetY: 4 })
}
handleMainClick(): void {
if (!this.isExpanded) {
// 收起状态:点击展开
this.isExpanded = true;
}
// 展开状态:点击空白处收起
// (因为按钮都stopPropagation了,点按钮不会走到这里)
}
handleTogglePlay(): void {
this.musicService.togglePlay();
}
handlePrev(): void {
this.musicService.playPrev();
}
handleNext(): void {
this.musicService.playNext();
}
navigateToMusicPage(): void {
this.isExpanded = false;
router.pushUrl({
url: 'pages/MusicPage'
});
}
}
6.3 使用方式
在首页(或者应用的根组件)里加一个悬浮窗:
// pages/Index.ets
import { MusicFloatingWindow } from '../components/MusicFloatingWindow';
@Entry
@Component
struct Index {
@State currentIndex: number = 0;
build() {
Stack() {
Tabs({ barPosition: BarPosition.End, index: this.currentIndex }) {
TabContent() {
// 首页
}
TabContent() {
// 百科页
}
TabContent() {
// 音乐页
}
TabContent() {
// 我的页
}
}
.width('100%')
.height('100%')
// 音乐悬浮窗(在所有Tab之上)
MusicFloatingWindow()
}
.width('100%')
.height('100%')
}
}
这样,不管用户在哪个Tab页,都能看到音乐悬浮窗,随时控制播放。
⚠️ 常见问题与解决方案
问题1:权限被拒——用户不给悬浮窗权限
现象:
用户拒绝了悬浮窗权限,全局悬浮窗用不了。
怎么办:
- 不要强逼用户:用户不给就算了,不要反复弹
- 降级方案:用应用内悬浮窗代替
- 解释清楚:告诉用户为什么需要,用了有什么好处
- 设置里开启:提供一个入口,让用户随时可以去设置里开
- 其他替代:通知栏控制、桌面小组件,都是替代方案
💡 心态要摆正:
悬浮窗是"锦上添花"的功能,不是"必不可少"的。
用户愿意开最好,不愿意开也没关系——不要因为这个影响核心功能体验。
好的产品是"用户用得舒服",不是"功能都给我开"。
问题2:悬浮窗消失——切到后台就没了
现象:
应用切到后台,悬浮窗就消失了;回到前台又出来了。
原因:
- 用的是应用内模式(IN_APP),不是全局模式(GLOBAL)
- 或者没有全局悬浮窗权限
- 或者系统为了省电,把悬浮窗杀了
解决:
1. 确认用的是全局模式
mode: FloatingMode.GLOBAL
2. 确认有权限
- 检查权限,没有就申请
- 有权限还是消失,可能是系统限制
3. 后台保活
- 开启后台播放服务
- 用前台服务(Foreground Service)
- 加入后台白名单(引导用户设置)
4. 降级方案
- 如果悬浮窗保活困难,就用通知栏控制
- 通知栏是系统级的,不容易被杀
- 悬浮窗 + 通知栏,双保险
问题3:拖动不跟手——有延迟、卡顿
现象:
拖动悬浮窗的时候,感觉不跟手,有延迟,或者卡顿。
常见原因及解决方案:
原因1:布局太复杂
悬浮窗里嵌套太多、组件太重,渲染慢。
解决:
- 简化布局,减少层级
- 不要用列表、网格等重组件
- 图片不要太大
- 用轻量的组件
原因2:透明像素太多
悬浮窗是透明的,底层要做混合,开销大。
解决:
- 不透明的地方尽量不透明
- 减小悬浮窗的尺寸
- 圆角不要太大(圆角的抗锯齿开销大)
原因3:拖动事件处理太重
onDragMove 里做了太多事情。
解决:
- onDragMove 里只做最必要的事情
- 不要在拖动时做复杂计算
- 不要在拖动时更新UI(除了位置)
原因4:设备性能差
低端设备GPU不行。
解决:
- 低端机关掉拖动动画
- 简化悬浮窗效果
- 或者干脆禁用悬浮窗
问题4:遮挡重要内容
现象:
悬浮窗挡住了页面上的重要按钮或内容。
解决策略:
1. 默认位置选好
- 不要挡在导航栏、标签栏这些地方
- 放右下角或者右边缘,大多数人右手操作
- 离底部导航栏有一定距离
2. 可以拖动
用户觉得挡了,可以自己拖走。
3. 自动隐藏
- 不操作的时候,缩一半到屏幕外面
- 或者变半透明
- 用户碰一下又出来
4. 有关闭按钮
用户不想看,就能关掉。不要做得关不掉。
5. 智能避让
高级一点的做法:检测到下面有可点击的内容,就自动挪位置。这个实现起来比较复杂,一般产品不需要。
💡 设计哲学:
悬浮窗是"客人",不是"主人"。
它应该安安静静待在边上,需要的时候一伸手就能够到,不需要的时候不打扰。
喧宾夺主的悬浮窗,功能再强也是失败的。
问题5:和系统手势冲突
现象:
从屏幕边缘滑动的时候,经常误触悬浮窗。或者拖动悬浮窗的时候,触发了系统的返回手势。
原因:
悬浮窗在屏幕边缘,和系统的边缘手势区域重叠了。
解决方法:
- 留一定边距:不要完全贴边,留8-12vp的距离
- 拖动阈值调大:不是一碰就拖,移动一定距离才算拖动
- 判断方向:如果是水平滑动,优先让给系统返回手势
- 用户可设置:让用户自己调悬浮窗的位置
系统层面一般也会做处理——比如只有往悬浮窗方向的拖动才算拖动悬浮窗,边缘往内的滑算系统手势。
📝 本章小结
核心知识点
本文从悬浮窗的基本概念讲到实战实现,系统介绍了鸿蒙7的闪控悬浮窗能力:
1. 悬浮窗的类型与价值
- 应用内悬浮窗:只在应用内显示,不需要权限
- 全局悬浮窗:跨应用显示,需要权限
- 核心价值:快捷操作、多任务、状态可见、快速返回
2. 鸿蒙7 FloatingControl
- 内置拖动、吸附、展开收起
- 应用内/全局两种模式
- 丰富的属性配置(位置、吸附方向、自动隐藏等)
- 系统优化,拖动流畅
3. 权限管理
- 全局悬浮窗需要 SYSTEM_FLOAT_WINDOW 权限
- 动态申请,用户授权
- 权限被拒的降级方案
- 友好的权限说明,提高授权率
4. 交互设计
- 拖动:跟手、不抖、有阈值
- 吸附:自动贴边,有动画
- 展开收起:点击切换,有过渡动画
- 自动隐藏:不操作时缩到一边
5. 应用通信
- 应用内:状态管理 + Service事件
- 全局:AppStorage + EventHub
- 跳转:Deep Link / 启动Ability
6. 性能优化
- 轻量布局,简化层级
- 控制更新频率
- 不可见时减少更新
- 做好资源释放,避免内存泄漏
最佳实践总结
✅ 克制:少即是多
悬浮窗不是功能入口大全。
只放最常用的1-3个功能。
放太多反而乱,用户也记不住。
小球就一个图标,面板就3个按钮,够了。
更多功能,点进去App里用。
✅ 不打扰:用户需要的时候才出现
默认贴边,不挡核心内容。
不操作的时候自动缩起来。
用户可以拖动,可以关闭。
永远不要"关不掉"。
悬浮窗是服务用户的,不是骚扰用户的。
✅ 权限:先说清楚,再申请
不要一进App就弹权限。
等用户用到了(比如第一次开始播放音乐)再弹。
弹之前先说清楚:为什么要、有什么用、会不会收集信息。
用户拒绝了也没关系,有降级方案。
✅ 性能:越轻量越好
悬浮窗是常驻的,性能要求比普通页面高。
布局简单,图片压缩,更新不要太频繁。
拖动要流畅,不能卡。
内存泄漏要避免。
小功能也要有高质量。
✅ 降级:有当然好,没有也能用
悬浮窗是加分项,不是必选项。
- 有权限 → 全局悬浮窗
- 没权限 → 应用内悬浮窗
- 都不行 → 通知栏控制
核心功能永远不受影响。
下一步预告
恭喜你!完成了鸿蒙7新特性系列的最后一篇文章。
回顾一下,我们一起学习了鸿蒙7的5大新特性:
- 本地文搜图——用CLIP模型和向量检索实现图片搜索
- 沉浸光感组件——光随指动,提升空间感和质感
- 3DGS端侧重建——高斯溅射技术实现单图3D化
- 空间音频——3D声场带来沉浸式听觉体验
- 闪控悬浮窗——悬浮球实现快捷后台控制
这些技术代表了移动端的未来发展方向——AI端侧化、3D化、空间化、多模态融合。鸿蒙7把这些前沿技术带到了我们的手机上,让普通开发者也能轻松用上。
接下来,你可以:
- 在「民族图鉴」项目中实践这些功能
- 根据项目需要,选择合适的特性集成
- 持续关注鸿蒙的新特性,保持学习
技术在不断进步,保持好奇心,持续学习,你就能一直走在前面。
🔗 相关链接
- 项目源码: GitCode 仓库
- 鸿蒙悬浮窗开发: 官方文档
- 悬浮窗权限: 官方文档
- 窗口管理: 官方文档
💡 提示:悬浮窗是一个"小而精巧"的功能——它很小,但做好了非常体现产品的用心。不要因为它小就轻视它,也不要因为它酷炫就滥用它。找到那个"方便"和"打扰"之间的平衡点,你的悬浮窗就成功了。希望这篇文章能帮你做出用户喜欢的悬浮窗。
更多推荐




所有评论(0)