UGUI源码学习笔记_第七篇_EventSystem

be196a137e7104846a7c2ffe9924e9152de6df9e

本文把「EventSystem 基础概念」「一次点击的流水线方法级讲解」「底层机制深挖」三块,按"先概念、再主干、后机制"的顺序进行讲解。


0. 前置:EventSystem 概览——它是什么、管什么、怎么用、有哪些字段

0.1 EventSystem 是什么:一个"只管状态、不干活"的中枢

从类定义看:

[AddComponentMenu("Event/Event System")]
[DisallowMultipleComponent]
public class EventSystem : UIBehaviour

三个标签已经把它说了一半:

  • UIBehaviour 子类:它走的是 UnityEngine UI 的标准生命周期(Awake/OnEnable/Start/OnDisable/OnDestroy),和挂场景里的普通 MonoBehaviour 行为一致。
  • [DisallowMultipleComponent]:同一个 GameObject 上不允许挂两个 EventSystem。注意——这个限制是"同一物体",不是"同一场景"。场景里放可以两个 EventSystem,但运行时只有一个会真正干活。
  • [AddComponentMenu("Event/Event System")]:Hierarchy 里右键 Add Component → Event → Event System 就能挂,通常新建 UI 时 Unity 会自动帮你生成一个。

它的官方 remarks 把定位讲得很直白,原文翻译过来就是:

EventSystem 负责在一个 Unity 场景里处理并派发事件。一个场景应当只包含一个 EventSystem。它本身基本只持有状态,然后把具体功能委托给可覆写的模块组件;启动时它会搜索挂在同一物体上的所有 BaseInputModule,加进内部列表;每帧 update 时,每个模块先收到 UpdateModules 调用去刷新内部状态,之后当前激活模块才收到 Process 调用——自定义模块的处理逻辑就发生在 Process 里。

总结一下它的设计哲学:EventSystem 是"经理",不是"工人"。具体的射线(BaseRaycaster)、输入采集(BaseInputModule)、事件派发(ExecuteEvents)、对象选择(SetSelectedGameObject)都是别人干的,它负责把它们编排起来、存好共享状态、决定"这一帧由谁来处理"。

0.2 它管什么、不自己干什么:职责边界

把职责拆开,能清楚看到 EventSystem 和周边组件的分工:

职责 谁在干
每帧驱动、选当前模块 EventSystem(Update / TickModules / ChangeEventModule)
采集鼠标/触摸/键盘原始输入 BaseInputModule(具体是 StandaloneInputModule)
把屏幕点变成射线命中列表 BaseRaycaster(具体是 GraphicRaycaster / PhysicsRaycaster)
命中结果排序、取第一名 EventSystem(RaycastAll + RaycastComparer)
沿父链派发事件、找 handler ExecuteEvents(ExecuteHierarchy / GetEventHandler)
维护本次指针交互的状态 PointerEventData(状态机)
维护 Hover 进入/离开链 BaseInputModule.HandlePointerExitAndEnter
持有"当前选中对象"等全局状态 EventSystem(m_CurrentSelected 等)
点击流水线的端到端串联(Down/Up/Click/Drag 怎么流) EventSystem + InputModule + ExecuteEvents 协作

所以你脑子里要建立的模型是:EventSystem 是中枢 + 状态容器;InputModule 是输入驱动;Raycaster 是命中探测;ExecuteEvents 是派发引擎;Graphic 是可被命中的叶子。 它们通过 EventSystem 这个对象彼此串联。

0.3 怎么用:三个必须满足的前提

要在项目里让 UGUI 事件正常工作,EventSystem 不是"挂上就完事",它依赖一个最小协作闭环。从源码看,至少要满足这三点:

① 场景里有且仅有一个 EventSystem

OnEnable 时它会把自己塞进静态列表 m_EventSystems,current 属性永远取这个列表的第一个元素。第 1 节会展开"多 ES 只有第一个干活"的守卫逻辑。Editor 下挂多个还会弹警告提醒你删掉多余的。

② 同一个物体上挂至少一个 InputModule

UpdateModules()干的事就是 GetComponents<BaseInputModule>(m_SystemInputModules),把同物体上的所有输入模块收进 m_SystemInputModules 列表;之后 Update() 才会从这些模块里选当前激活的并调 Process。

实战:你新建 UI 时 Unity 自动生成的 EventSystem 物体,上面同时挂了 StandaloneInputModule(PC/鼠标键盘)和(移动端模板)TouchInputModule。EventSystem 和 InputModule 必须同物体——把 InputModule 挂到别处,EventSystem 收不到它,整个输入链路就断了。

