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

本文把「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 里塞进了大量与输入链路相关的逻辑。下图把整条生命周期(含每帧循环与失焦分支)串起来:
主要可以关注三条主线:
- 注册线(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)。顺序很具体:
- 对缓存的
pointerPress直接发送OnPointerUp。 - 从抬起位置向上找到当前
IPointerClickHandler。 - 若它等于按下时缓存的
pointerClick,且eligibleForClick仍为 true,则对缓存对象直接发送OnPointerClick。 - 若正在拖拽,从抬起位置用
ExecuteHierarchy发送OnDrop。 - 清除 Press、Click 状态。
- 对缓存的
pointerDrag发送OnEndDrag,再清除 Drag 状态。 - 必要时刷新 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决定,从先到后:
module不同 → 先比eventCamera.depth;再比module.sortOrderPriority;再比module.renderOrderPriority;sortingLayer(用SortingLayer.GetLayerValueFromID换算);sortingOrder;depth(仅当两结果同属一个 rootCanvas,即module.rootRaycaster相同);distance;sortingGroupID/Order(仅PACKAGE_PHYSICS2D下);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是这个系列最绕的一个方法,三段式:
- 目标为 null,或旧
pointerEnter为 null:给整个hovered链发 Move+Exit,清空hovered,return。 - 目标没变:仅当
IsPointerMoving()为真时,给hovered链发 Move——这就是上一篇"目标未变只有移动才发 Move"的出处。 - 目标变了:先
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。
—— 与君共勉~ ——

浙公网安备 33010602011771号