Cocos Creator 3.8.3 引擎 / 真机问题修复合集
项目背景:2D 横版 RPG,Cocos Creator 3.8.3,Android 原生 + 热更新。
下面每个问题都是真机(或原生模拟器)上才出现、Web 预览正常的那类,附了现象、定位过程和可以直接照抄的修复代码。
所有补丁都做了「引擎内部接口不存在就跳过」的兜底,升级引擎版本不会因为补丁本身炸掉。
| # | 问题 | 修复所在文件 |
|—|------|--------------|
| 1 | 原生端瓦片地图索引错位
| 2 | 原生端 Spine 概率性崩溃
| 3 | CHAR Label 首帧空文本崩溃
| 4 | 真机音频偶发静音 / 抢声道
| 5 | 输入派发器优先级吞触摸
| 6 | RESIZE_HEIGHT 下 contentSize 不同步
| 7 | 增量编译导致构建产物组件为 null | 构建流程(无需改代码) |
目录
1. 原生端瓦片地图索引错位,地图顶部几行不渲染
现象
部分 TMX 地图(例如 nscene1_5_4)真机上最上面几行地面不显示,空白区里其他图层的装饰物却正常;浏览器完全正常;空白范围固定在地图上,跟着地图滚动。
原因
原生端 TiledLayer.prepareDrawData() → fillIndicesBuffer() 写索引时,起点取的是共享索引缓冲「当前的尾部」(chunk.meshBuffer.indexOffset)。而这份共享缓冲每帧开头都会被 StaticVBAccessor.reset() 把尾部清回 0;已经不需要重建的图层,drawInfo 里记的索引起点却还停在上一帧的位置。
于是只要某一帧只有部分图层重建(带 <animation> 动画瓦片的 wave 层每帧都要重建),它就会从索引 0 开始写,把排在最前面那个图层的开头一段索引覆盖掉。覆盖的索引个数正好等于动画层的索引数:wave 有 95 个瓦片 = 570 个索引,换算到地面层大约就是最上面 3 行多。
浏览器端走的是逐层 _render → fillBuffers,每帧都从 0 按绘制顺序整段重写,所以不会错位;prepareDrawData 只有原生端会调用。
修复
索引起点不再取「尾部」,而是由顶点块的位置反推(4 个顶点对应 6 个索引)。顶点块在共享缓冲里是固定的、互不重叠的,每个图层每帧都写回自己那一段,互不干扰。
import { TiledLayer } from "cc";
let installed = false;
export function installTiledLayerIndexFix() {
if (installed) return;
installed = true;
const proto = TiledLayer.prototype as any;
if (!proto || typeof proto.fillIndicesBuffer !== "function") {
console.warn("[TiledFix] 没找到 TiledLayer.fillIndicesBuffer,跳过(引擎版本可能不同)");
return;
}
if (proto.fillIndicesBuffer.__tiledFix) return;
const original = proto.fillIndicesBuffer;
proto.fillIndicesBuffer = function (renderData: any, drawInfo: any) {
const chunk = renderData && renderData.chunk;
if (!chunk) return;
const meshBuffer = chunk.meshBuffer;
const iBuf = meshBuffer.iData;
const quadCount = renderData.vertexCount / 4;
// 4 个顶点 = 6 个索引,索引起点直接由顶点块位置推出来
const indexStart = chunk.vertexOffset / 4 * 6;
const ibCount = quadCount * 6;
if (!Number.isInteger(indexStart) || indexStart + ibCount > iBuf.length) {
// 布局放不下时退回引擎原逻辑,避免整块不画
return original.call(this, renderData, drawInfo);
}
let indexOffset = indexStart;
let vertexId = chunk.vertexOffset;
for (let i = 0; i < quadCount; i++) {
iBuf[indexOffset] = vertexId;
iBuf[indexOffset + 1] = vertexId + 1;
iBuf[indexOffset + 2] = vertexId + 2;
iBuf[indexOffset + 3] = vertexId + 2;
iBuf[indexOffset + 4] = vertexId + 1;
iBuf[indexOffset + 5] = vertexId + 3;
indexOffset += 6;
vertexId += 4;
}
// 让原生上传时把这一整段索引都传上去
if (meshBuffer.indexOffset < indexOffset) {
meshBuffer.indexOffset = indexOffset;
}
drawInfo.setIndexOffset(indexStart);
drawInfo.setIBCount(ibCount);
};
(proto.fillIndicesBuffer as any).__tiledFix = true;
}
在进入地图场景时安装一次即可(我们是在 GameMap.onLoad() 里调用)。
2. 原生端 Spine 概率性崩溃(SIGSEGV)
现象
真机概率性闪退,崩溃点在 Batcher2d::fillIndexBuffers / StencilManager::getDepthStencilState,和具体业务时机无关,抓不到 JS 层异常。
原因
3.8.3 原生设置/清空 sp.Skeleton.skeletonData 时,旧的 native SkeletonRenderer 被丢弃、等待 GC 析构,但注册在 RenderEntity 上的 dynamic RenderDrawInfo 指针不会随之移除。旧 renderer 析构后这些指针就成了悬空指针,之后 Batcher2d 遍历该节点访问已释放内存 → 概率性 SIGSEGV。
3.8.5+ 在 SkeletonRenderer::stopSchedule() 里补了 clearDynamicRenderDrawInfos(),3.8.3 没有。
修复
每次 skeletonData 更新后手动清一次 drawInfo(下一帧 render() 会重新填充,不影响显示),顺带把原生化实现漏掉复位的 _skeleton / _state 置空(否则旧 skeleton 析构后 findBone/findAnimation/findSlot 也会踩到已释放内存)。
import { sp, sys } from 'cc';
(function patchSpineClearDrawInfos() {
if (!sys.isNative) return;
const proto: any = sp.Skeleton.prototype;
const origin = proto && proto._updateSkeletonData;
// 原生适配层可能晚于本模块执行,用函数标记判断,保证能重新包上
if (typeof origin !== 'function' || origin.__clearDrawInfosWrapped) return;
const wrapped: any = function () {
origin.apply(this, arguments);
// 原生实现清空数据时漏了复位这两个字段,旧 skeleton 被析构后它们会变成悬空指针
if (!this.skeletonData) {
this._skeleton = null;
this._state = null;
}
this._renderEntity?.clearDynamicRenderDrawInfos?.();
};
wrapped.__clearDrawInfosWrapped = true;
proto._updateSkeletonData = wrapped;
})();
补丁放在 Spine 组件所在模块的顶层,模块被任何代码引用时就会执行。
3. CHAR 缓存模式的 Label 首帧空文本崩溃
现象
热更包/真机包,打开背包「强化」面板必现:
Uncaught TypeError: Cannot read properties of undefined (reading 'length')
[0] updateRenderData@src/cocos-js/cc.js:40930:58
[1] updateRenderData@src/cocos-js/cc.js:50554:24
[2] updateRenderer@src/cocos-js/cc.js:41935:29
[3] updateAllDirtyRenderers@src/cocos-js/cc.js:32303:31
定位过程
用构建产物里未压缩的 assets/debug/src/cocos-js/cc.js 对照行号:
-
40930:58→RenderData.updateRenderData()里的断言assert(this._renderDrawInfo.render2dBuffer.length === ...)—— 报错是读.length,说明_renderDrawInfo存在,但render2dBuffer是undefined; -
50554:24→bmfontUtils.updateRenderData()结尾的renderData.updateRenderData(comp, comp.spriteFrame)(bmfontUtils同时被cacheMode = CHAR的letter组装器继承,所以命中 CHAR 模式的 Label); -
再往上就是脏渲染列表的
updateAllDirtyRenderers。
原因
-
native 下
RenderData.render2dBuffer只在dataLength的 setter 里初始化(setStride / setVBCount / initRender2dBuffer); -
CHAR 模式(以及 BitmapFont)的组装器
createData()只调了resize(0, 0),没改dataLength,所以缓冲没有初始化; -
文本非空时,更新流程会执行
renderData.dataLength = 四边形数,顺带完成初始化,所以平时不会炸; -
文本为空时四边形数是 0,等于初始
dataLength,这段被跳过,紧接着的断言读到undefined.length就抛异常。
断言包在 if (JSB && DEBUG) 里,正式包不会触发,所以这是「真机 debug 包/带断言的热更引擎」才会出现的问题。
工程里的具体触发点:Strengthen.prefab 的 curDes / nextDes 是 CHAR 模式且初始文本为空;打开面板时如果还没选装备,refreshAttributeView() 提前 return,首帧就是空文本。
修复
在 Label 提交渲染数据前,检查 drawInfo 是否还没初始化,缺了就按当前 dataLength 补一次(等价于引擎在 setter 里做的事),初始化过之后直接短路,没有额外开销:
import { Label, sys } from "cc";
export function installLabelRenderDataFix(): void {
if (!sys.isNative) return;
const proto: any = Label.prototype;
if (proto.__renderDataFixInstalled) return;
proto.__renderDataFixInstalled = true;
const origUpdateRenderer = proto.updateRenderer;
if (typeof origUpdateRenderer === "function") {
// 脏渲染列表走的入口(updateAllDirtyRenderers -> updateRenderer)
proto.updateRenderer = function () {
ensureRender2dBuffer(this);
origUpdateRenderer.call(this);
};
}
const origUpdateRenderData = proto.updateRenderData;
if (typeof origUpdateRenderData === "function") {
// 直接调用 Label.updateRenderData 的入口(RichText 等会走到),一并兜住
proto.updateRenderData = function (force?: boolean) {
ensureRender2dBuffer(this);
origUpdateRenderData.call(this, force);
};
}
}
function ensureRender2dBuffer(label: Label): void {
if (label.cacheMode !== Label.CacheMode.CHAR) return;
const renderData: any = (label as any).renderData;
if (!renderData) return;
const drawInfo: any = renderData.renderDrawInfo;
if (!drawInfo || drawInfo.render2dBuffer !== undefined) return;
if (typeof drawInfo.initRender2dBuffer !== "function") return;
drawInfo.setStride(renderData.floatStride);
drawInfo.setVBCount(renderData.dataLength);
drawInfo.initRender2dBuffer();
}
在启动脚本(Launch)模块加载时调用一次 installLabelRenderDataFix()。这是纯脚本改动,走热更就能生效。
备选方案:把这类 Label 的
cacheMode从 CHAR 改成 BITMAP,也能绕开(工程里其他 138 个 Label 就是这么配的)。但那样只修当前这一处,以后再出现「初始为空」的 CHAR Label 还会踩。
4. 真机音频:偶发静音、BGM 被掐、循环音消失
现象
-
同一个音效连续触发时偶发不响;
-
战斗里同屏音效一多,BGM 或循环音(AOE)被掐掉;
-
循环音效多了以后整体变得很安静。
原因(两处,都是引擎/平台行为)
-
AudioSource 加载 clip 是异步的(真机上解码/预加载要一帧以上),这期间
playing仍然是false。如果此时只看playing复用声源、换掉它的clip,引擎的_syncPlayer会清空_operationsBeforeLoading,之前排队的play就被丢掉 → 表现为静音。 -
原生端声道数上限:
AudioSource.maxAudioChannel在 Android 上只有 13(Web 是 24)。超过上限后play2d会静默失败,声源永远卡在「播放中」,还会把 BGM / 循环音挤掉。
修复要点
-
用
__busy标记「已分配、尚未结束」的声源,只有ENDED或stop才释放;同一clip且还在加载中的声源可以共用(不换 clip 就不会清掉排队的 play); -
同一个循环音效被多个对象播放时共用一个声源、只加引用计数,不再各占一条声道;
-
维护音效预算:按
maxAudioChannel - 2判断是否到顶,到顶时掐掉离玩家最远的那条位置音效(音乐、循环音、UI 音不参与淘汰); -
对
play加超时兜底:如果引擎预加载/播放失败(既不ENDED也不会playing),超时后主动释放声源,避免它永远被当成「忙」。
/** 取一个可用声源:不能只靠 playing 判断空闲(真机上加载 clip 是异步的) */
private _getAudioSource(clip: AudioClip, owner?: AudioData): AudioSource | undefined {
let result: AudioSource | undefined;
// 1) 同一 clip 且还在加载中的声源可以共用(没有换 clip,不会清掉排队的 play)
for (let i = 0; i < this._audioSources.length; ++i) {
const audioSource = this._audioSources[i];
if ((audioSource as any).__busy && !audioSource.playing && audioSource.clip === clip) {
result = audioSource;
break;
}
}
// 2) 真正空闲的声源:既没在播放,也没有等待加载/播放的操作
if (!result) {
for (let i = 0; i < this._audioSources.length; ++i) {
const audioSource = this._audioSources[i];
if (!(audioSource as any).__busy && !audioSource.playing) {
result = audioSource;
break;
}
}
}
// ...没有空闲声源时再扩容,完整实现见 audioManager.ts
return result;
}
/** 当前占用的声源数是否到顶(Android 上限 13,留 2 条给引擎自身) */
private _isOverSoundBudget(): boolean {
const max = Math.max(6, AudioSource.maxAudioChannel - 2);
let count = 0;
for (let i = 0; i < this._audioSources.length; ++i) {
if ((this._audioSources[i] as any).__busy && ++count >= max) return true;
}
return false;
}
5. 引擎输入派发器优先级导致 input.on 收不到触摸
现象
登录界面空白处连点能触发,角色选择界面怎么点都没反应——而这两个界面用的是同一套「连点 5 次」检测代码。
原因
引擎(cocos/input/input.ts)里事件按派发器优先级从大到小走:
-
UI 派发器
priority = 1(pointer-event-dispatcher.ts) -
input.on(...)所在的派发器priority = 0
只要屏幕上有一个 UI 节点接住了这次触摸,UI 派发器就 return false,循环直接 break,排在后面的派发器——也就是 input.on 的监听——根本收不到事件。角色选择面板铺满了全屏背景节点,每次点击都被它接住了。
修复
用 priority = 2 注册自己的派发器,保证先于 UI 拿到事件,并且返回 true 不打断后续派发:
function installRawTapHook(): boolean {
const anyInput = input as any;
if (typeof anyInput._registerEventDispatcher !== "function") {
return false; // 引擎内部结构变了就退回公开接口
}
anyInput._registerEventDispatcher({
priority: 2,
dispatchEvent(event: any): boolean {
if (event && event.type === Input.EventType.TOUCH_END) {
onTouchEnd(event as EventTouch);
}
return true;
},
});
return true;
}
这个技巧本身和「连点开日志」无关,凡是需要在 UI 之前拿到全局触摸的场景(调试手势、悬浮球、全局快捷操作)都能用。
6. Label 在 RESIZE_HEIGHT 下 contentSize 没同步
现象
动态创建的 Toast 气泡,文本超长自动换行时背景圆角框大小不对(比文字小或大一圈)。
原因
父节点的背景是在同一帧里用 UITransform.contentSize 画的,而 Label.overflow = RESIZE_HEIGHT 的高度要等引擎下一次更新渲染数据才回写,直接读 trans.height 拿到的是旧值。
修复
创建完 Label、设好 overflow 之后,手动强制刷一次:
// 强制更新 Label 渲染数据,确保 RESIZE_HEIGHT 模式下引擎同步计算出正确的 contentSize
textLabel.updateRenderData(true);
之后再用 UITransform 的宽高去画背景就正确了。
7. 构建产物组件变 null,真机报 Error 3817
现象
真机启动报:
Error 3817 ... Arguments: Canvas, 3
构建出来的场景里,LoginPanel、Authentication、CreateRolePanel、CreateNewHero、HotUpdateView 等组件被序列化成了 null。但源码场景和 library/ 里的序列化数据都是正常的(组件 __type__ 仍是有效类 id),编辑器预览也正常。
原因
这是 Creator 编辑器/构建 worker 的增量脚本编译问题,不是业务代码 bug。改过处于大循环依赖中的脚本(我们这次是 framework/HttpSocket.ts)后,增量重载可能出现:
Class extends value undefined is not a constructor or null
(HttpSocket extends Singleton 时基类还没加载到),随后一连串 Missing class,最终构建产物被写坏。
处理
关闭 Creator → 删除 temp/programming(脚本编译缓存)→ 重开工程重新编译 → 重新构建。不需要改任何业务代码。
构建后自查
用构建产物核对组件是否真的还在,例如:
-
temp/programming/packer-driver/targets/editor/main-record.json -
assets/main/import/0f/0f3966ff6.json里对应场景的组件序列化
如果那边组件已经变成 null,就说明构建产物是坏的,重新全量编译即可。
经验:凡是「编辑器里好好的、只有构建产物不对」的问题,先怀疑缓存,再怀疑代码。
8. 附:这类问题的定位方法论
-
先分清是 JS 逻辑问题还是引擎/原生问题:Web 预览正常、真机异常,基本可以锁定引擎原生实现或平台差异。
-
报错栈里的行号要对着构建产物看:
build/<平台>/proj/.../assets/debug/src/cocos-js/cc.js是未压缩的引擎 bundle,用它去找cc.js:xxxxx对应的源码,比在压缩后的 release 产物里瞎猜快得多;找到后和编辑器内置引擎源码(resources/3d/engine/cocos/...)对照,能确认具体函数和上下文。 -
看断言/日志前缀判断构建类型:
assert在 release 包里会被裁掉,debug 包里才有。同一个现象在 release 上不出现,往往说明它只是断言失败,而不是真的数据错乱。 -
优先用「补一次初始化 / 按不变量重算」而不是改引擎安装目录:工程内的 monkey patch 更容易走热更、容易回退,也方便写「接口不存在就跳过」的兜底。
-
补丁都加版本标记或函数标记(如
__tiledFix、__clearDrawInfosWrapped),避免多次安装;并给「退回原逻辑」的分支,保证升级引擎后最坏情况只是补丁失效,不会把游戏搞挂。 -
能复现的优先做最小复现:例如 Label 崩溃最后定位到「打开强化面板且没有选装备」这一个具体路径,比盯着引擎源码猜快得多。