让思绪多留一会儿

使用Hexo制作的网站

0%

手指基础事件

基础事件由操作系统提供

  • TouchBegan
  • TouchMoved
  • TouchEnded
  • TouchCanceled

手势优先级

  • TapDownEvent TapUpEvent TapCancelEvent
  • TapEvent(Clicked)
  • LongTapEvent(LongPress)
  • Drag
  • MultiDrag

手势识别器类图

手势识别器类图

手势识别过程

假设按钮按如下顺序摆放

按钮摆放示意图

当手指在在Button3按下后。Button3的TapGestureRecognizer添加到Touch的识别列表中,再将DragGestureRecognizer添加到Touch的识别列表中;Button2的TapGestureRecognizer添加到Touch的识别列表中,再将DragGestureRecognizer添加到Touch的识别列表中;Button1的TapGestureRecognizer添加到Touch的识别列表中,再将DragGestureRecognizer添加到Touch的识别列表中。

Touch的识别列表如下,顶部为队列头部,底部为队列尾端。

Touch中的识别器列表示意图

手指基础事件会经过这些识别器进行识别。按照List的顺序,每个识别器都会去识别手势。

TouchBegan发生后。Button3.TapGestureRecognizer识别到TapDownEvent。Button3.DragGestureRecognizer觉得手势可能会在将来触发。接着Button2.TapGestureRecognizer识别到TapDownEvent。Button2.DragGestureRecognizer觉得手势可能会在将来触发。接着Button1.TapGestureRecognizer识别到TapDownEvent。Button1.DragGestureRecognizer觉得手势可能会在将来触发。识别器识别完成后,Touch再挨个调用Recognizer的Accept函数。挨个发射Event。最后UI上的3个按钮都触发了TapDownEvent。

接着,玩家在Button3上抬起了手指,系统会触发TouchEnded。Button3.TapGestureRecognizer识别到TapUpEvent,也识别到了TapEvent。Button3.DragGestureRecognizer没有识别到手势。接着Button2.TapGestureRecognizer识别到TapUpEvent,也识别到了TapEvent。Button2.DragGestureRecognizer没有识别到手势。接着Button1.TapGestureRecognizer识别到TapUpEvent,也识别到了TapEvent。Button1.DragGestureRecognizer没有识别到手势。

现在3个识别器都识别到了TapUpEvent和TapEvent。TapUpEvent比较好处理。每个识别器都发射TapUpEvent就好了。但是TapEvent就不一样了。正常逻辑来讲,3个按钮不可能都触发TapEvent,只能有1个TapEvent被触发,其余的都应该当作没发生过。

这里需要引入手指竞争概念。TapEvent算是高级手势,如果TapEvent被识别到,识别器可以独占手指。其它识别器将放弃识别。我们可以在Recognizer的返回值上做文章。识别器之间需要竞争。

EGestureRecognizerResult(Recognizer返回值)

  • Ignore(识别器忽略手指,退出手势竞争)
  • MayBeGesture(识别器认为手指将来会触发手势,告诉调用者保留自己继续识别的权力)
  • TriggerGesture(识别器识别到手势,如果自己没有被淘汰,则应该触发手势)
  • CancelGesture(识别器识放弃竞争,调用者将自己淘汰。比如控件被禁用了)

下面两个是抢占手势的两种情况,会与上面的4个枚举或运算后返回

  • ConsumeEventHint
  • ConsumeGestureHint

这两个都是通知调用者识别器将占用手指,让调用者淘汰掉其它的识别器。区别是ConsumeEventHint表示自己识别成功。而ConsumeGestureHint表示自己是因为特殊情况,一定要占用手指,在多点触控的时候用得上。举个例子,按钮已经触发DragEvent了,这时玩家把新的手指放到了按钮上,新的手指应该直接被Drag识别器捕获,并且竞争成功。

再次讨论手势识别过程

