Android弹幕的两种实现及性能对比——自定义LayoutManager
简介 在上一篇文章中,使用“动画”方案来实现弹幕效果。容器控件是自定义的。每个弹幕都作为它的子控件,弹幕的初始位置放在容器控件右侧的外侧。每个弹幕都通过动画从右到左翻译通过屏幕。 该方案的性能需要改进。开启 GPU 渲染模式: 原因是容器控件预先构建了所有的弹幕视图,并将它们堆叠在屏幕的右侧。如果弹幕数据量大,容器控件会因为子视图太多而消耗大量measure+layout时间。 既然性能问题是提前
简介
在上一篇文章中,使用“动画”方案来实现弹幕效果。容器控件是自定义的。每个弹幕都作为它的子控件,弹幕的初始位置放在容器控件右侧的外侧。每个弹幕都通过动画从右到左翻译通过屏幕。
该方案的性能需要改进。开启 GPU 渲染模式:
原因是容器控件预先构建了所有的弹幕视图,并将它们堆叠在屏幕的右侧。如果弹幕数据量大,容器控件会因为子视图太多而消耗大量measure+layout时间。
既然性能问题是提前加载不必要的弹幕造成的,那我们能不能只预加载有限数量的弹幕呢?
只加载有限数量的子视图的可滚动控件是 RecyclerView!它不是提前将Adapter中的所有数据都转换成View,而是只预加载一屏数据,然后随着滚动不断加载新的数据。
要想用RecyclerView实现弹幕效果,就得“自定义LayoutManager”。
自定义布局参数
自定义LayoutManager第一步:继承recyclerview LayoutManger:
1.class LaneLayoutManager: RecyclerView.LayoutManager() {
2.override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {}
3.}
4.复制代码
根据 Android studio 的提示,你必须实现一个 generateDefaultLayoutParams() 方法。用于生成自定义的 LayoutParams 对象,在布局参数中携带自定义属性。
当前场景不需要自定义布局参数,所以该方法可以实现如下:
1.override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {
2.return RecyclerView.LayoutParams(
3.RecyclerView.LayoutParams.WRAP_CONTENT,
4.RecyclerView.LayoutParams.WRAP_CONTENT
5.)
6.}
7.复制代码
表示recyclerview的LayoutParams。
初始填充弹幕
自定义 LayoutManager 最重要的一步是定义如何布局表格项。
对于 LinearLayoutManager,表格项在一个方向上线性分布。第一次显示列表时,表格项从上到下依次填充,称为“初始填充”。
对于 LaneLayoutManager,第一个填充是“将项目符号列表填充到列表末尾(在屏幕外不可见)”。
关于LinearLayoutManager如何填充表格项的源码分析,上一篇RecyclerView面试题 |滚动时如何填充或回收表格项目?经分析,引述如下结论:
- LinearLayoutManager 在 onLayoutChildren() 方法中布局表格条目。
2、布局表项的关键方法包括fill()和layoutChunk()。前者表示列表的填充动作,后者表示填充单个表项。
3.在填充动作中,通过while循环不断填充表条目,直到列表中的剩余空间用完。这个过程用伪代码表示如下:
1.public 类 LinearLayoutManager {
2.//布局表格项
3.public void onLayoutChildren() {
4.//填写表格条目
5.fill() {
6.while(列表还有空间){
- //填充单个表条目
8.layoutChunk(){
- //使表格条目成为子视图
- addView(视图)
11.}
12.}
13。}
14.}
15.}
16.复制代码
1、为了避免每次填满新的表项都重新创建视图,需要从RecyclerView缓存中获取表项视图,即调用recycler getViewForPosition()。这个方法的详细解释可以点击RecyclerView缓存机制|如何重用表条目?
看完源码了解原理后,弹幕布局可以建模如下:
1.class LaneLayoutManager : RecyclerView.LayoutManager() {
2.private val LAYOUT_FINISH u003d -1 //标记填充结束
3.private var adapterIndex u003d 0 //列出适配器索引
4 //弹幕纵向间距
- 差距 u003d
6.get() u003d field.dp
7.//Layout 8 override fun onLayoutChildren(recycler: RecyclerView.Recycler?, state: RecyclerView.State?)
9.{
10.fill(回收站)
11。}
12.//填表项
- 私人乐趣填充(recycler: RecyclerView.Recycler?)
14.{
15.//弹幕布局可用高度,即列表高度
16.var totalSpace u003d 高度 - paddingTop - paddingBottom
17.var remainSpace u003d totalSpace
18.//只要有足够的空间就继续填充表项
19.while (goOnLayout(remainSpace)) {
20.//填写单表条目 21 val consumeSpace u003d layoutView(recycler)
- if (consumeSpace u003du003d LAYOUT_FINISH) 中断
- //更新剩余空间
24.remainSpace -u003d consumeSpace
25.}
26.}
27。
28.//是否还有空间可以填充 # 和 # 是否还有更多数据 29 private fun goOnLayout(remainSpace: Int) u003d remainSpace > 0 && adapterIndex in 0 直到 itemCount
30。
- //填充单个表条目
- private fun layoutView(recycler: RecyclerView.Recycler?): Int {
// 1. 从缓存池中获取表项视图
//如果缓存未命中,触发#onCreateViewHolder()和#onBindViewHolder()
val view u003d recycler?.getViewForPosition(adapterIndex)
view ?: return LAYOUT\_FINISH //如果获取不到table item view,则填充结束
// 2. 将表格项视图变成列表 38. addView(view)39 // 3 测量项视图 40 measureChildWithMargins(view, 0, 0) 41 // 可用弹幕布局的高度,即列表高度为 42 var totalSpace u003d height - paddingTop - paddingBottom
//弹幕车道数,即垂直列表可以容纳几个弹幕44 Val。 laneCount u003d (totalSpace + gap) / (view.Measuredheight + gap)45 //计算当前table item所在的lane 46 Val index u003d adapterIndex% laneCount47 //计算当前的上下左右边框table item 48 val # left u003d width //弹幕左侧是列表右侧的49 val top u003d index \* (view.measuredHeight + gap)
val right u003d left + view.measuredWidth
val bottom u003d top + view.measuredHeight
// 4. 布局表格项目(此方法考虑项目装饰) 53 layoutDecorated(view, left, top, right, bottom)
val verticalMargin u003d (view.layoutParams as? RecyclerView.LayoutParams)?.let { it.topMargin + it.bottomMargin } ?: 0
//继续获取下一个表项视图
适配器索引++
//返回填充表格条目所消耗的像素值 58 return getDecoratedMeasuredHeight(view) + verticalMargin。
- }
60。}
61.复制代码
弹幕滚动的每条水平线称为“车道”。
车道从列表的顶部到底部垂直分布。列表高度 / 车道高度 u003d 车道数。
在fill()方法中,以“列表剩余高度>0”为循环条件,不断地将表格项填充到泳道中。它必须经过四个步骤:
1.从缓存池中获取表项视图
- 使表格项视图成为列表子项
3.测量表项查看
- 布局表项
经过这四个步骤,表格项相对于列表的位置就确定了,表格项的视图就已经渲染好了。
运行demo,果然~,我什么都没看到。。。
没有添加列表滚动逻辑,所以排列在列表右侧外侧的表格项仍然是不可见的。不过可以使用Android studio的Layout Inspector工具来验证第一次填充代码的正确性:
Layout Inspector 将使用线框来表示屏幕外的控件。如图所示,列表右侧的外侧填充了四个表格项。
自动滚动弹幕
为了查看填充的表格项目,您必须让列表自发滚动。
最直接的解决办法就是不断调用recyclerview smoothScrollBy()。为此,为 Countdown 编写了一个扩展方法:
1.fun <T> 倒计时( .
- duration: Long, //总倒计时时间
- interval: Long, //倒计时间隔
- onCountdown: suspend (Long) -> T //倒计时回调
5.): 流量<T> u003d
- 流 { (持续时间 - 间隔 向下到 0 步 间隔).forEach { emit(it) } }
.onEach { 延迟(间隔) }
.onStart { emit(duration) }
.map { onCountdown(it) }
.flowOn(Dispatchers.Default)
11.复制代码
使用Flow构造一个异步数据Flow,每次都会发出倒计时的剩余时间。关于 Flow 的详细解释,请点击 Kotlin 异步 | Flow应用场景及原理
然后你可以像这样自动滚动列表:
1.countdown(Long.MAX_VALUE, 50) {
- recyclerView.smoothScrollBy(10, 0)
3.}.launchIn(MainScope())
4.复制代码
每 50 毫秒向左滚动 10 个像素。效果如下图所示:
弹幕连续填充
因为只进行了初始填充,即每条车道只填充一个表格项,所以第一行的表格项滚入屏幕后不会有后续弹幕。
LayoutManger.onLayoutChildren() 只会在第一次布局列表时调用一次,即弹幕的初始填充只会执行一次。为了连续显示弹幕,表格条目必须在滚动时连续填充。
以前的 RecyclerView 面试问题 |滚动时如何填充或回收表格项目?在分析了列表滚动时不断填充表格项的源码后,引用如下结论:
1、在滚动之前,RecyclerView会根据预期的滚动位移来决定列表中需要填充多少个新表格项。
2、源码上显示,即在scrollVerticallyBy()中调用fill()填充表项:
1.public 类 LinearLayoutManager {
- @Override 3. public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) { 4. return scrollBy(dy, recycler, state);
- }
6。
- int scrollBy(int delta, RecyclerView.Recycler recycler, RecyclerView.State state) {
...
//填写表项 10 final int consumed u003d mLayoutState.mScrollingOffset + fill(recycler, mLayoutState, state, false);
...
- }
13.}
14.复制代码
对于弹幕场景,也可以写一个类似的:
1.class LaneLayoutManager : RecyclerView.LayoutManager() { 2.override fun scrollHorizontallyBy(dx: Int, recycler: RecyclerView.Recycler?, state: RecyclerView.State?): Int {
return scrollBy(dx, recycler)
- }
5。
- 覆盖 fun canScrollHorizontally(): 布尔 {
return true //表示列表可以水平滚动
- }
9.}
10.复制代码
覆盖 canScrollHorizontally() 返回 true 以指示列表可以水平滚动。
RecyclerView的滚动是逐段进行的,每一段的位移都会通过scrollhorizontalyby()传递。通常,在这种方法中,会根据位移填充新的表格项,然后触发列表的滚动。列表滚动的源码分析可以点击RecyclerView。滚动是如何实现的? (1) |解锁阅读源码新姿势。
scrollBy() 封装了基于滚动连续填充表格条目的逻辑。 (稍后分析)
表格项连续填充的逻辑比第一次填充要复杂一点。第一次填充,只需按照泳道从上到下展开表格项目,并填充列表的高度即可。对于连续填充,需要根据滚动距离(无弹幕显示的车道)计算出即将耗尽的车道,对于已耗尽的车道仅填写表格项。
为了快速获取耗尽车道,必须抽象出一个“车道”结构来保存车道的滚动信息:
1.//车道
2.数据类车道(
- var end: Int, //泳道尽头弹幕横坐标
- var endLayoutIndex: Int, //车道末端弹幕的布局索引
- var startLayoutIndex: Int //泳道头弹幕的布局索引
6.)
7.复制代码
泳道结构包含三个数据:
1、车道尽头弹幕横坐标:是车道最后一个弹幕的右值,即其右侧与RecyclerView左侧的距离。该值用于判断经过一段时间的滚动位移后车道是否会干涸。
2、车道尽头弹幕布局索引:是车道最后一个弹幕的布局索引。记录下来,通过getChildAt()轻松获取车道上最后一个弹幕的视图。 (布局索引和适配器索引不同,RecyclerView只持有有限数量的表项,所以布局索引的取值范围为[0,x],X的取值略大于一屏幕表项。对于弹幕,适配器索引的值为[0,∞])
- 车道首部弹幕布局指数:类似2,方便获取车道首部弹幕的视野。
借助泳道结构,我们第一次要重构填表项的逻辑:
1.class LaneLayoutManager : RecyclerView.LayoutManager() {
- //初始填充时最后填充的弹幕
- 私有变量 lastLaneEndView:查看? u003d 空
- //所有车道
- private var lanes u003d mutableListOf<Lane>()
- //弹幕的初始填充 7 override fun onLayoutChildren(recycler: RecyclerView.Recycler?, state: RecyclerView.State?) { 8. fillLanes(recycler, lanes)
- }
- //骑车填充弹幕 11 private fun fillLanes(recycler: RecyclerView.Recycler?, lanes: MutableList<Lane>) {
lastLaneEndView u003d null
//如果列表垂直方向还有空间,继续补弹幕 14 while (hasMoreLane(height - lanes.bottom())) {
//将单个弹幕填充到第 16 车道 val consumeSpace u003d layoutView(recycler, lanes)
如果 (consumeSpace u003du003d LAYOUT\_FINISH) 中断
}
- }
- //填充单条弹幕并记录车道信息 21 private fun layoutView(recycler: RecyclerView.Recycler?, lanes: MutableList<Lane>): Int {
val view u003d recycler?.getViewForPosition(adapterIndex)
查看?:返回 LAYOUT\_FINISH
measureChildWithMargins(view, 0, 0)
val verticalMargin u003d (view.layoutParams as? RecyclerView.LayoutParams)?.let { it.topMargin + it.bottomMargin } ?:26 val consumed u003d getDecoratedMeasuredHeight(view) + if (lastLaneEndView u003du003d null) 0 else verticalGap+ verticalMargin27 //如果列表的垂直方向可以容纳新的lane,则创建新lane,否则停止填充 28} if (height - lanes.bottom() - consume > 0) {29} lanes.add(emptyLane (adapterIndex))30} else} return LAYOUT\_FINISH3132 # addView(view)33 //获取新添加的lane 34 # val # Lane u003d lanes Last() 35 //计算上、下、左、右帧barrage 36 # val # left u003d 车道末端 + horizontalGap37 val top u003d if (lastLaneEndView u003du003d null) paddingTop else lastLaneEndView!!.bottom + verticalGap38 val right u003d left + view.measuredWidth39 val bottom u003d 顶部+ 视图measuredhight40 //定位弹幕 41 , layoutDecorated(view, left, top, right, bottom)42 // 更新lane末端横坐标和layout index 43 ,lane Apply {44} end u003d right45} endLayoutIndex u003d childCount - 1 //因为是新添加的表项,它的索引值必须是最大的 46} 4748} adapterIndex++49} lastlaneendview u003d view50} return} consume51} 52}
53.复制代码
弹幕的初始填充也是在垂直方向不断增加车道的过程。判断是否添加的逻辑如下: List height - 当前底部 Lane 的底部值 - 本次弹幕填充消耗的像素值 > 0,其中 lanes Bottom() 是 list < Lane 的扩展方法>
1fun List<Lane>.bottom() u003d lastOrNull()?.getEndView()?.bottom ?: 02 复制代码
它获取车道列表中的最后一个车道,然后获取车道中最后一个弹幕视图的底部值。其中getEndView()定义为lane的扩展方法:
1class LaneLayoutManager : RecyclerView.LayoutManager() {
- 数据类 Lane(var end: Int, var endLayoutIndex: Int, var startLayoutIndex: Int)
- 私人乐趣Lane.getEndView():查看? u003d getChildAt(endLayoutIndex)
4.}
5.复制代码
理论上,“获取Lane中最后一个弹幕的视图”应该是Lane提供的方法。但是是不是不需要定义为 Lane 的扩展方法,也需要定义在 LaneLayoutManager 的内部呢?
如果定义在 Lane 内部,则无法在此上下文中访问 layoutmanager 如果 getchildat() 方法仅定义为 LaneLayoutManager 的私有方法,则无法访问 endLayoutIndex。所以这是整合两个语境。
回看滚动时不断填充弹幕的逻辑:
1.class LaneLayoutManager : RecyclerView.LayoutManager() { 2. 覆盖 fun scrollHorizontallyBy(dx: Int, recycler: RecyclerView.Recycler?, state: RecyclerView.State?): Int {
return scrollBy(dx, recycler)
- }
- //根据位移确定要填充多少表项 6 private fun scrollBy(dx: Int, recycler: RecyclerView.Recycler?): Int {
//如果列表没有子级或没有滚动,则返回
如果 (childCount u003du003d 0 || dx u003du003d 0) 返回 0
//在滚动开始前更新车道信息
updateLanesEnd(lanes)
//获取滚动绝对值
val absDx u003d abs(dx)
//遍历所有车道并用弹幕填充耗尽的车道
lanes.forEach { lane ->
if (lane.isDrainOut(absDx)) layoutViewByScroll(recycler, lane)
}
//滚动列表的立足点:将表格项平移到手指位移的反方向等距离
offsetChildrenHorizontal(-absDx)
返回 dx
- }
21.}
22.复制代码
滚动时不断填充弹幕的逻辑如下:
1.更新车道信息
- 用弹幕填满干涸的车道
3.触发滚动
其中,1和2发生在真正的滚动之前。在滚动之前,已经获得了滚动位移。根据排量,可以计算出滚动后会耗尽的车道:
1//车道是否干燥
2.private fun Lane.isDrainOut(dx: Int): Boolean u003d getEnd(getEndView()) - dx < width
3.//获取表项的{right}值
4.private fun getEnd(view: View?) u003d
- 如果(查看 u003du003d null) Int.MIN_VALUE
- else getDecoratedRight(view) + (view.layoutParams as RecyclerView.LayoutParams).rightMargin
7.复制代码
车道耗尽的判断依据是:车道最后一个弹幕右侧是否小于向左移动dx后的列表宽度。如果小于,则说明车道内的弹幕已经充分展现。这时候继续补弹幕:
1.//在滚动时填充一个新的弹幕
2.private fun layoutViewByScroll(recycler: RecyclerView.Recycler?, lane: Lane) { 3. val view u003d recycler?.getViewForPosition(adapterIndex) 4 view ?: return 5. measureChildWithMargins(view, 0, 0) 6 addView(view ) 7 8 val left u003d lane.end + horizontalGap 9. val top u003d lane.getEndView()?.top ?: paddingTop
- val right u003d left + view.measuredWidth
- val 底部 u003d 顶部 + view.measuredHeight
- layoutDecorated(视图,左,上,右,下)
- lane.apply {
结束 u003d 正确
endLayoutIndex u003d childCount - 1
- }
- adapterIndex++18}19 复制代码
填充逻辑与第一次填充几乎相同。唯一不同的是,滚动时的填充由于空间不足无法提前返回,因为是通过寻找正确的lane来填充的。
为什么要在填充耗尽的车道之前更新车道信息?
1.//更新车道信息
2.private fun updateLanesEnd(lanes: MutableList<Lane>) {
- lanes.forEach { lane ->
lane.getEndView()?.let { lane.end u003d getEnd(it) }
- }
6.}
7.复制代码
因为 RecyclerView 的滚动是逐段进行的,所以好像滚动了一段丢失的距离。 Scrollhorizontalyby() 可能要回调十次以上。每次回调,弹幕会前进一小段,即Lane末端的弹幕横坐标会发生变化,同时在Lane结构中也要改变。否则,Lane depletion 的计算就会出错。
无限滚动弹幕
初次连续充填后,弹幕可以顺利卷起。如何让唯一的弹幕数据无限循环?
只需在 Adapter 上做一个小手脚:
1.class LaneAdapter : RecyclerView.Adapter<ViewHolder>() {
- //数据集
- private val dataList u003d MutableList()
- 覆盖乐趣 getItemCount(): Int {
//将表项设置为无穷大
返回 Int.MAX\_VALUE
- }
8。
- 覆盖乐趣 onBindViewHolder(holder: ViewHolder, position: Int) {
- Val RealIndex u003d位置%datalist.size11 ... 12} 1314覆盖fun inbindViewHolder(持有人:viewholder,位置,位置:int,有效载荷:mutableLelist <any>){15 val realIndex u003d position u003d position%datalist.size16 ... 17 ... 17 ... 17 ... 17 }18}19 复制代码
将列表的数据量设置为无穷大。在创建表项视图并为其绑定数据时,取适配器索引的模块。
恢复弹幕
最后剩下的问题是如何恢复弹幕。如果没有回收,对不起RecyclerView这个名字。
回收表项的入口在LayoutManager中定义:
1.public void removeAndRecycleView(View child, @NonNull Recycler recycler) {
- removeView(child);
- recycler.recycleView(child);
4.}
5.复制代码
回收逻辑最终将委托给 Recycler。回收表项的源码分析,点击以下文章:
-
RecyclerView缓存机制 |什么是回收的?
-
RecyclerView缓存机制 |在哪里回收?
-
RecyclerView动画原理 |换个姿势看源码(预排版)
-
RecyclerView动画原理 |前布局、后布局和暂存缓存的关系
-
RecyclerView 面试题 |什么情况下表项会被回收到缓存池中?
对于弹幕场景,弹幕什么时候才能恢复?
当然,弹幕滚出屏幕的那一刻!
我们如何捕捉这一刻?
当然是用每次滚动前的位移来计算的!
滚动的时候,除了不断的填充弹幕,我们还要不断的循环弹幕(源码里是这么说的,我只是抄袭而已):
1.private fun scrollBy(dx: Int, recycler: RecyclerView.Recycler?): Int { 2. if (childCount u003du003d 0 || dx u003du003d 0) return 0
- updateLanesEnd(lanes)
- val absDx u003d abs(dx)
- //弹幕持续填充
- lanes.forEach { lane ->
if (lane.isDrainOut(absDx)) layoutViewByScroll(recycler, lane)
- }
- //弹幕持续恢复
- recycleGoneView(lanes, absDx, recycler)
- offsetChildrenHorizontal(-absDx)
12
返回 dx
13.}
14.复制代码
这是scrollBy() 的完整版本。滚动时,先填充,然后立即回收:
1.fun recycleGoneView(lanes: List<Lane>, dx: Int, recycler: RecyclerView.Recycler?) {
- 回收商?:退货
- //穿越车道
- lanes.forEach { lane ->
//获取车道头部弹幕
getChildAt(lane.startLayoutIndex)?.let { startView ->
//如果车道头部弹幕滚出屏幕,回收
如果 (isGoneByScroll(startView, dx)) {
//恢复弹幕视图
removeAndRecycleView(startView, recycler)
//更新车道信息
updateLaneIndexAfterRecycle(lanes, lane.startLayoutIndex)
lane.startLayoutIndex +u003d lanes.size - 1
}
}
- }
17.}
18.复制代码
回收和填充一样,也是通过遍历找到消失的弹幕,进行回收。
判断弹幕消失的逻辑如下:
1.fun isGoneByScroll(view: View, dx: Int): Boolean u003d getEnd(view) - dx < 0
2.复制代码
如果向左平移dx后弹幕右侧小于0,则表示弹幕已经滚出列表。
弹幕恢复后,会从 RecyclerView 中分离出来。该操作会影响列表中其他弹幕的布局索引值。就像删除数组中的一个元素一样,所有后续元素的索引值都会减一:
1.fun updateLaneIndexAfterRecycle(lanes: List<Lane>, recycleIndex: Int) {
- lanes.forEach { lane ->
如果 (lane.startLayoutIndex > recycleIndex) {
lane.startLayoutIndex--
}
如果 (lane.endLayoutIndex > recycleIndex) {
lane.endLayoutIndex--
}
- }
10.}
11.复制代码
遍历所有车道。只要车道头的弹幕布局指数大于回收指数,就会减一。
性能
再次开启 GPU 渲染模式:
体验非常流畅,直方图没有超过警戒线。
talk 很便宜,给我看代码
完整代码,点击这里在这个repo中搜索LaneLayoutManager。
总结
之前花了很多时间看源码,还产生了“看源码这么费时间,有什么用?”这样的疑惑。这种性能优化是一个很好的回应。因为看过RecyclerView的源码,它的思路和解决问题的方法都在我脑海中种下。当弹幕的性能出现问题时,这颗种子就会发芽。有很多解决方案。什么样的种子在头上,就会长出什么样的芽。所以源码就是播种。虽然不能马上发芽,但总有一天会结果。
更多推荐
所有评论(0)