闪控窗适合承载用户明确开启、持续存在又需要随手控制的任务。它看起来只是把一个 ArkUI 页面放进小窗,真正棘手的却是窗口命令与系统状态并不同步:start() 的 Promise 完成,不等于窗口已经进入 STARTED;连续点击两次“开启”,第二次可能撞上仍在进行的启动流程;窗口尚未启动完成时点击停止,简单地“再调一次 stop”也并不安全。

本文用一个可复现的示例工程 FocusLatch 拆解这个问题。Demo 页面叫 FocusSessionPage,会把会话 FOCUS-2601 的专注倒计时放入闪控窗。文中的 14:12:08、剩余 18:40、命令序号 17 与错误码均是演示数据,不冒充真实项目监控结果;接口行为与能力边界则按公开文档核对。

一、异常不在按钮,而在两条时间线

为了稳定复现,页面给“开启闪控窗”按钮加了一个自动连点入口:在 80ms 内提交两次 OPEN。第一次进入 floatView.create()、setUIContext() 和 start();第二次到来时,第一个 start() 可能已经返回 Promise,也可能仍在系统窗口服务内部排队。若业务层只用 controller !== undefined 判断是否可操作,就会把“控制器存在”误当成“窗口已启动”。

官方接口给出了非常明确的提示:start() 返回不表示启动流程结束,应以 onStateChange 收到 STARTED 为准;重复启动可能返回 1300030。stop() 也一样,Promise 返回不等于停止完成,真正的终点是 STOPPED 回调。这个约束意味着代码里同时存在两条时间线:命令提交时间线与系统状态回调时间线。

FocusLatch 把状态拆成六种:IDLE、CREATING、STARTING、STARTED、STOPPING、FAILED。这里没有直接照搬系统枚举,因为业务还需要表示控制器创建和页面装载。系统回调是事实来源,业务状态只是为了让按钮、日志和排队策略拥有可解释的中间态。

在演示数据中,命令 #17 OPEN 于 14:12:08.112 入队;第二个 OPEN 在 14:12:08.191 到达,被记为 DUPLICATE_IGNORED;STARTED 回调在 14:12:08.264 才出现。三条时间戳解释了一个常见误判:第二次点击发生时,第一次 Promise 的控制流看似已经向后推进,但系统状态仍未完成。

官方资料还限定了使用范围:闪控窗从 API 26.0.0 起提供,仅支持 Stage 模型,应先通过 floatView.isFloatViewEnabled() 判断设备能力;启动需要 ohos.permission.FLOAT_VIEW。这些前置条件不能塞进异常兜底里,否则“不支持”会被错误包装成“偶发启动失败”。

二、先做一个只接受意图的串行门闩

这段代码解决什么问题:把快速连点、页面重复触发和生命周期触发统一收口,确保任意时刻只执行一个窗口命令。

type WindowPhase = 'IDLE' | 'CREATING' | 'STARTING' |
  'STARTED' | 'STOPPING' | 'FAILED'
type Intent = 'OPEN' | 'CLOSE'

class SerialLatch {
  private tail: Promise<void> = Promise.resolve()
  private desired: Intent = 'CLOSE'
  private seq: number = 16

  submit(intent: Intent, task: (seq: number, intent: Intent) => Promise<void>): void {
    this.desired = intent
    const current = ++this.seq
    this.tail = this.tail
      .catch(() => undefined)
      .then(async () => {
        if (intent !== this.desired) {
          console.info(`[FocusLatch] #${current} superseded ${intent}`)
          return
        }
        await task(current, intent)
      })
  }
}

tail 不是互斥锁的炫技写法,它只做一件事:后来的任务必须等前一个任务收尾。desired 则保留最后一次用户意图。OPEN、CLOSE、OPEN 在短时间内连续到达时,不必机械执行三遍;还没开始的旧意图可以被覆盖,而已经提交给系统的调用必须等待回调确认,不能假装撤销。

容易出错的地方是把异常留在 Promise 链上。只要一次调用 rejected,后续 .then() 都不会运行,因此链头先用 .catch(() => undefined) 消化上一次失败,再让新的意图获得执行机会。真实工程还应把异常送入结构化日志,而不是像示例一样只依赖控制台。

