HarmonyOS APP悬浮窗:从权限申请到拖拽交互,合规开发悬浮窗应用

举报
Jack20 发表于 2026/06/20 15:04:31 2026/06/20
【摘要】 HarmonyOS APP悬浮窗:从权限申请到拖拽交互,合规开发悬浮窗应用📌 核心要点:悬浮窗是"浮在所有应用之上"的特殊窗口,权限管控严格、交互设计敏感,合规开发是第一要务一、背景与动机你一定见过这样的场景:打视频电话时,切到别的应用,视频画面会变成一个小浮窗悬在屏幕角落;用导航时,切出去回微信,导航箭头也变成了一个小浮窗。这就是悬浮窗的典型应用。悬浮窗(Float Window)是一种...

HarmonyOS APP悬浮窗:从权限申请到拖拽交互,合规开发悬浮窗应用

📌 核心要点:悬浮窗是"浮在所有应用之上"的特殊窗口,权限管控严格、交互设计敏感,合规开发是第一要务


一、背景与动机

你一定见过这样的场景:打视频电话时,切到别的应用,视频画面会变成一个小浮窗悬在屏幕角落;用导航时,切出去回微信,导航箭头也变成了一个小浮窗。这就是悬浮窗的典型应用。

悬浮窗(Float Window)是一种特殊类型的窗口,它可以浮在所有应用之上,不受当前应用切换的影响。这种"永远在线"的特性让它非常适合:视频通话、实时导航、快捷工具、系统通知等场景。

但悬浮窗也是一把双刃剑。试想一下,如果任何应用都能随意弹出悬浮窗,你的手机屏幕会被各种广告浮窗淹没——这体验简直灾难。所以 HarmonyOS 对悬浮窗的权限管控非常严格,开发时必须遵循合规要求。

这篇文章,咱们就把悬浮窗开发从头到尾讲清楚:权限怎么申请、窗口怎么创建、拖拽怎么实现、通信怎么做、合规怎么保证。


二、核心原理

2.1 悬浮窗与普通窗口的区别

2.2 悬浮窗权限申请流程

悬浮窗权限是系统级权限,不能自动获取,必须由用户手动授权。整个流程如下:

2.3 悬浮窗合规要点

合规要求

说明

违规后果

必须申请权限

ohos.permission.SYSTEM_FLOAT_WINDOW

无法创建悬浮窗

用户主动授权

不能静默获取,必须弹窗请求

应用审核不通过

不可遮挡关键区域

不能遮挡状态栏、导航栏、紧急呼叫按钮

用户体验差,可能被投诉

提供关闭入口

悬浮窗必须有明显的关闭按钮

应用审核不通过

尺寸限制

不能占满整个屏幕

系统可能拒绝创建

内容合规

不能展示广告、诱导点击

应用下架


三、代码实战

3.1 悬浮窗权限申请与创建

这是完整的悬浮窗权限申请和创建流程:

// FloatingWindowAbility.ets
import { UIAbility, AbilityConstant, WindowStage } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { abilityAccessCtrl, bundleManager, Permissions } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

// 悬浮窗权限
const FLOAT_WINDOW_PERMISSION: Permissions = 'ohos.permission.SYSTEM_FLOAT_WINDOW';