③ Canvas 上要有 GraphicRaycaster(针对 UI 交互)

UI 能被点中,靠的是 Canvas 上的 GraphicRaycaster。它在 OnEnable 时通过 RaycasterManager 自动登记,EventSystem.RaycastAll 才会去调它。没有这个组件,UI 射线就不会发生,点击自然落空。

一个常见误解:"EventSystem 不挂在 Canvas 上,能点到 UI 吗?" 能。EventSystem 和 Canvas 不需要父子关系,它只负责调度;真正做射线的是 Canvas 上的 GraphicRaycaster。你想让哪个 Canvas 可被交互,就在那个 Canvas 上挂 GraphicRaycaster。

补充:firstSelectedGameObject 的语义

EventSystem.firstSelectedGameObject是"激活输入模块时自动被键盘选中的对象"。当 ActivateModule 跑起来,它会把这个对象设为 currentSelectedGameObject,于是该按钮一进游戏就高亮、可被方向键/回车导航。如果你不做键盘导航,留空即可。

0.3.1 EventSystem 的生命周期

EventSystem 作为 UIBehaviour,生命周期走 Unity 标准回调,但它在 OnEnable / Update / OnDisable 里塞进了大量与输入链路相关的逻辑。下图把整条生命周期(含每帧循环与失焦分支)串起来:

flowchart TD A["编辑期挂载<br/>AddComponent Event System<br/>同物体挂 InputModule<br/>Canvas 挂 GraphicRaycaster"] --> B["OnEnable<br/>注册进静态 m_EventSystems<br/>UpdateModules 收集同物体 InputModule"] B --> C["ActivateModule<br/>firstSelectedGameObject → currentSelectedGameObject<br/>发 OnSelect 事件"] C --> D{"每帧 Update 守卫<br/>current != this ?"} D -- "不是 current" --> Z["空转 return<br/>多 ES 时第 2+ 个的常态"] D -- "是 current" --> E["TickModules 刷新模块状态<br/>m_SystemInputModules 逐个 UpdateModule"] E --> F{"应用有焦点?<br/>isFocused"} F -- "失焦 OnApplicationFocus" --> G["只 Tick 不 Process<br/>清空 m_InputPointerEvent<br/>不补 Up/Click"] F -- "有焦点" --> H["选当前模块 ChangeEventModule<br/>Process"] H --> I["RaycastAll 射线+排序<br/>鼠标/触摸/键盘输入处理<br/>Hover / 拖拽 / 释放 派发"] I --> E G --> E C -.->|"OnDisable"| J["从 m_EventSystems 移除<br/>DeactivateModule 发 OnDeselect"] J --> K["OnDestroy 清理"]

主要可以关注三条主线:

  • 注册线(A→B→C):从你挂组件开始,OnEnable 把自己登记进静态列表并收集输入模块,ActivateModule 完成启动选中。这是"它活过来"的阶段。
  • 运行线(D→E→F→H→I→回到 E):每帧 Update 先过 current 守卫,再 TickModules 刷新状态,有焦点才 Process 真正处理输入。I → E 的回环就是"每帧反复执行"的本体。
  • 异常线(F→G):失焦时 isFocused=false,流程退化成"只 Tick 不 Process",这是第 1 节要展开的关键分支。
  • 销毁线(C→J→K,虚线):OnDisable 把自己从列表移除并让模块 DeactivateModule,OnDestroy 收尾。

0.4 核心字段与公开 API 速查表

成员 类型 作用 / 注意点
current static EventSystem 全局当前激活的 EventSystem,等于 m_EventSystems[0]。setter 会把指定实例提到列表首位。多 ES 时只有它真正处理输入。
firstSelectedGameObject GameObject 激活模块时自动选中的对象(默认高亮按钮)。
currentSelectedGameObject GameObject 当前被"键盘选中"的对象,由 SetSelectedGameObject 维护。只读。
sendNavigationEvents bool 是否允许 Move/Submit/Cancel 这类键盘/手柄导航事件。
pixelDragThreshold int 拖拽阈值(像素),默认 10。与 PointerEventData 的 pressPosition 一起决定 ShouldStartDrag 何时启动拖拽。
currentInputModule BaseInputModule 当前激活的输入模块。只读。
isFocused bool 应用是否拥有焦点(由 OnApplicationFocus 维护)。失焦时 Process 会被拦。
alreadySelecting bool 防 SetSelectedGameObject 重入的守卫标记。
UpdateModules() void 重新收集同物体上的 BaseInputModule(增删模块后调用)。
SetSelectedGameObject(go, data) void 切换选中对象:给旧对象发 OnDeselect、新对象发 OnSelect,带重入保护。
SetSelectedGameObject(go) void 上面方法的无 data 重载,自动用缓存的 BaseEventData。
RaycastAll(eventData, results) void 核心:用 RaycasterManager 里所有射线器做射线,结果统一塞进 results 并按 RaycastComparer 排序。见第 2 节。
IsPointerOverGameObject() bool 无参版默认查鼠标左键(kMouseLeftId = -1)。触摸务必传 pointerId,否则误判。见第 8 节。

