本文围绕 FloatView 类悬浮阅读卡的业务状态护栏展开。文中 DevEco Studio 与手机图为模拟设计,数据来自本地固定夹具;未实际调用系统悬浮能力、未编译部署,也不把模拟回调当成系统保证。示例专门把平台生命周期接口和应用层状态机分开说明。

一、画面还在,数据却来自上一轮

一个阅读助手在页面上保留一张小卡片:用户可以将文章收入稍后阅读,任务列表会异步更新摘要和剩余时长。卡片尺寸并不大,真正麻烦的是它可能跨越前台、后台、关闭、重新打开等不同事件。一次请求发出时,卡片属于旧的页面活动期;数据返回时,用户已经回到前台,甚至已经手动关闭原来的阅读任务。如果回调只拿任务 ID 寻找页面状态,就很容易把旧结果写回当前卡片。

这次把问题收敛到三个可验证的动作:第一,应用进入后台后,旧回调不能更新新前台内容;第二,某个任务关闭之后,即使它的请求仍在途中,也不能让卡片复活;第三,回调到达的实际顺序可以被打乱,但统计必须稳定。这里没有试图实现跨应用悬浮窗的全部权限、窗口形态或系统调度。那一层必须在具体设备和当前 SDK 下单独验证,不能用几张模拟图取代。

Demo 定为 FloatReturnFence。入口页叫 FloatQueuePage.ets,诊断页叫 FloatAuditPage.ets,核心守卫是 EpochCardGuard.ets。任务编号统一为 FLT-1010-25,固定数据集为 reading_queue_07,共有 NOTE-01 到 NOTE-07 七条阅读卡回调。最终业务状态预期是 FLOAT_GUARDED,这只表示规则模型达到预期,不意味着系统浮窗已经成功创建。

图01展示“回前台”和“关闭”两道边界。封面刻意不画系统窗口截图,因为本轮需要说明的是为什么业务收件箱必须拒收过期结果,而不是向读者证明某个悬浮 API 在某款设备上已运行。

二、把前台、卡片存活和数据有效拆成三份事实

需要先拒绝一种看上去省事的写法:用一个 isVisible 同时表示应用是否在前台、窗口是否显示、卡片业务是否有效。三个事实的来源不同。前后台归属于 UIAbility 生命周期;卡片显示取决于窗口状态及产品策略;数据能否写入取决于请求所属代次和任务是否关闭。混在一起就会产生“窗口可见所以回调合法”的误判。当前方案不让任何单一布尔值包办这些判断。

模型中保留 foreground、activeEpoch、closedIds 三个维度。每次进入一个新的前台活动期,代次递增。离开前台只把前台标记设为 false,已经发出的工作可继续完成,但结果不能直接投递给视图。关闭动作先把任务 ID 写入关闭集合,随后才发起真正的界面收口。这样做的取舍是允许网络或本地读取自然完成,却不允许它在不合适的时刻改变业务内容。

还有一个容易忽略的点:只校验代次仍不够。假设 NOTE-07 在当前代次内发出请求,用户在结果回来前点了关闭。它并未过期,但已经失去业务接受资格。所以 closedIds 必须与代次并列存在,而不是让“新代次”天然拥有覆盖一切关闭决定的权力。这里强调的是实体生命周期,而不是页面生命周期。

按这组固定事件,19:20:11 对应旧前台 epoch=4,19:20:12 进入后台,19:20:13 回前台得到 epoch=5。本轮显示四条已应用卡片、两条旧代次忽略、一条关闭后忽略,总回调数固定为七。关闭的是 NOTE-07。最终模拟态还保留 systemFloat=NOT_RUN 和 mode=FIXTURE_ONLY,避免把业务状态与系统执行结果混为一谈。

三、守卫只处理资格,不在这里直接修改界面

先解决“回调是否被接纳”。这个决策最好写成纯业务服务,便于把乱序输入逐条塞进同一套规则。如果把校验放在多个按钮的 onClick 中,关闭按钮也许会检查一次,而网络回调又会遗漏一次。守卫只返回 APPLY、STALE 或 AFTER_CLOSE,真正的状态更新留在调用方。规则集中后,页面重建时也更容易做回归检查。

