我用Spine做角色战斗表现时,最开始把伤害结算写在Update里靠播放时间硬算,结果策划一改动画时长,满屏伤害数字错位。后来彻底把播放、回调、停止这三件事重新理了一遍,才算把这套动画系统真正管住。这篇就把我在Unity里控制Spine动画的完整经验写出来,从最基础的API到回调触发的生命周期再到停止动画的各种姿势,适合已经能跑通Spine集成、但想流程化控制动画播放的开发者参考。
1. 组件选型与初始化:SkeletonAnimation和SkeletonGraphic差的不是一点
1.1 两种组件的适用场景
在Unity里用Spine,首先要面对一个选择:挂在角色身上的是SkeletonAnimation还是SkeletonGraphic。很多新手只看到两边都能播动画就随便用,等做到UI层、换装或者合批时才发现当初选错了。
SkeletonAnimation继承自MonoBehaviour,主要用在3D场景、2D场景的世界对象上,比如地图上的怪物、场景里的NPC、战斗中的人物模型。它依赖MeshRenderer和MeshFilter渲染,受光照系统影响,也能和物理系统正常交互。如果你做的是RPG战斗场景,角色要转身、要受击位移、要和3D场景里的物体碰撞,选它更合理。
SkeletonGraphic则是为UGUI设计的,挂在Canvas下,作为Graphic组件参与UGUI的渲染和布局。比如UI立绘、卡牌角色、技能图标里的动态效果,或者你需要在某个UI面板里插一段Spine动画,选它就对了。它最大的优势是能和UI的图集、字体一起走UGUI的合批逻辑,在UI密集的界面里性能更好。但它不能直接挂到3D世界坐标的物体上,除非你用RectTransform配合Overlay相机或者WorldSpace Canvas去做,操作成本高很多。
我见过一个项目,战斗角色用的是SkeletonAnimation,结果后面需求变成“角色脚下要踩一个UI血条和名字”,他们为了图省事把血条做成Canvas里的ScreenSpaceOverlay,然后角色移动时血条和角色位置对不上,天天调坐标。其实正确做法是战斗角色放在WorldSpace Canvas或者直接用3D的UI方案,不然就单独用SkeletonGraphic。选组件这件事,最好在项目初期就定好。
1.2 初始化阶段最容易出的问题
Spine在Unity里的资源导入不像普通动画那样丢进去就能播,它依赖一套运行时和资源检查流程。最常见的问题有三个。
第一是Spine编辑器的导出版本和Unity运行时版本不匹配。比如你在Spine 3.8里做的动画,导出后塞给用了4.1运行时的工程,表现通常是骨骼错位、动画乱飞,甚至直接报Animation相关的序列化错误。这个不像普通报错那么直白,排查起来很头痛。建议项目立项时确定好Spine版本,升级运行时必须做全量回归测试。
第二是SkeletonDataAsset里的Premultiply Alpha设置。Spine默认的导出贴图是预乘Alpha的,如果你在材质球上勾了Straight Alpha或者把Shader里PMA选项关了,半透明边缘会发灰发白。常见症状是角色头发、飘带边缘有一圈白边。这个在初始化阶段就要检查,不要等美术素材全部做完再改。
第三是重复初始化的问题。SkeletonAnimation在Awake时会加载SkeletonDataAsset并产生一个SkeletonAnimation实例,如果你在Start里立刻去SetAnimation,一般没问题。但如果你在同一个对象上又动态改了SkeletonDataAsset,需要重新调用Initialize(true),这个操作会重建内部的骨骼数据,旧的事件绑定和动画状态全部失效,很多人在这里遇到“动画明明设置了却不播”的诡异问题。
我自己写项目时会做一个统一初始化管理器,专门负责SkeletonDataAsset的加载缓存和版本校验,任何模块要用Spine资源都从管理器走,避免哪儿都new一下然后互相打架。
2. 播放动画的API选择:SetAnimation、AddAnimation与AnimationName的关系
2.1 播放动作的核心方法
Spine播放动画最基本的写法是直接给AnimationName赋值。比如:
skeletonAnimation.AnimationName = "run";这个方式适合快速验证素材,或者做一些一次性的临时播放。但它的缺点是控制粒度太粗,拿不到动画的句柄,没法监听回调,也没法精确控制这一条轨道上的动画行为。实际项目里,我更推荐直接用AnimationState.SetAnimation。
Spine.TrackEntry entry = skeletonAnimation.AnimationState.SetAnimation(0, "run", true);SetAnimation三个参数分别是轨道编号、动画名、是否循环。它的返回值TrackEntry非常关键,这条动画后续的一切操作——暂停、放慢、回调、停止——都能通过这个对象来控制。
在动手写之前,需要先搞明白AnimationState是个什么东西。它是Spine的动画状态机,内部管理着多个Track(轨道),每个Track同一时刻只播放一条动画,你可以同时让多个Track各自播不同的动画组合起来。我们平时调用的AnimationName本质上也是交给了AnimationState去处理,只是省掉了中间步骤。
2.2 轨道编号与分层动画
SetAnimation的第一个参数是trackIndex。0号轨道是最常用的主轨道,一般放Idle、Run、Attack这类主状态动画。如果你只需要播放一条动画,全部用0号轨道就够了。
但Spine有一个很强大的特性,就是可以通过多轨道混层。比如角色下半身播放移动动画,上半身同时播放攻击动画,这在某些2D动作游戏里很常见。实现方式是骨架里把骨骼分成多个层,然后代码里让不同track控制不同层:
Spine.TrackEntry moveEntry = skeletonAnimation.AnimationState.SetAnimation(0, "run", true); Spine.TrackEntry attackEntry = skeletonAnimation.AnimationState.SetAnimation(1, "attack", false);第0轨跑动循环,第1轨攻击只播一次。前提是你在Spine编辑器里已经把骨骼的混合权重配置好,不然两条动画会把整个骨架抢成一团。这个知识点在项目里不一定用得上,但面试和排查复杂动画问题时会很有帮助。
2.3 队列播放与延迟控制
AddAnimation是新入同学最容易理解错的API。它不是在当前动画播放的同时再叠一条动画,而是把动画放进当前轨道的等待队列,等当前动画播完再切入下一条。
Spine.TrackEntry first = skeletonAnimation.AnimationState.SetAnimation(0, "attack1", false); Spine.TrackEntry second = skeletonAnimation.AnimationState.AddAnimation(0, "attack2", false, 0); Spine.TrackEntry third = skeletonAnimation.AnimationState.AddAnimation(0, "attack3", false, 0);这段代码的效果是攻击1先播,播完切攻击2,攻击2播完切攻击3,第三条播完后就停在攻击3的最后一帧。第三个参数delay传0表示紧接上一条播完就开始,如果你传一个正数就代表额外延迟多少秒,传负数在某些版本里有立即播放的意思,但我不建议依赖负数行为,不同版本会有差异。
这里有个容易踩的坑:当SetAnimation被再次调用时,它会立即打断当前轨道正在播的动画,而队列里还没播到的AddAnimation动画会被全部清掉。也就是说,如果你播了攻击1并排队了攻击2,这时候角色受到控制要切回Idle,队列里的攻击2会直接消失。这不是bug,而是Spine的正常行为。所以项目里只要涉及连击、排队,一定要做好轨道生命周期管理。
3. 回调的生命周期:Complete、LoopComplete、End、Interrupt和Event
3.1 TrackEntry回调与全局回调的区别
Spine回调体系里有一个容易让新手绕晕的地方:它有两层监听机制。一层挂在TrackEntry上,只针对某一次播放的动画实例;另一层挂在AnimationState上,面向所有轨道所有动画。
TrackEntry级别的回调适合处理“这个动作播完做什么”。比如当前角色播攻击,我想让它攻击结束回到Idle,那我就把回调绑定到这次攻击的TrackEntry上。这样即使同一时间好几个角色、好几个轨道都在播放,它们各自的回调井水不犯河水。
Spine.TrackEntry entry = skeletonAnimation.AnimationState.SetAnimation(0, "attack", false); entry.Complete += OnAttackComplete; void OnAttackComplete(Spine.TrackEntry trackEntry) { skeletonAnimation.AnimationState.SetAnimation(0, "idle", true); }AnimationState级别的回调则是全局钩子。你可以在它的Complete、Start、End、Event上挂监听,来统一处理所有动画播放时的事件。比如所有角色播任何动画时都要更新UI上的状态图标,那就用全局回调。但要注意,全局回调是最容易造成代码混乱的地方,因为所有动画都会触发它,你需要在回调内部自己判断是哪个角色、哪条轨道、哪条动画。
我一般在项目里维护一个简单的规则:业务逻辑用TrackEntry回调,跨模块的统一副作用(音效、埋点、特效生成)用AnimationState全局回调。这样可以避免一条动画播放时,同一种逻辑被多个Listener重复执行。
3.2 各类回调的触发时机
Spine的回调名和Unity的MonoBehaviour事件很像,但触发时机完全不同。我整理了一张表,按我的实际经验对照着看最直观:
| 回调 | 触发条件 | 使用场景 |
|---|---|---|
| Start | 动画开始播放,并且已经接管轨道 | 初始化战斗状态、记录起手时间 |
| Complete | 非循环动画播放完毕 | 播放完攻击后切回待机 |
| LoopComplete | 循环动画每播完一轮触发 | 跑步循环里刷足迹 |
| End | 动画被清除、被替换或被中断时触发 | 释放这个动画绑定的资源 |
| Interrupt | 动画被更高优先级播放打断 | 受击打断攻击时的特殊逻辑 |
| Event | 动画播放到Spine编辑器里设置的事件点 | 伤害判定、脚步声、特效激活 |
这里要特别强调:Complete只在非循环动画正常播完时触发。如果你的动画是循环的,在循环模式下不会触发Complete,而是每播完一轮触发LoopComplete。如果非循环动画没有播完就被别的动画顶掉,Complete也不会触发,取而代之的是Interrupt和End。这是新手最常误解的地方。
循环动画想等它播完怎么办?比如一个“呼吸”循环想让角色呼吸两轮后进入倒地,不能直接监听Complete,而要监听LoopComplete自己计数:
int breatheCount = 0; Spine.TrackEntry entry = skeletonAnimation.AnimationState.SetAnimation(0, "breathe", true); entry.LoopComplete += (trackEntry) => { breatheCount++; if (breatheCount >= 2) { skeletonAnimation.AnimationState.SetAnimation(0, "down", false); } };还有一种情况:非循环动画播完后,如果你不主动切下一个动画,TrackEntry的End会在后续的更新中被触发。也就是说Complete之后还可能出现End。所以状态机切换时,不要在Complete里马上销毁角色或清空资源,等End再释放会安全一些。
3.3 回调失效的典型场景
新手经常反馈:“我明明绑定了Complete,为什么不触发?”排查下来,绝大多数原因是动画根本没走完——要么被新动画抢占了轨道,要么角色被disable了。
第一个场景是攻击动画播放中被受击打断:
skeletonAnimation.AnimationState.SetAnimation(0, "hit", false);如果你在受击后调用了上面这行,原来那条攻击动画就被中断了。攻击的Complete永远等不到,只有Interrupt和End会触发。所以做战斗系统时,攻击伤害结算最好不要放在攻击动画的Complete里,而是放到攻击动画里的Event时间点,或者你自己用一个状态机字段记录“当前是否在攻击收尾阶段”,被中断时手动补救。
第二个场景是GameObject被SetActive(false)。SkeletonAnimation的动画更新依赖MonoBehaviour的Update,一旦物体失活,整个动画状态机就停了,所有回调都不会触发。你重新激活物体时,动画时间也不会自动补帧。如果你有“物体隐藏期间动画应该继续推进”的需求,需要自己在外部维护一个逻辑时钟,用timeScale做补偿。
第三个场景是TrackEntry事件绑定到旧对象但没解除。比如角色死亡,播放死亡动画并绑了Complete,结果还没播完角色就被回收了。下次这个对象从对象池出来,Complete还挂着一个旧的回调,可能被不知名的Trigger再次触发。这个后面章节我会专门讲对象池清理。
3.4 用Event回调做攻击判定帧
Spine在编辑器里允许给动画添加事件帧,这些事件可以挂在任意时间点。相比用Complete做伤害结算,用Event做伤害判定更精确、更抗改动。
比如你在Spine编辑器里给“attack”动画的第6帧加了一个名叫hit的事件。代码里这样监听:
Spine.TrackEntry entry = skeletonAnimation.AnimationState.SetAnimation(0, "attack", false); entry.Event += (trackEntry, spineEvent) => { if (spineEvent.Data.Name == "hit") { Debug.Log("命中帧到了,执行伤害结算"); } };这样无论策划把动画时长从0.5秒改成1秒,还是把命中帧从第6帧挪到第8帧,代码都不用改。因为事件点是挂在动画播放进度上的,不是挂在固定的现实时间上。反过来,如果我用一个协程去延迟伤害结算,比如yield return new WaitForSeconds(0.3f),动画一有改动就得重新调数值,这是最拖慢迭代效率的写法。
Event回调还有一个好处:它可以配合音效。比如脚步落地事件、出招喊声事件、武器挥动风声事件,统统在编辑器里点一下就能精准对齐。你觉得某个手感不好,挪一下事件帧就行,完全不用程序员介入。
4. 停止动画的四种方式,用错一个就要排查半小时
4.1 四种停止方式对比
“停止动画”听起来是最简单的操作,实际上却是坑最多的环节。不同项目的不同需求,对应的停止方式完全不一样,选错一个,轻则动画卡最后一帧,重则骨骼回不到初始状态。
我按自己的使用频率排个优先级:
| 方式 | 写法 | 行为 | 适用场景 |
|---|---|---|---|
| 清空指定轨道 | AnimationState.ClearTrack(0) | 清除该轨道上当前及队列动画 | 角色被销毁、被回收 |
| 清空全部轨道 | AnimationState.ClearTracks() | 清除所有轨道动画 | 一次性重置整个Spine动画系统 |
| 平滑回到初始姿势 | AnimationState.SetEmptyAnimation(0, 0.2f) | 用0.2秒过渡到空白动画 | 从攻击平滑回到待机 |
| 直接恢复初始姿势 | Skeleton.SetToSetupPose() | 立即回到骨架的初始姿势 | 敌人死亡瞬间、全量重置 |
ClearTrack和ClearTracks会把轨道清空,但如果只清轨道,骨架会停在当前动画的最后一帧,不会自动回到初始姿势。如果你想要的是“播完这个后完全静止在一个自然状态”,需要再调用SetToSetupPose或者SetEmptyAnimation。
SetEmptyAnimation是比较容易忽略但很实用的方法。它的第二个参数是过渡时间,传入0.2f就代表在0.2秒内把角色从当前姿势淡出到一个空白状态,期间不会有明显的骨骼跳变。举个例子:角色攻击结束需要回到“双手自然下垂”的待机准备姿势,直接用SetAnimation(0, "idle", true)当然可以,但如果idle和attack之间没有配混合,切换时骨骼会瞬间弹跳。而SetEmptyAnimation再联动SetAnimation,就能做出相对顺滑的过渡。
4.2 暂停与停止不要混为一谈
有些需求只是“先别动了”,不是彻底停止。比如角色进入短暂定身状态。这种时候去ClearTrack就做过头了,因为你一清轨道,动画状态也没了,要恢复还得重新播放一遍。
暂停的正确做法是控制时间缩放:
skeletonAnimation.timeScale = 0;这样整个动画状态机不再向前推进,当前帧和姿势都保持住。如果要单条轨道暂停,可以直接对TrackEntry下手:
Spine.TrackEntry entry = skeletonAnimation.AnimationState.GetCurrent(0); entry.timeScale = 0;timeScale为0时不会触发任何回调,这个行为很关键。如果你的战斗逻辑在等待一个动画播完之后接技能,而某帧把timeScale设0了,后面恢复时间的时候,动画如果已经到达结束点,会不会补触发Complete呢?在Spine的运行时里,时间恢复后动画会继续从暂停点播放,如果暂停点已经超过了动画时长,则会按照运行时逻辑处理,这可能触发也可能不触发,取决于具体版本和是否走了循环逻辑。所以不要依赖这个行为,最好在暂停时用一个状态位标记,恢复后主动去检查动画是否应该结束,而不是赌回调。
4.3 停止时回调残留问题
停止动画时最容易出的隐蔽问题是回调没解绑。我以前写过一个对象池,敌人死亡后播放一个死亡动画,等End触发后回收对象。第二次敌人被激活再死一次,结果发现第一次的Complete回调还在,导致死亡结算代码执行了两遍。
原因是每次SetAnimation都会生成一个新的TrackEntry,如果你死死拿着旧TrackEntry并绑了回调,即使它已经被清出轨道,只要那份引用还在,回调的委托也还在内存里。正确做法是在停止或回收时主动解除绑定:
private void CleanupEntry(Spine.TrackEntry entry) { if (entry == null) return; entry.Complete -= OnDeathComplete; entry.End -= OnDeathEnd; entry.Event -= OnHitEvent; }在调用ClearTrack之前,先遍历当前轨道上所有TrackEntry,解绑一遍,再清理。停动画这件事一定要跟资源释放放同一个生命周期方法里处理,千万别只在某个业务回调里随手stop一下就不管了。
5. 一个通用角色状态机:把播放、回调、停止串起来
5.1 状态与动画的映射
理论讲了一大堆,最终还是要落到代码。我把自己项目里用的一段通用状态机分享出来,它把播放、回调、停止三者统一管理,配合上面的生命周期知识可以直接用。
先定义一个简单的动画模块类:
public class CharacterSpineAnim { private SkeletonAnimation skeletonAnimation; private Spine.TrackEntry currentEntry; private System.Action currentCompleteCallback; public CharacterSpineAnim(SkeletonAnimation skeletonAnimation) { this.skeletonAnimation = skeletonAnimation; } public void Play(string animName, bool loop, System.Action onComplete = null) { if (currentEntry != null) { currentEntry.Complete -= HandleComplete; currentEntry.End -= HandleEnd; } currentCompleteCallback = onComplete; currentEntry = skeletonAnimation.AnimationState.SetAnimation(0, animName, loop); currentEntry.Complete += HandleComplete; currentEntry.End += HandleEnd; } private void HandleComplete(Spine.TrackEntry entry) { var callback = currentCompleteCallback; currentCompleteCallback = null; callback?.Invoke(); } private void HandleEnd(Spine.TrackEntry entry) { if (currentEntry == entry) { currentEntry.Complete -= HandleComplete; currentEntry.End -= HandleEnd; currentEntry = null; } } public void Stop() { if (currentEntry != null) { currentEntry.Complete -= HandleComplete; currentEntry.End -= HandleEnd; currentEntry = null; } skeletonAnimation.AnimationState.ClearTrack(0); } }这个封装的要点在于:任何新动画播放前,先把上一个TrackEntry的回调解绑,确保不会出现多个动画的回调互相叠加。同时它只维护一个“当前动画”的引用,状态切换时的中断场景全部交给HandleEnd去兜底。
5.2 封装动画切换方法
一个角色身上的动画往往会绑定多个业务状态。比如战斗角色有Idle、Run、Attack、Hit、Die五个状态。我不建议每个业务模块都去直接调SetAnimation,而是统一用一个方法:
public void PlayIdle() => Play("idle", true); public void PlayRun() => Play("run", true); public void PlayAttack() => Play("attack", false, OnAttackFinish); private void OnAttackFinish() { PlayIdle(); }这样设计有个明显的好处:状态的迁移路径一目了然。策划说“攻击完不要回待机,要回到跑动”,你只需要改OnAttackFinish里的逻辑,其他模块不用动。
对于受击打断,这种封装也能处理。角色正在攻击,这时候被怪物打了一下,你调用Play("hit", false),新的SetAnimation会立即抢占轨道,原来的攻击动画触发Interrupt和End,之前绑定的OnAttackFinish会被HandleEnd解绑掉,不会误触发。也就是说,攻击没打完,它对应的收尾动作就不会执行。这比我在Update里硬写的播放时间判断要稳健得多。
5.3 攻击连击与受击打断的实现
连击是状态机里稍微复杂一点的场景,因为它要利用队列机制。拿两段普攻举例:
public void PlayComboAttack() { Spine.TrackEntry first = skeletonAnimation.AnimationState.SetAnimation(0, "attack1", false); Spine.TrackEntry second = skeletonAnimation.AnimationState.AddAnimation(0, "attack2", false, 0); second.Complete += (entry) => { PlayIdle(); }; }这里不能给first绑定Complete回调去切attack2,因为AddAnimation本来就会自动排队。如果你在first.Complete里再调用一次AddAttack,队列会重复,导致攻击2播两遍。正确做法是把收尾逻辑绑定在第二个动画的Complete上。
还有一个细节:当你需要“攻击1被受击打断后,攻击2也不要再排出来”时,一定要在触发受击新SetAnimation之前,手动处理旧Entry队列。Spine在SetAnimation覆盖同一轨道时,会把当前轨道队列里所有还没播的AddAnimation清掉,所以攻击2理论上不会播。但如果你用AddAnimation排的是另一条轨道,比如攻击2在Track 1,那受击就不一定能清掉它。遇到这种多层轨道混编的情况,我建议在受击函数里显式调用ClearTrack(1)来兜底,不要依赖运行时默认行为。
6. 实战避坑:对象池复用、性能优化与平台差异
6.1 对象池复用导致回调串台
对象池是Unity项目绕不开的话题,Spine动画在对象池里的表现特别容易出现“幽灵回调”。典型的翻车现场是:箱子里有一个怪物,死亡动画播完后被回收。第二次从池里拿出来,还没让它播放任何动画,它的Complete回调就自己触发了。
这个问题的根源在于:上次播放死亡动画时绑定的TrackEntry引用没有被清理。Spine内部其实已经把这个Entry标记为Dispose了,但只要C#侧还持有引用,委托链就不会断。因此回收角色时,除了清理Transform、位移、Buff列表之外,一定要调用动画模块里的Stop方法,显式解绑所有事件。
我在项目里会强制要求所有可池化的Spine对象,在回收时统一走一遍以下流程:
- 调用
Stop()或ClearTracks(),停止所有轨道动画。 - 把
currentEntry置空,解绑所有回调。 - 把
SkeletonAnimation.timeScale重置为1。 - 调用
Skeleton.SetToSetupPose(),把骨骼恢复到初始姿势。
这样下次从池里拿出来时,动画状态是干净的,不会受上一次生命周期影响。
6.2 性能:动画实例多起来了怎么办
Spine动画的CPU开销主要在骨骼计算和顶点更新,场景里一旦出现几十个角色同时播放动画,帧率下降会非常明显。我自己在项目里总结了几条实用的性能策略。
第一,SkeletonDataAsset要做全局缓存。同一个怪物类型的所有实例可以共享同一个SkeletonDataAsset,不要让每个实例都new一份。这不仅省内存,动画数据还能利用Unity的资源缓存机制,加载速度会明显提升。
第二,摄像机外的角色可以降频更新。Spine的骨骼计算是逐帧跑的,如果角色在屏幕外,其实不需要每帧都做完整更新。我在项目里会给SkeletonAnimation挂一个简单的可见性管理器,用OnBecameVisible和OnBecameInvisible控制它是否进入高频更新,或者在Invisible时把timeScale调低,比如0.5,减少计算量。
第三,不要滥用Event回调做高频逻辑。Spine的Event在动画播放到事件帧时会触发,如果事件帧非常多且每个都触发一次复杂逻辑,会造成主线程卡顿。比如角色跑步时每帧刷一个脚印粒子,如果把每帧都当成事件去触发,粒子系统会瞬间生成大量对象。实际做法是让LoopComplete去决定“这一轮循环里的某个时间点”,再配合协程或者延迟调用。
6.3 平台差异:PMA、纹理与WebGL/小游戏注意点
Spine动画在不同平台的坑不太一样,这里我只挑三个最常见的讲。
第一个是贴图格式。移动端和编辑器的纹理压缩方案有差异,有些GPU型号对RGBA32的纹理支持不好,导致Spine动画显示花屏或透明区域异常。建议打包前测试不同平台的纹理压缩格式,尤其是Android的ETC2和iOS的ASTC,不要直接沿用默认设置。
第二个是PMA预乘Alpha的问题,我在开篇提过一次,这里再强调:Spine的Shader默认是预乘Alpha的,如果你的美术管线需要Straight Alpha,必须在材质球上改对关键词。实际症状是角色半透明边缘出现很脏的黑边或白边,这种问题在编辑器里可能不明显,打包到移动端尤其明显。
第三个是微信小游戏和WebGL平台的兼容性。Spine的资源加载在这些平台上有异步和缓存上的差异,事件回调的时序也可能受加载影响。我在小游戏平台上遇到过动画播一半突然停住的情况,查下来是纹理压缩格式不兼容导致GPU加载失败。所以如果你要发WebGL或微信小游戏,建议从一开始就按那个平台的纹理规范来准备Spine图集,不要等快上线了再做适配。
最后分享一个我自己调试Spine时的习惯:遇到动画表现不对,先别急着改代码,在Inspector里把当前SkeletonAnimation上的AnimationName、当前Track的当前动画名、播放进度都打出来看一眼。回调不触发十次有八次是因为你脑子里以为当前播的是A,其实轨道上早就被切到B了。把“当前播放状态”可视化,比在代码里瞎猜要快得多。Spine动画控制说穿了就是播放找对API、回调理解生命周期、停止时彻底清理,这三点理顺之后,后面接任何玩法都顺手。