点击流水线

当前射线命中了谁、Down 实际交给了谁、Click 又缓存了谁。它们经常不是同一个 GameObject。Down、Up、Click 和 Drag 也不是一组自动成对的广播,而是 InputModule 按各自条件派发的事件。

鼠标路径每帧做什么

EventSystem.Update() 选择当前 InputModule 并调用 Process()。在 StandaloneInputModule 的正常鼠标路径里,左键处理顺序是:

逻辑示意:StandaloneInputModule 的鼠标左键主路径
GetMousePointerEventData()   // RaycastAll,得到 pointerCurrentRaycast
ProcessMousePress()          // Down 或 ReleaseMouse
ProcessMove()                // Enter、Exit、Move
ProcessDrag()                // BeginDrag、Drag

随后才处理右键、中键和滚轮。只要应用有焦点、存在鼠标,并且这一帧没有被触摸处理路径接管,GetMousePointerEventData() 通常会每帧执行一次 RaycastAll。但不能写成"所有平台上 RaycastAll 和 Execute 每帧必跑":失焦时 Process() 可以直接返回,无鼠标时不走鼠标路径;具体 Execute 还要看按键、位移、悬停变化和拖拽状态。

ProcessMove 每帧都会进入 HandlePointerExitAndEnter,但目标未变时,只有 IsPointerMoving() 才向 hovered 链发送 OnPointerMove。

Press 阶段有三个对象

按下时,currentOverGo 来自 pointerCurrentRaycast.gameObject。源码随后做两次父链查找:

var newPressed = ExecuteEvents.ExecuteHierarchy(
    currentOverGo, pointerEvent, ExecuteEvents.pointerDownHandler);
var newClick = ExecuteEvents.GetEventHandler<IPointerClickHandler>(
    currentOverGo);

if (newPressed == null)
    newPressed = newClick;

pointerEvent.pointerPress = newPressed;
pointerEvent.rawPointerPress = currentOverGo;
pointerEvent.pointerClick = newClick;

这三项各有用途:

  • rawPointerPress 是原始命中对象。
  • pointerPress 是父链上第一个真正处理 OnPointerDown 的对象;找不到 Down handler 时,回退为 Click handler。
  • pointerClick 是按下位置父链上找到的 Click handler。

所以"Down 发给按下点"只适合作口语。更准确地说,射线命中按下点,ExecuteHierarchy 再向上找到处理者。按下阶段还会从同一条父链找第一个 IDragHandler 保存到 pointerDrag,并发送 OnInitializePotentialDrag。

ReleaseMouse 的实际顺序

鼠标正常释放时,ProcessMousePress 调用 ReleaseMouse(pointerEvent, currentOverGo)。顺序很具体:

  1. 对缓存的 pointerPress 直接发送 OnPointerUp。
  2. 从抬起位置向上找到当前 IPointerClickHandler。
  3. 若它等于按下时缓存的 pointerClick,且 eligibleForClick 仍为 true,则对缓存对象直接发送 OnPointerClick。
  4. 若正在拖拽,从抬起位置用 ExecuteHierarchy 发送 OnDrop。
  5. 清除 Press、Click 状态。
  6. 对缓存的 pointerDrag 发送 OnEndDrag,再清除 Drag 状态。
  7. 必要时刷新 Enter/Exit 链。

Click 接收者虽然通过 GetEventHandler 从父链选出,最终派发却不是再次 ExecuteHierarchy,而是对已选定的 pointerClick 直接 Execute。

这也解释了几个常见现象。移出按钮再松手,Up 仍发给按下时的 pointerPress,但 Click handler 对不上,于是没有 Click。若对象在 OnPointerDown 或按住期间被禁用、销毁,ExecuteEvents 会因为目标无效而丢掉后续 Up。可如果是在当前点击的 OnPointerClick 中销毁按钮,当前 Up 早已发送完,不能把它算作"丢 Up"的例子。

Drag 会提前取消 Press

ProcessDrag 先检查指针确实移动、光标未锁定且存在 pointerDrag。超过阈值后先发送 OnBeginDrag,并把 dragging 设为 true。同一次处理中随后继续:

