ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

资源回收源码阅读参考-上

资源回收源码阅读参考-上

文章目录

  • 概述
  • 明确两套记录
  • 推荐阅读
    • 1.引用计数只在 Resource.destroy 里面连锁
    • 2.加载 .lh 时,谁握住了图集
    • 3.createNodes 之后,界面又握了一次贴图
    • 4. 为什么只 destroy 窗口,图集还在
    • 5.clearRes 清的是根地址
    • 6.和 GRoot / GWindow 的接缝
  • src和封装的对应
  • 自己改框架时核对这几件事

概述

  • 当前只覆盖「GRoot 上分层、窗口继承 GWindow、内容是 .lh 预制体并赋给 contentPane」这条链路。

明确两套记录

显示节点资源对象
是什么GWindow、contentPane、里面的 GImage.lh 对应的 Prefab、图集 AtlasResource、贴图 Texture / Texture2D
放在哪父节点的子节点列表,业务上挂在层里Loader.loadedMap(按 url 缓存)和 Resource._idResourcesMap(可被统一扫描的资源)
怎么结束Node.destroyResource.destroy,或 Loader.clearRes
计数没有引用计数Resource.referenceCount
  • Laya.loader.load / getRes 命中缓存时,只把缓存里的对象交出来,不会给 referenceCount 加一。所以「缓存里还有」和「还有人引用」是两件事。——如果进缓存就算引用加一,计数永远到不了 0,这批资源就清不掉了。
  • 业务租约(ResLease.leaseCount)是第三个数,只记录「还有几个窗口或 pin 声明要留着这个根 url」。它用来决定能不能对这个根 url 调用 clearRes。

补充:

  • 图集 AtlasResource
    • 1、textures<Texture。>:大图页(里面每个元素是一整页大图的 Texture),真正占显存的那几张位图(Texture.bitmap<Texture2D.>)。
    • 2、frames<Texture.>:每一张小图。AtlasLoader 用 Texture.create(大图, x, y, 宽, 高) 切出来,所以每个 frame 都是一个 Texture,很多 frame 共用同一张大图。——Texture.create(大图Texture, x, y, 宽, 高) 切出小图,这些小图的 bitmap 都指向大图那个 Texture2D。
    • 3、因此界面上的一个图标往往不是一张独立图片。它只是图集大图上的一个切块。回收时放掉这一个切块,显存里的大图还在;要等这张大图上的切块都不再被引用,对应的 Texture2D 才会释放。
  • Texture
    • 绘制时用的一块区域,记录从哪张位图的哪个矩形来画。
    • GImage 画的是它。这块区域可以是整张图,也可以只是大图上的一小块。
  • Texture2D
    • png加载出来在显存的位图。——load(“a.png”) 交给调用方的主资源是包好的 Texture,不是直接把 Texture2D 交出来。Texture2D 在 Texture.bitmap 上。
  • 贴图:
    • 没打进图集的:既指代单张png,又指代显存位图(Texture2D)。文件和显存对象是同一张图的两个阶段。——一张 png 对应一个 Texture2D,再包一个盖住整张图的 Texture。文件、显存位图、绘制对象是三层
    • 打进图集的:图集大图本身也是一张 png,它加载成一张 Texture2D(再包成 textures 里的整页 Texture)。界面上的每个小图标是 Texture,只记录这张大图上的一块矩形,很多小图标共用这一张 Texture2D。这时「单张小图」不是一张 Texture2D。
    • (贴图 > Texture2D) >>> (Texture< bitmap指向Texture2D>)
  • 引用计数具体场景例子:用一个只含一张图的窗口来看。窗口预制体叫 Bag.lh,里面只有一个 GImage,画的那张图叫 T(就是 GImage 握着的 Texture)。
    • 这种情况有2个引用,1个是绘制命令增加的,一个是预制体的deps握着的。
    • 去引用一般是先 拆界面:window.destroy()。 这一步会销毁GImage节点。绘制命令被 recover()——调用了this._texture._removeReference();【T引用为1了】
    • clearRes(“Bag.lh”)。 Loader._clearRes 把缓存条目删掉,并调用 Bag.lh.destroy()。destroy 里先把 _disposingCounter 加 1,再对 deps 里的每个资源 _removeReference。——这时候条件都满足,T.destroy() 就在这一步被调用,成功销毁T。

