记一次RN Debug经历

bug是如果一个scrollview上有多个TextInput,那么一个TextInput处于focus状态时点击其它TextInput只会关闭键盘,没有将另一个TextInput进行focus,其实如果我之前了解React中事件传递顺序的话并没有必要这么麻烦,虽然花了很多时间,但是也许学习到了很多。

项目源码: Reminders

首先iOS程序员的直觉之检查canBecomeFirstResponder,打断点

canBecomeFirstResponder检查

发现正常情况该方法会被调用两次(false, true),然而异常情况只会被调用一次(false)

正常情况第二次调用的调用栈

记一次RN Debug经历_第1张图片
textField canBecomeFirstResponder调用栈

图中的foucs方法暴露给了js, 猜测是由js调用的
记一次RN Debug经历_第2张图片
native端 focus方法

搜索focus

focus( 在整个项目中的搜索结果


结果太多。。。猜肯定和TextInput有关

记一次RN Debug经历_第3张图片
focus( 在整个TextInput中的搜索结果

TextField在_onPress里调用了focus (继续找下去可以看到JS端最终调用了UIManager.focus(), 对应上面native端focus方法)

接着搜索_onPress,看它是在哪里被调用的,可以看到这是个callback


记一次RN Debug经历_第4张图片
_onPress搜索

然后在_onPress里设个断点,发现异常情况并不会调用_onPress
正常时调用_onPress的调用栈(讲真我第一眼看到这个栈想掀桌子。。):

记一次RN Debug经历_第5张图片
_onPress调用栈

从上到下分析:
touchableHandlePress只是简单转发:


touchableHandlePress


在第三个_performsideEffectsForTransition打断点,发现无论怎样都会被执行多次。。慢慢分析会比较复杂。先尝试换思路,我们先确定是事件没有发出还是传输时丢失了, 我们需要先找到Js端event的源头然后推出Native发送event的位置

根据TextInput的render函数实现可知onPress信号由TouchableWithoutFeedback接受:

记一次RN Debug经历_第6张图片
TextInput::render()

由TouchableWithoutFeedback的实现可知TouchableWithoutFeedback只是将child的clone加上了一大堆方法处理的属性然后直接返回child的clone


记一次RN Debug经历_第7张图片
TouchableWithoutFeedback::render()


所以Js端event的接受者是child 即 {textContainer} ,对应的是native端的RCTTextField:

现在在原生找RCTTextField(一开始那个类)
原生检测touch事件无非两种方法。要么实现UIResponder的方法,要么加GestureRecognizer,
RCTTextField的实现里没有UIResponder的方法,所以确定是GestureRecognizer。
要添加GestureRecognizer必须要有RCTTextField的示例,所以必然会有RCTTextField的引用,搜索一下。


记一次RN Debug经历_第8张图片
RCTTextField搜索结果

从图看来一定是在RCTTextField自身或者RCTTextFieldManager里添加的GestureRecognizer了!一定是这样没错!

看了下。。。。妈的没有。。。。
想了想,还有一种可能:响应的是parent view而不是自身。那么最有可能的就是RootView了。
看了下,真的有!(现在想想这样做最有道理,首先性能上肯定占优势,其次如果子view和parentView都有gestureRecoginzer不做处理的话同时都会响应,就麻烦了

记一次RN Debug经历_第9张图片
RCTRootView初始化方法

在处理touch的方法handleGestureUpdate:里打个断点
看来是正常发出了。。。
看看JS调用栈有个
调用栈

设个断点

在receiveTouches里设断点

正常: topTouchStart,topTouchEnd,topFocus

异常:topTouchStart, topTouchEnd, topEndEditing,topBlur
(正常异常情况下topTouchStart和topTouchEnd的rootNodeID都相同)

我们都知道focus是touch的结果,所以推测是topTouchStart、topTouchEnd的后续异常处理导致的bug

继续看调用栈:_receiveRootNodeIDEvent只做了简单的转发

记一次RN Debug经历_第10张图片
_receiveRootNodeIDEvent

在handleTopLevel里打个断点:
handleTopLevel

发现正常异常情况在传入topTouchEnd时传给runEventQueueInBatch的参数不同
异常: event[1]. _dispatchListeners. __reactBoundMethod = function scrollResponderHandleResponderRelease(e)
正常: event[1]. _dispatchListeners. __reactBoundMethod = function touchableHandleResponderRelease(e)
我们有理由相信就是因为touchableHandleResponderRelease没被调用导致的bug

现在可以确定bug在EventPluginHub.extraceEvents里

来看下实现:

记一次RN Debug经历_第11张图片
EventPluginHub.extraceEvents

关于plugin是啥,一开始我也不知道。后来去专门看了下初始化的源码才知道。现在就当未知数(每个plugin负责监听一套事件,现在的RN虽然有两个默认plugin,但是大多数组件都依赖于原有的React自带plugin,由native端定义的plugin几乎没用上)。
我们知道的:

  1. 正常异常情况下EventPluginRegistry.plugins返回的值都是一个长度为2的数组
  2. 异常情况下接受“topTouchEnd”时第一个plugin产生的extractedEvents[1]的listener是scrollResponderHandleResponderRelease(e) 而正常情况下是touchableHandleResponderRelease(e)
  3. extractedEvents在for plugin循环里调用(一般不会去更改plugin)
    推断:
    EventPluginRegistry.plugins两次返回的都是一样的数组
    bug在possiblePlugin.extractEvents里
    进去看看。。
    记一次RN Debug经历_第12张图片
    possiblePlugin.extractEvents

    extracted的_dispatchListeners在这一行前是null
    执行完这一行变为含有function scrollResponderHandleResponderRelease(e)的回调
    根据accumulate这个参数名大概知道是把finalEvent的内容放进extracted里了
    看下finalEvent的内容验证一下
    记一次RN Debug经历_第13张图片
    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时打个断点

记一次RN Debug经历_第14张图片
在touchStart时打个断点

这行结束前后console输出一下possiblePlugin.getResponderID()
possiblePlugin.getResponderID()

假设正确

所以问题其实是出在接收touchStart时调用的extractEvents方法
在该方法里watch responderId:
发现responderId在
var extracted = canTriggerTransfer(topLevelType, topLevelTargetID, nativeEvent) ? setResponderAndExtractTransfer(topLevelType, topLevelTargetID, nativeEvent, nativeEventTarget) : null;
里被改变。。。。
从名字里也能看出来是setResponderAndExtractTransfer干的。。。进去看看

setResponderAndExtractTransfer

在这changeResponder方法里repsonderId被更改
更改结果为wantsResponderID
所以是wantsResponderID出错了
该执行路径下(异常时执行路径)wantsResponderID只在声明时赋值了
wantsResponderID声明

有两种可能:
executeDispatchsInOrderStopAtTrue出错
参数shouldSetEvent不对

进executeDispatchsInOrderStopAtTrue看看:

executeDispatchsInOrderStopAtTrue

再进executeDispatchesInOrderStopAtTrueImpl
核心代码如下

executeDispatchesInOrderStopAtTrueImpl

// 省略一些无关代码
记一次RN Debug经历_第15张图片
executeDispatchesInOrderStopAtTrueImpl续

这段代码大概就是找到第一个响应事件的listener然后返回listener所属的object的id
该循环在i=3时跳出
dispatchListeners[3] 里是scrollResponderHandleStartShouldSetResponderCapture
dispatchListeners[4] 里是touchableHandleStartShouldSetResponder
也就是说scrollResponderHandleStartShouldSetResponderCapture ”偷走”了事件
搜索下scrollResponderHandleStartShouldSetResponderCapture, 找到他的实现:
记一次RN Debug经历_第16张图片
scrollResponderHandleStartShouldSetResponderCapture

注意那行注释。。。如果键盘打开他就会“eat taps”。。。。。。
其实bug就出在这。。。下面是因为理解错误而白白多走得几步(React系统中parentView有一个方法专门“偷”子view事件)

下面是之前想错了多想的几步,没有考虑到原生中点击事件也有可能在hitTest里就被截取了=。=

所以bug出在这?其实仔细想想并不是。。。这个只是说了如果键盘打开他就会接收事件
在iOS中如果parent view 和 sub view 都能响应点击事件(UIResponder),那么subView会得到事件的优先处置权
所以bug出在parentView的优先级高于subView,也就是event._dispatchListeners顺序错误
回到setResponderAndExtractTransfer方法。发现异常情况下shouldSetEvent._dispatchListeners在这一行被赋值:

setResponderAndExtractTransfer

进去看看:
accumulateTwoPhaseDispatches

再进
forEachAccmulated

上三步可以简化为
accumulateTwoPhaseDispatchesSingle(shouldSetEvent)

EventPluginHub.injection.getInstanceHandle().traverseTwoPhase(event.dispatchMarker,accumulateDirectionalDispatches, event);
大概做的事是从rootView遍历到标记为event.dispatchMarker的view,然后反向遍历(event.dispatchMarker会被访问两次,一次正向,一次反向),每个节点调用accumulateDirectionalDispatches,参数为(节点id,遍历方向,event)

记一次RN Debug经历_第17张图片
accumulateDirectionalDispatches

大概就是问一下每个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了。。。桑心。。。

你可能感兴趣的:(记一次RN Debug经历)