export default class FloatingWindowAbility extends UIAbility {
private floatWindow: window.Window | null = null;

onWindowStageCreate(windowStage: WindowStage): void {
windowStage.loadContent('pages/Index');
}

/**
* 检查悬浮窗权限
*/
private async checkFloatWindowPermission(): Promise<boolean> {
try {
const atManager = abilityAccessCtrl.createAtManager();
const bundleInfo = await bundleManager.getBundleInfoForSelf(
bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION
);
const grantStatus = await atManager.checkAccessToken(
bundleInfo.appInfo.accessTokenId,
FLOAT_WINDOW_PERMISSION
);
return grantStatus === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED;
} catch (e) {
console.error('[FloatWindow] 检查权限失败');
return false;
}
}

/**
* 请求悬浮窗权限
*/
private async requestFloatWindowPermission(): Promise<boolean> {
try {
const atManager = abilityAccessCtrl.createAtManager();
const result = await atManager.requestPermissionsFromUser(
this.context,
[FLOAT_WINDOW_PERMISSION]
);
// 用户授权结果
const authResult = result.authResults[0];
return authResult === 0; // 0 表示已授权
} catch (e) {
console.error('[FloatWindow] 请求权限失败');
return false;
}
}

/**
* 创建悬浮窗(完整流程)
*/
async createFloatingWindow(): Promise<void> {
// 第一步:检查权限
const hasPermission = await this.checkFloatWindowPermission();
if (!hasPermission) {
// 第二步:请求权限
const granted = await this.requestFloatWindowPermission();
if (!granted) {
console.warn('[FloatWindow] 用户拒绝授权悬浮窗权限');
// 提示用户去设置页手动开启
this.promptUserToSettings();
return;
}
}

// 第三步:创建悬浮窗
try {
this.floatWindow = await window.create(
this.context,
'floatWindow',
window.WindowType.TYPE_FLOAT
);

// 第四步:配置悬浮窗属性
await this.configureFloatWindow(this.floatWindow);

// 第五步:加载内容并显示
await this.floatWindow.setUIContent('pages/FloatWindowContent');
this.floatWindow.showWindow();

console.info('[FloatWindow] 悬浮窗创建成功');
} catch (e) {
const err = e as BusinessError;
console.error(`[FloatWindow] 创建悬浮窗失败: ${err.code} - ${err.message}`);
}
}

/**
* 配置悬浮窗属性
*/
private async configureFloatWindow(floatWin: window.Window): Promise<void> {
// 设置悬浮窗大小(不能太大,建议不超过屏幕的1/3)
const display = window.getDefaultDisplaySync();
const screenWidth = display.width;
const screenHeight = display.height;
const floatWidth = Math.floor(screenWidth * 0.3); // 屏幕宽度的30%
const floatHeight = Math.floor(screenHeight * 0.25); // 屏幕高度的25%

await floatWin.resize(floatWidth, floatHeight);

// 设置悬浮窗位置(右上角,留出状态栏空间)
const statusBarHeight = 80; // 状态栏高度近似值
await floatWin.moveWindowTo(
screenWidth - floatWidth - 20, // 右边距20
statusBarHeight + 20 // 顶部距状态栏20
);

// 设置悬浮窗背景透明(圆角效果需要)
floatWin.setWindowBackgroundColor('#00000000');

// 设置不可触摸区域外的触摸穿透
floatWin.setTouchable(true);
}

/**
* 提示用户去设置页手动开启权限
*/
private promptUserToSettings(): void {
// 在UI层显示提示对话框
AppStorage.setOrCreate('showPermissionTip', true);
console.info('[FloatWindow] 请引导用户去设置页开启悬浮窗权限');
}

/**
* 销毁悬浮窗
*/
destroyFloatingWindow(): void {
if (this.floatWindow) {
this.floatWindow.destroyWindow();
this.floatWindow = null;
console.info('[FloatWindow] 悬浮窗已销毁');
}
}

onWindowStageDestroy(): void {
this.destroyFloatingWindow();
}
}

3.2 悬浮窗拖拽交互

悬浮窗最核心的交互就是拖拽——用户可以随意拖动浮窗到屏幕任意位置。这个示例实现完整的拖拽功能:

// FloatWindowDrag.ets
import { window } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';