另一个边界是“同意图重复”。FocusLatch 在业务层把 STARTING 期间的 OPEN 记为忽略,计数器从 0 增到 2,但不把它当失败。因为系统返回 1300030 的含义是重复操作,并不说明第一个启动必然失败。若每个重复操作都弹错误提示,用户会看到红色失败,同时小窗又在下一刻正常出现,交互比异常本身更糟。

三、控制器只创建一次,监听函数也只保留一份

这段代码解决什么问题:创建闪控窗、加载页面、注册稳定的状态回调,并以回调推进业务状态。

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

class FocusFloatHost {
  phase: WindowPhase = 'IDLE'
  controller?: floatView.FloatViewController
  readonly storage: LocalStorage = new LocalStorage({
    sessionId: 'FOCUS-2601', remainText: '18:40', taskState: 'RUNNING'
  })

  private readonly onState = (info: floatView.FloatViewStateChangeInfo): void => {
    const systemState = String(info.state)
    console.info(`[FocusLatch] state=${systemState}`)
    if (systemState.includes('STARTED')) this.phase = 'STARTED'
    if (systemState.includes('STOPPED')) this.phase = 'IDLE'
  }

  async prepare(ctx: common.UIAbilityContext): Promise<void> {
    if (this.controller) return
    if (!floatView.isFloatViewEnabled()) {
      this.phase = 'FAILED'
      throw new Error('FLOAT_VIEW_UNAVAILABLE')
    }
    this.phase = 'CREATING'
    this.controller = await floatView.create({
      context: ctx,
      templateType: floatView.FloatViewTemplateType.ROUNDED_RECTANGLE,
      isConfirmOnClose: true
    })
    this.controller.onStateChange(this.onState)
    await this.controller.setUIContext('pages/FocusSessionPage', this.storage)
    await this.controller.setFloatViewVisibilityInApp(false)
    this.phase = 'IDLE'
  }
}

LocalStorage 在这里传递的是会话标识、剩余时间文本与任务状态,不是用来保存控制器。控制器属于系统窗口资源,页面状态属于 ArkUI 数据,两者生命周期不同。把它们塞进同一个全局对象,往往会让页面重建时误以为旧控制器仍可用。

监听函数写成只读字段,原因是取消监听时需要传回同一个函数引用。若注册时和取消时各写一个匿名箭头,即便函数体完全相同,也不是同一个对象,offStateChange 无法准确解除原监听。文档明确提醒不再使用时取消监听以避免内存泄漏,这不是可有可无的“清理习惯”。

setUIContext() 被放在启动之前。官方说明重复调用会先销毁旧 UIContent 再加载新内容,因此 FocusLatch 不在每次 OPEN 时重新装载页面。倒计时变化通过同一个 LocalStorage 更新;只有会话页面结构真正改变时,才考虑更换 UIContent。

示例把前台可见性设为 false,表示应用主页面在前台时隐藏闪控窗,避免主页面和小窗重复展示同一倒计时。这个选择不是通用答案:直播控制台或跨页工具可能希望前台仍可见,应该根据产品语义决定,不能为了“看起来能跑”照抄。

上图是与本文字段对应的演示配图,不是实际 DevEco Studio 截图或真机测试证据。左侧工程树包含 FocusFloatHost.ets 与 FocusSessionPage.ets;中间标出 STARTING 门闩和 onStateChange;右侧模拟器显示 FOCUS-2601;HiLog 使用同一组 14:12:08 时间线。

四、Promise 只确认受理,回调才确认完成

这段代码解决什么问题:把 OPEN/CLOSE 的提交逻辑与系统完成事件分开,并处理启动期间到来的关闭意图。

class FocusWindowService extends FocusFloatHost {
  private latch: SerialLatch = new SerialLatch()
  private stopAfterStarted: boolean = false

  open(ctx: common.UIAbilityContext): void {
    this.latch.submit('OPEN', async (seq) => {
      await this.prepare(ctx)
      if (this.phase === 'STARTED' || this.phase === 'STARTING') {
        console.info(`[FocusLatch] #${seq} DUPLICATE_IGNORED`)
        return
      }
      this.phase = 'STARTING'
      await this.controller!.start() // 已受理,不把这里当成 STARTED
      console.info(`[FocusLatch] #${seq} start accepted`)
    })
  }