当Tap或Drag或LongTap被识别后,他们都会返回 TriggerGesture|ConsumeEventHint 或者 TriggerGesture|ConsumeGestureHint。Touch得到每个识别器的结果后,会判断哪一个识别器竞争成功。识别列表可以当作优先级。列表从前往后,如果返回值包含ConsumeGestureHint,第一个返回ConsumeGestureHint会直接成功,否则第一个返回包含ConsumeEventHint会竞争成功。竞争成功后,其余所有识别器调用Reset函数,将刚才的识别取消掉。识别完成后,再调用Accept函数将最终的识别结果执行下去。

在本文的例子中,由于Button3的Tap手势被识别。Button1和Button2的TapGestureRecognizer竞争失败而退出。他们调用Reset后将原来的TapUpEvent转变为TapCancelEvent。在Accept调用里,将发出TapCancelEvent。为什么这么设计呢,主要时考虑到这样的方式可以顺便通知别的控件,手指被别人占有了,起到提示作用。这些控件也确实彻底丧失了这个手指,但是手指并没有离开屏幕,所以TapCancelEvent看上去更合适一点。

读到这里,细心的人会发现一个问题。如果TapEvent被识别后,该控件的TapUpEvent将来会不会触发?在有些框架里,TapUpEvent不会再触发了。但我觉得这样的设计并不好。大家都作用与统一个控件,TapUpEvent应该触发。所以我修改了竞争机制,最后会保留竞争成功的识别器和TapGestureRecognizer。以此保证TapUpEvent被正确触发。

关于多指操作

ConsumeGestureHint就是处理多指操作的,如果识别器是单指识别器,可以将多余的手指直接占有,避免其他控件触发手势导致奇怪的逻辑表现。

如果是双指缩放这类手势事件,当单个手指进入识别器时,返回MayBeGesture。当第二个手指进入识别器时,返回TriggerGesture|ConsumeEventHint。系统下一帧遍历到第一个手指时,由于识别器已经是Trigger状态,所以第一个手指的识别器返回TriggerGesture|ConsumeGestureHint,尝试让自己竞争成功,不会被别的控件占有。当有2个手指都竞争成功后,就可以在Accept中发射双指手势事件了。为什么一定要等到这个时候才能发射手势事件呢?毕竟能不能占有到手指还是Touch说了算,识别器不能保证一定能竞争成功,所以要等到确实占有两个手指时,才能发射手势事件。

多指情况示例

按钮按如下摆放

按钮摆放示意图

用户的第一次按下的手指,简称Touch1。用户的第二次按下的手指,简称Touch2。

例1:假设我们在Button3上我们绑定了LongTap,并且在Button1上我们绑定了MultiDrag。我们假设Touch1在Button3上按下,并且之后没有移动。2秒后,Touch2在Button3上按下。接着两根手指都抬起。Touch1在按下0.5秒后,就会触发LongTap,识别器返回TriggerGesture|ConsumeEventHint。当Touch2按下后,Touch2会按正常流程对让每一个识别器进行识别。Button3的LongTap识别器返回TriggerGesture|ConsumeGestureHint,表示自己已经触发手势了,希望能够直接占有该手指。这时候Touch2经过优先级判断后,也确实会将手指分给LongTap识别器。我们用这样的方式解决多手指在单手指手势中的冲突。

例2:我们假设Touch1在Button3上按下,并且之后没有移动。0.1秒后,Touch2在Button3上按下。接着两根手指都抬起。按照流程,Button3的LongTapGestureRecognizer并没有识别到任何手势,所有手势识别器返回的都是MayBeGesture。当第二个手指按下时,Button3的识别器依然返回MayBeGesture。Button1的MultiDragGestureRecognizer会返回TriggerGesture|ConsumeEventHint,这是由于识别器列表中已经有一个手指处于未占有状态,加上这根手指,就满足多指拖拽的手势识别。Touch会将Touch2分配给MultiDragGestureRecognizer。但这时候,还不能触发MultiDrag,因为Touch1没有被识别器占有。等到下一帧,我们遍历到Touch1时,Button1的MultiDragGestureRecognizer会返回TriggerGesture|ConsumeGestureHint,用最高优先级来占有手指。占有到后,触发MultiDrag。一定要用ConsumeGestureHint来占有手指,这样就就算Touch1刚好触发了别的手势,也会被Button3的MultiDragGestureRecognizer识别器占有。

