Fix一個隨機出現的鍵盤彈出的issue後的思考(ReactNative)

最近花了近一週fix了一個移動端的bug,是個頗有趣的bug,大概是這樣的。這是一個比較長的故事,有興趣的能夠一直看。node

是一個什麼樣的bug

bug的表現是在一款tablet端應用使用好久以後,第一,在輸入框內輸入一些內容後,點擊done/search,第二,而後點擊頁面的一些空白區域,軟鍵盤彈出,而且光標focus在最近輸入過的輸入框內。react

此時應用對用戶行爲的響應會讓用戶很疑惑和費解。
總結,它有以下幾個特色git

  1. 應用最開始是正常的,不是每次都能重現
  2. 一旦出現這個bug,在每個存在輸入框的頁面都存在這個問題
  3. 重現的場景不明,目前已知是應用使用的越久越容易出現,應用一旦後臺關閉病從新啓動,又會消失。

如何去修復這個bug

第一步 試圖穩定重現

咱們先是試圖去找一個最小的用戶journey去復現這個bug,當時運氣比較好,花了大概半天時間找到了一條最小的重現路徑。github

不說業務背景,簡單介紹下應用的頁面邏輯。react-native

咱們的應用在登陸以後有一個home頁面,home頁面存在三個tab能夠滑動或者點擊切換,
在tab頁面之上還存在一些功能菜單,其中某個功能菜單menuA能夠點擊跳到另外一個新的帶有一個輸入框的頁面。網絡

頁面大概以下,不是專業ux很醜勿見怪。
圖片描述ide

咱們發現的一條能夠快速重現的路徑是測試

  1. 登陸到達home頁面後,反覆切換三個tab屢次(20次以上)
  2. 點擊menuA到達一個帶輸入框的頁面
  3. 在輸入框輸入數據,並點擊軟鍵盤的done
  4. 點擊頁面空白區域
  5. 而後軟鍵盤就出來了。

第二步 試圖從代碼部分找到爲何最小場景會出現問題

 找到一個最小重現路徑以後,咱們能夠從代碼裏面找找爲何會出現這個問題。
由於這個bug在應用重啓後沒有,咱們懷疑的方向就定位在render的問題,大機率是出在組件上。
咱們中間有幾個猜想this

  1. 本身封裝的input組件有問題
  2. 三個tabs的滑動組件有問題,滑動組件內的scroll view影響了RN的手勢響應系統

最後發現貌似都不是,這個時候和組內另一個同事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了。

第四步 測試bug是否還能重現

咱們但願經過減小頁面元素反覆的刪除建立,來fix這個bug,結果怎麼樣呢?

竟然神奇的很難復現了,咱們很開心,雖然仍是沒弄懂緣由。

後面QA說在真機上仍是遇到了幾回,讓咱們更是費解,費解的是出現的機率確實變少了,但爲啥還會出現?

第五步 分析bug產生的根本緣由

這個時候咱們須要瞭解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出現這樣的記錄
圖片描述

而且任何一個點擊的響應通常都會有以下四個階段

  • onResponderGrant
  • onResponderStart
  • onResponderEnd
  • onResponderRelease

而後試圖重現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值不同,先留一個假設。

  • 變量trackedTouchCount
  • 方法canTriggerTransfer返回值
  • 方法setResponderAndExtractTransfer
  • 影響grantEvent執行

 

在控制檯仔細觀察,隨便點擊幾下,獲得以下的截圖,
圖片描述
這是在正常未出現bug的狀況下,trackedTouchCount的值在0和1之間擺動,當tounchstart的時候+1,在touchend的時候-1。

咱們再去重現bug,當咱們去反覆切換tab的時候,看看日誌有什麼區別。

圖片描述

簡單分析

有一條toucnStart的記錄987沒有對應的TouchEnd,致使trackedTouchCount無法復位爲0。
爲何在反覆切換tab的時候,會出現這樣有toucnStart而沒有toucnEnd的情景,想了下發現是每次切換tab實際上是作了這麼幾件事情

  • 點擊tab頁籤
  • 頁面出現mask(new一個新的)
  • 頁面請求數據
  • 數據response到達(destroy mask)

但若是頻繁點動tab頁籤,其實某些邊界時刻,點到的是mask,對應mask的node的toucnStart被觸發,而後請求即將到達,mask被destroy了,toucnEnd永遠都不會被觸發了。

因此當咱們把mask的實現從new-destroy改爲new-update的時候,保證了toucnEnd最終可以被觸發了。

概括

  1. mask的老的實現,致使mask的toucnEnd事件某些情況不會被響應,
  2. 影響了變量trackedTouchCount的值得正確性(永遠沒法恢復爲0 ),
  3. 影響方法canTriggerTransfer返回值在頁面有input的時候爲true,
  4. 影響方法setResponderAndExtractTransfer中的grantEvent的被錯誤執行,
  5. 最終致使focus的時候input被錯誤的grant,
  6. 而後點擊其餘任何空白區域都會觸發input。

什麼狀況下 會再次發生

這一次咱們定位了這個issue的問題,而且使用了一些不是徹底fix的方法,讓這個bug不會因爲mask的頻繁使用而出現。
但有沒有可能在其餘的業務場景或者寫代碼的過程當中再次引入這個bug呢? 答案是確定的。

後續在team 內咱們再次fix過幾回相似的問題,簡單總結以下:

  • 場景1 : 給某個業務實體(人 或 物) 添加備註標籤,在輸入欄輸入並按回車後就會生成新的備註標籤,備註標籤上會有一個小叉叉,點擊小叉叉能夠刪除這個備註標籤。一旦刪除某個備註標籤,就會重現。
  • 場景2: 在某個頁面,會展現一些實體(物)的詳細信息,由於信息比較多,咱們作了一個flip的效果,點擊後會翻轉展現更多,在點一下就會回到以前的,就像一個撲克牌的兩面,一面是花紋,一面是具體的大小好比K。 若是連續屢次翻轉,就會重現。

這兩個場景,以及咱們最初遇到的mask的場景,看似沒有任何聯繫,可是最終都會觸發軟鍵盤莫名顯示的問題,其根本緣由和以前mask的一致,都是trackedTouchCount這個變量被改壞了。

那爲何這幾個場景都會改壞這個變量呢?
在排查的過程當中,咱們發現一旦出現某個頁面元素(或者在RN的語境下稱之爲組件比較合適)被刪除,而頁面元素上的onPressOut沒來得及觸發,就會出現此類的問題。
這是RN事件響應系統的問題,通常很難去修改底層庫,咱們目前的解決辦法基本上是

  1. 不頻繁反覆的刪除新建一樣的頁面元素,讓同一頁面元素保持住,或者減小其刪除從新新建的次數(mask 和翻轉的例子);
  2. 不使用onPressIn事件去觸發刪除某些正在顯示的組件(備註標籤的例子);

其餘

另一個在github上面報的由於trackedTouchCount變量不正確狀態致使的issue

[ListView]Scroll on ListView end with an error saying "Ended a touch event which was not counted in trackedTouchCount"有興趣的能夠看看。

相關文章
相關標籤/搜索