一、闪控球

1.1 场景与定位

闪控球是一种在设备屏幕上悬浮的非全屏应用窗口,以小圆球形式呈现,用于展示少量关键信息并支持应用外一键操作,点击后可自动拉起原应用对应界面。其设计目标是实现跨应用快捷操作,典型场景包括:

  • 抢单
  • 记账
  • 比价
  • 翻译
  • 题目搜索
  • 账单记录

闪控球适用于需要快速触达核心功能、但无需长时间驻留大量内容的交互模式。

1.2 基础交互

  • 创建:应用接入闪控球功能后,用户可通过应用内指定入口手动开启闪控球。点击闪控球,跳转至原应用对应界面。
  • 数量限制:系统最多支持 2 个闪控球,一个应用内仅支持创建 1 个闪控球。超出最大个数限制时,新闪控球会替换最早启动的闪控球。
  • 最小化:支持拖拽收入侧边条。
  • 删除:用户可将单个或多个闪控球整体拖拽至底部垃圾桶删除,也可以长按后通过菜单删除。
  • 位置记忆:关闭闪控球会记录当前位置,下一次打开功能时自动展示在上次关闭时的位置。旋转屏幕或重启设备会恢复到默认位置,默认位置位于屏幕右上侧。

1.3 视觉规格

1.3.1 模板结构

闪控球提供四种固定模板,应用可根据业务诉求选择接入(模板由系统管理,应用不能定制 UI):

模板类型布局组成说明
静态布局(STATIC)图标 + 标题支持图标和标题,创建后无法更新内容
普通文本布局(NORMAL)标题 + 内容支持标题和内容,可动态更新
强调文本布局(EMPHATIC)图标 + 标题 + 内容支持图标、标题和内容,可动态更新
纯文本布局(SIMPLE)仅标题(可双行展示)只支持标题,可动态更新

标题和内容不支持自定义字体大小,由系统统一控制。

1.3.2 尺寸规格

闪控球宽度根据传入内容自动切换档位,高度固定不变。不同设备类型的尺寸(包含底板)如下:

设备类型默认尺寸 (vp)档位1档位2档位3档位4
直板机(竖屏/横屏)70×4070×4080×4090×4098×40
双折叠展开态70×4070×4080×4090×4098×40
三折叠展开态70×4070×4080×4090×4098×40
平板/电脑104×60104×60114×60124×60132×60
  • 泛手机设备(直板机、折叠屏展开态)内部底板距离左右边距为 4vp;平板/电脑为 6vp
  • 当两个应用闪控球合并展示时,整体高度为 76vp(手机规格)。

1.4 接口说明(@ohos.window.floatingBall)

接口描述
isFloatingBallEnabled(): boolean判断当前设备是否支持闪控球功能
create(config: FloatingBallConfiguration): Promise<FloatingBallController>创建闪控球控制器
startFloatingBall(params: FloatingBallParams): Promise<void>启动闪控球
updateFloatingBall(params: FloatingBallParams): Promise<void>更新闪控球信息
stopFloatingBall(): Promise<void>停止闪控球
on(type: 'stateChange', callback: Callback<FloatingBallState>): void开启闪控球生命周期状态监听
off(type: 'stateChange', callback?: Callback<FloatingBallState>): void关闭闪控球生命周期状态监听
on(type: 'click', callback: Callback<void>): void开启闪控球点击事件监听
off(type: 'click', callback?: Callback<void>): void关闭闪控球点击事件监听
getFloatingBallWindowInfo(): Promise<FloatingBallWindowInfo>获取闪控球窗口信息
restoreMainWindow(want: Want): Promise<void>恢复应用主窗口,加载指定页面

1.5 完整开发步骤与示例代码

1.5.1 权限与约束

  • 必须申请 ohos.permission.USE_FLOAT_BALL 权限(受限权限,需在指定场景下使用)。
  • 仅允许应用在前台时启动闪控球。
  • 支持 Phone、Tablet、PC/2in1 设备,从 API version 20 开始支持。
  • 需 DevEco Studio 6.0.1 Release 及以上版本的模拟器支持。

1.5.2 工具类封装(Utils.ts)

以下代码封装了闪控球的创建、更新、停止逻辑,以及图标加载工具方法。

// Utils.ts
// 该页面提供工具类,展示闪控球的创建、更新、关闭逻辑
import hilog from '@ohos.hilog';
import image from '@ohos.multimedia.image';
import { BusinessError } from '@kit.BasicServicesKit';
import { floatingBall } from '@kit.ArkUI';
import { Want } from '@kit.AbilityKit';
import { ContextUtil } from './ContextUtil';

const DOMAIN: number = 0xF811;
const TAG: string = '[Sample_FloatingBall]';
const BUNDLE_NAME: string = ContextUtil.context.abilityInfo.bundleName;

export class Utils {
    public static getRawfilePixelMapSync(path: string): image.PixelMap {
        try {
            const BUFFER = ContextUtil.context.resourceManager.getRawFileContentSync(path);
            const IMAGE_SOURCE: image.ImageSource = image.createImageSource(BUFFER.buffer as ArrayBuffer);
            hilog.debug(DOMAIN, TAG, `Get rawfile pixelMap path '${path}' successfully`);
            return IMAGE_SOURCE.createPixelMapSync();
        } catch (e) {
            hilog.error(DOMAIN, TAG, `Get rawfile pixelMap path '${path}' failed, error: ${e}`);
            throw e as Error;
        }
    }

