玩Unity的应该都跟DOTween打过交道这个补间动画插件几乎成了UI动画和物体运动的标准选择。但我发现一个很有意思的现象很多人写DOTween动画时会写OnComplete回调却没想清楚它到底在什么时候触发。DOTween中回调函数的触发时机这个话题看起来简单实际上一踩就是一个坑。比如循环动画里OnStart只触发一次OnComplete却每循环都触发Kill掉一个补间还会触发OnKill调用了SetAutoKill(false)之后补间还活着回调又会漏触发。这些细节不搞清楚调动画的时候就会被诡异的时序折腾到怀疑人生。这篇文章我把DOTween里所有回调函数的触发时机一次性梳理清楚包括基础的回调节点、外部操作对回调的影响、时间缩放和嵌套补间的坑、以及我平时排查回调问题的流程。适合刚接触DOTween的初学者也适合写了一半动画回来救场的资深开发。接下来直接用代码和实际运行顺序说话。1. 先认识DOTween的回调家族1.1 老三样OnStart、OnUpdate、OnCompleteDOTween在为任何对象做补间时最常见的三个回调就是OnStart、OnUpdate、OnComplete。它们各自负责动画生命周期里的某个固定节点理解它们就是理解回调时机的地基。OnStart的触发时机实际上很巧妙。它不是在创建补间的那一刻触发而是在补间真正开始播放的第一个更新帧触发。这是什么意思比如你写了一段代码transform.DOMove(new Vector3(0, 3, 0), 1f) .OnStart(() { Debug.Log(OnStart); }) .OnUpdate(() { Debug.Log(OnUpdate); }) .OnComplete(() { Debug.Log(OnComplete); });这段代码在运行到的时候OnStart不会立刻打印。真正打印发生在下一帧DOTween开始推进这个补间时。如果你的补间前面设置了SetDelay(2f)那OnStart要等到延迟结束、补间真正开始动的那一刻才会触发。这一点和大家的第一印象相反很多人以为创建Tween的瞬间就会触发OnStart实际上并不是。OnUpdate是每一帧补间更新时触发频率和DOTween的更新模式相关。默认情况下它跟随游戏的主循环每帧更新一次所以OnUpdate也会每帧执行一次。调试时很容易用OnUpdate打印当前进度比如把补间的position值打印出来。但要注意如果补间被Pause暂停OnUpdate不会继续触发因为暂停状态下DOTween不会对这个补间做任何推进。OnComplete的含义相对明确它表示整个补间已经跑到了终点全部播放完毕。这个全部播放完毕有一个边界条件如果你设置了SetLoops(3)那么OnComplete不会在第1个循环结束的时候触发而是等3个循环全部跑完最后一次循环结束才触发。这一点在循环动画中特别容易踩到下面会重点展开。1.2 小众但好用的回调OnKill、OnStepComplete、OnPause除了老三样DOTween还提供了几个容易被忽略的回调OnKill、OnStepComplete、OnPause、OnPlay、OnRewind、OnWaypointChange。它们不是每次都用但用上的时候基本都是在处理特殊时序。OnKill表示补间被销毁时触发。这里的关键点在于无论这个补间是自然播放完被自动Kill还是你在中途手动调用tween.Kill()都会触发OnKill。很多人的误区是认为只有手动Kill才触发实际上DOTween默认autoKill为true补间播放完毕会立刻把自己销毁此时OnKill也会被触发。所以如果你在OnComplete里写了一段逻辑又在OnKill里写了一段逻辑实际运行时会发现OnComplete和OnKill几乎是连着触发的。OnStepComplete是循环相关的回调。当你的补间设置了SetLoops每完成一次循环都会触发一次OnStepComplete。比如循环3次OnStepComplete会触发3次。而OnComplete只在最后一次循环结束触发。两者在最后一次循环时的触发顺序是OnStepComplete先触发紧接着OnComplete触发。这个顺序我实测过很多次稳定可靠。OnPause和OnPlay是反面的一对。OnPlay在补间从暂停或初始状态被播放时触发OnPause在补间被暂停时触发。这里有一个有意思的地方如果你在补间还没开始播放时调用PauseOnPause不一定会触发因为补间本来就不在播放状态。所以涉及Pause相关的回调时建议先用IsActive()和IsPlaying()判断状态再下结论。1.3 回调的添加与叠加规则DOTween的回调并不限制只能添加一个你可以用追加的方式挂多个回调。比如OnComplete(() A()).OnComplete(() B())只要链式调用时都写了OnComplete两个lambda都会被保留并在补间完成时依次触发。这个行为在很多初学者看来像覆盖但实际上DOTween内部维护了一个回调队列追加的所有方法都会执行。不过我建议不要过度堆叠回调。如果你在同一个补间上挂四五个OnComplete代码的可读性会急速下降调试时也难判断某个回调是从哪里挂上去的。我自己的习惯是一个补间最多挂一个OnComplete如果需要串联两个阶段就在这个OnComplete里再去启动下一个补间或者直接用Sequence组织多个子补间让回调职责清晰一点。2. 回调触发时机的基础逻辑2.1 补间更新循环与触发节点DOTween之所以能统一回调的触发顺序是因为它内部有一个统一的更新循环。每次更新时DOTween会检查所有活跃的补间推进它们的进度然后根据推进的状态发出对应的回调。这个循环我们可以简单理解成一个流水线补间对象被创建后进入活跃列表每一帧DOTween遍历这个列表对每个补间做三步处理。第一步判断是否处于Play状态只有处于Play状态才推进进度第二步在进度从0变为大于0的瞬间触发OnStart如果之前没触发过第三步推进进度并触发OnUpdate到达某个节点时再触发OnStepComplete或OnComplete。这几步的顺序在DOTween内部是固定的所以回调之间不会出现随机先后。实际上我们调试时可以不用看源码用一个日志序列就能验证触发顺序。比如在补间上挂上OnStart、OnUpdate、OnStepComplete、OnComplete四个回调然后跑一个SetLoops(2)的补间日志会输出OnStart一次OnUpdate每一帧一次第一次循环结束时OnStepComplete一次第二次循环结束时OnStepComplete一次后紧跟OnComplete。这个顺序看清楚之后很多混乱就迎刃而解了。2.2 延迟、循环次数对回调的影响SetDelay是最容易改变回调时机的参数。延迟意味着补间虽然创建了但在延迟期内完全不推进。这个阶段OnStart不会触发OnUpdate也不会触发。注意不是触发值为0的更新而是压根不触发。这个区别很关键如果你用OnUpdate做累计计数延迟期内计数会不动容易误判为卡死。循环次数对回调的影响主要在于OnComplete和OnStepComplete的触发次数。不循环时OnComplete只触发一次OnStepComplete也触发一次两者顺序是OnStepComplete先、OnComplete后。循环N次时OnStepComplete触发N次OnComplete依旧只触发一次。还有一个细节是循环模式Yoyo模式下第1次循环正向、第2次循环反向OnStepComplete同样会在每次折返时触发这个触发时机不是等折返动作完成而是折返点也就是一循环的终点。延迟和循环叠加在一起时逻辑会变成这样先走完延迟再跑循环。假设SetDelay(1f)加SetLoops(2)时间线是第0到1秒没有任何回调第1秒之后补间开始第一次循环第一次循环结束触发OnStepComplete第二次循环结束触发OnStepComplete加OnComplete。多花点时间在纸上画一条时间轴比靠脑子判断可靠得多。3. 外部操作如何改变回调触发节奏3.1 Play、Pause、Rewind对回调的影响在补间播放过程中外部调用Pause()、Play()、Rewind()会立刻改变补间状态并且同步触发对应回调。这里的触发时机是立即的不是在下一帧而是在你调用方法的当帧同步触发。举例Pause()的典型坑是在OnUpdate里根据某个条件调用tween.Pause()Pause会触发OnPause回调。如果你在OnPause回调里又去修改补间的状态就要小心递归调用。我遇到过一次在OnPause里调用Kill结果OnKill在OnPause的调用栈里同步触发日志顺序看起来就是OnUpdate、OnPause、OnKill连续三条。Rewind()的作用是把补间回退到起点它和Play()配合可以重播动画。回调方面Rewind会触发OnRewind并且在回退完成后如果补间是暂停的会触发OnPause。如果回退前补间正在播放Rewind会让补间立刻回到起点但不会自动暂停之后会继续从起点正常播放。这种场景下OnRewind的触发时机是在回退动作完成的那个更新节点而不是调用Rewind的瞬间具体要看补间当前进度是否已经处于0。DOTween的Rewind如果进度已经为0不会触发多余回调。3.2 Complete、Kill、SetAutoKill对回调的影响Complete()是个经常被误用的方法。它表示强制把补间进度拉到终点。调用Complete()时如果补间还没有播放完DOTween会立即推进到终点然后触发OnComplete。这里要注意Complete()不会触发OnStepComplete因为它是直接跳到最后一步不是逐步走完循环。如果补间设置了SetLoops(3)你只跑了1个循环就调用Complete()它会直接跳到最终状态并触发OnComplete但不会把中间两个循环的OnStepComplete补发。Kill()则是销毁补间触发OnKill。补间被Kill后就不能再恢复所有回调都不会再触发。这个时机很好理解但坑在第1小节提到的自动销毁。DOTween默认autoKill为true补间播放完毕会在OnComplete之后立刻执行Kill所以OnComplete和OnKill是紧挨着的。如果你在OnKill里访问这个补间的引用来做清理可能会发现它已经被标记为不可用因此建议在回调里不要依赖补间对象本身持续存活。SetAutoKill(false)则会让补间播放完毕后保留在Active状态不被销毁。此时OnComplete正常触发但OnKill不会触发因为补间还活着。要考虑的是不自动销毁会导致补间对象一直占用内存如果大量创建不销毁的补间内存会持续增长。我一般在需要复用补间或需要反复播放的地方才会用SetAutoKill(false)用完手动Kill。4. 实战场景拆解时间缩放与嵌套补间4.1 时间缩放与回调DOTween默认受Unity的Time.timeScale影响这一点直接关系到回调触发时机。当timeScale为0时DOTween里的补间不会推进OnUpdate、OnComplete都不会触发。这里有一个很多人踩过的坑在游戏暂停界面上用了DOTween做UI动画timeScale0之后动画直接停住回调全都不触发。解决方式是给DOTween设置不受时间缩放影响的更新方式比如SetUpdate(true)配合UpdateType.UnscaledTime这样补间在暂停状态下依然会用真实时间推进回调也会正常触发。反过来把timeScale调大比如做2倍速播放补间进度会按缩放后的时间加速推进OnUpdate触发频率不变但每次推进的进度增量变大。也就是说OnUpdate依然是每帧触发但整体动画更快走完OnComplete会更快到来。这个加速并不会跳帧触发OnUpdate它仍然是逐帧调用只是进度变化量大。但这里有一个特殊的点DOTween在极端的帧率波动时如果一帧内进度跳过了多个循环节点它只会按当前进度触发对应的最终回调不会把中间每个循环的OnStepComplete都补一次。比如一帧时间很长导致二段循环阶段被跳过了可能只触发最后一次的OnStepComplete和OnComplete。4.2 嵌套补间与回调顺序嵌套补间是指一个补间的回调里再创建或启动另一个补间这种场景在实际项目里非常常见比如一个角色攻击动画完成后衔接硬直动画。嵌套补间的回调顺序有一个隐蔽的坑在回调里创建新补间时新补间不会立刻启动它的OnStart而是等到下一帧DOTween更新时才会触发。也就是说父补间的OnComplete之后子补间的回调不会同步触发中间至少隔一帧。如果你的逻辑里对顺序有严格要求比如必须父动画的OnComplete紧接子动画的OnStart用DOTween的Sequence会更合适。Sequence本质上就是把多个补间按顺序排列前一个补间结束时会自动开始下一个而不是隔一帧。不过使用Sequence时回调也会有一些行为变化下面我结合自己踩过的一个坑讲。除了嵌套还有一种常见场景是同一GameObject上多个补间同时播放。此时回调的触发顺序取决于各自补间的进度不会按照创建顺序统一触发。比如一个补间A播放10秒补间B播放1秒B的OnComplete会先于A的OnComplete触发这跟创建先后没有关系。如果有强先后依赖建议用Sequence或在一个回调里管理另一个补间的启动。5. 回调不触发、顺序反了的排查清单5.1 高频问题与原因对照实际项目中回调不触发或者说顺序不对往往不是DOTween坏了而是某个参数改变了回调的触发前提。我把几个高频问题整理成了表格遇到疑似问题可以先对照排查。现象常见原因解决方法OnStart一直没有触发补间被Pause或延迟未结束检查IsPlaying确认补间是否进入播放OnUpdate不触发补间已完成或未开始timeScale为0且未设置UnscaledTime打印IsActive和进度确认补间状态OnComplete不触发SetLoops未循环完Kill中断Pause后未恢复检查循环次数、autoKill状态OnComplete和OnKill顺序混乱自动销毁导致OnKill紧接OnComplete在日志中确认时间戳接受默认顺序OnStepComplete触发多次设置了循环次数这是正常行为按循环次数判断OnKill在Pause时触发调用Pause后又调用Kill确认Kill和Pause的调用顺序这张表覆盖了我遇到过的绝大多数问题。特别是OnComplete不触发很多时候不是DOTween的问题而是补间压根没跑完。如果你的补间是无限循环你SetLoops(-1)那OnComplete永远都不会触发因为补间没有终点。5.2 我的排查流程遇到回调时序不对我一般按固定流程排查。第一步先确认补间对象本身的状态用tween.IsActive()和tween.IsPlaying()打印出来看它到底是不是处于播放中。第二步检查参数尤其是SetLoops、SetDelay、SetAutoKill这几个会改变触发前提的参数。第三步在回调里加标识日志比如OnStart_tick1OnUpdate_tick45这样的累计打印连续跑几帧把日志按顺序列出来。日志会告诉你真正的触发时刻而不是你想当然的触发时刻。如果排查到这一步还找不到原因就把补间的创建代码单独抽到一个临时方法里去掉所有外部调用用最简单的链式结构跑一遍。我记得有一次排查了半天找不到问题后来发现是另一个模块的全局TimeScale被改成0了与补间本身无关。这种跨模块的全局状态往往是最难定位的。6. 进阶技巧用回调做状态机与事件解耦6.1 用回调实现多阶段动画状态机DOTween的回调非常适合实现轻量级动画状态机。以一个模拟项目X的三连击动画为例第一个补间控制攻击位移OnComplete触发第二个攻击补间第二个OnComplete触发第三个每个回调对应的状态清晰且可读。这种写法比在Update里集中判断状态要直观得多。多阶段状态机的核心是把动画的不同阶段用回调串联起来而不是在一个Update循环里用大量if判断。比如做一个面板弹出动画可以拆成三个补间位移进入、缩放回弹、透明度稳定每个补间都用OnComplete驱动下一个阶段。阶段之间还可以通过Sequence组织回调就是状态机里的状态转移信号而DOTween的补间对象就是状态本身。不过用回调做状态机有一个注意事项当状态机被外部中断时比如玩家再次点击面板导致动画重播旧补间必须Kill掉并清理回调否则旧补间的OnComplete可能会在下一次触发时误启动已经被替换的后继补间。我习惯在每次创建新动画前调用Kill同时在OnKill里加一个是否需要重置状态的标记。6.2 回调与对象池、异步流程的配合对象池和DOTween回调也是常见的组合。比如子弹飞行的补间结束后回调里执行回收子弹进池子。这里最怕的是对象已经被池子回收补间却还在运行或者回调被延迟触发。解决方式是在回收前调用Kill彻底断开生命周期。我之前做的一个物体飞行Demo里就处理过这个问题子弹到达目标位置后补间自动触发OnComplete这时再执行回收补间已经被autoKill掉非常干净。异步流程配合上DOTween提供了ToTask()和异步等待方法可以把回调包装成可等待的任务用async/await顺序执行。比如等待一个UI动画完成后再执行加载逻辑代码可以写成await transform.DOMove(new Vector3(0, 0, 0), 1f).AsyncWaitForCompletion();这种写法的好处是把回调嵌套扁平化OnComplete里的逻辑可以直接放在await之后回调只作为底层信号。需要注意AsyncWaitForCompletion受autoKill影响如果补间被自动Kill等待会自动完成这一点我在实际项目里吃过亏建议在需要的场景使用SetAutoKill(false)配合手动Kill。最后再分享一个个人习惯我在用DOTween写动画前一定会先在纸上把回调时序画一条时间轴。DOTween的API已经做得非常简洁了但简洁不等于简单回调触发时机就是它内部最严格的那层逻辑。把OnStart、OnUpdate、OnComplete、OnStepComplete、OnKill这五个关键节点的时间点画清楚后面真正写逻辑的时候几乎不会踩坑。时间轴上的每个标记旁边我会顺手写上会产生哪些外部操作比如此处可能调用Kill这样排查起来一目了然。这个小习惯帮我省下了很多调试时间也推荐你试试。