@Entry
@Component
struct FloatWindowDragPage {
// 拖拽偏移量
@State offsetX: number = 0;
@State offsetY: number = 0;
// 上次触摸位置
private lastX: number = 0;
private lastY: number = 0;
// 是否正在拖拽
private isDragging: boolean = false;
// 悬浮窗引用
private floatWin: window.Window | null = null;

aboutToAppear(): void {
// 获取悬浮窗实例
const context = getContext(this) as common.UIAbilityContext;
try {
this.floatWin = window.findWindow('floatWindow');
} catch (e) {
console.error('[FloatDrag] 获取悬浮窗失败');
}
}

build() {
Column() {
// 拖拽手柄区域
Row() {
// 拖拽指示图标
Text('⋮⋮')
.fontSize(14)
.fontColor('#888')

Blank()

// 关闭按钮(合规要求:必须提供关闭入口)
Text('✕')
.fontSize(16)
.fontColor('#888')
.padding(4)
.onClick(() => {
this.closeFloatWindow();
})
}
.width('100%')
.height(30)
.padding({ left: 8, right: 8 })
.backgroundColor('#2a2a3e')
.borderRadius({ topLeft: 12, topRight: 12 })

// 悬浮窗内容区域
Column({ space: 8 }) {
Text('🎵 正在播放')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#fff')

Text('歌曲名称')
.fontSize(12)
.fontColor('#aaa')

// 播放控制
Row({ space: 16 }) {
Text('⏮')
.fontSize(20)
.onClick(() => this.prevTrack())
Text('⏯')
.fontSize(24)
.onClick(() => this.togglePlay())
Text('⏭')
.fontSize(20)
.onClick(() => this.nextTrack())
}
.margin({ top: 4 })
}
.width('100%')
.padding(12)
.backgroundColor('#1a1a2e')
.borderRadius({ bottomLeft: 12, bottomRight: 12 })
}
.width('100%')
.borderRadius(12)
.shadow({ radius: 16, color: '#00000066', offsetY: 4 })
// 拖拽手势
.gesture(
GestureGroup(GestureMode.Parallel,
// 拖拽手势
PanGesture({ fingers: 1, distance: 2 })
.onActionStart((event: GestureEvent) => {
this.isDragging = true;
this.lastX = event.offsetX;
this.lastY = event.offsetY;
})
.onActionUpdate((event: GestureEvent) => {
if (!this.isDragging || !this.floatWin) return;

// 计算偏移量
const deltaX = event.offsetX - this.lastX;
const deltaY = event.offsetY - this.lastY;

// 获取当前窗口位置
const props = this.floatWin.getWindowProperties();
const currentLeft = props.windowRect.left;
const currentTop = props.windowRect.top;

// 移动窗口到新位置
const newLeft = currentLeft + deltaX;
const newTop = currentTop + deltaY;

this.floatWin.moveWindowTo(newLeft, newTop);

// 更新上次位置
this.lastX = event.offsetX;
this.lastY = event.offsetY;
})
.onActionEnd(() => {
this.isDragging = false;
// 可选:吸附到屏幕边缘
this.snapToEdge();
})
)
)
}

/**
* 吸附到屏幕边缘
* 拖拽结束后,自动吸附到最近的左/右边缘
*/
private snapToEdge(): void {
if (!this.floatWin) return;
const props = this.floatWin.getWindowProperties();
const display = window.getDefaultDisplaySync();
const screenWidth = display.width;
const currentLeft = props.windowRect.left;
const windowWidth = props.windowRect.width;

// 判断更靠近左边还是右边
const center = currentLeft + windowWidth / 2;
const targetLeft = center < screenWidth / 2 ? 10 : screenWidth - windowWidth - 10;

// 动画移动到边缘
this.floatWin.moveWindowTo(targetLeft, props.windowRect.top);
}

/**
* 关闭悬浮窗
*/
private closeFloatWindow(): void {
if (this.floatWin) {
this.floatWin.hideWindow();
// 通知主窗口悬浮窗已关闭
AppStorage.setOrCreate('floatWindowVisible', false);
}
}

private prevTrack(): void {
console.info('[FloatDrag] 上一首');
}

private togglePlay(): void {
console.info('[FloatDrag] 播放/暂停');
}

private nextTrack(): void {
console.info('[FloatDrag] 下一首');
}
}

3.3 悬浮窗与主窗口通信

悬浮窗和主窗口的通信是多窗口通信的特殊场景,因为悬浮窗可能在前台也可能在后台,通信需要更健壮:

// FloatWindowCommunication.ets
import { emitter } from '@kit.BasicServicesKit';
import { window } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';

// 通信事件ID
const EVENT_MUSIC_STATE = 20001; // 音乐状态同步
const EVENT_MUSIC_CONTROL = 20002; // 音乐控制指令
const EVENT_FLOAT_ACTION = 20003; // 悬浮窗动作通知

