Realização e análise do efeito de loop infinito RecyclerView

prefácio

Quando eu estava navegando no blog há dois dias, encontrei uma captura de tela da sala de transmissão ao vivo. Os efeitos de alternância e loop desta lista de produtos são muito bons:

Os alunos familiarizados com o Android devem pensar rapidamente que esta é uma lista linear implementada pelo recyclerView, que tem dois efeitos principais:

1. Depois que o item superior é trocado, o estilo é ampliado + animação de transição.
2. A lista é reproduzida automaticamente e infinitamente.

O primeiro efeito é mais fácil de conseguir. Alterações no layout do item superior podem ser feitas monitorando o OnScroll do RecyclerView para determinar a posição do item e dimensioná-lo. Ou julgue o primeiro item visível e modifique o estilo quando o layoutManager personalizado estiver executando operações relacionadas a layoutChild.

A reprodução automática pode ser feita usando julgamento de gestos + tarefas atrasadas.

Este artigo fornece principalmente a implementação do LayoutManager personalizado para o segundo efeito de reprodução de loop infinito .

texto

Existem rodas existentes?

Primeiro verifique se existe uma roda adequada, "não reinvente a roda", a menos que a roda não atenda às necessidades.

1. Modificar adaptador e implementação de mapeamento de dados

Depois do google, existem muitos blogs sobre o loop infinito do recyclerView, e o conteúdo é basicamente o mesmo. A maioria dos blogs mencionou/usou uma forma de modificar o adaptador e o mapeamento de dados, que tem principalmente as seguintes etapas:

1. Modifique o método getItemCount() do adaptador para retornar Integer.MAX_VALUE

2. Ao buscar dados do item, use a posição do índice % list.size

3. Durante a inicialização, deixe o recyclerView deslizar para uma posição aproximadamente Integer.MAX_VALUE/2 para evitar que o usuário deslize para o limite.

在逛stackOverFlow时找到了这种方案的出处: java - How to cycle through items in Android RecyclerView? - Stack Overflow

这个方法是建立了一个数据和位置的映射关系,因为itemCount无限大,所以用户可以一直滑下去,又因对位置与数据的取余操作,就可以在每经历一个数据的循环后重新开始。看上去RecyclerView就是无限循环的。

很多博客会说这种方法并不好,例如对索引进行了计算/用户可能会滑到边界导致需要再次动态调整到中间之类的。然后自己写了一份自定义layoutManager后觉得用自定义layoutManager的方法更好。

其实我倒不这么觉得。

