04 结束时,QuickDock 已经能处理两个任务、前后台切换和一次 FLOAT_SURFACE_LOST 恢复。

看起来功能已经很完整,我真正开始做长时间测试以后,问题反而集中出现了。

最明显的一次是:连续显示 / 隐藏闪控窗十几轮,再从闪控球恢复回来,屏幕上明明只有一个窗口,HiLog 里同一条进度却打印了两次。继续跑几轮以后,同一条任务状态变化会触发三次 Adapter update。

这类问题比“窗口打不开”更麻烦。

因为用户看到的是正常 UI,后台却已经悄悄积累:

重复 listener
重复 timer
失效 adapter
隐藏窗口实例
旧 ball 实例

第五篇不再加新能力,只解决资源归属。

本轮固定数据:

taskId:
float_20261002_05

activeJob:
upload_release_02

progress:
76%

cycles:
12

showCalls:
24

duplicateCreateBlocked:
11

floatWindowId:
quickdock_float_01

ballId:
quickdock_ball_01

销毁前:
window=1
ball=0
listener=1
timer=1
adapter=1

销毁后:
window=0
ball=0
listener=0
timer=0
adapter=0

disposeCost:
33ms

status:
RESOURCE_CLEAN

一、屏幕上只有一个窗口,不代表工程里只有一个资源

04 的异常恢复让 Float View 可以重建。

如果只看视觉结果:

旧窗口消失
新窗口出现

很容易觉得资源已经替换完成。

但真正需要检查的是:

旧 Window 对象
旧 Adapter
旧 Task listener
旧 UpdateScheduler timer
旧 Floating Ball

有没有一起退出。

我后来把 QuickDock 的显示层资源归成五类:

FLOAT_WINDOW
FLOATING_BALL
TASK_LISTENER
UPDATE_TIMER
ADAPTER

任何一个遗漏,都可能在后续切换里放大。

所以 05 新增:

registry/
└── QuickDockResourceRegistry.ets

不是为了做一个“万能资源管理器”,而是给所有显示层资源一个统一可观测入口。

二、谁创建资源,谁负责注册

第一条规则非常简单:

创建点和注册点必须在同一个 Owner 里。

以前 FloatManager 创建 Adapter,DisplayModeCoordinator 负责 listener,UpdateScheduler 又自己创建 timer。销毁时需要跨三个对象猜谁还活着。

现在资源创建后立即登记:

export type ResourceType =
  'FLOAT_WINDOW' |
  'FLOATING_BALL' |
  'TASK_LISTENER' |
  'UPDATE_TIMER' |
  'ADAPTER'

export class QuickDockResourceRegistry {
  private resources:
    Map<
      ResourceType,
      Set<string>
    > = new Map()

  register(
    type: ResourceType,
    id: string
  ): void {
    let bucket =
      this.resources.get(type)

    if (bucket === undefined) {
      bucket =
        new Set<string>()

      this.resources.set(
        type,
        bucket
      )
    }

    bucket.add(id)
  }

  unregister(
    type: ResourceType,
    id: string
  ): void {
    this.resources
      .get(type)
      ?.delete(id)
  }
}

这段代码不负责真正销毁系统资源。

它只记录:

现在应该还有什么

真正释放仍然由资源 Owner 完成。

三、重复 show 必须在创建之前就被挡住

本轮总共调用:

showCalls=24

其中很多来自:

主页面进入
从闪控球恢复
Surface 恢复
前台恢复
用户再次点显示

如果每个入口都直接 create(),窗口实例一定会越来越多。

所以 QuickDockFloatManager 的 create 逻辑现在有单实例守卫:

export class QuickDockFloatManager {
  private created:
    boolean = false

  private readonly windowId:
    string =
      'quickdock_float_01'

  async ensureShown(
    snapshot:
      QuickTaskSnapshot
  ): Promise<void> {
    if (this.created) {
      await this.update(
        snapshot
      )

      QuickDockMetrics
        .recordDuplicateBlocked()

      return
    }

    await this.adapter.create(
      this.windowId
    )

    this.created = true

    QuickDockResourceRegistry
      .shared()
      .register(
        'FLOAT_WINDOW',
        this.windowId
      )

    await this.adapter.show(
      snapshot
    )
  }
}