  close(): void {
    this.latch.submit('CLOSE', async (seq) => {
      if (this.phase === 'STARTING') {
        this.stopAfterStarted = true
        console.info(`[FocusLatch] #${seq} close queued`)
        return
      }
      if (this.phase !== 'STARTED') return
      this.phase = 'STOPPING'
      await this.controller!.stop()
      console.info(`[FocusLatch] #${seq} stop accepted`)
    })
  }
}

演示版展示了策略核心,但生产实现还要在 onState 收到 STARTED 时读取 stopAfterStarted,若为真则清零标记并提交 CLOSE。为什么不在 STARTING 时直接调用 stop?因为系统可能认为当前状态不支持停止;文档列出的 1300030、1300031 正是在提醒调用者,不要把异步状态机当同步布尔值。

同理,start() 的 Promise fulfilled 只能写成 start accepted,不能写 started success。这条日志命名规范很小,却能显著减少排查误导。建议把日志拆成 intent_received、api_accepted、state_callback、intent_settled 四类,再带上命令序号和会话 ID。这样即使日志穿插,也能还原因果关系。

错误处理需要分层。801 或能力判断失败应走不支持提示;201 指向权限或授权链路;1300030 在明确的重复场景可以降级为幂等命中;1300002、1300003 更接近控制器或窗口服务异常,不宜无限重试。自动重试前还要检查用户最后意图,用户已经点击关闭时,后台再悄悄重启窗口会越权。

手机运行演示里,页面顶部状态栏为 14:12,会话为 FOCUS-2601,剩余 18:40,当前窗口状态 STARTED。红色标注只指向状态迁移,不用于装饰。这个画面表达的是“回调已确认窗口启动”,而不是“start Promise 已返回”。

五、关闭页面不等于销毁闪控窗

闪控窗能在关联 UIAbility 退到后台后保持显示,因此主页面的 aboutToDisappear() 不能无条件 stop。用户从主页面返回桌面,恰恰可能是希望继续看倒计时。真正应该停止的条件是业务会话完成、用户在闪控窗执行关闭,或产品定义的显式终止动作。

但监听必须有明确归属。如果服务实例随 UIAbility 生命周期存在,可以在能力销毁阶段成对取消监听;如果服务被提升为更长生命周期的业务单例,注销时机也要随之上移。下面的释放代码强调的是成对关系,而不是建议在任意页面消失时调用。

这段代码解决什么问题:在服务真正结束时先完成窗口停止,再解除监听和丢弃控制器引用。

async dispose(): Promise<void> {
  const c = this.controller
  if (!c) return
  try {
    if (this.phase === 'STARTED') {
      this.phase = 'STOPPING'
      await c.stop()
      // 正式工程可等待 STOPPED 回调或设置有界超时
    }
  } catch (e) {
    const err = e as BusinessError
    console.error(`[FocusLatch] dispose code=${err.code}`)
  } finally {
    c.offStateChange(this.onState)
    this.controller = undefined
    this.phase = 'IDLE'
  }
}

这里仍有一个故意保留的边界:await c.stop() 后立刻注销监听,可能错过晚到的 STOPPED。对诊断要求高的工程应让回调完成一个 stopDeferred,并设置有界超时;超时后再解除监听。不能无限等待,因为窗口服务异常时会拖住整个销毁流程。

finally 里清引用是为了避免失败后继续操作一个状态未知的控制器,但也意味着再次打开需要重新创建。若产品允许窗口服务暂时故障后恢复,可以引入 BROKEN 状态和退避重建;不要在 catch 里递归调用 prepare(),那会制造没有上限的重建风暴。

诊断页把同一次演示的关键字段放在一起:Command #17、重复 OPEN 计数 2、STARTED 14:12:08.264、stopAfterStarted=false。03 负责展示用户看到的倒计时,04 负责解释命令为何没有执行两遍,两张图承担不同信息。

六、把“能开启”改成可验收的状态协议