    // 闪控球启动逻辑
    public static async onClickCreateFloatingBall(
        floatingBallController: floatingBall.FloatingBallController | undefined,
        template: floatingBall.FloatingBallTemplate,
        onActiveRowChange: (value: number) => void,
        title: string = 'title',
        content: string = 'content',
        backgroundColor: string = '#0ff77c',
        icon?: image.PixelMap): Promise<void> {
        // 注册点击回调事件
        try {
            floatingBallController?.on('click', () => {
                hilog.debug(DOMAIN, TAG, `FloatingBall onClickEvent`);
                let want: Want = {
                    bundleName: BUNDLE_NAME,
                    abilityName: 'MainAbility'
                }
                // 使用promise异步回调恢复主窗口
                floatingBallController?.restoreMainWindow(want)
                .then(() => {
                    hilog.debug(DOMAIN, TAG, `Success in restoring FloatingBall main window`);
                }).catch((err: BusinessError) => {
                    hilog.error(DOMAIN, TAG, `failed to restore FloatingBall main window. code: ${err.code}, message: ${err.message}`);
                })
            })
        } catch (e) {
            hilog.error(DOMAIN, TAG, `Failed to register click listener: ${e}`);
        }
        // 注册状态变化事件
        try {
            floatingBallController?.on('stateChange',
            (state: floatingBall.FloatingBallState) => {
                hilog.debug(DOMAIN, TAG, `FloatingBall stateCange: ${state}`);
                if(state === floatingBall.FloatingBallState.STOPPED) {
                    floatingBallController?.off('click')
                    floatingBallController?.off('stateChange')
                    floatingBallController = undefined;
                    // 执行状态更新回调
                    onActiveRowChange?.(-1);
                }
            })
        } catch (e) {
            hilog.error(DOMAIN, TAG, `Failed to register stateChange listener: ${e}`);
        }
        // 最后启动闪控球
        let startParams: floatingBall.FloatingBallParams = icon? {
            template: template,
            title: title,
            content: content,
            backgroundColor: backgroundColor,
            icon: icon
        } : {
            template: template,
            title: title,
            content: content,
            backgroundColor: backgroundColor
        }
        try {
            floatingBallController?.startFloatingBall(startParams)
            .then(() => {
                hilog.debug(DOMAIN, TAG, `succeed in starting FloatingBall`);
            }).catch((err: BusinessError) => {
                hilog.error(DOMAIN, TAG, `failed to start FloatingBall. code: ${err.code}, message: ${err.message}`);
            })
        } catch (e) {
            console.error('startFloatingBall Error', e)
        }
    }

    // 闪控球更新逻辑
    public static onClickUpdateFloatingBall(
        floatingBallController: floatingBall.FloatingBallController | undefined,
        template: floatingBall.FloatingBallTemplate,
        title: string = 'newTitle',
        content: string = 'newContent',
        icon?: image.PixelMap): void {
        // 更新时给标题、内容随机使用数字后缀
        let random_string: string = Math.floor(Math.random() * 100).toString();
        let updateParams: floatingBall.FloatingBallParams = icon ? {
            template: template,
            title: title + random_string,
            content: content + random_string,
            backgroundColor: '#f6ea0a',
            icon: icon
        } : {
            template: template,
            title: title + random_string,
            content: content + random_string,
            backgroundColor: '#f6ea0a',
        }
        try {
            floatingBallController?.updateFloatingBall(updateParams).then(() => {
                hilog.debug(DOMAIN, TAG, `Succeed in updating FloatingBall`);
            }).catch((err: BusinessError) => {
                hilog.error(DOMAIN, TAG, `failed to update FloatingBall. code: ${err.code}, message: ${err.message}`);
            })
        } catch (e) {
            console.error('updateFloatingBall Error:', e)
        }
    }

    // 闪控球停止逻辑
    public static onClickStopFloatingBall(floatingBallController: floatingBall.FloatingBallController | undefined): void {
        // stop 是异步流程,需要通过 stateChange 状态回调获取实际删除结果
        floatingBallController?.stopFloatingBall().then(() => {
            hilog.debug(DOMAIN, TAG, `Succeed in stopping FloatingBall`);
        }).catch((err: BusinessError) => {
            hilog.error(DOMAIN, TAG, `failed to stop FloatingBall. code: ${err.code}, message: ${err.message}`);
        })
    }
}

1.5.3 主页面实现(Index.ets)

以下页面通过按钮展示四种模板的创建、更新与关闭操作,并管理闪控球控制器的生命周期。

// Index.ets
// 该页面利用按钮点击事件展示闪控球基本操作
import hilog from '@ohos.hilog';
import image from '@ohos.multimedia.image';
import { floatingBall } from '@kit.ArkUI';
import { Utils } from '../util/Utils';

const DOMAIN: number = 0xF811;
const TAG: string = '[Sample_FloatingBall]';

@Entry
@Component
struct Index {
  // 当前可用的行,-1 表示全部行可见
  @State private activeRow: number = -1;
  // 声明闪控球控制器
  private floatingBallController: floatingBall.FloatingBallController | undefined = undefined;
  // 缓存 icon 图标(静态布局)
  private cachedIcon1: image.PixelMap | undefined = undefined;
  // 缓存 icon 图标(强调文本布局)
  private cachedIcon2: image.PixelMap | undefined = undefined;

  // activeRow 的状态更新函数(确保闪控球销毁时,activeRow的值更新为-1)
  private activeRowChange = (value: number) => {this.activeRow = value};

  // 判断某个布局是否可用(是否置灰)
  private isEnabled(rowInex: number): boolean {
    return this.activeRow === -1 || this.activeRow === rowInex;
  }