// entry/src/main/ets/model/EpochCardGuard.ets
export type Delivery = 'APPLY' | 'STALE' | 'AFTER_CLOSE';
export interface ReadingTicket { taskId: string; epoch: number; requestId: string }

export class EpochCardGuard {
  private activeEpoch: number = 3;
  private foreground: boolean = false;
  private closedIds: Set<string> = new Set<string>();

  enterForeground(): number {
    this.activeEpoch += 1;
    this.foreground = true;
    return this.activeEpoch;
  }
  enterBackground(): void { this.foreground = false; }
  closeTask(taskId: string): void { this.closedIds.add(taskId); }
  currentEpoch(): number { return this.activeEpoch; }

  decide(ticket: ReadingTicket): Delivery {
    if (!this.foreground || ticket.epoch !== this.activeEpoch) return 'STALE';
    if (this.closedIds.has(ticket.taskId)) return 'AFTER_CLOSE';
    return 'APPLY';
  }
}

这里的 Set<string> 是进程内状态,主要负责当前页面活动期的门禁,绝不等同于跨重启的持久化关闭账本。产品如果要求用户主动关闭的条目在下一次启动时仍然隐藏,就要把关闭记录同步到合适的业务存储,并与任务清单版本一起恢复。否则进程重启后 Set 清空,是一个很真实的边界,而不是因为接口失效。

顺序上应先检查“是否仍属于当前前台活动期”,再看“该任务是否已关闭”。这会让旧轮次中已经关闭的条目被归为 STALE,而不是重复计入“关闭后忽略”。原因并不是后者不重要,而是统计口径必须确定。一个输入只能命中一条拒绝原因,问题排查时才不会出现总回调数七、分类数量却加出八的结果。

对于生产环境,还需要在 requestId 上做幂等保护。当前样例没有重复请求的独立输入,因此没有把去重数字虚构到演示界面里;未来增加重试时,应按请求 ID 建账并限定记录保留时间。否则一个有效代次内重复到达的成功回调可能让计数增加两次。守卫可以扩展,但不能为了看起来“功能完整”偷偷把未测试的规则写成已验证结论。

四、Ability 生命周期只发出事实,不直接发起浮窗写入

官方生命周期文档确认 UIAbility 存在 onForeground() 和 onBackground()。这两个方法适合提供前后台事实,但不能由此推导所有系统窗口模式下的绘制时机。尤其应用进入多任务、失焦与真正后台之间存在其他事件,不能只凭这一组回调声称系统浮窗已经关闭或仍然可见。这里把生命周期当作一条输入通道,业务守卫最终依然要求实体 ID 与 epoch 同时合法。

// entry/src/main/ets/entryability/EntryAbility.ets(生命周期接线摘录)
import { UIAbility } from '@kit.AbilityKit';

export default class EntryAbility extends UIAbility {
  onForeground(): void {
    AppStorage.setOrCreate<boolean>('floatForeground', true);
  }
  onBackground(): void {
    AppStorage.setOrCreate<boolean>('floatForeground', false);
  }
  onDestroy(): void {
    AppStorage.setOrCreate<boolean>('floatForeground', false);
  }
}

这里没有在 Ability 里直接调用 EpochCardGuard.enterForeground(),是有意的。生命周期对象负责广播事实;业务协调器负责把事实变成代次变更。如果两边都递增,会导致一次回前台变成两个 epoch,正好把有效回调误杀。具体接线需要在应用入口选择唯一所有者,例如页面级会话协调器;各处监听只能观察和转发,不应该都认为自己可以创建新会话。

AppStorage.setOrCreate 可以在示例中承载简单布尔状态,但它不是队列,不保证某个远端异步任务看到前台标记与用户视觉感知完全同步。生产落地时,应当把事件序列写入集中协调器,且对已经注册的监听在页面消失时解除订阅。否则同一个页面反复进入可能累积多个观察者,回调数和显示次数都会莫名增长。这个释放问题经常被浮窗外观掩盖,所以必须写在生命周期一节,而不是只写“需要注意”。