FocusLatch 的验收不是看小窗有没有偶尔弹出来,而是验证协议:双击 OPEN 只产生一次 start 调用;STARTING 期间的 CLOSE 被排队,STARTED 后立即收敛为 STOPPING;重复回调不会让状态倒退;不支持设备在 create 之前结束;服务释放后再无状态日志。

建议至少覆盖五组测试序列:OPEN、OPEN→OPEN、OPEN→CLOSE、OPEN→CLOSE→OPEN、STARTED→CLOSE→CLOSE。每组都检查调用次数、最终状态、最后意图和监听数量。单测可以替换控制器为 fake,按计划延迟发出 STARTED/STOPPED;真机验证则关注权限、支持机型和系统交互,但不要把模拟器配图当真机证据。

还有三个产品边界值得写进评审:闪控窗必须由用户可理解的持续任务触发;窗口关闭确认是否开启要符合任务风险;主应用前台是否隐藏小窗要有一致规则。技术状态机只能保证命令不打架,不能替产品决定什么内容有资格常驻屏幕。

回到最初的异常,1300030 并不神秘。它只是暴露了业务层把“调用完成”“系统完成”“用户意图完成”混成了一件事。串行门闩解决命令竞争,状态回调给出系统事实,LocalStorage负责页面数据,成对监听守住资源边界。四条线分开后,闪控窗才从一个容易偶发失控的按钮功能,变成可以解释、可以测试、也可以安全退出的工程模块。

七、把偶发现象变成一张可执行的检查表

如果只靠手点两下按钮,很多竞争条件很难稳定重现。更可行的方法是给控制器外包一层可替换适配器,在测试环境中主动延迟 create、start 回调和状态回调。比如让 start 在 40ms 后 fulfilled,却在 160ms 后才发出 STARTED,再把第二次 OPEN 安排在 80ms。这个顺序正好覆盖“接口已经受理、系统还未完成”的空窗期,也对应本文 14:12:08.112 到 14:12:08.264 的演示时间线。

测试断言不要只看最终状态。OPEN→OPEN 最后为 STARTED 并不代表实现正确,因为中间可能真的调了两次 start,只是第二次被系统拒绝。应同时检查适配器调用计数为 1、重复意图计数为 1、没有向用户展示失败提示、命令序号仍能与回调关联。结果一致而路径不同,线上风险完全不同。

OPEN→CLOSE 更值得拆成三个时间点。CLOSE 在 create 之前到达时,旧 OPEN 尚未开始,可以直接被最后意图覆盖;CLOSE 在 STARTING 期间到达时,应设置 stopAfterStarted;CLOSE 在 STARTED 之后到达时,可以正常提交 stop。若三个时间点共用一句 if (controller) stop(),测试很容易在其中两个分支暴露不支持状态。

还有一种反向竞争:窗口已经 STOPPING,用户又点 OPEN。立即 create 新控制器会让旧窗口还未销毁、新窗口已经启动;忽略 OPEN 又违背最后意图。比较稳妥的处理是记录 desired=OPEN,等待 STOPPED,再复用或重建控制器。是否复用取决于控制器停止后的官方能力约束和工程验证,不能凭对象还在就假设状态可恢复。

状态回调自身也可能给业务制造噪声。页面重建误注册两次监听,同一个 STARTED 会处理两遍;旧控制器晚到的 STOPPED 可能把新控制器状态改回 IDLE。因此监听处理除了看 state,还应绑定 controller generation。每次真正重建控制器就增加代次,回调闭包带上注册时的代次;不是当前代次的回调只记录,不改变页面状态。这与网络请求的晚到结果隔离思路相似,但对象是窗口控制器,不能只比较会话 ID。

尺寸与模板切换同样要服从串行协议。setWindowSize() 和 switchTemplate() 都是异步操作,官方建议先读取 getFloatViewLimits() 并按模板限制设置尺寸。若任务状态变化时同时改模板、用户又触发关闭,尺寸调用不应越过 STOPPING。工程上可以把 RESIZE 作为低优先级意图,只在 STARTED 且当前 desired 仍为 OPEN 时执行;失败时保留旧模板,不要为了尺寸重试而重新启动窗口。

