在这里插入图片描述

摘要

本文围绕 HarmonyOS(鸿蒙)中的**闪控窗(悬浮窗 / 胶囊)**展开,讲清从权限申请、窗口创建、内容加载到销毁的完整接入链路。全文先说明闪控窗的适用场景与常见踩坑点,再用一张规格表界定它的能力边界,随后给出可直接复用的 TypeScript 核心代码,包括权限被拒后的设置页兜底引导、横竖屏切换时基于「相对比例 + 安全区」的重新定位,以及用 Preferences 持久化浮窗坐标并在启动时还原。最后总结落地要点:权限先申请、销毁要配对、尺寸要克制、重交互下沉。适合正在为鸿蒙应用接入全局轻量入口的开发者参考。

目录

一、背景与痛点

很多场景下,用户不想每次都回桌面找入口:听歌想切歌、扫码想快速调起、计时想随时看秒数。把这些高频操作做成**闪控窗(悬浮窗 / 胶囊)**浮在任意界面之上,是鸿蒙里很讨喜的交互。它把「一次点击」压缩成「零次跳转」,用户在任何 App 里都能直接触达,体验提升非常直观。

但接入时我踩过不少坑:权限申请被拒后没有兜底提示、窗口层级盖不住状态栏、退出不销毁导致常驻、横竖屏切换后位置错位、多窗口重复创建互相覆盖。这些问题大多不是接口不会用,而是对窗口的生命周期与层级模型理解不到位。
闪控窗本质是创建一个 type 为 floating 的窗口,挂载在 Ability 之上,由窗口管理器统一负责层级、焦点与输入分发。它和普通页面最大的区别在于:它不属于页面路由栈,而是一个独立的窗口实例,因此需要自己管理创建、显示、隐藏与销毁的完整生命周期。

本文按「背景 → 能力规格 → 核心代码 → 实战要点 → 边界」的顺序,讲清接入链路与几个关键设计点,代码可直接作为调用范式参考。

二、能力与规格

维度说明
入口window.createWindow + WindowType.TYPE_FLOAT
权限ohos.permission.SYSTEM_FLOAT_WINDOW(敏感权限,需动态申请并说明用途)
生命周期跟随 Ability,Ability 销毁时务必销毁窗口
层级悬浮于应用之上,但低于系统状态栏/导航栏
尺寸建议 80×80 左右的胶囊,最大不超过屏幕 1/4
位置支持 moveWindowTo 绝对定位,需自行处理横竖屏与安全区
内容通过 loadContent 加载独立页面,与主页面解耦
典型场景音乐胶囊、计时器、快捷扫码、全局工具条

从规格表可以看出,闪控窗的能力边界很清晰:能浮、能定位、能加载独立 UI,但不越权。它不能覆盖系统状态栏与导航栏,也不能绕过权限直接创建,这决定了它更适合做「轻量入口」而非「全屏功能」。

三、核心代码(创建 + 加载内容 + 销毁)

import { window } from '@kit.WindowKit';
import { common } from '@kit.AbilityKit';
import { abilityAccessCtrl, Permissions } from '@kit.AbilityKit';

let floatWin: window.Window | null = null;

// 1. 先动态申请悬浮窗权限(用户授权后才创建,否则直接抛错)
async function ensurePermission(ctx: common.UIAbilityContext): Promise<boolean> {
  const permissions: Permissions[] = ['ohos.permission.SYSTEM_FLOAT_WINDOW'];
  const atManager = abilityAccessCtrl.createAtManager();
  const res = await atManager.requestPermissionsFromUser(ctx, permissions);
  // authResults[i] === 0 表示已授权
  return res.authResults[0] === 0;
}
// 1.1 权限被拒后的兜底:弹窗引导用户去设置页手动开启
import { promptAction } from '@kit.ArkUI';
import { Want } from '@kit.AbilityKit';