// ===== 主窗口页面 =====
@Entry
@Component
struct MainWindowWithFloatPage {
@State songName: string = '夜曲';
@State artist: string = '周杰伦';
@State isPlaying: boolean = false;
@State floatWindowVisible: boolean = false;

aboutToAppear(): void {
// 监听悬浮窗的控制指令
emitter.on({ eventId: EVENT_MUSIC_CONTROL }, (eventData) => {
const action = eventData.data?.action as string;
switch (action) {
case 'play':
this.isPlaying = true;
break;
case 'pause':
this.isPlaying = false;
break;
case 'next':
this.nextTrack();
break;
case 'prev':
this.prevTrack();
break;
}
});

// 监听悬浮窗动作
emitter.on({ eventId: EVENT_FLOAT_ACTION }, (eventData) => {
const action = eventData.data?.action as string;
if (action === 'close') {
this.floatWindowVisible = false;
} else if (action === 'open') {
this.floatWindowVisible = true;
}
});

// 监听 AppStorage 变化
AppStorage.setOrCreate('floatWindowVisible', false);
}

aboutToDisappear(): void {
emitter.off(EVENT_MUSIC_CONTROL);
emitter.off(EVENT_FLOAT_ACTION);
}

build() {
Column({ space: 20 }) {
Text('音乐播放器')
.fontSize(24)
.fontWeight(FontWeight.Bold)

// 当前播放信息
Column({ space: 8 }) {
Text(this.songName)
.fontSize(20)
.fontWeight(FontWeight.Medium)
Text(this.artist)
.fontSize(14)
.fontColor('#aaa')
}
.padding(20)
.borderRadius(16)
.backgroundColor('#1a1a2e')
.width('80%')

// 播放控制
Row({ space: 24 }) {
Text('⏮').fontSize(28).onClick(() => this.prevTrack())
Text(this.isPlaying ? '⏸' : '▶').fontSize(36).onClick(() => this.togglePlay())
Text('⏭').fontSize(28).onClick(() => this.nextTrack())
}

// 悬浮窗控制
Button(this.floatWindowVisible ? '隐藏悬浮窗' : '显示悬浮窗')
.width('80%')
.backgroundColor(this.floatWindowVisible ? '#FF9800' : '#4CAF50')
.onClick(() => this.toggleFloatWindow())
}
.width('100%')
.height('100%')
.backgroundColor('#0d0d1a')
.foregroundColor('#fff')
.justifyContent(FlexAlign.Center)
}

private togglePlay(): void {
this.isPlaying = !this.isPlaying;
// 同步状态给悬浮窗
emitter.emit({ eventId: EVENT_MUSIC_STATE }, {
data: { isPlaying: this.isPlaying, songName: this.songName, artist: this.artist }
});
}

private nextTrack(): void {
// 切换下一首
this.songName = '晴天';
this.artist = '周杰伦';
emitter.emit({ eventId: EVENT_MUSIC_STATE }, {
data: { isPlaying: this.isPlaying, songName: this.songName, artist: this.artist }
});
}

private prevTrack(): void {
this.songName = '稻香';
this.artist = '周杰伦';
emitter.emit({ eventId: EVENT_MUSIC_STATE }, {
data: { isPlaying: this.isPlaying, songName: this.songName, artist: this.artist }
});
}

/**
* 切换悬浮窗显示/隐藏
*/
private toggleFloatWindow(): void {
if (this.floatWindowVisible) {
// 隐藏悬浮窗
try {
const floatWin = window.findWindow('floatWindow');
floatWin.hideWindow();
this.floatWindowVisible = false;
} catch (e) {
console.error('[MainWin] 隐藏悬浮窗失败');
}
} else {
// 显示悬浮窗(如果已创建则直接show,否则创建)
try {
const floatWin = window.findWindow('floatWindow');
floatWin.showWindow();
this.floatWindowVisible = true;
} catch (e) {
// 悬浮窗未创建,需要创建
this.createFloatWindow();
}
}
}

/**
* 创建悬浮窗
*/
private async createFloatWindow(): Promise<void> {
const context = getContext(this) as common.UIAbilityContext;
try {
const floatWin = await window.create(context, 'floatWindow', window.WindowType.TYPE_FLOAT);
const display = window.getDefaultDisplaySync();
const floatWidth = Math.floor(display.width * 0.35);
const floatHeight = 120;

await floatWin.resize(floatWidth, floatHeight);
await floatWin.moveWindowTo(display.width - floatWidth - 20, 100);
floatWin.setWindowBackgroundColor('#00000000');
await floatWin.setUIContent('pages/FloatWindowDrag');
floatWin.showWindow();

this.floatWindowVisible = true;

// 同步当前状态给悬浮窗
emitter.emit({ eventId: EVENT_MUSIC_STATE }, {
data: { isPlaying: this.isPlaying, songName: this.songName, artist: this.artist }
});
} catch (e) {
console.error('[MainWin] 创建悬浮窗失败');
}
}
}


四、踩坑与注意事项

4.1 权限被拒的优雅降级

:用户拒绝了悬浮窗权限,应用直接崩溃或功能完全不可用。

悬浮窗是"锦上添花"的功能,不应该成为应用的"硬依赖"。正确的做法是提供降级方案:

// ✅ 正确:权限被拒后提供降级方案
async showMusicControl(): Promise<void> {
const hasPermission = await this.checkFloatWindowPermission();
if (hasPermission) {
// 有权限:使用悬浮窗
this.createFloatWindow();
} else {
// 无权限:使用通知栏控制(降级方案)
this.showNotificationControl();
}
}

4.2 悬浮窗尺寸的合理范围

:悬浮窗太大遮挡内容,太小又看不清。

建议的尺寸范围:

最小尺寸:不低于 100×100 vp,否则内容无法展示

最大尺寸:不超过屏幕的 1/3,否则失去"浮窗"的意义

推荐尺寸:屏幕宽度的 25%~35%,高度的 15%~25%

4.3 拖拽时的边界检查

:拖拽悬浮窗时没有做边界检查,窗口被拖出屏幕外。

// ❌ 错误:没有边界检查,窗口可能被拖出屏幕
floatWin.moveWindowTo(newLeft, newTop);

