RecyclerView 的坑 1 Added View has RecyclerView as parent but view is not a real child. Unfiltered in

RecyclerView Crash 解析
本文解析了一个常见的RecyclerView Crash问题,即快速滚动时出现的AddedViewhasRecyclerViewasparentbutviewisnotarealchild错误。文章深入探讨了RecyclerView的工作原理、复用机制及布局管理器的实现细节。

前言

最近在用ReyclerView写模块化页面,每个模块视图部分作为一个小的Aapter,会发现一些RecyclerView的坑,在博客中进行一些总结,保持更新。

1、问题出现

打开RecyclerView页面,快速滚动crash Added View has RecyclerView as parent **

“Added View has RecyclerView as parent but view is not a real child. Unfiltered index: xx ” 使用的是LinearLayoutManger。

直接打开RecyclerView源码(24.2.1),在它的内部类LayoutManger中,可以搜到这个Crash,在addViewInt方法中报的crash,addViewInt方法是将childview添加到RecyclerView中,在添加之前要检查获取的childview是否合法,来看下面源码。

private void addViewInt(View child, int index, boolean disappearing) {
            final ViewHolder holder = getChildViewHolderInt(child);
            ************
            final LayoutParams lp = (LayoutParams) child.getLayoutParams();
            // child 如果是从复用池里捞出来重用走这个逻辑
            if (holder.wasReturnedFromScrap() || holder.isScrap()) {
                if (holder.isScrap()) {
                    holder.unScrap();
                } else {
                    holder.clearReturnedFromScrapFlag();
                }
                mChildHelper.attachViewToParent(child, index, child.getLayoutParams(), false);
                if (DISPATCH_TEMP_DETACH) {
                    ViewCompat.dispatchFinishTemporaryDetach(child);
                }
            } else if (child.getParent() == mRecyclerView) { 
                int currentIndex = mChildHelper.indexOfChild(child);
                if (index == -1) {
                    index = mChildHelper.getChildCount();
                }
                if (currentIndex == -1) {
                    throw new IllegalStateException("Added View has RecyclerView as parent but"
                            + " view is not a real child. Unfiltered index:"
                            + mRecyclerView.indexOfChild(child));
                }
                if (currentIndex != index) {
                    mRecyclerView.mLayout.moveView(currentIndex, index);
                }
            } else {
                mChildHelper.addView(child, index, false);
                lp.mInsetsDirty = true;
                if (mSmoothScroller != null && mSmoothScroller.isRunning()) {
                    mSmoothScroller.onChildAttachedToWindow(child);
                }
            }
            if (lp.mPendingInvalidate) {
                if (DEBUG) {
                    Log.d(TAG, "consuming pending invalidate on child " + lp.mViewHolder);
                }
                holder.itemView.invalidate();
                lp.mPendingInvalidate = false;
            }
        }

这个crash产生的直接原因很简单,就是持有的childview的父view已经是RecyclerView了,换句话说这个View已经在RecyclerView上,并且这个View的mChildHelper.indexOfChild(child)==-1。
mChildHelper.indexOfChild这个方法干了什么呢?看下面代码,首先会找到这个View在RecyclerView中的index,如果没找到直接返回-1;如果有这个View,再去mBuket中寻找,如果发现这个View是个隐藏View,则返回-1. Buket中记录了隐藏的View的index,什么View需要被隐藏呢?看下一个代码块,在开始做动画之前,这个“隐藏”并不是把View移除掉,而是把这些需要做动画的View做记录,并存在一个数组中,在做动画时特殊处理(脑补)。

int indexOfChild(View child) {
        final int index = mCallback.indexOfChild(child);
        if (index == -1) {
            return -1;
        }
        if (mBucket.get(index)) {
            if (DEBUG) {
                throw new IllegalArgumentException("cannot get index of a hidden child");
            } else {
                return -1;
            }
        }
        // reverse the index
        return index - mBucket.countOnesBefore(index);
    }

ChildHelper 利用Buket隐藏View

 private void addAnimatingView(ViewHolder viewHolder) {
        final View view = viewHolder.itemView;
        final boolean alreadyParented = view.getParent() == this;
        mRecycler.unscrapView(getChildViewHolder(view));
        if (viewHolder.isTmpDetached()) {
            // re-attach
            mChildHelper.attachViewToParent(view, -1, view.getLayoutParams(), true);
        } else if(!alreadyParented) {
            mChildHelper.addView(view, true);
        } else {
            mChildHelper.hide(view);
        }
    }

