Android 弹幕的两种实现及性能对比 | 自定义 LayoutManager

6,502 阅读14分钟

引子

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

这个方案的性能有待改善,打开 GPU 呈现模式:

1629556466944.gif

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

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

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

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

自定义布局参数

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

class LaneLayoutManager: RecyclerView.LayoutManager() {
    override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {}
}

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

当前场景中没有自定义布局参数的需求,遂可以这样实现这个方法:

override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {
    return RecyclerView.LayoutParams(
        RecyclerView.LayoutParams.WRAP_CONTENT,
        RecyclerView.LayoutParams.WRAP_CONTENT
    )
}

表示沿用RecyclerView.LayoutParams

初次填充弹幕

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

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

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

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

  1. LinearLayoutManager 在onLayoutChildren()方法中布局表项。
  2. 布局表项的关键方法包括fill()layoutChunk(),前者表示列表的一次填充动作,后者表示填充单个表项。
  3. 在一次填充动作中通过一个while循环不断地填充表项,直到列表剩余空间用完。用伪代码表示这个过程如下所示:
public class LinearLayoutManager {
   // 布局表项
   public void onLayoutChildren() {
       // 填充表项
       fill() {
           while(列表有剩余空间){
               // 填充单个表项
               layoutChunk(){
                   // 让表项成为子视图
                   addView(view)
               }
           }
       }
   }
}
  1. 为了避免每次填充新表项时都重新创建视图,需要从 RecyclerView 的缓存中获取表项视图,即调用Recycler.getViewForPosition()。关于该方法的详解可以点击RecyclerView 缓存机制 | 如何复用表项?

看过源码,理解原理后,弹幕布局就可以仿照着写:

class LaneLayoutManager : RecyclerView.LayoutManager() {
    private val LAYOUT_FINISH = -1 // 标记填充结束
    private var adapterIndex = 0 // 列表适配器索引

    // 弹幕纵向间距
    var gap = 5
        get() = field.dp
        
    // 布局孩子
    override fun onLayoutChildren(recycler: RecyclerView.Recycler?, state: RecyclerView.State?) {
        fill(recycler)
    }
    // 填充表项
    private fun fill(recycler: RecyclerView.Recycler?) {
        // 可供弹幕布局的高度,即列表高度
        var totalSpace = height - paddingTop - paddingBottom
        var remainSpace = totalSpace
        // 只要空间足够,就继续填充表项
        while (goOnLayout(remainSpace)) {
            // 填充单个表项
            val consumeSpace = layoutView(recycler)
            if (consumeSpace == LAYOUT_FINISH) break
            // 更新剩余空间
            remainSpace -= consumeSpace
        }
    }
    
    // 是否还有剩余空间用于填充 以及 是否有更多数据
    private fun goOnLayout(remainSpace: Int) = remainSpace > 0 && adapterIndex in 0 until itemCount

    // 填充单个表项
    private fun layoutView(recycler: RecyclerView.Recycler?): Int {
        // 1. 从缓存池中获取表项视图
        // 若缓存未命中,则会触发 onCreateViewHolder() 和 onBindViewHolder()
        val view = recycler?.getViewForPosition(adapterIndex)
        view ?: return LAYOUT_FINISH // 获取表项视图失败,则结束填充
        // 2. 将表项视图成为列表孩子
        addView(view)
        // 3. 测量表项视图
        measureChildWithMargins(view, 0, 0)
        // 可供弹幕布局的高度,即列表高度
        var totalSpace = height - paddingTop - paddingBottom
        // 弹幕泳道数,即列表纵向可以容纳几条弹幕
        val laneCount = (totalSpace + gap) / (view.measuredHeight + gap)
        // 计算当前表项所在泳道
        val index = adapterIndex % laneCount
        // 计算当前表项上下左右边框
        val left = width // 弹幕左边位于列表右边
        val top = index * (view.measuredHeight + gap)
        val right = left + view.measuredWidth
        val bottom = top + view.measuredHeight
        // 4. 布局表项(该方法考虑到了 ItemDecoration)
        layoutDecorated(view, left, top, right, bottom)
        val verticalMargin = (view.layoutParams as? RecyclerView.LayoutParams)?.let { it.topMargin + it.bottomMargin } ?: 0
        // 继续获取下一个表项视图
        adapterIndex++
        // 返回填充表项消耗像素值
        return getDecoratedMeasuredHeight(view) + verticalMargin
    }
}

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

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

