在这里插入图片描述

📖 引言

你有没有过这样的经历:在用「民族图鉴」App听民族音乐的时候,想切歌,但又不想切回App——你可能正在刷别的页面,或者正在看别的App。这时候,如果有一个小小的悬浮球一直飘在屏幕边上,点一下就能暂停/切歌,那该多方便?

这就是**悬浮窗(Floating Window)**要解决的问题——让应用的核心功能,在不打开应用的情况下也能快速使用。

悬浮窗大家都不陌生——微信的视频通话悬浮窗、音乐播放器的迷你悬浮球、系统的悬浮球快捷操作……但过去,在鸿蒙上做悬浮窗比较麻烦,要自己处理很多事情:权限、拖动、吸附、窗口管理……

但鸿蒙7(API 26)带来了全新的**闪控悬浮窗(Floating Control)**能力:

  • 系统级支持:一套API,快速实现悬浮窗
  • 拖动吸附:支持拖动、边缘吸附、自动隐藏
  • 展开/收起:小球展开成大面板,收起成小球
  • 低开销:系统优化,不卡、不费电
  • 权限统一管理:统一的悬浮窗权限模型

对于「民族图鉴」来说,悬浮窗有什么用?

  • 音乐悬浮球:听音乐时,随时暂停/切歌,不用切回App
  • 迷你播放器:小窗口显示正在播放的音乐,还有封面和进度
  • 快捷入口:一键回到首页、一键搜索、收藏夹
  • 后台播放控制:应用退到后台后,依然可以控制播放

本文将从悬浮窗的基本概念讲起,深入到权限管理、拖动交互、展开收起、状态同步、性能优化等关键技术,并结合「民族图鉴」项目,带你实现一个音乐悬浮播放球。


🎯 学习目标

完成本文后,你将能够:

  • ✅ 理解悬浮窗的类型与适用场景
  • ✅ 掌握全局悬浮窗、应用内悬浮窗、迷你控制栏的区别
  • ✅ 了解悬浮窗的权限申请与系统限制
  • ✅ 学会使用鸿蒙7的闪控悬浮窗API
  • ✅ 掌握悬浮窗的拖动、吸附、展开/收起交互
  • ✅ 理解悬浮窗与应用的通信:状态同步、操作回调
  • ✅ 掌握悬浮窗的性能优化:渲染开销、内存占用
  • ✅ 实现「民族图鉴」的音乐悬浮播放球
  • ✅ 解决权限被拒、悬浮窗消失、拖动不跟手等常见问题

💡 需求分析

什么是悬浮窗?

悬浮窗,就是悬浮在其他应用之上的小窗口。它不受应用页面的限制,可以一直显示在屏幕上,用户可以随时和它交互。

悬浮窗的核心价值

  1. 快捷操作:不用打开App,就能用核心功能
  2. 多任务:一边用别的App,一边用这个功能
  3. 状态可见:随时知道当前状态(比如正在播放什么歌)
  4. 快速返回:点一下就能回到App

悬浮窗的类型

悬浮窗不是只有一种。根据使用场景和权限等级,可以分成几类:

类型 说明 权限要求 示例
应用内悬浮窗 只在应用内显示,退出应用就消失 不需要特殊权限 应用内的悬浮按钮、迷你播放器
全局悬浮窗 在任何应用上面都能显示 需要悬浮窗权限 微信视频通话悬浮窗、音乐播放器悬浮球
系统级悬浮窗 系统级别的,权限最高 系统应用才能用 系统悬浮球、无障碍悬浮窗
迷你控制栏 在通知栏/控制中心的快捷控件 媒体会话权限 音乐通知栏控制器

「民族图鉴」主要用两种

  1. 应用内悬浮窗:用户在App里浏览时,右下角有个小悬浮球,可以快速控制音乐
  2. 全局悬浮窗:用户退出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 权限被拒的处理

用户拒绝了权限怎么办?不要慌,也不要一直弹——那样会惹烦用户。

正确的做法

  1. 第一次使用时,说明为什么需要这个权限,再申请
  2. 如果用户拒绝了,提供一个"去设置开启"的入口
  3. 不要每次都弹,用户拒绝了就记住,等用户主动点某个按钮再弹
  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();
          }
        }
      }
    });
  }
}

💡 权限申请的艺术

  1. 先说清楚为什么要:用户知道了用处,才更愿意授权
  2. 不要一上来就弹:等用户用到相关功能再弹,转化率更高
  3. 给用户选择权:不想开也没关系,有降级方案
  4. 不要反复弹:用户拒绝了就别一直弹,会被拉黑的

好的权限体验,是让用户觉得"这个权限我愿意给",而不是"不给就不让用"。


步骤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:权限被拒——用户不给悬浮窗权限

现象
用户拒绝了悬浮窗权限,全局悬浮窗用不了。

怎么办

  1. 不要强逼用户:用户不给就算了,不要反复弹
  2. 降级方案:用应用内悬浮窗代替
  3. 解释清楚:告诉用户为什么需要,用了有什么好处
  4. 设置里开启:提供一个入口,让用户随时可以去设置里开
  5. 其他替代:通知栏控制、桌面小组件,都是替代方案

💡 心态要摆正
悬浮窗是"锦上添花"的功能,不是"必不可少"的。
用户愿意开最好,不愿意开也没关系——不要因为这个影响核心功能体验。
好的产品是"用户用得舒服",不是"功能都给我开"。


问题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:和系统手势冲突

现象
从屏幕边缘滑动的时候,经常误触悬浮窗。或者拖动悬浮窗的时候,触发了系统的返回手势。

原因
悬浮窗在屏幕边缘,和系统的边缘手势区域重叠了。

解决方法

  1. 留一定边距:不要完全贴边,留8-12vp的距离
  2. 拖动阈值调大:不是一碰就拖,移动一定距离才算拖动
  3. 判断方向:如果是水平滑动,优先让给系统返回手势
  4. 用户可设置:让用户自己调悬浮窗的位置

系统层面一般也会做处理——比如只有往悬浮窗方向的拖动才算拖动悬浮窗,边缘往内的滑算系统手势。


📝 本章小结

核心知识点

本文从悬浮窗的基本概念讲到实战实现,系统介绍了鸿蒙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大新特性:

  1. 本地文搜图——用CLIP模型和向量检索实现图片搜索
  2. 沉浸光感组件——光随指动,提升空间感和质感
  3. 3DGS端侧重建——高斯溅射技术实现单图3D化
  4. 空间音频——3D声场带来沉浸式听觉体验
  5. 闪控悬浮窗——悬浮球实现快捷后台控制

这些技术代表了移动端的未来发展方向——AI端侧化、3D化、空间化、多模态融合。鸿蒙7把这些前沿技术带到了我们的手机上,让普通开发者也能轻松用上。

接下来,你可以:

  • 在「民族图鉴」项目中实践这些功能
  • 根据项目需要,选择合适的特性集成
  • 持续关注鸿蒙的新特性,保持学习

技术在不断进步,保持好奇心,持续学习,你就能一直走在前面。


🔗 相关链接


💡 提示:悬浮窗是一个"小而精巧"的功能——它很小,但做好了非常体现产品的用心。不要因为它小就轻视它,也不要因为它酷炫就滥用它。找到那个"方便"和"打扰"之间的平衡点,你的悬浮窗就成功了。希望这篇文章能帮你做出用户喜欢的悬浮窗。

Logo

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

更多推荐