Unity中的UGUI源码解析之事件系统(9)-输入模块(下)
接上一篇文章, 继续介绍输入模块.
StandaloneInputModule类是上一篇文章介绍的抽象类PointerInputModule的具体实现类, 事件系统的主要处理部分就在这个类.
TouchInputModule类本来是单独处理触摸指针事件的触摸事件部分, 但是后面被挪到StandaloneInputModule中了, 不在维护, 所以我们也不介绍了.
今天就和大家一起一步步来认识StandaloneInputModule.
StandaloneInputModule
目前在Unity中, 默认的输入模块就是StandaloneInputModule, 大部分事件处理过程也是这个模块, 我们也可以通过继承PointerInputModule并参考StandaloneInputModule实现自己的输入模块.
前面的文章介绍过, 如果场景中不存在EventSystem组件, 则在创建任意UI元素时, 会自动创建一个EventSystem对象, 上面默认带了两个组件: EventSystem和StandaloneInputModule, 在结合Canvas身上的Graphic Raycaster组件, 就构成了基本的事件系统.
面板属性
public class StandaloneInputModule : PointerInputModule
{
// ---------------------------------------------------------------------
// -- 几个轴名称, 用于Input.GetAxisRaw或者轴数据
[SerializeField] private string m_HorizontalAxis = "Horizontal";
[SerializeField] private string m_VerticalAxis = "Vertical";
[SerializeField] private string m_SubmitButton = "Submit";
[SerializeField] private string m_CancelButton = "Cancel";
// ---------------------------------------------------------------------
/// 每秒允许的键盘/控制器的输入次数
[SerializeField] private float m_InputActionsPerSecond = 10;
/// 判断重复按键的延迟秒数
[SerializeField] private float m_RepeatDelay = 0.5f;
/// 是否强制激活此模块
[SerializeField] [FormerlySerializedAs("m_AllowActivationOnMobileDevice")]
private bool m_ForceModuleActive;
}
对应的属性不再列出.
属性和字段
/// 上一个动作的发生的时间, 主要用于移动事件
private float m_PrevActionTime;
/// 上一个位移向量, 主要用于移动事件
private Vector2 m_LastMoveVector;
/// 连续移动次数
private int m_ConsecutiveMoveCount = 0;
/// 鼠标的上一个位置
private Vector2 m_LastMousePosition;
/// 鼠标的当前位置
private Vector2 m_MousePosition;
/// 当前击中对象
private GameObject m_CurrentFocusedGameObject;
工具函数
// 没有焦点时, 在某些操作系统上忽略事件处理
private bool ShouldIgnoreEventsOnNoFocus()
{
switch (SystemInfo.operatingSystemFamily)
{
case OperatingSystemFamily.Windows:
case OperatingSystemFamily.Linux:
case OperatingSystemFamily.MacOSX:
#if UNITY_EDITOR
if (UnityEditor.EditorApplication.isRemoteConnected)
return false;
#endif
return true;
default:
return false;
}
}
// 获取原始的位移向量方向(未被平滑处理)
private Vector2 GetRawMoveVector()
{
Vector2 move = Vector2.zero;
move.x = input.GetAxisRaw(m_HorizontalAxis);
move.y = input.GetAxisRaw(m_VerticalAxis);
if (input.GetButtonDown(m_HorizontalAxis))
{
if (move.x < 0)
move.x = -1f;
if (move.x > 0)
move.x = 1f;
}
if (input.GetButtonDown(m_VerticalAxis))
{
if (move.y < 0)
move.y = -1f;
if (move.y > 0)
move.y = 1f;
}
return move;
}
重写的函数
// 更新输入模块
public override void UpdateModule()
{
// 过滤非焦点状态
if (!eventSystem.isFocused && ShouldIgnoreEventsOnNoFocus())
return;
// 记录鼠标的上一个位置和当前位置
m_LastMousePosition = m_MousePosition;
m_MousePosition = input.mousePosition;
}
// 获取当前状态是否受支持(能不能处理事件)
public override bool IsModuleSupported()
{
// 强制支持或者支持鼠标或者支持触摸
return m_ForceModuleActive || input.mousePresent || input.touchSupported;
}
// 是否应该激活模块, 切换输入模块时使用
public override bool ShouldActivateModule()
{
// 游戏对象激活并且层级激活
if (!base.ShouldActivateModule())
return false;
// 激活状态(任意一个都标识激活)
var shouldActivate = m_ForceModuleActive; // 强制
shouldActivate |= input.GetButtonDown(m_SubmitButton); // 提交键被按下(比如回车)
shouldActivate |= input.GetButtonDown(m_CancelButton); // 取消键被按下(比如Esc)
shouldActivate |= !Mathf.Approximately(input.GetAxisRaw(m_HorizontalAxis), 0.0f); // 存在水平位移
shouldActivate |= !Mathf.Approximately(input.GetAxisRaw(m_VerticalAxis), 0.0f); // 存在竖直位移
shouldActivate |= (m_MousePosition - m_LastMousePosition).sqrMagnitude > 0.0f; // 鼠标位置有变化
shouldActivate |= input.GetMouseButtonDown(0); // 左键被按下
// 多点触摸必定激活
if (input.touchCount > 0)
shouldActivate = true;
return shouldActivate;
}
// 激活模块, 切换模块时使用
public override void ActivateModule()
{
// 过滤非焦点状态
if (!eventSystem.isFocused && ShouldIgnoreEventsOnNoFocus())
return;
base.ActivateModule();
// 记录鼠标两个位置
m_MousePosition = input.mousePosition;
m_LastMousePosition = input.mousePosition;
var toSelect = eventSystem.currentSelectedGameObject;
if (toSelect == null)
toSelect = eventSystem.firstSelectedGameObject;
// 向焦点对象发送选中事件
eventSystem.SetSelectedGameObject(toSelect, GetBaseEventData());
}
// 反激活模块, 清空各种状态
public override void DeactivateModule()
{
base.DeactivateModule();
ClearSelection();
}
// 事件处理(选择更新/导航/触摸/鼠标)
public override void Process()
{
// 过滤非焦点状态
if (!eventSystem.isFocused && ShouldIgnoreEventsOnNoFocus())
return;
// 向焦点对象发送updateSelected事件
bool usedEvent = SendUpdateEventToSelectedObject();
// 导航事件处理(位移, 提交, 取消)
if (eventSystem.sendNavigationEvents)
{
// 如果对象不自行处理updateSelected事件, 则进一步向焦点对象发送位移(主要是水平和竖直的轴事件)事件
if (!usedEvent)
usedEvent |= SendMoveEventToSelectedObject();
// 如果对象不自行处理updateSelected和位移事件, 则进一步向焦点对象发送剩下的导航事件(提交和取消)
if (!usedEvent)
SendSubmitEventToSelectedObject();
}
// 处理触摸事件和鼠标事件
if (!ProcessTouchEvents() && input.mousePresent)
ProcessMouseEvent();
}
选择更新事件
// 向当前焦点对象发送选择更新事件(updateSelected)
protected bool SendUpdateEventToSelectedObject()
{
if (eventSystem.currentSelectedGameObject == null)
return false;
var data = GetBaseEventData();
ExecuteEvents.Execute(eventSystem.currentSelectedGameObject, data, ExecuteEvents.updateSelectedHandler);
return data.used;
}
导航事件
当选择更新事件的处理器没有截获事件(BaseEventData.used != true ), 同时EventSystem支持导航事件, 就需要处理导航事件, 导航事件指的的是: 位移(Move), 提交(Submit), 取消(Cancel), 都在Project Settings->Input 中设置.
// 处理导航事件中的位移事件(主要是水平和竖直方向上的轴事件)
protected bool SendMoveEventToSelectedObject()
{
float time = Time.unscaledTime;
// 获取当前位移方向
Vector2 movement = GetRawMoveVector();
//过滤微小位移
if (Mathf.Approximately(movement.x, 0f) && Mathf.Approximately(movement.y, 0f))
{
m_ConsecutiveMoveCount = 0;
return false;
}
// 只有按下[位移按钮]才处理
bool allow = input.GetButtonDown(m_HorizontalAxis) || input.GetButtonDown(m_VerticalAxis);
// 两次位移基本同向
bool similarDir = (Vector2.Dot(movement, m_LastMoveVector) > 0);
if (!allow)
{ // 长按[位移按钮]
// 同方向且连续次数为1, 说明是按下[位移按钮]后第一个长按判断, 等待延时后当做重复处理
if (similarDir && m_ConsecutiveMoveCount == 1)
allow = (time > m_PrevActionTime + m_RepeatDelay);
else // 已经进入重复按键, 等待延时处理
allow = (time > m_PrevActionTime + 1f / m_InputActionsPerSecond);
}
// 不处理位移事件
if (!allow)
return false;
// 根据位移封装轴事件数据
var axisEventData = GetAxisEventData(movement.x, movement.y, 0.6f);
// 有位移方向则开始处理位移事件
if (axisEventData.moveDir != MoveDirection.None)
{
// 向当前焦点对象发送位移事件
ExecuteEvents.Execute(eventSystem.currentSelectedGameObject, axisEventData, ExecuteEvents.moveHandler);
// 两次位移方向不同, 清空连续事件次数
if (!similarDir)
m_ConsecutiveMoveCount = 0;
// 两次位移方向相同, 增加连续事件次数
m_ConsecutiveMoveCount++;
// 记录上次位移时间
m_PrevActionTime = time;
// 记录上次位移方向
m_LastMoveVector = movement;
}
else
{ // 两次位移方向不同, 清空连续事件次数
m_ConsecutiveMoveCount = 0;
}
return axisEventData.used;
}
处理触摸事件
我们在上面的Process方法中看到, Unity优先处理触摸事件, 如果有触摸事件被处理, 则略过鼠标事件的处理.
private bool ProcessTouchEvents()
{
// 支持多点触控, 分别处理多个触摸, 大部分情况下只需要处理一个
for (int i = 0; i < input.touchCount; ++i)
{
Touch touch = input.GetTouch(i);
// 只处理直接来自设备的触摸(TouchType.Direct)和来自触控笔的触摸(TouchType.Stylus)
if (touch.type == TouchType.Indirect)
continue;
bool released;
bool pressed;
// 构造触摸事件数据, 检测出被触摸的对象(pointerData.pointerCurrentRaycast)
var pointer = GetTouchPointerEventData(touch, out pressed, out released);
// 处理触摸按下
ProcessTouchPress(pointer, pressed, released);
// 没有抬起的状态下, 处理移动和拖拽事件, 这两个事件处理和鼠标事件保持一致, 抽象为Poninter事件统一处理
if (!released)
{
ProcessMove(pointer);
ProcessDrag(pointer);
}
else // 移除触摸事件
RemovePointerData(pointer);
}
return input.touchCount > 0;
}
// 处理触摸按下
protected void ProcessTouchPress(PointerEventData pointerEvent, bool pressed, bool released)
{
var currentOverGo = pointerEvent.pointerCurrentRaycast.gameObject;
// 处理和分发按下事件
if (pressed)
{
// 赋值用于按下事件的各种数值
pointerEvent.eligibleForClick = true;
pointerEvent.delta = Vector2.zero;
pointerEvent.dragging = false;
pointerEvent.useDragThreshold = true;
pointerEvent.pressPosition = pointerEvent.position;
pointerEvent.pointerPressRaycast = pointerEvent.pointerCurrentRaycast;
// 选中按下的对象
DeselectIfSelectionChanged(currentOverGo, pointerEvent);
// 分发进入事件和设置进入对象
if (pointerEvent.pointerEnter != currentOverGo)
{
// send a pointer enter to the touched element if it isn't the one to select...
HandlePointerExitAndEnter(pointerEvent, currentOverGo);
pointerEvent.pointerEnter = currentOverGo;
}
// 在当前对象和其所有的父级对象上查找拥有ponterDown事件处理器的对象
var newPressed = ExecuteEvents.ExecuteHierarchy(currentOverGo, pointerEvent, ExecuteEvents.pointerDownHandler);
// 如果没有找到则使用当前对象身上的pointerClick
if (newPressed == null)
newPressed = ExecuteEvents.GetEventHandler<IPointerClickHandler>(currentOverGo);
// Debug.Log("Pressed: " + newPressed);
float time = Time.unscaledTime;
if (newPressed == pointerEvent.lastPress)
{ // 上次和本次的点击对象相同(也可能都是空对象), 记录点击次数和时间, 如果两次点击间隔少于0.3s, 则增加点击次数
var diffTime = time - pointerEvent.clickTime;
if (diffTime < 0.3f)
++pointerEvent.clickCount;
else
pointerEvent.clickCount = 1;
pointerEvent.clickTime = time;
}
else
{ // 否则初始化点击次数为1
pointerEvent.clickCount = 1;
}
// 记录[按下对象](有pointerDown或者当前对象上有pointerClick处理器)
pointerEvent.pointerPress = newPressed;
// 记录原始按下对象
pointerEvent.rawPointerPress = currentOverGo;
pointerEvent.clickTime = time;
// 记录ponterDrag对象(当前对象上有ponterDrag处理器)
pointerEvent.pointerDrag = ExecuteEvents.GetEventHandler<IDragHandler>(currentOverGo);
// 向ponterDrag对象分发initializePotentialDrag事件
if (pointerEvent.pointerDrag != null)
ExecuteEvents.Execute(pointerEvent.pointerDrag, pointerEvent, ExecuteEvents.initializePotentialDrag);
}
// 处理和分发抬起事件
if (released)
{
// 向[按下对象]分发抬起事件
ExecuteEvents.Execute(pointerEvent.pointerPress, pointerEvent, ExecuteEvents.pointerUpHandler);
// 用于判断按下对象与处理PointerClick的对象是不是同一个
var pointerUpHandler = ExecuteEvents.GetEventHandler<IPointerClickHandler>(currentOverGo);
if (pointerEvent.pointerPress == pointerUpHandler && pointerEvent.eligibleForClick)
{ // 如果是同一个对象且同时标记了点击(没有被拖拽打断), 则分发pointerClick事件
ExecuteEvents.Execute(pointerEvent.pointerPress, pointerEvent, ExecuteEvents.pointerClickHandler);
}
else if (pointerEvent.pointerDrag != null && pointerEvent.dragging)
{ // 向当前对象和其父级对象分发drop事件(拖拽过程中抬起)
ExecuteEvents.ExecuteHierarchy(currentOverGo, pointerEvent, ExecuteEvents.dropHandler);
}
// 清空按下数据和状态
pointerEvent.eligibleForClick = false;
pointerEvent.pointerPress = null;
pointerEvent.rawPointerPress = null;
// 向要处理[pointerDrag]的对象分发拖拽结束事件
if (pointerEvent.pointerDrag != null && pointerEvent.dragging)
ExecuteEvents.Execute(pointerEvent.pointerDrag, pointerEvent, ExecuteEvents.endDragHandler);
// 清空拖拽数据和状态
pointerEvent.dragging = false;
pointerEvent.pointerDrag = null;
// 向要处理[pointerEnter]的对象分发pointerExit事件
ExecuteEvents.ExecuteHierarchy(pointerEvent.pointerEnter, pointerEvent, ExecuteEvents.pointerExitHandler);
pointerEvent.pointerEnter = null;
}
}
因为这段是比较核心的, 我们在简单做个归纳.
通过上面的代码, 我们知道了Unity先处理触摸事件, 然后才处理鼠标事件, 然后将整个触摸过程细化, 分成几个小的状态, 然后分别记录数据和处理事件, 最后分发事件.
整个触摸过程中, 我们需要处理的主要状态和事件如下(注意, 以下的事件都是只要有任何的处理器, 则其它对象(父级对象)上的处理器不能再处理):
- 触摸按下
- 分发之前对象的离开事件和当前对象的选中事件和当前对象的反选事件(ISelectHandler, IDeselectHandler)
- 分发之前对象的离开事件和当前对象的进入事件(IPointerEnterHandler, IPointerExitHandler)
- 记录进入对象(pointerEnter)
- 分发当前对象或者其父级对象上的按下事件(IPointerDownHandler)
- 记录拖拽对象(pointerDrag)
- 分发拖拽对象上的拖拽开始事件(IInitializePotentialDragHandler)
- 记录当前事件按下的对象(可能是当前对象或者其父级对象, pointerPress), 按下次数, 按下时间等
- 触摸抬起
- 分发当前事件按下对象的抬起事件(IPointerUpHandler)
- 如果满足条件, 分发按下对象的抬起事件点击事件(IPointerClick)或者分发拖拽抬起事件(IDropHandler)
- 分发拖拽对象上的拖拽结束事件(IEndDragHandler)
- 分发进入对象上的离开事件(IPointerExitHandler)
- 触摸移动(后面介绍)
再简化一点:
触摸屏幕: 找出被触摸到的对象->分发反选和选中事件->分发离开和进入事件->分发按下事件->分发拖拽开始事件.
开始移动: 处理移动(导航)->处理拖拽(记录拖拽状态->分发触摸开始->取消按下状态->分发触摸中).
抬起手指: 分发抬起事件->分发点击或者拖拽抬起事件->分发拖拽结束事件->分发离开事件.
处理鼠标事件
鼠标事件的内容比较多, 我们从简单到复杂分别介绍.
触发处理
在没有触摸事件需要处理, 同时又检测到鼠标设备的时候, 触发鼠标事件的处理.
然后构造鼠标事件数据, 在这个过程中查找出了被击中的对象.
最后按照左右中键的顺序分别处理每个按键的各种状态.
其中左键有进出状态, 其它两个键只有按下(包含弹起)和拖拽的状态
// 事件处理(选择更新/进出/触摸/鼠标)
public override void Process()
{
// ...
// 处理触摸事件和鼠标事件
if (!ProcessTouchEvents() && input.mousePresent)
ProcessMouseEvent();
}
protected void ProcessMouseEvent(int id)
{
// 构造鼠标事件, 检测出被击中的对象(pointerData.pointerCurrentRaycast)
var mouseData = GetMousePointerEventData(id);
var leftButtonData = mouseData.GetButtonState(PointerEventData.InputButton.Left).eventData;
m_CurrentFocusedGameObject = leftButtonData.buttonData.pointerCurrentRaycast.gameObject;
// 处理左键(按下-抬起, 进出, 拖拽)
ProcessMousePress(leftButtonData);
ProcessMove(leftButtonData.buttonData);
ProcessDrag(leftButtonData.buttonData);
// 处理右键(按下-抬起, 拖拽)
ProcessMousePress(mouseData.GetButtonState(PointerEventData.InputButton.Right).eventData);
ProcessDrag(mouseData.GetButtonState(PointerEventData.InputButton.Right).eventData.buttonData);
// 处理中键(按下-抬起, 拖拽)
ProcessMousePress(mouseData.GetButtonState(PointerEventData.InputButton.Middle).eventData);
ProcessDrag(mouseData.GetButtonState(PointerEventData.InputButton.Middle).eventData.buttonData);
// 分发滚轮事件
if (!Mathf.Approximately(leftButtonData.buttonData.scrollDelta.sqrMagnitude, 0.0f))
{
var scrollHandler = ExecuteEvents.GetEventHandler<IScrollHandler>(leftButtonData.buttonData.pointerCurrentRaycast.gameObject);
ExecuteEvents.ExecuteHierarchy(scrollHandler, leftButtonData.buttonData, ExecuteEvents.scrollHandler);
}
}
处理进出事件
只有鼠标未锁定时才处理进出事件, 其它已经介绍过, 不再赘述.
protected virtual void ProcessMove(PointerEventData pointerEvent)
{
var targetGO = (Cursor.lockState == CursorLockMode.Locked ? null : pointerEvent.pointerCurrentRaycast.gameObject);
HandlePointerExitAndEnter(pointerEvent, targetGO);
}
处理拖拽事件
与上面一样, 只有鼠标未锁定时才处理拖拽事件.
protected virtual void ProcessDrag(PointerEventData pointerEvent)
{
// 拖拽事件处理条件(有位移, 鼠标未锁定, 有拖拽对象)
if (!pointerEvent.IsPointerMoving() ||
Cursor.lockState == CursorLockMode.Locked ||
pointerEvent.pointerDrag == null)
return;
// 分发拖拽开始事件(IBeginDragHandler), 并设置拖拽状态
// 条件: 未处于拖拽状态, 超过最小位移判断
if (!pointerEvent.dragging
&& ShouldStartDrag(pointerEvent.pressPosition, pointerEvent.position, eventSystem.pixelDragThreshold, pointerEvent.useDragThreshold))
{
ExecuteEvents.Execute(pointerEvent.pointerDrag, pointerEvent, ExecuteEvents.beginDragHandler);
pointerEvent.dragging = true;
}
// 分发抬起和拖拽事件
if (pointerEvent.dragging)
{
// 如果按下的和拖拽的不是同一个对象, 那么需要取消按下对象的按下状态, 向其分发抬起事件并清空按下的相关数据
// 也就是说, 同一个对象上可以同时处理点击和拖拽, 如果不同对象, 只要拖拽, 点击就无法触发了
// ScrollRect就是利用了这一点来实现拖拽和内部的点击互不影响!
// 如果需要在同一个对象上可以同时处理点击和拖拽, 而且在拖拽之后不要触发点击, 则可以参考这里的代码, 在拖拽回调中设置一些信息来屏蔽后续的点击事件触发, 如置空pointerPress或者设置eligibleForClick为false
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);
}
}
处理按下(包含抬起)事件
处理鼠标按下抬起事件的过程和代码与处理触摸的高度一致, 这里只贴不同的地方, 重复的地方不在赘述.
protected void ProcessMousePress(MouseButtonEventData data)
{
var pointerEvent = data.buttonData;
var currentOverGo = pointerEvent.pointerCurrentRaycast.gameObject;
if (data.PressedThisFrame())
{
// ...
// 分发进入事件和设置进入对象, 鼠标处理没有这部分
// if (pointerEvent.pointerEnter != currentOverGo)
// {
// send a pointer enter to the touched element if it isn't the one to select...
// HandlePointerExitAndEnter(pointerEvent, currentOverGo);
// pointerEvent.pointerEnter = currentOverGo;
//}
// ...
}
// PointerUp notification
if (data.ReleasedThisFrame())
{
// ...
// 避免错误, 刷新进出状态
if (currentOverGo != pointerEvent.pointerEnter)
{
HandlePointerExitAndEnter(pointerEvent, null);
HandlePointerExitAndEnter(pointerEvent, currentOverGo);
}
}
}
因为整个过程与触摸的处理类似, 最后我们也做一个简化理解:
点击屏幕或者拖拽: 找出被点击到的对象并收集三个按钮的数据, 分别处理左键右键中键
左键(按下抬起, 进出, 拖拽)
右键(按下抬起, 拖拽)
中键(按下抬起, 拖拽)
按下抬起:
按下: 分发反选和选中事件->分发按下事件->分发拖拽开始事件
抬起: 分发抬起事件->分发点击或者拖拽抬起事件->分发拖拽结束事件->分发离开事件.
拖拽:记录拖拽状态->分发触摸开始事件->取消按下状态->分发触摸中事件.
总结
今天介绍的是事件系统中最重要的标准输入模块部分. 虽然各种事件各种条件五花八门, 但是相信经过整个系列的拆分和分析, 理解起来并没有什么难度.
经过将近一个月的学习和分享, 我们终于完成了整个事件系统源码的解析. 这对于我本人来说也算是一个小小的挑战, 庆幸的是最终还是完成了.
以前我虽然也大致看过这一部分的源码, 但是没有这么详细, 都是带着需求和问题去搜寻, 借这次机会终于大致将这部分啃下来了, 对整体的轮廓和关键的细节有了一定的掌握, 相信在未来的开发中能够让我少走很多弯路.
这段我专门了解了下, 很多同学对这些源码, 原理不怎么感兴趣, 希望我出一些实战和入门的文章, 我的意见是这些方面已经有很多优秀的作者写了很多文章, 包括我本人也因此受益良多, 但是源码和原理方面的文章还是比较少, 而我想在这方面做一份贡献, 所以未来很长一段时间还是会专注源码的学习和解读. 最多在过程中会穿插一些开发技巧类或者渲染方面的文章. 所以不管有多少同学感兴趣, 我还是会尽量坚持下去, 给有兴趣有缘的同学一些参考, 大家共同学习进步.
UGUI源码解析大概会分为三部分:
我们已经完成了第一部分, 接下来会进入源码解析的第二部分, 即UGUI常用组件源码解析.
相信整个系列下来, 我和大家都能对UGUI有很多新的认识, 以便在日常的开发中有的放矢, 心中有数.
顺便说一下, 下一个部分有很多内容看不到源码, 是写到C++里的, 我只能尽量根据表现来猜测, 不能保证正确.
好了, 今天就是这些, 希望对大家有所帮助.
|