UGUI源码剖析(5):事件的旅程——EventSystem的架构与输入处理管线

UGUI源码剖析(第五章):事件的旅程------EventSystem的架构与输入处理管线

在前面的章节中,我们已经理解了UI是如何被"画"出来的。现在,我们将探索一个更深层次的问题:UI是如何"活"过来的? 当玩家点击一个按钮时,从硬件发出信号到按钮脚本的OnClick被调用,这期间到底发生了什么?答案,就藏在UGUI的"神经中枢"------EventSystem及其附属模块之中。

EventSystem并非一个孤立的组件,而是一个由管理器、射线发射器、和输入模块 协同工作的、高度模块化的输入处理管线。这一章,我们将以"精讲"的方式,深入这条管线的每一个环节。

1. EventSystem:交互世界的"中央处理器(CPU)"

EventSystem组件是整个交互系统的总指挥 。在一个场景中,通常有且仅有一个EventSystem。它就像计算机的CPU,本身不执行具体的I/O操作,但负责管理和调度所有的外设(输入模块)和总线(事件派发)。

Update() 方法: EventSystem的核心逻辑,都封装在其Update()方法中。

cs 复制代码
  // EventSystem.cs
 protected virtual void Update()
 {
     if (current != this)
         return;
 ​
     // 1. 更新所有输入模块的内部状态
     TickModules();
 ​
     // 2. 寻找并切换到当前最合适的激活模块
     bool changedModule = false;
     for (var i = 0; i < m_SystemInputModules.Count; i++)
     {
         var module = m_SystemInputModules[i];
         if (module.IsModuleSupported() && module.ShouldActivateModule())
         {
             if (m_CurrentInputModule != module)
             {
                 ChangeEventModule(module);
                 changedModule = true;
             }
             break;
         }
     }
 ​
     // ... (如果没有任何模块激活,则激活第一个支持的模块)
 ​
     // 3. 命令当前激活的模块去处理输入事件
     if (!changedModule && m_CurrentInputModule != null)
         m_CurrentInputModule.Process();
 }
  • 职责 :EventSystem的职责非常纯粹,就是**"轮询与调度"**。它在每一帧,都会询问所有挂载在自己身上的BaseInputModule:"你们谁现在应该工作?"。通过ShouldActivateModule(),它找到优先级最高的那个(例如,在有触摸时,TouchInputModule会优先于StandaloneInputModule),然后通过ChangeEventModule将其设为m_CurrentInputModule。最后,它对当前激活的模块下达指令:"Process()!开始处理输入!"

  • 模块化设计 :这种设计是典型的策略模式(Strategy Pattern) 。EventSystem是上下文(Context),而不同的BaseInputModule是不同的策略(Strategy)。这使得UGUI的输入系统具有极高的可扩展性。我们可以通过编写自己的BaseInputModule(例如VRInputModule, KinectInputModule),来让UGUI支持任何我们想要的输入设备,而无需修改EventSystem的任何代码。

2. GraphicRaycaster:UI世界的"视觉传感器"

2.1. GraphicRegistry:为性能而生的"图形(Graphic)档案库"

在EventSystem开始工作之前,它需要一份清晰的、可供查询的"地图",来知道在每一个Canvas上,到底有哪些可交互的UI元素。GraphicRegistry这个全局单例,正是扮演了这个**"地图绘制者"和"档案管理员"**的角色。

GraphicRegistry的内部结构非常清晰,它主要维护着两个核心的字典:

cs 复制代码
 // GraphicRegistry.cs
 private readonly Dictionary<Canvas, IndexedSet<Graphic>> m_Graphics;
 private readonly Dictionary<Canvas, IndexedSet<Graphic>> m_RaycastableGraphics;
  • m_Graphics: 存储了每一个Canvas下,所有当前已激活的Graphic元素的列表。

  • m_RaycastableGraphics: 存储了每一个Canvas下,所有当前已激活且raycastTarget为true的Graphic元素的列表。

档案的建立与维护:Graphic的"报到"与"注销"

这份"档案"并非在需要时才去动态生成,而是在Graphic的生命周期中,被主动地、增量地维护着。