多指不完美的地方

在上边的示例中,两个手指都是从Button3的位置按下的。如果Touch1在Button3上按下,2秒后,Touch2在Button2上按下。Touch2的识别列表中并不会有Button3的任何识别器。这时候Touch2就不会被Button3的LongTapGestureRecognizer占有。假如Button1也绑定了LongTap。就会出现这样的情况,Button3的LongTap在Touch1按下0.5秒后触发。Button1的LongTap在Touch2按下0.5秒后触发。会显得混乱。不过实际应用中,这样摆放按钮的情况几乎不存在,可以不用顾虑。

贴一下我的知乎地址,欢迎大家来讨论。点击前往

先说个结论。在Unity里,用MonoBehaviour去实现资源的自动释放是不可行的。

事情的起因是这样的,游戏界面中需要根据逻辑去设置图标。图标不是直接拼在界面上的,需要动态加载。既然是动态加载的,就必定涉及到资源释放。我的想法很简单,在MonoBehaviour的OnDestroy中进行资源释放。但是游戏运行时,资源依然没有正确释放。于是我对MonoBehaviour的生命周期进行了测试。以下是示意代码:

AutoRelease.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class AutoRelease : MonoBehaviour
{
private void Awake()
{
Debug.Log("AutoRelease Awake");
}

private void Start()
{
Debug.Log("AutoRelease Start");
}

private void OnDestroy()
{
Debug.Log("AutoRelease OnDestroy");
}
}

AutoReleaseTest.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class AutoReleaseTest : MonoBehaviour
{

public GameObject prefab;
public GameObject parent;

private GameObject newObject;
int frameNum = 0;
void Start()
{
newObject = GameObject.Instantiate<GameObject>(prefab, parent.transform);
newObject.AddComponent<AutoRelease>();
}

void Update()
{
frameNum++;
if (frameNum == 100)
{
GameObject.Destroy(newObject);
newObject = null;
}
}
}

代码逻辑很简单,根据Prefab实例化一个物体,将它创建在parent节点下。经过100帧之后,删除它。接着,我们造一个测试用例。

场景示例

一切准备就绪,点击运行。Prefab启动后出现在Cube节点下,经过100帧后消失。表现完全符合我们的预期。可是Console里却是空空如也。

大家可以按照我文中的例子自己动手尝试。我的环境是Unity2020.3.9

贴一下我的知乎地址,欢迎大家来讨论。点击前往

环境是Lua5.3。

需求

当table已存在的key/value被修改时,报错提示。当table新增的key/value时,报错提示。其他时候,能够像普通的table一样使用。

简单实现

建一个空的table,设置元方法__index,读数据时从原来的table读。再设置__newindex元方法,方法内调用error函数。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
local metatable = {}

function metatable.__index(ro_table, key)
return table.____ro_raw_table[key]
end

function metatable.__newindex(ro_table, key, value)
error(string.format("inaccessible due to its readonly. key = %s, value = %s", key, value))
end

function read_only(data)
local retValue = {
____ro_raw_table = data
}
setmetatable(retValue, metatable)
return retValue
end

调用read_only函数后,我们得到了一个新的table。我们去访问原来的data中的key时,由于retValue没有这个索引,就会触发__index元方法调用。当我们尝试修改retValue的某个值,因为retValue没有对应的索引,所以会触发__newindex元方法。这里发现我们retValue里有____ro_raw_table这个变量,我主要是想用这个变量来判断表是不是一个只读表。我们可以对read_only做个优化,如果一个表已经是只读的了,直接返回就行了,避免套娃行为。