当前 12 轮压力测试里:

duplicateCreateBlocked=11

这不是 11 次失败。

它说明 11 次“本来可能重复创建”的调用被正确降级成 update。

四、listener 最容易被“异常恢复”悄悄复制

窗口重建以后,我曾经写过:

rebuild
→ bind listener

却忘了旧 listener 是否已经解除。

于是:

listenerCount
1 → 2

页面仍然正常,直到下一次 TaskStore 更新才暴露。

所以 listener 现在也有显式 ID:

export class TaskSubscriptionOwner {
  private readonly listenerId:
    string =
      'quickdock_task_listener'

  private bound:
    boolean = false

  bind(): void {
    if (this.bound) {
      return
    }

    FloatTaskStore.shared()
      .on(
        this.listenerId,
        this.onSnapshot
      )

    QuickDockResourceRegistry
      .shared()
      .register(
        'TASK_LISTENER',
        this.listenerId
      )

    this.bound = true
  }

  unbind(): void {
    if (!this.bound) {
      return
    }

    FloatTaskStore.shared()
      .off(
        this.listenerId
      )

    QuickDockResourceRegistry
      .shared()
      .unregister(
        'TASK_LISTENER',
        this.listenerId
      )

    this.bound = false
  }
}

这条写法的重点不是 ID 字符串,而是:

bind / unbind 幂等

同一个 Owner 重复 bind 不会多注册一份。

五、Timer 不能因为 hide 就忘记

QuickDock 的进度 UI 还有一个 250ms 合并 Timer。

它属于:

显示更新调度

而不是:

业务任务

窗口隐藏以后,如果 Scheduler 仍然留着 pending timer,可能发生:

窗口已经 hide
timer 到点
Adapter update

轻则多一条错误日志,重则访问已经 dispose 的 Adapter。

所以 05 把 Timer Owner 也收进 Registry:

export class UpdateScheduler {
  private timer:
    number = -1

  schedule(
    callback:
      () => void
  ): void {
    if (
      this.timer >= 0
    ) {
      return
    }

    this.timer =
      setTimeout(
        () => {
          this.timer = -1

          QuickDockResourceRegistry
            .shared()
            .unregister(
              'UPDATE_TIMER',
              'progress_update'
            )

          callback()
        },
        250
      )

    QuickDockResourceRegistry
      .shared()
      .register(
        'UPDATE_TIMER',
        'progress_update'
      )
  }

  cancel(): void {
    if (
      this.timer < 0
    ) {
      return
    }

    clearTimeout(
      this.timer
    )

    this.timer = -1

    QuickDockResourceRegistry
      .shared()
      .unregister(
        'UPDATE_TIMER',
        'progress_update'
      )
  }
}

现在 Timer 不再是“某个工具类内部看不见的状态”。

六、窗口、球、Adapter 的释放顺序不能随便

这一篇我把最终收口顺序固定成:

停止 UI Timer
→ 解除 Task listener
→ hide / dispose Floating Ball
→ hide / dispose Float Window
→ dispose Adapter
→ 清 Registry

为什么 Adapter 最后?

因为 Window / Ball 的 hide 和 dispose 可能还需要 Adapter 提供系统调用。

如果 Adapter 先销毁,后面的释放动作就只能进入异常分支。

所以最终 disposeAll():

async disposeAll():
  Promise<void> {
  this.scheduler.cancel()

  this.subscriptionOwner
    .unbind()

  await this.ballManager
    .dispose()

  await this.floatManager
    .dispose()

  await this.adapter
    .dispose()

  QuickDockResourceRegistry
    .shared()
    .assertEmpty()
}

本轮销毁耗时:

33ms

这只是当前测试基线。

真正的验收是:

count=0

七、hide 和 dispose 必须继续保持两个语义

