HarmonyOS 7 闪控窗口生命周期与资源回收:连载06(共08篇)【共创稿事节】

用户在城市艺术夜展的入口排队时,可能反复打开活动详情、退至其他应用、点击闪控球展开闪控窗,再从闪控窗返回主应用。这些动作会改变系统窗口状态,却未必改变排队业务状态。若把“主应用进入后台”直接当作“排队结束”,用户的凭证就可能被错误取消;如果每次恢复前台都新建一个窗口控制器,又会遇到重复启动、事件回调重叠以及资源无法回收的问题。

上篇已经把进度消息集中到 QueueStore,主页面与轻量窗口均以 ticketId 和 seq 读取同一快照。现在要管理的是窗口本身:什么时候创建、什么时候可见、何时停止、停止后清理哪些订阅,以及主窗口返回时如何继续展示最新快照。排队资格始终由业务服务确认,轻量窗口生命周期只影响信息呈现。

主应用与两种轻量窗口的生命周期关系

一、先区分三种不同的“状态”

一个完整的排队应用至少包含三条状态轴。第一条是 EntryPhase,描述预约、等待、叫号、入场及终止阶段;第二条是 UIAbility 的前台、后台与销毁过程;第三条是闪控球、闪控窗各自的启动、隐藏、切换及停止。三者可以同时存在,但不能互相替代。

发生的动作业务凭证窗口管理
用户切换到其他应用保留最新排队快照依系统窗口规则继续呈现
点击闪控球展开闪控窗凭证不变变更窗口形态
关闭闪控窗仍可在主应用查看凭证停止窗口并注销窗口监听
收到入场成功事件归约到 ADMITTED根据产品策略收起或停止窗口
重新进入活动页面查询并显示最新快照按需重建入口与观察关系

对于已经绑定的闪控球与闪控窗,floatView.bind() 要求两个控制器在启动前完成绑定。绑定后任一停止接口都会同时结束两个窗口;切换为另一种形态时,不能按“被停止后重新创建”的逻辑反复调用启动接口。闪控窗 onStateChange() 可能返回 STARTED、HIDDEN、STOPPED、IN_SIDEBAR、IN_FLOATING_BALL 和 ERROR,其中 IN_FLOATING_BALL 是形态变化的重要信号。

stop() 返回的 Promise 表示停止请求已处理,不能据此立即认为完整停止流程已经结束。窗口真正进入停止状态应结合 FloatViewState.STOPPED 回调判断。这个细节直接关系到重复启动防护和监听器释放的先后顺序。

二、集中管理窗口控制器与回调引用

沿用前面的 QueueBallGateway、QueueFloatGateway 与 QueueStore,新增 logic/QueueWindowLifecycle.ets 专门处理两个控制器的状态回调。下面的类接收已经创建好的控制器,监听器作为稳定的成员函数保存。只有保存相同的函数引用,才能在停止后准确注销,防止匿名回调长期挂在旧控制器上。

// logic/QueueWindowLifecycle.ets
import { floatView, floatingBall } from '@kit.ArkUI';

export class QueueWindowLifecycle {
  private view?: floatView.FloatViewController;
  private ball?: floatingBall.FloatingBallController;
  private active: boolean = false;
  private stopping: boolean = false;
  private bound: boolean = false;

  private readonly onViewState =
    (info: floatView.FloatViewStateChangeInfo): void => {
      if (info.state === floatView.FloatViewState.STARTED) {
        this.active = true;
      } else if (info.state === floatView.FloatViewState.STOPPED) {
        this.active = false;
        this.stopping = false;
        this.detach();
      } else if (info.state === floatView.FloatViewState.ERROR) {
        console.error('闪控窗状态异常');
      }
    };

  private readonly onBallState =
    (state: floatingBall.FloatingBallState): void => {
      console.info(`闪控球状态:${state}`);
    };