事实上,这种方法已经可以很好地满足大部分无限循环的场景,并且由于它依然沿用了LinearLayoutManager。就代表列表依旧可以使用LLM(LinearLayoutManager)封装好的布局和缓存机制。

  1. 首先索引计算这个谈不上是个问题。至于用户滑到边界的情况,也可以做特殊处理调整位置。(另外真的有人会滑约Integer.MAX_VALUE/2大约1073741823个position吗?

  2. 性能上也无需担心。从数字的直觉上,设置这么多item然后初始化scrollToPosition(Integer.MAX_VALUE/2)看上去好像很可怕,性能上可能有问题,会卡顿巴拉巴拉。

实际从初始化到scrollPosition到真正onlayoutChildren系列操作,主要经过了以下几步。

先上一张流程图:

image.png

  • 设置mPendingScrollPosition,确定要滑动的位置,然后requestLayout()请求布局;
/**
 * <p>Scroll the RecyclerView to make the position visible.</p>
 *
 * <p>RecyclerView will scroll the minimum amount that is necessary to make the
 * target position visible. If you are looking for a similar behavior to
 * {@link android.widget.ListView#setSelection(int)} or
 * {@link android.widget.ListView#setSelectionFromTop(int, int)}, use
 * {@link #scrollToPositionWithOffset(int, int)}.</p>
 *
 * <p>Note that scroll position change will not be reflected until the next layout call.</p>
 *
 * @param position Scroll to this adapter position
 * @see #scrollToPositionWithOffset(int, int)
 */
 

@Override
public void scrollToPosition(int position) {
    mPendingScrollPosition = position;//更新position
    mPendingScrollPositionOffset = INVALID_OFFSET; 
    if (mPendingSavedState != null) {
        mPendingSavedState.invalidateAnchor();
    }
    requestLayout();
}
复制代码
  • 请求布局后会触发recyclerView的dispatchLayout,最终会调用onLayoutChildren进行子View的layout,如官方注释里描述的那样,onLayoutChildren最主要的工作是:确定锚点、layoutState,调用fill填充布局。

onLayoutChildren部分源码:

@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    // layout algorithm:
    // 1) by checking children and other variables, find an anchor coordinate and an anchor
    //  item position.
    // 2) fill towards start, stacking from bottom
    // 3) fill towards end, stacking from top
    // 4) scroll to fulfill requirements like stack from bottom.
 
    //..............
    // 省略,前面主要做了一些异常状态的检测、针对焦点的特殊处理、确定锚点对anchorInfo赋值、偏移量计算
    int startOffset;
    int endOffset;
    final int firstLayoutDirection;
    if (mAnchorInfo.mLayoutFromEnd) {
        // fill towards start
        updateLayoutStateToFillStart(mAnchorInfo); //根据mAnchorInfo更新layoutState
        mLayoutState.mExtraFillSpace = extraForStart;
        fill(recycler, mLayoutState, state, false);//填充
        startOffset = mLayoutState.mOffset;
        final int firstElement = mLayoutState.mCurrentPosition;
        if (mLayoutState.mAvailable > 0) {
            extraForEnd += mLayoutState.mAvailable;
        }
        // fill towards end
        updateLayoutStateToFillEnd(mAnchorInfo);//更新layoutState为fill做准备
        mLayoutState.mExtraFillSpace = extraForEnd;
        mLayoutState.mCurrentPosition += mLayoutState.mItemDirection;
        fill(recycler, mLayoutState, state, false);//填充
        endOffset = mLayoutState.mOffset;
        if (mLayoutState.mAvailable > 0) {
            // end could not consume all. add more items towards start
            extraForStart = mLayoutState.mAvailable;
            updateLayoutStateToFillStart(firstElement, startOffset);//更新layoutState为fill做准备
            mLayoutState.mExtraFillSpace = extraForStart;
            fill(recycler, mLayoutState, state, false);
            startOffset = mLayoutState.mOffset;
        }
    } else {
        //layoutFromStart 同理,省略
    }
    //try to fix gap , 省略
复制代码
  • onLayoutChildren中会调用updateAnchorInfoForLayout更新anchoInfo锚点信息,updateLayoutStateToFillStart/End再根据anchorInfo更新layoutState为fill填充做准备。

  • fill的源码: `
int fill(RecyclerView.Recycler recycler, LayoutState layoutState,
        RecyclerView.State state, boolean stopOnFocusable) {
    // max offset we should set is mFastScroll + available
    final int start = layoutState.mAvailable;
    if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
        // TODO ugly bug fix. should not happen
        if (layoutState.mAvailable < 0) {
            layoutState.mScrollingOffset += layoutState.mAvailable;
        }
        recycleByLayoutState(recycler, layoutState);
    }
    int remainingSpace = layoutState.mAvailable + layoutState.mExtraFillSpace;
    LayoutChunkResult layoutChunkResult = mLayoutChunkResult;
    // (不限制layout个数/还有剩余空间) 并且 有剩余数据
    while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
        layoutChunkResult.resetInternal();
        if (RecyclerView.VERBOSE_TRACING) {
            TraceCompat.beginSection("LLM LayoutChunk");
        }
        layoutChunk(recycler, state, layoutState, layoutChunkResult);
        if (RecyclerView.VERBOSE_TRACING) {
            TraceCompat.endSection();
        }
        if (layoutChunkResult.mFinished) {
            break;
        }
        layoutState.mOffset += layoutChunkResult.mConsumed * layoutState.mLayoutDirection;
        /**
         * Consume the available space if:
         * * layoutChunk did not request to be ignored
         * * OR we are laying out scrap children
         * * OR we are not doing pre-layout
         */
        if (!layoutChunkResult.mIgnoreConsumed || layoutState.mScrapList != null
                || !state.isPreLayout()) {
            layoutState.mAvailable -= layoutChunkResult.mConsumed;
            // we keep a separate remaining space because mAvailable is important for recycling
            remainingSpace -= layoutChunkResult.mConsumed;
        }

        if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
            layoutState.mScrollingOffset += layoutChunkResult.mConsumed;
            if (layoutState.mAvailable < 0) {
                layoutState.mScrollingOffset += layoutState.mAvailable;
            }
            recycleByLayoutState(recycler, layoutState);//回收子view
        }
        if (stopOnFocusable && layoutChunkResult.mFocusable) {
            break;
        }
    }
    if (DEBUG) {
        validateChildOrder();
    }
    return start - layoutState.mAvailable;
复制代码

fill主要干了两件事:

  • 循环调用layoutChunk布局子view并计算可用空间
  • 回收那些不在屏幕上的view

所以可以清晰地看到LLM是按需layout、回收子view。

就算创建一个无限大的数据集,再进行滑动,它也是如此。可以写一个修改adapter和数据映射来实现无限循环的例子,验证一下我们的猜测:

//adapter关键代码
@NonNull
@Override
public DemoViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
    LayoutInflater inflater = LayoutInflater.from(parent.getContext());
    Log.d("DemoAdapter","onCreateViewHolder");
    return new DemoViewHolder(inflater.inflate(R.layout.item_demo, parent, false));
}

@Override
public void onBindViewHolder(@NonNull DemoViewHolder holder, int position) {
    Log.d("DemoAdapter","onBindViewHolder: position"+position);
    String text = mData.get(position % mData.size());
    holder.bind(text);
}

@Override
public int getItemCount() {
    return Integer.MAX_VALUE;
}
复制代码

在代码我们里打印了onCreateViewHolder、onBindViewHolder的情况。我们只要观察这viewHolder的情况,就知道进入界面再滑到Integer.MAX_VALUE/2时会初始化多少item。 `

RecyclerView recyclerView = findViewById(R.id.rv);
recyclerView.setAdapter(new DemoAdapter());
LinearLayoutManager layoutManager =  new LinearLayoutManager(this);
layoutManager.setOrientation(RecyclerView.VERTICAL);
recyclerView.setLayoutManager(layoutManager);
recyclerView.scrollToPosition(Integer.MAX_VALUE/2);
复制代码

初始化后ui效果:

日志打印: image.png

可以看到,页面上共有5个item可见,LLM也按需创建、layout了5个item。

2、自定义layoutManager

找了找网上自定义layoutManager去实现列表循环的博客和代码,拷贝和复制的很多,找不到源头是哪一篇,这里就不贴链接了。大家都是先说第一种修改adapter的方式不好,然后甩了一份自定义layoutManager的代码。

然而自定义layoutManager难点和坑都很多,很容易不小心就踩到,一些博客的代码也有类似问题。 基本的一些坑点在张旭童大佬的博客中有提及, 【Android】掌握自定义LayoutManager(一) 系列开篇 常见误区、问题、注意事项,常用API。

比较常见的问题是:

  1. 不计算可用空间和子view消费的空间,layout出所有的子view。相当于抛弃了子view的复用机制
  2. 没有合理利用recyclerView的回收机制
  3. 没有支持一些常用但比较重要的api的实现,如前面提到的scrollToPosition。

其实最理想的办法是继承LinearLayoutManager然后修改,但由于LinearLayoutManager内部封装的原因,不方便像GridLayoutManager那样去继承LinearLayoutManager然后进行扩展(主要是包外的子类会拿不到layoutState等)。

要实现一个线性布局的layoutManager,最重要的就是实现一个类似LLM的fill(前面有提到过源码,可以翻回去看看)和layoutChunk方法。

(当然,可以照着LLM写一个丐版,本文就是这么做的。)

fill方法很重要,就如同官方注释里所说的,它是一个magic func。

从OnLayoutChildren到触发scroll滑动,都是调用fill来实现布局。

/**
 * The magic functions :). Fills the given layout, defined by the layoutState. This is fairly
 * independent from the rest of the {@link LinearLayoutManager}
 * and with little change, can be made publicly available as a helper class.
 */
int fill(RecyclerView.Recycler recycler, LayoutState layoutState,
        RecyclerView.State state, boolean stopOnFocusable) {
复制代码

前面提到过fill主要干了两件事:

  • 循环调用layoutChunk布局子view并计算可用空间
  • 回收那些不在屏幕上的view

而负责子view布局的layoutChunk则和把一个大象放进冰箱一样,主要分三步走:

  1. add子view
  2. measure
  3. layout 并计算消费了多少空间

就像下面这样:

/**
 * layout具体子view
 */
private void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,
                         LayoutState layoutState, LayoutChunkResult result) {
    View view = layoutState.next(recycler, state);
    if (view == null) {
        result.mFinished = true;
        return;
    }
    RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) view.getLayoutParams();

    // add
    if (layoutState.mLayoutDirection != LayoutState.LAYOUT_START) {
        addView(view);
    } else {
        addView(view, 0);
    }

    Rect insets = new Rect();
    calculateItemDecorationsForChild(view, insets);

    // 测量
    measureChildWithMargins(view, 0, 0);

    //布局
    layoutChild(view, result, params, layoutState, state);

    // Consume the available space if the view is not removed OR changed
    if (params.isItemRemoved() || params.isItemChanged()) {
        result.mIgnoreConsumed = true;
    }
    result.mFocusable = view.hasFocusable();
}
复制代码

那最关键的如何实现循环呢??

其实和修改adapter的实现方法有异曲同工之妙,本质都是修改位置与数据的映射关系。

修改layoutStae的方法:

    boolean hasMore(RecyclerView.State state) {
        return Math.abs(mCurrentPosition) <= state.getItemCount();
    }


    View next(RecyclerView.Recycler recycler, RecyclerView.State state) {
        int itemCount = state.getItemCount();
        mCurrentPosition = mCurrentPosition % itemCount;
        if (mCurrentPosition < 0) {
            mCurrentPosition += itemCount;
        }
        final View view = recycler.getViewForPosition(mCurrentPosition);
        mCurrentPosition += mItemDirection;
        return view;
    }

}
复制代码

最终效果:

源码地址:aFlyFatPig/cycleLayoutManager (github.com)

注:也可以直接引用依赖使用,详见readme.md。

后记

本文介绍了recyclerview无限循环效果的两种不同实现方法与解析。

虽然自定义layoutManager坑点很多并且很少用的到,但了解下也会对recyclerView有更深的理解。

Acho que você gosta

Origin juejin.im/post/7215200495983214629
Recomendado
Clasificación