HarmonyOS 7 新特性实战(17):用 floatView 让桌面闪控窗与导览共用播放会话
导览播放到一半,用户回到桌面处理其他事情,暂停按钮仍应控制当前播放的声音。这个需求很容易被实现成两个播放器:主页面保存一份进度,悬浮卡片再维护另一份。窗口看起来相似,播放状态却逐渐分离。
这次用 API 26 的 floatView 实现系统闪控窗,主页面与窗内面板共用 AVPlayer 会话。实验包含授权、显示、桌面暂停、恢复主窗、侧边栏收起和关闭。音源为随包生成的二十秒低音量循环测试音,便于观察播放与暂停,不包含真实展品讲解。
先确认这次接的是哪个窗口
floatView 从 API 26 开始提供,通过 ArkUI Kit 导入。系统管理外部窗口,应用提供内部页面。旧版 floatingBall 是闪控球;两者可以绑定,但独立闪控窗不需要为了显示小窗先申请闪控球权限。
| 对象 | 本实验使用方式 | 对应职责 |
|---|---|---|
| floatView | 圆角矩形模板 | 系统窗口、拖动、收起与关闭 |
| 自定义面板 | 作为窗口内容加载 | 展示当前会话并发送命令 |
| AVPlayer | 服务单例持有 | 播放、暂停及进度回调 |
| AppStorage | 两个入口共享状态 | 同步标题、会话 ID、状态与进度 |
窗口启动需要应用处于前台,并取得 FLOAT_VIEW 用户授权。设备支持查询之后,授权、创建、内容加载与启动仍可能失败,错误保留在主页面。约束参考闪控窗开发指南,具体签名按 API 26 SDK 声明核对。
权限通过后再创建控制器
只在用户点击“授权并打开系统闪控窗”时申请权限。拒绝授权不会创建应用内浮层冒充系统窗,也不会清除已经准备好的播放状态。
if (!floatView.isFloatViewEnabled()) {
throw new Error('当前设备不支持闪控窗');
}
const permission = await abilityAccessCtrl.createAtManager()
.requestPermissionsFromUser(context, ['ohos.permission.FLOAT_VIEW']);
if (permission.authResults.length !== 1 || permission.authResults[0] !== 0) {
throw new Error('闪控窗权限未授权,主页面仍可控制播放');
}
const controller = await floatView.create({
context: context,
templateType: floatView.FloatViewTemplateType.ROUNDED_RECTANGLE
});
this.controller = controller;
工程同时声明对应权限与用途说明。静态声明和动态授权分别承担配置与用户选择,不能省去其中一项。独立窗口没有接入 USE_FLOAT_BALL,也没有修改其他应用的签名或权限。
窗口内容页面需要登记在应用路由配置中。首版读取当前模板限制,使用允许的最大尺寸,避免写死像素值。
controller.onStateChange(this.stateCallback);
await controller.setUIContext('pages/FloatTourPanel');
const limits = floatView.getFloatViewLimits(
floatView.FloatViewTemplateType.ROUNDED_RECTANGLE);
await controller.setWindowSize(limits.maxSize);
AppStorage.set('floatState', '等待 STARTED 回调');
await controller.start();
这里的页面字符串是平台加载内容所需的路由标识。主窗与面板各自创建 UI,播放器不会随页面加载再创建一次。
Promise 返回以后,仍要等待窗口状态
start 的 Promise 返回不能作为“窗口已显示”的唯一依据。Demo 监听 onStateChange,通过 STARTED、IN_SIDEBAR、STOPPED 等状态显示真实过程。回调中的关闭原因可以区分应用主动停止与点击系统标题栏关闭。
this.stateCallback = (info: floatView.FloatViewStateChangeInfo): void => {
if (this.controller !== controller) { return; }
AppStorage.set('floatState', `${info.state} ${info.stopReason}`);
AppStorage.set('floatLive',
info.state !== floatView.FloatViewState.STOPPED &&
info.state !== floatView.FloatViewState.ERROR);
if (info.state === floatView.FloatViewState.STOPPED) {
controller.offStateChange(this.stateCallback);
this.controller = undefined;
this.stateCallback = undefined;
tourPlayback.pause().catch(() => {});
}
};
比较当前控制器可阻止旧实例事件改变新窗口状态。收到 STOPPED 后解除监听并清空引用,下次打开时创建新控制器。若保留已经停止的引用,同时又在入口判断“有控制器就直接返回”,窗口关闭一次便无法重开。
本实验选择“关闭系统窗暂停播放”。侧边栏收起只改变显示状态,停止按钮才释放播放器。这样用户关闭浮动控制入口时,不会留下看不到来源的循环测试音。
播放状态来自 AVPlayer 回调
会话服务准备随包 WAV,通过真实 AVPlayer 的 initialized、prepared、playing、paused 等状态推进。页面不会在点击按钮时直接把文字改为“播放中”。进度来自 timeUpdate,不使用独立定时器模拟播放。
player.on('timeUpdate', (position: number) => {
if (generation === this.generation) {
AppStorage.set('tourPosition', position);
}
});
player.on('stateChange', (state: string) => {
if (generation !== this.generation) { return; }
AppStorage.set('tourState', state);
if (state === 'initialized') {
player.prepare().catch((error: Error) => { finish(error); });
} else if (state === 'prepared') {
finish();
}
});
finish 结束准备等待,并清除准备超时定时器。准备失败时释放播放器并关闭 raw 文件描述符。正常播放期间文件描述符保持有效,停止或切换条目时再关闭。
音源为 48kHz、单声道、16 位 PCM WAV,数据区为 1920000 字节,另加 44 字节头部。播放器音量设为 0.15,这是实验参数,不保证不同设备的听觉响度一致。两个条目使用同一音源,切换重点是会话身份与资源释放。
用明确命令代替 toggle
主窗与面板都发送 pause、resume 或 stop,命令附带 sessionId。服务检查当前会话与执行互斥,再根据真实播放器状态执行。
if (sessionId !== AppStorage.get<string>('tourSessionId') ||
this.busy || !this.player) {
return;
}
this.setBusy(true);
try {
if (action === 'pause' && this.player.state === 'playing') {
await this.player.pause();
}
if (action === 'resume' &&
(this.player.state === 'paused' || this.player.state === 'prepared')) {
await this.player.play();
}
if (action === 'stop') {
await this.releasePlayer();
AppStorage.set('tourState', 'stopped');
}
} catch (error) {
AppStorage.set('tourError', (error as Error).message);
} finally {
this.setBusy(false);
}
已经暂停时再次暂停,不会重新播放。切换到玄龟后,带旧应龙会话 ID 的命令失效。准备阶段关闭窗口时,播放器还不能暂停,服务保存 pauseRequested,准备与播放启动结束后兑现暂停请求。服务测试专门覆盖了这一时序。
两个页面通过 StorageLink 观察相同的 AppStorage 键,按钮调用同一个服务单例;页面不持有播放器。实现中将进度字段命名为 positionMs,避免与 ArkUI 组件已有的 position 属性发生冲突。
桌面暂停、恢复主窗与关闭
2026-09-20,在 HBN-AL80 真机(API 26)上授权并打开系统闪控窗,应龙会话 yinglong-2 进入 playing。暂停后按 Home,桌面上的窗口保持 paused、12.2 秒;点击“回到导览”后,主应用恢复到导览页,窗口仍显示同一会话与暂停位置。