第二篇为了闪控窗 / 闪控球切换,已经明确:

hide
!=
dispose

05 不能为了“释放彻底”把这条边界破坏掉。

当前:

切换到 Floating Ball
→ Float Window hide
→ Float Window 仍登记为已创建资源

任务真正结束 / Ability 收口
→ Float Window dispose
→ Registry unregister

所以销毁前的资源计数:

window=1
ball=0
listener=1
timer=1
adapter=1

是合理状态。

Ball 当前不显示,所以:

ball=0

但 Float Window 仍然存在。

只有最终 dispose 后才要求全部归零。

八、资源 Registry 不是“看到 0 就完事”,还要看归属

如果所有资源都放进一个总数:

count=4

根本不知道是谁没释放。

所以 Registry 必须按类型统计:

FLOAT_WINDOW: 1
FLOATING_BALL: 0
TASK_LISTENER: 1
UPDATE_TIMER: 1
ADAPTER: 1

出现异常时,日志直接能定位:

UPDATE_TIMER leak

而不是只显示:

RESOURCE_LEAK

这种粒度对窗口能力非常重要。

九、Task 完成以后,UI 资源必须自动进入销毁流程

第五篇还测试:

upload_release_02
→ COMPLETED

任务进入终态以后:

显示完成状态
→ 保留短时间结果
→ cancel timer
→ unbind listener
→ dispose display resources

不能等用户手工关应用才释放。

Task Registry 可以保留任务历史,但显示资源不应该跟着历史一直存在。

这也是:

业务历史

和:

活动 UI 资源

的边界。

十、前后台切换不会重复创建资源

04 有:

onBackground
onForeground

如果每次 Foreground 都执行:

create Float Window

连续 12 轮前后台切换一定会出问题。

所以现在 Foreground 做:

Registry 查询
→ Window 是否仍存在
→ Adapter 是否可用

存在
→ update

Surface lost
→ RecoveryCoordinator rebuild

不存在
→ create once

创建、恢复和更新三个分支明确分开。

十一、异常恢复后旧 Adapter 要进入 DISPOSED

FLOAT_SURFACE_LOST 重建时,我给 Adapter 自己也加状态:

CREATED
ATTACHED
LOST
DISPOSING
DISPOSED

新的 Adapter 只有在旧 Adapter 进入 DISPOSED 后才登记为活动实例。

这样 adapterCount 才能长期保持:

1

而不是视觉上只有一份,Registry 里却两份。

十二、DevEco 图里只看“12 轮以后资源是否还是可解释的”

开发图:

统一 HiLog:

taskId=float_20261002_05

resource probe
cycles=12

showCalls=24

duplicateCreateBlocked=11

floatWindowId=
quickdock_float_01

ballId=
quickdock_ball_01

before dispose:
window=1
ball=0
listener=1
timer=1
adapter=1

dispose order:
timer
→ listener
→ ball
→ window
→ adapter

disposeCost=33ms

after dispose:
all=0

status=RESOURCE_CLEAN

这比“没有崩溃”更接近工程验收。

十三、运行图直接展示销毁前和销毁后

最终运行图:

当前任务:

float_20261002_05
upload_release_02
76%

压力数据:

cycles=12
showCalls=24
blocked=11

销毁前:

window 1
listener 1
timer 1
adapter 1

销毁后:

全部 0

最终:

RESOURCE_CLEAN

这张图真正回答的是:

QuickDock 已经不仅会创建系统窗口,也知道什么时候、按什么顺序把所有展示资源还回去。

十四、为什么 05 还不直接做 25 轮最终回归

因为资源治理刚刚完成。

这一篇先把:

计数口径
归属规则
幂等规则
释放顺序

定死。

如果直接把 25 轮数据堆进来,出现 leak 时还要反过来补 Registry,很难定位。

所以 05 只跑 12 轮资源压力测试,专门验证资源模型。

06 再把这个模型带进全场景回归。

十五、资源 Registry 也不能拥有业务资源

这里我再强调一个边界。

Registry 管理的是:

Window
Ball
Listener
Timer
Adapter

不管理:

Task Runner
上传任务
压缩任务
业务 Repository

任务生命周期仍归 TaskRegistry。

否则一次窗口 dispose 很容易顺手把业务任务也销毁,回到第一篇就已经否定的错误模型。

十六、生命周期冲突最终都要变成“可失败指标”

第五篇给下一轮留下的验收指标已经明确:

duplicateWindow
listenerLeak
timerLeak
adapterLeak
ballLeak
stateLoss

这些都不能靠肉眼判断。

如果第六篇 25 轮以后任何一个不是 0,就不允许最终 PASS。

这也是 05 结束时最重要的工程变化:资源问题从“偶发感觉不对”变成了“有明确计数器”。

十七、RESOURCE_CLEAN 的含义

最终状态:

RESOURCE_CLEAN

至少代表:

重复创建被阻止

单例窗口稳定

监听不会重复

Timer 可取消

Ball 可释放

Window 可释放

Adapter 最后释放

所有 Registry 计数归零

下一篇不会再加新功能,只会拿这套资源模型去跑完整 25 轮回归。

十八、资源释放不能只发生在“任务正常结束”

如果只在:

COMPLETED

里调用 disposeAll(),工程看起来很干净,但真正的异常路径还没覆盖。

QuickDock 还可能从这些路径退出:

用户取消任务

UIAbility 销毁

Surface 丢失后恢复失败

应用主动退出

后台能力申请失败后回退主页面

这些路径里任何一条忘记调用资源收口,最后都会留下不一致状态。

所以第五篇把资源回收触发点从“任务结果”提升成:

Display Session 结束

只要当前这组 Float View / Ball 显示会话结束,就必须进入同一套释放流程。

业务任务是否保留历史,是 TaskRegistry 的事。

显示资源是否归零,是 ResourceRegistry 的事。

两者不再互相借生命周期。

十九、disposeAll 本身也必须幂等

资源治理里很容易出现另一个反直觉问题:

正常结束调用一次 disposeAll

Ability destroy 又调用一次 disposeAll

如果第二次释放已经销毁的 Window 或 Adapter,可能反而产生新的异常。

所以 disposeAll() 不只是“按顺序释放”,还必须:

可以重复调用

当前做法是每个资源 Owner 自己判断状态:

export class FloatOwner {
  private state:
    'ACTIVE' |
    'DISPOSING' |
    'DISPOSED' =
      'ACTIVE'

  async dispose():
    Promise<void> {
    if (
      this.state ===
        'DISPOSED' ||
      this.state ===
        'DISPOSING'
    ) {
      return
    }

    this.state =
      'DISPOSING'

    try {
      await this.adapter.hide()
      await this.adapter.dispose()
    } finally {
      this.state =
        'DISPOSED'
    }
  }
}

这样生命周期回调重复到达,也不会把资源释放链重新执行一遍。

二十、资源治理里最怕“Owner 不清楚”

早期代码里最危险的一种写法是:

Page 创建 listener
Manager 保存 listener
Coordinator 负责 off

每个人都知道一点,但没有一个对象完整拥有生命周期。

第五篇以后,QuickDock 给每类资源都指定 Owner:

Float Window
→ QuickDockFloatManager

Floating Ball
→ QuickDockBallManager

Task Listener
→ TaskSubscriptionOwner

Update Timer
→ UpdateScheduler

Adapter
→ DisplayAdapterOwner

ResourceRegistry 只做登记和验收,不越权释放。

这个规则看起来像普通代码整洁,但对系统窗口尤其重要。因为系统资源通常是异步创建、异步销毁,一旦 Owner 不明确,就很容易出现“我以为别人会释放”。

二十一、隐藏资源和活动资源要分开统计

第五篇销毁前:

window=1
ball=0

并不意味着 Floating Ball 从来没创建过。

它只是当前不处于活动显示资源集合。

所以我把资源计数分成:

created
visible
active
disposed

最终验收主要看:

active

而不是历史创建次数。

