記一次全民K歌的crash定位過程

騰訊音樂技術發表於2018-11-12

全民K歌4.6版本釋出後,出現了一個與RecyclerView相關的IllegalArgumentException,作此記錄。

一、問題

從下面堆疊中可以看出,RecyclerView此時正在執行佈局,嘗試獲取ViewHolder快取時發生了crash。所以在分析這個問題前,我們先來簡單瞭解一下RecyclerView的佈局流程及快取策略

記一次全民K歌的crash定位過程

二、準備

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進行呼叫的,只會執行一次:

記一次全民K歌的crash定位過程

排除了業務邏輯建立兩個Fragment的可能,那就只能是系統建立的了。容易聯想到應用退後臺被系統殺掉重建的情況,FeedFragment與FeedSubFragment都會被系統恢復,而FeedFragment恢復的過程中也會走到onCreateView的生命週期,於是又建立一個FeedSubFragment。

透過開啟開發者選項中的“不保留活動”,復現了這樣的場景,恢復後產生了2個FeedSubFragment,一個正常顯示,另一個從xml載入佈局後沒有發起資料的請求,於是頁面一直是loading的預設狀態,而FeedListView為INVISIBLE。

至於原因,可以先看下我們頁面的結構:

記一次全民K歌的crash定位過程

FeedFragment包含2個部分,一個是Titlebar,包含關注、好友、熱門、附近4個Tab選項,另一個是FeedSubFragment用於承載各個Tab的內容,隨Tab切換更新資料顯示。使用者點開K歌時,預設是定位好友頁的,但如果發現使用者上次離開時不在好友,那這次開啟應自動切換到使用者離開時的那個頁面,這是透過TitleBar內View的performClick來觸發切換的,FeedFragment監聽到點選後通知FeedSubFragment發起網路請求。

因為FeedFragment只會有一個FeedSubFragment的引用,所以一個能正常顯示,另一個一直是loadind的狀態,與前面使用者crash時的狀態是一致的。而對使用者來說,這是無感知的,因為正常顯示的那個Fragment不是透明的,蓋在了另一個的上面。

四、關聯

整理下我們已有的線索:

  1. 引起crash的holder處於FLAG_REMOVED的狀態或與Adapter取到的型別不一致

  2. RecyclerView建立了兩個ViewHolder並指向了同一個Footer

  3. 動態頁面出現了兩個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,如下:

記一次全民K歌的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又會怎樣呢?

記一次全民K歌的crash定位過程

①② 可直接複用

③ 取到了假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/,如需轉載,請註明出處,否則將追究法律責任。

相關文章