推荐阅读

  1. resource/Resource.ts:_addReference、_removeReference、addDep、destroy、destroyUnusedResources
  2. net/Loader.ts:_cacheRes、getRes、_clearRes、createNodes
  3. loaders/HierarchyLoader.ts 和 resource/HierarchyResource.ts、resource/PrefabImpl.ts
  4. display/cmd/DrawTextureCmd.ts 的 texture setter 和 recover,再看 ui2/ImageRenderer.ts 的 destroy
  5. display/Node.ts 的 destroy,display/Sprite.ts 的 destroy,ui2/GImage.ts 的 destroy
  6. ui2/GWindow.ts 的 contentPane、destroy、hideImmediately,ui2/GRoot.ts 的 showWindow、hideWindowImmediately
  • 读完应能回答:关掉一个窗口之后,图集为什么还在显存里,以及哪一次调用才会把它清掉。
  • 与后续标题一一对应解答。

1.引用计数只在 Resource.destroy 里面连锁

Resource._removeReference(约 295 行)减计数。减到 0 以后,只有下面四个条件同时成立才会立刻 destroy:

  • 当前正处于某次 Resource.destroy(_disposingCounter > 0)
  • referenceCount <= 0
  • lock 不为 true
  • destroyedImmediately 为 true

显示节点销毁时的减引用,<比如绘制命令被 recover()>发生在这次 Resource.destroy 之外,_disposingCounter 是 0,所以贴图不会在拆界面的那一步被销毁。

Resource.destroy(约 358 行)自己做四件事:

  1. 调用 _disposeResource
  2. 对 deps 里每个资源 _removeReference(连锁从这里开始)
  3. 从 Resource._idResourcesMap 删掉自己
  4. 若有 url,调用 Loader.clearRes(url, this),把缓存条目去掉

destroyUnusedResources(约 69 行)另开一条路:扫描 _idResourcesMap,把 lock 不为 true 且 referenceCount === 0 的资源 destroy。它不打开 Loader.loadedMap 逐个看。一个资源若构造时 managed === false,就不在这张表里,扫描看不到它。

Prefab 的构造是 super(false)(HierarchyResource.ts 约 21 行),默认不进这张表。onLoad 才把它登记进去。因此加载完成的 .lh 会被 destroyUnusedResources 扫到。

2.加载 .lh 时,谁握住了图集

HierarchyLoader._load(约 51 行)在依赖都加载完之后:

newPrefabImpl→onLoad()→addDeps(依赖数组)→ 返回给 Loader 写入缓存onLoad()//记录到 Resource._idResourcesMapaddDeps(依赖数组)//持有依赖资源;依赖资源引用计数+1;同时这里持有资源对象,是为了后续减少引用用。返回给 Loader 写入缓存;//Loader的_load2里面接收Promise,得到返回后,作为内容调用_cacheRes()写入loadedMap

Loader._cacheRes(约 1061 行)只是 loadedMap[url] = [typeId, data]。这一步不加引用计数。

所以一个刚加载好、还没被别的预制体依赖的根 .lh,常见状态是:

  • loader.getRes(url) 能拿到它
  • 它自己的 referenceCount 仍是 0
  • 它的 deps 里每个图集的 referenceCount 大于 0

注意:

  • referenceCount === 0 在根 .lh 上是正常状态,表示「没有别的 Resource 把它列进 deps」,不表示界面上没有人在用它。
  • Texture._addReference(Texture.ts 约 398 行)会把同一次加计传递给 _bitmap 和 _atlas。AtlasResource 构造时(约 88 行)会给自己的帧和图集页各加一次引用。读计数时看到的是这条链叠出来的结果,不会等于窗口个数。

