
《我所理解的cocos2d-x 3.0》这份笔记是我在把手上老项目从2.x向3.0迁移时一边读源码一边写下的。3.0不是一个换了几个新API的小版本它把渲染、事件、UI、动作、资源管理全部重构了一遍。如果你跟我一样在3.0刚发布那一刻打开工程发现到处都是编译错误又对着新目录结构不知道从哪里查起这篇笔记或许能帮你节省不少弯路。我尽量不把这篇写成官方文档的复述而是记录“我实际用下来”的理解和判断——哪些变化只是换名字哪些变化动了引擎骨架以及那些文档里不会主动告诉你的坑。1. 从2.x到3.0这一版到底动了什么根基刚接触3.0的人最容易产生一个错觉整个引擎就是在2.x基础上改了改类名。毕竟CCNode变成Node、CCSprite变成Sprite看起来只是把前缀去掉而已。但真正深入之后会发现这不是表面功夫整个工程的组织方式、代码书写习惯、内存管理心智模型全都要跟着变。1.1 类名与文件结构表面是改名实则是重新规划3.0把绝大多数类名的CC前缀拿掉了文件层级也重组了。2.x时代我们include一个类经常要写#include CCSprite.h这种带前缀的头文件3.0统一变成#include cocos2d.h引擎核心头文件集中管理上层业务代码大多数时候只需要引入这一个头文件。这个变化往浅了说是省事往深了说是引擎对内部模块边界做了重新划分。头文件分散的时候每个模块之间的耦合其实是靠开发者自觉控制的头文件归一之后暴露出来的公共API更收敛内部实现细节被藏得更深。坐标、尺寸这些基础类型也一起调整了。2.x里的CCPoint、CCSize、CCRect到3.0变成了Vec2、Size、Rect而且这些类型背后接入了向量运算、插值等更完整的数学封装。写惯2.x的人会经常不自觉地写出ccp(x, y)然后在3.0里发现这个宏没了得改成Vec2(x, y)。思路倒是很简单但手速一时半会儿改不回来。宏的变化也需要注意。2.x里大量使用CC_SAFE_RELEASE、CC_SAFE_DELETE这类宏3.0里它们并没有全部消失但使用场景变少了。原因在于引擎内部对对象生命周期管理的责任划分发生了变化后面我专门用一节聊引用计数这里先记住一个结论3.0的新代码鼓励你用create方法 树形节点的自动持有而不是手动管理临时对象。1.2 C11全面进场不是语法升级是写代码方式变了3.0是cocos2d-x全面拥抱C11的一个大版本。auto类型推导、lambda匿名函数、范围for循环、nullptr、override关键字这些不再只是语言特性而是引擎API在设计层面默认你使用它们。拿最常见的开关按钮来说2.x时代注册菜单回调要么写一个函数指针要么维护一个CCCallFunc动作3.0里变成auto button ui::Button::create(normal.png, pressed.png); button-addClickEventListener([](Ref* sender) { // 点击逻辑 });lambda直接嵌在业务逻辑里代码读起来更连贯。但lambda也带了新问题捕获的上下文对象可能已经释放回调触发时变成访问悬空指针。这个坑在后面动作和事件的章节里会反复出现这里先留个印象。范围for让遍历节点树舒服很多但要注意一个细节遍历children时如果顺便做了remove操作迭代器会失效。3.0的Node::getChildren()返回的是VectorNode*而Vector是自己的容器实现迭代器的行为和std::vector不完全一样。1.3 Renderer重写从“各画各的”到“排队统一画”2.x的渲染方式可以简单理解成每个节点自己往OpenGL里提交绘制命令节点多了以后draw call自然就高。3.0换了一套思路所有可见节点把绘制数据提交给Renderer渲染器攒一批再统一提交给GPU。相同纹理的精灵会被合并到同一个批次里单位控件、TileMap、粒子这类模块都从这套机制里受益。对业务开发者来说这套机制带来的直观变化有两个。第一同屏精灵数量相同时3.0的帧率比2.x明显稳第二我们不需要再像2.x时代那样刻意把精灵打包成一张大图求性能——当然资源合批还是有意义的但引擎至少给你兜了底。理解了这个差异你就知道为什么3.0里经常强调“少操作状态切换”而不是“少创建节点”。下表大致列出了2.x和3.0的底层差异对比项2.x3.0类名CCPrefix前缀去掉前缀统一命名基础类型CCPoint、CCSizeVec2、SizeC标准C98风格为主C11成为默认渲染机制节点直接提交绘制Renderer统一渲染、自动合批事件回调手动实现TouchDelegateEventListener lambdaUI体系CCMenu 手动布局Widget / Layout / LayoutManager内存管理手动保留为主create自动autorelease树管理为主2. 场景、节点与导演理解3.0的树形结构场景管理是理解整个引擎的基础。很多人上手第一周就能写出Scene::create()、addChild、replaceScene这几个调用但真到了排查问题的时候比如为什么节点不显示、为什么事件收不到、为什么内存不释放绕来绕去基本都会绕回到“节点树是怎么组织的”这个问题上。2.1 Director、Scene、Node的分工关系可以把Director理解成引擎运转的调度中心它持有当前场景驱动每帧的更新和渲染。Scene是游戏中最顶层的容器一个场景内部可以挂多个Node子树。Node是基础树节点Layer3.0里严格说并没有被移除只是UI体系弱化了对它的依赖或者Sprite、Label、ui::Widget都是它的具体形态。我第一次用3.0时最不适应的一点是以前做界面习惯往Layer上加东西现在更多是把Layer当成一个普通节点容器界面的根容器直接用Scene就行。Scene和Node在职责上几乎没区别Scene只是默认参与场景切换逻辑。想通这一点之后布局思路反而简单了——你就是在往一棵树上挂节点树根的形态不影响挂法。2.2 addChild之后从“加了个对象”到“接入了一套生命周期”addChild这个操作别看只是一行代码背后发生的是一整套生命周期管理。节点在addChild之后会进入onEnter流程被加入到渲染队列中开始接收每帧的update调用同时也意味着它被父节点retain了一次之后父节点移除它时会自动release。这正是3.0里最核心的持有关系树负责管活着的节点。写业务时有个非常容易踩的坑临时创建了一个节点但既没有加入树又忘了手动管理它的引用计数。在3.0里通过create()创建的对象默认是autorelease状态如果它没有在当帧结束前被某个容器持有比如addChild、setChild之类它就会被自动释放。所以如果发现某个节点创建后一闪而没或者直接为空先检查它有没有被加进树。反过来如果你手动retain了一个节点又忘了release内存就会一直不释放。这个平衡是3.0里最需要适应的地方。2.3 锚点、位置与子坐标解决“图偏了”和“转到哪了”的根源锚点anchorPoint这个问题在3.0里依旧是很多看起来“莫名其妙”的bug来源。锚点是一个范围在(0,0)到(1,1)之间的比例值它决定了position这个坐标到底绑定在节点自身哪个点上。比如一个宽100像素的Sprite锚点默认是(0.5, 0.5)那position(100,100)指的就是它中心点在(100,100)如果把锚点改成(0,0)那position就代表它左下角在(100,100)。所以做界面滑动、旋转、缩放时锚点选错了表现会完全不一样。子节点坐标继承父节点变换这一点也值得反复琢磨。父节点旋转子节点的世界坐标会跟着旋转父节点半透明子节点也半透明。这不是bug而是设计如此。我做的一个小Demo里把弹窗挂在一个按钮的子节点下结果弹窗出现的位置和旋转偏移全都随着按钮跳排查了半天才反应过来是继承问题。理解这层继承才能在调UI时快速判断“该用convertToNodeSpace还是convertToWorldSpace”。3. 事件分发重构从代理回调到集中式监听2.x时代写触摸事件给我的感觉像是自己在地上插了一堆电线这里注册一下那里注销一下一个不留神就漏了。3.0把事件机制收敛到了一个EventDispatcher上思路统一了很多但也不是没有坑。3.1 2.x时代的触摸写法到底绕在哪里当年用CCTouchDelegate的时候你得让Layer继承委托类然后实现ccTouchesBegan、ccTouchesMoved那套方法再在onEnter里调用setTouchEnabled(true)或者手动注册到TouchDispatcher。更要命的是如果你在onExit里忘记注销节点保留了一个已经失效的触摸入口后续线程里很容易出问题。那时候我排查过最诡异的一个bug某个页面退出后触摸仍然会触发已删除页面的逻辑。最后发现是onExit里压根没写注销代码。这种问题在3.0里基本被内置解决了——绑定到节点上的监听器会随节点从树上移除时自动清理但需要你用的不是fixed优先级模式这一点我马上展开。3.2 EventListener家族五个常用监听器一贴即用3.0把事件抽象成EventListener几个常用子类分别是EventListenerTouchOneByOne单点触控、EventListenerTouchAllAtOnce多点触控、EventListenerKeyboard键盘、EventListenerCustom自定义事件、EventListenerAcceleration重力感应。每个监听器都是创建后设置回调再交给EventDispatcher。单个触摸监听器的标准写法是这样的auto listener EventListenerTouchOneByOne::create(); listener-setSwallowTouches(true); listener-onTouchBegan [](Touch* touch, Event* event) { auto target static_castNode*(event-getCurrentTarget()); auto locationInNode target-convertToNodeSpace(touch-getLocation()); if (target-getBoundingBox().containsPoint(locationInNode)) { return true; } return false; }; listener-onTouchMoved [](Touch* touch, Event* event) { // 拖动逻辑 }; Director::getInstance()-getEventDispatcher() -addEventListenerWithSceneGraphPriority(listener, node);注意这里onTouchBegan返回bool返回true表示“我要这个触摸”返回false表示“我不感兴趣”。这个设计跟2.x的ccTouchBegan一样但3.0里和setSwallowTouches配合起来更直观。swallow开启后谁先抢到触摸后面的监听器就收不到这个事件了。3.3 优先级之争scene graph priority和fixed priority到底怎么选addEventListenerWithSceneGraphPriority(listener, node)监听器的优先级依赖节点在场景树里的层级顺序。节点在树里越靠后越后收到事件但同时也容易被更晚的节点拦截。而addEventListenerWithFixedPriority(listener, number)监听器不依赖树结构完全按数字决定先后。数字越小越先收到事件。这个问题在做一个通用弹窗遮罩时体现最明显弹窗需要把底下所有UI的触摸全部拦住。树层级靠后并不意味着一定最后一个收到事件因为还有其他层级。这时候用fixed priority的监听器把弹窗监听器的优先级压低再配合swallowTouches才能可靠拦截。不过要小心fixed priority监听器不会随节点移除自动注销你必须手动removeEventListener否则节点都释放了监听器还在事件一来直接崩溃。我实际开发时在这个问题上栽过两次。3.4 EventCustom用自定义事件解耦模块之间的消息除了输入事件3.0还提供了EventCustom用来在业务模块之间传消息。做一个血量变化的通知写法类似这样// 监听侧 auto listener EventListenerCustom::create(on_hp_changed, [](EventCustom* event) { auto hp static_castint(event-getUserData()); // 更新UI }); Director::getInstance()-getEventDispatcher()-addEventListenerWithSceneGraphPriority(listener, node); // 触发侧 EventDispatcher* dispatcher Director::getInstance()-getEventDispatcher(); dispatcher-dispatchCustomEvent(on_hp_changed, reinterpret_castvoid*(currentHp));这种解耦方式在模块较多时很省心但也不能滥用。全局事件广播太多代码的可读性和可追踪性会迅速下降。我的习惯是只对跨模块、跨场景的通知使用EventCustom模块内部就让它用普通的函数调用。4. 动作与动画Action体系重做后的玩法动作系统是我最早觉得3.0“值得学”的部分。2.x的动作体系很完整但接口名字混乱创建方式也繁琐。3.0把动作统一成Action家族创建方式基本收敛为SomeAction::create(...)再配合组合、回调、缓动能写出很丰富的表现效果。4.1 Action家族常用动作一张表看清3.0里的基础动作多数是so called “间隔动作”FiniteTimeAction创建方式非常统一动作类作用创建示例MoveTo / MoveBy移动MoveTo::create(1.0f, Vec2(100, 100))RotateTo / RotateBy旋转RotateTo::create(0.5f, 90.0f)ScaleTo / ScaleBy缩放ScaleTo::create(0.3f, 1.5f)FadeIn / FadeOut淡入淡出FadeIn::create(0.5f)DelayTime延时DelayTime::create(1.0f)TintTo颜色渐变TintTo::create(0.5f, Color3B::RED)Blink闪烁Blink::create(1.0f, 3)还有变速动作EaseIn、EaseOut、EaseBounce、EaseElastic这些缓动家族作用是把一个线性动作变成有加速度的曲线效果。表现上差别很大但接口一致EaseOut::create(MoveTo::create(...))套一层就行。我实际做UI过渡动画时几乎离不开缓动——UI弹出那种“带一点弹性的停稳”效果就是EaseBackOut或者EaseElasticOut的功劳。4.2 Sequence、Spawn组合才是动作系统的灵魂单个动作没什么好说的真正好用的是组合。Sequence让多个动作按顺序执行Spawn让多个动作同时执行。一个典型的进入动画auto moveIn MoveTo::create(0.4f, Vec2(visibleSize.width / 2, visibleSize.height / 2)); auto scaleUp ScaleTo::create(0.4f, 1.0f); auto fadeIn FadeIn::create(0.4f); auto appear Spawn::create(scaleUp, fadeIn, nullptr); node-runAction(Sequence::create(EaseOut::create(moveIn, 0.5f), appear, nullptr));这里有个需要注意的地方Sequence::create和Spawn::create的最后一个参数必须是nullptr来结束可变参数列表。漏掉这个nullptr一些版本的引擎会在运行时崩溃或者行为异常。这个写法本身有点老派但3.0早期确实是这么用的如果你在看老代码看到末尾有nullptr别奇怪。组合动作的另一个用途是“动作之后做事情”。比如播放一个动画之后判定结果可以用CallFuncnode-runAction(Sequence::create( DelayTime::create(0.5f), CallFunc::create([node]() { // 动作结束后的逻辑 }), nullptr ));4.3 动画播放Animation、Animate和帧动画的常规组合在3.0里做一个序列帧动画一般分成三步先收集帧、再用Animation组织帧、最后用Animate动作套到节点上。这里特别容易出问题的是帧的来源——用SpriteFrameCache加载plist之后再取SpriteFrame要比直接Sprite::create逐个加载图片高效得多。动画播放的节奏控制也是个常见需求。比如攻击动作的起手、打击判定、收招这三段时间不一样我会在动画编辑器里分三个Animation再由业务层的状态机按需切换。千万别在一个连续帧动画里硬塞逻辑判断否则后面调整“打击判定的时间点”会非常痛苦。4.4 动作回调与生命周期lambda捕获里的隐性风险动作回调里用lambda捕获节点或this是很自然的写法但有一个经典崩溃场景一个节点在动作执行过程中被从场景里移除并释放了而动作还在队列里回调触发时顺着捕获的裸指针去访问已经释放的对象。我在做某个闯关Demo时反复遇到“点击按钮后播放一段飞行动画动画结束时崩溃”的问题。查了半天发现是动画回调里用了this而this所在的场景早已被replaceScene换成下一个场景了。解决思路有两个一是在场景退出时对还在执行的节点统一stopAllActions二是在回调里先判断对象是否合法比如检查节点的getReferenceCount或者用一个标志位标记场景已退出。更稳妥的做法是回调里尽量捕获弱管理的引用不要裸捕。5. UI系统控件、布局与屏幕适配UI系统是3.0相比2.x变化最大、也是最让人重新学一遍的部分。2.x时代做UI基本靠CCMenu加一堆手动摆放的CCSprite做约束布局可以说根本没得玩。3.0推出了完整的UI控件家族虽然早期版本稳定性一般但设计思路已经成型。5.1 Widget、Layout、LayoutManagerUI框架的三个层次Widget是所有UI控件的基类Layout是容器类负责收纳多个子控件并协调排列LayoutManager决定子控件怎么排。这三者的关系可以类比成控件是“家具”LayoutManager是“摆放规则”Layout是“客厅”本身。在早期3.0里相对布局主要通过Layout的setPositionPercent和setSizePercent实现百分比是相对父容器宽高计算的。这样做的好处是屏幕变化时控件能自动跟着比例跑。但要注意布局容器本身的sizeType要先设置成“百分比”或“绝对”之一否则后续设置可能不生效。5.2 常用控件的使用Button、Text、ImageView和ScrollViewui::Button是最常用的控件支持普通、按下、禁用三态切图还能设置九宫格setScale9Enabled在按钮宽高变化时保持圆角不被拉伸。ui::Text是文本控件支持TTF和系统字体做多行文本时记得设置setTextAreaSize不然换行行为会很怪异。ui::ImageView其实就是包装过的Sprite但能吃到UI布局的百分比系统所以尽量用它承载UI图片。ScrollView和ListView滚动起来有一点需要注意触摸事件在滚动容器内部会被容器拦截如果你给容器里的按钮注册点击事件同时又想实现滑动翻页两者之间的触摸冲突需要小心调。我一般会给按钮设置一个小的触摸移动阈值超过就判定为滚动而不是点击。按钮事件在3.0里通常写addClickEventListener回调参数是Ref*但大多数时候不需要这个参数直接写lambda壳子就行。还有一个addTouchEventListener能拿到更多触摸状态开始、移动、结束、取消做按住蓄力这类交互时用它。5.3 屏幕适配设计分辨率、ResolutionPolicy和九宫格屏幕适配在3.0里主要靠Director的setDesignResolutionSize。引擎会拿设计分辨率和实际屏幕尺寸算一个缩放比再按照不同策略呈现SHOW_ALL会完整显示设计区域但屏幕上下或左右可能出现黑边NO_BORDER会填满屏幕但会裁掉设计区域的一部分FIXED_HEIGHT和FIXED_WIDTH是后加的固定一边另一边自适应。选择策略没有绝对正确全看游戏类型。我做横版动作游戏时偏好FIXED_HEIGHT因为场景高度固定左右两端多裁一点对游戏核心玩法影响小做UI密集的卡牌项目时又换回SHOW_ALL尽量保证所有控件不因为裁切而跑到屏幕外。九宫格Scale9在UI适配里是性价比最高的技巧。一张圆角矩形背景切分成九块中间一块随意拉伸四个角保持原尺寸这样同一个背景图在不同尺寸的弹窗上都能用。3.0里调用方式很简单auto button ui::Button::create(bg_normal.png); button-setScale9Enabled(true); button-setCapInsets(Rect(10, 10, 20, 20)); // 四角切分区域 button-setContentSize(Size(200, 80));setCapInsets里的矩形指的是原始图片上中间可拉伸区域的范围。这个值如果不按图片尺寸算准拉伸出来的圆角会变形所以每张九宫格图的四个角像素是多少一定要和美术对清楚。6. 资源管理与内存细节缓存、引用计数与autorelease性能问题排查到最后十有八九会撞到资源管理。3.0的资源管理组件不算复杂但每一步的取舍都直接影响内存和加载速度。6.1 FileUtils与搜索路径跨平台读文件不写死路径FileUtils是引擎层的文件读取接口。它最大的特点是支持搜索路径列表你只要把资源目录加进去后面直接用相对路径访问就行。FileUtils* fu FileUtils::getInstance(); fu-addSearchPath(res); fu-addSearchPath(res/ui); auto fullPath fu-fullPathForFilename(ui/main_scene.json);在Android和iOS上资源被打包进不同的只读目录路径规则差异很大靠写死路径几乎没法跨平台跑。用addSearchPath统一处理之后业务代码完全不用关心平台差异。还有一个容易被忽略的函数FileUtils::getInstance()-isFileExist(path)做“本地存档是否存在、缓存是否已生成”这类判断时非常有用。6.2 纹理缓存、SpriteFrame缓存、Animation缓存的分工缓存这块我用一张小表整理过了不同缓存对应不同数据层级缓存类管理内容常用入口TextureCache原始纹理图片解码后的GPU数据Director::getInstance()-getTextureCache()SpriteFrameCacheplist里定义的帧纹理上一块块区域的引用SpriteFrameCache::getInstance()AnimationCache帧动画的定义帧序列 每帧时长AnimationCache::getInstance()加载顺序上是层层依赖的先有TextureCache里的纹理SpriteFrameCache里的帧引用纹理的一块区域AnimationCache再把帧串成动画。所以当你发现动画播放出来是“空白”时优先查最底层那张纹理有没有成功加载而不是直接怀疑动画定义写错。清缓存要考虑时机。场景切换时如果旧场景的纹理不再被任何Sprite引用可以调用removeUnusedTextures()把它释放掉。但如果一张纹理被多个场景共享释放之后重新加载会带来卡顿频繁切换场景时表现尤其明显。我的经验是保持核心公共资源常驻只清理那些确定不会再用的场景私有资源。6.3 引用计数create、addChild、removeChild这条生命线3.0的Ref就是引擎所有可管理对象的基类三个关键方法是retain、release、autorelease。理解它们就能看懂一整条对象生命线。调用SomeNode::create()时引擎内部创建完对象之后调用了autorelease。autorelease是什么意思把这个对象丢进当前自动释放池到这一帧结束释放池清理时如果它的引用计数没有通过其他途径增加它就会被销毁。所以关键来了create出来的对象如果什么都不做往往活不过一帧但一旦addChild到某个节点里addChild内部会retain它引用计数从1变成2之后只要节点还在对象就活着。节点从树上移除时removeChild会对应release引用计数回落如果此时没有其他持有者对象就真正释放了。这个设计带来的一个常见误区是很多人手动retain一个节点后来忘了对应写release导致对象永远不释放。反过来如果对同一个对象既放心交给节点又手动release了一次就变成重复释放。排查这类问题可以用Xcode的调试器看引用计数变化或者干脆在怀疑点打日志。6.4 三个高频内存问题悬空指针、重复释放、缓存不释放积累下来我最经常遇到的内存问题就这么三类。第一类是悬空指针节点已经被移除并释放业务代码里还保存着一个指向它的裸指针帧循环里再访问就崩了。解决思路是养成“移除即置空”的习惯或者在需要长期持有节点时用retain把它稳稳地留在手里。第二类重复释放一个对象既被手动管理又被自动释放池管理两边各release一次引用计数变负数。这种bug难查在于它可能不是必然崩而是有时候崩、有时候不崩。建议在create之后除非非常明确自己要接管生命周期否则就乖乖交给树的自动管理。第三类缓存不释放图片、plist、动画定义都加载过但没有从缓存里移除导致内存只增不减。回到前面的表格做场景切换时定期清理SpriteFrameCache和AnimationCache里不再引用的条目纹理层清理removeUnusedTextures能解决大部分内存增长问题。7. 迁移中的典型踩坑记录从2.x移植到3.0怎么排查最后一部分我把自己迁移过程中真正遇到过的几个诡异问题整理成清单。不是为了炫技是因为这些问题在网上很难一条条搜到准确答案写下来能给后来人一点参照。7.1 触摸失效先分“没注册”还是“被吞了”迁移后最常见的问题是节点点了没反应。排查步骤我固定这么走第一步确认这个节点有没有绑定监听器搜索代码里有没有addEventListenerWithSceneGraphPriority。第二步确认监听器绑定的是不是实际可见的节点有时候我监听绑定在父节点上触摸点和子节点坐标对不上。第三步确认有没有别的地方设置了setSwallowTouches(true)某个上层UI把触摸吞了。第四步如果是fixed优先级监听器检查它的生命周期是否被管理。这套排查路径能覆盖我遇到过的大部分触摸失灵情况。7.2 图片发紫或发白纹理通道、压缩格式和缓存图片显示出来后颜色不对优先怀疑不是游戏逻辑问题而是资源侧问题。用了压缩纹理PVR、ETC等但没有正确的配置文件图像就会花掉图片尺寸不符合纹理要求时渲染也可能出偏差另外两次加载同名但内容不同的图片纹理缓存没有正确刷新就会出现旧图残留。遇到这类问题我先用Texture2D::setDefaultAlphaPixelFormat查看当前解码格式再确认素材本身在目标平台上的支持情况。如果只是临时想验证是不是缓存的锅把缓存全清掉再跑一遍基本能定位。7.3 动作回调不执行查Action归属和节点状态动作回调不触发十有八九是动作对象出问题了。比如节点执行了stopAllActions()之后之前runAction返回的Action*已经失效后续再对同一对象做retain或判断状态就可能出错。又或者节点已经从树上移除它的动作被自动清理但业务代码还持有旧的动作对象去step自然也没有回调。最好的习惯是每次runAction都用tag管理动作用getActionByTag查询状态用stopActionByTag精确停止。尽量避免保存裸的Action指针跨帧使用。7.4 子线程更新UI引擎不是线程安全的这是很多刚开始做异步加载的团队会踩的坑在子线程里解析完数据顺手创建了Sprite或者更新了Label然后主线程渲染时直接崩溃。3.0的UI和渲染对象都不是线程安全的子线程里能做的只是纯算法、数据解析这类不碰引擎对象的工作。需要把结果抛回主线程可以用Director::getInstance()-getScheduler()-performFunctionInCocosThread(...)或者自己维护一个主线程任务队列下一帧在update里统一执行。这份笔记写到最后我想说的反而和代码没关系。3.0最大的难点不在某个API不会用而在需要同时接受多套心智模型的改变——渲染变成了排队制事件变成了注册制UI变成了容器制内存变成了树责任制。这些变化我反复踩坑才慢慢内化。如果你也在迁移路上别急着抱怨编译错误多先把这几个底层模型想清楚后面顺很多。