闪控窗最麻烦的一类问题,不是窗口太大或太小,而是它已经被系统停掉,业务却还握着旧控制器。页面上的按钮仍显示“正在运行”,再次点击又可能得到实例冲突;更糟的是,代码看到 STOPPED 就立刻重启,在应用已经进入后台或授权被关闭时,形成一串没有意义的失败调用。

这篇文章用 FloatRecoveryDesk 拆这条恢复链。入口页是 LaunchDesk,闪控窗内容页是 FloatTaskPage,诊断页是 StopTracePage。演示任务 FV-1608-731 在 16:08 运行,代次 731,清单完成 17/25,进度 68%。一次停止事件到达后,旧控制器完成监听解绑,页面进入 RECOVERY_READY,但不会自动启动。文中状态和数据用于说明设计,不冒充真实设备测试结果。

一、先承认“窗口停了”和“任务停了”不是一回事

FloatView 从 API 26.0.0 起提供,系统负责窗口框架与统一交互,应用加载自己的内容页。它可以在主窗口退到后台后继续显示,但创建和启动不是随时都能做。官方约束很明确:启动时应用必须在前台,必须已经获得 ohos.permission.FLOAT_VIEW;同一个应用只允许启动一个 FloatView,多开会返回 1300033;若浮球或画中画已经启动,也要先结束冲突能力。

这些约束决定了恢复逻辑不能只看一个业务布尔值。taskRunning=true 可能意味着数据处理任务还在继续,却不代表窗口控制器仍然有效。反过来,窗口被用户关闭,也不一定要求立即取消后端任务。Demo 把窗口状态与业务任务状态分开:前者由 FloatViewStateChangeInfo 驱动,后者由任务仓库维护。

窗口状态机只保留七步:IDLE -> PREFLIGHT -> CREATING -> RUNNING -> STOPPED_OBSERVED -> CLEANED -> RECOVERY_READY。RECOVERY_READY 不是“马上再开”,而是“旧资源已经收口,等用户在合规条件下重新触发”。任务状态仍可以是 TASK_RUNNING、TASK_PAUSED 或 TASK_COMPLETED,两条状态线不能互相替代。

演示记录的 stopReason=7 只作为原始诊断值保存,文章不擅自给这个数字命名。原因枚举和具体语义必须以目标 SDK 的 API 参考为准。工程里真正稳定的判断是 info.state === floatView.FloatViewState.STOPPED;stopReason 用于日志与后续分流,而不是拿一个未经核对的数字直接驱动自动重启。

二、启动前置检查要串行,不能让按钮制造并发创建

下面代码解决连续点击“打开闪控窗”导致两个 create() 同时起跑的问题。generation 标记一次启动意图,startInFlight 把创建链串行化。前置检查先确认能力开关和当前前台资格,再创建控制器、注册监听、加载页面并启动。

import { floatView } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';

class FloatRecoveryCoordinator {
  private controller?: floatView.FloatViewController;
  private startInFlight: boolean = false;
  private generation: number = 730;
  state: string = 'IDLE';

  async requestStart(context: common.UIAbilityContext,
    isForeground: boolean): Promise<void> {
    if (this.startInFlight || this.controller) return;
    this.generation += 1;
    this.state = 'PREFLIGHT';
    if (!isForeground || !floatView.isFloatViewEnabled()) {
      this.state = 'RECOVERY_BLOCKED';
      return;
    }

    this.startInFlight = true;
    try {
      this.state = 'CREATING';
      this.controller = await floatView.create({
        context,
        templateType: floatView.FloatViewTemplateType.ROUNDED_RECTANGLE
      });
      this.bindStateChange(this.generation);
      await this.controller.setUIContext('pages/FloatTaskPage');
      await this.controller.start();
      this.state = 'RUNNING';
    } finally {
      this.startInFlight = false;
    }
  }
}

这里 isFloatViewEnabled() 不是完整的权限请求流程,它只是前置条件之一。首次使用或用户拒绝后,需要通过 abilityAccessCtrl 查询并按交互时机请求 ohos.permission.FLOAT_VIEW。权限申请必须由清楚的用户动作触发,不能为了恢复窗口在后台偷偷弹授权。