3.createNodes 之后,界面又握了一次贴图

  • 生成节点树
    • instantiate -> 加载 -> 创建
    • Loader.createNodes(约 1039 行)是 getRes(url).create()。——得到Prefab实例<PrefabImpl.>
    • PrefabImpl.create(约 89 行)解析 json,生成节点树,并把根节点的 url 设成预制体地址。——解析.lh的JSON为节点树。
  • GImage - 管资源和尺寸
    • ImageRenderer - 选并维护绘制命令 —— 有removeRef 和 addRef;创建绘制命令后把 cmd.lock = true;
    • DrawTextureCmd - 在渲染时把这张图画出来 —— set texture会addRef,recover有removeRef。
    • Graphics - 控件上的 graphics 就是一个 Graphics 实例,里面的 _cmds 保存绘制命令。—— Graphics.destroy 遇到 lock 的命令不会 recover。;GImage 的贴图引用是在 ImageRenderer.destroy 里还掉的,不是在 Graphics.destroy 里。
  • Sprite - Sprite.texture 是另一条路(Sprite.ts 约 1267 行和 destroy 约 311 行),同样成对加减引用。
  • GWindow的contentPane - 对旧内容只 removeChild,不 destroy。旧节点若不再挂到别的地方,要自己销毁,否则它握着的贴图引用还在。(本封装只赋值一次;销毁整窗时,Node.destroy 会递归销毁子节点,contentPane 作为子节点一起拆掉。

4. 为什么只 destroy 窗口,图集还在

拆掉 GWindow 之后:

  • 绘制命令上的贴图引用已经还掉
  • 但Prefab 仍在 loadedMap 里
  • 但Prefab.deps 仍握着图集和贴图 ——【***】
  • 虽然根 .lh 自己的 referenceCount 往往还是 0

调用 Resource.destroyUnusedResources(),扫描会看到这个 .lh:lock 是 false,referenceCount 是 0,于是 destroy 它。destroy 再对 deps _removeReference。若某个图集因此到 0,并且没有 lock,它会在这次连锁里被销毁。

5.clearRes 清的是根地址

  • 本封装的 ResLease.flush 只处理根 url,并且还要这个根资源自己的 referenceCount === 0。根 .lh 的计数不包含绘制命令,只在别的 Resource 把它列进 deps 时大于 0。没有这种依赖时,这个数就是 0,clearRes 可以执行。若它仍大于 0,这次跳过,等那个持有者 destroy 时再 _removeReference,计数到 0 才连锁销毁。
  • Loader.clearRes(url) 不带第二个参数时,_clearRes(约 1127 行)先删掉 loadedMap 整条,再对里面还没销毁的 Resource 调用 destroy。这里不读 referenceCount,也不看别的对象的 deps。
  • Resource.destroy 末尾会再调 clearRes(url, this)。这时对象已经标记销毁,这次只从缓存摘掉自己,不会第二次 destroy。
  • 共享图集的正确释放方式是:只对根 .lh(以及你额外单独 load 的那个 url)调用 clearRes。图集留在 deps 里。Prefab.destroy 对它走 _removeReference,计数还大于 0 就不会连锁 destroy。另一份计数可以来自另一个窗口的 .lh(它 addDep 过这张图集),也可以来自还在显示的 DrawTextureCmd。
  • 对依赖的 url,例如图集地址,直接 clearRes,走的是上面的无条件 destroy。别的预制体的 deps 里虽然还指着这个对象,那只是一个数组,不会挡住这次 destroy。正在显示的 DrawTextureCmd 同样挡不住。图集的 _disposeResource 会把帧和图集页的引用减掉,画面上的图会坏。那个预制体的 deps 里则留下一个已经销毁的对象。
  • 情景:bag.lh 和 shop.lh 共用 ui.atlas 时,只对已经不用的那个根 .lh 调用 clearRes。背包关掉之后清 bag.lh,商店还开着就留着 shop.lh。商店也关掉、画面上的图片命令已经还掉引用之后,再清 shop.lh。最后一份预制体引用被还掉时,图集计数到 0,会跟着被释放。
  • 结论:整个过程都不要对 ui.atlas 调用 clearRes。预制体还在 deps 里握着它,或者窗口还在画它的时候去清图集地址,会直接把图集销毁,正在显示的图会坏。

6.和 GRoot / GWindow 的接缝

  • GWindow.show 调用 GRoot.showWindow,窗口成为 GRoot 的直接子节点(GRoot.ts 约 75 行)。
  • GWindow.hideImmediately 调用 GRoot.hideWindowImmediately,后者只在 win.parent == this(父节点就是 GRoot)时 removeChild(约 107 行)。窗口若挂在层节点上,这条官方路径不会把它从层上拿下来。
  • GRoot.adjustModalLayer(约 286 行)只检查 GRoot 的直接子节点里有没有 modal 的 GWindow。层上的窗口进不了这套遮罩。
    所以业务层是自己放在 GRoot 上的全屏 GWidget,窗口是层的子节点。UIWindow 重写了 show、hideImmediately、bringToFront。遮罩是同层里窗口下面的一个 GWidget,只挡这一层。
  • GWindow.destroy(约 305 行)若还在父节点上,会先 hideImmediately,再 super.destroy()。UIWindow.destroy 调用它,返回之后才归还租约。这样这份窗口的绘制命令已经还掉引用。随后的clearRes 销毁预制体时,只会再还 deps 里的那一份。别的预制体或还在显示的绘制命令若仍握着同一张图集**,图集不会在这次连锁里被销毁。

src和封装的对应

src行为封装
Loader.load + loadedMapUIWindow.loadContent 里 Laya.loader.load
Prefab.addDeps 握住依赖,根预制体 referenceCount 仍常为 0ResLease 在租约大于 0 时对根资源 lock = true
Loader.createNodes / PrefabImpl.createLaya.Loader.createNodes,结果赋给 contentPane
DrawTextureCmd.recover 在节点销毁时还贴图引用UIWindow.destroy 先 super.destroy(),再 dropLease
Loader.clearRes 不看引用计数ResLease.flush 只在租约为 0 且根资源 referenceCount === 0 时调用
deps 的连锁释放不单独 clearRes 图集 url
destroyUnusedResources 会扫到未 lock 的根 .lh默认不自动调用;resLease.gc() 显式调用。租约还在的根资源已被 lock
GRoot.showWindow 把窗口挂到 GRootUILayerHost 建层,UIManager.open 把窗口 addChild 到层上
hideWindowImmediately 只处理父节点是 GRoot 的窗口UIWindow.hideImmediately 从当前父节点移除

自己改框架时核对这几件事

  • 租约是业务这边记的「这个根 url 还要留着」。引擎的 loadedMap 和 referenceCount 都不知道窗口还要不要用它。
  • 新加的常驻界面,用 resLease.pin(urls),让根 .lh 在整个过程中保持 lock。
  • 窗口里又 Laya.loader.load 了预制体依赖之外的 url,把这个 url 放进 extraUrls,让它和预制体同一份租约。
    • 漏掉的话,这个 url 没有租约,flush 不会 clearRes 它,会一直留在 loadedMap。
    • 已经在根 .lh 的 deps 里的图集、嵌套预制体不用放进 extraUrls。
  • 替换 contentPane 时,把被 removeChild 的旧节点 destroy 掉(为的是还掉它握着的贴图引用)。
  • 销毁顺序保持「先整棵显示树,再还租约」。
    • 顺序反了的结果:
    • 先 clearRes 时,绘制命令还握着贴图,图集计数到不了 0,连锁销毁不会发生。
    • 等节点随后销毁,减引用又落在 Resource.destroy 之外,图集计数即使到 0 也会留到下一次 destroyUnusedResources。
  • 只对根 url 调用 clearRes。图集、嵌套 .lh 走 deps。
  • 打开 resLease.debug,需要对照引擎日志时再设 Laya.Resource.DEBUG = true。用 resLease.inspect(url) 看 leaseCount、engineRef、locked、deps。根 .lh 的 engineRef 为 0 且 locked 为 true,是打开期间的正常结果。
  • resLease.gc() / autoGc 会销毁所有未 lock 且 referenceCount === 0 的资源。
    • 没有走租约、只是预加载在缓存里的 .lh 会在这一步被拆掉。
    • 正在显示但根 .lh 没 lock 的窗口也一样:预制体会被拆掉,节点还在;
    • 图集只要绘制命令还握着,就不会一起没。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进