Baserecyclerviewadapterhelper: 解决方案:ViewHolder views must not be attached when created. Ensure that you are not passing 'true' to the attachToRoot parameter of LayoutInflater.inflate(..., boolean attachToRoot)

Created on 28 Jun 2019  ·  3Comments  ·  Source: CymChad/BaseRecyclerViewAdapterHelper

Describe the bug

  1. 当前使用的版本号: 2.9.45-androidx 、2.9.46

BUG说明

viewPager2(对于视图回收的viewPager理应也会出现此bug)上,Fragement里放垂直滑动的recyclerView,
第一页Fragement里的RecyclerView adapte设置了空布局后;滑动到第四页,此时,第一页的Fragement已经被回收,执行了onDestroy()方法,但是,此Fragement的实例还在。

这时候再从第四页,滑动回到第一页,Fragement会重走onCreateViewonViewCreated,这时候,由于视图被重建,所以RecyclerView并不相同,同时对RecyclerView进行重新设置adapter

(理想的、正确的状态是,从第四页回到第一页仍然显示空布局)

前面已经提到,Fragement只是回收视图,但是实例任然在,所以作为全局变量的adapter并没变,adapter仍是同一个,
此时的问题就变成了:同一个adapter设置给了两个或者多个不同实例的RecyclerView,这时候BUG出现,重复设置adapter会报如下错:

ViewHolder views must not be attached when created. Ensure that you are not passing 'true' to the attachToRoot parameter of LayoutInflater.inflate(..., boolean attachToRoot)

此错误非常不明确,没有错误具体行数,排查较为麻烦。

经过反复对源码的断点调试,以及对源码翻看,发现问题所在。
首先源码中的mEmptyLayout是一个class全局变量,当显示空布局时,在:

public K onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { 
………………
    case EMPTY_VIEW:
        baseViewHolder = createBaseViewHolder(mEmptyLayout);
        break;
………………
}

此方法中,mEmptyLayout被直接进行了引用,在第一次给RecyclerView设置adapter的时候,没问题。当第二次把同一adapter设置给不同实例的RecyclerView后,就会出现上述错误。因为mEmptyLayout被第一个RecyclerView视图所持有,不能被添加到第二个RecyclerView上。

也就是说adapter不能被复用,所以在之前的Issuse中,给出的解决方案都是让ViewPager不回收视图,全部保存在内存中。其实说实话,这种方式对内存很不友好。

解决方案:

很简单:将上述方法修为以下:

public K onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { 
………………
    case EMPTY_VIEW:
        //在重用mEmptyLayout视图前,先将mEmptyLayout从之前对父布局中释放出来
        ViewGroup elViewGroup = (ViewGroup)mEmptyLayout.getParent();
        if (elViewGroup != null) {
                    elViewGroup.removeView(mEmptyLayout);
                }
        baseViewHolder = createBaseViewHolder(mEmptyLayout);
        break;
………………
}

同时,为了保证复用性,以下方法最好也进行修改:

public void bindToRecyclerView(RecyclerView recyclerView) {
//        if (getRecyclerView() != null) {
//            throw new IllegalStateException("Don't bind twice");
//        }
        // 改为此判断方式,应该判断是否是同一实例
        if (getRecyclerView() == recyclerView) {
            throw new IllegalStateException("Don't bind twice");
        }
        setRecyclerView(recyclerView);
        getRecyclerView().setAdapter(this);
    }

解释

解决方法是我们此小团队考虑到的最简方式,
第一、不论是对于viewPager还是viewPager2,我们一致认为,不应该强行保存所有视图在内存中,当页数很多时候,同时每个页面也复杂的话,非常占用内存。
第二、viewPager可以设置setOffscreenPageLimit,用于达到缓存的目的,或者重写viewPager适配器中的destroyItem
但是在AndroidXviewPager2中,虽然也有setOffscreenPageLimit方法,但是其实际使用效果会有点区别。因为viewPager2本身也是用RecyclerView实现的,所以会遵循RecyclerView的复用机制。具体可以去阅读源码,不再解释。
第三、我们也想到过,每次重建视图,就重新new一个Adapter,但是此问题在于,Adapter除了data数据、空布局状态等等需要保存拷贝,还有很多内部状态需要保存拷贝。除非克隆一个Adapter,这是不现实的,唯有复用,并且RecyclerView Adapter是一个松耦合设计,本身就应该具有复用特性,并且实际测试情况下来,重建的RecyclerView视图在复用Adapter后会自动恢复滚动状态等等,这一特性,已经说明了系统本身的设计用意。
再者,每次对Adapter的重建,都是不必要的性能开销。

我相信从Google的出发点,并不是希望我们什么东西都扔给内存,导致内存的滥用。

测试使用的Fragment代码

其实就是非常常规的写法:

class Test1Fragment : Fragment() {

    private val mAdapter by lazy { TestAdapter() }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return inflater.inflate(R.layout.fragment_test, container, false)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

        mRecyclerView.adapter = mAdapter

        if (savedInstanceState != null) {
            //恢复状态
        } else {
            //第一次加载数据
            //…………
            //假如数据加载失败、或者数据为null,显示空布局
            mAdapter.setEmptyView(R.layout.layout_empty, mRecyclerView)
        }

    }

    override fun onDestroy() {
        super.onDestroy()
    }
}

致谢

这是一个很优秀的开源库,感谢各位作者。希望大家都参与到项目的维护中,你为人人,人人为我。

Most helpful comment

如有错误,或者其他见解的,欢迎提出,共同讨论

All 3 comments

如有错误,或者其他见解的,欢迎提出,共同讨论

我觉得adapter不应该持有view,而是保存一个布局资源的id即可,不然在共享缓存池上面也会有问题

没懂,你确定是最简单方式

Was this page helpful?
0 / 5 - 0 ratings