要做一款移动设备上的软键盘,那么怎么处理用户的点击位置,就是你遇到的第一个难题,在这个问题上,我也走了很长的路。
我把落格输入法开发以来的触控逻辑大致分类为三个阶段,现在分别来讲讲设计思路,希望能够对你有所帮助。
第一代触控引擎
显然,对于一个初学者来说,没什么比系统控件更好用的了,功能全,速度也不慢,业务逻辑完善,所以,落格输入法的第一代消息处理就是用的 UIButton 的 TouchUpInside 消息。因为一开始我甚至用的是 xib 构建键盘布局,所以直接使用了 @IBAction func buttonTouchUpInside(_ sender: UIButton) 这样的声明,你一看就懂了,对吧?处理很方便,用户点击了哪个 Button,那么传进来的就是哪个,不需要做额外的判断,系统都帮你搞定了一切。但很快就遇到了第一个问题——按下按钮后程序总要执行一会,于是 UI 就会卡顿,很明显从按下按键到候选出现,会有延迟。
为了避免输入法业务逻辑干扰(实际上是阻挡)UI 更新,我改为在后台处理进一步的消息,实际上你也应该总是这么做——永远不要在主线程处理业务逻辑。
1 2 3 |
DispatchQueue.global(qos: .userInitiated).async { self.input(button:button) } |
但这样又引入了另一个问题,当用户点击按键间隔太短速度太快时,按键处理的顺序会错乱,于是,我把异步改为同步,这样业务逻辑还是在后台线程处理,但会严格按照调用的顺序依次执行(这确保了用户按键以实际顺序进行处理)
1 2 3 |
DispatchQueue.global(qos: .userInitiated).sync { self.input(button:button) } |
注意第一行,async → sync。
第二代触控引擎
第一代引擎正常工作了很久,但最终还是遇到了另一个问题:当用户点击更加快的时候,某些后台逻辑处理不正常。
这实际上是由于 UIButton 自身触控处理机制冲突造成的,当用户点击屏幕键盘速度太快,实际上短时间内同时按下了两个按钮,此时主线程自身是互相阻止的,只有当用户两个手指都离开屏幕,消息才会发送,即 @IBAction func buttonTouchUpInside(_ sender: UIButton) 被立即调用两次。在极短时间内,两次连续调用,虽然是有严格顺序的,但每个按键消息都会处理候选栏的刷新,这就需要异步更改 UI,这就导致了一些处理逻辑异常——在 UI 刷新完成之前,下一个消息已经开始执行。
解决的思路有两个,要么把业务逻辑的一些判断改为和 UI 不关联,要么想办法让系统能够处理用户同时按下多个 UIButton 的情况。 ——显然,应该从后者入手,于是,我在开发 落格输入法 X 时做了一个按键缓存机制,它不再使用 TouchUpInside ,而是 TouchDown 和 TouchUp :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
var pendingKey:UIButton? @IBAction func keyboardKeyDown(_ sender:UIButton) { if sender == pendingKey {return} if let pending = pendingKey { keyboardKeyConfirm(pending) } pendingKey = sender } @IBAction func keyboardKeyUp(_ sender: UIButton) { guard let pending = pendingKey, sender.tag == pending.tag else {return} keyboardKeyConfirm(pending) pendingKey = nil } func keyboardKeyConfirm(_ sender:UIButton) { buttonTouchUpInside(sender) } |
注意 buttonTouchUpInside 就是上一代的处理逻辑,并没有什么变化,这么写是为了让你明白我的变更思路,这样当用户按下一个键,那么就立即缓存它,当用户再按下一个键,如果已经有缓存了,那么就处理它,并将新的加入缓存;当用户抬起手指,那么就处理缓存的那个按键。如此一来,所有的按键都会得到处理,并且不会被 TouchUpInside 这个信号阻拦——因为我已经不再使用它了。
由于更改了信号获取源头(从 TouchUpInside 改为 TouchDown + TouchUp ),对用户来说“手感”变化很大。
第三代触控引擎
第二代实际上已经工作的很好,但在 落格输入法 X 上架之前,我们内部测试就发现了另一个问题——实际上并不能说是新发现的,因为它一直都存在,那就是“q”和“p”的问题。在新的 iOS 系统当中,似乎是为了避免和系统手势冲突,iOS 为屏幕边缘的 5 个像素做了保留处理,当你点击到屏幕边缘的时候(即按按键“q”或者“p”时稍微靠边了点), TouchDown 这个消息是不会立即被触发的。
它会被延迟到你抬起手指的那一刻,然后和 TouchUp 一起发送给键盘。这就导致了用户正常打字的时候,遇到这两个位置,总是会感觉“卡顿”了一下,因为视觉和声音反馈上,确实是延迟到你抬起手指的那一刻而不是按下就立即触发。
如果是在 app 当中,你可以这样做:
1 2 3 |
override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge { return [.left,.right] } |
但显然,在键盘中这个代码并不生效。总之,为了让即将上架的 落格输入法 X 更加具有竞争力,我只好硬着头皮继续想办法。
我在尝试了很多方案之后,我终于找到了一个能获取信号的控件—— UITapGestureRecognizer 。
不是用它来进行标准识别点击——这同样是没用的,必须使用它的 func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool 这个代理方法,只有它能够越过系统屏蔽,正确获得 TouchDown 调用,而不是被延迟到用户抬起手指的那一刻。
对应地,我又使用 UIView 本身的 func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) 来获取 TouchUp 行为,如此一来,就可以参考上一代引擎的逻辑实现了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
class TouchLayer:UIView,UIGestureRecognizerDelegate { var tapGR = UITapGestureRecognizer() init(keyboard:KeyboardViewController) { super.init(frame: CGRect.zero) tapGR.delegate = self self.addGestureRecognizer(tapGR) } var currentTouch:UITouch? override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) { if let t = currentTouch { touchUpInside(t) } } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { if let t = currentTouch { touchUpInside(t) } touchDown(touch) return true } } |
其他代码略过不表,这样操作之后,把这个 TouchLayer 覆盖在键盘上方即可——当然,你也可以在你的 Button 子类里进行这样的操作,然后单独处理。这里我则盖在键盘上方,进行统一处理了。
为此,我不得不对键盘的业务逻辑进行了一番调整和重构……直到去年年底,我写了一篇文章 落格输入法 X 是如何处理屏幕边缘延迟问题的。
总之,这就是现在线上版本 落格输入法 X 在使用的触控逻辑,目前来看,一切良好。如果说缺点,大概就是从二代升级三代代价实在是太大了,这几乎是 落格输入法 X 与 经典版 的重点区别之一了。
本文由 落格博客 原创撰写:落格博客 » 落格输入法是如何处理按键消息的
转载请保留出处和原文链接:https://www.logcg.com/archives/3205.html
原来以前经常切窗口的时候会在输入框留下一个q字母是这个原因…
不过这样看来,iOS原生输入法没有UX方面的问题,用的API和留给第三方的输入API很可能不是同一套?略坑
第二代那套逻辑有点像做ACT游戏处理输入缓冲的思路