if (pointerEvent.pointerPress != pointerEvent.pointerDrag)
{
    ExecuteEvents.Execute(pointerEvent.pointerPress,
        pointerEvent, ExecuteEvents.pointerUpHandler);
    pointerEvent.eligibleForClick = false;
    pointerEvent.pointerPress = null;
    pointerEvent.rawPointerPress = null;
}
ExecuteEvents.Execute(pointerEvent.pointerDrag,
    pointerEvent, ExecuteEvents.dragHandler);

于是 Press 对象和 Drag 对象不同时,拖拽刚开始就会收到一次提前的 Up,Click 资格也被取消;真正松手时 pointerPress 已经为空。若两者相同,Press 会保留到释放阶段,但只要发生了拖拽,释放流程还会走 Drop 和 EndDrag。

失焦与光标锁定不是同一种取消

应用失焦时,StandaloneInputModule.UpdateModule() 并不会无条件给普通按压补 Up+Click。只有 m_InputPointerEvent 正在 dragging,且 pointerDrag 不为空时,它才调用 ReleaseMouse。即使走到这里,Click 仍需满足"抬起位置 handler 相同 + eligibleForClick"两个条件。

光标锁定时,鼠标事件位置被设为 (-1,-1),ProcessMove 把 target 当成 null,ProcessDrag 也直接返回。因此在锁定状态下开始按压,通常命不中 UI。不过如果先按下并缓存了 pointerPress,之后才锁定,释放阶段仍可能把 Up 发给旧的 pointerPress;受到影响更直接的是当前命中、Click、Drop、Hover 和 Drag。

Enter、Exit 与 GraphicRaycaster

对普通 UGUI 命中,GraphicRaycaster 从:

GraphicRegistry.GetRaycastableGraphicsForCanvas(canvas)

取可射线 Graphic,再检查 raycastTarget、canvasRenderer.cull、depth、屏幕点是否落在 Rect 内,以及 Graphic.Raycast。因此 UI Graphic 本身不需要 Collider。只有 RectTransform 加 Collider、没有可命中 Graphic 的对象,也不会自动成为 UGUI 射线目标。

这里有个边界:非 ScreenSpaceOverlay Canvas 如果启用了 GraphicRaycaster.blockingObjects,Raycaster 会额外做 2D/3D Physics raycast,用 Collider 判断是否有物体挡在 UI 前面。Collider 不参与 Graphic 自身的矩形命中,但可能参与阻挡;另挂 PhysicsRaycaster 则是另一套世界物体命中路径。

HandlePointerExitAndEnter 默认沿父链处理 Hover。旧目标切到新目标时,它先从旧目标向上发 Move、Exit,停在新旧共同祖先之前;再从新目标向上发 Enter、Move,同样不重复通知共同祖先。移到空白时,会尝试清理整条 hovered 链。

"尝试"二字很重要。ExecuteEvents.GetEventList 会跳过 null、!activeInHierarchy 的 GameObject,也会跳过未启用的 Behaviour。悬停对象被禁用或销毁后,EventSystem 会清理缓存,但死对象自身不会可靠收到 OnPointerExit;仍有效的父对象可能收到。需要收尾的状态,应在 OnDisable、OnDestroy 或业务取消路径中兜住,不要只押在 Exit 上。

把一次正常拖拽的主线压缩一下,就是:命中
→ Down/缓存 Click 与 Drag
→ InitializePotentialDrag
→ Move
→ BeginDrag
→ 必要时提前 Up 并取消 Click
→ Drag
→ 释放时 Up、Drop、EndDrag。

普通点击则停在前半段,释放时通过 handler 匹配决定有没有 Click。


1. EventSystem.Update:谁在真正驱动这条流水线

