Unity AI开发实战:GoAP目标导向行为规划原理与实现
1. 项目概述为什么GoAP是Unity AI开发的“瑞士军刀”在Unity里做AI你是不是也经历过这样的循环写一堆if-else判断角色状态代码越堆越乱最后变成一坨难以维护的“面条代码”或者用行为树Behavior Tree感觉节点拖来拖去逻辑复杂了之后连线都看不清调试起来更是头疼如果你正在为角色行为逻辑的可维护性和可扩展性发愁那今天聊的GoAPGoal-Oriented Action Planning目标导向行为规划很可能就是你的解药。这不是什么高深莫测的学术概念而是一种让AI角色自己“动脑子”决定怎么做事的实用架构。简单说你不再需要手把手告诉AI“先A再B然后C”而是告诉它“你的目标是拿到宝藏”并给它一套“可以走路、可以开门、可以打败守卫”的行动库AI自己就会规划出一条“走到门前-开门-打败守卫-拿到宝藏”的行动序列。为什么在Unity的众多AI方案里我尤其推荐你试试GoAP因为它完美解决了行为树和状态机的两大痛点僵化和臃肿。行为树需要你预先设计好所有分支一旦需求变更比如新增一个“贿赂守卫”的选项你可能要重构整棵树。而GoAP是动态规划的AI在运行时根据当前世界状态World State和可用行动Actions实时计算出一条最优路径来达成目标Goal。这意味着你的AI会更加灵活和智能能应对你未曾预料到的场景组合。从《F.E.A.R.》里的经典敌人到许多独立游戏中的NPCGoAP都证明了其价值。对于Unity开发者无论是制作需要复杂决策的RPG同伴、具有策略性的RTS单位还是行为丰富的模拟市民掌握GoAP都能让你从繁琐的状态管理中解放出来专注于设计更有趣的行为本身。2. GoAP核心原理让AI学会“思考”而非“执行”要理解GoAP我们可以把它想象成一个AI版的“路径规划”问题。不过它规划的不是空间中的路线而是逻辑状态空间中的路线。其核心是三个概念世界状态World State、目标Goal和行动Action。世界状态就是游戏世界在某一时刻所有相关事实的集合通常用一个键值对字典Dictionarystring, object表示。比如{ “hasWeapon”: true, “enemyVisible”: false, “health”: 75, “distanceToTarget”: 15.2f }。这些是AI做决策的“已知条件”。目标则是一个具体的、AI想要达成的世界状态。它也是一个世界状态的子集描述了“理想情况”。例如一个“攻击敌人”的目标可能是{ “enemyIsDefeated”: true }。一个“治疗自己”的目标可能是{ “health”: 100 }假设满血是100。行动是AI可以执行的基本单元。每个行动都有前提条件Preconditions执行这个行动前世界必须满足的状态。比如“开枪”行动的前提可能是{ “hasWeapon”: true, “enemyVisible”: true, “ammoCount” 0 }。执行效果Effects执行这个行动后会对世界状态产生的影响。比如“开枪”的效果可能是{ “enemyHealth”: -20, “ammoCount”: -1 }让敌人生命减20弹药减1。成本Cost执行这个行动所需的“代价”可以简单理解为时间、体力或魔法值。规划器会寻找总成本最低的行动序列。GoAP规划器Planner的工作流程就是一个反向搜索的过程从目标出发规划器拿到一个目标例如{ “enemyIsDefeated”: true }。寻找匹配行动在当前的世界状态中寻找那些效果能部分满足目标的行动。比如找到一个“致命攻击”行动其效果是{ “enemyIsDefeated”: true }。检查前提条件查看“致命攻击”的前提条件比如{ “enemyIsStunned”: true, “isInMeleeRange”: true }。如果当前世界状态不满足这些前提就把这些前提当作新的、需要达成的“子目标”。递归搜索继续为这些子目标寻找能达成它们的行动并检查新行动的前提……如此递归直到找到的所有行动其前提条件都能被当前世界状态满足。生成序列将这个过程找到的行动链反向就得到了从当前状态到目标状态的行动序列。规划器通常会使用A*等搜索算法在众多可能的行动序列中找到总成本最低的一条。注意这里有一个关键理解点。GoAP是离线规划Offline Planning它在每个决策周期比如每秒计算一次计划然后让AI执行这个计划序列直到计划完成或被打断比如世界状态发生剧变。它不适合需要每帧做出瞬时反应如格斗游戏的闪避的场景后者更适合行为树或状态机。2.1 与行为树、状态机的本质区别为了更直观我们用一个表格对比一下特性有限状态机 (FSM)行为树 (Behavior Tree)目标导向行为规划 (GoAP)核心思想定义状态和转移条件定义任务节点和执行顺序定义行动、前提和效果动态生成计划决策方式基于当前状态和输入事件从根节点开始按节点逻辑序列、选择、并行等遍历基于当前世界状态和目标使用搜索算法如A*规划灵活性低。状态和转移需预先定义新增逻辑可能需重构整个状态机。中。树结构清晰但复杂后难以维护变更可能影响多个分支。高。只需增加新的行动或修改现有行动的代价/效果AI能自动组合出新行为。可维护性复杂后极差“面条代码”。尚可但树形结构庞大后连线复杂调试困难。优秀。行动是独立的、可复用的模块数据驱动易于调试可打印规划路径。适用场景简单、确定性的行为如开门动画、巡逻状态。复杂的、层次化的行为逻辑如BOSS战多阶段。目标驱动、需要智能组合基础动作的复杂行为如《模拟人生》中市民的日常、RTS单位的资源采集链。性能开销极低低到中取决于树深度和节点复杂度中到高搜索算法的开销行动和状态越多越耗时。实操心得不要试图用GoAP完全取代行为树或状态机。一个更佳的架构是混合使用。例如用行为树或状态机管理AI的高层状态如“巡逻”、“战斗”、“逃跑”而在“战斗”状态下使用一个GoAP规划器来动态决定具体的战术动作是“寻找掩体”还是“投掷手雷”。这样既能利用行为树的响应速度又能获得GoAP的规划智能。3. 在Unity中从零搭建GoAP系统理论懂了手会了吗接下来我们一步步在Unity里实现一个完整的GoAP系统。我们将创建一个简单的场景一个AI守卫它的目标是“保护宝藏”。它拥有“巡逻”、“发现玩家”、“追击”、“攻击”等行为。3.1 定义核心数据模型首先我们需要定义最基础的类WorldState、GoapAction和GoapGoal。// WorldState.cs using System.Collections.Generic; public class WorldState { public Dictionarystring, object states new Dictionarystring, object(); public bool HasState(string key) { return states.ContainsKey(key); } public void SetState(string key, object value) { states[key] value; } public object GetState(string key) { if (states.ContainsKey(key)) return states[key]; return null; } public bool CompareState(Dictionarystring, object target) { foreach (var kvp in target) { if (!states.ContainsKey(kvp.Key)) return false; if (!states[kvp.Key].Equals(kvp.Value)) return false; } return true; } public WorldState Clone() { WorldState newState new WorldState(); foreach (var kvp in states) { newState.states.Add(kvp.Key, kvp.Value); } return newState; } }// IGoapAction.cs (接口) public interface IGoapAction { // 行动的唯一标识符 string Name { get; } // 执行此行动的成本 float Cost { get; } // 行动的目标由哪个GoapAgent持有 GameObject Target { get; set; } // 前提条件 Dictionarystring, object Preconditions { get; } // 执行效果 Dictionarystring, object Effects { get; } // 检查前提条件在当前世界状态下是否满足 bool CheckProceduralPrecondition(GoapAgent agent); // 行动是否已完成 bool IsDone { get; } // 每帧执行如果行动需要时间 void Perform(GoapAgent agent); // 行动开始前调用 void OnActionStart(GoapAgent agent); // 行动结束后调用无论成功失败 void OnActionEnd(GoapAgent agent, bool success); }// IGoapGoal.cs (接口) public interface IGoapGoal { // 目标的优先级数值越大越优先 int Priority { get; } // 目标所期望的世界状态 Dictionarystring, object DesiredState { get; } // 检查此目标在当前世界状态下是否仍然有效/可追求 bool IsValid(GoapAgent agent); // 计算目标对于当前状态的紧迫性可用于动态调整优先级 float CalculatePriority(GoapAgent agent); }3.2 实现A*规划器Planner这是GoAP的大脑。我们将实现一个基于A*算法的规划器。// GoapPlanner.cs using System.Collections.Generic; using System.Linq; using UnityEngine; public class GoapPlanner { public QueueIGoapAction Plan(GameObject agent, HashSetIGoapAction availableActions, WorldState worldState, IGoapGoal goal) { // 重置所有行动的状态 foreach (var action in availableActions) { action.Reset(); // 假设IAction有一个Reset方法清理临时状态 } // 检查是否有行动能直接满足目标 var usableActions new HashSetIGoapAction(); foreach (var action in availableActions) { if (action.IsAchievableGiven(worldState)) // 检查行动的前提是否可被满足 { usableActions.Add(action); } } // 构建搜索节点 ListNode leaves new ListNode(); Node start new Node(null, 0, worldState, null); bool success BuildGraph(start, leaves, usableActions, goal.DesiredState); if (!success) { Debug.LogWarning($[GoapPlanner] No plan found for goal: {goal.GetType().Name}); return null; } // 找到代价最小的叶子节点即达成目标的节点 Node cheapest null; foreach (Node leaf in leaves) { if (cheapest null || leaf.runningCost cheapest.runningCost) cheapest leaf; } // 从叶子节点回溯到根节点构建行动序列 ListIGoapAction result new ListIGoapAction(); Node n cheapest; while (n ! null) { if (n.action ! null) { result.Insert(0, n.action); // 在头部插入因为我们是反向回溯 } n n.parent; } // 将List转换为Queue QueueIGoapAction queue new QueueIGoapAction(); foreach (IGoapAction action in result) { queue.Enqueue(action); } return queue; } private bool BuildGraph(Node parent, ListNode leaves, HashSetIGoapAction usableActions, Dictionarystring, object goal) { bool foundOne false; // 遍历所有可用行动看哪些行动的前提能被当前节点状态满足 foreach (var action in usableActions) { if (StateContainsState(parent.state, action.Preconditions)) { // 应用行动的效果得到新的世界状态 WorldState currentState parent.state.Clone(); currentState ApplyActionEffects(currentState, action.Effects); Node node new Node(parent, parent.runningCost action.Cost, currentState, action); // 检查新状态是否满足目标 if (StateContainsState(currentState, goal)) { leaves.Add(node); foundOne true; } else { // 未满足目标则继续搜索从剩余行动中移除当前行动防止循环 HashSetIGoapAction subset ActionSubset(usableActions, action); bool found BuildGraph(node, leaves, subset, goal); if (found) foundOne true; } } } return foundOne; } // 判断状态state是否包含子状态substate private bool StateContainsState(WorldState state, Dictionarystring, object substate) { foreach (var kvp in substate) { if (!state.HasState(kvp.Key)) return false; if (!state.GetState(kvp.Key).Equals(kvp.Value)) return false; } return true; } // 将行动效果应用到世界状态 private WorldState ApplyActionEffects(WorldState state, Dictionarystring, object effects) { WorldState newState state.Clone(); foreach (var effect in effects) { newState.SetState(effect.Key, effect.Value); } return newState; } // 获取可用行动的子集移除已使用的行动 private HashSetIGoapAction ActionSubset(HashSetIGoapAction actions, IGoapAction removeMe) { HashSetIGoapAction subset new HashSetIGoapAction(); foreach (IGoapAction a in actions) { if (!a.Equals(removeMe)) subset.Add(a); } return subset; } // A*搜索中的节点 private class Node { public Node parent; public float runningCost; public WorldState state; public IGoapAction action; public Node(Node parent, float runningCost, WorldState state, IGoapAction action) { this.parent parent; this.runningCost runningCost; this.state state; this.action action; } } }3.3 创建GoapAgent与具体行动GoapAgent是承载AI逻辑的MonoBehaviour它持有规划器、当前目标、行动队列和世界状态。// GoapAgent.cs using System.Collections.Generic; using UnityEngine; public class GoapAgent : MonoBehaviour { private GoapPlanner planner; private QueueIGoapAction currentActions; private IGoapGoal currentGoal; private WorldState worldState; [SerializeField] private ListIGoapAction availableActions; [SerializeField] private ListIGoapGoal availableGoals; void Start() { planner new GoapPlanner(); worldState new WorldState(); currentActions new QueueIGoapAction(); // 初始化世界状态例如{ “seePlayer”: false, “hasTreasure”: false, “health”: 100 } InitializeWorldState(); } void Update() { // 1. 检查当前目标是否仍然有效或是否有更高优先级的目标 IGoapGoal bestGoal FindHighestPriorityGoal(); if (currentGoal ! bestGoal || currentActions null || currentActions.Count 0) { // 目标改变或计划执行完毕需要重新规划 currentGoal bestGoal; PlanNewActions(); } // 2. 执行当前行动序列 if (currentActions ! null currentActions.Count 0) { IGoapAction action currentActions.Peek(); // 查看队列第一个行动但不移除 if (action.IsDone) { // 行动完成移出队列 currentActions.Dequeue(); action.OnActionEnd(this, true); } else { // 执行行动 action.Perform(this); } } } private IGoapGoal FindHighestPriorityGoal() { IGoapGoal bestGoal null; int highestPriority int.MinValue; foreach (var goal in availableGoals) { if (goal.IsValid(this) goal.Priority highestPriority) { highestPriority goal.Priority; bestGoal goal; } } return bestGoal; } private void PlanNewActions() { if (currentGoal null) return; HashSetIGoapAction actionSet new HashSetIGoapAction(availableActions); QueueIGoapAction plan planner.Plan(gameObject, actionSet, worldState, currentGoal); if (plan ! null) { currentActions plan; Debug.Log($[GoapAgent] New plan formed for goal: {currentGoal.GetType().Name}); foreach (var a in plan) { Debug.Log($ - {a.Name}); } } else { Debug.LogWarning($[GoapAgent] Failed to find a plan for goal: {currentGoal.GetType().Name}); currentActions new QueueIGoapAction(); } } private void InitializeWorldState() { worldState.SetState(seePlayer, false); worldState.SetState(playerInAttackRange, false); worldState.SetState(health, 100); worldState.SetState(hasTreasure, false); worldState.SetState(isAtPost, true); // 是否在岗哨位置 } // 提供方法供外部如感知系统修改世界状态 public void UpdateWorldState(string key, object value) { worldState.SetState(key, value); // 世界状态发生重大变化时可以强制重新规划 // if (key seePlayer (bool)value true) PlanNewActions(); } }现在让我们实现两个具体的行动PatrolAction巡逻和AttackAction攻击。// PatrolAction.cs using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; public class PatrolAction : MonoBehaviour, IGoapAction { public string Name Patrol; public float Cost 1.0f; public GameObject Target { get; set; } public bool IsDone isDone; private NavMeshAgent navAgent; private Vector3[] waypoints; private int currentWaypoint 0; private bool isDone false; public Dictionarystring, object Preconditions new Dictionarystring, object { { isAtPost, false } // 前提不在岗哨巡逻就是为了回到岗哨或去下一个点 }; public Dictionarystring, object Effects new Dictionarystring, object { { isAtPost, true } // 效果到达岗哨巡逻点 }; void Start() { navAgent GetComponentNavMeshAgent(); // 假设在Inspector中设置巡逻点 waypoints new Vector3[] { /* ... 巡逻点位置 ... */ }; } public bool CheckProceduralPrecondition(GoapAgent agent) { // 设置目标为下一个巡逻点 if (waypoints.Length 0) return false; Target new GameObject(PatrolTarget); Target.transform.position waypoints[currentWaypoint]; return true; } public void Perform(GoapAgent agent) { if (Target null) { isDone true; return; } navAgent.SetDestination(Target.transform.position); float distanceToTarget Vector3.Distance(transform.position, Target.transform.position); if (distanceToTarget 1.0f) { // 到达当前巡逻点 currentWaypoint (currentWaypoint 1) % waypoints.Length; isDone true; // 本次巡逻行动完成到达了一个点 } } public void OnActionStart(GoapAgent agent) { isDone false; Debug.Log($[{Name}] Starting patrol to waypoint {currentWaypoint}); } public void OnActionEnd(GoapAgent agent, bool success) { if (Target ! null) Destroy(Target); Debug.Log($[{Name}] Patrol action ended. Success: {success}); // 更新世界状态现在在岗哨巡逻点了 agent.UpdateWorldState(isAtPost, true); } public void Reset() { isDone false; } public bool IsAchievableGiven(WorldState state) { /* 简化实现通常检查Preconditions是否在state中 */ return true; } }// AttackAction.cs using System.Collections.Generic; using UnityEngine; public class AttackAction : MonoBehaviour, IGoapAction { public string Name Attack; public float Cost 5.0f; // 攻击成本较高可能消耗体力或暴露自己 public GameObject Target { get; set; } public bool IsDone isDone; private bool isDone false; private float attackTimer 0f; public float attackInterval 2.0f; public int damagePerAttack 10; public Dictionarystring, object Preconditions new Dictionarystring, object { { seePlayer, true }, { playerInAttackRange, true } }; public Dictionarystring, object Effects new Dictionarystring, object { { playerIsDefeated, true } // 攻击的最终效果是击败玩家简化 }; public bool CheckProceduralPrecondition(GoapAgent agent) { // 在实际项目中这里会通过感知系统找到玩家对象 GameObject player GameObject.FindGameObjectWithTag(Player); if (player ! null) { Target player; return true; } return false; } public void Perform(GoapAgent agent) { if (Target null) { isDone true; return; } attackTimer Time.deltaTime; if (attackTimer attackInterval) { attackTimer 0f; // 执行攻击逻辑例如播放动画调用Target的受伤方法 Debug.Log($[{Name}] Attacking {Target.name} for {damagePerAttack} damage!); // 假设这里减少了玩家的生命值 // 检查玩家是否被击败 // if (playerHealth 0) { agent.UpdateWorldState(playerIsDefeated, true); } } // 简化攻击一次就认为行动完成实际可能需要多次攻击直到目标死亡 isDone true; } public void OnActionStart(GoapAgent agent) { isDone false; attackTimer 0f; Debug.Log($[{Name}] Starting attack on {Target.name}); } public void OnActionEnd(GoapAgent agent, bool success) { Debug.Log($[{Name}] Attack action ended. Success: {success}); } public void Reset() { isDone false; } public bool IsAchievableGiven(WorldState state) { return true; } }3.4 实现目标Goal最后我们实现一个“保护宝藏”的目标。// ProtectTreasureGoal.cs using System.Collections.Generic; using UnityEngine; public class ProtectTreasureGoal : MonoBehaviour, IGoapGoal { public int Priority 50; // 基础优先级 public Dictionarystring, object DesiredState new Dictionarystring, object { { treasureIsSafe, true } // 期望状态宝藏安全 }; public bool IsValid(GoapAgent agent) { // 宝藏是否还存在是否已经被玩家拿走 // 这里可以访问agent的世界状态或查询场景中的宝藏对象 // 简化始终有效 return true; } public float CalculatePriority(GoapAgent agent) { // 动态计算优先级例如看到玩家时优先级提高 WorldState ws agent.GetWorldState(); // 假设agent有GetWorldState方法 bool seesPlayer (bool)ws.GetState(seePlayer); return Priority (seesPlayer ? 30 : 0); } }4. 性能优化与高级技巧一个基础的GoAP系统跑起来后你很快会发现一个问题搜索爆炸。当行动和世界状态变量很多时A*搜索的节点数会呈指数级增长导致规划耗时剧增卡顿掉帧。以下是几个关键的优化方向1. 世界状态剪枝与抽象不要把所有游戏数据都塞进世界状态。只放入对决策有影响的变量。例如“玩家衣服颜色”通常不影响战斗AI就不要放进去。同时可以使用分层抽象。底层状态是具体的“distanceToPlayer”: 12.5f但规划时可以使用抽象状态“playerIsNear”: true。这能大幅减少状态空间。2. 行动池预过滤在调用规划器前先根据当前世界状态过滤掉绝对不可能执行的行动。例如一个“开车”的行动前提是{ “hasCar”: true }如果世界状态里“hasCar”是false那么这个行动根本不用加入搜索。这可以通过在GoapAgent中维护一个“可用行动”列表并在世界状态变化时更新它来实现。3. 使用更高效的搜索算法与启发函数标准的A*需要好的启发式函数Heuristic来引导搜索。对于GoAP启发值可以估算为当前状态与目标状态之间差异的“代价”。一个简单但有效的启发函数是计算当前状态与目标状态中不匹配的变量数量。你也可以为每个状态变量赋予不同的权重。4. 异步规划与计划缓存不要让规划阻塞主线程。可以将规划任务放到另一个线程或使用UnityWebRequest那样的协程异步处理。规划完成后再将计划队列传回主线程执行。同时对于常见的目标-状态组合可以缓存规划结果。如果AI再次处于相同的世界状态并追求相同目标可以直接使用缓存计划省去计算开销。5. 增量式规划与计划修复不是每次都要从头规划。当世界状态发生微小变化如玩家移动了几米但未破坏当前计划的前提时可以尝试修复Repair现有计划而不是重新规划。例如当前计划是“走到A点-开门”如果走到一半发现门已经开了那么只需移除“开门”这个行动即可。实操心得调试与可视化GoAP的调试比行为树更抽象。我强烈建议你实现一个调试视图。在Unity Editor中可以绘制GUI来显示当前活跃的目标及其优先级。当前执行中的行动序列。当前的世界状态快照。规划器搜索的节点数和耗时。 这能让你直观地理解AI的“思考过程”快速定位逻辑错误。例如如果AI总是卡住你可以看到是因为没有找到有效计划还是计划中的某个行动无法完成其前提条件。5. 常见问题与排查实录即使理解了原理实际集成时还是会踩坑。下面是我在项目中遇到的一些典型问题及解决方案问题1AI陷入“规划循环”或频繁重新规划。现象AI不停地规划新计划刚执行一两个行动就放弃然后又开始规划行为抽搐。原因世界状态更新太频繁例如每帧都根据玩家位置更新distanceToPlayer导致世界状态持续变化触发重新规划。行动效果没有正确更新世界状态行动执行完了但对应的世界状态如“hasKey”: true没有设置导致目标永远无法达成规划器不断尝试新计划。目标优先级波动剧烈CalculatePriority方法逻辑有问题导致目标优先级在阈值上下频繁跳动。解决对连续变化的状态如距离设置阈值或滞后区间。例如只有当玩家距离变化超过5米时才更新distanceToPlayer状态。在行动的OnActionEnd方法中务必调用agent.UpdateWorldState来反映行动的效果。为目标优先级添加平滑或冷却机制避免抖动。问题2规划时间过长导致游戏卡顿。现象游戏在AI决策时明显掉帧。原因可用行动太多20个世界状态变量太多15个搜索空间过大。解决实施“行动预过滤”如前所述这是效果最显著的优化。限制搜索深度/时间在规划器的BuildGraph函数中设置一个最大搜索深度如50层或最大耗时如5ms超时则返回当前找到的最佳但不一定完整计划或执行一个默认的“备用行为”。简化状态表示用布尔值或枚举代替浮点数。用“playerIsNear”代替“distanceToPlayer”。问题3多个AI争夺同一个目标或资源行为不合理。现象两个AI都规划了“拿起唯一一把剑”的行动导致逻辑冲突。原因世界状态是每个AI私有的它们不知道其他AI的意图。解决引入世界资源管理器World Resource Manager。这是一个全局管理器跟踪关键资源如武器、位置、任务的占用情况。AI在规划“拿剑”行动前需要向管理器“预订”这把剑。如果预订失败则该行动的前提条件不满足AI会规划其他方案比如“用拳头攻击”。这需要扩展CheckProceduralPrecondition方法加入资源锁检查。问题4行动执行失败但计划没有重新规划。现象AI计划“开门”但门被锁住了行动执行失败AI就傻站着。原因规划器生成计划后就不再运行除非主动触发重新规划。解决在GoapAgent的Update中除了检查目标变化还要监控当前行动的执行状态。如果当前行动执行失败IsDone为true但success为false或者执行超时应立即清除当前计划队列并触发重新规划。同时可以考虑让失败的行动在其效果中增加一个“负面状态”如{ “doorIsLocked”: true }这样新的规划就会避开这个行动。问题5如何与Unity的动画系统、导航系统优雅集成核心将IGoapAction.Perform方法作为协调者而不是具体执行者。在Perform中调用navAgent.SetDestination来移动。设置动画参数如animator.SetBool(“IsAttacking”, true)。等待这些外部系统完成。可以通过检查navAgent.remainingDistance来判断是否到达通过动画状态机事件或定时器来判断攻击动画是否播放完毕。只有所有这些子任务都完成后才将行动的IsDone设为true。这样GoAP行动就成为了一个高级别的“任务”封装底层实现可以很灵活。