所以我们看到这个Crash如果满足上述几个条件就会发生:1、ChildView的Holder是新创建的,不是从复用池里捞出来;2、ChildView的父View是RecyclerView;3、ChildView 还在做动画。
为什么一个新创建的ViewHolder的View的Parent是RecyclerView,做着动画,还在被复用?按道理做着动画不能被复用啊!!

2 原因

原因肯定是我们用错了,写法有问题啊,分析一下RecyclerView的复用机制:

(1) ArrayList mChangedScrap:在视图范围内,且正在做动画的holder
(2)ArrayList mAttachedScrap:在视图范围内,除去做动画的Holder
(3)ArrayList mCachedViews:默认最大值为2,存储的是最近移出屏幕的ViewHolder,这里面的ViewHolder的属性全部保留,当读取缓存ViewHolder时优先从这里取,如果取到,可以避免再计算位置,LayoutParams。
(4)RecycledViewPool mRecyclerPool:主要用来缓存重置ViewHolder的对象

如果addViewInt这个函数接收的child是从mCachedViews或mRecyclerPool中取的,不会有问题,但偏偏是mAttachedScrap中的View,正常情况下addViewInt不会添加视图范围内部的View,所以addViewInt传入的View应该是调用onCreateViewHolder获取的。通过debug这个crash,查找调用堆栈,发现确实在复用池中没有找到对应类型的View,重新调用了onCreateViewHolder。

Crash产生原因是,当RecyclerView调用OnCreateViewHolder时,我们并没有重新生成一个View和ViewHolder,我们返回的View可能是一个之前已经存在的View,把这个View存储成了一个全局变量,在OnCreateViewHolder没有重新生成,而是把上次的View又返回了,导致出现这种问题。类似与下面写法,所以在RecyclerView的Adapter中,如果调用OnCreateViewHolder,一定不要偷懒,返回一个全新的对象吧

public View onCreateView(ViewGroup parent, int viewType) {
            if (mRootView == null) {
                mRootView = LayoutInflater.from(getContext()).inflate(R.layout.xxxxxx, null, false);
                mContainer = (LinearLayout) mRootView.findViewById( R.id.container )
            }
            return mRootView ;
        }

(3)你以为这就结束了吗

当我们写法有问题的时候:
为什么需要刚打开页面快速滚动才能复现?为什么网上一些其他的解决方法貌似也很管用:把Adapter的hasStableID设置false或者setItemAnimation为null。我们既然找到了真实原因,就再聊聊这些治标不治本的方法为什么管用。

解决以上问题的根源在于:当我们的ItemCount没发生改变时,为什么会重复调用OnCreateViewHolder,复用池为什么找不到这个View??如果复用池中可以找到,就不会有这个问题了。

原因是:当RecyclerView每次onLayout时,会将他的View都回收一遍,然后再重新计算添加,关键的步骤在LinearLayoutManger中scrapOrRecycleView方法里,这个方法负责回收子View。

里面有个条件判断非常关键——mAdapter.hasStableIds(),removeViewAt(index)和detachViewAt(index)两个处理方法不同。RemoveViewAt(index)会将View在RecyclerView中移除,并会回收到复用池中;detachViewAt(index)会把View放到 mAttachedScrap或mAttachedScrap中。

什么时候会放到复用池呢?当动画mItemAnimator null会立即放入,如果itemAnimation不为null,会在动画执行完毕后放入复用池。

执行动画的条件是什么?两个必要条件是mItemAnimator不为null,hasStabelID = true。mItemAnimator默认是DefaultItemAnimator,不为null;Adapter hasStableID默认应该是false。

所以为啥设置hasStabelID为false或者设置mItemAnimator为null会有奇效,但是这绝对不是永久解决方案,最好的方案是改变写法,把bug解除。

为啥当打开页面快速滑动才容易复现这个问题呢,因为动画执行时间很短,只有在做动画时,不断Scroll,让RecyclerView重新去计算视图,加载需要的View,才会走到addViewInt逻辑,才能触发问题。

private void scrapOrRecycleView(Recycler recycler, int index, View view) {
            final ViewHolder viewHolder = getChildViewHolderInt(view);
            if (viewHolder.shouldIgnore()) {
                return;
            }
            if (viewHolder.isInvalid() && !viewHolder.isRemoved() &&
                    !mRecyclerView.mAdapter.hasStableIds()) {
                    // hasStableIds很关键
                removeViewAt(index); 
                recycler.recycleViewHolderInternal(viewHolder);
            } else {
                detachViewAt(index);
                recycler.scrapView(view);
                mRecyclerView.mViewInfoStore.onViewDetached(viewHolder);
            }
        }

ReyclerView当添加动画Finish时,会调用removeAnimation方法,这个时候View会被加入到复用池汇中。