restoreMainWindow 的观察点是主页面恢复。浮窗可以继续保留,因此不能用“浮窗是否消失”判断恢复是否成功。2026-09-19 的 Pura X View API 26 模拟器也取得了相同路径:桌面上的 yinglong-2 暂停在 4.0 秒,返回后主页面与浮窗保持同一会话。
| 真机操作 | 实际回读 | 对应行为 |
|---|---|---|
| 授权后打开 | STARTED,状态值 1 | 系统窗启动 |
| 暂停并返回桌面 | yinglong-2,paused,12.2 秒 | 桌面继续显示当前会话 |
| 窗内返回导览 | 同一会话、同一暂停位置 | 主页面恢复 |
| 收起侧边栏 | 窗口状态 4 | 播放会话继续保留 |
| 主页面继续 | playing | 主窗控制共享播放器 |
| 应用关闭窗口 | 3 APP_STOP,paused | 应用关闭触发暂停 |
| 切换玄龟并重开 | xuangui-4,playing | 新会话与控制器重建 |
| 标题栏关闭 | 3 TITLE_BAR_STOP_CLICK,paused | 系统关闭触发暂停 |
| 主页面停止 | stopped | 结束播放并释放资源 |

桌面暂停、主窗恢复、侧边栏和两种关闭入口共同检查了窗口与播放器的状态关系。音源仍是测试信号;接入真实讲解时还需要处理资源内容和播放完成策略。
2026-09-22 在 HBN-AL80 真机重新打开应龙测试音,页面回读会话 yinglong-2 为 playing;点击“授权并打开系统闪控窗”后,系统浮窗单独创建 FloatTourPanel,浮窗内显示同一会话和 playing · 17.4 秒。这次运行补充了当前版本的系统窗证据:

接入真实讲解前还要补什么
真实展厅需要建立讲解资源与展品的对应关系,增加字幕、完成状态、下载失效和恢复策略。当前短循环实验的结束由停止按钮控制,不能直接用于无限后台讲解。跨账号切换也要明确停止旧会话。
当前尺寸取自创建时的系统限制。折叠、自由窗口与方向变化可进一步接入 onLimitsChange 和 onRectChange,重新检查面板布局。闪控球绑定需要另一项权限与独立交互验证,当前实验没有覆盖。ERROR 回调与系统异常回收后的恢复也应单独测试;正常关闭后重开不能覆盖所有错误状态。
模拟器曾在启动时返回 1300003 / Failed to get global float view limits,重试后成功,原因尚未定位。遇到该错误时应保留错误码与发生阶段,检查失败后控制器引用和监听是否清理,再允许用户重新打开。
六项服务测试使用系统接口替身验证权限拒绝、启动失败重试、旧会话失效、暂停幂等、准备中关闭及资源清理;模拟器与真机操作负责系统窗与播放状态回读。后续更换设备或扩展真实讲解时,继续保留这种分层验证,便于区分会话逻辑、窗口状态与设备输出问题。
2026-09-23 在 HBN-AL80(API 26)补做当前包复测:应龙测试音启动后,主页面依次回读 playing、paused、恢复 playing、stopped;打开系统闪控窗后,窗内显示同一 yinglong-2 会话。点击系统窗标题栏关闭,主页面回读 3 TITLE_BAR_STOP_CLICK 与 stopped。这确认短测试音的主窗/系统窗共享会话和关闭回收,不覆盖长期后台、异常回收或真实讲解资源。


参考:闪控窗开发指南、API 26 SDK 的 floatView 类型声明与 MediaKit AVPlayer 声明。
更多推荐

所有评论(0)