自定义控件,弹幕的两种实现及性能对比!
共 32080字,需浏览 65分钟
·
2021-09-17 10:04
BAT
作者:
唐子玄
, 链接:https://juejin.cn/post/7004603099113340936
弹幕有多种实现方式,该系列介绍其中的两种,并对比它们的性能。
引子
实现如上图所示的弹幕,第一个想到的方案是 “动画”,即自定义容器控件,将子控件布局在容器控件右边的外侧,然后为每个子控件启动一个从右向左的动画。
每当有一个新弹幕,弹幕容器控件就应该执行如下操作:
生成一个新的子控件
为子控件绑定数据
测量子控件
将子控件添加到容器控件
布局子控件
开启子控件动画
// 自定义弹幕容器控件
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绘制原理(画多大?)
弹幕控件的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 完整代码可以点击这里:https://github.com/wisdomtl/taylorCode
推荐阅读
• 耗时2年,Android进阶三部曲第三部《Android进阶指北》出版!
BATcoder技术群,让一部分人先进大厂
大家好,我是刘望舒,腾讯TVP,著有三本业内知名畅销书,连续四年蝉联电子工业出版社年度优秀作者,谷歌开发者社区特邀讲师,百度百科收录的高级技术专家。
前华为技术专家,现大厂技术负责人。
想要加入 BATcoder技术群,公号回复BAT
即可。
为了防止失联,欢迎关注我的小号
微信改了推送机制,真爱请星标本公号👇