最近花了近一週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