引子

上一篇用“动画”计划实现了弹幕成果,自定义容器控件,每一条弹幕都作为其子控件,将子弹幕的初始地位置于容器控件左边的外侧,每条弹幕都通过从右向左的动画来实现贯通屏幕的平移。

这个计划的性能有待改善,关上 GPU 出现模式:

起因在于容器控件会提前构建所有弹幕视图并将它们沉积在屏幕的右侧。若弹幕数据量大,则容器控件会因为子视图过多而消耗大量 measure + layout 工夫。

既然是因为提前加载了不须要的弹幕才导致的性能问题,那是不是能够只预加载无限个弹幕?

只加载无限个子视图且可滚动的控件,不就是 RecyclerView 吗!它并不会把 Adapter 中所有的数据提前全副转换成 View,而是只预加载一屏的数据,而后随着滚动再继续一直地加载新数据。

为了用 RecyclerView 实现弹幕成果,就得 “自定义 LayoutManager”

自定义布局参数

自定义 LayoutManager 的第一步:继承RecyclerView.LayoutManger

1.class LaneLayoutManager: RecyclerView.LayoutManager() {2.override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {}3.}4.复制代码

依据 AndroidStudio 的提醒,必须实现一个generateDefaultLayoutParams()的办法。它用于生成一个自定义的LayoutParams对象,目标是在布局参数中携带自定义的属性。

以后场景中没有自定义布局参数的需要,遂能够这样实现这个办法:

1.override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {2.return RecyclerView.LayoutParams(3.RecyclerView.LayoutParams.WRAP_CONTENT,4.RecyclerView.LayoutParams.WRAP_CONTENT5.)6.}7.复制代码

示意沿用RecyclerView.LayoutParams

首次填充弹幕

自定义 LayoutManager 最重要的环节就是定义如何布局表项。

对于LinearLayoutManager来说,表项沿着一个方向线性铺开。当列表第一次展现时,从列表顶部到底部,表项被一一填充,这称为“首次填充”。

对于LaneLayoutManager来说,首次填充即是“将一列弹幕填充到紧挨着列表尾部的中央(在屏幕之外,可不见)”。

对于LinearLayoutManager如何填充表项的源码剖析,在之前的一篇RecyclerView 面试题 | 滚动时表项是如何被填充或回收的?中剖析过,现征引论断如下:

  1. LinearLayoutManager 在onLayoutChildren()办法中布局表项。
  2. 布局表项的要害办法包含fill()layoutChunk(),前者示意列表的一次填充动作,后者示意填充单个表项。
  3. 在一次填充动作中通过一个while循环不断地填充表项,直到列表残余空间用完。用伪代码示意这个过程如下所示:
1.public class LinearLayoutManager { 2.// 布局表项3.public void onLayoutChildren() { 4.// 填充表项 5.fill() { 6.while(列表有残余空间){ 7. // 填充单个表项 8.layoutChunk(){ 9. // 让表项成为子视图10. addView(view)11.}12.}13. }14.}15.}16.复制代码
  1. 为了防止每次填充新表项时都从新创立视图,须要从 RecyclerView 的缓存中获取表项视图,即调用Recycler.getViewForPosition()。对于该办法的详解能够点击RecyclerView 缓存机制 | 如何复用表项?

看过源码,了解原理后,弹幕布局就能够仿照着写:

1.class LaneLayoutManager : RecyclerView.LayoutManager() { 2.private val LAYOUT_FINISH = -1 // 标记填充完结 3.private var adapterIndex = 0 // 列表适配器索引 4 // 弹幕纵向间距5. var gap = 6.get() = field.dp 7.// 布局孩子8.override fun onLayoutChildren(recycler: RecyclerView.Recycler?, state: RecyclerView.State?) 9.{10.fill(recycler)11. }12.// 填充表项13. private fun fill(recycler: RecyclerView.Recycler?) 14.{15.// 可供弹幕布局的高度,即列表高度16.var totalSpace = height - paddingTop - paddingBottom17.var remainSpace = totalSpace18.// 只有空间足够,就持续填充表项19.while (goOnLayout(remainSpace)) {20.// 填充单个表项21.val consumeSpace = layoutView(recycler)22. if (consumeSpace == LAYOUT_FINISH) break23. // 更新残余空间24.remainSpace -= consumeSpace25.}26.}27.28.// 是否还有残余空间用于填充 以及 是否有更多数据29.private fun goOnLayout(remainSpace: Int) = remainSpace > 0 && adapterIndex in 0 until itemCount30.31.    // 填充单个表项32.    private fun layoutView(recycler: RecyclerView.Recycler?): Int {33.        // 1. 从缓存池中获取表项视图34.        // 若缓存未命中,则会触发 onCreateViewHolder() 和 onBindViewHolder()35.        val view = recycler?.getViewForPosition(adapterIndex)36.        view ?: return LAYOUT_FINISH // 获取表项视图失败,则完结填充37.        // 2. 将表项视图成为列表孩子38        addView(view)39        // 3. 测量表项视图40.        measureChildWithMargins(view, 0, 0)41        // 可供弹幕布局的高度,即列表高度42.        var totalSpace = height - paddingTop - paddingBottom43.        // 弹幕泳道数,即列表纵向能够包容几条弹幕44.        val laneCount = (totalSpace + gap) / (view.measuredHeight + gap)45        // 计算以后表项所在泳道46.        val index = adapterIndex % laneCount47        // 计算以后表项上下左右边框48.        val left = width // 弹幕右边位于列表左边49.        val top = index * (view.measuredHeight + gap)50.        val right = left + view.measuredWidth51.        val bottom = top + view.measuredHeight52.        // 4. 布局表项(该办法思考到了 ItemDecoration)53.        layoutDecorated(view, left, top, right, bottom)54.        val verticalMargin = (view.layoutParams as? RecyclerView.LayoutParams)?.let { it.topMargin + it.bottomMargin } ?: 055.        // 持续获取下一个表项视图56.        adapterIndex++57.        // 返回填充表项耗费像素值58.        return getDecoratedMeasuredHeight(view) + verticalMargin.59.    }60.}61.复制代码

每一条程度的,供弹幕滚动的,称之为“泳道”。

泳道是从列表顶部往底部垂直铺开的,列表高度/泳道高度 = 泳道的数量。

fill()办法中就以“列表残余高度>0”为循环条件,一直地向泳道中填充表项,它得经验了四个步骤:

  1. 从缓存池中获取表项视图
  2. 将表项视图成为列表孩子
  3. 测量表项视图
  4. 布局表项

这四步之后,表项绝对于列表的地位就确定下来,并且表项的视图曾经渲染实现。

运行下 demo,果然~,什么也没看到。。。

列表滚动逻辑还未加上,所以布局在列表左边外侧的表项仍然处于不可见地位。但能够利用 AndroidStudio 的Layout Inspector工具来验证首次填充代码的正确性:

Layout Inspector中会用线框示意屏幕以外的控件,如图所示,列表左边的外侧被四个表项占满。

主动滚动弹幕

为了看到填充的表项,就得让列表自发地滚动起来。

最间接的计划就是不停地调用RecyclerView.smoothScrollBy()。为此写了一个扩大法办法用于倒计时:

1.fun <T> countdown( .2.    duration: Long, // 倒计时总时长 3.    interval: Long, // 倒计时距离 4.    onCountdown: suspend (Long) -> T // 倒计时回调 5.): Flow<T> = 6.    flow { (duration - interval downTo 0 step interval).forEach { emit(it) } } 7.        .onEach { delay(interval) } 8.        .onStart { emit(duration) } 9.        .map { onCountdown(it) }10.        .flowOn(Dispatchers.Default)11.复制代码

应用Flow构建了一个异步数据流,该流每次都会发射一个倒计时的剩余时间。对于Flow的具体解释能够点击Kotlin 异步 | Flow 利用场景及原理

而后就能像这样实现列表主动滚动:

1.countdown(Long.MAX_VALUE, 50) {2.    recyclerView.smoothScrollBy(10, 0)3.}.launchIn(MainScope())4.复制代码

每 50 ms 向左滚动 10 像素。成果如下图所示:

继续填充弹幕

因为只做了首次填充,即每个泳道只填充了一个表项,所以随着第一排的表项滚入屏幕后,就没有后续弹幕了。

LayoutManger.onLayoutChildren()只会在列表首次布局时调用一次,即首次填充弹幕只会执行一次。为了继续一直地展现弹幕,必须在滚动时不停地填充表项。

之前的一篇RecyclerView 面试题 | 滚动时表项是如何被填充或回收的?剖析过列表滚动时继续填充表项的源码,现征引论断如下:

  1. RecyclerView 在滚动产生之前,会依据预计滚动位移大小来决定须要向列表中填充多少新的表项。
  2. 体现在源码上,即是在scrollVerticallyBy()中调用fill()填充表项:
1.public class LinearLayoutManager { 2.   @Override 3.   public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) { 4.       return scrollBy(dy, recycler, state); 5.   } 6.7.   int scrollBy(int delta, RecyclerView.Recycler recycler, RecyclerView.State state) { 8.       ... 9.       // 填充表项10.       final int consumed = mLayoutState.mScrollingOffset + fill(recycler, mLayoutState, state, false);11.       ...12.   }13.}14.复制代码

对于弹幕的场景,也能够仿照着写一个相似的:

1.class LaneLayoutManager : RecyclerView.LayoutManager() { 2.override fun scrollHorizontallyBy(dx: Int, recycler: RecyclerView.Recycler?, state: RecyclerView.State?): Int { 3.        return scrollBy(dx, recycler)  4.    } 5. 6.    override fun canScrollHorizontally(): Boolean { 7.        return true // 示意列表能够横向滚动 8.    } 9.}10.复制代码

重写canScrollHorizontally()返回 true 示意列表可横向滚动。

RecyclerView 的滚动是一段一段进行的,每一段滚动的位移都会通过scrollHorizontallyBy()传递过去。通常在该办法中依据位移大小填充新的表项,而后再触发列表的滚动。对于列表滚动的源码剖析能够点击RecyclerView 的滚动是怎么实现的?(一)| 解锁浏览源码新姿态 。

scrollBy()封装了依据滚动继续填充表项的逻辑。(稍后剖析)

继续填充表项比首次填充的逻辑更简单一点,首次填充只有将表项依照泳道从上到下顺次铺开填满列表的高度即可。而继续填充得依据滚动间隔计算出哪个泳道行将枯竭(没有弹幕展现的泳道),只对枯竭的泳道填充表项。

为了疾速获取枯竭泳道,得形象出一个“泳道”构造,以保留该泳道的滚动信息:

1.// 泳道2.data class Lane(3.    var end: Int, // 泳道开端弹幕横坐标4.    var endLayoutIndex: Int, // 泳道开端弹幕的布局索引5.    var startLayoutIndex: Int // 泳道头部弹幕的布局索引6.)7.复制代码

泳道构造蕴含三个数据,别离是:

  1. 泳道开端弹幕横坐标:它是泳道中最初一个弹幕的 right 值,即它的右侧绝对于 RecyclerView 左侧的间隔。该值用于判断通过一段位移的滚动后,该泳道是否会枯竭。
  2. 泳道开端弹幕的布局索引:它是泳道中最初一个弹幕的布局索引,记录它是为了不便地通过getChildAt()获取泳道中最初一个弹幕的视图。(布局索引有别于适配器索引,RecyclerView 只会持有无限个表项,所以布局索引的取值范畴是[0,x],x的取值比一屏表项稍多一点,而对于弹幕来说,适配器索引的取值是[0,∞])
  3. 泳道头部弹幕的布局索引:与 2 相似,为了不便地取得泳道第一个弹幕的视图。

借助于泳道这个构造,咱们得重构下首次填充表项的逻辑:

1.class LaneLayoutManager : RecyclerView.LayoutManager() { 2.    // 首次填充过程中的上一个被填充的弹幕 3.    private var lastLaneEndView: View? = null 4.   // 所有泳道 5.    private var lanes = mutableListOf<Lane>() 6.    // 首次填充弹幕 7.    override fun onLayoutChildren(recycler: RecyclerView.Recycler?, state: RecyclerView.State?) { 8.        fillLanes(recycler, lanes) 9.    }10.   // 通过循环填充弹幕11.    private fun fillLanes(recycler: RecyclerView.Recycler?, lanes: MutableList<Lane>) {12.        lastLaneEndView = null13.        // 如果列表垂直方向上还有空间则持续填充弹幕14.        while (hasMoreLane(height - lanes.bottom())) {15.            // 填充单个弹幕到泳道中16.            val consumeSpace = layoutView(recycler, lanes)17.            if (consumeSpace == LAYOUT_FINISH) break18.        }19.   }20.    // 填充单个弹幕,并记录泳道信息21.    private fun layoutView(recycler: RecyclerView.Recycler?, lanes: MutableList<Lane>): Int {22.        val view = recycler?.getViewForPosition(adapterIndex)23.        view ?: return LAYOUT_FINISH24.        measureChildWithMargins(view, 0, 0)25.        val verticalMargin = (view.layoutParams as? RecyclerView.LayoutParams)?.let { it.topMargin + it.bottomMargin } ?:26        val consumed = getDecoratedMeasuredHeight(view) + if (lastLaneEndView == null) 0 else verticalGap + verticalMargin27        // 若列表垂直方向还能够包容一条新得泳道,则新建泳道,否则进行填充28        if (height - lanes.bottom() - consumed > 0) {29            lanes.add(emptyLane(adapterIndex))30        } else return LAYOUT_FINISH3132        addView(view)33        // 获取最新追加的泳道34        val lane = lanes.last()35        // 计算弹幕上下左右的边框36        val left = lane.end + horizontalGap37        val top = if (lastLaneEndView == null) paddingTop else lastLaneEndView!!.bottom + verticalGap38        val right = left + view.measuredWidth39        val bottom = top + view.measuredHeight40        // 定位弹幕41        layoutDecorated(view, left, top, right, bottom)42        // 更新泳道开端横坐标及布局索引43        lane.apply {44            end = right45            endLayoutIndex = childCount - 1 // 因为是刚追加的表项,所以其索引值必然是最大的46        }4748        adapterIndex++49        lastLaneEndView = view50        return consumed51    }52}53.复制代码

首次填充弹幕也是一个一直在垂直方向上追加泳道的过程,判断是否追加的逻辑如下:列表高度 - 以后最底部泳道的 bottom 值 - 这次填充弹幕耗费的像素值 > 0,其中lanes.bottom()是一个List<Lane>的扩大办法:

1fun List<Lane>.bottom() = lastOrNull()?.getEndView()?.bottom ?: 02复制代码

它获取泳道列表中的最初一个泳道,而后再获取该泳道中最初一条弹幕视图的 bottom 值。其中getEndView()被定义为Lane的扩大办法:

1class LaneLayoutManager : RecyclerView.LayoutManager() {2.    data class Lane(var end: Int, var endLayoutIndex: Int, var startLayoutIndex: Int)3.    private fun Lane.getEndView(): View? = getChildAt(endLayoutIndex)4.}5.复制代码

实践上“获取泳道中最初一条弹幕视图”应该是Lane提供的办法。但偏偏把它定义成Lane的扩大办法,并且还定义在LaneLayoutManager的外部,这是多此一举吗?

若定义在 Lane 外部,则在该上下文中无法访问到LayoutManager.getChildAt()办法,若只定义为LaneLayoutManager的公有办法,则无法访问到endLayoutIndex。所以此举是为了综合两个上下文环境。

再回头看一下滚动时继续填充弹幕的逻辑:

1.class LaneLayoutManager : RecyclerView.LayoutManager() { 2.    override fun scrollHorizontallyBy(dx: Int, recycler: RecyclerView.Recycler?, state: RecyclerView.State?): Int { 3.        return scrollBy(dx, recycler)  4.    } 5.    // 依据位移大小决定填充多少表项 6.    private fun scrollBy(dx: Int, recycler: RecyclerView.Recycler?): Int { 7.        // 若列表没有孩子或未产生滚动则返回 8.        if (childCount == 0 || dx == 0) return 0 9.        // 在滚动还未开始前,更新泳道信息10.        updateLanesEnd(lanes)11.        // 获取滚动绝对值12.        val absDx = abs(dx) 13.        // 遍历所有泳道,向其中的枯竭泳道填充弹幕14.        lanes.forEach { lane ->15.            if (lane.isDrainOut(absDx)) layoutViewByScroll(recycler, lane)16.       }17.        // 滚动列表的落脚点:将表项向手指位移的反方向平移雷同的间隔18.        offsetChildrenHorizontal(-absDx)19.       return dx20.    }21.}22.复制代码

滚动时继续填充弹幕逻辑遵循这样的程序:

  1. 更新泳道信息
  2. 向枯竭泳道填充弹幕
  3. 触发滚动

其中 1,2 都产生在实在的滚动之前,在滚动之前,曾经拿到了滚动位移,依据位移就能够计算出滚动产生之后行将枯竭的泳道:

1// 泳道是否枯竭2.private fun Lane.isDrainOut(dx: Int): Boolean = getEnd(getEndView()) - dx < width3.// 获取表项的 right 值4.private fun getEnd(view: View?) = 5.    if (view == null) Int.MIN_VALUE 6.    else getDecoratedRight(view) + (view.layoutParams as RecyclerView.LayoutParams).rightMargin7.复制代码

泳道枯竭的断定根据是:泳道最初一个弹幕的左边向左平移 dx 后是否小于列表宽度。若小于则示意泳道中的弹幕曾经全展现完了,此时就要持续填充弹幕:

1.// 弹幕滚动时填充新弹幕 2.private fun layoutViewByScroll(recycler: RecyclerView.Recycler?, lane: Lane) { 3.    val view = recycler?.getViewForPosition(adapterIndex) 4    view ?: return 5.    measureChildWithMargins(view, 0, 0) 6    addView(view) 7 8    val left = lane.end + horizontalGap 9.    val top = lane.getEndView()?.top ?: paddingTop10.    val right = left + view.measuredWidth11.    val bottom = top + view.measuredHeight12.    layoutDecorated(view, left, top, right, bottom)13.    lane.apply {14.        end = right15.        endLayoutIndex = childCount - 116.    }17.    adapterIndex++18}19复制代码

填充逻辑和首次填充的简直一样,惟一的区别是,滚动时的填充不可能因为空间不够而提前返回,因为是找准了泳道进行填充的。

为什么要在填充枯竭泳道之前更新泳道信息?

1.// 更新泳道信息2.private fun updateLanesEnd(lanes: MutableList<Lane>) {3.    lanes.forEach { lane ->4.        lane.getEndView()?.let { lane.end = getEnd(it) }5.   }6.}7.复制代码

因为 RecyclerView 的滚动是一段一段进行的,看似滚动了一丢丢间隔,scrollHorizontallyBy()可能要回调十几次,每一次回调,弹幕都会后退一小段,即泳道开端弹幕的横坐标会发生变化,这变动得同步到Lane构造中。否则泳道枯竭的计算就会出错。

有限滚动弹幕

通过首次和继续填充,弹幕曾经能够晦涩的滚起来了。那如何让仅有的弹幕数据有限轮播呢?

只须要在Adapter上做一个小手脚:

1.class LaneAdapter : RecyclerView.Adapter<ViewHolder>() { 2.    // 数据集 3.    private val dataList = MutableList()4.    override fun getItemCount(): Int { 5.        // 设置表项为无穷大 6.        return Int.MAX_VALUE 7.   } 8. 9.    override fun onBindViewHolder(holder: ViewHolder, position: Int) {10.        val realIndex = position % dataList.size11        ...12    }1314    override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList<Any>) {15        val realIndex = position % dataList.size16        ...17    }18}19复制代码

设置列表的数据量为无穷大,当创立表项视图及为其绑定数据时,对适配器索引取模。

回收弹幕

剩下的最初一个难题是,如何回收弹幕。若没有回收,也对不起RecyclerView这个名字。

LayoutManager中就定义有回收表项的入口:

1.public void removeAndRecycleView(View child, @NonNull Recycler recycler) {2.    removeView(child);3.    recycler.recycleView(child);4.}5.复制代码

回收逻辑最终会委托给Recycler实现,对于回收表项的源码剖析,能够点击上面的文章:

  1. RecyclerView 缓存机制 | 回收些什么?
  2. RecyclerView 缓存机制 | 回收到哪去?
  3. RecyclerView 动画原理 | 换个姿态看源码(pre-layout)
  4. RecyclerView 动画原理 | pre-layout,post-layout 与 scrap 缓存的关系
  5. RecyclerView 面试题 | 哪些状况下表项会被回收到缓存池?

对于弹幕场景,什么时候回收弹幕?

当然是弹幕滚出屏幕的那一瞬间!

如何能力捕捉到这个霎时 ?

当然是通过在每次滚动产生之前用位移计算出来的!

在滚动时除了要继续填充弹幕,还得继续回收弹幕(源码里就是这么写的,我只是剽窃一下):

1.private fun scrollBy(dx: Int, recycler: RecyclerView.Recycler?): Int { 2.    if (childCount == 0 || dx == 0) return 0 3.    updateLanesEnd(lanes) 4.    val absDx = abs(dx) 5.    // 继续填充弹幕 6.    lanes.forEach { lane -> 7.        if (lane.isDrainOut(absDx)) layoutViewByScroll(recycler, lane) 8.    } 9.    // 继续回收弹幕10.    recycleGoneView(lanes, absDx, recycler)11.    offsetChildrenHorizontal(-absDx)12    return dx13.}14.复制代码

这是scrollBy()的完整版,滚动时先填充,紧接着马上回收:

1.fun recycleGoneView(lanes: List<Lane>, dx: Int, recycler: RecyclerView.Recycler?) { 2.    recycler ?: return 3.    // 遍历泳道 4.    lanes.forEach { lane -> 5.        // 获取泳道头部弹幕 6.        getChildAt(lane.startLayoutIndex)?.let { startView -> 7.            // 如果泳道头部弹幕曾经滚出屏幕则回收它 8.           if (isGoneByScroll(startView, dx)) { 9.                // 回收弹幕视图10.                removeAndRecycleView(startView, recycler)11.                // 更新泳道信息12.                updateLaneIndexAfterRecycle(lanes, lane.startLayoutIndex)13.                lane.startLayoutIndex += lanes.size - 114.            }15.        }16.    }17.}18.复制代码

回收和填充一样,也是通过遍历找到行将隐没的弹幕,回收之。

判断弹幕隐没的逻辑如下:

1.fun isGoneByScroll(view: View, dx: Int): Boolean = getEnd(view) - dx < 02.复制代码

如果弹幕的 right 向左平移 dx 后小于 0 则示意弹幕曾经滚出列表。

回收弹幕之后,会将其从 RecyclerView 中 detach,这个操作会影响列表中其余弹幕的布局索引值。就如同数组中某一元素被删除,其前面的所有元素的索引值都会减一:

1.fun updateLaneIndexAfterRecycle(lanes: List<Lane>, recycleIndex: Int) { 2.    lanes.forEach { lane -> 3.        if (lane.startLayoutIndex > recycleIndex) { 4.            lane.startLayoutIndex-- 5.        } 6.        if (lane.endLayoutIndex > recycleIndex) { 7.            lane.endLayoutIndex-- 8.        } 9.   }10.}11.复制代码

遍历所有泳道,只有泳道头部弹幕的布局索引大于回收索引,则将其减一。

性能

再次关上 GPU 出现模式:

这次体验上就很丝滑,柱状图也没有超过警戒线。

talk is cheap, show me the code

残缺代码能够点击这里,在这个repo中搜寻LaneLayoutManager

总结

之前花了很多工夫看源码,也产生过“看源码那么费时,到底有什么用?”这样的狐疑。这次性能优化是一次很好的回应。因为看过 RecyclerView 的源码,它解决问题的思维办法就种在脑袋里了。当遇到弹幕性能问题时,这颗种子就会发芽。解决方案是多种多样的,脑袋中有怎么的种子,就会长出怎么的芽。所以看源码是播撒种子,虽不能立即发芽,但总有一天会后果。