cs 复制代码
 // Graphic.cs
 protected override void OnEnable()
 {
     base.OnEnable();
     CacheCanvas(); // 首先找到自己属于哪个Canvas
     GraphicRegistry.RegisterGraphicForCanvas(canvas, this);
     // ...
 }
  • RegisterGraphicForCanvas的内部逻辑是:

    1. 将这个graphic添加到对应Canvas的m_Graphics列表中。

    2. 同时 ,它会调用RegisterRaycastGraphicForCanvas。这个内部方法会检查graphic.raycastTarget属性,如果为true,则再将这个graphic添加到m_RaycastableGraphics列表中。

  • 注销(Unregister): 相应地,在OnDisable或OnDestroy中,Graphic会调用GraphicRegistry.UnregisterGraphicForCanvas,将自己从这两个列表中移除。

  • 动态更新 (raycastTarget的set访问器): Graphic.raycastTarget属性的set访问器,是动态维护m_RaycastableGraphics列表的关键。

    cs 复制代码
     // Graphic.cs
     public virtual bool raycastTarget
     {
         get { /*...*/ }
         set
         {
             if (value != m_RaycastTarget)
             {
                 // 如果是从true变为false,则从可射线检测列表注销
                 if (m_RaycastTarget)
                     GraphicRegistry.UnregisterRaycastGraphicForCanvas(canvas, this);
                 
                 m_RaycastTarget = value;
     ​
                 // 如果是从false变为true,且当前是激活状态,则注册到可射线检测列表
                 if (m_RaycastTarget && isActiveAndEnabled)
                     GraphicRegistry.RegisterRaycastGraphicForCanvas(canvas, this);
             }
             // ...
         }
     }

2.2 修正与深挖:GraphicRegistry的真正价值

  • GraphicRegistry的价值,在于为GraphicRaycaster提供了一份预先构建好的、最小化的"嫌疑人名单"。

  • 当GraphicRaycaster工作时,它调用的GraphicRegistry.GetRaycastableGraphicsForCanvas(canvas),直接返回的就是那个已经被raycastTarget == true条件过滤过的m_RaycastableGraphics列表

  • 为什么这至关重要? 这避免了GraphicRaycaster在每一帧,都需要去遍历一个Canvas下所有 的Graphic元素(这个数量可能非常庞大,包含大量纯装饰性的、不可交互的图片和文字),并逐一检查它们的raycastTarget属性。通过在OnEnable/OnDisable和raycastTarget属性变更时,付出一点点增量维护的成本,GraphicRegistry为每一帧的射线检测,都节省了大量的、不必要的遍历和判断开销。这是一个典型的**"空间换时间"** 和**"预处理优化"**的设计思想。

2.3 GraphicRaycaster:事件世界的"传感器"

有了"档案库",我们还需要一个"传感器"来读取它,并将物理输入转换为有意义的命中信息。这就是Raycaster的职责。

Raycast静态方法的内部 这个私有静态方法是真正的检测核心。

复制代码
      
cs 复制代码
 // GraphicRaycaster.cs (private static Raycast)
 private static void Raycast(...)
 {
     for (int i = 0; i < totalCount; ++i)
     {
         Graphic graphic = foundGraphics[i];
 ​
         // 过滤:不可交互、被剔除、深度无效的元素直接跳过
         if (!graphic.raycastTarget || graphic.canvasRenderer.cull || graphic.depth == -1)
             continue;
 ​
         // 几何检测:指针是否在矩形范围内?
         if (!RectTransformUtility.RectangleContainsScreenPoint(graphic.rectTransform, pointerPosition, ...))
             continue;
         
         // 过滤器检测:调用Graphic自身的Raycast方法,进行CanvasGroup等父级过滤
         if (graphic.Raycast(pointerPosition, eventCamera))
         {
             s_SortedGraphics.Add(graphic);
         }
     }
 ​
     // 深度排序:将所有命中的元素,按深度从前到后排序
     s_SortedGraphics.Sort((g1, g2) => g2.depth.CompareTo(g1.depth));
     
     // ... 将排序后的结果添加到最终列表中 ...
 }
  • 多层过滤 :一次成功的命中,需要通过三道关卡 :首先是Graphic自身的基础状态检查;然后是RectTransformUtility进行的2D几何 判断;最后是graphic.Raycast进行的、自下而上的层级树"过滤器"检查(详见第一章对Graphic.Raycast的分析)。

  • 深度排序 :在所有命中的元素中,只有**最前面(depth值最大)**的那个,才是最终的交互目标。这个排序是确保UI交互正确性的关键。

3. StandaloneInputModule:输入处理的"总工程师"

StandaloneInputModule是PC平台(鼠标/键盘/手柄)输入处理的"总工程师",它编排了整个事件处理的复杂流程。

Process() 方法 & ProcessMousePress() 方法:

复制代码
 
cs 复制代码
      // StandaloneInputModule.cs
public override void Process()
{
    // ...
    // 优先处理触摸,如果没触摸且有鼠标,则处理鼠标
    if (!ProcessTouchEvents() && input.mousePresent)
        ProcessMouseEvent();
    // ...
}

protected void ProcessMouseEvent(int id)
{
    // 1. 获取当前帧的鼠标状态,并执行射线检测
    var mouseData = GetMousePointerEventData(id);
    var leftButtonData = mouseData.GetButtonState(PointerEventData.InputButton.Left).eventData;
    
    m_CurrentFocusedGameObject = leftButtonData.buttonData.pointerCurrentRaycast.gameObject;

    // 2. 根据鼠标状态,处理按下、移动、拖拽事件
    ProcessMousePress(leftButtonData);
    ProcessMove(leftButtonData.buttonData);
    ProcessDrag(leftButtonData.buttonData);

    // ... (处理右键、中键、滚轮)
}

