十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Cocos Creator 项目避坑实战:从报错排查到打包 APK 与性能调优

Cocos Creator 项目避坑实战:从报错排查到打包 APK 与性能调优 第一次接手一个跑到一半的 Cocos Creator 项目最让人心里发毛的不是代码写得乱而是昨天还好好的今天打开就报错。这种问题往往横跨三个层面编辑器与工程结构层、运行时脚本与资源层、原生构建层。前者让你连项目都打不开中者让你在真机上看到一片白屏后者让你在打包 APK 的最后一步被 Gradle 按在地上摩擦。这篇文章就把这几年我在 Cocos Creator 开发中反复踩过的坑按类别拆开讲清楚每个问题背后的成因、排查路径和最终落地的解决方式覆盖从 2.x 到 3.x 的常见差异也包含多机型适配、性能调优和原生打包这些必须过一遍的关口。不管你是刚接触引擎的新手还是已经能写业务但一遇到报错就懵的开发者下面这些内容应该都能直接拿去用。1. 编辑器打不开工程、脚本报红但能跑工程结构层的坑这类问题的特点是看起来像代码问题其实和代码一点关系都没有。很多人第一反应是去翻脚本翻了半天发现代码逻辑完全正常问题出在工程配置或缓存上。1.1 哪些目录必须进版本管理哪些可以随时删多人协作最容易出事的地方就是目录管理。Cocos Creator 工程里几个关键目录的性质完全不同混着提交或者一起忽略都会出问题。目录/文件性质是否入库说明assets/源资源与脚本必须所有.meta必须一起提交否则 uuid 会重新生成settings/编辑器工程配置必须包含构建配置、分组配置、物理配置等library/导入缓存不要可安全删除重新打开编辑器会自动重建temp/临时文件不要删除无副作用build/构建产物不要体积大且可重复生成profiles/本地编辑器偏好视情况通常不入库属于个人环境设置native/原生工程模板扩展视情况若自己改过 Android/iOS 工程模板需要入库library这个目录值得单独说一句。它是资源导入后的二进制缓存包含 uuid 到具体路径的映射关系。当你发现资源明明在编辑器却说找不到或者导入的图集显示异常第一件事就是把library整个删掉再重开编辑器重新导入。这个操作我大概做过不下五十次绝大多数资源层面的诡异问题都能靠它解决。反过来.meta文件绝对不能删也不能随便忽略。每个资源的 uuid 就写在.meta里场景文件引用节点、预制体引用贴图靠的都是这个 uuid 而不是路径。如果.meta丢了编辑器会生成一个新的 uuid于是原本引用这个资源的场景就会出现一堆Missing。1.2 版本升级后工程打不开的典型链路从 2.x 升到 3.x或者 3.x 内部跨大版本升级比如 3.6 到 3.8出问题最多的是序列化格式和 API 重命名。表现出来通常是工程能打开但场景里节点全空或者打开就报一堆Cannot read property of undefined。我的处理顺序是这样的先备份整个工程目录。这不是客套话升级过程是不可逆的一旦保存过就很麻烦了。在干净环境里打开也就是删掉library和temp之后打开让编辑器按新版本重新导入全部资源。看控制台的第一条错误不要看后面的。资源导入阶段的报错往往是链式的第一条才是根因。逐个场景过一遍。3.x 的cc.NodeAPI 变化很大比如node.setPosition(x, y)在 2.x 可用3.x 里虽然兼容但node.position返回的是只读的Vec3直接改.x是无效的必须node.setPosition(x, y, z)或者整体赋值。这里有个特别容易忽略的点3.x 里Vec2和Vec3的区别在 2D 项目里很致命。如果你拿到一个Vec3的引用然后改它的x某些情况下确实生效了因为position的 getter 返回的可能就是内部对象引用但另一些情况下比如经过序列化、或者经过 tween就不生效。我在一个拖拽功能上就吃过这个亏本地测试一切正常打包到真机上拖拽完全没反应。原因是真机上跑的是 release 构建Vec3的返回策略和编辑器预览不同。改成setPosition之后问题消失。1.3 脚本报红但游戏照跑TypeScript 类型声明的问题经常出现这种情况编辑器里脚本文件全是红波浪线但游戏能正常运行。这通常不是代码错而是 TypeScript 的声明文件没被正确识别。tsconfig.json里的types字段需要指向编辑器提供的声明文件3.x 一般在项目根目录自动生成temp/declarations。如果你手动改过tsconfig.json或者用 npm 装了cocos/creator-types但版本和编辑器对不上就会出现大面积报红。处理方式是把tsconfig.json恢复成编辑器生成的默认内容只在compilerOptions里加自己的规则比如{ compilerOptions: { strict: false, experimentalDecorators: true, skipLibCheck: true } }skipLibCheck打开可以省掉大量第三方库的类型报错。另外如果你的项目是用 VSCode 打开但工作区根目录选错了比如选了assets而不是工程根目录也会导致声明文件找不到这个坑我见新手踩过很多次。2. 生命周期与事件那些莫名其妙的行为源头脚本层面的诡异问题九成和生命周期顺序、事件注销、节点销毁时机有关。这些东西文档里都写了但只有真正踩过一次才知道它的影响面有多大。2.1 onLoad 和 start 的执行顺序到底能不能依赖引擎的调用顺序是确定的onLoad→onEnable→start→update→lateUpdate→onDisable→onDestroy。但同一层级的多个节点之间谁先谁后是不保证的。这个不保证害死人。典型的场景是A 脚本在start里需要读 B 脚本初始化好的数据。你在编辑器里测试时 A 恰好排在 B 后面一切正常改了个节点顺序或者新增了一个节点顺序就变了直接崩。我的做法是永远不要在start里跨节点取数据。有三种替代方式用全局事件B 初始化完成后派发一个自定义事件A 在onLoad里注册监听。用显式的初始化调用由一个上层管理器在start里按顺序调用各个子模块的init()顺序完全由你控制。用懒加载访问器需要时才去取取的时候确保对方已初始化。第一种方式最通用。用EventTarget做一个全局事件总线注意一定要用cc.director或者自己 new 一个独立的EventTarget不要去监听节点上的事件因为节点销毁后事件也跟着没了。import { EventTarget } from cc; export const Bus new EventTarget();这里有个细节EventTarget的off如果不传callback参数会把该事件的所有监听都清掉。如果你只想移除自己的那一个必须把函数引用也传进去。很多人写Bus.off(xxx)图省事结果把别人的监听一起清空了问题排查起来极其困难。另一个高频陷阱是addComponent后的执行时机。当你调用node.addComponent(MyComp)时如果这个节点当前是激活的MyComp的onLoad会立即同步执行而start要等到下一帧。所以如果你在addComponent之后立刻调用组件上的某个方法这个方法里如果依赖了onLoad里赋的值是安全的但如果依赖start里的值就是 undefined。还有一个反直觉的点节点的active为false时挂在它上面的组件的onLoad也不会执行直到节点第一次被激活。这意味着你不能在节点未激活时通过组件去做任何初始化得把它激活之后再做。2.2 事件监听的注销内存泄漏与幽灵回调事件没注销导致的后果有两个内存泄漏以及已经销毁的节点还在响应回调。第二个后果更恶心。比如你给一个按钮注册了点击事件回调里操作某个节点。按钮所在的弹窗关闭、节点destroy了但事件还在下次再点的时候回调仍然执行然后访问已经销毁的对象报Cannot read property of null。规范做法是在onEnable注册、onDisable注销成对出现onEnable() { this.node.on(click, this.onClick, this); } onDisable() { this.node.off(click, this.onClick, this); }用onEnable/onDisable而不是onLoad/onDestroy的原因是节点的active切换、父节点被销毁都会触发onDisable覆盖的场景更全。用编辑器里直接绑定的事件也就是在属性面板上拖函数的那种不需要手动注销引擎会在节点销毁时自动处理。2.3 节点销毁的延迟性destroy 之后还活着node.destroy()不是立刻销毁它是延迟到当前帧结束才真正执行。所以在调用了destroy()之后的同一帧里这个节点还能被访问isValid也返回true。这会导致一个经典的错误遍历子节点数组边遍历边销毁。// 错误示范 for (const child of this.node.children) { child.destroy(); }这里的问题不是destroy的延迟而是children返回的是内部数组的引用销毁过程中数组会被修改导致遍历跳过元素。正确做法是先复制一份const list this.node.children.slice(); for (const child of list) { child.destroy(); }要判断一个节点是否还有效用isValid(node, true)。第二个参数表示即使引擎已返回但在本次循环中被销毁的对象也判为无效在帧内做批量处理时非常有用。我一般封装一个工具函数所有对节点的延迟访问比如setTimeout之后的操作都先过一遍这个校验。3. 资源加载与内存把崩溃挡在门外移动端的崩溃一半以上和内存有关。Cocos Creator 的资源管理机制本身是引用计数但它默认不会帮你释放需要开发者主动做。这块搞不清楚游戏跑十几分钟就闪退是很正常的。3.1 resources 目录和 Asset Bundle 该怎么选resources目录下的所有资源会被无条件打进主包不管你有没有用到。这是新手最常犯的错误把几百张图全丢进resources结果首包体积巨大加载时间以分钟计。正确做法是resources里只放启动必须的资源比如启动 Logo、Loading 界面用的小图、配置文件。其余资源全部按功能模块划分成 Asset Bundle通过assetManager.loadBundle按需加载。Bundle 的配置在编辑器的资源管理器里把一个文件夹设为 Bundle设置好优先级和压缩类型。这里有个选择压缩类型适用场景代价合并依赖模块内资源互相引用多需要一个索引文件加载多一步合并所有 JSON配置类文本多JSON 会被打进一个文件无压缩调试期、小模块文件数多请求次数多压缩发布包体需要解压少量 CPU 开销分包的粒度我一般按游戏内的功能界面来切比如主城、战斗、背包各一个 Bundle。切太细会导致 Bundle 数量暴涨每个 Bundle 至少一次网络请求或 IO加载反而更慢切太粗就失去了按需加载的意义。经验值是一个 Bundle 控制在 2 到 10 MB 之间。3.2 引用计数与 release 的正确姿势assetManager内部维护引用计数但只对通过load加载的资源生效。你调一次load计数加一调一次release计数减一减到零且没有其他强引用时资源才真正被释放。常见的两个错误错误一加载了不释放。每次进战斗界面都load一遍战斗图集退出时不释放。跑几轮下来内存直接爆。错误二对同一个资源加载两次只释放一次。计数永远是 2资源永远不释放。这种情况下必须保证load和release的次数严格配对。我习惯封装一个资源管理器用一个 Map 记录每个路径的引用次数对外暴露retain(path)和release(path)两个方法内部保证配对。同时在场景切换时统一清掉这个场景注册的所有资源避免遗漏。class ResMgr { private refMap new Mapstring, number(); // 加载并计数 async load(path: string, type?: any) { this.refMap.set(path, (this.refMap.get(path) ?? 0) 1); return await assetManager.loadAny(path); } // 释放并计数 release(path: string) { const n this.refMap.get(path) ?? 0; if (n 1) { this.refMap.delete(path); assetManager.release(path); } else { this.refMap.set(path, n - 1); } } }顺带说一句图片资源有个特殊性它和它的SpriteFrame、Texture2D是三层结构释放时要理清释放的是哪一层。一般释放SpriteFrame就够了Texture2D会被连带处理。但如果你是自己从Texture2D创建的SpriteFrame那就得手动管理。3.3 算清楚一张图占多少内存很多人对纹理内存没概念觉得一张 1024×1024 的 PNG 在磁盘上只有 200KB那内存里也就 200KB。完全不是。纹理上传到 GPU 后是未压缩的位图占用内存按公式算内存 宽 × 高 × 每像素字节数默认的 RGBA8888 每像素 4 字节所以 1024×1024 的图占 4MB。2048×2048 就是 16MB。放二十张这样的图80MB 就没了中低端机直接开始告警。控制手段有几个用图集。把多张小图打进一张大图集减少纹理数量更重要的是能合批。但要注意图集本身尺寸不要超过 2048部分低端机的最大纹理尺寸就卡在这。用压缩纹理。3.x 的压缩纹理配置可以针对不同平台生成 ETC2、ASTC、PVRTC 等格式。ASTC 4×4 是 1 字节每像素直接省掉四分之三内存。代价是低端机不一定支持需要在构建时按平台分别配置同时保留一份未压缩的兜底。控制尺寸。美术给图时按最终显示尺寸的 1 到 2 倍给别给一张 4096 的图然后缩到 200 显示纯浪费。我做项目时会固定做一件事在加载完一个模块后打印一次director.root.device相关的内存信息或者用浏览器端的内存快照确保单个场景的资源占用在一个可接受的范围内。移动端我给自己定的红线是纹理内存不超过 150MB。4. 渲染与性能DrawCall 超标时的排查顺序帧率掉下来的时候很多人第一反应是代码写得不够优化然后去翻update。但实际上一半以上的性能问题出在渲染层而且往往是美术资源的用法问题。4.1 DrawCall 为什么合不起来引擎合批batching的条件并不复杂同一个材质、同一张贴图、渲染顺序连续。任何一条不满足就会被打断产生新的 DrawCall。按打断的常见程度排序我的排查顺序是节点树顺序被打断。这是最隐蔽的一种。合批是按渲染顺序遍历节点树做的如果 A、B 两个 Sprite 用了同一张图但中间夹了一个用别的图的 Sprite就合不上。解决办法是调整同级节点的顺序把同图的放一起。用了 Mask 或 Graphics。Mask组件会改变模板缓冲状态直接打断合批。一个列表里每个 item 都带 MaskDrawCall 会直接翻倍。能不用 Mask 就别用可以用圆角图片加九宫格替代方形的遮罩需求。Label 打断了合批。3.x 的 Label 如果和 Sprite 混排会打断 Sprite 的合批。Label 自己内部所有字符如果能合到同一张字体图集上是能合批的但如果用了系统字体每个 Label 可能都是一次独立的绘制。不同混合模式。比如半透明和普通混合混在一起会有额外的状态切换。材质实例不同。使用同一个材质资源的不同材质实例或者动态改了材质参数都会造成不合批。一个实用的技巧是打开渲染调试面板逐个关闭节点观察 DrawCall 变化。找到打断点之后通常不需要改代码只需要调整节点顺序或换掉某个组件。4.2 用 Profiler 定位是 CPU 瓶颈还是 GPU 瓶颈帧率低但不知道为什么这时候要做的是分类而不是盲目优化。判断方法很简单如果降低分辨率或把画质调低之后帧率明显回升说明是GPU 瓶颈问题在渲染填充率、DrawCall、Shader 复杂度。如果降低分辨率没效果说明是CPU 瓶颈问题在脚本逻辑、物理计算、频繁的节点操作。CPU 瓶颈的常见元凶每帧调用getComponent。这个函数内部要做类型查找开销不小。全部改成onLoad里缓存一次。字符串拼接和 JSON 解析。战斗中每帧拼字符串打日志或者每帧解析一次配置这些都是隐形的性能杀手。日志记得在 release 构建里关掉console.log在原生平台上是真的会拖慢帧率的。频繁的节点增删。instantiate和destroy涉及大量内存分配和 GC列表滚动时特别明显。物理系统。如果只是用来做碰撞检测而不需要物理模拟考虑改成自己写 AABB 检测能省掉一大块开销。真的要用物理把固定步长调大一些比如从 1/60 调到 1/30物理计算量直接减半。GPU 瓶颈的常见元凶则是叠加的大面积半透明 UI、全屏后期特效、复杂的自定义 Shader。4.3 对象池不想让 GC 在战斗中找人麻烦列表滚动、子弹发射、飘字这些场景如果每次都instantiatedestroy帧率会周期性抖动。原因是 V8 的 GC 在回收时会造成明显的停顿。对象池的核心就两条取出时先看池子里有没有有就复用回收时不销毁重置状态后放回池子。class Pool { private map new Mapstring, Node[](); get(prefab: Prefab): Node { const key prefab.data.name; const list this.map.get(key); if (list list.length 0) { const node list.pop()!; node.active true; return node; } return instantiate(prefab); } put(node: Node) { const key node.name; node.removeFromParent(); node.active false; if (!this.map.has(key)) this.map.set(key, []); this.map.get(key)!.push(node); } }几个容易忽略的点回收时必须把节点从父节点移除、把active设为false还要重置它的位置、缩放、透明度、以及所有需要复位的组件状态。另外要设一个池子上限超过上限的节点真正销毁否则池子会无限膨胀内存越吃越多反而更糟。通常按屏幕内最大同时显示数量的 1.5 倍设上限比较合适。5. 打包 APK从构建面板到真机闪退的完整链路打包这块是热词里提到最多的内容也确实是最容易卡住人的环节。因为它涉及的环境变量、工具链版本、签名配置全是外部的任何一环不对都会失败。5.1 原生环境准备版本匹配比装全更重要构建 Android 需要三样东西JDK、Android SDK、NDK。问题不在于有没有装而在于版本对不对。工具常见坑建议JDK17 和低版本 Gradle 不兼容按引擎文档指定的版本装不要盲目用最新的Android SDK缺 Build Tools 或 Platform 版本用 SDK Manager 装齐看构建日志报的哪个版本缺NDK版本不匹配导致编译报错严格用引擎文档推荐的版本Gradle缓存损坏导致编译卡死删除用户目录下的.gradle/caches后重试环境变量这块JAVA_HOME必须指向 JDK 的根目录而不是bin目录这是新手最常犯的错。ANDROID_HOME指向 SDK 根目录。设置完之后重启命令行和编辑器因为环境变量是进程启动时读取的改完不重启不生效。构建面板里几个关键项包名必须是反域名格式比如com.yourcompany.yourgame不能有大写字母和中文。API LeveltargetSdkVersion太新可能在某些机型上有兼容问题太旧又上不了应用商店。一般跟着引擎默认值走。架构arm64-v8a是必须的现在主流商店都要求支持 64 位。armeabi-v7a可以加但会让包体变大。只打arm64能省一半体积。签名release 构建必须用正式签名文件debug 签名不能上架。签名文件的密码和别名别写在构建面板里就完事记好丢了就换不了包了。5.2 构建报错的分类排查法构建失败的输出信息通常很长但可以按阶段分类阶段一编辑器导出阶段报错。一般是资源问题比如某个资源引用丢失、脚本编译不通过。这种情况下先看编辑器的控制台不要去看 Gradle 日志。阶段二Gradle 配置阶段报错。典型信息是Could not find或者Unsupported class file major version。前者是依赖下载不到检查网络和 Gradle 仓库配置后者是 JDK 版本和 Gradle 版本不匹配是最高频的报错直接换 JDK 版本。阶段三原生编译阶段报错。常见的是 NDK 相关比如某个第三方库用了旧版 NDK 的 API。这时候要看具体是哪个.so编译失败。阶段四链接阶段报错。一般是重复符号或者找不到符号通常是引入了多个第三方 SDK 造成的冲突。我的做法是保留每一次构建的完整日志出问题时对比上一次成功和这一次失败之间的差异。多数情况下差异就在最近的改动里比从头翻日志高效得多。一个很实用的小技巧如果构建失败且原因不明先把build目录整个删掉重新构建。Gradle 的增量编译缓存损坏是很常见的删掉重来能解决相当一部分玄学问题。5.3 真机上的白屏、黑屏与闪退包装出来能装上但一打开就是白屏这种问题按下面的顺序查基本能定位看日志。挂上设备用adb logcat过滤自己的包名看有没有脚本报错。Cocos 的 JS 报错会打到 logcat 里这是第一步也是最重要的一步。确认启动场景。构建配置里的启动场景必须是一个真实存在、能正常运行的场景。有时候改了启动场景名但构建配置没同步就会白屏。确认资源路径大小写。Windows 和 macOS 的文件系统不区分大小写但 Android 的设备上区分。本地加载Textures/Bg而实际路径是textures/bg编辑器里能跑真机上就是加载失败。这个问题我在跨平台项目里遇到过一次改了半个小时才反应过来。确认兼容性设置。低端机上如果用了它不支持的压缩纹理格式会出现纹理全黑但其他 UI 正常的情况看起来像是部分白屏。确认原生插件。如果接了第三方 SDK检查它的初始化是否在正确的时机有些 SDK 在Application层做初始化时机不对会直接崩溃。闪退的排查要更依赖符号化后的堆栈。原生崩溃的堆栈在 logcat 里是一串地址需要用 NDK 里的工具做符号化才能看到函数名。这一步比较费劲但一旦建立起来后面排查效率会高很多。6. 脚本与原生互调桥接层的那些细节一旦项目需要接第三方 SDK、读取设备信息、或者调用系统能力就绕不开脚本和原生的互调。这块的坑主要在于类型转换和线程。6.1 类型签名的对应关系跨语言调用时参数类型必须用约定的字符表示。常用的几个字符类型说明Iint32 位整数Jlong64 位整数注意是大写 JFfloat单精度Ddouble双精度注意不是 FZboolean布尔值SString字符串Vvoid无返回值新手最容易错的是把long写成L那是对象类型前缀或者把double写成F。写错不会报错只是调用结果莫名其妙不对比如传进去的数字变成 0。另一个坑是字符串编码。JS 侧传过去的字符串默认是 UTF-8如果原生侧按别的编码解中文就会乱码。这个在接第三方 SDK 回传用户昵称时特别常见。6.2 线程问题不能从子线程直接回调 JS原生的回调可能来自任意线程。如果 SDK 的回调是在子线程里触发的而你在回调里直接调用 JS 函数结果是不可预期的——轻则数据错乱重则直接崩溃。正确的做法是让回调切回主线程再执行。在 Android 侧一般用runOnUiThread然后通过引擎提供的线程调度机制把调用排到下一帧执行。具体的接口名字各版本略有差异但思路是一致的JS 侧的所有操作都必须在主线程上执行。还有一个实际经验跨语言调用的开销比同语言函数调用大得多不要在高频路径上做互调。比如每帧调用一次原生方法取传感器数据帧率会明显下降。更好的方式是在原生侧缓存数据JS 侧按较低的频率去取或者由原生侧主动在数据变化时才回调。7. 屏幕适配一次配置好后面省很多事适配问题在开发期不明显一到多机型测试就集中爆发。核心是把设计分辨率、适配策略、安全区这三件事一次想清楚。7.1 Fit Height 和 Fit Width 怎么选Canvas组件的适配策略有两个选项本质是保高度还是保宽度。Fit Height保证设计高度完整显示宽度按屏幕比例伸缩。竖屏游戏一般用这个。Fit Width保证设计宽度完整显示高度伸缩。横屏游戏一般用这个。选错的后果是某些设备上重要 UI 会被裁到屏幕外或者上下左右出现大片空白。如果两个方向都要求完整显示那就得用第三种方式让背景层按比例放大溢出屏幕前景 UI 用Widget组件贴边对齐。这种做法几乎适用于所有游戏代价是背景图要给得足够大或者在边缘做延伸处理。Widget组件是适配的主力工具。它的对齐规则分上下左右和水平垂直居中Target可以选父节点或者某个固定尺寸的节点。常见的用法是把顶部资源栏的Top对齐、底部导航栏的Bottom对齐、中间的列表用百分比布局。要注意Widget的Align Mode有ONCE和ALWAYS两种前者只在初始化时对齐一次后者每帧检查。列表内容动态变化时用ALWAYS其他情况用ONCE省性能。7.2 刘海屏和安全区全面屏设备的刘海、挖孔、圆角会遮挡 UI。引擎提供了SafeArea组件把它挂在一个覆盖全屏的节点上它会自动根据设备的实际安全区调整自己的尺寸和位置。但用法有讲究SafeArea只能处理整体位移和缩放如果 UI 元素的位置是硬编码的绝对坐标套上SafeArea之后可能跑偏。我的做法是把界面结构分成三层背景层全屏铺满不需要安全区允许被刘海遮挡。内容层挂SafeArea所有需要完整显示的内容放这里面。装饰层不挂SafeArea用于边缘装饰允许溢出。这样分层之后适配逻辑清晰出问题也容易定位是哪一层的问题。另外提醒一句适配测试千万别只在自己手机上跑。至少覆盖三种比例16:9老机型、19.5:9常见全面屏、4:3平板。平板上的表现往往和手机差异巨大尤其是横屏平板上用 Fit Width 会导致高度严重不足。8. 遇到没见过的报错我是怎么定位的前面讲了很多具体问题但实际开发中一定会遇到文档里没写、搜索引擎也找不到答案的报错。这时候靠的是方法论。第一步把报错原文完整看一遍。很多人看到一大串红色就慌了直接截个图去问人。但报错的第一行和最后一行的at xxx调用栈里往往就写着出错的函数名和文件名。先读再问。第二步二分定位。如果不知道是哪段代码引起的把最近改动的代码注释掉一半看问题还在不在。还在就说明在另一半以此类推。这个方法笨但绝对有效我定位过最难的一个问题某个第三方 SDK 和引擎的全局变量重名就是靠它找到的。第三步构造最小复现。新建一个空场景只放相关的节点和脚本看能不能复现。能复现说明问题在业务代码里不能复现说明和场景环境有关。这一步能把排查范围缩小一大半。第四步对比环境。同一个工程在同事机器上正常、在你机器上不正常那就是环境差异。对比编辑器版本、Node 版本、系统版本、装过的插件。这类问题看着玄学但差异一定存在。第五步把结论记下来。我有个自己的踩坑文档每次解决一个不常见的问题就记一条现象、原因、解法。下次遇到同样的问题三分钟搞定。这份文档比任何教程都值钱因为它是为你自己的项目量身定制的。还有个经验是关于升级引擎版本的。不建议在项目中期升级编辑器的大版本收益通常小于风险。如果确实需要升先在一个独立分支上做跑通全部功能再合回来并且预留至少两天的回归测试时间。最后再说一个我至今印象最深的问题某个界面在低端机上偶尔卡死日志里什么都没有。查了三天最后发现是一个while循环里用了浮点累加做条件判断在精度不同的设备上永远达不到退出条件。这类问题没法靠经验预判只能靠把可疑代码一段段替换成确定性的写法。所以现在写循环我宁可多写两行用整数计数也不用浮点做边界判断。这种小习惯平时看不出价值真出事的时候能救命。
返回列表