最近做一款3D动作游戏时,策划一口气丢过来十几张状态图:待机、走路、跑步、跳跃、下落、攻击一二三段、受击、硬直、格挡、闪避、死亡……一开始我本着“先跑起来再优化”的态度,用一堆bool和if-else硬塞。等做到第五个状态时,Update里的条件判断已经乱成一锅粥。那段时间我最怕听到的一句话就是:加一个新的受击状态。每次加状态都要把原来的逻辑重新捋一遍,稍不留神就出现某个状态下漏了恢复参数的bug。
后来我实在受不了,把项目重构了一遍,核心方案就是Unity开发里非常经典的状态模式。这算是我在实战里啃下来的硬骨头,这次就把自己对状态模式的理解、完整实现思路、落地过程中踩过的坑,一次性说清楚。不管你是刚学Unity的新手,还是写了一阵子业务逻辑想整理代码的开发者,这篇文章都能给你一套可以直接抄作业的方案。
1. 为什么角色的状态管理会越写越乱
1.1 从一段真实的“状态灾难”说起
很多Unity项目最初都是小步快跑。角色能动就行,先加了一个isMoving的判断:
csharp复制void Update()
{
if (isMoving)
{
// 播放移动动画
}
else
{
// 播放待机动画
}
}
这个阶段一切安好。接着需求来了:角色要能跳跃。于是加了一个isJumping:
csharp复制void Update()
{
if (isJumping)
{
// 跳跃中逻辑
}
else if (isMoving)
{
// 移动逻辑
}
else
{
// 待机逻辑
}
}
看起来也没毛病。等加了攻击、受击、死亡,问题就来了。最典型的是状态互斥和优先级问题:角色死亡那一帧,如果同时按了攻击键,是播放攻击还是播放死亡?受击和格挡怎么判断先后?跳跃中能不能攻击?这些问题用if-else处理起来,分支会以肉眼可见的速度膨胀。
我当时重构前的代码里,光是Update方法就有两百多行,嵌套了四层if-else。每次策划微调某个技能的时间轴,我都要花半天去追踪哪些条件会影响这个状态。更可怕的是,Animator那边还有一个动画状态机,角色逻辑状态和动画状态经常不同步,动画都播完了,逻辑还卡在上一帧。
1.2 状态模式到底在解决什么问题
状态模式的核心思想可以用一句话讲:把每一种状态对应的行为,封装到一个独立的类里。每个类只管自己的进入条件、持续行为和退出时该做的事。需要切换状态时,不再靠大堆条件判断,而是告诉状态机“现在切到某个状态”,让状态机负责协调旧状态的退出和新状态的进入。
这个思路解决了我遇到的三个痛点:
- 状态互斥:任何时刻只有一个状态处于激活状态,天然不会出现“既在攻击又在死亡”的矛盾情况。
- 可扩展性:新增一个状态,只需要新建一个类,然后在状态机里注册一下。原有的状态类一行都不需要改。
- 逻辑内聚:每个状态的逻辑都收拢在自己的类里,查找问题直接打开对应类就可以,不用再在一大段Update里来回翻。
用生活化的类比来说,状态模式就像给角色准备了一组“工作模式卡片”。你手上有一沓卡片,每张卡片正面写着当前模式下该干什么。任何时候手里只能拿一张卡片。要换模式时,把当前卡片塞回去,再抽出一张新卡片就行。整个过程有明确的“收起旧卡片”和“展开新卡片”两个动作,不会再出现手忙脚乱抓错卡的情况。
1.3 搞清楚:状态模式和状态机不是一回事
这里必须澄清一个常见的混淆点。很多人把“有限状态机”(FSM)和“状态模式”当同一个东西用,其实两者有关联但不完全一样。
- 有限状态机是一个抽象概念,指由有限个状态、事件、转换条件组成的模型。任何满足“有明确状态集合、状态间有明确转换规则”的系统,都可以称为状态机。
- 状态模式是GoF二十三种设计模式之一,它提供了用“多态”来实现状态机的一种具体代码组织方式。状态模式把每个状态做成一个类,触发转换时通过接口调用来切换。
也就是说,状态模式是实现状态机的一种优秀方案,但不是唯一方案。你也可以用枚举加switch实现状态机,还可以用字典加委托实现。后面我会讲不同实现的取舍,先记住这个区别,很多面试题会在这里挖坑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 状态模式核心结构与设计取舍
2.1 三个核心角色
标准的GoF状态模式包含三个核心角色。
1. 状态接口(State)
定义状态必须实现的行为。常见的有进入、更新、退出:
csharp复制public interface IState
{
void Enter();
void Update();
void FixedUpdate();
void LateUpdate();
void Exit();
}
2. 具体状态类(ConcreteState)
继承状态接口,实现具体逻辑。例如IdleState管待机,JumpState管跳跃,每个类只关心自己的逻辑。
3. 上下文(Context)
维护当前状态的实例,对外暴露切换状态的方法。Unity里一般把这个角色挂在一个MonoBehaviour上,或者单独写一个状态机类,让MonoBehaviour持有它。
理解了这三个角色,实现思路就顺了。
2.2 状态流转的两种设计方式
写状态机时,最需要想清楚的是:状态切换的触发逻辑放在哪里。这直接决定了代码风格和后期维护方式。
第一种:状态内部判断切换
每个状态在自己的Update里判断是否满足退出条件,并主动调用状态机的切换方法。比如IdleState里检测到玩家按了移动键,就调用ChangeState(StateType.Run)。
这种方式的优点是内聚性强,状态的离开条件通常和该状态自己最相关,写在一起逻辑清晰。缺点是状态之间会形成间接依赖,IdleState需要知道RunState的存在。
第二种:上下文统一判断切换
上下文(状态机)里集中管理切换条件,通过外部输入来决定何时切换状态。比如在PlayerController.Update中读取输入,然后调ChangeState。
这种方式把状态的依赖降到最低,但管理器会变得臃肿,状态多了以后条件判断还是会堆积。
我实际开发中比较推荐的做法是混合式:已经在某个状态里、且只涉及该状态自身条件的,放在状态内部处理;跨状态、涉及外部输入的,放在玩家控制器或状态机入口处处理。比如待机切跑步,这是待机状态内部的判断逻辑,就写在IdleState里。而受击打断攻击,这种优先级较高的切换,由状态机统一处理。这样既保证了内聚性,又不会让某个状态类越权。
2.3 几种常见的简化变体
标准的状态模式其实写起来偏重,尤其在原型阶段,每个状态都要建一个类会显得很啰嗦。所以实际开发中会出现一些简化变体。
- 枚举+switch方式:一个类里用switch分发状态逻辑。优点是代码量少,适合原型或状态少的场景;缺点是状态多了之后这个类会变成超级类,违反开闭原则。
- 字典+委托方式:把状态映射到方法或委托上。
csharp复制Dictionary<StateType, Action> stateActions = new Dictionary<StateType, Action>();
优点是灵活,但状态内部数据不好保存,复杂状态用起来别扭。
- 完整状态模式:即本文要重点讲的,每个状态一个类。适合状态多、状态间转换规则复杂的项目。
我自己判断是否用完整状态模式的标准很简单:如果状态超过5个,或者状态之间有明显的优先级抢占、打断关系,就直接上完整状态模式。 状态少的项目用switch就够了,没必要为了用模式而用模式。
3. 实操落地:在Unity里实现一套可复用的状态机
3.1 场景需求与状态划分
为了讲得具体,我拿一套常见的角色控制需求来举例。假设开发一个第三人称动作角色,需要以下状态:
- Idle:待机,无输入时进入
- Run:地面移动
- Jump:跳跃上升
- Fall:空中下落
- Attack:攻击动作
- Hit:受击硬直
- Dead:死亡
这里有几个关键状态转换规则:
- Idle到Run:检测到水平方向输入且在地面
- Run到Idle:水平输入归零且在地面
- 地面上按下跳跃键:Idle或Run切到Jump
- Jump到Fall:垂直速度小于0
- Fall到Idle:落地
- 攻击时受到敌人攻击:Attack被Hit打断(优先级关系)
- 任意状态血量为0:切换到Dead
有了需求,就可以设计代码结构了。
3.2 定义状态基类与状态机管理类
我建议定义一个抽象基类StateBase而不是直接用接口。原因是角色状态通常需要访问玩家控制器的共享数据(比如移动速度、动画组件、刚体),基类构造函数接收这些引用,能省去在接口实现里反复写引用的麻烦。
csharp复制public enum PlayerStateType
{
Idle,
Run,
Jump,
Fall,
Attack,
Hit,
Dead
}
public abstract class StateBase
{
protected PlayerController player;
protected StateMachine stateMachine;
public StateBase(PlayerController player, StateMachine stateMachine)
{
this.player = player;
this.stateMachine = stateMachine;
}
public virtual void Enter() { }
public virtual void Update() { }
public virtual void FixedUpdate() { }
public virtual void LateUpdate() { }
public virtual void Exit() { }
}
然后是实现状态机管理类。这个类负责维护当前状态、注册所有状态、执行切换逻辑:
csharp复制using System.Collections.Generic;
public class StateMachine
{
private Dictionary<PlayerStateType, StateBase> states = new Dictionary<PlayerStateType, StateBase>();
private StateBase currentState;
public StateBase CurrentState => currentState;
public PlayerStateType CurrentStateType => currentState.CurrentType;
public void AddState(PlayerStateType type, StateBase state)
{
states[type] = state;
}
public void Initialize(PlayerStateType startStateType)
{
foreach (var state in states.Values)
{
state.Init();
}
SwitchState(startStateType);
}
public void SwitchState(PlayerStateType newStateType)
{
if (!states.ContainsKey(newStateType))
{
Debug.LogError($"状态机中不存在状态: {newStateType}");
return;
}
currentState?.Exit();
currentState = states[newStateType];
currentState.Enter();
}
public void Update()
{
currentState?.Update();
}
public void FixedUpdate()
{
currentState?.FixedUpdate();
}
public void LateUpdate()
{
currentState?.LateUpdate();
}
}
这里有几个细节值得注意:
SwitchState先调用旧状态的Exit,再设置新状态并调用Enter。这个顺序是严格有讲究的:必须先清场再开新场。如果把顺序反过来,可能会出现新状态已经Enter了,旧状态还在Update残留逻辑。- 状态切换流程是通用封装,后续加状态只需要写新的状态类并注册即可,状态机管理类本身几乎不用改。
- 状态机类本身不是
MonoBehaviour,这样可以用new直接创建,不依赖场景中的GameObject,方便测试。
3.3 实现具体状态
以IdleState和RunState为例,看一下具体状态类的写法。
csharp复制public class IdleState : StateBase
{
public IdleState(PlayerController player, StateMachine stateMachine)
: base(player, stateMachine) { }
public override void Enter()
{
player.Animator.Play("Idle");
}
public override void Update()
{
if (!player.IsGrounded)
{
stateMachine.SwitchState(PlayerStateType.Fall);
return;
}
if (player.InputMoveDirection != Vector3.zero)
{
stateMachine.SwitchState(PlayerStateType.Run);
return;
}
if (player.IsJumpPressed)
{
stateMachine.SwitchState(PlayerStateType.Jump);
return;
}
}
public override void Exit()
{
// 待机状态退出时一般不需要额外清理
}
}
RunState类似,只不过多了根据输入方向旋转角色的逻辑,同时还要判断输入归零时切回Idle。
csharp复制public class RunState : StateBase
{
public RunState(PlayerController player, StateMachine stateMachine)
: base(player, stateMachine) { }
public override void Enter()
{
player.Animator.Play("Run");
}
public override void Update()
{
if (!player.IsGrounded)
{
stateMachine.SwitchState(PlayerStateType.Fall);
return;
}
if (player.InputMoveDirection == Vector3.zero)
{
stateMachine.SwitchState(PlayerStateType.Idle);
return;
}
player.UpdateRotationToMoveDirection();
}
public override void FixedUpdate()
{
player.ApplyMovement();
}
}
从上面代码可以看到,每个状态类只关心自己的逻辑。IdleState不需要知道RunState内部是怎么移动的,它只需要在条件成立时通知状态机切换。这种解耦带来的直接收益是:我自己在调试时,打开对应状态类就能定位问题,不用再在一大段方法里翻来翻去。
3.4 在MonoBehaviour中接入
有了状态机和具体状态类,接下来要在Unity的MonoBehaviour里把它们接起来。一般做法是在PlayerController中创建状态机、注册所有状态、每帧驱动状态机。
csharp复制using UnityEngine;
public class PlayerController : MonoBehaviour
{
[Header("Components")]
public Animator Animator;
public new Rigidbody Rigidbody;
[Header("Settings")]
public float moveSpeed = 5f;
public float rotationSpeed = 720f;
public float jumpForce = 8f;
public float gravity = -20f;
[Header("GroundCheck")]
public Transform groundCheckPoint;
public float groundCheckRadius = 0.2f;
public LayerMask groundLayer;
[Header("Debug")]
public string currentStateName;
private StateMachine stateMachine;
private Vector3 inputMoveDirection;
private bool isJumpPressed;
private bool isGrounded;
public Vector3 InputMoveDirection => inputMoveDirection;
public bool IsJumpPressed => isJumpPressed;
public bool IsGrounded => isGrounded;
void Start()
{
stateMachine = new StateMachine();
stateMachine.AddState(PlayerStateType.Idle, new IdleState(this, stateMachine));
stateMachine.AddState(PlayerStateType.Run, new RunState(this, stateMachine));
stateMachine.AddState(PlayerStateType.Jump, new JumpState(this, stateMachine));
stateMachine.AddState(PlayerStateType.Fall, new FallState(this, stateMachine));
stateMachine.AddState(PlayerStateType.Attack, new AttackState(this, stateMachine));
stateMachine.AddState(PlayerStateType.Hit, new HitState(this, stateMachine));
stateMachine.AddState(PlayerStateType.Dead, new DeadState(this, stateMachine));
stateMachine.Initialize(PlayerStateType.Idle);
}
void Update()
{
ReadInput();
stateMachine.Update();
UpdateGroundedState();
currentStateName = stateMachine.CurrentStateType.ToString();
}
void FixedUpdate()
{
stateMachine.FixedUpdate();
ApplyGravity();
}
void LateUpdate()
{
stateMachine.LateUpdate();
}
private void ReadInput()
{
float horizontal = Input.GetAxisRaw("Horizontal");
float vertical = Input.GetAxisRaw("Vertical");
inputMoveDirection = new Vector3(horizontal, 0, vertical).normalized;
if (Input.GetButtonDown("Jump"))
isJumpPressed = true;
else
isJumpPressed = false;
}
private void UpdateGroundedState()
{
isGrounded = Physics.CheckSphere(groundCheckPoint.position, groundCheckRadius, groundLayer);
}
private void ApplyGravity()
{
Rigidbody.velocity += Vector3.up * gravity * Time.fixedDeltaTime;
}
public void ApplyMovement()
{
Vector3 velocity = inputMoveDirection * moveSpeed;
Rigidbody.velocity = new Vector3(velocity.x, Rigidbody.velocity.y, velocity.z);
}
public void UpdateRotationToMoveDirection()
{
if (inputMoveDirection == Vector3.zero) return;
Quaternion targetRotation = Quaternion.LookRotation(inputMoveDirection);
transform.rotation = Quaternion.RotateTowards(
transform.rotation, targetRotation, rotationSpeed * Time.deltaTime);
}
}
这段代码的思路是:PlayerController只负责读取外部输入、驱动状态机、暴露共享数据。真正的游戏逻辑分散在各个状态类中。PlayerController里不再有繁琐的状态判断代码,而是变成一个“数据提供者+状态机驱动器”。
3.5 关键细节:状态切换与动画衔接
做了这么多次状态机,我必须要说:逻辑状态机写好了,动画衔接又是另一个坑。
Unity的Animator是一个可视化状态机,它也有自己的状态和转换条件。直接Animator.Play("Idle")在多数简单的场景下能跑,但如果你在逻辑状态机里切换了状态,而动画状态的转换需要时间(例如从Run切到Idle,如果动画混合时间较长,角色会表现得很突兀),就会看起来不自然。
我在一个动作游戏中遇到过一个具体问题:角色从Run切到Idle时,因为Animator.Play会强制从目标动画的第一帧开始播放,导致角色明明还在移动减速,动画却已经瞬间到了待机第一帧,看起来像被按了暂停键一样。
后来我的处理方式是:如果项目使用Animator的方向混合树,就尽量让逻辑状态机的切换动作和Animator的触发器对应,而不是每个状态都直接Animator.Play。更简单的做法是,把动画状态机的参数暴露出来,逻辑状态机只负责设置参数,由动画状态机自己处理过渡:
csharp复制public override void Enter()
{
player.Animator.SetBool("IsRunning", true);
}
这样动画状态的过渡交给Animator的过渡时间处理,逻辑状态机的切换和动画状态机的切换就不打架了。这个取舍要视项目而定,如果你的角色状态和动画状态是一一对应的简单情况,直接Animator.Play最省事。
4. 实战中踩过的坑与排查技巧
4.1 同一帧里换两次状态
这是一个非常隐蔽的问题。假设角色在受到攻击的瞬间,同时按下了攻击键,状态机可能先切换到Hit,紧接着在同一个Update里又切换到Attack,导致Hit状态几乎没有生效,角色受击表现完全丢失。
我排查这个问题时,找到的根源在于:状态机本身没有“锁定”机制。某些高优先级状态(比如Hit、Dead)一旦进入,就应该屏蔽掉其他低优先级状态的切换请求,至少在当前状态保持一帧以上。
解决办法是在状态机里增加优先级控制,或者在高优先级状态里设置一个时间锁:
csharp复制public class HitState : StateBase
{
private float hitDuration = 0.3f;
private float elapsedTime;
public override void Enter()
{
player.Animator.Play("Hit");
elapsedTime = 0f;
player.IsControllable = false;
}
public override void Update()
{
elapsedTime += Time.deltaTime;
if (elapsedTime >= hitDuration)
{
player.IsControllable = true;
stateMachine.SwitchState(PlayerStateType.Idle);
}
}
}
然后其他状态在Update里判断是否可以切换时,先看看玩家是否可控制,不可控就忽略输入。这相当于给状态切换加了一道“闸门”。
4.2 状态对象复用导致数据残留
状态类通过构造函数接收玩家和状态机引用,它们在整个生命周期里只创建一次,之后不断被复用。如果在Exit里没有清理状态内部的数据,下次进入这个状态时,可能带着上一次的残留数据。
举个例子,AttackState里我在Enter时播放攻击动画,并记录攻击伤害数值。如果Exit时没有把攻击目标列表清空,下次进入攻击状态时,就可能出现“攻击还没命中就已经有目标列表”的bug。
我的习惯是:在Exit里统一清理状态自己创建的所有临时数据,在Enter里重新初始化所有字段。这是一个需要刻意遵守的纪律,多态复用带来的性能优势要建立在数据清理正确的基础上。
4.3 外部代码对状态机的误操作
状态机暴露了SwitchState方法,外部组件可以随时调用。这看起来方便,但也带来了隐患:某个AI组件可能在角色还没进入可切换状态时就强行切到攻击状态,打破游戏节奏。
我后来在项目里加了状态切换的合法性校验。每个状态定义一个“允许切换进入的状态集合”,SwitchState里先判断当前状态是否允许切换到目标状态,不允许就忽略并在日志中警告:
csharp复制public bool CanSwitchTo(PlayerStateType targetStateType)
{
return allowedSwitchToStates.Contains(targetStateType);
}
这不是必须的,但对有大量状态、且切换规则明确的动作游戏非常有用。它能把状态转换规则收敛到代码里,而不是散落在各个状态类的条件判断中。
4.4 调试与可视化技巧
状态机最大的痛点是运行时看不到当前状态。我以前遇到过一个问题:角色卡在某个状态出不来,只能靠打日志推断。后来学乖了,加了几个可视化手段。
- 在Inspector中显示当前状态名:在主控类里加一个公共字段,每次状态切换时刷新它,这样运行时可以直接在Inspector里看到角色当前状态。
csharp复制public string currentStateName;
void Update()
{
currentStateName = stateMachine.CurrentStateType.ToString();
}
-
状态切换日志:在
StateMachine.SwitchState里加一个可选日志,调试时打开,发布时关闭。 -
在场景中绘制状态文本:用
OnGUI或TextMeshPro在角色头顶显示当前状态,方便观察动画表现和逻辑状态的对应关系。
这些调试手段看起来简单,但在实际排查问题时效率提升巨大。我曾经排查一个“无法退出攻击状态”的bug,打开状态可视化后立刻发现角色根本不是卡在Attack状态,而是卡在Fall状态,攻击动画只是Fall状态的残留表现,误导了我半天。
5. 状态模式的进阶玩法与扩展思路
5.1 分层状态机处理复合状态
动作游戏里经常出现这样的需求:角色在空中时可以攻击,攻击动作结束后继续下落。这时候单纯的“一个时间只处于一个状态”就不够用了,因为角色其实同时处于“空中”和“攻击”两个维度。
我采用的方案是分层状态机(Hierarchical State Machine),把状态分成运动层和动作层。运动层管Idle、Run、Fall,动作层管Attack、Skill、Hit。两层状态机相互独立,运动层决定角色怎么移动,动作层决定角色当前在做什么。
这种设计在复杂动作游戏里非常实用,接近真实项目的状态管理方式。Unity的Animator内部其实就是这种思路,它有基础层和叠加层,分别负责运动动画和面部/手部动画。
5.2 堆栈状态机处理暂停恢复
另一个有用的变体是堆栈状态机(Pushdown Automaton)。它的核心是维护一个状态栈,切换状态时不是直接替换,而是把当前状态压入栈,新状态入栈成为当前状态。当新状态退出时,从栈中弹出旧状态继续执行。
典型的应用场景是游戏内的“暂停菜单”。角色在奔跑(Run状态),突然打开背包,此时应该暂停角色控制逻辑,弹出一个UI页面。等关闭背包时,角色应该回到之前的状态继续跑,而不是回到默认的Idle。
堆栈状态机实现不算复杂,主要是把状态机里的“当前状态”换成“状态栈”:
csharp复制public class PushdownStateMachine
{
private Stack<StateBase> stateStack = new Stack<StateBase>();
public void PushState(StateBase newState)
{
stateStack.Peek()?.Exit();
stateStack.Push(newState);
newState.Enter();
}
public void PopState()
{
if (stateStack.Count <= 1) return;
stateStack.Peek()?.Exit();
stateStack.Pop();
stateStack.Peek()?.Enter();
}
}
5.3 用ScriptableObject配置状态参数
如果状态数量多,且每个状态有大量可调参数(动画名称、持续时间、移动速度倍率、伤害数值),把这些参数硬编码在状态类里会非常痛苦。策划每次调参都要找开发改代码。
我现在更倾向用ScriptableObject把状态参数做成可配置资源。每个状态类对应一个配置实例:
csharp复制[CreateAssetMenu(menuName = "PlayerState/AttackConfig")]
public class AttackConfig : ScriptableObject
{
public string animationName;
public float duration;
public float damageMultiplier;
public float moveSpeedMultiplier;
}
状态类在Enter时读取配置来初始化数据,策划在编辑器里复制一份配置就能调出不同手感,不需要重新编译代码。这是我个人强烈推荐的生产环境方案,能显著降低和策划的沟通成本。
5.4 什么时候不该用状态模式
最后也必须说清楚:状态模式不是万能的,过度设计同样是坑。
- 状态特别少(两三个)时,直接if-else更简单直观。
- 状态间转换非常频繁、且没有明确边界(例如一次动画循环里播放多个动作)时,状态模式的类数量会失控。
- 状态主要依赖连续数值变化(例如血量从100降到0)时,状态模式只适合描述“血量归零”这个离散事件,不适合描述“血量下降”这个连续过程。
我自己的原则是:先让项目跑起来,当出现状态管理混乱的信号(比如反复加if-else、状态互斥难控制、别人看不懂你的条件判断)时,再引入状态模式重构。 设计模式的意义是解决问题,不是炫技。一上来就套模式,很多时候反而会让简单项目变得笨重。
另外,如果项目里已经有成熟的可视化状态机插件,比如Animator本身就能覆盖大部分逻辑,不一定非要自己写代码状态机。我这套状态模式更推荐用在“逻辑复杂、动画系统无法覆盖全部规则”的场景,比如技能系统、AI行为状态、UI页面流转等。
最后再分享一个很实用的小技巧。如果你用状态模式做AI,别把“感知”和“行为”都塞进同一个状态类。可以把感知逻辑放在状态机外面的一个独立组件里,负责更新“目标可见、距离、血量”等环境信息,状态类只根据这些信息做决策。这样状态类的关注点更单一,AI的行为树和状态机也能天然解耦。我在项目里验证过,这种分层让AI扩展新状态时,几乎不需要改动已有状态。
状态模式本身不难,难的是在合适的场景用对、在复杂需求下做恰当的变体。希望这篇实战总结能帮你少踩一些我踩过的坑。有什么实现细节上的问题,欢迎多交流。