  build() {
    Column({space: 12}) {
      // 静态布局,支持标题和图标,该布局在创建后无法修改
      Row({space: 6}) {
        Button('STATIC').onClick( async () => {
          // 请在组件内获取context,确保this.getUIContext().getHostContext()返回的结果是UIAbilityContext
          if (!this.floatingBallController) {
            this.floatingBallController = await floatingBall.create({
              context: this.getUIContext().getHostContext()
            })
          }
          if (this.floatingBallController) {
            // 仅当没有缓存 cachedIcon1 时才加载;有缓存时,直接使用;
            if (!this.cachedIcon1) {
              let pixelMap = Utils.getRawfilePixelMapSync('books.png');  // 图片尺寸有最大限制
              if (pixelMap) {
                this.cachedIcon1 = pixelMap;  // 把图标缓存起了
                hilog.debug(DOMAIN, TAG, `Success to load icon PixelMap`);
              } else {
                hilog.error(DOMAIN, TAG, `Failed to load icon PixelMap`);
              }
            }
            Utils.onClickCreateFloatingBall(this.floatingBallController,
              floatingBall.FloatingBallTemplate.STATIC, this.activeRowChange, 'title', 'content', '#0ff77c', this.cachedIcon1)
              this.activeRow = 0;
          }
        })
        .enabled(this.isEnabled(0))
        // 更新闪控球信息(该布局在创建后无法更新,按钮永久置灰)
        Button('Update1').enabled(false)
        // 关闭闪控球
        Button('Close1').onClick(() => {
          Utils.onClickStopFloatingBall(this.floatingBallController);
          this.activeRow = -1;  // 关闭后恢复所有行显示
        })
        .enabled(this.isEnabled(0))
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)
      
      // 普通文本布局,支持标题和内容
      Row({space: 6}) {
        Button('NORMAL').onClick( async () => {
          if (!this.floatingBallController) {
            this.floatingBallController = await floatingBall.create({
              context: this.getUIContext().getHostContext()
            })
          }
          if (this.floatingBallController) {
            Utils.onClickCreateFloatingBall(this.floatingBallController,
              floatingBall.FloatingBallTemplate.NORMAL, this.activeRowChange, 'title', 'content')
              this.activeRow = 1;
          }
        })
        .enabled(this.isEnabled(1))
        // 更新闪控球信息
        Button('Update2').onClick(() => Utils.onClickUpdateFloatingBall(this.floatingBallController,
          floatingBall.FloatingBallTemplate.NORMAL))
          .enabled(this.isEnabled(1))
        // 关闭闪控球
        Button('Close2').onClick(() => {
          Utils.onClickStopFloatingBall(this.floatingBallController);
          this.activeRow = -1;
        })
        .enabled(this.isEnabled(1))
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)
      
      // 强调文本布局,支持标题、图标和内容
      Row({space: 6}) {
        Button('EMPHATIC').onClick( async () => {
          if (!this.floatingBallController) {
            this.floatingBallController = await floatingBall.create({
              context: this.getUIContext().getHostContext()
            })
          }
          if (this.floatingBallController) {
            if(!this.cachedIcon2) {
              let pixelMap = Utils.getRawfilePixelMapSync('video.png');
              if (pixelMap) {
                this.cachedIcon2 = pixelMap;
                hilog.debug(DOMAIN, TAG, `Success to load icon PixelMap`);
              } else {
                hilog.debug(DOMAIN, TAG, `Failed to load icon PixelMap`);
              }
            }
            Utils.onClickCreateFloatingBall(this.floatingBallController,
              floatingBall.FloatingBallTemplate.EMPHATIC, this.activeRowChange, '16', 'Min', '#0ff77c', this.cachedIcon2)
              this.activeRow = 2;
          }
        })
        .enabled(this.isEnabled(2))
        // 更新闪控球信息
        Button('Update3').onClick(() => Utils.onClickUpdateFloatingBall(this.floatingBallController,
          floatingBall.FloatingBallTemplate.EMPHATIC, '', 'Min', this.cachedIcon2))
          .enabled(this.isEnabled(2))
        // 关闭闪控球
        Button('Close3').onClick(() => {
          Utils.onClickStopFloatingBall(this.floatingBallController);
          this.activeRow = -1;
        })
        .enabled(this.isEnabled(2))
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)

      // 纯文本布局,只支持标题
      Row({space: 6}) {
        Button('SIMPLE').onClick( async () => {
          if (!this.floatingBallController) {
            this.floatingBallController = await floatingBall.create({
              context: this.getUIContext().getHostContext()
            })
          }
          if (this.floatingBallController) {
            Utils.onClickCreateFloatingBall(this.floatingBallController,
              floatingBall.FloatingBallTemplate.SIMPLE, this.activeRowChange, 'title')
              this.activeRow = 3;
          }
        })
        .enabled(this.isEnabled(3))
        // 更新闪控球信息
        Button('Update4').onClick(() => Utils.onClickUpdateFloatingBall(this.floatingBallController,
          floatingBall.FloatingBallTemplate.SIMPLE))
          .enabled(this.isEnabled(3))
        // 关闭闪控球
        Button('Close4').onClick(() => {
          Utils.onClickStopFloatingBall(this.floatingBallController);
          this.activeRow = -1;
        })
        .enabled(this.isEnabled(3))
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

1.5.4 关键实现说明

  1. 控制器创建:通过 floatingBall.create({ context: this.getUIContext().getHostContext() }) 获取控制器,context 必须是 UIAbilityContext
  2. 事件注册:在启动前注册 clickstateChange 回调。点击回调中调用 restoreMainWindow 恢复主界面;状态回调中监听到 STOPPED 时需清理监听并置空控制器。
  3. 图标缓存:为避免重复加载,将 PixelMap 缓存为成员变量。图片尺寸有最大限制,建议使用合适尺寸的资源。
  4. 模板更新限制STATIC 模板创建后不可更新,因此对应更新按钮永久置灰。
  5. 行状态管理activeRow 控制当前激活的模板行,同一时间只允许一个闪控球处于活动状态,其他行按钮置灰。

二、闪控窗

2.1 场景与定位

闪控窗是悬浮于桌面或其他应用界面上的小型窗口,展示区域较大,可承载更多关键信息,支持应用外便捷操作和实时监测。其设计目标是实现跨应用高频、轻量交互,典型场景包括:

  • 金融盯盘
  • 游戏直播
  • 需要持续监控数据的业务

闪控窗由系统管理并统一绘制 UI(提供标题栏、拖拽区、内容区),但允许应用在内容区加载自定义页面,比闪控球具有更强的信息承载能力。

2.2 基础交互

  • 创建:用户通过应用内指定入口开启闪控窗。点击闪控窗跳转至原应用对应界面。系统内最多显示一个闪控窗
  • 与闪控球切换:若应用同时接入闪控窗和闪控球,两者可在绑定后互相切换(通过缩小按钮或点击闪控球)。
  • 最小化:支持拖拽收入侧边条,或通过“最小化”按钮收入侧边条。
  • 删除:可拖拽至底部垃圾桶删除,或直接点击关闭按钮删除。
  • 拖动:可以手动拖拽闪控窗拖动热区改变位置,拖拽时自动避让状态栏、导航条、输入法键盘等系统组件。

2.3 视觉规格

2.3.1 窗口显示状态

状态窗口样式应用场景
常规态矩形,在最大最小尺寸范围内自由设定宽高大多数场景
Mini态横向细长条矩形(高度固定为 32vp,宽度同常规态)游戏直播、盯盘等对遮挡要求高的场景

2.3.2 窗口模板

提供常规态与 mini 态两种标准模板,均包含 titlebar 布局、拖拽区、内容区。如果业务同时接入两种形态,需在窗口内容区自行配置切换按钮。

2.3.3 尺寸限制(不同设备)

闪控窗尺寸有最大/最小限制,应用可按需在区间内定义。建议通过 getFloatViewLimits() 获取推荐范围,并通过 onLimitsChange() 监听变化。

各设备类型的尺寸限制如下(表格中“窗口宽度”指闪控窗当前宽度):

设备类型最小宽度最小高度最大宽度最大高度
直板机(竖屏)屏幕宽度30%窗口宽度×75%屏幕宽度×90%窗口宽度×75%
直板机(横屏)直板机竖屏屏幕宽度30%窗口宽度×75%屏幕宽度×45%窗口宽度×75%
双折叠展开态折叠态竖屏屏幕宽度30%窗口宽度×75%屏幕宽度×55%窗口宽度×75%
三折叠展开态折叠态竖屏屏幕宽度30%窗口宽度×75%屏幕宽度×50%窗口宽度×75%
平板/电脑屏幕宽度×10%窗口宽度×75%屏幕宽度×50%窗口宽度×75%

Mini 态高度固定为 32vp,宽度同常规态规格。

2.4 与系统悬浮窗的区别

对比项闪控窗系统悬浮窗
功能承载单一功能或局部信息,无法覆盖全应用业务应用全量功能,可内部切换和跳转
悬浮方式支持应用内或跨应用悬浮跨应用悬浮
窗口限制仅限执行本任务相关功能无此限制
UI 绘制系统统一绘制标题栏等,内容区可自定义开发者完全自绘
动效高端精致,系统统一开发者自控
设备支持Phone、Tablet、2in1仅 2in1

2.5 完整开发步骤与示例代码

2.5.1 权限与约束

  • 必须申请 ohos.permission.FLOAT_VIEW(user_grant 权限),需动态授权。
  • 仅允许应用在前台时启动闪控窗。
  • 同一个应用只能启动一个闪控窗,启动多个会返回错误码 1300033。
  • 同一应用已启动闪控球或画中画窗口时,无法启动闪控窗,需先停止后者。
  • 仅支持 Stage 模型,从 API 26.0.0 开始支持。
  • 模板类型:目前支持 FloatViewTemplateType.ROUNDED_RECTANGLE(圆角矩形)和 FloatViewTemplateType.RHORIZONTAL_BAR(水平条状矩形)。

2.5.2 基础场景:单独操作闪控窗(Index_plain.ets)

以下代码展示闪控窗的独立创建、启动、尺寸设置与生命周期管理。

// 应用初始化创建,申请用户授权
aboutToAppear(): void {
  let flag = bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION;
  let bundleInfo = bundleManager.getBundleInfoForSelfSync(flag);
  let atManager = abilityAccessCtrl.createAtManager();
  atManager.verifyAccessToken(bundleInfo.appInfo.accessTokenId, 'ohos.permission.FLOAT_VIEW').then(data => {
    console.info('Permission check: ' + JSON.stringify(data));
    this.result = data === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED ? '权限已授权' : '权限未授权';
  });

  let enable: boolean = floatView.isFloatViewEnabled();
  console.info(TAG + 'floatView enabled is: ' + enable);
}

// 确认用户授权状态
requestPermission(): void {
  let atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
  atManager.requestPermissionsFromUser(getContext(this), ['ohos.permission.FLOAT_VIEW'] as Permissions[])
    .then((data) => {
      console.info(TAG + `grant result: ${data.authResults}.`);
      this.result = data.authResults[0] === 0 ? '权限已授权' : '权限被拒绝';
    })
    .catch((reason: BusinessError) => {
      console.error(TAG + `requestPermissionsFromUser failed, ${reason?.code}, ${reason?.message}.`);
    });
}

导入模块、声明闪控窗控制器并声明页面视图组件:

// 声明闪控窗控制器
private floatViewController: floatView.FloatViewController | undefined = undefined;
@State result: string = '';

// 创建闪控窗
async createWindow(): Promise<void> {
  try {
    await this.createWindowPlain();
  } catch (err) {
    console.error(TAG +
      `Failed to create float view controller. Cause: ${(err as BusinessError).code}, message: ${(err as
        BusinessError).message}`);
  }
}

使用 create() 接口创建闪控窗控制器实例后注册状态变化事件回调,使用 setUIContext() 设置闪控窗页面内容,通过 getFloatViewLimits() 获取窗口尺寸限制,通过 setWindowSize() 设置合适的窗口大小,通过 start() 接口启动闪控窗:

// 启动闪控窗
async createWindowPlain(): Promise<void> {
  if (!this.floatViewController) {
    let ctx = this.getUIContext().getHostContext() as common.UIAbilityContext;
    let floatConfig: floatView.FloatViewConfiguration = {
      context: ctx,
      templateType: floatView.FloatViewTemplateType.ROUNDED_RECTANGLE
    };
    // 创建闪控窗控制器实例
    this.floatViewController = await floatView.create(floatConfig);
    // 注册状态变化事件回调
    this.registerStateChangeCallback();
    // 注册尺寸变化事件回调
    this.registerLimitsChangeCallback();
  }
  // 设置闪控窗页面内容
  // FloatViewPage为闪控窗页面,由应用根据实际业务实现
  await this.floatViewController.setUIContext('pages/FloatViewPage');
  // 获取闪控窗尺寸限制,设置闪控窗大小
  let limits: floatView.FloatViewLimits =
    floatView.getFloatViewLimits(floatView.FloatViewTemplateType.ROUNDED_RECTANGLE);
  let size: window.Size = {
    width: limits.maxSize.width,
    height: limits.maxSize.height
  };
  await this.floatViewController.setWindowSize(size);
  // 启动闪控窗
  await this.floatViewController.start();
  console.info(TAG + 'Float view started in unbind state');
}

(可选)通过 onLimitsChange() 监听闪控窗尺寸限制变化,动态调整窗口尺寸,通过 onStateChange() 监听闪控窗状态变化,绑定状态变化回调:

// 注册闪控窗尺寸限制变化回调函数
public registerLimitsChangeCallback(): void {
  this.floatViewController?.onLimitsChange((limits: floatView.FloatViewLimits) => {
    console.info(TAG + `Limits changed: minSize=${limits.minSize}, maxSize=${limits.maxSize}`);
  });
}

// 注册闪控窗状态变化回调函数
public registerStateChangeCallback(): void {
  this.floatViewController?.onStateChange((info: floatView.FloatViewStateChangeInfo) => {
    console.info(TAG + `State changed: ${info.state}, reason: ${info.stopReason}`);
    if (info.state === floatView.FloatViewState.STOPPED) {
      this.floatViewController?.offStateChange();
      this.floatViewController?.offLimitsChange();
      this.floatViewController = undefined;
    }
  });
}

onStateChange() 监听 start() 完成后,通过 stop() 停止闪控窗:

// 删除闪控窗
deleteAll(): void {
  console.info(TAG + 'Deleting all');
  if (this.floatViewController) {
    this.floatViewController.stop().then(() => {
      console.info(TAG + 'Float view stopped');
      this.floatViewController = undefined;
    }).catch((err: BusinessError) => {
      console.error(TAG + `Failed to delete float view: ${err.code}`);
      this.floatViewController = undefined;
    });
  }
}

三、闪控窗与闪控球绑定使用(复杂场景)

闪控窗可与闪控球绑定使用,实现以下能力:

  • 绑定成功后,调用任一控制器的启动接口会同时创建闪控窗窗口和闪控球窗口,同一时刻仅展示其中一个窗口。
  • 绑定成功后,闪控窗窗口与闪控球窗口支持用户点击触发的互相切换,点击闪控球不会触发 click 回调
  • 绑定成功后,调用任一控制器的停止接口会同时销毁闪控窗窗口和闪控球窗口。
  • 绑定需要同时具有 ohos.permission.USE_FLOAT_BALLohos.permission.FLOAT_VIEW 权限。

3.1 完整绑定示例(Index_advanced.ets)

3.1.1 权限申请与初始化

// 应用初始化创建,申请用户授权
aboutToAppear(): void {
  let flag = bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION;
  let bundleInfo = bundleManager.getBundleInfoForSelfSync(flag);
  let atManager = abilityAccessCtrl.createAtManager();
  atManager.verifyAccessToken(bundleInfo.appInfo.accessTokenId, 'ohos.permission.FLOAT_VIEW').then(data => {
    console.info('Permission check: ' + JSON.stringify(data));
    this.result = data === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED ? '权限已授权' : '权限未授权';
  });

  let enable: boolean = floatView.isFloatViewEnabled();
  console.info(TAG + 'floatView enabled is: ' + enable);
}

// 查询用户授权状态
requestPermission(): void {
  let atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
  atManager.requestPermissionsFromUser(getContext(this), ['ohos.permission.FLOAT_VIEW'] as Permissions[])
    .then((data) => {
      console.info(TAG + `grant result: ${data.authResults}.`);
      this.result = data.authResults[0] === 0 ? '权限已授权' : '权限被拒绝';
    })
    .catch((reason: BusinessError) => {
      console.error(TAG + `requestPermissionsFromUser failed, ${reason?.code}, ${reason?.message}.`);
    });
}

3.1.2 导入模块并声明控制器

// 声明闪控窗控制器
private floatViewController: floatView.FloatViewController | undefined = undefined;
// 声明闪控球控制器
private floatingBallController: floatingBall.FloatingBallController | undefined = undefined;
@State result: string = '';
@State bindState: BindState = BindState.BIND;
@State statusText: string = '当前状态: 绑定';
@State currentDisplay: string = 'none';

// 创建闪控窗
async createWindow(): Promise<void> {
  try {
    // 绑定状态下:如果闪控球句柄已存在,先绑定闪控球和闪控窗
    if (this.bindState === BindState.BIND) {
      await this.createWindowAdvanced();
    } else {
      await this.createWindowPlain();
    }
  } catch (err) {
    console.error(TAG +
      `Failed to create float view controller. Cause: ${(err as BusinessError).code}, message: ${(err as
        BusinessError).message}`);
  }
}

3.1.3 创建控制器并绑定启动

使用 floatView.create() 接口创建闪控窗控制器实例,使用 floatingBall.create() 接口创建闪控球控制器实例:

async createWindowAdvanced(): Promise<void> {
  if (!this.floatingBallController) {
    let ctx = this.getUIContext().getHostContext() as common.UIAbilityContext;
    let ballConfig: floatingBall.FloatingBallConfiguration = {
      context: ctx
    };
    // 创建闪控球控制器实例
    this.floatingBallController = await floatingBall.create(ballConfig);
  }
  if (!this.floatViewController) {
    let ctx = this.getUIContext().getHostContext() as common.UIAbilityContext;
    let floatConfig: floatView.FloatViewConfiguration = {
      context: ctx,
      templateType: floatView.FloatViewTemplateType.ROUNDED_RECTANGLE
    };
    // 创建闪控窗实例
    this.floatViewController = await floatView.create(floatConfig);
  }
  this.registerStateChangeCallback();
  this.registerLimitsChangeCallback();
  await this.bindControllersAndStart();
}

// Bind状态创建复杂场景悬浮窗,否则创建plain悬浮窗
async bindFloatViewAndBall(): Promise<void> {
  this.bindState = BindState.BIND;
  this.statusText = '当前状态: 绑定';
  this.currentDisplay = 'none';
  console.info(TAG + 'State updated to BIND');
}

使用 floatView.bind() 接口将闪控窗控制器与闪控球控制器绑定,绑定时需要配置闪控球参数。使用 setUIContext() 设置闪控窗页面内容,通过 getFloatViewLimits() 获取窗口尺寸限制,通过 setWindowSize() 设置合适的窗口大小,通过 start() 接口启动闪控窗(绑定状态下会同时启动闪控球):

private async bindControllersAndStart(): Promise<void> {
  if (!this.floatViewController || !this.floatingBallController) {
    console.warn(TAG + 'Controllers not ready for binding');
    return;
  }
  const fvController = this.floatViewController;
  const fbController = this.floatingBallController;
  const ballParams: floatingBall.FloatingBallParams = {
    template: floatingBall.FloatingBallTemplate.EMPHATIC,
    title: "标题",
    content: "正文"
  };
  try {
    // 将闪控窗控制器与闪控球控制器绑定
    await floatView.bind(fvController, fbController, ballParams);
    // 设置闪控窗页面内容
    // FloatViewPage为闪控窗页面,由应用根据实际业务实现
    await fvController.setUIContext('pages/FloatViewPage');
    // 获取窗口尺寸大小约束并设置窗口大小
    let limits: floatView.FloatViewLimits =
      floatView.getFloatViewLimits(floatView.FloatViewTemplateType.ROUNDED_RECTANGLE);
    let size: window.Size = {
      width: limits.maxSize.width,
      height: limits.maxSize.height
    };
    await fvController.setWindowSize(size);
    // 启动闪控窗(绑定状态下会同时启动闪控球)
    await fvController.start();

  } catch (err) {
    console.error(TAG +
      `Bind and start failed. Cause: ${(err as BusinessError).code}, message: ${(err as BusinessError).message}`);
  }
}

3.1.4 停止与解绑

通过 stop() 停止闪控窗,绑定状态下会同时停止闪控球:

deleteAll(): void {
  console.info(TAG + 'Deleting all (ball and window)');

  // 删除闪控窗
  if (this.floatViewController) {
    this.floatViewController.stop().then(() => {
      console.info(TAG + 'Float view stopped');
      this.floatViewController = undefined;
    }).catch((err: BusinessError) => {
      console.error(TAG + `Failed to delete float view: ${err.code}`);
      this.floatViewController = undefined;
    });
  }

  // 删除闪控球
  if (this.floatingBallController) {
    this.floatingBallController.stopFloatingBall().then(() => {
      console.info(TAG + 'Floating ball stopped');
      this.floatingBallController = undefined;
    }).catch((err: BusinessError) => {
      console.error(TAG + `Failed to delete floating ball: ${err.code}`);
      this.floatingBallController = undefined;
    });
  }
}

(可选)通过 floatView.unbind() 解绑闪控窗与闪控球:

unbindFloatViewAndBall(): void {
  this.bindState = BindState.UNBIND;
  this.statusText = '当前状态: 解绑';
  console.info(TAG + 'State updated to UNBIND');
  if (!this.floatViewController || !this.floatingBallController) {
    console.warn(TAG + 'Controllers not initialized, unbinding not executed');
    return;
  }
  floatView.unbind(this.floatViewController, this.floatingBallController).then(() => {
    console.info(TAG + 'Succeeded in unbinding');
  }).catch((err: BusinessError) => {
    console.error(TAG + `Unbind failed. Code: ${err.code}, Message: ${err.message}`);
  });
}

四、闪控窗与防窥保护组合使用(进阶场景)

闪控窗支持与防窥保护功能联动使用。当用户通过闪控窗查看敏感信息时,系统将自动通过传感器检测周围环境,一旦识别到非机主的窥视行为,会立即在闪控窗上拉起蒙层以遮盖内容,防止隐私泄露。用户也可根据情况手动解除此保护。

防窥保护基于 SystemCapability.Security.DlpAntiPeep 能力实现。识别逻辑为:系统将长期通过人脸解锁的用户标记为机主。当传感器检测到非机主与机主同时注视屏幕时,会通过回调向应用发送被窥视状态通知(HIDE),应用据此触发隐私保护动作。

4.1 权限与前提条件

本场景需要申请以下权限:

  • ohos.permission.FLOAT_VIEW
  • ohos.permission.DLP_GET_HIDE_STATUS

还需要在系统设置 > 隐私与安全 > 防窥保护中打开对应应用开关。可通过 canIUse('SystemCapability.Security.DlpAntiPeep') 判断当前设备是否支持防窥保护。

4.2 防窥保护工具类封装

封装防窥保护工具类,核心能力为:检测设备是否支持防窥功能、查询系统开关状态、获取当前窥视状态,并在必要时调用系统蒙层接口进行隐私遮挡。

// 判断是否支持防窥保护
export function canUseAntiPeep(): boolean {
  return canIUse('SystemCapability.Security.DlpAntiPeep');
}

// 判断防窥保护是否打开
export async function isAntiPeepOn(): Promise<boolean> {
  try {
    let result: boolean = await dlpAntiPeep.isDlpAntiPeepSwitchOn();
    console.info(TAG + `isAntiPeepOn isDlpAntiPeepSwitchOn success. ${result}`)
    return result;
  } catch (err) {
    console.error(TAG + `[isAntiPeepOn] isDlpAntiPeepSwitchOn failed.${JSON.stringify(err)}`);
    return false;
  }
}

// 获取防窥保护状态
export function getAntiPeepInfo(): dlpAntiPeep.DlpAntiPeepStatus {
  try {
    let dlpAntiPeepStatus = dlpAntiPeep.getDlpAntiPeepInfo();
    console.info(TAG + `getDlpHideInfo success. ${JSON.stringify(dlpAntiPeepStatus)}`);
    return dlpAntiPeepStatus;
  } catch (err) {
    console.info(TAG + `getDlpHideInfo failed. ${JSON.stringify(err)}`);
    return -1;
  }
}

// 开启全局保护
export function showSystemMaskLayer(windowId: number): Promise<boolean> {
  return new Promise((resolve) => {
    try {
      if (canUseAntiPeep()) {
        dlpAntiPeep.setAntiPeepMaskLayer(windowId).catch((err: BusinessError) => {
          console.error(
            TAG + `Execute setAntiPeepMaskLayer failed. error code:${err.code}, error message:${err.message}`);
          resolve(false);
        }).then(() => {
          console.info(TAG + `setAntiPeepMaskLayer success`);
          resolve(true);
        })
      } else {
        resolve(false);
      }
    } catch (err) {
      console.error(TAG + `Call setAntiPeepMaskLayer failed. ${JSON.stringify(err)}`);
      resolve(false);
    }
  });
}

注册防窥保护状态监听,调用 dlpAntiPeep.on('dlpAntiPeep') 接口注册防窥保护状态监听。其中 PASS 表示当前设备屏幕无人窥视,HIDE 表示有除机主以外的人在窥视设备屏幕:

export interface AntiPeepCallback {
  onStatusChanged: (status: dlpAntiPeep.DlpAntiPeepStatus) => Promise<void>;
}

export function listenOnAntiPeepStatus(antiPeepCB: AntiPeepCallback): boolean {
  try {
    console.info(TAG + `start on('dlpAntiPeep')`);
    dlpAntiPeep.on('dlpAntiPeep', (dlpAntiPeepStatus: dlpAntiPeep.DlpAntiPeepStatus) => {
      if (antiPeepCB) {
        antiPeepCB.onStatusChanged(dlpAntiPeepStatus);
      } else {
        console.warn(TAG + `antiPeepCB is empty`);
      }
    });
    console.info(TAG + `on('dlpAntiPeep') ok`);
    return true;
  } catch (err) {
    console.error(TAG + `dlpAntiPeep.on failed. ${JSON.stringify(err)}`);
    return false;
  }
}

4.3 主页面集成防窥保护

初始化防窥状态并注册回调,申请闪控窗权限。初始化时依次检查设备支持情况、开关状态与当前状态。声明及初始化防窥回调处理函数,回调处理逻辑为:当状态为 HIDE 时,获取应用主窗并调用 setAntiPeepMaskLayer() 拉起系统蒙层。

isListenOn: boolean = false;
isPeep: boolean = false;
isGranted: boolean = false;
private floatViewController: floatView.FloatViewController | undefined = undefined;
antiPeepCB: AntiPeepCallback = {
  onStatusChanged: async (status: dlpAntiPeep.DlpAntiPeepStatus): Promise<void> => {
    await this.handleAntiPeepStatus(status);
  }
};

// 声明防窥回调处理函数
private async handleAntiPeepStatus(status: dlpAntiPeep.DlpAntiPeepStatus) {
  console.info(TAG + `[handleAntiPeepStatus] ${status}`);
  switch (status) {
    case dlpAntiPeep.DlpAntiPeepStatus.PASS:
      console.info(TAG + 'DlpAntiPeepStatus is PASS');
      break;
    case dlpAntiPeep.DlpAntiPeepStatus.HIDE:
      // 从存储中获取已保存的窗口信息
      let window: window.Window = AppStorage.get('MAIN_WINDOW') as window.Window;
      const windowId: number = window.getUIContext().getWindowId() as number;
      console.info(TAG + `DlpAntiPeepStatus is HIDE ${windowId}`);
      dlpAntiPeep.setAntiPeepMaskLayer(windowId)
        .then((result) => {
          console.info(TAG, `setAntiPeepMaskLayer success + ${result}`);
        })
        .catch((err: BusinessError) => {
          console.error(TAG,
            `Execute setAntiPeepMaskLayer failed. error code:${err.code}, error message:${err.message}`);
        })
      break;
    default:
      break;
  }
}

// 初始化防窥状态及注册回调
private initAntiPeepStatus() {
  if (canUseAntiPeep()) {
    isAntiPeepOn().then((opened) => {
      if (opened) {
        let info = getAntiPeepInfo();
        this.handleAntiPeepStatus(info);
        this.isListenOn = listenOnAntiPeepStatus(this.antiPeepCB);
        if (this.isListenOn) {
          console.info(TAG + 'succeed in listenOnAntiPeepStatus ')
        }
      } else {
        try {
          this.getUIContext().getPromptAction().showToast({
            message: $r('app.string.anti_peep_not_enable')
          });
        } catch (error) {
          console.error(TAG + 'show toast error. code =' + error.code + ', message =' + error.message);
        }
      }
    })
  } else {
    try {
      this.getUIContext().getPromptAction().showToast({
        message: $r('app.string.device_not_supported')
      });
    } catch (error) {
      console.error(TAG + 'show toast error. code =' + error.code + ', message =' + error.message);
    }
  }
}

// 确认用户授权状态
private requestPermission(): void {
  let atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
  let hostContext = this.getUIContext().getHostContext();
  atManager.requestPermissionsFromUser(hostContext, ['ohos.permission.FLOAT_VIEW'] as Permissions[])
    .then((data) => {
      console.info(TAG + `grant result: ${data.authResults}.`);
      this.isGranted = data.authResults[0] === 0;
    })
    .catch((reason: BusinessError) => {
      console.error(TAG + `requestPermissionsFromUser failed, ${reason?.code}, ${reason?.message}.`);
    });
}

// 应用初始化创建,申请用户授权
aboutToAppear(): void {
  this.initAntiPeepStatus();
  this.requestPermission();
  let enable: boolean = floatView.isFloatViewEnabled();
  console.info(TAG + 'floatView enabled is: ' + enable);
}

创建并启动闪控窗,使用 floatView.create() 创建控制器,通过 setUIContext()getFloatViewLimits()setWindowSize()start() 完成页面内容设置与启动,并通过 onStateChange()onRectChange()onLimitsChange() 注册状态与尺寸限制变化回调:

// 启动闪控窗
async createWindowAntiPeep(): Promise<void> {
  if (!this.floatViewController) {
    let ctx = this.getUIContext().getHostContext() as common.UIAbilityContext;
    let floatConfig: floatView.FloatViewConfiguration = {
      context: ctx,
      templateType: floatView.FloatViewTemplateType.ROUNDED_RECTANGLE
    };
    // 创建闪控窗控制器实例
    this.floatViewController = await floatView.create(floatConfig);
    // 注册状态变化事件回调
    this.registerStateChangeCallback();
    // 注册尺寸变化事件回调
    this.registerLimitsChangeCallback();
  }
  // 设置闪控窗页面内容
  // FloatViewPage为闪控窗页面,由应用根据实际业务实现
  await this.floatViewController.setUIContext('pages/FloatViewPage');
  // 获取闪控窗尺寸限制,设置闪控窗大小
  let limits: floatView.FloatViewLimits =
    floatView.getFloatViewLimits(floatView.FloatViewTemplateType.ROUNDED_RECTANGLE);
  let size: window.Size = {
    width: limits.maxSize.width,
    height: limits.maxSize.height
  };
  await this.floatViewController.setWindowSize(size);
  // 启动闪控窗
  await this.floatViewController.start();
  console.info(TAG + 'Float view started in unbind state');
}

// 注册闪控窗尺寸限制变化回调函数
public registerLimitsChangeCallback(): void {
  this.floatViewController?.onLimitsChange((limits: floatView.FloatViewLimits) => {
    console.info(TAG + `Limits changed: minSize=${limits.minSize}, maxSize=${limits.maxSize}`);
  });
}

// 注册闪控窗状态变化回调函数
public registerStateChangeCallback(): void {
  this.floatViewController?.onStateChange((info: floatView.FloatViewStateChangeInfo) => {
    console.info(TAG + `State changed: ${info.state}, reason: ${info.stopReason}`);
    if (info.state === floatView.FloatViewState.STOPPED) {
      this.floatViewController?.offStateChange();
      this.floatViewController?.offLimitsChange();
      this.floatViewController = undefined;
    }
  });
}

通过 stop() 停止闪控窗:

// 停止闪控窗
deleteAll(): void {
  console.info(TAG + 'Deleting all');
  if (this.floatViewController) {
    this.floatViewController.stop().then(() => {
      console.info(TAG + 'Float view stopped');
    }).catch((err: BusinessError) => {
      console.error(TAG + `Failed to delete float view: ${err.code} reason ${err.message}`);
    });
  }
}

五、共性约束与最佳实践

5.1 权限与启动条件

  • 闪控球:需 ohos.permission.USE_FLOAT_BALL,仅限指定场景(抢单、比价、盯盘等),违规使用将受处罚。
  • 闪控窗:需 ohos.permission.FLOAT_VIEW(user_grant 权限),需动态申请。
  • 两者均仅允许应用在前台时启动

5.2 数量与互斥

  • 闪控球:每应用最多 1 个,系统最多 2 个。
  • 闪控窗:系统最多 1 个,每应用最多 1 个。
  • 同一应用已启动闪控球或画中画窗口时,无法启动闪控窗,需先停止前者。

5.3 生命周期管理

  • 必须注册 stateChange 回调,在 STOPPED 状态时主动取消所有监听off('click')off('stateChange') 等)并释放控制器引用,避免内存泄漏。
  • 闪控球的位置具有记忆功能(关闭后记录位置,下次启动自动恢复),但旋转屏幕或重启设备会恢复到默认位置(屏幕右上侧)。

5.4 尺寸适配建议

  • 闪控球:宽度自动适配内容,高度固定,无需开发者额外计算。
  • 闪控窗:务必先调用 getFloatViewLimits() 获取当前设备的推荐尺寸范围,再通过 setWindowSize() 设置合理大小;同时监听 onLimitsChange() 以动态响应配置变化。若设置超出范围,系统会自动裁剪,实际尺寸通过 onRectChange() 回调获取。

5.5 设备兼容性

  • 闪控球:支持 Phone、Tablet、PC/2in1(从 API 20 开始),模拟器支持需 DevEco Studio 6.0.1 Release 及以上。
  • 闪控窗:支持 Phone、Tablet、2in1(从 API 26.0.0 开始)。
  • 均要求 Stage 模型

六、总结

闪控球与闪控窗构成 HarmonyOS 轻量化窗口体系的两级载体:

  • 闪控球侧重“一键触达”,信息精炼、交互直接,适合高频快捷入口;
  • 闪控窗侧重“轻量驻留”,信息丰富、可自定义内容区,适合持续监测和轻任务处理;
  • 两者可绑定协同,实现从“小球”到“小窗”的平滑切换,满足复杂场景需求;
  • 开发时需严格遵循权限、数量、生命周期及尺寸适配规则,结合防窥保护等系统能力可进一步提升安全性。

开发者应根据业务诉求选择合适的形态,并严格遵循官方约束,确保用户体验的一致性与系统稳定性。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