// ✅ 正确:添加边界检查
private moveWithBoundsCheck(floatWin: window.Window, left: number, top: number): void {
const display = window.getDefaultDisplaySync();
const props = floatWin.getWindowProperties();
const width = props.windowRect.width;
const height = props.windowRect.height;

// 限制在屏幕范围内
const clampedLeft = Math.max(0, Math.min(left, display.width - width));
const clampedTop = Math.max(0, Math.min(top, display.height - height));

floatWin.moveWindowTo(clampedLeft, clampedTop);
}

4.4 悬浮窗与键盘冲突

:主窗口输入框获焦弹出键盘时,悬浮窗位置被顶上去或被键盘遮挡。

解决方案:监听避让区域变化,动态调整悬浮窗位置:

// 监听主窗口的避让区域变化
mainWindow.on('avoidAreaChange', (data) => {
if (data.type === window.AvoidAreaType.TYPE_KEYBOARD) {
const keyboardHeight = data.area.bottom;
// 如果悬浮窗在键盘区域,向上移动
this.adjustFloatWindowPosition(keyboardHeight);
}
});

4.5 悬浮窗的合规红线

以下行为是绝对禁止的,一旦违规可能导致应用被下架:

1. 广告悬浮窗:悬浮窗不能用于展示广告

2. 诱导点击:悬浮窗的关闭按钮不能故意做得很小或难以点击

3. 强制显示:不能在用户关闭悬浮窗后自动重新弹出

4. 遮挡紧急功能:不能遮挡紧急呼叫、SOS等关键功能入口

5. 后台静默创建:不能在后台偷偷创建悬浮窗,必须在用户主动操作后创建


五、HarmonyOS 6 适配

5.1 API 变化

特性

HarmonyOS 5.0

HarmonyOS 6.0

悬浮窗权限

SYSTEM_FLOAT_WINDOW

权限不变,但审核更严格

窗口圆角

手动设置透明背景

新增 setWindowCornerRadius API

拖拽能力

手动实现

新增 setWindowDraggable 系统拖拽

吸附能力

手动实现

新增 setWindowSnapToEdge 系统吸附

悬浮窗动画

手动实现

新增窗口过渡动画 API

5.2 迁移要点

1. 权限审核:HarmonyOS 6 对悬浮窗权限的审核更加严格,需要提供详细的使用说明

2. 系统拖拽:新增的 setWindowDraggable 可以替代手动拖拽实现,代码更简洁

3. 圆角支持:新增 setWindowCornerRadius 可以直接设置窗口圆角,不再需要透明背景的 hack

4. 动画过渡:创建和销毁悬浮窗时建议添加过渡动画

5.3 兼容性写法

// 兼容 HarmonyOS 5.0 和 6.0 的悬浮窗配置
private async configureFloatWindowCompat(floatWin: window.Window): Promise<void> {
const display = window.getDefaultDisplaySync();
const floatWidth = Math.floor(display.width * 0.3);
const floatHeight = 120;

await floatWin.resize(floatWidth, floatHeight);
await floatWin.moveWindowTo(display.width - floatWidth - 20, 100);

// HarmonyOS 5.0:使用透明背景实现圆角
floatWin.setWindowBackgroundColor('#00000000');

// HarmonyOS 6.0:使用新的圆角 API
// if (deviceInfo.osFullName >= '6.0.0') {
// floatWin.setWindowCornerRadius(16);
// floatWin.setWindowDraggable(true); // 系统拖拽
// floatWin.setWindowSnapToEdge(true); // 系统吸附
// }
}


六、总结

知识点

核心内容

关键API

悬浮窗权限

必须申请且用户手动授权

ohos.permission.SYSTEM_FLOAT_WINDOW

窗口创建

权限检查→创建→配置→显示

window.create, TYPE_FLOAT

窗口配置

大小、位置、背景、触摸

resize, moveWindowTo, setTouchable

拖拽交互

Pan手势+moveWindowTo

PanGesture, moveWindowTo

边缘吸附

拖拽结束后自动吸附到边缘

自行计算位置

窗口通信

Emitter事件+AppStorage状态

emitter.emit/on, AppStorage

合规开发

关闭入口、尺寸限制、内容合规

无特定API,开发规范

优雅降级

权限被拒时提供替代方案

通知栏控制等

一句话总结:悬浮窗开发三步走——先申请权限(合规第一),再创建配置(尺寸位置合理),最后实现交互(拖拽+通信)。记住,悬浮窗是"服务型"窗口,不是"广告型"窗口,用户随时可以关掉它,这才是正确的悬浮窗姿势。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。