作者:唐子玄, 链接:https://juejin.cn/post/7004603099113340936
弹幕有多种实现方式,该系列介绍其中的两种,并对比它们的性能。

引子

实现如上图所示的弹幕,第一个想到的方案是 “动画”,即自定义容器控件,将子控件布局在容器控件右边的外侧,然后为每个子控件启动一个从右向左的动画。
每当有一个新弹幕,弹幕容器控件就应该执行如下操作:
  • 生成一个新的子控件
  • 为子控件绑定数据
  • 测量子控件
  • 将子控件添加到容器控件
  • 布局子控件
  • 开启子控件动画
// 自定义弹幕容器控件
classLaneView
@JvmOverloadsconstructor
(context: Context, attrs: AttributeSet? = 
null
, defStyleAttr: 
Int
 = 
0

    :ViewGroup(context, attrs, defStyleAttr) {

// 存放弹幕数据的列表
privatevar
 datas = emptyList<Any>()

// 展示一条弹幕
funshow(dataAny)
 {

        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()

                }

            }

        }

    }


// 展示多条弹幕
funshow(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接口及它的简单实现。利用它可以方便的实现缓存池:
// 池
publicinterfacePool<T
{

// 从池中获取对象
acquire()
;

// 释放对象
booleanrelease(@NonNull T instance)
;

}

Pool接口中定义池的两个必要操作,即获取对象和释放对象。
SimplePool是对Pool的一个实现:
publicstaticclassSimplePool<TimplementsPool<T
{

// 池对象容器
privatefinal
 Object[] mPool;

// 池大小
privateint
 mPoolSize;


publicSimplePool(int maxPoolSize)
{

if
 (maxPoolSize <= 
0
) {

thrownew
 IllegalArgumentException(
"The max pool size must be > 0"
);

        }

// 构造池对象容器
        mPool = 
new
 Object[maxPoolSize];

    }


// 从池容器中获取对象
public T acquire()
{

if
 (mPoolSize > 
0
) {

// 总是从池容器末尾读取对象
finalint
 lastPooledIndex = mPoolSize - 
1
;

            T instance = (T) mPool[lastPooledIndex];

            mPool[lastPooledIndex] = 
null
;

            mPoolSize--;

return
 instance;

        }

returnnull
;

    }


// 释放对象并存入池
@Override
publicbooleanrelease(@NonNull T instance)
{

if
 (isInPool(instance)) {

thrownew
 IllegalStateException(
"Already in the pool!"
);

        }

// 总是将对象存到池尾
if
 (mPoolSize < mPool.length) {

            mPool[mPoolSize] = instance;

            mPoolSize++;

returntrue
;

        }

returnfalse
;

    }


// 判断对象是否在池中
privatebooleanisInPool(@NonNull T instance)
{

// 遍历池对象
for
 (
int
 i = 
0
; i < mPoolSize; i++) {

if
 (mPool[i] == instance) {

returntrue
;

            }

        }

returnfalse
;

    }

}

有了SimplePool的帮助实现弹幕缓存池就轻而易举了:
classLaneView
@JvmOverloadsconstructor
(context: Context, attrs: AttributeSet? = 
null
, defStyleAttr: 
Int
 = 
0

    :ViewGroup(context, attrs, defStyleAttr) {

// 弹幕池
privatelateinitvar
 pool: Pools.SimplePool<View>

// 构建弹幕视图的 lambda
lateinitvar
 createView: () -> View

// 从池中获取弹幕,若失败则重新构建弹幕视图
privatefunobtain()
: View = pool.acquire() ?: createView()

// 回收离屏弹幕
privatefunrecycle(view: View)
 {

        view.detach()

        pool.release(view)

    }

}

obtain()尝试从弹幕池中获取弹幕视图,若失败则重新创建。recycle()用于在弹幕视图动画结束后进行回收并存入弹幕池。

自定义弹幕布局 & 绑定数据

一条弹幕布局中有哪些控件?每个控件如何展示数据?
这是两个随着业务变化而变的点,遂把它们抽象成两个“策略”,其实现由外部注入。
classLaneView
@JvmOverloadsconstructor
(context: Context, attrs: AttributeSet? = 
null
, defStyleAttr: 
Int
 = 
0

    :ViewGroup(context, attrs, defStyleAttr) {

// 构建弹幕视图的 lambda
lateinitvar
 createView: () -> View

// 绑定弹幕数据的 lambda
lateinitvar
 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()实现:
publicfinalvoidmeasure(int widthMeasureSpec, int heightMeasureSpec)
{

    ...

    setMeasuredDimensionRaw()

    ...

}


privatevoidsetMeasuredDimensionRaw(int measuredWidth, int measuredHeight)
{

    mMeasuredWidth = measuredWidth;

    mMeasuredHeight = measuredHeight;

    mPrivateFlags |= PFLAG_MEASURED_DIMENSION_SET;

}

布局的落脚点是“mLeftmTopmRightmBottom被赋值”,通过View.layout()实现:
publicvoidlayout(int l, int t, int r, int b)
{

    ...

    setFrame()

    ...

}


protectedbooleansetFrame(int left, int top, int right, int bottom)
{

    ...

    mLeft = left;

    mTop = top;

    mRight = right;

    mBottom = bottom;

    ...

}

关于 View 绘制流程的详细介绍可以点击Android自定义控件 | View绘制原理(画多大?)
弹幕控件的show()方法中就通过调用measure()layout()来实现测量及布局子控件:
//自定义弹幕控件
classLaneView
@JvmOverloadsconstructor
(context: Context, attrs: AttributeSet? = 
null
, defStyleAttr: 
Int
 = 
0

    :ViewGroup(context, attrs, defStyleAttr) {


// 展示一条弹幕
funshow(dataAny)
 {

        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
 个泳道):


classLaneView
@JvmOverloadsconstructor
(context: Context, attrs: AttributeSet? = 
null
, defStyleAttr: 
Int
 = 
0

    :ViewGroup(context, attrs, defStyleAttr) {

// 泳道垂直间距
var
 verticalGap: 
Int
 = 
5
set
(value) {

            field = value.dp

        }


privatefungetRandomTop(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

    }

}

做动画

每一条泳道都是一个队列,存放着等待做动画的弹幕视图。
// 泳道
classLane
(
var
 laneWidth: 
Int
) {

// 弹幕视图队列
privatevar
 viewQueue = LinkedList<View>()

privatevar
 currentView: View? = 
null
// 用于限制泳道内弹幕间距的布尔值
privatevar
 blockShow = 
false
// 弹幕布局监听器
privateval
 onLayoutChangeListener =

        OnLayoutChangeListener { v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->

// 只有当前一个弹幕滚动得足够远,才开启下一个弹幕的动画
if
 (laneWidth - left > v.measuredWidth + horizontalGap) {

                blockShow = 
false
                showNext()

            }

        }

// 开始该泳道中下一个弹幕的滚动
funshowNext()
 {

// 还未到展示下一个弹幕,则直接返回
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
        }

    }


// 添加弹幕视图
funadd(view: ViewdataAny)
 {

        viewQueue.addLast(view)

        showNext()

    }

}

Lane是泳道的抽象,它用LinkedList作为存放弹幕视图的队列。
Lane.showNext()从队列中取出弹幕视图,并为它构建从右到左的位移动画,通过 ValueAnimator 生成一组[0,1]值用于表示动画0-100%的进度,由此计算出动画过程中弹幕视图的left值,最终通过调用view.layout()实现弹幕的平移。
为了让同一条泳道的弹幕不发生重叠,只有当前一条弹幕滚动足够长的距离后,才能开启下一个弹幕的动画。所以为弹幕视图设置了布局变化监听器,当弹幕视图完全平移出屏幕并且又滚动了水平间距horizontalGap后才开启下一个弹幕视图的动画。

响应点击事件

为了响应每个弹幕的点击事件,需要拦截弹幕容器控件的触摸事件:
classLaneView
@JvmOverloadsconstructor
(context: Context, attrs: AttributeSet? = 
null
, defStyleAttr: 
Int
 = 
0

    :ViewGroup(context, attrs, defStyleAttr) {

// 记录所有泳道的map结构
privatevar
 laneMap = ArrayMap<
Int
, Lane>()

// 弹幕点击监听器
var
 onItemClick: ((View, Any) -> 
Unit
)? = 
null
// 手势监听器
privateval
 gestureDetector = GestureDetector(context, 
object
 : GestureDetector.OnGestureListener {

overridefunonShowPress(e: MotionEvent?)
 {

        }


overridefunonSingleTapUp(e: MotionEvent?)
Boolean
 {

// 将单击事件传递给监听器
            e?.let {

                findDataUnder(it.x, it.y)?.let { pair ->

// 执行单击事件响应逻辑
                    onItemClick?.invoke(pair.first, pair.second)

                }

            }

returnfalse
        }


overridefunonDown(e: MotionEvent?)
Boolean
 {

returnfalse
        }


overridefunonFling(e1: MotionEvent?,e2: MotionEvent?,velocityX: Float,velocityY: Float)
Boolean
 {

returnfalse
        }


overridefunonScroll(e1: MotionEvent?,e2: MotionEvent?,distanceX: Float,distanceY: Float)
Boolean
 {

returnfalse
        }


overridefunonLongPress(e: MotionEvent?)
 {

        }

    })


overridefundispatchTouchEvent(ev: MotionEvent?)
Boolean
 {

// 将触摸事件分发给手势监听器
        gestureDetector.onTouchEvent(ev)

returnsuper
.dispatchTouchEvent(ev)

    }

}

dispatchTouchEvent()中将触摸事件传递给手势监听器,它将触摸事件解析成单击事件,并通过onSingleTapUp()回调出来。
onSingleTapUp()中通过findDataUnder()找到触摸事件对应的弹幕视图:
privatefunfindDataUnder(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 的位置。
privatefun View.getRelativeRectTo(otherView: View)
: Rect {

// 将子视图和父视图置于同一个全局坐标系,并获取他们的矩形区域
val
 parentRect = Rect().also { otherView.getGlobalVisibleRect(it) }

val
 childRect = Rect().also { getGlobalVisibleRect(it) }

// 获取父子视图矩形区域的相对位置
return
 childRect.relativeTo(parentRect)

}


privatefun 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+layoutanimation的耗时过长。
原因在于fun show(datas: List<Any>),若服务器返回 100 条弹幕数据,则这一瞬间就有 100 个弹幕视图被构建并成为弹幕容器控件的子视图,它们都堆积在屏幕右边的外侧。
下一篇将分享另一种性能更加优越的方案~~
Talk is cheap, show me the code 完整代码可以点击这里:https://github.com/wisdomtl/taylorCode
---END---
继续阅读
阅读原文