上文说 EventSystem.Update() 选择 InputModule 并调 Process(),但没展开"选择"本身。这块逻辑全在 EventSystem.cs 的 Update(),有几个点你排查问题时必须心里有数:

  • 多 EventSystem 守卫:方法第一行就是 if (current != this) return;(494 行)。current 是静态列表 m_EventSystems 的第一个元素,组件 OnEnable 时把自己加进去(443 行)。也就是说,场景里如果有多个 EventSystem,只有静态列表排第一的那个会真正处理输入,其余的每帧直接 return。Editor 下还会打警告提醒只保留一个(542–544 行)。
  • 驱动两步走:TickModules()(475–483 行)每帧都跑,它只是逐个调 BaseInputModule.UpdateModule(),用来刷新内部状态(比如 StandaloneInputModule 记录 m_MousePosition)。之后才遍历 m_SystemInputModules 选当前模块,最后在"模块没切换"的前提下才调 Process()(529–530 行)。Tick 和 Process 是分开的——这解释了为什么失焦时可能 Tick 但不 Process。
  • 模块选择逻辑(498–527 行):优先选第一个 IsModuleSupported() && ShouldActivateModule() 为真的模块;若一轮选下来还是空,则退而求其次选第一个 IsModuleSupported() 的(forceModuleActive 的旧语义残留)。ChangeEventModule 会先 DeactivateModule() 旧模块再 ActivateModule() 新模块(548–559 行),ActivateModule 会把 firstSelectedGameObject 设为当前选中对象(254–258 行)。
  • 失焦的真相:OnApplicationFocus(bool)(485–490 行)在失焦时把 m_HasFocus 置 false,并立刻调一次 TickModules(),而不是 Process()。这就是上一篇说的"失焦时 UpdateModule 不一定补 Up+Click"的底层原因——失焦走的是 Tick 分支,Process 被 Process() 开头的 if (!eventSystem.isFocused && ShouldIgnoreEventsOnNoFocus()) return; 拦掉了(StandaloneInputModule.cs:272)。

2. RaycastAll 的多射线器模型

上一篇文章把镜头给到了 ExecuteHierarchy 和 ReleaseMouse,但"射线结果从哪来、谁排第一" 其实才是点不中、点错对象的根本原因。EventSystem.RaycastAll做三件事:

raycastResults.Clear();
var modules = RaycasterManager.GetRaycasters();   // 全局所有已注册射线器
for (...) module.Raycast(eventData, raycastResults); // 每个射线器往"同一个"list追加
raycastResults.Sort(s_RaycastComparer);             // 按统一比较器排序

关键点:所有射线器共享同一个 raycastResults 列表,不是各自独立命中。GraphicRaycaster(UI 画布)、PhysicsRaycaster / Physics2DRaycaster(世界物体)、PanelRaycaster(UI Toolkit)都往里塞自己的结果,最后统一排序。所以"UI 和世界物体谁在前"不是两套系统各算各的,而是一条统一的比较链决定的。

射线器怎么被收集的? RaycasterManager维护一个静态 List<BaseRaycaster>,BaseRaycaster 在 OnEnable/OnDisable 时自动 Add/Remove。换句话说,你在 Canvas 上挂一个 GraphicRaycaster 组件,它就被自动登记进了这条流水线,不需要任何手动注册。

排序优先级由 RaycastComparer决定,从先到后:

  1. module 不同 → 先比 eventCamera.depth;再比 module.sortOrderPriority;再比 module.renderOrderPriority;
  2. sortingLayer(用 SortingLayer.GetLayerValueFromID 换算);
  3. sortingOrder;
  4. depth(仅当两结果同属一个 rootCanvas,即 module.rootRaycaster 相同);
  5. distance;
  6. sortingGroupID/Order(仅 PACKAGE_PHYSICS2D 下);
  7. index(同一次 RaycastAll 内的追加次序)。

点中"最前面"的对象,不是简单地"离相机近"。ScreenSpaceOverlay 画布的 distance 恒为 0,它的优先度靠 sortOrderPriority(canvas.sortingOrder)和 renderOrderPriority(rootCanvas.renderOrder)顶上去——这也解释了为什么 Overlay 层通常能盖住一切。

BaseInputModule.FindFirstRaycast只取排序后第一个 gameObject != null 的结果(它只跳过 null,不校验 isValid),这就是 pointerCurrentRaycast 的"第一名"。


3. GraphicRaycaster 命中判定全貌