  attach(view: floatView.FloatViewController,
    ball: floatingBall.FloatingBallController, bound: boolean): void {
    this.detach();
    this.view = view;
    this.ball = ball;
    this.bound = bound;
    this.stopping = false;
    view.onStateChange(this.onViewState);
    ball.on('stateChange', this.onBallState);
  }

  async stop(): Promise<void> {
    if (this.stopping) return;
    this.stopping = true;
    try {
      if (this.bound && this.view) {
        await this.view.stop();
      } else {
        if (this.view) await this.view.stop();
        if (this.ball) await this.ball.stopFloatingBall();
      }
    } catch (error) {
      this.stopping = false;
      console.error(`停止轻量窗口失败:${String(error)}`);
    }
  }

  detach(): void {
    this.view?.offStateChange(this.onViewState);
    this.ball?.off('stateChange', this.onBallState);
    this.view = undefined;
    this.ball = undefined;
    this.bound = false;
  }
}

类中 bound 是业务层保存的绑定关系标记,并非系统接口自动提供的属性。采用已绑定模式时,调用一次 view.stop() 即可,避免对闪控球重复发出停止请求。未绑定模式需要分别停止,两者的实际状态仍以对应回调为准。工程可以进一步把已停止、正在停止、已启动等状态整理为枚举,针对多次快速点击实施更严格的串行锁。

detach() 只移除窗口监听器,不删除 QueueStore 的业务快照。停窗后,主页面仍可以通过既有仓储继续展示进度。如果项目还为窗口单独注册了 queueStore.listen(),应保存该函数并在窗口停止时执行;窗口事件监听、业务消息订阅和窗口内容页的 LocalStorage 是不同资源,应该分别清理。

三、让窗口内容订阅与停止事件成对出现

上篇的事件仓储已经返回取消订阅的函数。新增一个较小的会话管理器可以集中持有它,避免 FloatViewPage、主页面和网关分别创建多个长期监听。窗口成功启动后绑定当前凭证;收到 STOPPED 或用户主动关闭时,清理订阅与旧投影引用。

// logic/QueueWindowSession.ets
import { QueueSnapshot } from '../model/QueueModel';
import { queueStore } from './QueueStore';

export class QueueWindowSession {
  private dispose?: () => void;
  private currentTicket: string = '';
  private lastSeq: number = -1;

  open(ticketId: string,
    present: (snapshot: QueueSnapshot) => void): void {
    this.close();
    this.currentTicket = ticketId;
    this.dispose = queueStore.listen(ticketId, (snapshot: QueueSnapshot): void => {
      if (snapshot.ticketId !== this.currentTicket) return;
      if (snapshot.seq <= this.lastSeq) return;
      this.lastSeq = snapshot.seq;
      present(snapshot);
    });
  }

  close(): void {
    this.dispose?.();
    this.dispose = undefined;
    this.currentTicket = '';
    this.lastSeq = -1;
  }
}

这里的 present 是应用自身注入的窗口更新回调,不是系统接口。闪控球可把它接到上篇的 QueueBallRelay.offer();闪控窗可用 toFloatPanel() 转换后更新连载04建立的 LocalStorage。这既避免每个窗口直接访问网络,也使窗口关闭时的数据订阅清理具备一个明确入口。

用户在窗口上点击“关闭”,close() 应与窗口停止回调协调执行。对已经停止的会话重复调用 close() 不会造成数据丢失;但是主页面的订阅是另一份,不应该共用同一个释放函数。多个凭证交替成为关注对象时,先撤销旧 ticketId 的窗口观察,再注册新凭证,防止把旧活动的序号投到新活动窗口里。

DevEco Studio 中的闪控窗状态监听与注销结构

四、主应用前后台与轻量窗口的可见性策略

Stage模型下,主窗口与 UIAbility 生命周期密切相关,但闪控窗属于独立的辅助窗口。主应用切到后台时,闪控窗可以继续呈现,这正是排队轻量交互的价值所在。不能在 onBackground() 里一律停止闪控窗,否则用户刚切换应用就失去了排队入口。