private boolean removeAnimatingView(View view) {
        eatRequestLayout();
        final boolean removed = mChildHelper.removeViewIfHidden(view);
        if (removed) {
            final ViewHolder viewHolder = getChildViewHolderInt(view);
            mRecycler.unscrapView(viewHolder);
            mRecycler.recycleViewHolderInternal(viewHolder);
        }
        resumeRequestLayout(false);
        return removed;
    }
`nmap -A -p 1-65535 192.168.146.143` 是 Nmap 工具的一个常用命令,以下是对该命令各部分的详细解释: ### 命令各部分含义 - **nmap**:这是命令的基础,代表调用 Nmap 网络扫描工具。Nmap 是一款广泛使用的开源网络探索和安全审计工具,可用于发现网络主机、开放端口、服务版本等信息。 - **-A**:该选项表示启用全面扫描模式。在这种模式下,Nmap 会同时进行操作系统检测(试图确定目标主机运行的操作系统类型和版本)、服务版本检测(识别目标主机上运行的服务及其版本)、脚本扫描(使用 Nmap 脚本引擎执行一系列预定义脚本,以获取更多关于目标的信息)以及 traceroute(追踪数据包到达目标主机所经过的路由路径)等操作,从而尽可能全面地了解目标主机的情况。 - **-p 1-65535**:“-p” 是指定端口范围的选项。端口是计算机与外界进行通信的通道,每个端口对应着不同的服务。“1 - 65535” 表示扫描从 1 到 65535 的所有端口。在 TCP/IP 协议中,端口号的范围是 0 到 65535,其中 0 到 1023 是知名端口,通常被系统服务和常见应用程序使用;1024 到 49151 是注册端口,可由用户程序注册使用;49152 到 65535 是动态或私有端口。通过扫描这个范围内的所有端口,可以发现目标主机上开放的所有服务。 - **192.168.146.143**:这是目标主机的 IP 地址。Nmap 将对该 IP 地址对应的主机进行扫描,以获取相关信息。 ### 使用方法 要使用这个命令,需要在具备 Nmap 工具的环境中打开终端或命令提示符窗口。在 Kali Linux 等专业渗透测试系统中,Nmap 通常是默认安装的。在 Windows 系统中,可以从 Nmap 官方网站下载并安装 Nmap 工具。 打开终端后,输入 `nmap -A -p 1-65535 192.168.146.143` 并按下回车键,Nmap 就会开始对目标主机进行扫描。扫描过程可能需要一些时间,具体取决于网络状况、目标主机的响应速度以及扫描的端口数量。 ### 可能的输出结果 - **端口状态**:会列出扫描范围内每个端口的状态,常见的状态有 “open”(开放)、“closed”(关闭)、“filtered”(被过滤,可能是由于防火墙或其他安全设备阻止了 Nmap 的探测)、“unfiltered”(未被过滤,但无法确定端口是否开放)等。例如: ```plaintext PORT STATE SERVICE 21/tcp open ftp 22/tcp open ssh 80/tcp open http ``` - **服务版本信息**:如果启用了服务版本检测(通过 “-A” 选项),Nmap 会尝试识别每个开放端口上运行的服务及其版本。例如: ```plaintext 21/tcp open ftp vsftpd 3.0.3 22/tcp open ssh OpenSSH 7.6p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0) 80/tcp open http Apache httpd 2.4.29 ((Ubuntu)) ``` - **操作系统信息**:在全面扫描模式下,Nmap 会尝试推断目标主机的操作系统类型和版本。输出可能类似于: ```plaintext Running: Linux 4.X - 5.X OS CPE: cpe:/o:linux:linux_kernel:4 cpe:/o:linux:linux_kernel:5 OS details: Linux 4.15 - 5.6 ``` - **脚本扫描结果**:如果有适用的 Nmap 脚本被执行,会显示脚本的执行结果。例如,某些脚本可能会检测到目标主机上的漏洞或配置错误。 ```plaintext |_http-server-header: Apache/2.4.29 (Ubuntu) |_http-title: Apache2 Ubuntu Default Page: It works ``` - **traceroute 结果**:显示数据包从扫描主机到目标主机所经过的路由路径,包括每个跃点的 IP 地址和响应时间。 ```plaintext TRACEROUTE (using port 80/tcp) HOP RTT ADDRESS 1 0.26 ms 192.168.146.1 2 0.35 ms 192.168.1.1 3 1.23 ms 10.0.0.1 4 2.56 ms 192.168.146.143 ``` ### 示例代码 ```plaintext nmap -A -p 1-65535 192.168.146.143 ```
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值