上一篇文章提到 GraphicRegistry.GetRaycastableGraphicsForCanvas,但 GraphicRaycaster.Raycast内部的判定比一句"取可射线 Graphic"复杂得多,逐条过一遍:

  • 入口守卫:canvas == null 直接返回;canvasGraphics 为空返回。
  • eventCamera 的三条返回路径:ScreenSpaceOverlay → 返回 null;ScreenSpaceCamera 且 worldCamera == null → 返回 null;否则 canvas.worldCamera ?? Camera.main。Overlay 模式没有相机,因此后续 distance 一律算成 0。
  • 多显示器过滤:用 eventData.displayIndex 与当前 canvas 的 targetDisplay 比对,非本显示的事件直接 return——这是多屏不串台的关键。
  • 视口裁剪:把屏幕点转成视口坐标后,超出 [0,1] 直接 return。
  • blockingObjects 物理阻挡:仅当非 Overlay 且 blockingObjects != None 时,才用 ReflectionMethodsCache.Singleton.raycast3D / getRayIntersectionAll 反射调用 Physics 做 3D/2D 射线,算出 hitDistance,后面任何 distance >= hitDistance 的 graphic 都被裁剪掉。这就是"UI 前面挡了个 3D/2D 碰撞体就点不穿"的实现。
    • 为什么用反射:ReflectionMethodsCache缓存了 Physics 方法的反射委托,配合 #if PACKAGE_PHYSICS 和 Singleton.raycast3D != null 判空,让 UGUI 不硬依赖 Physics 程序集——没引入 Physics 包时这段代码不会报编译或运行错误。
  • ignoreReversedGraphics:无相机(Overlay)时按 transform.forward 与 Vector3.forward 点积判断正反面;有相机时按与相机朝向的点积判断。背面的 graphic 直接丢弃。
  • 内部静态 Raycast:遍历 canvasGraphics,跳过 !raycastTarget、canvasRenderer.cull、depth == -1 的对象;用 RectTransformUtility.RectangleContainsScreenPoint 做矩形包含判定;再用 graphic.Raycast(pointerPosition, eventCamera)(Image 等可覆写的矩形/多边形判定,含 raycastPadding);收集完后按 depth 降序排序。
  • distance 计算:Overlay/无相机 = 0;否则用射线与平面交点公式;若 distance < 0(在相机背后)跳过。

最终每条命中被打包成 RaycastResult,附带 module / sortingLayer / sortingOrder / depth / distance / worldPosition / worldNormal 等字段,供上层排序和你的业务代码读取。


4. GraphicRegistry:可射线 Graphic 的真正来源

GraphicRaycaster 取用的 canvasGraphics 来自 GraphicRegistry。它是个单例,内部两张表:m_Graphics(画布下全部 Graphic)和 m_RaycastableGraphics(仅 raycastTarget == true 的)。RegisterGraphicForCanvas 在登记 Graphic 时会同时登记到射线集合(且只收 raycastTarget 为真的),DisableGraphicForCanvas / UnregisterGraphicForCanvas 负责维护。

一个 Graphic 想被 UGUI 射线命中,必须满足——是 Graphic 子类、raycastTarget = true、被所在 Canvas 的 GraphicRegistry 登记、depth != -1、未被 cull、且矩形命中。它不需要 Collider。这恰好是和 PhysicsRaycaster(靠世界物体 Collider)的根本分界线。


5. ExecuteEvents:事件派发的"零反射"内核

为什么 UGUI 派发这么快,ExecuteEvents.cs 的设计很值得讲:

  • 静态委托缓存:每种 handler 接口(如 IPointerDownHandler)都有一个 private static readonly EventFunction<T> s_XxxHandler = Execute;,Execute(handler, eventData) 直接调 handler.OnXxx(ValidateEventData<T>(eventData))。派发是强类型委托调用,没有 MethodInfo 反射查找的开销——这是 UGUI 每帧处理大量事件仍能扛住的根本原因。
  • GetEventList 的过滤规则:go == null 或 !go.activeInHierarchy 直接返回空;对每个组件用 ShouldSendToComponent判定 component is T && ((Behaviour)component).isActiveAndEnabled。这就是上一篇文章"跳过 null、!activeInHierarchy、未启用 Behaviour"的精确出处——注意必须是 isActiveAndEnabled,挂了脚本但把组件勾掉,照样收不到事件。
  • 对象池复用:Execute<T>(248–283 行)用 ListPool<IEventSystemHandler>.Get() 取列表,遍历调用,catch 异常只 Debug.LogException 不中断,结束 Release 回池——避免每帧分配/GC。
  • 两个"向上找"的区别:ExecuteHierarchy<T>(290–302 行)用静态 s_InternalTransformList(容量 30)收集 root 到根的整条父链,从 root 向上 Execute,第一个命中就返回并派发;GetEventHandler<T>(359–372 行)则是沿父链 CanHandleEvent 找第一个能处理的,只找不派发。
    • 回到上一篇的谜题:按下用 ExecuteHierarchy(找到 Down handler 立即发),Click 用 GetEventHandler(只定位,真正派发时直接 Execute 已选中的 pointerClick),Drop 用 ExecuteHierarchy(从抬起点向上找到第一个 Drop handler 发)。现在能精确解释"为什么 Click 不是再次 ExecuteHierarchy"——因为接收者早已在按下阶段锁定,释放阶段只是校验一致性。

6. PointerEventData:被低估的状态机

