bug是如果一个scrollview上有多个TextInput,那么一个TextInput处于focus状态时点击其它TextInput只会关闭键盘,没有将另一个TextInput进行focus,其实如果我之前了解React中事件传递顺序的话并没有必要这么麻烦,虽然花了很多时间,但是也许学习到了很多。
项目源码: Reminders
首先iOS程序员的直觉之检查canBecomeFirstResponder,打断点
发现正常情况该方法会被调用两次(false, true),然而异常情况只会被调用一次(false)
正常情况第二次调用的调用栈
图中的foucs方法暴露给了js, 猜测是由js调用的
搜索focus
结果太多。。。猜肯定和TextInput有关
TextField在_onPress里调用了focus (继续找下去可以看到JS端最终调用了UIManager.focus(), 对应上面native端focus方法)
接着搜索_onPress,看它是在哪里被调用的,可以看到这是个callback
然后在_onPress里设个断点,发现异常情况并不会调用_onPress
正常时调用_onPress的调用栈(讲真我第一眼看到这个栈想掀桌子。。):
从上到下分析:
touchableHandlePress只是简单转发:
在第三个_performsideEffectsForTransition打断点,发现无论怎样都会被执行多次。。慢慢分析会比较复杂。先尝试换思路,我们先确定是事件没有发出还是传输时丢失了, 我们需要先找到Js端event的源头然后推出Native发送event的位置
根据TextInput的render函数实现可知onPress信号由TouchableWithoutFeedback接受:
由TouchableWithoutFeedback的实现可知TouchableWithoutFeedback只是将child的clone加上了一大堆方法处理的属性然后直接返回child的clone
所以Js端event的接受者是child 即 {textContainer} ,对应的是native端的RCTTextField:
现在在原生找RCTTextField(一开始那个类)
原生检测touch事件无非两种方法。要么实现UIResponder的方法,要么加GestureRecognizer,
RCTTextField的实现里没有UIResponder的方法,所以确定是GestureRecognizer。
要添加GestureRecognizer必须要有RCTTextField的示例,所以必然会有RCTTextField的引用,搜索一下。
从图看来一定是在RCTTextField自身或者RCTTextFieldManager里添加的GestureRecognizer了!一定是这样没错!
看了下。。。。妈的没有。。。。
想了想,还有一种可能:响应的是parent view而不是自身。那么最有可能的就是RootView了。
看了下,真的有!(现在想想这样做最有道理,首先性能上肯定占优势,其次如果子view和parentView都有gestureRecoginzer不做处理的话同时都会响应,就麻烦了)
在处理touch的方法handleGestureUpdate:里打个断点
看来是正常发出了。。。
看看JS调用栈有个
设个断点
正常: topTouchStart,topTouchEnd,topFocus
异常:topTouchStart, topTouchEnd, topEndEditing,topBlur
(正常异常情况下topTouchStart和topTouchEnd的rootNodeID都相同)
我们都知道focus是touch的结果,所以推测是topTouchStart、topTouchEnd的后续异常处理导致的bug
继续看调用栈:_receiveRootNodeIDEvent只做了简单的转发
在handleTopLevel里打个断点:
发现正常异常情况在传入topTouchEnd时传给runEventQueueInBatch的参数不同
异常:
event[1]. _dispatchListeners. __reactBoundMethod = function scrollResponderHandleResponderRelease(e)
正常:
event[1]. _dispatchListeners. __reactBoundMethod = function touchableHandleResponderRelease(e)
我们有理由相信就是因为touchableHandleResponderRelease没被调用导致的bug
现在可以确定bug在EventPluginHub.extraceEvents里
来看下实现:
关于plugin是啥,一开始我也不知道。后来去专门看了下初始化的源码才知道。现在就当未知数(每个plugin负责监听一套事件,现在的RN虽然有两个默认plugin,但是大多数组件都依赖于原有的React自带plugin,由native端定义的plugin几乎没用上)。
我们知道的:
- 正常异常情况下EventPluginRegistry.plugins返回的值都是一个长度为2的数组
- 异常情况下接受“topTouchEnd”时第一个plugin产生的extractedEvents[1]的listener是scrollResponderHandleResponderRelease(e) 而正常情况下是touchableHandleResponderRelease(e)
- extractedEvents在for plugin循环里调用(一般不会去更改plugin)
推断:
EventPluginRegistry.plugins两次返回的都是一样的数组
bug在possiblePlugin.extractEvents里
进去看看。。
extracted的_dispatchListeners在这一行前是null
执行完这一行变为含有function scrollResponderHandleResponderRelease(e)的回调
根据accumulate这个参数名大概知道是把finalEvent的内容放进extracted里了
看下finalEvent的内容验证一下
finalEvent在一开始初始化
var finalEvent = ResponderSyntheticEvent.getPooled(finalTouch, responderID, nativeEvent, nativeEventTarget);
后大概就是这样子没变过
然后我们看一下用来初始化finalEvent的ResponderSyntheticEvent.getPooled的参数
正常:{
finalTouch: "onResponderRelease"
responderID: ".r[1]{TOP_LEVEL}[0].$1.0.1.0.$scene_0.0.1.$1.1.0.1:$r_s1_1.1"
nativeEvent: …
nativeEventTarget: …
}
异常:{
finalTouch: "onResponderRelease"
responderID: ".r[1]{TOP_LEVEL}[0].$1.0.1.0.$scene_0.0.1.$1.1"
nativeEvent: …
nativeEventTarget: …
}
可以看到responderID不同。。自然respond的方法也不同
于是我们watch responderID,找到它是在什么时候开始不同的
发现responderID从ResponderSyntheticEvent.getPooled一开始就是不同的
想想既然touchEnd触发事件,那么touchStart理论上就没意义了,个人能想到唯一有可能的功能就是确定responderId
于是在touchStart时打个断点
这行结束前后console输出一下possiblePlugin.getResponderID()
假设正确
所以问题其实是出在接收touchStart时调用的extractEvents方法
在该方法里watch responderId:
发现responderId在
var extracted = canTriggerTransfer(topLevelType, topLevelTargetID, nativeEvent) ? setResponderAndExtractTransfer(topLevelType, topLevelTargetID, nativeEvent, nativeEventTarget) : null;
里被改变。。。。
从名字里也能看出来是setResponderAndExtractTransfer干的。。。进去看看
在这changeResponder方法里repsonderId被更改
更改结果为wantsResponderID
所以是wantsResponderID出错了
该执行路径下(异常时执行路径)wantsResponderID只在声明时赋值了
有两种可能:
executeDispatchsInOrderStopAtTrue出错
参数shouldSetEvent不对
进executeDispatchsInOrderStopAtTrue看看:
再进executeDispatchesInOrderStopAtTrueImpl
核心代码如下
// 省略一些无关代码
这段代码大概就是找到第一个响应事件的listener然后返回listener所属的object的id
该循环在i=3时跳出
dispatchListeners[3] 里是scrollResponderHandleStartShouldSetResponderCapture
dispatchListeners[4] 里是touchableHandleStartShouldSetResponder
也就是说scrollResponderHandleStartShouldSetResponderCapture ”偷走”了事件
搜索下scrollResponderHandleStartShouldSetResponderCapture, 找到他的实现:
注意那行注释。。。如果键盘打开他就会“eat taps”。。。。。。
其实bug就出在这。。。下面是因为理解错误而白白多走得几步(React系统中parentView有一个方法专门“偷”子view事件)
下面是之前想错了多想的几步,没有考虑到原生中点击事件也有可能在hitTest里就被截取了=。=
所以bug出在这?其实仔细想想并不是。。。这个只是说了如果键盘打开他就会接收事件
在iOS中如果parent view 和 sub view 都能响应点击事件(UIResponder),那么subView会得到事件的优先处置权
所以bug出在parentView的优先级高于subView,也就是event._dispatchListeners顺序错误
回到setResponderAndExtractTransfer方法。发现异常情况下shouldSetEvent._dispatchListeners在这一行被赋值:
进去看看:
再进
上三步可以简化为
accumulateTwoPhaseDispatchesSingle(shouldSetEvent)
EventPluginHub.injection.getInstanceHandle().traverseTwoPhase(event.dispatchMarker,accumulateDirectionalDispatches, event);
大概做的事是从rootView遍历到标记为event.dispatchMarker的view,然后反向遍历(event.dispatchMarker会被访问两次,一次正向,一次反向),每个节点调用accumulateDirectionalDispatches,参数为(节点id,遍历方向,event)
大概就是问一下每个view:你接不接受这个event,接受的话就把你放到event的候选目标里
但是为啥会需要两个遍历阶段?
capturephase是啥?
查了一下文档:
Gesture Responder System:http://facebook.github.io/react-native/releases/0.26/docs/gesture-responder-system.html#capture-shouldset-handlers
里面有这么一句话:
However, sometimes a parent will want to make sure that it becomes responder. This can be handled by using the capture phase. Before the responder system bubbles up from the deepest component, it will do a capture phase, firing on*ShouldSetResponderCapture. So if a parent View wants to prevent the child from becoming responder on a touch start, it should have a onStartShouldSetResponderCapture handler which returns true.
。。。
嗯。。。我现在就想抽自己。。。当初为啥不好好看文档。。。不过至少对RN了解深入了很多。。。
其实这么说来bug原因就是scrollView不应该capture event。而是如果子view都不处理才去处理。
打开scrollResponder把
scrollResponderHandleStartShouldSetResponderCapture和scrollResponderHandleStartShouldSetResponder的实现互换就解决了。
这样还有个问题,如果打开键盘后直接点击返回键盘不消失,好办,在Touchable的响应方法里加一句这个就搞定了
TextInputState.blurTextInput(TextInputState.currentlyFocusedField())
当然这样textInput被点击时也会触发这个事件,但是因为iOS如果在一个时间周期内收到关、开键盘。他就会忽视前一个信号。所以一切正常啦~
update:
在RN的repo里提了这个问题,有人说可以组合keyboardDismissMode和keyboardShouldPersistent两个属性,来解决。但是个人感觉这依旧是个workaround呀。。。因为我觉得通过capture事件来隐藏键盘还是不符合正常事件传递顺序。而且这样点击返回按钮依然不会隐藏键盘。然而没人继续回我了。。。大概600多个issue确实太忙了。。看来又不能在大型项目中提交PR了。。。桑心。。。