系统 FloatView 能力和应用子窗口的生命周期、权限、宿主条件并不自动一致。官方 FloatView 指南是能力边界的入口,具体 API 签名与支持设备应以当前文档和真实工程验证为准。本文没有硬写 createFloatView() 这类未核实的示例方法,也没有用普通组件的显隐效果替代系统悬浮能力证明。这样会少一段“酷炫代码”,却能让工程边界更可信。

五、七条回调怎样走出同一个确定结果

应用层夹具定义七条记录。NOTE-01 至 NOTE-04 属于当前 epoch=5,分别应用一次;NOTE-05、NOTE-06 持有旧 epoch=4,即使内容合法也只能被忽略;NOTE-07 在有效代次中提交过请求,但随后收到关闭命令,最后到来的结果归类为 AFTER_CLOSE。这里的七指回调数量,前后台事件和关闭动作是额外的控制事件,不参与回调总数。这个统计约定写清楚后,页面、日志和结论才不会相互打架。

// 固定输入夹具,模拟顺序而非真实网络/FloatView回调
import { EpochCardGuard, ReadingTicket, Delivery } from './EpochCardGuard';
const gate: EpochCardGuard = new EpochCardGuard();
const first: number = gate.enterForeground(); // 4
 gate.enterBackground();
const active: number = gate.enterForeground(); // 5
const list: ReadingTicket[] = [
  { taskId: 'NOTE-01', epoch: active, requestId: 'r01' },
  { taskId: 'NOTE-02', epoch: active, requestId: 'r02' },
  { taskId: 'NOTE-03', epoch: active, requestId: 'r03' },
  { taskId: 'NOTE-04', epoch: active, requestId: 'r04' },
  { taskId: 'NOTE-05', epoch: first, requestId: 'r05' },
  { taskId: 'NOTE-06', epoch: first, requestId: 'r06' }
];
const decisions: Delivery[] = list.map((ticket: ReadingTicket) => gate.decide(ticket));
gate.closeTask('NOTE-07');
decisions.push(gate.decide({ taskId: 'NOTE-07', epoch: active, requestId: 'r07' }));
// 期望:APPLY×4, STALE×2, AFTER_CLOSE×1

这段刻意不依赖真实计时器,因为它要验证的是决定与输入的关系。如果测试用 setTimeout 依赖毫秒级先后,运行快慢可能改变断言结果,尤其连续执行八轮素材制作时,测试通过反而带上随机性。真实业务自然还有网络取消、请求超时、图片句柄释放等问题,但这组固定样例先把因果顺序和验收口径钉住。

图02是白色主题 DevEco Studio 的布局示意:左目录、中间状态守卫、右侧模拟器和底部日志。图里出现的 FLT-1010-25、reading_queue_07、epoch=5、callbacks=7、applied=4、stale=2、afterClose=1 与本节统一。图像本身没有运行工程,日志文本属于用固定夹具设计出来的展示内容。工程编译是否成功需要另行在 DevEco 环境里确认。

六、页面只呈现当前会话,不替被拒绝的请求找借口

FloatQueuePage 不应该因为 NOTE-05、NOTE-06 的异步结果晚到,就把它们再次加入当前队列。推荐让协调器输出一份可直接绑定的快照:已应用的四张卡、两条被拒旧回调、一条关闭后忽略,以及 FLOAT_GUARDED 状态。页面只是快照的消费者,不能反过来利用列表长度推断该保存多少条请求。用户关闭后也不再出现 NOTE-07 的更新,避免卡片像“鬼影”一样反复浮现。

实际产品往往还会给卡片加缩略图。缩略图是异步资源,除了业务回调门禁,还要考虑图片解码和资源占用。如果图片获取已经完成而业务资格已失效,结果不应进入 UI;已经被创建的本地句柄应按该 API 的生命周期释放。如果尚在传输,应根据可用接口尝试取消请求,但不能把“调用了取消”当作回调绝对不会再到达。取消是节约资源的优化,资格校验才是状态正确性的底线。