fill()方法中就以“列表剩余高度>0”为循环条件,不断地向泳道中填充表项,它得经历了四个步骤:

  1. 从缓存池中获取表项视图
  2. 将表项视图成为列表孩子
  3. 测量表项视图
  4. 布局表项 这四步之后,表项相对于列表的位置就确定下来,并且表项的视图已经渲染完成。

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

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

微信截图_20210919225802.png

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

自动滚动弹幕

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

最直接的方案就是不停地调用RecyclerView.smoothScrollBy()。为此写了一个扩展法方法用于倒计时:

fun <T> countdown(
    duration: Long, // 倒计时总时长
    interval: Long, // 倒计时间隔
    onCountdown: suspend (Long) -> T // 倒计时回调
): Flow<T> =
    flow { (duration - interval downTo 0 step interval).forEach { emit(it) } }
        .onEach { delay(interval) }
        .onStart { emit(duration) }
        .map { onCountdown(it) }
        .flowOn(Dispatchers.Default)

使用Flow构建了一个异步数据流,该流每次都会发射一个倒计时的剩余时间。关于Flow的详细解释可以点击Kotlin 异步 | Flow 应用场景及原理

然后就能像这样实现列表自动滚动:

countdown(Long.MAX_VALUE, 50) {
    recyclerView.smoothScrollBy(10, 0)
}.launchIn(MainScope())

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

1632126431947.gif

持续填充弹幕

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

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

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

  1. RecyclerView 在滚动发生之前,会根据预计滚动位移大小来决定需要向列表中填充多少新的表项。
  2. 表现在源码上,即是在scrollVerticallyBy()中调用fill()填充表项:
public class LinearLayoutManager {
   @Override
   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 consumed = mLayoutState.mScrollingOffset + fill(recycler, mLayoutState, state, false);
       ...
   }
}

对于弹幕的场景,也可以仿照着写一个类似的:

class LaneLayoutManager : RecyclerView.LayoutManager() {
    override fun scrollHorizontallyBy(dx: Int, recycler: RecyclerView.Recycler?, state: RecyclerView.State?): Int {
        return scrollBy(dx, recycler) 
    }

    override fun canScrollHorizontally(): Boolean {
        return true // 表示列表可以横向滚动
    }
}

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

RecyclerView 的滚动是一段一段进行的,每一段滚动的位移都会通过scrollHorizontallyBy()传递过来。通常在该方法中根据位移大小填充新的表项,然后再触发列表的滚动。关于列表滚动的源码分析可以点击RecyclerView 的滚动是怎么实现的?(一)| 解锁阅读源码新姿势

scrollBy()封装了根据滚动持续填充表项的逻辑。(稍后分析)

持续填充表项比初次填充的逻辑更复杂一点,初次填充只要将表项按照泳道从上到下依次铺开填满列表的高度即可。而持续填充得根据滚动距离计算出哪个泳道即将枯竭(没有弹幕展示的泳道),只对枯竭的泳道填充表项。

为了快速获取枯竭泳道,得抽象出一个“泳道”结构,以保存该泳道的滚动信息:

// 泳道
data class Lane(
    var end: Int, // 泳道末尾弹幕横坐标
    var endLayoutIndex: Int, // 泳道末尾弹幕的布局索引
    var startLayoutIndex: Int // 泳道头部弹幕的布局索引
)

泳道结构包含三个数据,分别是:

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

借助于泳道这个结构,我们得重构下初次填充表项的逻辑:

class LaneLayoutManager : RecyclerView.LayoutManager() {
    // 初次填充过程中的上一个被填充的弹幕
    private var lastLaneEndView: View? = null
    // 所有泳道
    private var lanes = mutableListOf<Lane>()
    // 初次填充弹幕
    override fun onLayoutChildren(recycler: RecyclerView.Recycler?, state: RecyclerView.State?) {
        fillLanes(recycler, lanes)
    }
    // 通过循环填充弹幕
    private fun fillLanes(recycler: RecyclerView.Recycler?, lanes: MutableList<Lane>) {
        lastLaneEndView = null
        // 如果列表垂直方向上还有空间则继续填充弹幕
        while (hasMoreLane(height - lanes.bottom())) {
            // 填充单个弹幕到泳道中
            val consumeSpace = layoutView(recycler, lanes)
            if (consumeSpace == LAYOUT_FINISH) break
        }
    }
    // 填充单个弹幕,并记录泳道信息
    private fun layoutView(recycler: RecyclerView.Recycler?, lanes: MutableList<Lane>): Int {
        val view = recycler?.getViewForPosition(adapterIndex)
        view ?: return LAYOUT_FINISH
        measureChildWithMargins(view, 0, 0)
        val verticalMargin = (view.layoutParams as? RecyclerView.LayoutParams)?.let { it.topMargin + it.bottomMargin } ?: 0
        val consumed = getDecoratedMeasuredHeight(view) + if (lastLaneEndView == null) 0 else verticalGap + verticalMargin
        // 若列表垂直方向还可以容纳一条新得泳道,则新建泳道,否则停止填充
        if (height - lanes.bottom() - consumed > 0) {
            lanes.add(emptyLane(adapterIndex))
        } else return LAYOUT_FINISH

        addView(view)
        // 获取最新追加的泳道
        val lane = lanes.last()
        // 计算弹幕上下左右的边框
        val left = lane.end + horizontalGap
        val top = if (lastLaneEndView == null) paddingTop else lastLaneEndView!!.bottom + verticalGap
        val right = left + view.measuredWidth
        val bottom = top + view.measuredHeight
        // 定位弹幕
        layoutDecorated(view, left, top, right, bottom)
        // 更新泳道末尾横坐标及布局索引
        lane.apply {
            end = right
            endLayoutIndex = childCount - 1 // 因为是刚追加的表项,所以其索引值必然是最大的
        }

        adapterIndex++
        lastLaneEndView = view
        return consumed
    }
}

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

fun List<Lane>.bottom() = lastOrNull()?.getEndView()?.bottom ?: 0

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

class LaneLayoutManager : RecyclerView.LayoutManager() {
    data class Lane(var end: Int, var endLayoutIndex: Int, var startLayoutIndex: Int)
    private fun Lane.getEndView(): View? = getChildAt(endLayoutIndex)
}

理论上“获取泳道中最后一条弹幕视图”应该是Lane提供的方法。但偏偏把它定义成Lane的扩展方法,并且还定义在LaneLayoutManager的内部,这是多此一举吗?

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

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

class LaneLayoutManager : RecyclerView.LayoutManager() {
    override fun scrollHorizontallyBy(dx: Int, recycler: RecyclerView.Recycler?, state: RecyclerView.State?): Int {
        return scrollBy(dx, recycler) 
    }
    // 根据位移大小决定填充多少表项
    private fun scrollBy(dx: Int, recycler: RecyclerView.Recycler?): Int {
        // 若列表没有孩子或未发生滚动则返回
        if (childCount == 0 || dx == 0) return 0
        // 在滚动还未开始前,更新泳道信息
        updateLanesEnd(lanes)
        // 获取滚动绝对值
        val absDx = abs(dx) 
        // 遍历所有泳道,向其中的枯竭泳道填充弹幕
        lanes.forEach { lane ->
            if (lane.isDrainOut(absDx)) layoutViewByScroll(recycler, lane)
        }
        // 滚动列表的落脚点:将表项向手指位移的反方向平移相同的距离
        offsetChildrenHorizontal(-absDx)
        return dx
    }
}

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

  1. 更新泳道信息
  2. 向枯竭泳道填充弹幕
  3. 触发滚动 其中 1,2 都发生在真实的滚动之前,在滚动之前,已经拿到了滚动位移,根据位移就可以计算出滚动发生之后即将枯竭的泳道:
// 泳道是否枯竭
private fun Lane.isDrainOut(dx: Int): Boolean = getEnd(getEndView()) - dx < width
// 获取表项的 right 值
private fun getEnd(view: View?) = 
    if (view == null) Int.MIN_VALUE 
    else getDecoratedRight(view) + (view.layoutParams as RecyclerView.LayoutParams).rightMargin

泳道枯竭的判定依据是:泳道最后一个弹幕的右边向左平移 dx 后是否小于列表宽度。若小于则表示泳道中的弹幕已经全展示完了,此时就要继续填充弹幕:

// 弹幕滚动时填充新弹幕
private fun layoutViewByScroll(recycler: RecyclerView.Recycler?, lane: Lane) {
    val view = recycler?.getViewForPosition(adapterIndex)
    view ?: return
    measureChildWithMargins(view, 0, 0)
    addView(view)
    
    val left = lane.end + horizontalGap
    val top = lane.getEndView()?.top ?: paddingTop
    val right = left + view.measuredWidth
    val bottom = top + view.measuredHeight
    layoutDecorated(view, left, top, right, bottom)
    lane.apply {
        end = right
        endLayoutIndex = childCount - 1
    }
    adapterIndex++
}

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

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

// 更新泳道信息
private fun updateLanesEnd(lanes: MutableList<Lane>) {
    lanes.forEach { lane ->
        lane.getEndView()?.let { lane.end = getEnd(it) }
    }
}

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

无限滚动弹幕

经过初次和持续填充,弹幕已经可以流畅的滚起来了。那如何让仅有的弹幕数据无限轮播呢?

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

class LaneAdapter : RecyclerView.Adapter<ViewHolder>() {
    // 数据集
    private val dataList = MutableList()
    override fun getItemCount(): Int {
        // 设置表项为无穷大
        return Int.MAX_VALUE
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val realIndex = position % dataList.size
        ...
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList<Any>) {
        val realIndex = position % dataList.size
        ...
    }
}

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

回收弹幕

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

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

public void removeAndRecycleView(View child, @NonNull Recycler recycler) {
    removeView(child);
    recycler.recycleView(child);
}

回收逻辑最终会委托给Recycler实现,关于回收表项的源码分析,可以点击下面的文章:

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

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

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

如何才能捕捉到这个瞬间 ?

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

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

private fun scrollBy(dx: Int, recycler: RecyclerView.Recycler?): Int {
    if (childCount == 0 || dx == 0) return 0
    updateLanesEnd(lanes)
    val absDx = abs(dx)
    // 持续填充弹幕
    lanes.forEach { lane ->
        if (lane.isDrainOut(absDx)) layoutViewByScroll(recycler, lane)
    }
    // 持续回收弹幕
    recycleGoneView(lanes, absDx, recycler)
    offsetChildrenHorizontal(-absDx)
    return dx
}

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

fun recycleGoneView(lanes: List<Lane>, dx: Int, recycler: RecyclerView.Recycler?) {
    recycler ?: return
    // 遍历泳道
    lanes.forEach { lane ->
        // 获取泳道头部弹幕
        getChildAt(lane.startLayoutIndex)?.let { startView ->
            // 如果泳道头部弹幕已经滚出屏幕则回收它
            if (isGoneByScroll(startView, dx)) {
                // 回收弹幕视图
                removeAndRecycleView(startView, recycler)
                // 更新泳道信息
                updateLaneIndexAfterRecycle(lanes, lane.startLayoutIndex)
                lane.startLayoutIndex += lanes.size - 1
            }
        }
    }
}

回收和填充一样,也是通过遍历找到即将消失的弹幕,回收之。

判断弹幕消失的逻辑如下:

fun isGoneByScroll(view: View, dx: Int): Boolean = getEnd(view) - dx < 0

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

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

fun updateLaneIndexAfterRecycle(lanes: List<Lane>, recycleIndex: Int) {
    lanes.forEach { lane ->
        if (lane.startLayoutIndex > recycleIndex) {
            lane.startLayoutIndex--
        }
        if (lane.endLayoutIndex > recycleIndex) {
            lane.endLayoutIndex--
        }
    }
}

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

性能

再次打开 GPU 呈现模式:

1629555943171.gif

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

talk is cheap, show me the code

完整代码可以点击这里,在这个repo中搜索LaneLayoutManager

总结

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

推荐阅读

  1. Android自定义控件 | View绘制原理(画多大?)
  2. Android自定义控件 | View绘制原理(画在哪?)
  3. Android自定义控件 | View绘制原理(画什么?)
  4. Android自定义控件 | 源码里有宝藏之自动换行控件
  5. Android自定义控件 | 小红点的三种实现(上)
  6. Android自定义控件 | 小红点的三种实现(下)
  7. Android自定义控件 | 小红点的三种实现(终结)
  8. Android 弹幕的两种实现及性能对比 | 自定义控件
  9. Android 弹幕的两种实现及性能对比 | 自定义 LayoutManager