PointerEventData不是一个纯数据结构,它本身就是这次交互的状态机。几个字段的语义需搞清:

  • eligibleForClick:按下时置 true(ProcessMousePress / ProcessTouchPress),释放匹配成功消费后置 false,拖拽提前取消时也置 false。它是 Click 是否成立的唯一开关。
  • pointerPress 的双缓冲:pointerPress 的 setter 在赋新值前把旧值存进 lastPress。Click 双击判定 if (newPressed == lastPress) 用的就是它。
  • clickCount / clickTime / 双击阈值:双击阈值 doubleClickTime = 0.3f。同一个对象连续两次 press,间隔小于 0.3s 则 clickCount++,否则置 1。
  • pressPosition与拖拽阈值:拖拽是否启动由 ShouldStartDrag判定 (pressPos - currentPos).sqrMagnitude >= threshold²,threshold 来自 eventSystem.pixelDragThreshold(默认 10 像素)。
  • pointerPressRaycast:按下那一刻的射线结果,与 pointerCurrentRaycast 分开保存。光标移动后当前命中会变,但按下点的信息被保留下来专门用于 Click 一致性校验——这是"移出按钮松手没 Click"的判定依据。
  • useDragThreshold:可在 IInitializePotentialDragHandler.OnInitializePotentialDrag 里设 false,让该对象不受拖拽阈值限制,按下即视为可拖。
  • fullyExited / reentered:由 HandlePointerExitAndEnter 设置,告诉 handler 这次是"完全离开父链"还是"从子节点退回又重新进入父节点"。
  • IsPointerMoving / IsScrolling:只看 delta.sqrMagnitude / scrollDelta.sqrMagnitude 是否大于 0。ProcessMove 里的 Move 事件派发就靠它。

7. HandlePointerExitAndEnter:Hover 链的完整算法

上文说"默认沿父链处理 Hover",但没展开它怎么避免重复通知共同祖先。BaseInputModule.HandlePointerExitAndEnter是这个系列最绕的一个方法,三段式:

  1. 目标为 null,或旧 pointerEnter 为 null:给整个 hovered 链发 Move+Exit,清空 hovered,return。
  2. 目标没变:仅当 IsPointerMoving() 为真时,给 hovered 链发 Move——这就是上一篇"目标未变只有移动才发 Move"的出处。
  3. 目标变了:先 FindCommonRoot 找共同祖先,从旧 pointerEnter 向上发 Move+Exit 到共同祖先(不含)为止;再从新目标向上发 Enter+Move 到共同祖先(不含)为止,并维护 hovered 集合。

这里有个容易被忽略的开关 m_SendPointerHoverToParent(默认 true):

  • true 时,Hover/Enter 会沿父链一路冒泡,每个祖先都收到事件;
  • false 时,一旦遇到带 IPointerExitHandler / IPointerEnterHandler 的节点就停止向上传递。

实战含义:默认情况下,子物体收到 PointerEnter,它的父物体也会收到(因为父物体同样在父链上)。如果你只想让某个具体节点处理 hover 而不向上"污染"父级,要么把中间节点的事件脚本禁用,要么把 SendPointerHoverToParent 设为 false。

另外注意 Move 事件的时序:Exit 之前(旧链)和 Enter 之后(新链)都会发 Move。


8. 鼠标路径与触摸路径的差异与优先级

上一篇聚焦鼠标,但 StandaloneInputModule 同时管鼠标、键盘和触摸,Process()的顺序值得记牢:

bool usedEvent = SendUpdateEventToSelectedObject();          // 键盘/手柄的 UpdateSelected
if (!ProcessTouchEvents() && input.mousePresent)             // 先触摸(有模拟层),再鼠标
    ProcessMouseEvent();
if (eventSystem.sendNavigationEvents) { ... }               // 最后键盘导航 Move/Submit
  • 触摸优先:ProcessTouchEvents() 返回 input.touchCount > 0,有触摸就先处理,没触摸且 mousePresent 才走鼠标。这是因为触摸设备有一层"鼠标模拟"。
  • 触摸的 released 多一步 Exit:ProcessTouchPress 的释放分支除了发 Up/Click/Drop/EndDrag,还会 ExecuteHierarchy(pointerEvent.pointerEnter, pointerEvent, pointerExitHandler)给旧 pointerEnter 补一次 Exit——因为触摸"抬手"要模拟一次离开;而鼠标不会在 Up 时自动发 Exit,鼠标的 hover 链靠 ProcessMove → HandlePointerExitAndEnter 管理。这是两套路径最隐蔽的差异。
  • 指针字典与 id 语义:PointerInputModule 用 Dictionary<int, PointerEventData> m_PointerData按 id 存指针状态。鼠标用负数 id:kMouseLeftId = -1、kMouseRightId = -2、kMouseMiddleId = -3触摸用 fingerId(通常 ≥ 0)。IsPointerOverGameObject() 无参重载默认查 kMouseLeftId = -1,所以你对触摸调用它时必须传正确的 pointerId,否则一定误判"没点在 UI 上"。

