弹幕有多种实现方式,该系列介绍其中的两种,并对比它们的性能。
引子
实现如上图所示的弹幕,第一个想到的方案是“动画”,即自定义容器控件,将子控件布局在容器控件右边的外侧,然后为每个子控件启动一个从右向左的动画。
每当有一个新弹幕,弹幕容器控件就应该执行如下操作:
- 生成一个新的子控件
- 为子控件绑定数据
- 测量子控件
- 将子控件添加到容器控件
- 布局子控件
- 开启子控件动画
// 自定义弹幕容器控件 class LaneView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :ViewGroup(context, attrs, defStyleAttr) { // 存放弹幕数据的列表 private var datas = emptyList<Any>() // 展示一条弹幕 fun show(data: Any) { post { // 1.生成新子控件 val child = obtain() // 2.为子控件绑定数据 bindView(data, child) // 3.测量子控件 val width = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) val height = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) child.measure(width, height) // 4.将子控件添加到容器控件 addView(child) // 5.布局子控件 val left = measuredWidth val top = getRandomTop(child.measuredHeight) child.layout(left, top, left + child.measuredWidth, top + child.measuredHeight) // 6.开启子控件动画 laneMap[top]?.add(child, data) ?: run { Lane(measuredWidth).also { it.add(child, data) laneMap[top] = it it.showNext() } } } } // 展示多条弹幕 fun show(datas: List<Any>) { this.datas = datas datas.forEach { show(it) } } }
自定义弹幕容器控件LaneView
公开了两个show()
方法,用于触发弹幕的展示。然后就可以像这样使用弹幕控件:
val laneView = findViewById(R.id.laneView) laneView.show(datas)
缓存弹幕
如果每一条弹幕都重新创建视图就容易发生内存抖动,优化方案是使用缓存池将离屏弹幕视图缓存以供新弹幕使用。
androidx.core.util
包下有一个Pools
类,其中定义了一个Pool
接口及它的简单实现。利用它可以方便的实现缓存池:
// 池 public interface Pool<T> { // 从池中获取对象 T acquire(); // 释放对象 boolean release(@NonNull T instance); }
Pool
接口中定义池的两个必要操作,即获取对象和释放对象。
SimplePool
是对Pool
的一个实现:
public static class SimplePool<T> implements Pool<T> { // 池对象容器 private final Object[] mPool; // 池大小 private int mPoolSize; public SimplePool(int maxPoolSize) { if (maxPoolSize <= 0) { throw new IllegalArgumentException("The max pool size must be > 0"); } // 构造池对象容器 mPool = new Object[maxPoolSize]; } // 从池容器中获取对象 public T acquire() { if (mPoolSize > 0) { // 总是从池容器末尾读取对象 final int lastPooledIndex = mPoolSize - 1; T instance = (T) mPool[lastPooledIndex]; mPool[lastPooledIndex] = null; mPoolSize--; return instance; } return null; } // 释放对象并存入池 @Override public boolean release(@NonNull T instance) { if (isInPool(instance)) { throw new IllegalStateException("Already in the pool!"); } // 总是将对象存到池尾 if (mPoolSize < mPool.length) { mPool[mPoolSize] = instance; mPoolSize++; return true; } return false; } // 判断对象是否在池中 private boolean isInPool(@NonNull T instance) { // 遍历池对象 for (int i = 0; i < mPoolSize; i++) { if (mPool[i] == instance) { return true; } } return false; } }
有了SimplePool
的帮助实现弹幕缓存池就轻而易举了:
class LaneView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :ViewGroup(context, attrs, defStyleAttr) { // 弹幕池 private lateinit var pool: Pools.SimplePool<View> // 构建弹幕视图的 lambda lateinit var createView: () -> View // 从池中获取弹幕,若失败则重新构建弹幕视图 private fun obtain(): View = pool.acquire() ?: createView() // 回收离屏弹幕 private fun recycle(view: View) { view.detach() pool.release(view) } }
obtain()
尝试从弹幕池中获取弹幕视图,若失败则重新创建。recycle()
用于在弹幕视图动画结束后进行回收并存入弹幕池。
自定义弹幕布局 & 绑定数据
一条弹幕布局中有哪些控件?每个控件如何展示数据?
这是两个随着业务变化而变的点,遂把它们抽象成两个“策略”,其实现由外部注入。
class LaneView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :ViewGroup(context, attrs, defStyleAttr) { // 构建弹幕视图的 lambda lateinit var createView: () -> View // 绑定弹幕数据的 lambda lateinit var bindView: (Any, View) -> Unit }
用 lambda 来表达策略要比用 interface 来的简洁,然后就可以像这样从外部将策略注入:
val laneView = findViewById(R.id.laneView) laneView.apply { // 注入弹幕视图的构建策略 createView = ConstraintLayout { layout_width = wrap_content layout_height = 27 padding_end = 8 padding_start = 3 padding_top = 3 padding_bottom = 3 shape = shape { corner_radius = 17 solid_color = "#660C0B1C" } // 圆形头像 StrokeImageView { layout_id = "ivLane" layout_width = 21 layout_height = 21 scaleType = scale_fit_xy start_toStartOf = parent_id center_vertical = true roundedAsCircle = true } // 弹幕文字 TextView { layout_id = "tvLane" layout_width = wrap_content layout_height = wrap_content textSize = 11f textColor = "#ffffff" center_vertical = true start_toEndOf = "ivLane" margin_start = 6 } } // 注入弹幕视图数据绑定策略 bindView = { data, view -> view.find<TextView>("tvLane")?.apply { text = data?.text maxEms = 15 isSingleLine = true ellipsize = ellipsize_end } view.find<StrokeImageView>("ivLane")?.let { Glide.with(it.context).load(data.url).into(it) } } }
上述代码构建了一个用于展示圆形头像及文字的弹幕视图,和本篇开头演示的 GIF 效果一致。
其中运用了 Kotlin DSL 动态地声明式地构建了布局,详细介绍可以点击 Android性能优化 | 把构建布局用时缩短 20 倍(下) - 掘金 (juejin.cn)。
测量 & 布局
自定义容器控件必须做的两件事是测量和布局子控件,即确定子控件的尺寸和位置。
测量的落脚点是“mMeasuredWidth
和mMeasuredHeight
被赋值”,通过调用View.measure()
实现:
public final void measure(int widthMeasureSpec, int heightMeasureSpec) { ... setMeasuredDimensionRaw() ... } private void setMeasuredDimensionRaw(int measuredWidth, int measuredHeight) { mMeasuredWidth = measuredWidth; mMeasuredHeight = measuredHeight; mPrivateFlags |= PFLAG_MEASURED_DIMENSION_SET; }
布局的落脚点是“mLeft
、mTop
、mRight
、mBottom
被赋值”,通过View.layout()
实现:
public void layout(int l, int t, int r, int b) { ... setFrame() ... } protected boolean setFrame(int left, int top, int right, int bottom) { ... mLeft = left; mTop = top; mRight = right; mBottom = bottom; ... }
关于 View 绘制流程的详细介绍可以点击Android自定义控件 | View绘制原理(画多大?) - 掘金 (juejin.cn)。
弹幕控件的show()
方法中就通过调用measure()
和layout()
来实现测量及布局子控件:
//自定义弹幕控件 class LaneView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :ViewGroup(context, attrs, defStyleAttr) { // 展示一条弹幕 fun show(data: Any) { post { val child = obtain() bindView(data, child) // 3.测量子控件 val width = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) val height = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) child.measure(width, height) // 4.将子控件添加到容器控件 addView(child) // 5.布局子控件 val left = measuredWidth // 子控件的左侧位于弹幕控件的右侧 val top = getRandomTop(child.measuredHeight) child.layout(left, top, left + child.measuredWidth, top + child.measuredHeight) ... } } }
弹幕被添加到容器控件的初始位置是“容器控件最右侧的外边”,即处于一个不可见的外侧位置,实现方式是将子控件的左侧置于容器控件的右侧即可:
val left = measuredWidth
其中measuredWidth
表示容器控件的测量宽度。
getRandomTop()
用于让每一个弹幕随机的分布在不同的“泳道”中,位于同一行的弹幕称为同一泳道。(开篇 GIF 包含了 4 个泳道):
class LaneView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :ViewGroup(context, attrs, defStyleAttr) { // 泳道垂直间距 var verticalGap: Int = 5 set(value) { field = value.dp } private fun getRandomTop(commentHeight: Int): Int { // 计算布局泳道的可用高度 val lanesHeight = measuredHeight - paddingTop - paddingBottom // 计算可用高度中最多能布局几条泳道 val lanesCapacity = (lanesHeight + verticalGap) / (commentHeight + verticalGap) // 计算可用高度布局完所有泳道后剩余空间 val extraPadding = lanesHeight - commentHeight * lanesCapacity - verticalGap * (lanesCapacity - 1) // 计算第一条泳道相对于容器控件的 mTop 值 val firstLaneTop = paddingTop + extraPadding / 2 // 计算泳道垂直方向的随机偏移量 val randomOffset = (0 until lanesCapacity).random() * (commentHeight + verticalGap) return firstLaneTop + randomOffset } }
做动画
每一条泳道都是一个队列,存放着等待做动画的弹幕视图。
// 泳道 class Lane(var laneWidth: Int) { // 弹幕视图队列 private var viewQueue = LinkedList<View>() private var currentView: View? = null // 用于限制泳道内弹幕间距的布尔值 private var blockShow = false // 弹幕布局监听器 private val onLayoutChangeListener = OnLayoutChangeListener { v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom -> // 只有当前一个弹幕滚动得足够远,才开启下一个弹幕的动画 if (laneWidth - left > v.measuredWidth + horizontalGap) { blockShow = false showNext() } } // 开始该泳道中下一个弹幕的滚动 fun showNext() { // 还未到展示下一个弹幕,则直接返回 if (blockShow) return currentView?.removeOnLayoutChangeListener(onLayoutChangeListener) // 从泳道队列中取出弹幕视图 currentView = viewQueue.poll() currentView?.let { view -> // 为弹幕视图添加布局变化监听器 view.addOnLayoutChangeListener(onLayoutChangeListener) // 计算每个弹幕的动画时间 val distance = laneWidth + view.measuredWidth val speed = laneWidth.toFloat() / 4000 val duration = (distance / speed).toLong() // 构造 ValueAnimator val valueAnimator = ValueAnimator.ofFloat(1.0f).apply { setDuration(duration) interpolator = LinearInterpolator() addUpdateListener { val value = it.animatedFraction val left = (laneWidth - value * (laneWidth + view.measuredWidth)).toInt() // 通过重新布局来实现弹幕视图的滚动 view.layout(left, view.top, left + view.measuredWidth, view.top + view.measuredHeight) } addListener { // 动画结束时回收弹幕视图 onEnd = { recycle(view) } } } // 弹幕视图滚动开始 valueAnimator.start() blockShow = true } } // 添加弹幕视图 fun add(view: View, data: Any) { viewQueue.addLast(view) showNext() } }
Lane
是泳道的抽象,它用LinkedList
作为存放弹幕视图的队列。
Lane.showNext()
从队列中取出弹幕视图,并为它构建从右到左的位移动画,通过 ValueAnimator 生成一组[0,1]值用于表示动画0-100%的进度,由此计算出动画过程中弹幕视图的left
值,最终通过调用view.layout()
实现弹幕的平移。
为了让同一条泳道的弹幕不发生重叠,只有当前一条弹幕滚动足够长的距离后,才能开启下一个弹幕的动画。所以为弹幕视图设置了布局变化监听器,当弹幕视图完全平移出屏幕并且又滚动了水平间距horizontalGap
后才开启下一个弹幕视图的动画。
响应点击事件
为了响应每个弹幕的点击事件,需要拦截弹幕容器控件的触摸事件:
class LaneView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :ViewGroup(context, attrs, defStyleAttr) { // 记录所有泳道的map结构 private var laneMap = ArrayMap<Int, Lane>() // 弹幕点击监听器 var onItemClick: ((View, Any) -> Unit)? = null // 手势监听器 private val gestureDetector = GestureDetector(context, object : GestureDetector.OnGestureListener { override fun onShowPress(e: MotionEvent?) { } override fun onSingleTapUp(e: MotionEvent?): Boolean { // 将单击事件传递给监听器 e?.let { findDataUnder(it.x, it.y)?.let { pair -> // 执行单击事件响应逻辑 onItemClick?.invoke(pair.first, pair.second) } } return false } override fun onDown(e: MotionEvent?): Boolean { return false } override fun onFling(e1: MotionEvent?,e2: MotionEvent?,velocityX: Float,velocityY: Float): Boolean { return false } override fun onScroll(e1: MotionEvent?,e2: MotionEvent?,distanceX: Float,distanceY: Float): Boolean { return false } override fun onLongPress(e: MotionEvent?) { } }) override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { // 将触摸事件分发给手势监听器 gestureDetector.onTouchEvent(ev) return super.dispatchTouchEvent(ev) } }
在dispatchTouchEvent()
中将触摸事件传递给手势监听器,它将触摸事件解析成单击事件,并通过onSingleTapUp()
回调出来。
在onSingleTapUp()
中通过findDataUnder()
找到触摸事件对应的弹幕视图:
private fun findDataUnder(x: Float, y: Float): Pair<View, Any>? { var pair: Pair<View, Any>? = null // 遍历所有泳道 laneMap.values.forEach { lane -> // 遍历泳道中展示的弹幕视图 lane.forEachView { view, data -> // 获取弹幕与容器控件的相对位置 view.getRelativeRectTo(this@LaneView).also { rect -> if (rect.contains(x.toInt(), y.toInt())) { pair = view to data } } } } return pair }
其中getRelativeRectTo()
用计算于某个 View 相对于另一个 View 的位置。
private fun View.getRelativeRectTo(otherView: View): Rect { // 将子视图和父视图置于同一个全局坐标系,并获取他们的矩形区域 val parentRect = Rect().also { otherView.getGlobalVisibleRect(it) } val childRect = Rect().also { getGlobalVisibleRect(it) } // 获取父子视图矩形区域的相对位置 return childRect.relativeTo(parentRect) } private fun Rect.relativeTo(otherRect: Rect): Rect { val relativeLeft = left - otherRect.left val relativeTop = top - otherRect.top val relativeRight = relativeLeft + right - left val relativeBottom = relativeTop + bottom - top return Rect(relativeLeft, relativeTop, relativeRight, relativeBottom) }
性能
用这套方案实现弹幕的性能有待提高。
打开 GPU 呈现模式柱状图:
弹幕作为列表的一个部分,先将其移出屏幕,当再次进入屏幕时,列表的滚动会顿一下。从柱状图中可以看出,绿色的柱体很高,这表示measure+layout
和animation
的耗时过长。
原因在于fun show(datas: List<Any>)
,若服务器返回 100 条弹幕数据,则这一瞬间就有 100 个弹幕视图被构建并成为弹幕容器控件的子视图,它们都堆积在屏幕右边的外侧。
下一篇将分享另一种性能更加优越的方案~~
Talk is cheap, show me the code
完整代码可以点击这里