HarmonyOS 7 闪控球排队状态入口与主窗回跳:连载03(共08篇)【共创稿事节】
HarmonyOS 7 闪控球排队状态入口与主窗回跳:连载03(共08篇)【共创稿事节】
用户参加城市艺术夜展时,往往在预约完成后离开活动应用,临近入场才再次关注队列。完整活动列表适合查询多场活动,但一个持续占据视线的入口应当克制:只告诉用户当前排队阶段、序号和等待时间,点击后再进入凭证详情。HarmonyOS 的闪控球提供这类轻量信息窗口的系统形态。关键工作不是绘制一个圆形按钮,而是将前面建立的排队状态快照转换为系统模板需要的数据,并维护它与主窗口之间的交互关系。
前两篇已经建立 QueueSnapshot、EntryPhase、reduceQueue() 和活动凭证关联模型。这里沿用同一张凭证 entry-7264、同一场活动 art-night,不在窗口层重新计算排队结果。闪控球承载短状态,主应用依然是查看完整活动资料与凭证的入口。

一、先把信息密度控制住
闪控球由系统提供固定模板。floatingBall 模块包含 STATIC、NORMAL、EMPHATIC 和 SIMPLE 等模板;模板选定后,更新内容时应保持同一模板。活动排队需要标题和简短进度,采用 NORMAL 比把活动地点、入场方式和整张凭证都塞进小窗口更清楚。模板里的 title 和 content 均有长度要求,投影函数应当主动裁剪业务文案。
| 排队阶段 | 标题 | 内容建议 | 点击去向 |
|---|---|---|---|
| 已预约 | 入场已预约 | 查看活动时间 | 活动详情 |
| 排队中 | 排队中 · 28号 | 预计等待12分钟 | 排队详情 |
| 即将入场 | 请前往入口 | 查看入场指引 | 入场凭证 |
| 已入场 | 入场完成 | 查看活动详情 | 活动详情 |
| 凭证终止 | 凭证状态已更新 | 返回应用查看 | 凭证详情 |
这张表故意没有显示完整凭证号,也没有假设“当前序号减一”就是前方人数。等候时间继续使用服务端快照中的 waitMinutes。入口文案可以简短,但不能牺牲数据含义。对 CALLED 阶段应突出动作,而 EXPIRED 和 CANCELLED 则应停止给出正常排队的暗示。
系统窗口的生命周期和业务凭证的生命周期要分开。用户手动关闭闪控球,不等于取消预约;业务已入场,也不意味着系统窗口必然立即消失。两条状态线应该通过单独的控制器协调,而不是把 FloatingBallState 填进原有的 EntryPhase 枚举。
二、建立不携带敏感字段的投影
新增 logic/QueueBallProjection.ets,输入仍然使用连载01中的 QueueSnapshot。业务投影只生成短文案以及版本,不把窗口控制器、图标资源和权限逻辑混入业务模型。后续切换展示形态时,这部分可以直接复用。
import { EntryPhase, QueueSnapshot } from '../model/QueueModel';
export interface QueueBallText {
ticketId: string;
seq: number;
title: string;
content: string;
}
export function toQueueBallText(s: QueueSnapshot): QueueBallText {
let title: string = '入场进度';
let content: string = '返回应用查看详情';
switch (s.phase) {
case EntryPhase.RESERVED:
title = '入场已预约';
content = '查看活动时间';
break;
case EntryPhase.WAITING:
title = `排队中 · ${s.position}号`;
content = `预计等待${s.waitMinutes}分钟`;
break;
case EntryPhase.CALLED:
title = '请前往入口';
content = '打开入场凭证';
break;
case EntryPhase.ADMITTED:
title = '入场完成';
content = '查看活动详情';
break;
case EntryPhase.CANCELLED:
title = '预约已取消';
break;
case EntryPhase.EXPIRED:
title = '凭证已失效';
break;
}
return { ticketId: s.ticketId, seq: s.seq,
title: title.slice(0, 20), content: content.slice(0, 20) };
}
slice() 在此是面向中文文案的简化截取,工程中若需要处理表情、组合字符和复杂本地化文本,可以在内容适配层增加按字符簇截取的能力。seq 继续使用服务端凭证维度的单调版本;前端收到较旧的事件时,先由 reduceQueue() 决定是否接受,再把新的快照传给这个函数。不要因为窗口暂时不可见,就跳过版本检查。
从数据安全角度看,闪控球上展示的内容可能与其他应用同时出现在屏幕上。因此,票证核验内容、用户姓名和可直接使用的核验码应保留在主窗口的受控页面。即使应用自身拥有这些字段,紧凑投影也不应将它们拼进标题或内容。示例的 ticketId 仅用于内部关联,不参与用户可见文本。
三、封装系统闪控球的启动与更新
闪控球的入口为 @kit.ArkUI 中的 floatingBall。创建之前应检查 SystemCapability.Window.SessionManager 和 isFloatingBallEnabled();启动还需要 ohos.permission.USE_FLOAT_BALL,接入前应依据使用场景确认对应权限条件。当前场景在不满足使用条件时,可以保留主页面内的排队快捷卡片,不影响凭证的正常查询。
以下控制器接收有效的 UIAbilityContext 和返回主窗口所需的 Want。on('click') 用于处理用户点击,on('stateChange') 仅追踪系统窗口状态。启动与更新的模板固定为 NORMAL,避免更新时切换模板导致错误。
import { floatingBall } from '@kit.ArkUI';
import { common, Want } from '@kit.AbilityKit';
import { QueueSnapshot } from '../model/QueueModel';
import { toQueueBallText } from '../logic/QueueBallProjection';
export class QueueBallGateway {
private controller?: floatingBall.FloatingBallController;
private started: boolean = false;
private opening: boolean = false;
private lastSeq: number = -1;
constructor(private ctx: common.UIAbilityContext,
private mainWant: Want) {}
private onClick = (): void => {
if (this.controller) {
this.controller.restoreMainWindow(this.mainWant)
.catch((e: Error) => console.error(`返回应用失败:${e.message}`));
}
};
private onState = (state: floatingBall.FloatingBallState): void => {
this.started = state === floatingBall.FloatingBallState.STARTED;
if (state === floatingBall.FloatingBallState.STOPPED) {
this.lastSeq = -1;
}
};
async start(snapshot: QueueSnapshot): Promise<boolean> {
if (!canIUse('SystemCapability.Window.SessionManager') ||
!floatingBall.isFloatingBallEnabled()) return false;
if (this.opening) return false;
this.opening = true;
try {
if (!this.controller) {
this.controller = await floatingBall.create({ context: this.ctx });
this.controller.on('click', this.onClick);
this.controller.on('stateChange', this.onState);
}
if (this.started) return true;
const text = toQueueBallText(snapshot);
await this.controller.startFloatingBall({
template: floatingBall.FloatingBallTemplate.NORMAL,
title: text.title, content: text.content
});
this.lastSeq = snapshot.seq;
return true;
} catch (e) {
console.error(`闪控球启动失败:${String(e)}`);
this.controller?.off('click', this.onClick);
this.controller?.off('stateChange', this.onState);
this.controller = undefined;
return false;
} finally {
this.opening = false;
}
}
async update(snapshot: QueueSnapshot): Promise<void> {
if (!this.started || !this.controller || snapshot.seq <= this.lastSeq) return;
const text = toQueueBallText(snapshot);
await this.controller.updateFloatingBall({
template: floatingBall.FloatingBallTemplate.NORMAL,
title: text.title, content: text.content
});
this.lastSeq = snapshot.seq;
}
async stop(): Promise<void> {
const current = this.controller;
if (!current) return;
try {
if (this.started) await current.stopFloatingBall();
} finally {
current.off('click', this.onClick);
current.off('stateChange', this.onState);
this.controller = undefined;
this.started = false;
this.lastSeq = -1;
}
}
}
这里把 Promise 的完成与 stateChange 事件分开处理:方法完成表示操作请求得到响应,而窗口是否进入 STARTED 还应以生命周期回调为准。update() 只有在窗口处于启动状态且版本增加时才提交。较短时间内连续到来的多个进度变化,可以进一步在网关外进行合并更新,减少频繁刷新的视觉干扰。
start() 中的 catch 用来处理授权、设备能力、参数或窗口状态异常。如果系统返回权限相关错误,应检查权限配置和当前场景是否具备使用条件;如果是重复启动,应检查控制器是否被多个页面分别创建。用户关闭球后,业务排队仍在继续,下一次用户主动开启时可以依据最新快照重新提交内容。
四、把页面按钮接到同一份快照
在活动详情页中,由用户明确点击“开启简要状态”才尝试启动系统窗口。页面通过 this.getUIContext().getHostContext() 获取宿主上下文,构造返回当前应用的 Want;实际工程中的包名使用自身工程配置值。主页面仍使用连载02的 buildEventCards() 展示活动概况。
import { common, Want } from '@kit.AbilityKit';
import { QueueSnapshot, initialSnapshot } from '../model/QueueModel';
import { QueueBallGateway } from '../logic/QueueBallGateway';
@Entry
@Component
struct QueueEntryPage {
@State snapshot: QueueSnapshot = initialSnapshot();
@State hint: string = '可从此页面查看入场进度';
private gateway?: QueueBallGateway;
aboutToAppear(): void {
const ctx = this.getUIContext().getHostContext() as common.UIAbilityContext;
const want: Want = { bundleName: 'com.example.eventqueue',
abilityName: 'EntryAbility' };
this.gateway = new QueueBallGateway(ctx, want);
}
build() {
Column({ space: 16 }) {
Text('城市艺术夜展').fontSize(24).fontWeight(FontWeight.Bold)
Text(`当前阶段:${this.snapshot.phase}`)
Text(`序号 ${this.snapshot.position} · 等待 ${this.snapshot.waitMinutes} 分钟`)
Button('开启简要状态').onClick(async () => {
const ok = await this.gateway?.start(this.snapshot) ?? false;
this.hint = ok ? '已提交窗口启动请求' : '请继续在应用内查看进度';
})
Button('关闭简要状态').onClick(async () => {
await this.gateway?.stop();
this.hint = '已返回应用内查看方式';
})
Text(this.hint).fontSize(14).fontColor('#62748A')
}.padding(20).width('100%')
}
}
这段代码中的 initialSnapshot() 是前文的本地初始数据。正式事件到达后,仍由原有归约器更新 snapshot,再将接受后的新值传给 gateway.update()。页面切换时不应在每个组件的 aboutToAppear() 中重复启动闪控球;可以让单一网关实例由主 Ability 或专用业务管理器持有,明确谁负责开启、更新和关闭。

