Unity事件驱动架构:构建模块化机械臂流水线控制系统

发布时间:2026/8/10 14:30:15
Unity事件驱动架构:构建模块化机械臂流水线控制系统 1. 项目概述为什么要在Unity里搞一套事件驱动的机械臂流水线如果你接触过工业仿真、数字孪生或者机器人教学大概率在Unity里摆弄过机械臂模型。常见的做法是写一堆脚本把传感器检测、传送带运动、机械臂抓取这些逻辑硬编码在一起。代码写着写着就成了一团乱麻改个传感器类型可能得翻遍半个项目。这个项目要解决的就是这种“牵一发而动全身”的耦合问题。我们这次要搭建的不是一个简单的机械臂动画而是一个基于事件系统驱动的、高度模块化的虚拟流水线控制系统。它的核心价值在于通过一个中央“事件中心”让传感器、传送带、机械臂这些物理模块之间不再直接“对话”。传感器只管报告“有物体来了”事件中心负责把消息广播出去而监听这个事件的传送带或机械臂再各自行动。这样做最直接的好处是解耦和可扩展性。你想加一个视觉传感器来识别工件类型没问题新建一个视觉传感器脚本让它检测到工件后发布一个“工件类型识别完成”的事件机械臂的抓取逻辑监听这个事件就能自动调整抓取姿态完全不用去修改传送带或者其它传感器的代码。这套思路在工业软件架构里很常见但在Unity实时3D环境下亲手实现一遍对理解事件驱动架构、面向接口编程以及如何在游戏引擎中模拟工业控制逻辑有非常大的帮助。无论是用于教学演示、算法验证前的仿真测试还是作为数字孪生系统的前端交互原型都非常实用。2. 核心架构设计从“硬连线”到“事件总线”在开始敲代码之前我们必须把架构想清楚。传统的“硬连线”方式就像老式电话交换机每个设备之间都要拉一条线混乱且难以维护。而我们要采用的是“发布-订阅”模式它就像一个微信群聊。2.1 事件中心Event Center—— 我们的“微信群”事件中心是整个系统的中枢神经它是一个单例类全局唯一。它的职责非常简单注册Subscribe允许任何模块如机械臂说“我对‘物体到达抓取点’这个消息感兴趣有消息时请通知我。”注销Unsubscribe模块不想听了可以退出群聊。发布Publish当某个模块如传感器触发了一个条件它就通过事件中心发布一条消息比如“发布一条‘物体到达抓取点’的消息并附上这个物体的信息”。广播Broadcast事件中心将这条消息发送给所有注册监听该消息的模块。在Unity中实现我们通常会定义一个自定义的事件委托Delegate和事件参数类EventArgs。这里有一个关键设计点事件参数EventArgs要设计得足够通用和丰富。因为它要承载从传感器到执行器之间传递的所有上下文信息。// 定义事件类型枚举方便管理 public enum GameEventType { ObjectDetected, // 物体被传感器检测到 ObjectPlaced, // 物体被放置到某处 ConveyorStart, // 传送带启动 ConveyorStop, // 传送带停止 ArmReachTarget, // 机械臂到达目标点 ArmGripCompleted, // 机械臂抓取完成 ArmReleaseCompleted, // 机械臂释放完成 // ... 可以根据需要扩展 } // 事件参数基类或自定义类用于传递数据 public class CustomEventArgs : EventArgs { public GameEventType EventType { get; private set; } public GameObject TriggerObject { get; private set; } // 触发事件的对象如传感器 public GameObject DataObject { get; private set; } // 相关的数据对象如被检测的工件 public Vector3 Position { get; private set; } // 相关位置信息 public string CustomMessage { get; set; } // 自定义字符串信息 public CustomEventArgs(GameEventType type, GameObject trigger, GameObject data, Vector3 pos) { EventType type; TriggerObject trigger; DataObject data; Position pos; } }注意GameObject类型的传递要小心。在真实项目中更推荐传递一个唯一ID或自定义的数据对象而不是直接传递GameObject引用以避免意外的对象依赖和生命周期管理问题。这里为了演示清晰使用了GameObject。2.2 模块化设计定义清晰的边界有了事件中心接下来要把流水线的各个部分拆分成独立的模块。每个模块都应该有明确的职责。传感器模块SensorBase这是一个基类。无论是光电传感器、压力传感器还是我们后面可能加的视觉传感器都继承自它。它的核心方法是CheckCondition()在Update或FixedUpdate中持续检测。一旦条件满足如射线检测到物体它就调用EventCenter.Instance.Publish(...)发布相应事件。传送带模块ConveyorBelt它监听“物体放置”或“启动”事件。当收到事件后开始播放传送带动画移动UV或移动物体子节点。它也可以发布事件比如“物体到达末端”通知下一个工位的传感器。机械臂控制模块RobotArmController这是最复杂的部分。它需要监听“物体到达抓取点”事件然后顺序执行一系列子任务运动到接近点 - 运动到抓取点 - 执行抓取激活夹爪碰撞体/关节 - 提升 - 运动到放置点 - 释放。每一个关键步骤的完成都应该发布一个事件。这样设计的好处是其他模块比如一个指示灯模块可以轻松监听“机械臂开始移动”或“抓取完成”事件来更新状态。为什么要把机械臂的每个步骤都事件化想象一下如果未来你想加一个声光报警系统要求在机械臂抓取失败时闪烁红灯。如果抓取逻辑是内聚的一坨代码你就得硬插一个报警调用进去。但如果抓取失败比如夹爪碰撞体未触发会发布一个ArmGripFailed事件那么报警系统只需要监听这个事件即可与机械臂控制代码完全解耦。2.3 接口Interface的运用面向抽象编程为了让模块之间替换起来更方便我们应该依赖接口而非具体实现。例如可以定义一个IDetectable接口让所有可以被传感器检测的物体工件、托盘等都实现它。public interface IDetectable { string GetObjectID(); ObjectType GetObjectType(); // 枚举如ProductA, ProductB // 其他需要暴露给检测系统的属性 }这样传感器基类的检测代码可以写为Collider detectedCollider Physics.OverlapBox(...); IDetectable detectable detectedCollider.GetComponentIDetectable(); if (detectable ! null) { // 发布事件并传递 detectable 对象的信息 EventArgs args new CustomEventArgs(..., dataObject: detectedCollider.gameObject); EventCenter.Instance.Publish(args); }未来如果你有一种特殊工件只需要让它实现IDetectable接口传感器就能无缝识别它无需修改传感器代码。3. 实战构建一步步搭建你的虚拟流水线理论说完了我们动手搭一个最简单的流水线工件出现在传送带起点 - 光电传感器检测到工件 - 传送带启动 - 工件移动到机械臂下方 - 位置传感器触发 - 机械臂抓取并放置到成品区。3.1 第一步创建事件中心单例在Unity中创建一个名为EventCenter的C#脚本。确保它继承自MonoBehaviour并实现为单例。using System; using System.Collections.Generic; using UnityEngine; public class EventCenter : MonoBehaviour { public static EventCenter Instance { get; private set; } // 使用字典来存储事件类型和对应的委托列表 private DictionaryGameEventType, EventHandlerCustomEventArgs eventDictionary; private void Awake() { if (Instance ! null Instance ! this) { Destroy(this.gameObject); return; } Instance this; eventDictionary new DictionaryGameEventType, EventHandlerCustomEventArgs(); DontDestroyOnLoad(this.gameObject); // 如果需要跨场景 } // 订阅事件 public void Subscribe(GameEventType eventType, EventHandlerCustomEventArgs listener) { if (!eventDictionary.ContainsKey(eventType)) { eventDictionary[eventType] null; } eventDictionary[eventType] listener; } // 取消订阅 public void Unsubscribe(GameEventType eventType, EventHandlerCustomEventArgs listener) { if (eventDictionary.ContainsKey(eventType)) { eventDictionary[eventType] - listener; } } // 发布事件 public void Publish(GameEventType eventType, GameObject trigger, GameObject data, Vector3 pos, string message ) { EventHandlerCustomEventArgs thisEvent; if (eventDictionary.TryGetValue(eventType, out thisEvent)) { if (thisEvent ! null) { CustomEventArgs args new CustomEventArgs(eventType, trigger, data, pos); args.CustomMessage message; // 在Unity主线程中触发事件避免多线程问题 thisEvent(this, args); } } } }将这个脚本挂载到一个空的GameObject上比如叫_Managers。3.2 第二步实现一个光电传感器PhotoelectricSensor场景搭建在Unity中创建一个Cube拉长变薄作为传感器的“光栅”模型。调整它的Layer比如设为“Sensor”。编写脚本public class PhotoelectricSensor : MonoBehaviour { public GameEventType detectionEvent GameEventType.ObjectDetected; public Vector3 detectionHalfExtents new Vector3(0.5f, 0.1f, 0.5f); // 检测区域大小 public LayerMask detectableLayer; // 指定检测哪一层的物体 private void Update() { CheckDetection(); } private void CheckDetection() { Collider[] hits Physics.OverlapBox(transform.position, detectionHalfExtents, transform.rotation, detectableLayer); if (hits.Length 0) { // 假设只处理第一个检测到的物体 GameObject detectedObj hits[0].gameObject; // 发布检测到物体的事件 EventCenter.Instance.Publish(detectionEvent, this.gameObject, detectedObj, detectedObj.transform.position, Photoelectric Sensor Triggered); // 注意实际项目中需要防抖避免同一帧或连续帧重复发布事件 } } // 在Scene视图中绘制检测区域便于调试 private void OnDrawGizmosSelected() { Gizmos.color Color.green; Gizmos.matrix Matrix4x4.TRS(transform.position, transform.rotation, Vector3.one); Gizmos.DrawWireCube(Vector3.zero, detectionHalfExtents * 2); } }配置将脚本挂载到传感器物体上设置detectableLayer为工件所在的Layer如“Product”。实操心得Physics.OverlapBox比射线检测更适合检测一个区域内的物体。OnDrawGizmosSelected方法对于调试传感器范围至关重要一定要用。另外记得在Update中做防抖处理比如设置一个bool isTriggered标志位只有状态从“未触发”变为“触发”时才发布事件防止事件洪水。3.3 第三步实现事件驱动的传送带EventDrivenConveyor传送带需要监听传感器的事件来启动。场景搭建创建一个长条状的Plane或Cube作为传送带面。创建一些Cube作为工件。编写脚本public class EventDrivenConveyor : MonoBehaviour { public float speed 2.0f; public GameEventType startEvent GameEventType.ObjectDetected; // 监听启动事件 public GameEventType stopEvent GameEventType.ObjectPlaced; // 监听停止事件可选 private bool isRunning false; private ListGameObject onBeltObjects new ListGameObject(); // 记录传送带上的物体 private void OnEnable() { // 订阅事件 EventCenter.Instance.Subscribe(startEvent, OnStartEvent); if (stopEvent ! GameEventType.None) // 假设有None枚举值 { EventCenter.Instance.Subscribe(stopEvent, OnStopEvent); } } private void OnDisable() { // 务必退订防止内存泄漏和空引用 EventCenter.Instance.Unsubscribe(startEvent, OnStartEvent); if (stopEvent ! GameEventType.None) { EventCenter.Instance.Unsubscribe(stopEvent, OnStopEvent); } } private void OnStartEvent(object sender, CustomEventArgs e) { // 可以检查事件中的数据对象是否在传送带起点区域这里简化处理直接启动 if (!isRunning) { isRunning true; Debug.Log(Conveyor started by event from: e.TriggerObject.name); } // 将物体加入传送带管理列表需要更精确的触发区域判断 if (e.DataObject ! null !onBeltObjects.Contains(e.DataObject)) { onBeltObjects.Add(e.DataObject); } } private void OnStopEvent(object sender, CustomEventArgs e) { isRunning false; Debug.Log(Conveyor stopped.); // 从列表中移除物体 if (e.DataObject ! null) { onBeltObjects.Remove(e.DataObject); } } private void Update() { if (!isRunning) return; // 移动传送带上的所有物体 foreach (var obj in onBeltObjects) { if (obj ! null) { // 沿着传送带方向假设是transform.forward移动 obj.transform.Translate(transform.forward * speed * Time.deltaTime, Space.World); } } // 注意这是一个简单的实现。更真实的做法是将物体设为传送带的子物体或者使用物理力来推动。 } // 当有物体通过触发器进入传送带范围时也可以将其加入列表 private void OnTriggerEnter(Collider other) { if (other.CompareTag(Product) !onBeltObjects.Contains(other.gameObject)) { onBeltObjects.Add(other.gameObject); } } private void OnTriggerExit(Collider other) { onBeltObjects.Remove(other.gameObject); } }这里有一个关键设计抉择传送带的移动逻辑。上述代码直接在Update里Translate简单但不够物理。更优的方案是给工件添加Rigidbody。在传送带表面设置一个带有物理材质的Collider。在FixedUpdate中对传送带上的每个工件Rigidbody施加一个力AddForce或直接设置速度rigidbody.velocity。 这样做的好处是能更好地与其他物理对象如挡板、其他机械臂交互模拟更真实。3.4 第四步实现机械臂控制器RobotArmController这是重头戏。我们将实现一个状态机State Machine来控制机械臂的抓取流程并且每个状态转换都通过事件来驱动和通知。机械臂建模与骨骼在Unity中你可以使用多个空物体GameObject通过父子关系来模拟机械臂的连杆。例如Arm_Base-Arm_Joint1-Arm_Joint2- ... -Arm_EndEffector。在末端执行器上挂载一个代表夹爪的物体。运动控制对于教学演示使用Vector3.Lerp或Quaternion.Slerp进行插值运动足够简单直观。对于更精确的控制可以考虑使用逆向运动学IKUnity的Animator组件配合Humanoid IK或Animation Rigging包是非常强大的工具。编写基于事件的状态机脚本public class RobotArmController : MonoBehaviour { public enum ArmState { Idle, MovingToPickup, Gripping, MovingToPlace, Releasing } private ArmState currentState ArmState.Idle; public Transform pickupPoint; // 抓取点通常由传感器事件提供的位置 public Transform placePoint; // 放置点 public Transform endEffector; // 末端执行器夹爪尖端 public float moveSpeed 5f; public float gripDuration 0.5f; private GameObject currentTargetObject; // 当前要抓取的物体 private Vector3 targetPosition; private float gripTimer; private void OnEnable() { // 监听“物体到达抓取位置”的事件 EventCenter.Instance.Subscribe(GameEventType.ObjectDetected, OnObjectReadyForPickup); } private void OnDisable() { EventCenter.Instance.Unsubscribe(GameEventType.ObjectDetected, OnObjectReadyForPickup); } private void OnObjectReadyForPickup(object sender, CustomEventArgs e) { // 检查事件是否来自正确的传感器可选 // if (e.TriggerObject.name ! PickupSensor) return; if (currentState ArmState.Idle) { currentTargetObject e.DataObject; targetPosition e.Position; // 使用事件传递的位置或 pickupPoint.position currentState ArmState.MovingToPickup; Debug.Log(Arm start moving to pickup.); // 可以发布一个状态改变事件 EventCenter.Instance.Publish(GameEventType.ArmReachTarget, this.gameObject, null, Vector3.zero, Arm started moving to pickup); } } private void Update() { switch (currentState) { case ArmState.MovingToPickup: MoveEndEffectorTo(targetPosition); if (Vector3.Distance(endEffector.position, targetPosition) 0.01f) { currentState ArmState.Gripping; gripTimer 0; Debug.Log(Arm reached pickup point, start gripping.); EventCenter.Instance.Publish(GameEventType.ArmReachTarget, this.gameObject, currentTargetObject, targetPosition, Arm reached pickup point); } break; case ArmState.Gripping: // 模拟抓取动作比如播放夹爪闭合动画 gripTimer Time.deltaTime; if (gripTimer gripDuration) { // 抓取完成将物体设为末端执行器的子物体 if (currentTargetObject ! null) { currentTargetObject.transform.SetParent(endEffector); Rigidbody rb currentTargetObject.GetComponentRigidbody(); if (rb ! null) rb.isKinematic true; // 防止物理干扰 } currentState ArmState.MovingToPlace; Debug.Log(Grip completed, moving to place.); EventCenter.Instance.Publish(GameEventType.ArmGripCompleted, this.gameObject, currentTargetObject, endEffector.position, Grip action completed); } break; case ArmState.MovingToPlace: MoveEndEffectorTo(placePoint.position); if (Vector3.Distance(endEffector.position, placePoint.position) 0.01f) { currentState ArmState.Releasing; gripTimer 0; Debug.Log(Arm reached place point, start releasing.); } break; case ArmState.Releasing: gripTimer Time.deltaTime; if (gripTimer gripDuration) { // 释放物体 if (currentTargetObject ! null) { currentTargetObject.transform.SetParent(null); Rigidbody rb currentTargetObject.GetComponentRigidbody(); if (rb ! null) rb.isKinematic false; } currentState ArmState.Idle; Debug.Log(Release completed, back to idle.); EventCenter.Instance.Publish(GameEventType.ArmReleaseCompleted, this.gameObject, currentTargetObject, placePoint.position, Object placed); currentTargetObject null; } break; } } private void MoveEndEffectorTo(Vector3 targetPos) { // 简化运动直接移动末端。真实情况需要解算各关节角度IK endEffector.position Vector3.MoveTowards(endEffector.position, targetPos, moveSpeed * Time.deltaTime); } }这个状态机虽然简陋但清晰地展示了事件如何触发状态转换OnObjectReadyForPickup以及状态转换后如何发布新事件ArmReachTarget,ArmGripCompleted等。一个指示灯模块可以监听ArmGripCompleted事件来亮绿灯一个统计模块可以监听ArmReleaseCompleted来计数成品它们都无需修改机械臂的任何代码。4. 调试、优化与扩展让你的流水线更健壮系统跑起来后你会发现很多需要完善的地方。4.1 调试与可视化事件驱动系统最大的调试难点是“看不见”的事件流。我们需要让事件可视化。事件日志修改EventCenter.Publish方法将所有发布的事件类型、发布者、携带的数据打印到控制台或一个专用的UI面板上。编辑器内可视化为每个传感器、执行器编写自定义的Editor脚本在Scene视图绘制其检测范围、监听的事件类型等。可以使用Handles或Gizmos绘制不同的颜色和图标。状态机可视化在机械臂的Inspector窗口显示当前状态currentState甚至可以用一个简单的状态图来展示。4.2 性能与架构优化事件中心的性能使用Dictionary存储事件委托是标准做法。如果事件类型非常多成千上万可以考虑按模块或频道进行划分。避免在频繁调用的Update中发布大量事件。使用ScriptableObject作为事件通道Event Channel这是更优雅、解耦更彻底的一种Unity特定模式。为每种事件类型创建一个ScriptableObject资产如ObjectDetectedEventChannel里面包含一个UnityEvent或自定义的Action委托。发布者持有该资产的引用并调用RaiseEvent()订阅者同样持有引用并注册监听。这样做的好处是配置灵活在编辑器中就能拖拽关联且与具体的MonoBehaviour类解耦。对象池管理流水线上工件频繁生成和销毁一定要用对象池。当工件被放置到成品区后发布一个ObjectRecycled事件对象池管理器监听此事件将工件回收而非销毁。异步操作支持机械臂的运动、传感器的检测如果是复杂的视觉检测可能是耗时的。考虑使用Coroutine协程或UniTask如果项目已引入来处理这些异步流程并在完成时发布事件避免阻塞主线程。4.3 系统扩展添加新模块现在我们来演示如何轻松地扩展系统。假设我们要加一个称重传感器HX711模拟在抓取后检查工件重量是否合格。创建称重传感器脚本public class WeightSensor : MonoBehaviour { public GameEventType weightCheckEvent GameEventType.CustomEvent1; // 定义一个新事件类型 public float acceptableWeightMin 0.9f; public float acceptableWeightMax 1.1f; public void OnObjectGripped(object sender, CustomEventArgs e) // 监听抓取完成事件 { if (e.EventType GameEventType.ArmGripCompleted) { GameObject obj e.DataObject; // 假设工件上有一个 WeightComponent 脚本存储重量 WeightComponent wc obj.GetComponentWeightComponent(); if (wc ! null) { float weight wc.GetWeight(); bool isOK (weight acceptableWeightMin weight acceptableWeightMax); string resultMsg isOK ? Weight_OK : Weight_Fail; // 发布称重结果事件 EventCenter.Instance.Publish(weightCheckEvent, this.gameObject, obj, Vector3.zero, resultMsg); } } } private void OnEnable() { EventCenter.Instance.Subscribe(GameEventType.ArmGripCompleted, OnObjectGripped); } private void OnDisable() { EventCenter.Instance.Unsubscribe(GameEventType.ArmGripCompleted, OnObjectGripped); } }创建分拣机械臂或推杆新建一个SortingArmController脚本它只监听weightCheckEvent。当收到Weight_Fail消息时控制一个推杆将不合格工件推入废品筐并发布ObjectSortedOut事件。整个流程原有的抓取机械臂和传送带代码一行都不用改。这就是事件驱动架构的魅力所在。你可以像搭积木一样组合功能。每个模块都只关心自己发出的和需要响应的事件模块间的依赖降到最低。5. 常见问题与避坑指南在实际操作中你肯定会遇到下面这些问题。5.1 事件管理混乱问题事件类型越来越多到处都在Subscribe和Publish难以维护。解决严格的事件类型枚举将所有事件类型集中在一个GameEventType枚举或静态类中并附上详细的注释说明其触发者和用途。使用事件参数基类确保所有事件数据都能通过CustomEventArgs或它的派生类传递避免为每种事件都定义新委托。模块化事件处理在每个模块的OnEnable/OnDisable中集中订阅和退订不要在代码各处散落订阅逻辑。5.2 事件监听与退订不及时导致空引用或错误问题物体被销毁如工件被回收但它的脚本还在事件中心留有订阅当事件触发时就会调用已销毁对象的方法导致MissingReferenceException。解决严格遵守生命周期在MonoBehaviour的OnEnable中订阅在OnDisable中退订。这是黄金法则。使用弱引用模式高级用法可以探索使用WeakReference来包装事件监听者但这在Unity中并不常用因为MonoBehaviour的生命周期管理相对清晰。事件中心自清理可以在EventCenter的Publish方法中调用委托前检查目标对象是否为null对于UnityEngine.Object可以使用 null判断Unity重载了操作符如果是则将其从委托列表中移除。但这会增加发布时的开销。5.3 物理模拟与帧更新不同步问题在Update中移动物体Transform但碰撞检测在FixedUpdate中进行可能导致传感器检测不到快速移动的物体或者物体“穿透”。解决统一使用物理更新对于需要与物理世界交互的物体工件、机械臂末端务必添加Rigidbody并在FixedUpdate中通过rigidbody.MovePosition或施加力的方式来运动。传送带对物体的推动也应在FixedUpdate中通过物理力实现。传感器检测时机传感器的CheckDetection也放在FixedUpdate中与物理步长同步。合理配置碰撞体使用连续碰撞检测Continuous Dynamic对于高速移动的小物体很有必要。5.4 状态机逻辑复杂难调问题机械臂的状态机随着功能增加如中途急停、错误恢复变得非常复杂Update中的switch语句又长又乱。解决使用状态模式State Pattern为每个状态IdleState,MovingState等创建独立的类它们继承自一个抽象基类ArmStateBase。状态机只负责持有当前状态对象的引用并调用其Update,OnEnter,OnExit方法。这样每个状态的逻辑被封装清晰易维护。利用动画状态机Animator对于动作序列固定的机械臂可以直接使用Unity的Animator控制器将抓取、移动等流程做成动画片段用参数和事件触发器来控制。Animator本身就是一个强大的可视化状态机。引入行为树Behavior Tree或有限状态机FSM插件对于极其复杂的工业流程模拟可以考虑使用如NodeCanvas,Rainbow Folders等插件来管理逻辑它们提供了更好的可视化编辑和调试工具。5.5 如何模拟更真实的传感器如9轴IMU、震动传感器在虚拟环境中模拟这些传感器本质上是获取游戏对象如机械臂关节的物理信息并发布为事件。9轴IMU加速度计、陀螺仪、磁力计在机械臂的每个关节上挂载脚本在FixedUpdate中通过Rigidbody.velocity计算线加速度通过Rigidbody.angularVelocity计算角速度陀螺仪。磁力计模拟可以忽略或用一个虚拟的全局磁场方向。将这些数据封装后以固定频率如100Hz发布IMUDataEvent。震动传感器计算机械臂末端或关键部件Rigidbody加速度的幅值rigidbody.velocity.magnitude的变化率当超过阈值时发布VibrationExceedEvent。你甚至可以加入FFT快速傅里叶变换来模拟频谱分析但这需要一定的数学和编码能力。模拟这些传感器数据可以为后续的预测性维护算法、数字孪生体的健康状态监测提供测试数据流是连接虚拟仿真与真实算法开发的关键桥梁。搭建这套系统初期可能会觉得比直接写线性代码更繁琐但一旦项目规模稍微扩大或者需求开始变化你会发现前期在架构上的投入会成倍地回报你。它让代码变得清晰、可测试、易扩展。下次当你的策划或客户说“我们能不能在流水线中间加一个视觉质检工位”时你可以自信地回答“没问题给我一个下午。”