简单优化

优化一下read_only,代码如下:

1
2
3
4
5
6
7
8
9
10
function read_only(data)
if type(data) ~= "table" or data.____ro_raw_table then
return data
end
local retValue = {
____ro_raw_table = data
}
setmetatable(retValue, metatable)
return retValue
end

__index也可以优化一下,同时对返回值做一下处理。代码如下:

1
2
3
4
5
6
7
function metatable.__index(ro_table, key)
local value = ro_table.____ro_raw_table[key]
if value == nil and type(key) == "table" and key.____ro_raw_table then
value = ro_table.____ro_raw_table[key.____ro_raw_table]
end
return read_only(value)
end

还可以加一个read_only_cast方法,用于将只读表强转成非只读的。项目里可能用得上。代码如下:

1
2
3
4
5
6
function read_only_cast(data)
if type(data) ~= "table" or not data.____ro_raw_table then
return data
end
return data.____ro_raw_table
end

一切都很顺利,现在我们可以用pairs和ipairs试试遍历只读表。发现问题了。pairs只能遍历出____ro_raw_table这一个元素。并不能把原来表中的键值对遍历出来。ipairs倒是没有问题,遍历出来的就是原表中的数据。原因是ipairs遍历会固定从索引1开始访问,所以调用__index元方法,因此结果是正确的。pairs遍历时会调用__pairs元方法,如果没有,就会调用全局的next函数来完成遍历。next方法内部并不会调用任何元方法,所以会遍历不到数据。知道原因后,我们继续实现__pairs元方法。

实现pairs

__pairs元方法需要返回3个参数,第一个是遍历时使用的next方法,我们需要自定义一个。第二个和第三个参数是第一次调用next方法时传的两个参数。第二个我们传____ro_raw_table就好。第三个我们传nil,表示从头开始遍历。实际使用时,我们需要定义一个闭包函数,用一个局部变量来跟踪上一次遍历的index。代码如下:

1
2
3
4
5
6
7
8
9
function metatable.__pairs(ro_table)
local pairsIndex = nil
local read_only_next = function(raw_table)
local nk, nv = next(raw_table, pairsIndex)
pairsIndex = nk
return read_only(nk), read_only(nv)
end
return read_only_next, ro_table.____ro_raw_table, nil
end

next方法需要两个参数,第一个是table,第二个是index。返回两个参数,第一个是nextKey,第二个是nextValue。如果要用next来遍历table,index为nil时,代表从头遍历。下一次调用next需要将上一次返回的nextKey作为index传进去,这一步是pairs自动完成的。我们自己定义的read_only_next也需要遵守这个规则。这里发现我没有写第二个参数,因为我对nextKey做了read_only处理,所以pairs自动传过来的index是不正确的,我干脆就不写了。我们需要用闭包变量来记录真实的nextKey。

让只读表更自然

核心问题已经在上边都解决了。这一段就是添加一些边边角角的东西。根据自己的需要来。

实现__len,支持#操作。代码如下:

1
2
3
function metatable.__len(ro_table)
return #ro_table.____ro_raw_table
end

实现__tostring,字符串打印时能加上readonly前缀。代码如下:

1
2
3
function metatable.__tostring(ro_table)
return string.format("readonly %s", ro_table.____ro_raw_table)
end

实现__eq,项目内有判等的操作,需要支持一下。代码如下:

1
2
3
4
5
6
7
8
9
function metatable.__eq(left, right)
if type(left) == "table" and left.____ro_raw_table then
left = left.____ro_raw_table
end
if type(right) == "table" and right.____ro_raw_table then
right = right.____ro_raw_table
end
return left == right
end

如果原始table的元表也实现过__eq方法,也需要修改下。因为lua优先使用左边变量的__eq方法。

贴一下github地址:readonly.lua

临走前别忘了点赞哦。