// entry/src/main/ets/pages/FloatQueuePage.ets,显示层示例
interface ReadingCard { id: string; title: string }
@Entry
@Component
struct FloatQueuePage {
  @State cards: ReadingCard[] = [
    { id: 'NOTE-01', title: '湖畔的清晨' },
    { id: 'NOTE-02', title: '一杯咖啡的时间' },
    { id: 'NOTE-03', title: '春天的颜色' },
    { id: 'NOTE-04', title: '城市的傍晚' }
  ];
  build() {
    Column({ space: 10 }) {
      Text('悬浮阅读卡任务台').fontSize(24).fontWeight(FontWeight.Bold)
      Text('FLT-1010-25 · reading_queue_07 · epoch 5')
      ForEach(this.cards, (card: ReadingCard) => {
        Text(card.id + '  ' + card.title).width('100%').padding(12)
      }, (card: ReadingCard) => card.id)
      Text('FLOAT_GUARDED · 仅业务规则演示')
    }.width('100%').padding(16)
  }
}

这不是一段会自动创建系统悬浮窗的代码,它只是入口页如何消费业务状态的最小示例。在正式工程中,要把快照由协调器写入声明式状态变量,并避免异步回调直接操作已经失效的组件实例。ForEach 使用稳定任务 ID 做键,也不是为了优化一时的性能,而是为了防止重新排序或关闭项目时出现组件身份误复用。键稳定不能替代业务门禁,两者处在不同层。

图03采用纯手机画面表现任务清单:上方有完整系统状态栏,显示七个任务条目,四个有效卡片、两个旧任务和一个已经关闭的卡片。屏幕中的图片只用于帮助理解内容类型,与接口调用、系统悬浮显示或真实截图没有对应关系。页面底部保留 FIXTURE_ONLY 的提示,就是为了防止读者把这张图误用为运行证据。

七、诊断页的任务不是报喜,而是解释每一次拒绝

业务拒绝必须能被解释。若只记录一个 ignore=true,后续看到两个旧回调和一个关闭后回调混在一起,很难判断究竟是页面切换太快,还是关闭后又被服务偷偷刷新。建议在调试态区分 APPLY、STALE 和 AFTER_CLOSE,日志附上 taskId、ticket epoch、current epoch 及前台状态。不要直接记录文章正文、用户搜索关键词、完整设备标识等内容;规则核对所需的上下文通常比业务内容少得多。

这次时间线明确为 19:20:11 FOREGROUND epoch4、19:20:12 BACKGROUND epoch4、19:20:13 FOREGROUND epoch5、19:20:14 APPLY NOTE-01~04、19:20:15 STALE NOTE-05/06、19:20:16 CLOSE NOTE-07、19:20:17 AFTER_CLOSE NOTE-07。时间只是固定夹具设计值,非采集到的真实 HiLog。把控制事件和业务结果分开计数,七个回调才会得到四加二加一的稳定汇总。

验收最好增加两个逆序用例:先关闭,再收到同代次结果;以及先收到旧代次数据,再进入前台。前者必须命中关闭门禁,后者必须因为前台状态或代次不符而拒绝。模型还需要故意将事件顺序随机打乱,检查分类总和是否始终等于有效输入总数。如果某些场景对事件顺序本身有业务语义,则测试必须显式声明允许的偏序关系,不能随意打乱后期待每一种排列都有同一结果。

八、资源回收、失败状态与系统能力要分开验收

把卡片隐藏和释放业务资源视为两步。用户点击关闭后,应立即取消后续 UI 接纳资格;随后才能尝试取消网络请求、停止定时器、销毁订阅并释放由该卡片持有的资源。若清理失败,记录可重试的释放告警,但不恢复卡片显示资格。这样即便网络取消报错,关闭动作仍然对用户有效。反过来,不能因为后台动作被触发,就直接删除用户仍然计划稍后恢复的任务数据。