async function handlePermissionDenied(ctx: common.UIAbilityContext): Promise<void> {
  // 步骤 1:弹窗说明为什么需要该权限,避免用户不明所以直接拒绝
  const res = await promptAction.showDialog({
    title: '需要悬浮窗权限',
    message: '开启悬浮窗权限后,才能在其他应用之上显示快捷胶囊。是否前往设置开启?',
    buttons: [
      { text: '暂不开启', color: '#666666' },
      { text: '去设置', color: '#0A59F7' },
    ],
  });

  // 步骤 2:用户选择「暂不开启」则直接返回,不做强制跳转
  if (res.index !== 1) return;

  // 步骤 3:跳转到本应用的权限设置页,让用户手动打开悬浮窗开关
  // 说明:SYSTEM_FLOAT_WINDOW 属于敏感权限,二次申请不会再弹系统框,
  // 只能引导用户到设置页手动开启,因此这里用 startAbility 拉起设置页。
  const want: Want = {
    bundleName: 'com.ohos.settings',
    abilityName: 'com.ohos.settings.MainAbility',
    parameters: {
      // 部分系统版本支持直接定位到应用详情页
      pushParams: ctx.abilityInfo.bundleName,
    },
  };
  try {
    await ctx.startAbility(want);
  } catch (err) {
    // 步骤 4:跳转失败时降级为文字提示,保证用户仍有操作路径
    console.error(`跳转设置页失败: ${JSON.stringify(err)}`);
    promptAction.showToast({ message: '请手动前往「设置 → 应用 → 权限」开启悬浮窗权限' });
  }
}


// 2. 创建悬浮窗并加载自定义 UI(loadContent 指向你的浮窗页面)
async function showFloat(ctx: common.UIAbilityContext): Promise<void> {
  // 已存在则先复用,避免重复创建导致多窗口叠加
  if (floatWin) {
    await floatWin.showWindow();
    return;
  }
  if (!(await ensurePermission(ctx))) {
    // 权限未授予:走兜底引导,而不是静默失败
    await handlePermissionDenied(ctx);
    return;
  }

  floatWin = await window.createWindow({
    ctx,
    type: window.WindowType.TYPE_FLOAT,
    name: 'myFloat',
  });

  await floatWin.resize(80, 80);                    // 胶囊尺寸
  await floatWin.moveWindowTo(100, 200);            // 初始位置
  await floatWin.loadContent('pages/FloatCapsule'); // 浮窗内容页
  await floatWin.showWindow();
}

// 3. 隐藏与销毁分离:临时收起用 hide,彻底退出用 destroy
async function hideFloat(): Promise<void> {
  await floatWin?.hideWindow();
}

async function destroyFloat(): Promise<void> {
  if (!floatWin) return;
  await floatWin.destroyWindow();
  floatWin = null;
}

代码里有三个容易被忽略的细节:

  • 复用判断:floatWin 非空时直接 showWindow,避免用户连点导致创建多个窗口互相覆盖。
  • 隐藏与销毁分离:hideWindow 只是不可见,窗口实例仍在;destroyWindow 才真正释放资源。临时收起用前者,退出用后者。
  • 权限结果判断:authResults[0] === 0 才是授权成功,其余值都要走兜底提示,不要静默失败。
  • 兜底要给出路:SYSTEM_FLOAT_WINDOW 被拒后再次申请不会弹系统框,必须用 startAbility 拉起设置页让用户手动开启,跳转失败时再用 Toast 兜底提示路径。

四、实战要点

要点 1:权限先申请,别直接 create。
没授权就 createWindow(TYPE_FLOAT) 会直接异常。先走 requestPermissionsFromUser,并在 module.json5 的 requestPermissions 中声明权限,否则安装即被拒。用户拒绝后要有明确引导,比如提示「前往设置开启悬浮窗权限」,而不是让按钮点了没反应。

要点 2:Ability 销毁时同步销毁浮窗。
浮窗挂在 Ability 上,Ability onDestroy 里必须 destroyWindow(),否则出现「App 关了浮窗还在」的诡异常驻。建议把销毁逻辑收敛到一个统一方法,在 onDestroy 与 onBackground 中按需调用,避免多处遗漏。

要点 3:尺寸与位置要克制。
浮窗不是全屏入口,胶囊/小工具 80×80 左右最舒服。过大既挡内容又易被系统判定「干扰」,反而触发限制。位置方面,moveWindowTo 是绝对坐标,横竖屏切换后需要重新计算,并避开状态栏与底部导航栏的安全区。

要点 3.1:横竖屏切换要重新定位。
moveWindowTo 用的是绝对坐标,屏幕旋转后宽高互换,原来贴右下角的胶囊很可能跑到屏幕外或压住状态栏。正确做法是监听窗口尺寸变化,按「相对比例 + 安全区」重新算一次坐标,而不是沿用旧值。

import { window } from '@kit.WindowKit';
import { display } from '@kit.ArkUI';

// 浮窗尺寸(与 resize 保持一致)
const FLOAT_SIZE = 80;
// 距屏幕边缘的留白,避免贴边被系统手势区吞掉
const EDGE_GAP = 12;

