记一次全民K歌的crash定位过程

全民K歌4.6版本发布后,出现了一个与RecyclerView相关的IllegalArgumentException,作此记录。一、问题

从下面堆栈中可以看出,RecyclerView此时正在执行布局,尝试获取ViewHolder缓存时发生了crash。所以在分析这个问题前,我们先来简单了解一下RecyclerView的布局流程及缓存策略
[原文来自:www.11jj.com]

记一次全民K歌的crash定位过程 [原创文章:www.11jj.com]

二、准备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 newFooterEmptyViewHolder(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不是透明的,盖在了另一个的上面。

四、关联

整理下我们已有的线索:

  • 引起crash的holder处于FLAG_REMOVED的状态或与Adapter取到的类型不一致

  • RecyclerView创建了两个ViewHolder并指向了同一个Footer

  • 动态页面出现了两个FeedSubFragment及FeedListView,一个正常显示,一个不可见

  • 自媒体 微信号:11jj 扫描二维码关注公众号
    爱八卦,爱爆料。

    小编推荐

    1. 1

      victory纯音乐百度云(victory纯音乐百度网盘下载)

      大家好,小娟今天来为大家解答victory纯音乐百度云以下问题,victory纯音乐百度网盘下载很多人还不知道,现在让我们一起来看看吧!1、歌曲名:V

    2. 2

      【天气】9日中午前后 北部部分地区有雷雨或阵雨

      山东天色瞻望估计4月8—9日山东大部区域天色晴间多云北部沿海区域天色阴局部有雷雨或阵雨10日全省天色多云转阴鲁南和半岛区域局部有细雨济南天

    3. 3

      身体为什么会长疣,常见的五个皮肤疣种类你都知道吗?

      皮肤究竟有多灾?除了受粉刺、痘痘、斑的侵扰,有时还会冒出一些米粒巨细的“肉疙瘩”,也就是「疣」。其实,疣的风险水平严重与否,首要看

    4. 4

      冬季金鱼如何饲养(冬季金鱼如何饲养)

      大家好,小伟今天来为大家解答冬季金鱼如何饲养以下问题,冬季金鱼如何饲养很多人还不知道,现在让我们一起来看看吧!1、对于条件较好的养鱼

    5. 5

      qy152(qy152)

      大家好,小乐今天来为大家解答qy152以下问题,qy152很多人还不知道,现在让我们一起来看看吧!1、我也是找了好久了,然后找不到,这个网站怎么这

    6. 6

      河南省2024年上半年中小学教师资格考试(面试)4月12日开始报名!

      点击蓝字,存眷我们1.问:2024年上半年中小学教师资格测验(面试)报名时间若何放置?答:(1)网上报名时间:2024年4月12日10:00至15日17:00。(2)

    7. 7

      亲情号码怎么查看号码(如何查亲情号全号)

      大家好,小美今天来为大家解答亲情号码怎么查看号码以下问题,如何查亲情号全号很多人还不知道,现在让我们一起来看看吧!1、官方网站查询:

    8. 8

      复印件和扫描件的区别(复印件和扫描件统称为复印件吗)

      大家好,小伟今天来为大家解答复印件和扫描件的区别以下问题,复印件和扫描件统称为复印件吗很多人还不知道,现在让我们一起来看看吧!1、一

    Copyright 2024.依依自媒体,让大家了解更多图文资讯!