最近花了近一周fix了一个移动端的bug,是个颇有趣的bug,大概是这样的。这是一个比较长的故事,有兴趣的能够一直看。node
bug的表现是在一款tablet端应用使用好久以后,第一,在输入框内输入一些内容后,点击done/search,第二,而后点击页面的一些空白区域,软键盘弹出,而且光标focus在最近输入过的输入框内。react
此时应用对用户行为的响应会让用户很疑惑和费解。
总结,它有以下几个特色git
咱们先是试图去找一个最小的用户journey去复现这个bug,当时运气比较好,花了大概半天时间找到了一条最小的重现路径。github
不说业务背景,简单介绍下应用的页面逻辑。react-native
咱们的应用在登陆以后有一个home页面,home页面存在三个tab能够滑动或者点击切换,
在tab页面之上还存在一些功能菜单,其中某个功能菜单menuA能够点击跳到另外一个新的带有一个输入框的页面。网络
页面大概以下,不是专业ux很丑勿见怪。ide
咱们发现的一条能够快速重现的路径是测试
找到一个最小重现路径以后,咱们能够从代码里面找找为何会出现这个问题。
由于这个bug在应用重启后没有,咱们怀疑的方向就定位在render的问题,大几率是出在组件上。
咱们中间有几个猜想this
最后发现貌似都不是,这个时候和组内另一个同事pair,她发如今请求比较多的时候容易有问题,中间还怀疑过网络请求处理致使的。这个怀疑其实不大对,可是确实为咱们找到了一条路。spa
由于咱们最后发现
咱们全部的网络请求都在请求结果返回以前,在页面出现一层蒙版mask以及loading提示符号(在RN里面是ActivityIndicator),这个部分是会影响页面render的。
而把这部分去掉(在请求到达以前不出现蒙层),这个bug就没有了,这个发现当时仍是让人很震惊的以及疑惑的,由于彷佛找到了一部分缘由但咱们仍是没搞清楚为何。
有了这个思路的提示,咱们试图尝试修复。按照业务需求,咱们不能取消ActivityIndicator的使用,由于给用户适当的提示这个确实颇有必要,因此咱们试图去修改mask的实现。
在老的mask里面
咱们使用了一个第三方的RN组件react-native-root-siblings来帮助咱们在root同级插入一个兄弟元素显示咱们的loading提示符号。
通常在发完请求请求结果未到达以前,咱们就插入一个新的同级兄弟元素,请求完成后就删除掉它。
当时怀疑由于这部分反复的修改页面的元素结构,就把new-destory的逻辑换成了new-update的逻辑,减小了元素的修改。
update的时候只是去让ActivityIndicator不出现彷佛被hide了。
咱们但愿经过减小页面元素反复的删除建立,来fix这个bug,结果怎么样呢?
竟然神奇的很难复现了,咱们很开心,虽然仍是没弄懂缘由。
后面QA说在真机上仍是遇到了几回,让咱们更是费解,费解的是出现的几率确实变少了,但为啥还会出现?
这个时候咱们须要了解bug产生的真正缘由了。
咱们从新回到这个bug的表现,为何点击空白区域会触发TextInput的focus方法?咱们尝试作了这样的事情。
找出在会触发TextInput的focus的地方,会不会是被错误的调用了。
除了在代码逻辑里面少许的经过绑定ref而后触发.focus方法(由于是少许出现,不符合咱们这个bug一出现全部input都受影响的情景,快速排除不是这部分缘由),咱们发如今RN提供的TextInput组件里面也有不少地方会调用到focus方法。
大概查找的路径是文件node_modules/react-native/Libraries/Components/TextInput/TextInput.js中发现多处this.focus()的调用,除了正常的onFocus事件的绑定以及autoFocus,有一个在_onPress里面的调用感受很奇怪,暂时放着
_onFocus: function(event: Event) { if (this.props.onFocus) { this.props.onFocus(event); } if (this.props.selectionState) { this.props.selectionState.focus(); } }, //奇怪的地方 _onPress: function(event: Event) { if (this.props.editable || this.props.editable === undefined) { console.log('------> _onPress',event);//log this.focus(); } },
先打了一段log,发现点击空白区域的时候,真的被触发了呀,固然点击输入框也会触发,两者的表现同样同样的。
结论
无法确认是否是被错误的调用了,但确实是被调用了,咱们去找找调用的地方看有什么线索。
看到target里面的ResponderSyntheticEvent了吗,找到这个文件打几行log 有惊喜。
在ResponderSyntheticEvent打日志获取更多信息,并对比正常和有bug时候的异同
以下
function ResponderSyntheticEvent(dispatchConfig, dispatchMarker, nativeEvent, nativeEventTarget) { console.log('-->response',dispatchConfig.registrationName,nativeEventTarget); return SyntheticEvent.call(this, dispatchConfig, dispatchMarker, nativeEvent, nativeEventTarget); }
你会发现你点击页面的任何一个区域都会在console出现这样的记录
而且任何一个点击的响应通常都会有以下四个阶段
而后试图重现bug,看看log有没有什么不同,果真被逮住了。
其中绿色部分的log是正常的,红色划线是不正常的,发现是输入框(1387)这个node grant了手势响应可是后面手势开始是空白区域(1398),最终空白区域(1398)影响了输入框(1387)。
结论
正常状况下四个事件依次触发,出现bug的状况下input的onResponderGrant被调用后面是空白区域的onResponderStart被调用,和其余对比以后,发现onResponderGrant不该该被调用。
了解手势响应系统
仍是很疑惑为何最开始input框(1387)会grant呢?这部分涉及对手势的响应,去rn的官网上面咱们去了解一下手势响应系统,看到提到
具体的实如今ResponderEventPlugin.js文件中,你能够在源码中读到更多细节和文档。
而后找到react/lib/ResponderEventPlugin.js文件,
在多个地方(主要是setResponderAndExtractTransfer方法内)找到ResponderSyntheticEvent(老朋友了,以前在ta那里打过log)的调用,好比
var grantEvent = ResponderSyntheticEvent. getPooled(eventTypes.responderGrant, wantsResponderInst, nativeEvent, nativeEventTarget);
而 setResponderAndExtractTransfer 方法是否调用取决于canTriggerTransfer方法的返回值。
var extracted = canTriggerTransfer(topLevelType, targetInst, nativeEvent) ? setResponderAndExtractTransfer(topLevelType, targetInst, nativeEvent, nativeEventTarget) : null;
细看canTriggerTransfer方法
function canTriggerTransfer(topLevelType, topLevelInst, nativeEvent) { console.log('-->response c3', trackedTouchCount, trackedTouchCount > 0); return topLevelInst && ( // responderIgnoreScroll: We are trying to migrate away from specifically // tracking native scroll events here and responderIgnoreScroll indicates we // will send topTouchCancel to handle canceling touch events instead topLevelType === EventConstants.topLevelTypes.topScroll && !nativeEvent.responderIgnoreScroll || trackedTouchCount > 0 && topLevelType === EventConstants.topLevelTypes.topSelectionChange || isStartish(topLevelType) || isMoveish(topLevelType)); }
其实这个地方的log最开始打了好多,最好发现是trackedTouchCount值不同致使的。
同时去可以影响trackedTouchCount值的地方加一些log
extractEvents: function (topLevelType, targetInst, nativeEvent, nativeEventTarget) { if (isStartish(topLevelType)) { trackedTouchCount += 1; console.log('-->response trackedTouchCount+1',trackedTouchCount,topLevelType,nativeEventTarget); } else if (isEndish(topLevelType)) { if (trackedTouchCount >= 0) { trackedTouchCount -= 1; console.log('-->response trackedTouchCount-1',trackedTouchCount,topLevelType,nativeEventTarget); } else { console.log('-->response trackedTouchCount null',trackedTouchCount,topLevelType); console.error('Ended a touch event which was not counted in `trackedTouchCount`.'); return null; } } *** }
简单描述下这条依赖关系,但其实并不肯定是否是在有bug状况下trackedTouchCount值不同,先留一个假设。
在控制台仔细观察,随便点击几下,获得以下的截图,
这是在正常未出现bug的状况下,trackedTouchCount的值在0和1之间摆动,当tounchstart的时候+1,在touchend的时候-1。
咱们再去重现bug,当咱们去反复切换tab的时候,看看日志有什么区别。
简单分析
有一条toucnStart的记录987没有对应的TouchEnd,致使trackedTouchCount无法复位为0。
为何在反复切换tab的时候,会出现这样有toucnStart而没有toucnEnd的情景,想了下发现是每次切换tab实际上是作了这么几件事情
但若是频繁点动tab页签,其实某些边界时刻,点到的是mask,对应mask的node的toucnStart被触发,而后请求即将到达,mask被destroy了,toucnEnd永远都不会被触发了。
因此当咱们把mask的实现从new-destroy改为new-update的时候,保证了toucnEnd最终可以被触发了。
概括
这一次咱们定位了这个issue的问题,而且使用了一些不是彻底fix的方法,让这个bug不会因为mask的频繁使用而出现。
但有没有可能在其余的业务场景或者写代码的过程当中再次引入这个bug呢? 答案是确定的。
后续在team 内咱们再次fix过几回相似的问题,简单总结以下:
这两个场景,以及咱们最初遇到的mask的场景,看似没有任何联系,可是最终都会触发软键盘莫名显示的问题,其根本缘由和以前mask的一致,都是trackedTouchCount这个变量被改坏了。
那为何这几个场景都会改坏这个变量呢?
在排查的过程当中,咱们发现一旦出现某个页面元素(或者在RN的语境下称之为组件比较合适)被删除,而页面元素上的onPressOut没来得及触发,就会出现此类的问题。
这是RN事件响应系统的问题,通常很难去修改底层库,咱们目前的解决办法基本上是
另一个在github上面报的由于trackedTouchCount变量不正确状态致使的issue