記一次全民K歌的crash定位過程
全民K歌4.6版本釋出後,出現了一個與RecyclerView相關的IllegalArgumentException,作此記錄。
一、問題
從下面堆疊中可以看出,RecyclerView此時正在執行佈局,嘗試獲取ViewHolder快取時發生了crash。所以在分析這個問題前,我們先來簡單瞭解一下RecyclerView的佈局流程及快取策略
二、準備
1、佈局流程
透過RecyclerView的dispatchLayout方法,可以知道其佈局過程大概分為三個步驟:
dispatchLayoutStep1 : preLayout預佈局階段,主要處理Adapter的更新、決定使用怎樣的動畫及儲存當前子View的邊界等資訊,這裡佈局的結果是資料變化前的狀態
dispatchLayoutStep2 : 修改mInPreLayout狀態為false,然後交由LayoutManager的onLayoutChildren方法處理,它會根據當前子View的ViewHolder狀態將其回收至各個快取佇列中,然後尋找錨點並往上下兩個方法進行填充,當需要子View時,則請求RecyclerView提供,佈局結果為資料變化後的狀態。而上述crash正是發生在這一階段!程式碼如下所示:
private void dispatchLayoutStep2() { // some code here // Step 2: Run layout mState.mInPreLayout = false; mLayout.onLayoutChildren(mRecycler, mState); // some code here }
dispatchLayoutStep3 : postLayout,儲存當前子View的資訊並結合prelayout階段的結果,觸發動畫執行,最後清理一些狀態。
2、快取策略
RecyclerView共有以下幾種快取:
mAttachedScrap
未與RecyclerView分離的ViewHolder快取,用於layout過程中臨時存放,可以簡單理解為當前螢幕正在顯示且資料沒有發生變化的內容,可直接複用。新增前會執行ChildHelper的detachViewForParent方法,設定View的parent物件為null,但不會從RecyclerView中remove;另外,還會對mScrapContainer物件進行設定,使得ViewHolder.isScrap為true
mChangedScrap
也未與RecyclerView分離,但資料已發生變化,用於動畫執行前的preLayout階段。同樣會執行detachViewForParent及設定mScrapContainer
mCachedViews
當itemView滑出螢幕並從RecyclerView中被remove時,會先新增到這裡,其最大容量預設為2
mVewCacheExtension
業務自定義的的快取邏輯,K歌沒有實現
RecycledViewPool
最後一級快取,新增前需要先從RecyclerView中remove掉,對不同的viewType預設快取5個ViewHolder,複用時需要重新繫結資料
除了執行動畫的需要,在preLayout階段會優先從
mChangedScrap
快取中獲取ViewHolder外,其它情況都是先按
mAttachedScrap
>
mCachedViews
>
mViewCachedExtension
>
RecycledViewPool
的順序進行復用,如果沒有可用的,就呼叫Adapter的onCreateViewHolder方法進行建立
三、分析
有了上面對RecyclerView基礎的瞭解,再來看到下crash發生的地方:
ViewHolder tryGetViewHolderForPositionByDeadline(int position, boolean dryRun, long deadlineNs) { // some code here... // 拿到ViewHolder快取 holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun); if (holder != null) { // 對ViewHolder進行校驗,但沒有透過 if (!validateViewHolderForOffsetPosition(holder)) { if (!dryRun) { // 準備新增到到RecyledViewPool holder.addFlags(ViewHolder.FLAG_INVALID); // isScrap 說明是從mAttachedScrap獲取到的 if (holder.isScrap()) { // crash發生在這裡 removeDetachedView(holder.itemView, false); holder.unScrap(); } else if (holder.wasReturnedFromScrap()) { holder.clearReturnedFromScrapFlag(); } recycleViewHolderInternal(holder); } holder = null; } else { fromScrapOrHiddenOrCache = true; } } // some code here...
邏輯上可以判斷,holder是在getScrapOrHiddenOrCachedHolderForPosition方法中獲取到的,其內部實現是對mAttachedScrap、mCachedViews 及ChildHelper中因動畫需要未與RecyclerView分離的ItemView 進行查詢並返回(ChildHelper主要是接管了RecyclerView對子View的處理,解決動畫過程中,子View與Adapter資料不同步的問題,有興趣可自行了解,此處不展開),值得注意的是,這裡的快取查詢是以position為索引的,而RecycledViewPool則是透過viewType進行查詢的,這很關鍵。
holder.isScrap的判斷則說明了這是
mAttachedScrap
中的快取,之所以會走到引發了crash的removeDetachedView,是因為對holder的校驗沒有透過,已不符合可直接複用的特點,於是準備把它從RecyclerView中remove並改放到
RecycledViewPool
中,然後就crash了。
可為什麼會校驗不透過呢?再來看下校驗的原始碼:
boolean validateViewHolderForOffsetPosition(ViewHolder holder) { // if it is a removed holder, nothing to verify since we cannot ask adapter anymore // if it is not removed, verify the type and id. if (holder.isRemoved()) { if (DEBUG && !mState.isPreLayout()) { throw new IllegalStateException("should not receive a removed view unless it" + " is pre layout" + exceptionLabel()); } return mState.isPreLayout(); } if (holder.mPosition < 0 || holder.mPosition >= mAdapter.getItemCount()) { throw new IndexOutOfBoundsException("Inconsistency detected. Invalid view holder " + "adapter position" + holder + exceptionLabel()); } if (!mState.isPreLayout()) { // don't check type if it is pre-layout. final int type = mAdapter.getItemViewType(holder.mPosition); if (type != holder.getItemViewType()) { return false; } } if (mAdapter.hasStableIds()) { return holder.getItemId() == mAdapter.getItemId(holder.mPosition); } return true; }
K歌業務中沒有設定stableId,mAdapter.hasStableIds()一定為false;另外,我們的crash是發生在dispatchLayoutStep2的步驟中,呼叫onLayoutChildren前會將mState.mInPreLayout設定為false。那就只有兩種可能了: 要麼holder處於FLAG_REMOVED的狀態,要麼holder與Adapter取到的型別不一致 。此處先作為 線索一 ,後續需要用到。
迴歸到crash堆疊中,看下有沒有其它的有用資訊。最後,發現了ViewHolder與FeedListView的兩個細節
ViewHolder{394df98d position=2 id=-1, oldPos=-1, pLpos:-1}
// 這裡是ViewHolder.toString方法摘要 // some code here... if (isScrap()) { sb.append(" scrap ").append(mInChangeScrap ? "[changeScrap]" : "[attachedScrap]"); } // some code here... return sb.toString();
引起crash的ViewHolder位於列表中第3位且沒有scrap字樣,也就是isScrap為false,這就不對了,呼叫removeDetachedView前先判斷了isScrap為true的,為什麼進到方法裡面就變成false了呢?原來傳參給的是itemView,方法內又透過itemView的LayoutParam取到ViewHolder,正常來說,View與ViewHolder間是雙向引用、一一對應的關係,這裡定是出現了 ViewHolder1指向View,View又指向了另一個ViewHolder2的情況,說明我們的View被多個ViewHolder共用了。
要解釋這個問題,就得看下Adapter建立ViewHolder的程式碼:
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { if (viewType == REFRESH_HEADER) { // 下拉重新整理 return new RefreshHeaderContainerViewHolder(mRefreshHeaderContainer); } else if (viewType == HEADER) { // Header容器 return new HeaderContainerViewHolder(mHeaderContainer); } else if (viewType == FOOTER) { // Footer容器 return new FooterContainerViewHolder(mFooterContainer); } else if(viewType == FOOTER_EMPTY){ // 列表內容少,希望用空白填滿列表 return new FooterEmptyViewHolder(mFooterEmpty); } else if (viewType == LOAD_MORE_FOOTER) { // 上拉載入 return new LoadMoreFooterContainerViewHolder(mLoadMoreFooterContainer); } else { // 具體業務模組自行建立 return mAdapter.onCreateViewHolder(parent, viewType); } }
業務使用的RecyclerView是經過了封裝的,新增了對 重新整理、Header、Footer、空白、載入的支援。其中,mAdapter.onCreateViewHolder都是透過new ViewHolder(new View())的形式建立的,不可能存在View共用的情況;而另外幾個,確實有對同一型別的viewType建立多個ViewHolder的可能,但這不是正常邏輯,因為列表中的這些型別有且只有一個,只需建立一次就行。再看堆疊中的position=2,就可以鎖定是Footer的異常了,因為除了列表為空時,Footer的position為2,其它幾個型別都不會出現為2的情況。檢查了業務邏輯上Footer相關的程式碼並與Header進行了對比,沒找到合理的解釋,暫且放下並標記為 線索二:RecyclerView建立了兩個ViewHolder並指向了同一個Footer
繼續看上面提到的另一個細節
FeedListView{27f84f4a IFE….. ……ID 0,231-1080,1767 #7f0d0416 app:id/se}
View.toString摘要:
public String toString() { StringBuilder out = new StringBuilder(128); out.append(getClass().getName()); out.append('{'); out.append(Integer.toHexString(System.identityHashCode(this))); out.append(' '); switch (mViewFlags&VISIBILITY_MASK) { case VISIBLE: out.append('V'); break; case INVISIBLE: out.append('I'); break; case GONE: out.append('G'); break; default: out.append('.'); break; } }
雖然叫FeedListView,實際是繼承自RecyclerView。從toString方法可以知道,RecyclerView處於INVISIBLE的狀態。而K歌動態只有在請求到後臺資料前才會是INVISIBLE的狀態,只要拿到了資料或協議失敗,都會更改為VISIBLE的狀態。
這是很奇怪的一個現象,因為從log來看,資料是載入成功的了,使用者也有在列表中進行滑動、送禮、收聽之類的互動操作,所以,我們的列表一定是可見的。鑑於Crash堆疊也不可能有錯,為了解釋這種現象,大膽推測:使用者手機上出現了兩個FeedListView,一個正常顯示,一個不可見
相對於上面的這些分析,驗證就顯得簡單多了,我們透過使用者啟動時,Fragment.OnCreate相關的log來印證了線索三是對的,且不僅是存在了兩個列表,還出現了兩個FeedSubFragment,但FeedFragment只有一個,得到 線索三:動態頁面出現了兩個FeedSubFragment及FeedListView,一個正常顯示,一個不可見 。
onCreate:com.tencent.karaoke.module.feed.ui.FeedFragment onCreate:com.tencent.karaoke.module.feed.ui.FeedSubFragment
onCreate:com.tencent.karaoke.module.feed.ui.FeedSubFragment
FeedSubFragment是在FeedFragment的init方法中建立的,init是在onCreateView進行呼叫的,只會執行一次:
排除了業務邏輯建立兩個Fragment的可能,那就只能是系統建立的了。容易聯想到應用退後臺被系統殺掉重建的情況,FeedFragment與FeedSubFragment都會被系統恢復,而FeedFragment恢復的過程中也會走到onCreateView的生命週期,於是又建立一個FeedSubFragment。
透過開啟開發者選項中的“不保留活動”,復現了這樣的場景,恢復後產生了2個FeedSubFragment,一個正常顯示,另一個從xml載入佈局後沒有發起資料的請求,於是頁面一直是loading的預設狀態,而FeedListView為INVISIBLE。
至於原因,可以先看下我們頁面的結構:
FeedFragment包含2個部分,一個是Titlebar,包含關注、好友、熱門、附近4個Tab選項,另一個是FeedSubFragment用於承載各個Tab的內容,隨Tab切換更新資料顯示。使用者點開K歌時,預設是定位好友頁的,但如果發現使用者上次離開時不在好友,那這次開啟應自動切換到使用者離開時的那個頁面,這是透過TitleBar內View的performClick來觸發切換的,FeedFragment監聽到點選後通知FeedSubFragment發起網路請求。
因為FeedFragment只會有一個FeedSubFragment的引用,所以一個能正常顯示,另一個一直是loadind的狀態,與前面使用者crash時的狀態是一致的。而對使用者來說,這是無感知的,因為正常顯示的那個Fragment不是透明的,蓋在了另一個的上面。
四、關聯
整理下我們已有的線索:
-
引起crash的holder處於FLAG_REMOVED的狀態或與Adapter取到的型別不一致
-
RecyclerView建立了兩個ViewHolder並指向了同一個Footer
-
動態頁面出現了兩個FeedSubFragment及FeedListView,一個正常顯示,一個不可見
對於線索1,我們先假設是第一種情況,透過追蹤FLAG_REMOVED設定的路徑,發現只有當業務呼叫了Adapter的notifyXXXRemoved方法時,才會為ViewHolder新增FLAG_REMOVED標記。而線索二中的Footer實際上是一個容器,業務呼叫addFooterView新增進來的佈局都會填入容器中,不管使用者如何操作,對RecyclerView來說,Footer始終是有且只有一個,不存在刪除Footer的情況。於是線索一糾正為:
從
mAttachedScrap
中取到的ViewHolder型別與Adapter取到的不一致。
mAttachedScrap
中的ViewHolder是透過對比LayoutPosition查詢到的,而Adapter.getItemType的結果則是分析資料集而來,兩者的不一致說明了RecyclerView的狀態與資料集產生了不同步的情況,往往出現在Adapter中的列表資料發生了變化而又沒有呼叫notityXXX方法通知到RecyclerView的情況下。
crash所在的列表並沒有請求後臺資料卻產生了資料的變化,能產生這一現象的只有使用者釋出作品後,由客戶端自己構造的假資料了。
因作品釋出與K歌業務邏輯關聯較大,參考意義不大,這裡只做簡要的文字說明:
使用者釋出作品後,會生成一條釋出資料在動態中顯示,這條資料是存在於單例中的,兩個FeedSubFragment都能取到,釋出完成並重新整理列表才會把它從單例中清除。另外,使用者在K歌內的一些互動操作會觸發廣播,比如在作品詳情頁評論了作品,那動態中這個作品的feed評論計數會實時更新,不需要等待列表的重新整理操作,廣播也都是有註冊的。
作品剛釋出時,不可見的那個頁面對此無感知,會出現RecyclerView是Refresh、Header、Footer、Empty、Load五個item的狀態,而Adapter的資料集中在Header與Footer間多了一條假feed,雖然沒有呼叫notifyXXX,但當有互動操作或跳其它Activity返回等其它原因觸發layout時,也不會引起crash,如下:
①② 透過position可以從
mAttachedScrap
正確獲取到原來的ViewHolder並直接複用
③ 透過position取到了Footer的ViewHolder,發現型別不同,把它從佈局中remove並新增到快取池
RecycledViewPool
,最後新建立一個假Feed的ViewHolder
④ 取到了Empty的ViewHolder,同樣回收至RecycledViewPool,但因為上一步有把Footer的ViewHolder新增到了RecycledViewPool,處理完Empty後,會嘗試從RecycledViewPool查詢,而這裡是透過viewType來查詢的,所以可以找到上一步新增進來的ViewHolder,從而複用
⑤⑥ 同④
當假feed已經被layout出來,資料被刪除卻沒有notify的情況下執行layout又會怎樣呢?
①② 可直接複用
③ 取到了假feed的ViewHolder,回收至RecycledViewPool,然後重新建立了一個Footer的ViewHolder,這就導致了兩個ViewHolder指向同一個View的出現,一個新建立的新增到RecyclerView中顯示,並清除FLAG_TMP_DETACHED標記,另一個仍然存在於Scrap快取中未被使用
④ 取到了Scrap快取中Footer的ViewHolder,嘗試回收至RecycledViewPool,卻發現Footer已經不是FLAG_TMP_DETACHED的狀態,因為上一步已經把它新增到RecyclerView中,清除了這一標記,於是丟擲文章開頭的IllegalArgumentException異常
可能有人會感興趣增刪資料並呼叫了notifyXXXRemoved的正常情況下,RecyclerView是如何在preLayout及postLayout階段都能透過position獲取到正確的ViewHolder的,可以自行了解下ViewHolder的mPreLayoutPosition跟mPosition的作用,這裡不細說了
五、總結
至此,原因也就比較清晰了:使用者使用K歌停留在動態非好友頁,退後臺被系統殺掉重啟時,沒有考慮到Fragment恢復的情況,導致在正常的Fragment下多生成了一個不可見的Fragment,之後釋出了作品並對其執行了會引起資料變化的互動操作,使其layout到佈局中,重新整理列表後不可見的RecyclerView列表狀態與Adapter資料不同步,跳轉到其它Activity再返回時,觸發了RecyclerView的重新佈局,檢測到了狀態不對並丟擲了異常。
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31557897/viewspace-2219668/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- 記一次 Java 應用記憶體洩漏的定位過程Java記憶體
- 記一次公司JVM堆溢位抽絲剝繭定位的過程JVM
- 記一次公司JVM堆溢位抽繭剝絲定位的過程JVM
- 記錄一次K8s pod被殺的排查過程K8S
- 記錄一次現網MySQL記憶體增長超限問題定位過程MySql記憶體
- 記一次JVM FullGC引發嚴重線上事故的定位、分析、解決過程!JVMGC
- 記一次 GitLab 的遷移過程Gitlab
- 記一次nodejs開發CLI的過程NodeJS
- 記一次前端面試的全過程前端面試
- 記錄一次Dataguard的修復過程
- 記一次 Laravel-Admin 的 Debug 過程Laravel
- 記一次SQL調優過程SQL
- 一次透過dump檔案分析OutOfMemoryError異常程式碼定位過程Error
- 記一次 Composer 問題的解決過程!!
- 記一次PMML檔案的處理過程
- 記一次"記憶體洩露"排查過程記憶體洩露
- 記一次OOM問題排查過程OOM
- 記一次 Boomer 壓測 MQTT 過程OOMMQQT
- 記一次ElementUI原始碼修改過程UI原始碼
- 記一次系統演變過程
- MySQL 記一次 Bug發現過程MySql
- 記一次Linux核心崩潰:kdump,crash,vmcoreLinux
- 記一次使用windbg排查記憶體洩漏的過程記憶體
- 再記一次經典Net程式的逆向過程
- 記一次uboot升級過程的兩個坑boot
- 記一次VMware的崩潰除錯分析過程除錯
- 記一次Net軟體逆向的過程(經典)
- 記錄一次記憶體洩漏排查過程記憶體
- 記一次安卓webview查錯過程安卓WebView
- 記一次使用 AetherUpload 影片上傳過程
- 記一次https通訊除錯過程HTTP除錯
- 記一次ceph pg unfound處理過程
- 記一次透過Memory Analyzer分析記憶體洩漏的解決過程記憶體
- 記一次Nodejs安全工單的處理過程_20171226NodeJS
- 記錄一次Flink作業異常的排查過程
- 記一次"截圖"功能的專案調研過程!
- 解Bug之路-記一次儲存故障的排查過程
- 記錄一次使用drissionpage上傳下載的過程