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

简介: Android 弹幕的两种实现及性能对比 | 自定义控件

弹幕有多种实现方式,该系列介绍其中的两种,并对比它们的性能。


引子


image.png


https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/ffae9b1da6d34ee2b8755bfae55aae0e~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp


实现如上图所示的弹幕,第一个想到的方案是“动画”,即自定义容器控件,将子控件布局在容器控件右边的外侧,然后为每个子控件启动一个从右向左的动画。


每当有一个新弹幕,弹幕容器控件就应该执行如下操作:


  1. 生成一个新的子控件


  1. 为子控件绑定数据


  1. 测量子控件


  1. 将子控件添加到容器控件


  1. 布局子控件


  1. 开启子控件动画


// 自定义弹幕容器控件
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)


测量 & 布局


自定义容器控件必须做的两件事是测量和布局子控件,即确定子控件的尺寸和位置。


测量的落脚点是“mMeasuredWidthmMeasuredHeight被赋值”,通过调用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;
}


布局的落脚点是“mLeftmTopmRightmBottom被赋值”,通过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 呈现模式柱状图:


image.png


https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/fde30e697c464385a09f1cf03d35762a~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp


弹幕作为列表的一个部分,先将其移出屏幕,当再次进入屏幕时,列表的滚动会顿一下。从柱状图中可以看出,绿色的柱体很高,这表示measure+layoutanimation的耗时过长。


原因在于fun show(datas: List<Any>),若服务器返回 100 条弹幕数据,则这一瞬间就有 100 个弹幕视图被构建并成为弹幕容器控件的子视图,它们都堆积在屏幕右边的外侧。


下一篇将分享另一种性能更加优越的方案~~


Talk is cheap, show me the code


完整代码可以点击这里


推荐阅读


  1. Android自定义控件 | View绘制原理(画多大?)


  1. Android自定义控件 | View绘制原理(画在哪?)


  1. Android自定义控件 | View绘制原理(画什么?)


  1. Android自定义控件 | 源码里有宝藏之自动换行控件


  1. Android自定义控件 | 小红点的三种实现(上)


  1. Android自定义控件 | 小红点的三种实现(下)


  1. Android自定义控件 | 小红点的三种实现(终结)


  1. Android 弹幕的两种实现及性能对比 | 自定义控件


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


目录
相关文章
|
24天前
|
移动开发 Java Android开发
构建高效Android应用:探究Kotlin与Java的性能差异
【4月更文挑战第3天】在移动开发领域,性能优化一直是开发者关注的焦点。随着Kotlin的兴起,其在Android开发中的地位逐渐上升,但关于其与Java在性能方面的对比,尚无明确共识。本文通过深入分析并结合实际测试数据,探讨了Kotlin与Java在Android平台上的性能表现,揭示了在不同场景下两者的差异及其对应用性能的潜在影响,为开发者在选择编程语言时提供参考依据。
|
28天前
|
缓存 监控 Java
构建高效Android应用:从优化用户体验到提升性能
在竞争激烈的移动应用市场中,为用户提供流畅和高效的体验是至关重要的。本文深入探讨了如何通过多种技术手段来优化Android应用的性能,包括UI响应性、内存管理和多线程处理。同时,我们还将讨论如何利用最新的Android框架和工具来诊断和解决性能瓶颈。通过实例分析和最佳实践,读者将能够理解并实施必要的优化策略,以确保他们的应用在保持响应迅速的同时,还能够有效地利用系统资源。
|
1月前
|
缓存 移动开发 Android开发
提升安卓应用性能的实用策略
在移动开发领域,应用的性能优化是一个持续的挑战。对于安卓开发者而言,确保应用流畅、快速并且电池使用效率高,是吸引和保持用户的关键因素之一。本文将深入探讨针对安卓平台的性能优化技巧,包括内存管理、代码效率、UI渲染以及电池寿命等方面的考量。这些策略旨在帮助开发者构建出更高效、响应更快且用户体验更佳的安卓应用。
|
1月前
|
数据库 Android开发 UED
提升安卓应用性能的十大技巧
【2月更文挑战第30天】在移动设备上,应用程序的性能直接影响用户体验。本文将分享10个优化安卓应用性能的技巧,包括代码优化、内存管理、UI设计和使用性能分析工具等,帮助开发者提高应用的运行速度和响应时间,从而提升用户满意度。
|
1月前
|
Java 编译器 Android开发
构建高效Android应用:探究Kotlin与Java的性能差异
【2月更文挑战第30天】 随着Kotlin成为开发Android应用的首选语言,开发者社区对于其性能表现持续关注。本文通过深入分析与基准测试,探讨Kotlin与Java在Android平台上的性能差异,揭示两种语言在编译效率、运行时性能和内存消耗方面的具体表现,并提供优化建议。我们的目标是为Android开发者提供科学依据,帮助他们在项目实践中做出明智的编程语言选择。
|
1月前
|
监控 测试技术 Android开发
提升安卓应用性能的实用策略
【2月更文挑战第24天】 在竞争激烈的应用市场中,性能优化是提高用户体验和应用成功的关键。本文将探讨针对安卓平台的性能优化技巧,包括内存管理、多线程处理和UI渲染效率的提升。我们的目标是为开发者提供一套实用的工具和方法,以诊断和解决性能瓶颈,确保应用流畅运行。
|
1月前
|
安全 Java Android开发
构建高效Android应用:探究Kotlin与Java的性能差异
【2月更文挑战第24天】在移动开发领域,性能优化一直是开发者关注的焦点。随着Kotlin在Android开发中的普及,了解其与Java在性能方面的差异变得尤为重要。本文通过深入分析和对比两种语言的运行效率、启动时间、内存消耗等关键指标,揭示了Kotlin在实际项目中可能带来的性能影响,并提供了针对性的优化建议。
31 0
|
1月前
|
安全 Java Android开发
构建高效安卓应用:探究Kotlin与Java的性能对比
【2月更文挑战第22天】 在移动开发的世界中,性能优化一直是开发者们追求的关键目标。随着Kotlin在安卓开发中的普及,许多团队面临是否采用Kotlin替代Java的决策。本文将深入探讨Kotlin和Java在安卓平台上的性能差异,通过实证分析和基准测试,揭示两种语言在编译效率、运行时性能以及内存占用方面的表现。我们还将讨论Kotlin的一些高级特性如何为性能优化提供新的可能性。
70 0
|
1月前
|
安全 Java Android开发
构建高效Android应用:探究Kotlin与Java的性能差异
【2月更文挑战第18天】 在Android开发领域,Kotlin和Java一直是热门的编程语言选择。尽管两者在功能上具有相似性,但它们在性能表现上的差异却鲜有深入比较。本文通过一系列基准测试,对比了Kotlin与Java在Android平台上的运行效率,揭示了两种语言在处理速度、内存分配以及电池消耗方面的差异。此外,文章还将探讨如何根据性能测试结果,为开发者提供在实际应用开发中选择合适语言的建议。
|
1月前
|
Java 编译器 Android开发
构建高效Android应用:探究Kotlin与Java的性能差异
在开发高性能的Android应用时,选择合适的编程语言至关重要。近年来,Kotlin因其简洁性和功能性受到开发者的青睐,但其性能是否与传统的Java相比有所不足?本文通过对比分析Kotlin与Java在Android平台上的运行效率,揭示二者在编译速度、运行时性能及资源消耗方面的具体差异,并探讨在实际项目中如何做出最佳选择。
18 4