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


目录
相关文章
|
2月前
|
缓存 搜索推荐 Android开发
安卓开发中的自定义控件实践
【10月更文挑战第4天】在安卓开发的海洋中,自定义控件是那片璀璨的星辰。它不仅让应用界面设计变得丰富多彩,还提升了用户体验。本文将带你探索自定义控件的核心概念、实现过程以及优化技巧,让你的应用在众多竞争者中脱颖而出。
|
4月前
|
移动开发 监控 前端开发
构建高效Android应用:从优化布局到提升性能
【7月更文挑战第60天】在移动开发领域,一个流畅且响应迅速的应用程序是用户留存的关键。针对Android平台,开发者面临的挑战包括多样化的设备兼容性和性能优化。本文将深入探讨如何通过改进布局设计、内存管理和多线程处理来构建高效的Android应用。我们将剖析布局优化的细节,并讨论最新的Android性能提升策略,以帮助开发者创建更快速、更流畅的用户体验。
69 10
|
8天前
|
搜索推荐 Android开发 开发者
安卓应用开发中的自定义控件实践
在安卓应用开发的广阔天地中,自定义控件如同璀璨的星辰,点亮了用户界面设计的夜空。它们不仅丰富了交互体验,更赋予了应用独特的个性。本文将带你领略自定义控件的魅力,从基础概念到实际应用,一步步揭示其背后的原理与技术细节。我们将通过一个简单的例子——打造一个具有独特动画效果的按钮,来展现自定义控件的强大功能和灵活性。无论你是初学者还是资深开发者,这篇文章都将为你打开一扇通往更高阶UI设计的大门。
|
1月前
|
算法 JavaScript Android开发
|
2月前
|
缓存 搜索推荐 Android开发
安卓开发中的自定义控件基础与进阶
【10月更文挑战第5天】在Android应用开发中,自定义控件是提升用户体验和界面个性化的重要手段。本文将通过浅显易懂的语言和实例,引导你了解自定义控件的基本概念、创建流程以及高级应用技巧,帮助你在开发过程中更好地掌握自定义控件的使用和优化。
45 10
|
29天前
|
前端开发 Android开发 UED
安卓应用开发中的自定义控件实践
【10月更文挑战第35天】在移动应用开发中,自定义控件是提升用户体验、增强界面表现力的重要手段。本文将通过一个安卓自定义控件的创建过程,展示如何从零开始构建一个具有交互功能的自定义视图。我们将探索关键概念和步骤,包括继承View类、处理测量与布局、绘制以及事件处理。最终,我们将实现一个简单的圆形进度条,并分析其性能优化。
|
6月前
|
传感器 安全 Android开发
探索iOS与安卓应用开发的性能差异
在移动操作系统领域,iOS和安卓的较量从未停歇。本文将深入探讨两大平台在应用开发中的性能表现,揭示它们各自的优势与局限。通过对比分析,我们将理解开发者如何在这两个不同的生态系统中做出权衡,以及这些选择如何影响最终用户的体验。
37 0
|
2月前
|
前端开发 搜索推荐 Android开发
安卓开发中的自定义控件实践
【10月更文挑战第4天】在安卓开发的世界里,自定义控件如同画家的画笔,能够绘制出独一无二的界面。通过掌握自定义控件的绘制技巧,开发者可以突破系统提供的界面元素限制,创造出既符合品牌形象又提供卓越用户体验的应用。本文将引导你了解自定义控件的核心概念,并通过一个简单的例子展示如何实现一个基本的自定义控件,让你的安卓应用在视觉和交互上与众不同。
|
3月前
|
缓存 前端开发 Android开发
安卓应用开发中的自定义控件
【9月更文挑战第28天】在安卓应用开发中,自定义控件是提升用户界面和交互体验的关键。本文通过介绍如何从零开始构建一个自定义控件,旨在帮助开发者理解并掌握自定义控件的创建过程。内容将涵盖设计思路、实现方法以及性能优化,确保开发者能够有效地集成或扩展现有控件功能,打造独特且高效的用户界面。