controller 存在时直接返回,也不是用本地变量取代系统约束。它只阻止同一协调器重复创建。如果进程里有另一条路径已经启动了 FloatView,本地字段可能并不知道,系统仍会用 1300033 拒绝第二个实例。捕获该错误后应该进入 INSTANCE_CONFLICT,引导团队排查入口收敛,而不是无限重试。

启动链放在 try/finally 中,只保证 startInFlight 必定复位。若 create() 成功、setUIContext() 失败,控制器已经存在,不能假装回到 IDLE。实际工程应在 catch 中执行与创建阶段对应的清理,再根据错误码写入 CREATE_FAILED、CONTENT_FAILED 或 START_FAILED。阶段化错误比统一一句“打开失败”更有诊断价值。

三、STOPPED 到达后,先解绑再丢引用

官方示例在收到 STOPPED 后解除已注册的状态与尺寸监听,再把控制器置为 undefined。本文代码只展示状态监听,因此只调用 offStateChange();若工程实际注册了尺寸监听,则需在同一出口调用 offLimitsChange()。这个顺序看起来普通,却正是恢复能否稳定的关键。旧控制器如果仍被字段持有,下一次点击会被本地“已经存在”拦住;先置空再解绑,又可能丢掉唯一的清理入口。

下面代码解决停止事件与新启动意图交错的问题。回调闭包捕获注册时的代次;只有代次仍等于当前代次,才允许修改页面状态。旧代次的 STOPPED 只做必要清理,不覆盖新窗口状态。

private bindStateChange(boundGeneration: number): void {
  const target = this.controller;
  target?.onStateChange((info: floatView.FloatViewStateChangeInfo) => {
    console.info(`[FloatRecovery] task=FV-1608-731 gen=${boundGeneration} ` +
      `state=${info.state} stopReason=${info.stopReason}`);
    if (info.state !== floatView.FloatViewState.STOPPED) return;

    target.offStateChange();
    if (this.controller === target) {
      this.controller = undefined;
    }
    if (boundGeneration === this.generation) {
      this.state = 'RECOVERY_READY';
      console.info('[FloatRecovery] listeners=OFF controller=EMPTY progress=68%');
    }
  });
}

为什么保留 target?因为回调触发时,字段 this.controller 可能已经指向新的控制器。若直接对字段调用 offStateChange(),旧事件可能解绑新实例的监听。闭包持有注册对象,保证“谁注册,谁解绑”;字段相等检查则保证旧实例不能把新引用清空。

监听解除和引用清空是成对动作。offStateChange() 防止旧回调继续进业务层,controller=undefined 才允许下一次创建。若注册了尺寸变化监听,就在同一个清理函数中补上 offLimitsChange();若还注册了 onRectChange(),也必须对应 offRectChange()。原则是“注册什么就解除什么”,不为了看起来完整而凭空添加未成对的调用。

停止原因不应用来决定是否跳过清理。无论用户关闭、系统策略还是业务主动停止,只要状态已经是 STOPPED,该控制器都不再代表运行中的窗口。原因只影响下一步提示:用户主动关闭时提示保持克制,授权问题引导回到权限页,实例冲突转到入口排查;资源收口动作保持一致。

工程目录把协调器放在 service/FloatRecoveryCoordinator.ets,入口页面只发意图,闪控窗页面只消费任务快照,诊断页读取事件账本。下面的 DevEco Studio 风格配图按正文生成,展示目录、核心监听、右侧模拟器和 HiLog 的关系;它是说明图,不是真实 IDE 或真机测试证据。

四、恢复必须重新做前置检查

收到 STOPPED 时应用可能已经在后台,权限也可能被用户改变。直接在回调里调用 create(),既违反前台启动约束,也容易把一次正常关闭变成“关不掉”的糟糕体验。Demo 的恢复按钮只在主页面前台可见,并在点击时重新检查,而不是复用停止前的检查结果。