需要给 onDestroy 预留兜底路径,防止 Activity 已经退出,异步请求却抓着页面引用不放。对于当前示例,守卫没有订阅系统窗口,所以只需在结束时丢弃 Set 和状态快照;接入真实 FloatView 后,创建与销毁必须成对,权限失效、宿主不存在以及窗口关闭失败都要成为独立验收项。只有相关官方 API、支持形态和权限实际确认之后,才能把这些内容写进可运行的窗口控制代码。

本地夹具的完成结论是:FLT-1010-25 七个结果按照预期分配为四次应用、两次旧代次忽略和一次关闭后忽略,最新活动代次为五,最后状态 FLOAT_GUARDED。这里的“完成”只描述规则输出,systemFloat=NOT_RUN 必须同屏显示。未来补齐 DevEco 构建、真实设备前后台、系统窗口关闭与重启恢复测试后,才有资格判断实际平台行为。

九、边界还在,才方便下一次扩展

这组问题表面上是“回前台后的旧请求”,本质是异步结果没有携带足够明确的身份。当任务 ID、页面活动代次和关闭记录合在一起决定资格,视图重建就不再等同于业务重新接纳。不同系统形态下,FloatView 或应用子窗口的具体能力边界可能变化;真正应该长期稳定的是这套与平台调用分离的接收规则。

下一阶段如果要支持多窗口同时打开同一个任务,需要给每个窗口实例增加自己的 windowSessionId,而不只看共享的 activeEpoch。如果要支持跨进程恢复,则应把关闭账本和任务版本落盘;若接入后端推送,还要定义服务端事件序号与客户端 epoch 的对应关系。扩展之前先问:谁拥有这次结果?谁仍然有资格消费它?这两个问题答不清楚,窗口做得再漂亮,也无法保证阅读卡真正可用。

还要避免将“用户点击关闭”与“系统实际窗口销毁”画成同一个瞬间。业务层可以先撤销接收资格,界面层再等待关闭动画或系统回调结束。两者之间短暂存在的卡片外形只代表视觉过渡,不能重新获得读取新结果的权限。若动画中途被打断,必须仍然维持关闭标记,避免从可见性倒推业务有效性。这个边界能减少偶发动画和真实任务状态之间的相互污染。

某些请求完成后会触发额外的链式操作,比如更新角标、写历史记录、预取下一篇文章。资格被拒绝时,不能只禁止修改声明式状态,却仍然允许这些副作用继续发生。协调器最好先一次性判定资格,再把被允许的业务动作交给单一提交函数。否则UI看上去没有复活,后台账本却被旧请求写入了新数据,下一次进入页面仍会看见污染后的结果。

如果用户快速点击“关闭”和“重新打开”,旧的任务 ID 也许会被再次使用。此时只存关闭集合无法判断新打开的业务对象是否与之前同一实例,应当为每次打开引入新的 taskGeneration。本稿刻意没有把重开纳入七条固定结果,因此它是明确的扩展风险。测试应新增“关闭后重开同ID,再收到旧回调”,并坚持先验证新实例身份,不能让旧回调以新卡片的外壳绕过门禁。

最后一类验收是缺陷追踪而非功能成功:强制抛错、断网、系统回收、返回前台时页面未完成初始化,应该分别留下可识别的诊断码;在任何失败路径都不能让窗口或请求句柄变成永久持有。读者在复刻 Demo 时,如果暂时没有系统悬浮窗口权限,可以先验证守卫,再单独接入真实能力。这样即使平台条件受限,仍能得到一套可审计的业务状态模型,而不是用截图替代缺失的验证。

参考与核查边界:华为官方 FloatView 开发入口 https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/float-view-guide ;官方 UIAbility onForeground/onBackground 示例 https://developer.huawei.com/consumer/cn/doc/doccenter-industry-solutions/appmask-0000002749819583 (2026-09-24 更新);窗口生命周期相关参考 https://developer.huawei.com/consumer/cn/doc/doccenter-dev-faq/faqs-arkui-1022 。本稿只对上述生命周期与业务状态边界负责,不暗示系统悬浮接口已通过编译或审核。

Logo

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

更多推荐