这段代码没有问题,非常简单,但是它对需求意图的表达不够清晰,增加了框架的使用难度
- 如前所述,我们希望
passIn()
的时候只传递事件,希望在passOut()
的时候每个View决定是否要处理事件,并进行处理,而且在处理事件后,不再调用parent
的passOut()
方法把事件传出来 - 你会发现,这其中包含了两类职责:
- 一类是事件传递控制逻辑,另一类是事件处理钩子
- 其中事件传递控制逻辑基本不会变化,但事件处理的钩子中可能做任何事情
- 我们需要把不同职责的代码分开,更需要把变化的和不变的分开,减少框架使用者的关注点
于是我们用一个叫做dispatch()
的方法单独放事件传递的控制逻辑,用一个叫做onTouch()
的方法作为事件处理的钩子,而且钩子有一个返回值,表示钩子中是否处理了事件:
open class MView {
open fun dispatch(ev: MotionEvent): Boolean {
return onTouch(ev)
}
open fun onTouch(ev: MotionEvent): Boolean {
return false
}
}
class MViewGroup(private val child: MView) : MView() {
override fun dispatch(ev: MotionEvent): Boolean {
var handled = child.dispatch(ev)
if (!handled) handled = onTouch(ev)
return handled
}
override fun onTouch(ev: MotionEvent): Boolean {
return false
}
}
这样写完,整个行为其实没有变化,但你会发现:
- 控制逻辑集中在
dispatch()
中,一目了然 onTouch()
单纯是一个钩子,框架使用者只需要关心这个钩子和它的返回值,不用太关心控制流程- 另外,连
parent
也不需要了
1.3. 三造:区分事件类型
上文的实现看上去已经初具雏形了,但其实连开始提的那条原则都没实现完,因为原则要求一次操作只能有一个 View 进行处理,而我们实现的是一个触摸事件只能有一个View进行处理。 这里就涉及到一次触摸操作和一个触摸事件的区别:
- 假设还没有触摸事件的概念,我们要怎么区分一次触摸操作呢?
- 把触摸操作细分一下,大概有按下动作、抬起动作、与屏幕接触时的移动和停留动作
- 很容易想到,要区分两次触摸操作,可以通过按下和抬起动作进行区分,按下动作开始了一次触摸操作,抬起动作结束了一次触摸,按下和抬起中间的移动和停留都属于这一次触摸操作,至于移动和停留是否要区分,目前没有看到区分的必要,可以都作为触摸中来处理
- 于是在一次触摸操作中就有了三种动作的类型:
DOWN/UP/ING
,其中ING
有点不够专业,改个名字叫MOVE
吧 - 而每个触摸动作会在软件系统中产生一个同样类型的触摸事件
- 所以最后,一次触摸操作就是由一组从
DOWN
事件开始、中间是多个MOVE
事件、最后结束于UP
事件的事件流组成
于是设计原则更确切地说就是:一次触摸产生的事件流,只能被一个View消费
在上次试造的基础上把一个事件变成一个组事件流,其实非常简单:处理DOWN
事件时跟前面处理一个事件时一样,但需要同时记住DOWN
事件的消费对象,后续的MOVE/UP
事件直接交给它就行了
麻雀代码:
open class MView {
open fun dispatch(ev: MotionEvent): Boolean {
return onTouch(ev)
}
open fun onTouch(ev: MotionEvent): Boolean {
return false
}
}
class MViewGroup(private val child: MView) : MView() {
private var isChildNeedEvent = false
override fun dispatch(ev: MotionEvent): Boolean {
var handled = false
if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
clearStatus()
handled = child.dispatch(ev)
if (handled) isChildNeedEvent = true
if (!handled) handled = onTouch(ev)
} else {
if (isChildNeedEvent) handled = child.dispatch(ev)
if (!handled) handled = onTouch(ev)
}
if (ev.actionMasked == MotionEvent.ACTION_UP) {
clearStatus()
}
return handled
}
private fun clearStatus() {
isChildNeedEvent = false
}
override fun onTouch(ev: MotionEvent): Boolean {
return false
}
}
代码好像增加了很多,其实只多做了两件事:
- 增加了一个
isChildNeedEvent
状态,对是子View是否处理了DOWN
事件进行记录,并在其他触摸事件时使用这个状态 - 在收到
DOWN
事件的最开始和收到UP
事件的最后,重置状态
此时框架使用者还是只需要关心onTouch()
钩子,在需要处理事件时进行处理并返回true
,其他事情框架都做好了。
1.4. 四造:增加外部事件拦截
上面的框架已经能完成基本的事件分发工作了,但下面这个需求,你尝试一下用现在框架能实现吗? 需求:在可滑动View中有一个可点击View,需要让用户即使按下的位置是可点击View,再进行滑动时,也可以滑动外面的的可滑动View。
这个需求其实非常常见,比如所有「条目可点击的滑动列表」就是这样的(微信/QQ聊天列表)。
假如使用上面的框架:
- 可滑动View会先把事件传到里边的可点击View
- 可点击View一看来事件了,我又能点击,那舍我其谁啊
- 然后外面的可滑动View就永远无法处理事件,也就无法滑动
所以直接使用现在的模型去实现的「条目可点击的滑动列表」会永远滑动不了。
那怎么办呢?
- 难道要让里面的可点击View去感知一下(层层往上找),自己是不是被一个能消费事件的View包裹?是的话自己就不消费事件了?
- 这肯定是不行的,先不说子View层层反向遍历父亲是不是个好实现,至少不能外面是可以滑动的,里边View的点击事件就全部失效
- 或者我们调整
dispatch()
方法在传入事件过程中的人设,让它不是只能往里传递事件,而是在自己能消费事件的时候把事件给自己 - 这肯定也是不行的,跟第一个办法的主要问题一样
直接想实现觉得到处是矛盾,找不到突破口,那就从头开始吧,从什么样的触摸反馈是用户觉得自然的出发,看看这种符合直觉的反馈方案是否存在,找出来它是什么,再考虑我们要怎么实现:
- 当用户面对一个滑动View里有一个可点击View,当他摸在可点击View上时,他是要做什么?
- 显然,只有两个可能性,要么用户想点这个可点击View,要么用户想滑动这个可滑动View
- 那么,当用户刚用手指接触的时候,也就是
DOWN
事件刚来的时候,能判断用户想干什么吗?很抱歉,不能 - 所以,客观条件下,你就是不可能在
DOWN
事件传过来的时候,判断出用户到底想做什么,于是两个View其实都不能确定自己是否要消费事件
我*,这不傻*了吗,还搞什么GUI啊,大家都用命令行吧 等等,不要着急,GUI还是得搞的,不搞没饭吃的我跟你讲,所以你还是得想想,想尽办法去实现。
你先忘记前面说的原则,你想想,不考虑其他因素,也不是只能用DOWN
事件,只要你能判断用户的想法就行,你有什么办法
- 办法肯定是有的,你可以多等一会,看用户接下来的行为能匹配哪种操作模式
- 点击操作的模式是这样:用户先
DOWN
,然后MOVE
很小一段,也不会MOVE出这个子View,关键是比较短的时间就UP
- 滑动操作的模式是这样:用户先
DOWN
,然后开始MOVE
,这时候可能会MOVE出这个子View,也可能不,但关键是比较长的时间也没有在UP
,一直是在MOVE
- 所以你的结论是,只有
DOWN
不行,还得看接下来的事件流,得走着瞧 - 再多考虑个长按的情况,总结就是:
- 如果在某个时间内
UP
,就是点击里边的View - 如果比较长的时间
UP
,但没怎么MOVE
,就是长按里边的View - 如果在比较短的时间
MOVE
比较长的距离,就是滑动外面的View
看上去这个目标 View 判定方案很不错,安排得明明白白,但我们现有的事件处理框架实现不了这样的判定方案,至少存在以下两个冲突:
- 因为子View和父View都无法在
DOWN
的时候判断当前事件流是不是该给自己,所以一开始它们都只能返回false
。但为了能对后续事件做判断,你希望事件继续流过它们,按照当前框架的逻辑,你又不能返回false
。 - 假设事件会流过它们,当事件流了一会儿后,父 View 判断出这符合自己的消费模式啊,于是想把事件给自己消费,但此时子 View 可能已经在消费事件了,而目前的框架是做不到阻止子 View 继续消费事件的
所以要解决上述的冲突,就肯定要对上一版的事件处理框架进行修改,而且看上去一不小心就会大改
- 首先看第二个冲突,解决它的一个直接方案是:调整
dispatch()
方法在传入事件过程中的人设,让它不是只传递事件了,还可以在往里传递事件前进行拦截,能够看情况拦截下事件并交给自己的onTouch()
处理 - 基于这个解决方案,大概有以下两个改动相对小的方案调整思路:
- 思路一:
- 当事件走到可滑动父View的时候,它先拦截并处理事件,而且还把事件给攒着
- 当经过了几个事件
- 如果判断出符合自己的消费模式,那就直接开始自己消费了,也不用继续攒事件了
- 如果判断出不是自己的消费模式,再把所有攒着的事件一股脑给子 View,触发里边的点击操作
- 思路二:
- 所有的 View 只要可能消费事件,就在
onTouch()
里对DOWN
事件返回true
,不管是否识别出当前属于自己的消费模式 - 当事件走到到可滑动父 View 的时候,它先把事件往里传,里边可能会处理事件,可能不会,可滑动父 View 都暂时不关心
- 然后看子 View 是否处理事件
- 假如子 View 不处理事件,那啥问题没有,父 View 直接处理事件就好了
- 假如子 View 处理事件,可滑动父View就会绷紧神经暗中观察伺机而动,观察事件是不是符合自己的消费模式,一旦发现符合,它就把事件流拦截下来,即使子View也在处理事件,它也不往里
disptach
事件了,而是直接给自己的onTouch()
- 两个思路总结一下:
- 思路一:外面的父 View 先拦事件,如果判断拦错了,再把事件往里发
- 思路二:外面的父 View 先不拦事件,在判断应该拦的时候,突然把事件拦下来
- 这两个思路都要对当前框架做改变,看似差不多,但其实还是有比较明显的优劣的
- 思路一问题比较明显:
- 父 View 把事件拦下来了,然后发现拦错了再给子 View,但其实子 View 又并不一定能消费事件,这不就是白做一步吗。等到子View不处理事件,又把事件们还给父View,父View还得继续处理事件。整个过程不仅繁琐,而且会让开发者感觉到别扭
- 所以这个思路不太行,还得是把事件先给子View
- 思路二就相对正常多了,只有一个问题(下一节再讲,你可以猜一猜,这里我先当没发现),而且框架要做的改变也很少:
- 增加一个拦截方法
onIntercept()
在父 View 往里dispatch
事件前,开发者可以覆写这个方法,加入自己的事件模式分析代码,并且可以在确定要拦截的时候进行拦截 - 把分析拦截逻辑抽成一个方法非常合理:什么时候拦,什么时候不拦,内里的逻辑很多,但对外暴露的 API 可以很小,非常适合抽出去
- 在确定自己要拦截事件的时候,即使里边在一开始消费了事件,也不把事件往里传了,而是直接给自己的
onTouch()
示意图:
于是使用思路二,在「三造」的基础上,修改得到以下代码:
open class MView {
open fun dispatch(ev: MotionEvent): Boolean {
return onTouch(ev)
}
open fun onTouch(ev: MotionEvent): Boolean {
return false
}
}
class MViewGroup(private val child: MView) : MView() {
private var isChildNeedEvent = false
private var isSelfNeedEvent = false
override fun dispatch(ev: MotionEvent): Boolean {
var handled = false
if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
clearStatus()
if (onIntercept(ev)) {
isSelfNeedEvent = true
handled = onTouch(ev)
} else {
handled = child.dispatch(ev)
if (handled) isChildNeedEvent = true
if (!handled) {
handled = onTouch(ev)
if (handled) isSelfNeedEvent = true
}
}
} else {
if (isSelfNeedEvent) {
handled = onTouch(ev)
} else if (isChildNeedEvent) {
if (onIntercept(ev)) {
isSelfNeedEvent = true
handled = onTouch(ev)
} else {
handled = child.dispatch(ev)
}
}
}
if (ev.actionMasked == MotionEvent.ACTION_UP) {
clearStatus()
}
return handled
}
private fun clearStatus() {
isChildNeedEvent = false
isSelfNeedEvent = false
}
override fun onTouch(ev: MotionEvent): Boolean {
return false
}
open fun onIntercept(ev: MotionEvent): Boolean {
return false
}
}
写的过程中增加了一些对细节的处理:
- 不仅是在
DOWN
事件的dispatch()
前需要拦截,在后续事件中,也需要加入拦截,否则无法实现中途拦截的目标 - 在某一个事件判断拦截之后,还需要在后续事件中再判断一次是否要拦截吗?
- 完全不需要,我们希望的就是在一次触摸中,尽可能只有1个对象去消费事件,决定是你了,那就不要变
- 所以增加一个
isSelfNeedEvent
记录自己是否拦截过事件,如果拦截过,后续事件直接就交给自己处理 - 在后续事件时,子 View 没有处理事件,外面也不会再处理了,同样因为只能有一个 View 处理(Actvity会处理这样的事件,后面会提到)
这一下代码是不是看上去瞬间复杂了,但其实只是增加了一个事件拦截机制,对比上一次试造的轮子,会更容易理解。(要是 Markdown 支持代码块内自定义着色就好了)
而且对于框架的使用者来说,关注点还是非常少
- 重写
onIntercept()
方法,判断什么时候需要拦截事件,需要拦截时返回true
- 重写
onTouch()
方法,如果处理了事件,返回true
1.5. 五造:增加内部事件拦截
上面的处理思路虽然实现了需求,但可能会导致一个问题:里边的子 View 接收了一半的事件,可能都已经开始处理并做了一些事情,父 View 忽然就不把后续事件给它了,会不会违背用户操作的直觉?甚至出现更奇怪的现象?
这个问题确实比较麻烦,分两类情况讨论
- 里边的 View 接收了一半事件,但还没有真正开始反馈交互,或者在进行可以被取消的反馈
- 比如对于一个可点击的View,View的默认实现是只要被touch了,就会有
pressed
状态,如果你设置了对应的background
,你的 View 就会有高亮效果 - 这种高亮即使被中断也没事,不会让用户感觉到奇怪,不信你自己试试微信的聊天列表
- 但一个值得注意的点是,如果你只是直接不发送
MOVE
事件了,这会有问题,就这个按下高亮的例子,如果你只是不传MOVE
事件了,那谁来告诉里边的子View取消高亮呢?所以你需要在中断的时候也传一个结束事件 - 但是,你能直接传一个
UP
事件吗?也是不行的,因为这样就匹配了里边点击的模式了,会直接触发一个点击事件,这显然不是我们想要的 - 于是外面需要给一个新的事件,这个事件的类型就叫取消事件好了
CANCEL
- 总结一下,对于这种简单的可被取消情况,你可以这样去处理:
- 在确定要拦截的时候,在把真正的事件转发给自己的
onTouch()
的同时,另外生成一个新的事件发给自己的子View,事件类型是CANCEL
,它将是子View收到的最后一个事件 - 子View可以在收到这个事件后,对当前的一些行为进行取消
- 里边的View接收了一半事件,已经开始反馈交互了,这种反馈最好不要去取消它,或者说取消了会显得很怪
- 这个时候,事情会复杂一些,而且这个场景发生的远比你想象中的多,形式也多种多样,不处理好的后果也比只是让用户感觉上奇怪要严重得多,可能会有的功能会实现不了,下面举两个例子
- 在
ViewPager
里有三个page,page里是ScrollView
,ViewPager
可以横向滑动,page里的ScrollView
可以竖向滑动 - 如果按前面逻辑,当
ViewPager
把事件给里边ScrollView
之后,它也会偷偷观察,如果你一直是竖向滑动,那没话说,ViewPager
不会触发拦截事件 - 但如果你竖着滑着滑着,手抖了,开始横滑(或者只是斜滑),
ViewPager
就会开始紧张,想「组织终于决定是我了吗?真的假的,那我可就不客气了」,于是在你斜滑一定距离之后,忽然发现,你划不动ScrollView
了,而ViewPager
开始动 - 原因就是
ScrollView
的竖滑被取消了,ViewPager
把事件拦下来,开始横滑 - 这个体验还是比较怪的,会有种过于灵敏的感觉,会让用户只能小心翼翼地滑动
- 在一个
ScrollView
里有一些按钮,按钮有长按事件,长按再拖动就可以移动按钮 - (更常见的例子是一个列表,里边的条目可以长按拖动)
- 同样按前面的逻辑,当你长按后准备拖动按钮时,你怎么保证不让
ScrollView
把事件拦下来呢? - 所以这类问题是一定要解决的,但要怎么解决呢
- 还是先从业务上看,从用户的角度看,当里边已经开始做一些特殊处理了,外面应不应该把事件抢走?
- 不应该对吧,OK,解决方针就是不应该让外边的View抢事件
- 所以接下来的问题是:谁先判断出外边的View不该抢事件,里边的子View还是外边的父View?然后怎么不让外边的View抢?
- 首先,肯定是里边的View做出判断:这个事件,真的,外边的View你最好别抢,要不用户不开心了
- 然后里边就得告知外边,你别抢了,告知可以有几个方式
- 外边抢之前问一下里边,我能不能抢
- 里边在确定这个事件不能被抢之后,从
dispatch
方法返回一个特别的值给外边(之前只是true
和false
,现在要加一个) - 里边通过别的方式通知外边,你不要抢
- 讲道理,我觉得三个方式都行,但第三个方式最为简单直接,而且对框架没有过大的改动,Android也使用了这个方式,父View给子View提供了一个方法
requestDisallowInterceptTouchEvent()
,子View调用它改变父View的一个状态,同时父View每次在准备拦截前都会判断这个状态(当然这个状态只对当前事件流有效) - 然后,这个情况还得再注意一点,它应该是向上递归的,也就是,在复杂的情况中,有可能有多个上级在暗中观察,当里边的View决定要处理事件而且不准备交出去的时候,外面所有的暗中观察的父View都应该把脑袋转回去
所以,连同上一次试造,总结一下
- 对于多个可消费事件的View进行嵌套的情况,怎么判定事件的归属会变得非常麻烦,无法立刻在
DOWN
事件时就确定,只能在后续的事件流中进一步判断 - 于是在没判断归属的时候,先由里边的子View消费事件,外面暗中观察,同时两方一块对事件类型做进一步匹配,并准备在匹配成功后对事件流的归属进行抢拍
- 抢拍是先抢先得
- 父亲先抢到,发个
CANCEL
事件给儿子就完了 - 儿子先抢到,就得大喊大叫,撒泼耍赖,爸爸们行行好吧,最后得以安心处理事件
另外有几个值得一提的地方:
- 这种先抢先得的方式感觉上有点乱来是吧,但目前也没有想到更好的办法了,一般都是开发者自己根据实际用户体验调整,让父亲或儿子在最适合的时机准确及时地抢到应得的事件
- 父View在拦截下事件后,把接下来的事件传给自己的
onTouch()
后,onTouch()
只会收到后半部分的事件,这样会不会有问题呢? - 确实直接给后半部分会有问题,所以一般情况是,在没拦截的时候就做好如果要处理事件的一些准备工作,以便之后拦截事件了,只使用后半部分事件也能实现符合用户直觉的反馈
在「四造」的基础上,修改得到以下代码:
interface ViewParent {
fun requestDisallowInterceptTouchEvent(isDisallowIntercept: Boolean)
}
open class MView {
var parent: ViewParent? = null
open fun dispatch(ev: MotionEvent): Boolean {
return onTouch(ev)
}
open fun onTouch(ev: MotionEvent): Boolean {
return false
}
}
open class MViewGroup(private val child: MView) : MView(), ViewParent {
private var isChildNeedEvent = false
private var isSelfNeedEvent = false
private var isDisallowIntercept = false
init {
child.parent = this
}
override fun dispatch(ev: MotionEvent): Boolean {
var handled = false
if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
clearStatus()
// add isDisallowIntercept
if (!isDisallowIntercept && onIntercept(ev)) {
isSelfNeedEvent = true
handled = onTouch(ev)
} else {
handled = child.dispatch(ev)
if (handled) isChildNeedEvent = true
if (!handled) {
handled = onTouch(ev)
if (handled) isSelfNeedEvent = true
}
}
} else {
if (isSelfNeedEvent) {
handled = onTouch(ev)
} else if (isChildNeedEvent) {
// add isDisallowIntercept
if (!isDisallowIntercept && onIntercept(ev)) {
isSelfNeedEvent = true
// add cancel
val cancel = MotionEvent.obtain(ev)
cancel.action = MotionEvent.ACTION_CANCEL
handled = child.dispatch(cancel)
cancel.recycle()
} else {
handled = child.dispatch(ev)
}
}
}
if (ev.actionMasked == MotionEvent.ACTION_UP
|| ev.actionMasked == MotionEvent.ACTION_CANCEL) {
clearStatus()
}
return handled
}
private fun clearStatus() {
isChildNeedEvent = false
isSelfNeedEvent = false
isDisallowIntercept = false
}
override fun onTouch(ev: MotionEvent): Boolean {
return false
}
open fun onIntercept(ev: MotionEvent): Boolean {
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数Android工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年Android移动开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点,真正体系化!
由于文件比较大,这里只是将部分目录大纲截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且后续会持续更新
如果你觉得这些内容对你有帮助,可以添加V:vip204888 备注Android获取(资料价值较高,非无偿)
最后我想说
为什么很多程序员做不了架构师?
1、良好健康的职业规划很重要,但大多数人都忽略了
2、学习的习惯很重要,持之以恒才是正解。
3、编程思维没能提升一个台阶,局限在了编码,业务,没考虑过选型、扩展
4、身边没有好的架构师引导、培养。所处的圈子对程序员的成长影响巨大。
金九银十面试季,跳槽季,整理面试题已经成了我多年的习惯!在这里我和身边一些朋友特意整理了一份快速进阶为Android高级工程师的系统且全面的学习资料。涵盖了Android初级——Android高级架构师进阶必备的一些学习技能。
附上:我们之前因为秋招收集的二十套一二线互联网公司Android面试真题(含BAT、小米、华为、美团、滴滴)和我自己整理Android复习笔记(包含Android基础知识点、Android扩展知识点、Android源码解析、设计模式汇总、Gradle知识点、常见算法题汇总。)
里面包含不同方向的自学编程路线、面试题集合/面经、及系列技术文章等,资源持续更新中…
、学习的习惯很重要,持之以恒才是正解。
3、编程思维没能提升一个台阶,局限在了编码,业务,没考虑过选型、扩展
4、身边没有好的架构师引导、培养。所处的圈子对程序员的成长影响巨大。
金九银十面试季,跳槽季,整理面试题已经成了我多年的习惯!在这里我和身边一些朋友特意整理了一份快速进阶为Android高级工程师的系统且全面的学习资料。涵盖了Android初级——Android高级架构师进阶必备的一些学习技能。
附上:我们之前因为秋招收集的二十套一二线互联网公司Android面试真题(含BAT、小米、华为、美团、滴滴)和我自己整理Android复习笔记(包含Android基础知识点、Android扩展知识点、Android源码解析、设计模式汇总、Gradle知识点、常见算法题汇总。)
[外链图片转存中…(img-6QkNoAQ5-1711595222062)]
里面包含不同方向的自学编程路线、面试题集合/面经、及系列技术文章等,资源持续更新中…