// 记录浮窗相对屏幕的锚点比例(0~1),旋转后按比例还原
// 例如 { x: 1, y: 1 } 表示贴右下角,{ x: 0, y: 0 } 表示贴左上角
let anchorRatio = { x: 1, y: 1 };

/**
 * 根据当前屏幕宽高与安全区,计算浮窗应处的绝对坐标
 */
function calcFloatPosition(screenW: number, screenH: number, topSafe: number, bottomSafe: number) {
  // 1. 可用区域:扣掉状态栏与底部导航栏,浮窗只在安全区内活动
  const usableW = screenW;
  const usableH = screenH - topSafe - bottomSafe;

  // 2. 按锚点比例换算到可用区域内的坐标
  let x = anchorRatio.x * (usableW - FLOAT_SIZE);
  let y = topSafe + anchorRatio.y * (usableH - FLOAT_SIZE);

  // 3. 夹紧到安全区边界,防止旋转后比例换算越界
  x = Math.min(Math.max(x, EDGE_GAP), usableW - FLOAT_SIZE - EDGE_GAP);
  y = Math.min(Math.max(y, topSafe + EDGE_GAP), screenH - bottomSafe - FLOAT_SIZE - EDGE_GAP);

  return { x: Math.round(x), y: Math.round(y) };
}

/**
 * 屏幕旋转 / 窗口尺寸变化时重新定位浮窗
 */
async function relocateOnRotate(win: window.Window): Promise<void> {
  // 1. 读取当前屏幕宽高(旋转后宽高已互换)
  const screen = display.getDefaultDisplaySync();
  const screenW = screen.width;
  const screenH = screen.height;

  // 2. 读取安全区:状态栏高度 + 底部导航条高度
  //    这里用窗口的避让区域接口获取,不同系统版本字段可能略有差异
  const avoidArea = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM);
  const topSafe = avoidArea.topRect.height;
  const bottomSafe = avoidArea.bottomRect.height;

  // 3. 重新计算坐标并移动窗口
  const pos = calcFloatPosition(screenW, screenH, topSafe, bottomSafe);
  await win.moveWindowTo(pos.x, pos.y);
}

// 4. 注册监听:窗口尺寸变化(含旋转)时触发重新定位
export function bindRotateListener(win: window.Window): void {
  win.on('windowSizeChange', () => {
    // 旋转会触发尺寸变化,这里统一走重新定位逻辑
    relocateOnRotate(win).catch((err: Error) => {
      console.error(`浮窗重新定位失败: ${err.message}`);
    });
  });
}

几个关键点:

  • 别存绝对坐标,存比例:持久化时保存 anchorRatio 而不是 x/y,旋转后按比例还原,位置语义才不会丢。
  • 安全区必须参与计算:只按屏幕宽高算,浮窗会压到状态栏或底部导航条,视觉上像「被裁掉一半」。
  • 夹紧边界:比例换算在极端情况下可能越界,最后一步 Math.min/max 兜住,保证浮窗始终完整可见。
  • 监听要解绑:destroyFloat 时记得 win.off('windowSizeChange'),否则窗口销毁后回调仍可能被触发。

要点 4:交互别做重,点开再下沉。
浮窗本身只放「一键触发」,复杂操作点开后跳主 Ability 或弹半模态,别在浮窗里堆完整功能,否则层级和手势冲突一堆。浮窗内的点击事件也要注意与下层应用的手势隔离,避免误触穿透。

要点 5:状态要可恢复。
用户拖动过浮窗位置后,建议把坐标持久化到 Preferences,下次启动时还原,避免每次都回到默认位置,体验会明显更连贯。

import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { window } from '@kit.WindowKit';

// Preferences 文件名与键名,集中定义避免多处硬编码写错
const PREF_NAME = 'float_window_store';
const KEY_X = 'float_x';
const KEY_Y = 'float_y';

// 默认坐标:首次启动或读取失败时的兜底位置(贴右下角附近)
const DEFAULT_POS = { x: 100, y: 200 };

/**
 * 获取 Preferences 实例(懒加载 + 单例复用)
 * 每次读写都重新 getPreferences 会有额外开销,这里缓存一份
 */
let prefStore: preferences.Preferences | null = null;

async function getPrefStore(ctx: common.UIAbilityContext): Promise<preferences.Preferences> {
  if (prefStore) return prefStore;
  // 首次调用时创建/打开指定名称的 Preferences 文件
  prefStore = await preferences.getPreferences(ctx, PREF_NAME);
  return prefStore;
}

/**
 * 保存浮窗坐标:拖动结束或窗口移动后调用
 */
