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的内部逻辑是:
-
将这个graphic添加到对应Canvas的m_Graphics列表中。
-
同时 ,它会调用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清晰地展示了事件的派发顺序。
-
当鼠标按下 时,它会向当前悬浮的对象(currentOverGo)及其父级,派发IPointerDownHandler事件。
-
当鼠标抬起 时,它会先向之前被按下的对象 (pointerPress)派发IPointerUpHandler事件。
-
然后,它会检查抬起时悬浮的对象 (currentOverGo)是否与按下时的对象是同一个 ,并且是否具备IPointerClickHandler接口。如果都满足,才会派发IPointerClickHandler事件。
-
-
ExecuteEvents的 ruolo: ExecuteEvents是UGUI事件派发的最终执行者。ExecuteEvents.ExecuteHierarchy(target, eventData, handler)这个方法,会从target开始,向上遍历其Transform父级,直到找到第一个实现了指定事件接口(如IPointerClickHandler)的组件,并调用其方法。
总结:
UGUI的事件系统,是一套分层、解耦、且高度协作的精密管线,其旅程如下:
-
EventSystem 作为顶层调度器 ,在每一帧Update,选择并激活最合适的InputModule。
-
激活的InputModule (如StandaloneInputModule)从硬件获取原始输入,并创建一个PointerEventData来存储指针信息。
-
InputModule命令EventSystem执行RaycastAll。
-
EventSystem调用所有已注册的Raycaster(如GraphicRaycaster)。
-
GraphicRaycaster 从GraphicRegistry 获取高效的缓存列表,经过多层过滤和深度排序,返回命中的Graphic列表。
-
InputModule根据返回的命中结果和当前的输入状态,通过ExecuteEvents 系统,将高级的逻辑事件 (PointerDown, PointerClick等),精准地派发到目标GameObject上。
理解了这条从"硬件输入"到"逻辑事件"的完整、精密的旅程,我们才能在开发中,对UI的交互行为进行更底层的控制和调试,并能通过自定义BaseInputModule或BaseRaycaster,来扩展UGUI,以适应更特殊的输入设备和交互需求。