例如连续 12 轮切换以后:

Float Window historicalCreate=1
Floating Ball historicalCreate=1

active:
window=1
ball=0

这种状态是健康的。

如果历史创建次数每轮都增加,哪怕 active 只有 1,也说明单实例守卫失效。

二十二、重复创建被阻止以后,日志不能刷成“错误”

本轮:

duplicateCreateBlocked=11

这些其实是成功保护。

所以日志级别不应该用 ERROR。

QuickDock 现在区分:

INFO
duplicate show converted to update

WARN
unexpected resource state

ERROR
resource cannot be disposed

如果把 11 次保护行为全打成红色 ERROR,最终日志会让人误以为系统非常不稳定。

诊断日志本身也要表达正确语义。

二十三、Surface Recovery 和 Resource Dispose 会竞争同一个 Adapter

第四篇有恢复流程:

FLOAT_SURFACE_LOST
→ rebuild

第五篇又有销毁流程:

dispose

如果两者同时发生,就可能出现:

Recovery 正在创建新 Adapter
Dispose 正在销毁旧 Adapter

甚至新 Adapter 刚注册就被 dispose。

所以资源 Owner 增加一个会话序号:

displaySessionId

Recovery 只有在:

session still active

时允许提交新 Adapter。

如果会话已经进入 DISPOSING:

rebuild result discard

这条保护在普通测试里很难看到,但一旦没有,就会出现“应用退出时又闪一下窗口”的诡异现象。

二十四、Timer 归零还不够,pending Snapshot 也要清

UpdateScheduler 即使:

timer=0

内部如果还保留:

pendingSnapshot

下一次新的任务会话创建 Scheduler 时,可能误用旧任务数据。

所以 cancel 还需要:

timer=-1
pending=null

ResourceRegistry 统计资源数解决“有没有活动 Timer”,Owner 自己还要保证内部缓存状态清空。

资源治理不只是释放系统句柄,也包括清掉跨会话不应该残留的内存状态。

二十五、05 最后的 12 轮是“资源模型验收”,不是性能跑分

这 12 轮每轮都执行:

show
hide
switch
show
surface rebuild
dispose

目标不是得到更漂亮的耗时,而是确认每个资源在每轮结束以后都回到可解释状态。

最终稳定结果:

Float Window:
1 → 0

Listener:
1 → 0

Timer:
1 → 0

Adapter:
1 → 0

duplicate create:
被守卫拦截

只要其中任何一项不能解释,我宁可先停在第五篇,也不会直接进入最终 25 轮回归。

这也是 QuickDock 从“能跑”走向“能长期跑”的真正分界。

二十六、资源诊断页也不能反过来持有业务对象

为了看计数,我做了一个 Resource Probe 页面。最开始这个页面直接持有 FloatManager 和 TaskStore,结果测试页一打开,反而多注册了一份监听。

后来诊断页只读 Registry 的不可变快照:

windowCount
ballCount
listenerCount
timerCount
adapterCount

它不创建资源,也不拥有资源。这样测试工具本身不会污染被测试对象。

这条约束很容易忽略:监控代码如果改变了资源数量,最终看到的“资源健康”就失去可信度。

二十七、第五篇的结果要能被下一篇自动读取

05 结束后我把这组基线固化成 Acceptance 前置条件:

duplicateCreateBlocked 可观察
disposeAll 幂等
Registry 支持快照
资源 Owner 可查询状态

06 启动前会先跑一次 resource preflight。只要窗口、listener、timer 或 adapter 在测试开始前就不是 0,整轮回归直接停止。

这样可以避免“带着上一轮残留资源进入下一轮测试”,让最终 25 轮数据更可信。

参考资料

  • HarmonyOS 7 闪控窗开发指南:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/float-view-guide
  • HarmonyOS 文档中心:Window / Background Tasks Kit:https://developer.huawei.com/consumer/cn/doc/
  • Stage 模型与 Ability 生命周期:https://developer.huawei.com/consumer/cn/arkui/arkui-stage
Logo

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

更多推荐