闪控窗控制器提供 setFloatViewVisibilityInApp(boolean),用于控制应用在前台时闪控窗是否可见。对于进入完整排队页后避免重复遮挡的场景,可以把该选项设为 false;这是一项显示策略,并非排队业务状态。恢复到后台后的窗口展示仍须遵循系统对当前窗口状态的处理。

// logic/QueueWindowVisibility.ets
import { floatView } from '@kit.ArkUI';

export class QueueWindowVisibility {
  private controller: floatView.FloatViewController;

  constructor(controller: floatView.FloatViewController) {
    this.controller = controller;
  }

  async avoidCoveringMainPage(): Promise<void> {
    try {
      await this.controller.setFloatViewVisibilityInApp(false);
    } catch (error) {
      console.error(`设置主应用内可见性失败:${String(error)}`);
    }
  }

  async allowVisibleInMainPage(): Promise<void> {
    try {
      await this.controller.setFloatViewVisibilityInApp(true);
    } catch (error) {
      console.error(`恢复主应用内可见性失败:${String(error)}`);
    }
  }
}

不要把前后台事件和窗口创建动作写成无条件的一对一关系。更合适的做法是 UIAbility 的 onForeground() 触发业务快照读取和页面恢复,onBackground() 停止只属于完整页面的高频视觉刷新;轻量窗口根据其控制器状态及用户意愿决定是否继续呈现。若系统关闭关联主窗口而导致轻量窗口停止,应通过状态回调释放订阅,下次进入主应用后重新完成能力判断与控制器建立。

五、一次性处理异常、重复关闭和恢复入口

窗口停止时容易出现三类竞态:用户连续两次点击关闭,系统状态已变为 STOPPED 但代码还准备更新内容,或主应用恢复前台时旧窗口的回调晚到。治理的关键是保存窗口状态、让停止操作具备幂等性,并以最新业务快照作为重建时的数据来源。

对于 FloatViewState.IN_FLOATING_BALL,说明用户正在切换到闪控球,不应把它视为完全停止;对于 HIDDEN,也不应自动删除凭证缓存。只有真正进入 STOPPED 且相关会话结束后,才释放该窗口的监听和页面投影。业务层的 QueueStore 可以继续保留当前版本,避免重开后短暂回退到旧状态。

绑定的两个窗口在用户展开、收起时可能发生状态切换。此时若同时向两个控制器发送更新,应核对各自支持的当前操作状态,避免对已经停止的控制器执行更新。窗口更新失败可记录错误类别并等待重新建立窗口会话,不能因为一个视觉容器异常就覆盖业务快照,更不能反向修改排队状态。

建议沿着“创建—订阅—切换—停止—清理—重新进入”这条路径检查。关闭后重点确认旧回调没有重复触发、业务事件仍可进入仓储、重新打开时读取的是最新 seq。系统窗口状态与业务服务响应分别记录:一个负责“窗口能否显示”,另一个负责“用户是否可以入场”。

主应用后台与轻量窗口的状态管理界面

六、收束到可以持续维护的责任划分

经过本篇整合,排队状态不再寄托于某个页面或悬浮控件。QueueStore 保存可信业务快照;闪控球和闪控窗各有投影层;QueueWindowSession 管理业务订阅;QueueWindowLifecycle 处理窗口状态及监听释放。新增页面、改变展示样式或切换排队数据渠道时,不必重新定义“已入场”和“已失效”的含义。

特别需要坚持的是:用户关闭窗口与主动取消预约必须是不同的操作。前者只回收呈现资源,后者需要明确的业务请求与服务端确认。这一划分既让系统窗口管理更稳定,也减少了用户最关心的票务信息被界面操作意外修改的可能性。

相关接口见闪控窗开发指导、闪控球开发指导及窗口生命周期。

Logo

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

更多推荐