protected void ProcessMousePress(MouseButtonEventData data)
{
    var pointerEvent = data.buttonData;
    var currentOverGo = pointerEvent.pointerCurrentRaycast.gameObject;

    // --- 按下事件处理 ---
    if (data.PressedThisFrame())
    {
        // ... (设置点击计数、拖拽阈值等) ...

        // 3. 派发PointerDown事件
        var newPressed = ExecuteEvents.ExecuteHierarchy(currentOverGo, pointerEvent, ExecuteEvents.pointerDownHandler);
        
        // ... (记录下可能接收Click事件的对象) ...
        pointerEvent.pointerPress = newPressed;
        pointerEvent.rawPointerPress = currentOverGo;
    }

    // --- 抬起事件处理 ---
    if (data.ReleasedThisFrame())
    {
        // 4. 派发PointerUp事件
        ExecuteEvents.Execute(pointerEvent.pointerPress, pointerEvent, ExecuteEvents.pointerUpHandler);
        
        // 5. 判断并派发PointerClick事件
        var pointerClickHandler = ExecuteEvents.GetEventHandler<IPointerClickHandler>(currentOverGo);
        if (pointerEvent.pointerPress == pointerClickHandler && pointerEvent.eligibleForClick)
        {
            ExecuteEvents.Execute(pointerEvent.pointerPress, pointerEvent, ExecuteEvents.pointerClickHandler);
        }
        
        // ... (处理拖拽结束和Drop事件) ...
    }
}    

技术精讲:

  • 状态机 :StandaloneInputModule内部维护了一个复杂的状态机。它会跟踪每一个指针(鼠标左/中/右键、每一个触摸手指)的状态,包括它"按下"时的对象(pointerPress)、"进入"的对象(pointerEnter)、"正在拖拽"的对象(pointerDrag)等。

  • 事件派发管线:ProcessMousePress清晰地展示了事件的派发顺序。

    1. 当鼠标按下 时,它会向当前悬浮的对象(currentOverGo)及其父级,派发IPointerDownHandler事件。

    2. 当鼠标抬起 时,它会先向之前被按下的对象 (pointerPress)派发IPointerUpHandler事件。

    3. 然后,它会检查抬起时悬浮的对象 (currentOverGo)是否与按下时的对象是同一个 ,并且是否具备IPointerClickHandler接口。如果都满足,才会派发IPointerClickHandler事件。

  • ExecuteEvents的 ruolo: ExecuteEvents是UGUI事件派发的最终执行者。ExecuteEvents.ExecuteHierarchy(target, eventData, handler)这个方法,会从target开始,向上遍历其Transform父级,直到找到第一个实现了指定事件接口(如IPointerClickHandler)的组件,并调用其方法。

总结:

UGUI的事件系统,是一套分层、解耦、且高度协作的精密管线,其旅程如下:

  1. EventSystem 作为顶层调度器 ,在每一帧Update,选择并激活最合适的InputModule

  2. 激活的InputModule (如StandaloneInputModule)从硬件获取原始输入,并创建一个PointerEventData来存储指针信息。

  3. InputModule命令EventSystem执行RaycastAll。

  4. EventSystem调用所有已注册的Raycaster(如GraphicRaycaster)。

  5. GraphicRaycasterGraphicRegistry 获取高效的缓存列表,经过多层过滤和深度排序,返回命中的Graphic列表。

  6. InputModule根据返回的命中结果和当前的输入状态,通过ExecuteEvents 系统,将高级的逻辑事件 (PointerDown, PointerClick等),精准地派发到目标GameObject上。

理解了这条从"硬件输入"到"逻辑事件"的完整、精密的旅程,我们才能在开发中,对UI的交互行为进行更底层的控制和调试,并能通过自定义BaseInputModule或BaseRaycaster,来扩展UGUI,以适应更特殊的输入设备和交互需求。

相关推荐
Hilaku11 分钟前
我给团队做分享:不聊学什么,而是聊可以不学什么
前端·javascript·架构
真上帝的左手29 分钟前
十、软件设计&架构-分布式-分布式事务
分布式·架构
小马哥编程1 小时前
【软考架构】网络规划与设计,三层局域网模型和建筑物综合布线系统PDS
网络·计算机网络·架构·系统架构
玩代码1 小时前
Unity插件DOTween使用
unity·游戏引擎
zkmall2 小时前
ZKmall开源商城多商户架构:平衡管理与运营的技术方案
架构·开源
WanderInk2 小时前
InnoDB 的 Read Committed:它究竟“读”的是什么、怎么读、为什么这么读
java·后端·架构
亲爱的非洲野猪4 小时前
从 0 到 1:用 MyCat 打造可水平扩展的 MySQL 分库分表架构
android·mysql·架构
fs哆哆4 小时前
在VB.net中,委托Action与Func的比较
开发语言·c#·.net