日志设计最好提前固定字段,而不是出问题以后临时拼字符串。建议包含 sessionId、controllerGeneration、commandSeq、intent、phaseBefore、phaseAfter、apiResult、systemState、errorCode 和单调时钟耗时。墙上时间便于人工阅读,单调时钟才适合计算阶段耗时;系统时间被校准时,两个时间戳相减可能出现负数。

隐私边界也要注意。倒计时会话 ID 可以使用不包含账号信息的短标识,日志不要写用户输入的专注事项、联系人或业务正文。闪控窗本来就会跨应用显示,页面内容更应该按“旁人可能看到”设计。锁屏、录屏、投屏和多用户环境下的可见性策略,应由产品和安全评审共同确认,状态机不能替代数据分级。

降级路径需要与主路径同样明确。isFloatViewEnabled() 为 false 时,FocusLatch 可以保留应用内倒计时和普通通知,但不能静默尝试不存在的接口。用户看到的文案应该是“当前设备暂不支持闪控窗,计时仍在应用内继续”,而不是“启动失败,请重试”。前者说明能力边界,后者会诱导用户重复点击,恰好放大启动风暴。

权限失败也不能无限弹授权。FLOAT_VIEW 的授权流程、用户拒绝后的再次引导和设置页跳转,应遵循当前官方指导。本文只讨论控制器状态,不虚构授权接口。实现中可把权限检查放在 prepare 之前,并缓存本次会话已拒绝状态;用户没有再次主动操作时,不自动发起新的授权请求。

最后是一条容易被忽略的观测指标:不仅统计失败率,还要统计重复意图被门闩吸收的次数、STARTING 到 STARTED 的分位耗时、STOPPING 超时次数和旧代次回调数量。失败率为零并不等于实现健康;如果重复意图持续增长,可能是按钮没有禁用、页面重复订阅或入口被多处触发。门闩能保护系统,却不应该长期遮住上游交互缺陷。

完成这些检查后,“闪控窗是否稳定”就不再依赖某位开发者恰好点出一次问题。每条状态迁移都有前置条件,每个异步结果都有归属,每次资源注册都有对应注销,每个降级都有用户可理解的解释。对一个常驻窗口而言,这些约束比成功弹出一次更接近真正的完成。

还有一项验收经常被“正常关闭”遮住:系统或用户从窗口外部结束闪控窗时,业务任务是否继续。窗口是任务的视图,不应天然成为任务本身。FocusLatch 的专注计时由独立会话服务保存,小窗被关闭只会改变展示状态;是否连带结束计时,要由关闭来源和产品规则决定。如果把 STOPPED 一律映射成任务 CANCELLED,用户只是收起展示也可能丢失正在进行的会话。

反过来,业务任务完成后必须主动收敛窗口。倒计时归零时先把 LocalStorage 中的 taskState 改为 COMPLETED,让窗口有机会展示完成态,再按产品设定的短暂展示时长提交 CLOSE。这个延迟应可取消:用户已经手动关闭时不要再排一次,新的会话启动时也不能让旧定时器误关新窗口。为延迟任务同样绑定 sessionId 和 controllerGeneration,才能避免跨会话串扰。

应用被系统回收后的恢复策略也要保守。只有内存里的控制器引用消失,并不证明系统侧窗口一定存在或一定消失。恢复时应先根据公开接口允许的状态查询与业务持久化判断,不凭本地布尔值直接调用 stop 或 start。若平台没有提供足够的恢复证据,优先回到应用内状态并让用户重新明确开启,而不是自动创建一个跨应用常驻窗口。

可测试性最终取决于依赖方向。页面只提交 OPEN/CLOSE 意图并渲染只读快照,服务持有门闩与控制器,适配器封装系统 API,日志层只消费事件。这样测试页面时不需要真实窗口,测试门闩时不需要 ArkUI,真机验证则集中在适配器与系统交界处。把所有代码写进一个 @Entry 组件虽然短,却会让任何生命周期变化都触碰状态机。

这也是本文没有给出“万能管理器”完整源码的原因。真正的工程实现必须结合会话服务归属、权限流程、窗口恢复策略和产品关闭语义。可以复用的是分层原则和状态协议,而不是复制一个类名就宣称完成接入。能力越接近系统级常驻展示,越需要把不可验证的假设写出来,再逐项消除。

参考资料:

Logo

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

更多推荐