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,以适应更特殊的输入设备和交互需求。

相关推荐
sailing-data8 分钟前
【SE BT】BR/DER协议
物联网·架构
_守一28 分钟前
UE DS+Nakama进行游戏服务器开发(1)源码编译nakama
服务器·游戏
Eiceblue33 分钟前
使用 C# 将 Excel 转换为 Markdown 表格(含批量转换示例)
开发语言·c#·excel
天人合一peng1 小时前
unity 生成标记根据背景色变色为明显的颜色
unity·游戏引擎
魔士于安1 小时前
Unity 超市总动员 超市收银台 超市货架 超市购物手推车 超市常见商品
游戏·unity·游戏引擎·贴图·模型
CandyU21 小时前
Unity —— 数据持久化
unity·游戏引擎
Ghost Face...1 小时前
LS2K1000启动全链路架构解析
架构
zh路西法1 小时前
【Unity实现Oneshot胶卷显形】游戏窗口化与Win32API的使用
游戏·unity·游戏引擎
七夜zippoe1 小时前
工业物联网数据架构设计
物联网·架构·数据·工业物联网·dolphindb
黄俊懿2 小时前
MySQL主从复制:从“异步“到“GTID“,数据同步的进化之路
数据库·sql·mysql·oracle·架构·dba·db