async function saveFloatPosition(ctx: common.UIAbilityContext, x: number, y: number): Promise<void> {
  try {
    const store = await getPrefStore(ctx);
    // 1. 写入内存中的键值
    await store.put(KEY_X, x);
    await store.put(KEY_Y, y);
    // 2. 必须 flush 才会真正落盘,否则进程被杀后数据丢失
    await store.flush();
  } catch (err) {
    // 持久化失败不影响主流程,仅记录日志
    console.error(`保存浮窗坐标失败: ${JSON.stringify(err)}`);
  }
}

/**
 * 读取浮窗坐标:启动时调用,读不到或数据异常则回退默认值
 */
async function loadFloatPosition(ctx: common.UIAbilityContext): Promise<{ x: number; y: number }> {
  try {
    const store = await getPrefStore(ctx);
    // 1. 第二个参数是默认值,键不存在时直接返回它
    const x = await store.get(KEY_X, DEFAULT_POS.x) as number;
    const y = await store.get(KEY_Y, DEFAULT_POS.y) as number;

    // 2. 兜底校验:历史脏数据(NaN、负数)一律回退默认值
    if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || y < 0) {
      return { ...DEFAULT_POS };
    }
    return { x, y };
  } catch (err) {
    // 3. 读取异常(文件损坏、权限问题)同样回退默认值,保证浮窗能正常显示
    console.error(`读取浮窗坐标失败: ${JSON.stringify(err)}`);
    return { ...DEFAULT_POS };
  }
}

/**
 * 创建浮窗时还原上次位置
 */
async function showFloatWithRestore(ctx: common.UIAbilityContext): Promise<void> {
  const win = await window.createWindow({
    ctx,
    type: window.WindowType.TYPE_FLOAT,
    name: 'myFloat',
  });

  await win.resize(80, 80);
  // 读取持久化坐标,首次启动自动落到默认位置
  const pos = await loadFloatPosition(ctx);
  await win.moveWindowTo(pos.x, pos.y);
  await win.loadContent('pages/FloatCapsule');
  await win.showWindow();

  // 拖动结束后记录新坐标(示例:监听窗口位置变化)
  win.on('windowMove', (data: { x: number; y: number }) => {
    saveFloatPosition(ctx, data.x, data.y).catch(() => {});
  });
}

几个关键点:

  • 必须 flush:put 只写内存,不 flush 进程退出就丢了,这是最容易漏的一步。
  • 默认值兜底:get 的第二个参数就是默认值,再叠加一层 Number.isFinite 校验,防止历史脏数据把浮窗定位到屏幕外。
  • 读写都要 try/catch:Preferences 属于本地存储,文件损坏或空间不足都可能抛错,持久化失败不能阻断浮窗创建。
  • 与比例方案配合:如果同时用了 anchorRatio,建议存比例而不是绝对坐标,旋转后还原更稳。

五、小结与边界

闪控窗适合高频、轻量、全局可达的操作(播放控制、计时、快捷工具)。它是体验加分项,不是功能主入口。落地记住:权限先申请、销毁要配对、尺寸要克制、重交互下沉。

总结

把上面的内容收拢成一条可执行的接入路径:

  1. 权限先行:在 module.json5 声明 ohos.permission.SYSTEM_FLOAT_WINDOW,运行时用 requestPermissionsFromUser 动态申请;被拒后不要静默失败,用弹窗 + startAbility 拉起设置页,跳转失败再用 Toast 兜底。
  2. 创建与复用:createWindow 指定 WindowType.TYPE_FLOAT,创建前判断实例是否已存在,避免连点产生多个窗口互相覆盖。
  3. 内容解耦:用 loadContent 加载独立浮窗页面,浮窗只做「一键触发」,复杂交互下沉到主 Ability 或半模态。
  4. 位置可控:尺寸控制在 80×80 左右;定位用「锚点比例 + 安全区」计算,监听 windowSizeChange 在旋转后重新 moveWindowTo,并夹紧边界防止越界。
  5. 状态可恢复:坐标写入 Preferences 并 flush 落盘,启动时读取还原,读取异常或脏数据回退默认值。
  6. 生命周期配对:hideWindow 与 destroyWindow 分清用途,Ability onDestroy 中务必销毁窗口并解绑监听。

一句话概括:闪控窗是体验加分项而非功能主入口,把权限、层级、位置、状态四件事管住,就能稳定落地。


本文为原创首发实战笔记,基于 HarmonyOS 窗口管理能力,代码示例为调用范式示意,实际接口以华为官方文档为准。

Logo

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

更多推荐