async retryFromForeground(context: common.UIAbilityContext,
  isForeground: boolean): Promise<void> {
  if (this.state !== 'RECOVERY_READY') return;
  if (!isForeground) {
    this.state = 'WAITING_FOREGROUND';
    return;
  }
  if (!floatView.isFloatViewEnabled()) {
    this.state = 'WAITING_PERMISSION';
    return;
  }
  await this.requestStart(context, true);
}

async stopByUser(): Promise<void> {
  const target = this.controller;
  if (!target) return;
  this.state = 'STOP_REQUESTED';
  await target.stop();
  // 不在这里抢先置空,统一等待 STOPPED 回调完成解绑。
}

主动停止时也不在 stop() 返回后立刻清空。stop() 发起停止,状态监听才是窗口生命周期的统一出口。若两处都清理,容易出现重复解绑或状态顺序颠倒。项目可以给等待 STOPPED 设置诊断超时,但超时只用于告警,不应擅自假定系统已经完成销毁。

手机运行图展示 RECOVERY_READY:旧控制器为空、监听均已关闭,任务进度仍是 68%。这恰好体现窗口生命周期与业务任务的分离。按钮文案是“回到前台后重新打开”,而不是“自动恢复”。顶部状态栏和页面字段均为生成式演示数据。

五、日志要能还原一次停止,而不是只打印最终状态

Demo 给每次窗口意图写入事件账本:PREFLIGHT、CREATING、RUNNING、STOPPED_OBSERVED、LISTENERS_OFF、CONTROLLER_EMPTY、RECOVERY_READY。同一事件带任务号和代次,避免新旧窗口日志混在一起。

建议至少验证五条序列。第一,连续双击打开只产生一个创建链。第二,另一路径已启动窗口时,当前入口收到 1300033 后不重试。第三,运行中关闭窗口,监听先解绑、字段后清空。第四,STOPPED 后应用处于后台,只进入 WAITING_FOREGROUND。第五,用户关闭授权后点击恢复,进入 WAITING_PERMISSION,不调用 create()。

异常注入时还要观察业务任务。窗口停止后,任务可能继续、暂停或由产品规则取消,但必须由任务协调器明确决定。不能因为 FloatTaskPage 销毁就默认任务成功,也不能因为任务仍运行就偷偷重建窗口。界面只显示事实:任务进度 17/25、窗口状态 RECOVERY_READY、控制器 EMPTY。

诊断图与运行图明显不同。它展示停止原因原始值、解绑顺序、控制器指针变化和恢复门禁。红圈标出 STOPPED_OBSERVED,红箭头指向 listeners=OFF -> controller=EMPTY,用于解释资源为什么已经安全收口。

六、三个容易被“看起来能用”掩盖的边界

第一,FloatView 能在主窗口退到后台后继续显示,不等于可以在后台创建。持续显示与启动资格是两件事。恢复必须回到前台并重新核对授权,这是系统约束,不是体验层可随意绕开的限制。

第二,应用只有一个 FloatView 实例。把多个业务卡片分别封装成多个控制器,会把产品需求变成系统冲突。更稳妥的方式是单控制器加载一个内容页,在内容页内部切换任务快照;若还要结合浮球或画中画,应先按官方约束设计互斥关系。

第三,生成式配图不能代替真机验证。正式验收应覆盖手机、平板与 2-in-1 目标形态,验证前后台、授权拒绝、用户关闭、拖入回收区域、侧边暂存以及浮球绑定场景。本文不声称 stopReason=7 对应某个真实原因,只保留原始值演示诊断链。

七、最后的判断

闪控窗恢复的核心不是“再调用一次 start”,而是先证明旧控制器已经退出:监听解绑、引用清空、代次失效。只有这个闭环完成,下一次创建才有清晰起点。

恢复入口也不应该藏在状态回调里。让用户回到前台、重新确认能力开关和授权,再主动触发,是对系统约束和用户意图都更诚实的做法。

FV-1608-731 的 68% 在窗口停止前后保持不变,正好说明了这篇文章的主张:窗口是任务的一个视图,不是任务本身。把两条生命周期拆开,故障才能被解释,恢复才不会变成新的故障源。

参考资料:

Logo

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

更多推荐