五、点击回到哪里,比开启更重要
restoreMainWindow(want) 用于点击闪控球后恢复主窗口。业务上不能只回到活动列表首页,让用户再次寻找原来的凭证。可以在主窗口恢复后依据内部选中的 ticketId 打开排队详情,再由页面确认凭证归属。Want 用来定位应用入口;具体业务选中状态应从可信的本地状态仓库恢复,不要依靠窗口标题去反向解析凭证身份。
跨应用展示时,用户可能在短时间内连续点按,主窗口恢复和页面加载是不同层级的动作。将恢复请求放在点击回调中,避免在后台收到队列事件时主动切换主窗口。若用户已结束本次活动,仍可以返回活动详情查看历史记录,而不是让窗口跳到一个不存在的排队页面。

六、围绕错误类型排查,而非反复重试
| 现象 | 检查重点 | 处理方向 |
|---|---|---|
| 启动接口返回权限错误 | USE_FLOAT_BALL 声明与使用条件 | 保留应用内入口并确认权限配置 |
| 创建返回 801 | 设备模式与窗口系统能力 | 停止创建流程,保持主页面可用 |
| 更新失败 | 模板是否更换、状态是否已停止 | 固定模板并按生命周期更新 |
| 返回主窗失败 | 是否来自用户点击及 Want 信息 | 检查应用入口和前置状态 |
| 序号出现回退 | 是否在网关外执行版本归约 | 以 ticketId、seq 拒绝旧事件 |
| 多个球重复出现启动请求 | 控制器是否被多处创建 | 统一拥有者并防止重复操作 |
关键诊断字段可以限定为事件版本、阶段、控制器状态和错误码。对于排队系统,完整票证内容与核验凭据无须进入窗口调试记录。尤其在网络状态不稳定时,要区分“业务进度尚未更新”和“系统窗口更新失败”:前者需要重新取得较新的队列快照,后者需要恢复窗口控制流程。混为一种错误会造成用户反复收到没有变化的等待时间。
七、为下一种窗口形态保留扩展点
闪控球已经把排队状态压缩成短标题和短内容,适合用户偶尔查看。需要同时展示场馆、进度、凭证入口和刷新操作时,就要使用信息量更大的窗口形态。下一篇继续复用 QueueSnapshot 和凭证关联结果,构造闪控窗的内容页及操作区。切换形态之前应停止当前冲突窗口,避免系统窗口之间争用同一位置。
本篇的核心是将三个层次分清:队列归约负责事实,短文案投影负责展示,floatingBall 控制器负责系统窗口生命周期。只要这条分界保持清楚,入口从页面扩展到闪控球也不会改变已有的排队判断。
更多推荐


所有评论(0)