RecyclerView#ScrollBy滑动大量数据卡顿解决方案

1,022 阅读3分钟

背景

项目中需要实现类似苹果联系人应用中的滚动条效果,在数据量比较大时,滑动滚动条会异常卡顿

解决方案

使用LinearLayoutManager#scrollToPositionWithOffset(int position, int offset), 下述解决方案是针对每个Item及ItemDecoration高度固定的场景:

private boolean scrollByForFixedItemHeight(RecyclerView recyclerView, int delta) {
    if (!recyclerView.hasFixedSize()) {
        // RecyclerView设置为FixedSize,可加快滑动性能
        return false;
    }
    RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();
    if (!(layoutManager instanceof LinearLayoutManager)) {
        return false;
    }
    LinearLayoutManager manager = (LinearLayoutManager) layoutManager;
    View firstVisibleView = manager.findViewByPosition(manager.findFirstVisibleItemPosition());
    if (null == firstVisibleView) {
        return false;
    }
    // 因为高度是固定的,所以第一个可见的高度便是平均高度
    int avgHeight = manager.getDecoratedMeasuredHeight(firstVisibleView);
    int destScrolled = recyclerView.computeVerticalScrollOffset() + delta;
    int destPos = destScrolled / avgHeight + 1;
    int destOffset = destPos * avgHeight - destScrolled;
    manager.scrollToPositionWithOffset(destPos, destOffset);
    return true;
}

原因分析

scrollBy

在滑动时,调用的是recyclerView.scrollBy(0, delta)来对RecyclerView滑动的:

@Override
public void scrollBy(int x, int y) {
    ...省略部分代码
    final boolean canScrollVertical = mLayout.canScrollVertically();
    if (canScrollHorizontal || canScrollVertical) {
        scrollByInternal(canScrollHorizontal ? x : 0, canScrollVertical ? y : 0, null);
    }
}
boolean scrollByInternal(int x, int y, MotionEvent ev) {
    ...省略部分代码
    if (mAdapter != null) {
        scrollStep(x, y, mReusableIntPair);
    }
    ...省略部分代码
}
void scrollStep(int dx, int dy, @Nullable int[] consumed) {
    ...省略部分代码
    if (dy != 0) {
        consumedY = mLayout.scrollVerticallyBy(dy, mRecycler, mState);
    }
    ...省略部分代码
}

最终是调用到LayoutManager#scrollVerticallyBy方法中,在我对应的业务场景,LayoutManager是LinearLayoutManager:

public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler,
        RecyclerView.State state) {
    ...省略部分代码
    return scrollBy(dy, recycler, state);
}
int scrollBy(int delta, RecyclerView.Recycler recycler, RecyclerView.State state) {
    ...省略部分代码      
    final int absDelta = Math.abs(delta);
    updateLayoutState(layoutDirection, absDelta, true, state);
    final int consumed = mLayoutState.mScrollingOffset
        + fill(recycler, mLayoutState, state, false);
    ...省略部分代码    
}

在updateLayoutState会计算剩余可用于布局的距离及计算当前应从哪个位置布局,从代码中可以看到是(从离顶部最近的一个 +1 开始布局):

private void updateLayoutState(int layoutDirection, int requiredSpace,
        boolean canUseExistingSpace, RecyclerView.State state) {
    ...省略部分代码  
    mLayoutState.mAvailable = requiredSpace;
    if (layoutToEnd) {
        mLayoutState.mCurrentPosition = getPosition(child) + mLayoutState.mItemDirection;
    } else {
        mLayoutState.mCurrentPosition = getPosition(child) + mLayoutState.mItemDirection;
    }
    ...省略部分代码
}

在fill方法中,会根据可用距离循环布局:

int fill(RecyclerView.Recycler recycler, LayoutState layoutState,
        RecyclerView.State state, boolean stopOnFocusable) {
    final int start = layoutState.mAvailable;
    int remainingSpace = layoutState.mAvailable + layoutState.mExtraFillSpace;
    while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
        ...省略部分代码
        // 这个方法里会调用onBindViewHolder
        layoutChunk(recycler, state, layoutState, layoutChunkResult);
        ...省略部分代码
    }
    ...省略部分代码
}

从上述逻辑看,会根据剩余控件不停布局,假设从pos 1滑动到pos 5000,则会将pos 1~ pos 5000之间的RecyclerView.Adapter#onBindViewHolder均执行一次,从而造成卡顿。

LinearLayoutManager#scrollToPositionWithOffset

public void scrollToPositionWithOffset(int position, int offset) {
    mPendingScrollPosition = position;
    mPendingScrollPositionOffset = offset;
    if (mPendingSavedState != null) {
        mPendingSavedState.invalidateAnchor();
    }
    requestLayout();
}

在调用requestLayout之后,会进行重新布局,调用LinearLayoutManager#onLayoutChildren方法:

public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    ...省略部分代码
    if (!mAnchorInfo.mValid || mPendingScrollPosition != RecyclerView.NO_POSITION
           || mPendingSavedState != null) {
           // calculate anchor position and coordinate
           updateAnchorInfoForLayout(recycler, state, mAnchorInfo);
    }
    ...省略部分代码
    if (mAnchorInfo.mLayoutFromEnd) {
        updateLayoutStateToFillStart(mAnchorInfo);
    } else {
        updateLayoutStateToFillEnd(mAnchorInfo);
    }

上述代码中updateAnchorInfoForLayout会将待滑动到的位置赋值到anchorInfo中,在updateLayoutStateToFillEnd中将当前要布局的位置设置为待滑动到的位置。

    private void updateAnchorInfoForLayout(RecyclerView.Recycler recycler, RecyclerView.State state,
            AnchorInfo anchorInfo) {
        if (updateAnchorFromPendingData(state, anchorInfo)) {
            return;
        }
  }
private boolean updateAnchorFromPendingData(RecyclerView.State state, AnchorInfo anchorInfo) {
    ...省略部分代码
    // if child is visible, try to make it a reference child and ensure it is fully visible.
    // if child is not visible, align it depending on its virtual position.
    anchorInfo.mPosition = mPendingScrollPosition;
    ...省略部分代码
}
private void updateLayoutStateToFillEnd(AnchorInfo anchorInfo) {
    updateLayoutStateToFillEnd(anchorInfo.mPosition, anchorInfo.mCoordinate);
}
private void updateLayoutStateToFillEnd(int itemPosition, int offset) {
    mLayoutState.mCurrentPosition = itemPosition;
}

在fill方法里,会根据mLayoutState.mCurrentPosition即传入的滑动位置开始布局

View next(RecyclerView.Recycler recycler) {
    if (mScrapList != null) {
        return nextViewFromScrapList();
    }
    final View view = recycler.getViewForPosition(mCurrentPosition);
    mCurrentPosition += mItemDirection;
    return view;
}

总结

因为直接从要滑动到的位置开始布局的,所以scrollToPositionWithOffsetscrollBy性能好很多。