9. BaseInput:可注入的"假输入"接口

排查或自动化测试时,你常想"喂一段假的鼠标输入"。UGUI 早就留了这个 seam。BaseInputModule.input优先用 m_InputOverride,否则自动找一个 BaseInput 类型的组件,没有就 AddComponent<BaseInput>()。BaseInput的所有方法都是虚的,默认转发到 UnityEngine.Input(mousePosition、GetMouseButtonDown 等)。

含义:想要自定义输入系统、网络同步输入或自动化测试,只需派生 BaseInput 重写虚方法并赋给 inputOverride,完全不用动 InputModule。这是 UGUI 可测试性的关键接缝。


10. EventTrigger:为什么"挂一个组件就能收所有事件"

EventTrigger一次性实现了全部 17 个 IEventSystemHandler 接口,所以 GetEventList 在它所在 GameObject 上一定能找到 handler。但源码注释里有一句醒目警告:

"Attaching this component will make that object intercept ALL events, and no events will propagate to parent objects."

原因很直接:冒泡到 EventTrigger 这一级就被 ExecuteHierarchy 命中返回了,父节点收不到。它内部用 List<Entry> triggers + EventTriggerType 枚举做数据驱动的 UnityEvent 派发(Execute 方法),本质是查表调用,不是反射。

建议:在"需要事件继续向上冒泡"的节点上谨慎挂 EventTrigger;它适合做叶子节点的交互捕获,不适合放在父容器上"顺便"收事件。


11. 注意点

  • 每帧 RaycastAll 是主要开销来源。虽然 GraphicRegistry 缓存了射线集合、m_RaycastResultCache 复用、ListPool 复用(避免 GC),但 Graphic 数量大或 Canvas 多时,逐个 graphic 的矩形/深度判定仍会积少成多。
  • raycastTarget 是性价比最高的优化点:只有需要交互的 Graphic 才勾 raycastTarget。Image 默认 true,纯装饰图务必关掉——Graphic.disableXXX 会同步维护 GraphicRegistry 的射线集合,关掉后它就不再参与射线。
  • IndexedSet 的设计意图:GraphicRegistry 用 IndexedSet<Graphic>(带禁用槽位)而非普通 List,是为了支持"软删除/恢复"而不触发重排,降低 GC 抖动。
  • 多 Canvas = 多射线器:每个 Canvas 一个 GraphicRaycaster,RaycastAll 会逐个 Raycaster.Raycast。Canvas 拆分要克制。
  • 失焦行为:ShouldIgnoreEventsOnNoFocus在 Editor 下看 EditorApplication.isRemoteConnected,真机/打包恒为 true。所以真机失焦时,UpdateModule 仅在 dragging 时才 ReleaseMouse,否则直接 return 并清空 m_InputPointerEvent——不补 Up/Click。
  • 多 EventSystem 是隐性雷:只有静态列表第一个处理;其余每帧空转。场景务必只保留一个(Editor 会警告)。

12. 总结点击流水线

一次点击从源码视角看,是这么流动的:

EventSystem.Update 守住"只有 current 处理"的闸门
→ StandaloneInputModule.Process 先触摸后鼠标
→ GetMousePointerEventData 里 RaycastAll 调所有 RaycasterManager 登记的射线器(Graphic/Physics/Panel),统一按 RaycastComparer 排出第一命中
→ 按下时 ExecuteHierarchy 找 Down handler、GetEventHandler 锁 Click/Drag handler,并记录 rawPointerPress / pointerPress / pointerClick / pointerPressRaycast
→ 移动走 HandlePointerExitAndEnter 维护 Hover 链(受 SendPointerHoverToParent 控制是否冒泡)
→ 越过阈值后 ProcessDrag 提前取消 Press 并取消 Click 资格
→ 释放时 ReleaseMouse 校验"抬起点 handler == 按下点 pointerClick 且 eligibleForClick"决定有没有 Click,再发 Drop / EndDrag。

—— 与君共勉~ ——

posted @ 2026-11-03 06:28  昂流  阅读(8)  评论(0)    收藏  举报
//替换成自己路径的js文件 hhttp(s)://static.tctip.com/tctip-1.0.4.min.js