☰
Android弹幕App开发实战:自定义View绘制、弹道调度与性能优化
2026/10/13 4:18:36 网站建设 项目流程

做弹幕App这件事,我第一次碰是在某个直播项目里。需求只有一句话:视频上面能飘字,用户发什么就飘什么。当时我直接引入了一套现成的Android弹幕组件,功能倒是齐全,可后来要自定义弹幕样式、要压内存占用、要在直播场景下保证不掉帧,文档和社区帖子都帮不上忙。于是就有了这个"Android 弹幕君APP开发实战"系列,我打算从零开始写一套弹幕层,把弹幕君APP的每个环节拆开讲透。这一篇先做最基础也最核心的部分:自定义View绘制、弹道调度和首轮性能优化,保证你读完能自己跑出一个可用的滚屏弹幕层。

这篇文章适合两类人:一类是刚学会自定义View、想拿弹幕练手的Android开发者,另一类是已经在项目里用过弹幕库,但遇到定制需求时不知道怎么下手的同学。我会把"先想清楚再写代码"的过程也写进来,因为弹幕看着是动画,实际上是一个调度问题、一个渲染问题,最后才是一个动画问题。

1. 动手前先划清边界:弹幕君第一版到底要做什么

1.1 弹幕形态远比想象中多

很多人提到弹幕,第一反应就是"从右往左滚动的文字"。实际上,弹幕产品里常见的形态至少有四种:普通滚动弹幕、顶部固定弹幕、底部固定弹幕,以及带描边或阴影的高级样式。再往前一步,还有逆向滚动、彩色渐变、逐字入场、图标弹幕、点击触发的特效弹幕。如果一开始就把所有形态塞进第一版,绘制逻辑会迅速失控。

我在弹幕君第一版里只保留了普通滚动弹幕。这不是偷懒,而是因为滚动弹幕覆盖了弹幕系统最核心的链路:文字测量、坐标计算、轨道分配、生命周期管理、渲染性能。滚动弹幕跑通了,顶部固定和底部固定就只是"把x坐标从移动改成静止"的变体;描边和阴影也只是画笔配置的扩展。数据结构上预留一个type字段,后续加形态不用改底层。

1.2 选择从零开发而不是继续用开源弹幕库

有些朋友会问:明明有现成的开源弹幕库,性能和功能都比我第一版强,为什么还要从零造轮子?

我的理由有三个。第一,开源弹幕库为了兼容各种场景,抽象层次通常很高,我要改一个"弹幕消失后加个回弹动画"的需求,得先读懂它的缓存层、调度层、渲染层,成本并不低。第二,弹幕的性能瓶颈往往不是库本身,而是使用方对需求的理解:比如弹幕内容里混入Emoji怎么办、每条弹幕的文字宽度怎么算、播放器暂停时弹幕要不要冻结,这些只有在亲手实现一遍时才会真正意识到。第三,也是最重要的,当你遇到"同屏80条弹幕还保持流畅"这类指标时,没有底层认知,你连问题出在drawText还是invalidate频率上都判断不出来。

所以在弹幕君这个项目里,我决定只参考思路,不直接搬运代码,把一条弹幕从产生到消失的完整旅程亲手写一遍。

1.3 第一版的验收清单

写代码之前,我给自己列了一个足够小的验收清单,避免在无限细节里出不来:

功能项第一版要求备注
弹幕类型普通滚动弹幕数据模型预留type字段
样式控制颜色、字号、描边开关全局统一设置
弹幕来源内存队列手动添加方便测试,后续再接网络
轨道分配自动选择空闲轨道不做复杂碰撞检测
同屏上限超过80条丢弃新弹幕保护帧率
暂停/继续支持整体冻结先服务直播场景
性能指标80条同屏不低于50fps中端设备测试

这里有一个经验:定指标时不要拍脑袋写"不卡",要写可验证的数字。我最终是在一台中端测试机上用"同屏80条弹幕"作为压力场景,帧率用系统自带工具记录,低于50fps就算不过关。有了这个验收清单,第一节代码才不会越写越失控。

2. 自定义View绘制弹幕:坐标、文字和渲染流程

2.1 弹幕对象的数据结构设计

一条弹幕本质上就是一组文本加一组运动参数。我定义了这样一个数据类:

data class DanmakuItem( val id: Long, val content: String, val color: Int = Color.WHITE, val textSize: Float = 32f, val speed: Float = 0.6f, // px/ms,由速度策略计算 val type: Int = TYPE_SCROLL, // 预留弹幕类型 var trackIndex: Int = -1, var startTime: Long = 0L, // 进入屏幕的时间戳 var x: Float = 0f, // 当前x坐标 var yBase: Float = 0f // 所在轨道的基础y坐标 )

这里有个看似不起眼但很关键的字段:startTime。它记录了这条弹幕开始移动的时间戳,而不是记录"已经走了多少像素"。后续所有位置计算都以startTime为基准,这样才能避免丢帧后位置错乱。我也把speed字段直接放在数据类里,虽然它可以用时长反推,但存下来会让代码更容易调试。

2.2 onDraw中用绝对时间推移位置

弹幕View的核心代码不复杂,就是一个继承自View的DanmakuView,在onDraw里遍历所有正在飞行的弹幕,计算x坐标,然后绘制:

class DanmakuView(context: Context, attrs: AttributeSet?) : View(context, attrs) { private val activeItems = mutableListOf<DanmakuItem>() private val textPaint = TextPaint(ANTI_ALIAS_FLAG) override fun onDraw(canvas: Canvas) { super.onDraw(canvas) val now = SystemClock.uptimeMillis() val iterator = activeItems.iterator() while (iterator.hasNext()) { val item = iterator.next() val elapsed = now - item.startTime item.x = width.toFloat() - elapsed * item.speed if (item.x < -getTextWidth(item)) { iterator.remove() continue } drawDanmakuItem(canvas, item, now) } } }

这是第一版最朴素的写法,速度是线性的,方向是从右往左,所以x随着时间递减。当x小于负的文字宽度时,说明整条弹幕已经出了屏幕左边界,就可以从活跃列表里移除。

要注意的是,我在这里用while循环加iterator,而不是for-each,是为了在遍历时安全地删除已经出屏的弹幕。这是自定义View绘制里高频踩坑点:一边遍历一边remove很容易抛异常。

2.3 baseline:文字垂直居中最容易翻车的点

绘制文字时,很多人会直接把轨道中心y坐标传给drawText的y参数,结果文字整体偏上或偏下。原因是drawText的y不是文字的顶部,也不是文字的中心,而是baseline基线。文字真正的视觉中心在ascent和descent之间的某个位置。

我封装了一个方法处理baseline:

private fun drawTextCentered( canvas: Canvas, text: String, centerY: Float, paint: TextPaint ) { val fm = paint.fontMetrics val baseline = centerY - (fm.ascent + fm.descent) / 2f canvas.drawText(text, 0f, baseline, paint) }

这个公式的原理是:ascent是负值,descent是正值,两者相加再除以2,等于字体的"中心偏移量"。用centerY减去这个偏移量,就得到baseline。如果你忽略这一步,弹幕在不同字号、不同字体混排时会出现明显的上下跳动,看起来非常业余。

还有一个更隐蔽的坑:如果弹幕文本里包含中文、英文、数字混合,或者意外的emoji,系统可能会切换到不同字体,fontMetrics也会变。所以我在弹幕君里固定使用同一个TextPaint实例,并且只在主线程设置一次字体属性,避免每条弹幕重新测量。设置字体时我也没有直接依赖系统默认字体,而是指定了一个支持中文的Typeface,保证测量结果稳定。

3. 调度器设计:弹道、速度与密度控制的取舍

3.1 轨道分配:把屏幕高度切成一条条"泳道"

弹幕和普通文字动画最大的区别是,它不能想飘就飘。两条弹幕如果进了同一个轨道,速度又快,后一条就会追尾,叠在一起完全没法看。所以必须有一个调度器决定每条弹幕走哪一行。

我用的方案很直观:把可弹幕区域的高度按行高切分成若干轨道,每个轨道记录一个"可进入时间"。弹幕还没到达期间,该轨道不允许新弹幕进入。

class TrackManager(private val trackCount: Int, private val rowHeight: Float) { private val availableAt = LongArray(trackCount) fun acquire(now: Long, duration: Long): Int { var target = -1 var earliest = Long.MAX_VALUE for (i in 0 until trackCount) { if (availableAt[i] <= now) { target = i break } if (availableAt[i] < earliest) { earliest = availableAt[i] target = i } } availableAt[target] = now + duration return target } }

这段逻辑的意思是:优先选已经空闲的轨道,如果全部被占用,就选最早能空闲的那条。duration是这条弹幕从入屏到完全出屏的总时间,所以availableAt记录的是"这条弹幕彻底离开屏幕的时间"。这个方案比逐像素碰撞检测简单得多,而且已经能满足第一版的需求。

3.2 速度策略:固定速度与固定漂移时长的差异

弹幕速度有两个常见流派。第一种是固定速度,每条弹幕每秒移动固定像素。实现最简单,但长文本弹幕会在屏幕上待很久,短文本弹幕一眨眼就飞过去了,整体观感很碎。第二种是固定漂移时长,让每条弹幕从入屏到完全出屏都花同样的时间,这样不管文字多长,节奏都统一,这也是大多数弹幕产品的做法。

我用的是固定时长策略,计算公式如下:

totalDuration = 7000ms(默认) speed = (viewWidth + textWidth) / totalDuration

viewWidth是弹幕View的宽度,textWidth是这条文字自身的宽度。可以看到,文字越长,speed越大。这样长文本虽然移动快,但整体漂移时间和其他弹幕一致,观众不会觉得某条弹幕"赖在屏幕上不走"。

算法上还要加两个限制:最小速度和最大速度。如果一条弹幕文本特别短,计算出来的速度可能小于0.2px/ms,观感上有点拖;如果文本特别长,速度可能超过1.5px/ms,看起来像一道光闪过,根本看不清内容。所以我在弹幕君里把速度夹在0.4到1.2px/ms之间。这个区间是试出来的,不同直播间风格可以调。

3.3 高密度下的兜底策略

直播间弹幕经常在短时间内暴增。轨道数只有那么多,如果每来一条都进调度器,最终所有轨道都会被占满。这时候该怎么办?

我第一版选的策略是"丢弃尾部"。具体来说,当所有轨道都不可用,并且新弹幕没有更高优先级时,直接丢弃这条弹幕。这个策略对直播场景是合理的:弹幕是实时的,用户发出一条后几秒钟就过去了,保留最新内容比保留老旧内容更重要。如果追求更高体验,可以把丢弃改成"排队",但排队会造成延迟,直播间用户无法接受。

另外,我还在调度器里加了一个同屏数量上限,超过80条就停止接收新弹幕。这个上限不是随便定的,而是根据性能测试得出的:80条以下,Bitmap缓存方案能稳定跑满帧率;再往上,中端机就会开始掉帧。与其让所有弹幕都慢下来,不如通过丢弃保护核心体验。

4. 首版性能优化实录:从频繁drawText到Bitmap缓存

4.1 现象:弹幕一多就卡,问题不在数量而在绘制过程

第一版写完,我直接往弹幕队列里塞了80条测试弹幕。可以看到弹幕在飘,但帧率明显不稳,测试机上的帧率掉到20fps左右。起初我以为是弹幕数量太多,后来把数量降到40条,依然只有30fps。

问题出在onDraw里的drawText。drawText不是简单地复制一段像素,它需要把文本变成字形,处理中文的复杂笔画、下划线和抗锯齿,还要根据Paint的样式生成对应的Path,这个开销远大于drawBitmap。80条弹幕,每帧就是80次drawText,等于每16毫秒内让CPU做80次文本栅格化。中端机根本扛不住。

4.2 预渲染Bitmap:把文字画一次,之后只搬图

优化思路很简单:弹幕文字内容是不变的,颜色、字号、描边也是不变的,变的只是每一帧的x和y。那我们完全把"文字变成图片"这件事提前做完,滚动时只drawBitmap。

核心代码是给每条弹幕生成一个Bitmap:

private fun createDanmakuBitmap(item: DanmakuItem): Bitmap { val paint = TextPaint(ANTI_ALIAS_FLAG) paint.color = item.color paint.textSize = item.textSize paint.typeface = typeface if (enableStroke) { paint.style = Paint.Style.FILL_AND_STROKE paint.strokeWidth = 3f paint.strokeColor = Color.BLACK } val textWidth = ceil(paint.measureText(item.content)).toInt() val fm = paint.fontMetrics val textHeight = ceil(fm.descent - fm.ascent).toInt() val bitmap = Bitmap.createBitmap(textWidth, textHeight, Bitmap.Config.ARGB_8888) val canvas = Canvas(bitmap) val baseline = -fm.ascent canvas.drawText(item.content, 0f, baseline, paint) return bitmap }

这段代码里有个很容易理解错的点:新创建的Canvas原点在Bitmap左上角,所以baseline算出来是-fm.ascent,也就是从顶部往下偏移一个ascent的高度。如果你的描边宽度很粗,Bitmap边缘会被裁掉,所以我通常会在宽高上额外加上2倍strokeWidth,再用Matrix平移画出完整描边。

优化后的onDraw变成了:

canvas.drawBitmap(bmp, item.x, item.yBase - bmp.height / 2f, null)

我把Bitmap的缓存Key设计成文本、颜色、字号、描边开关和字体这几项的组合。因为直播间里"哈哈哈哈哈"这类弹幕重复率极高,同一个Key命中后直接复用Bitmap,连生成那一次的开销都能省掉。我用了一个简单的LruCache来管理,而不是把所有Bitmap都放内存里,避免弹幕内容太多导致OOM。

4.3 改掉不合理的帧循环

初版我图省事,用Thread + while(true)循环,每16ms调用一次invalidate。这个方案有两个问题:一是Thread休眠不精确,会受系统调度影响;二是没有弹幕时循环依然在跑,白白耗电。

后来改成了Choreographer回调:

private val frameCallback = object : Choreographer.FrameCallback { override fun doFrame(frameTimeNanos: Long) { invalidate() Choreographer.getInstance().postFrameCallback(this) } } fun start() { if (!isRunning) { isRunning = true Choreographer.getInstance().postFrameCallback(frameCallback) } } fun stop() { isRunning = false Choreographer.getInstance().removeFrameCallback(frameCallback) }

Choreographer会在每次VSync信号到来时回调doFrame,这是和系统UI刷新完全同步的节奏。另一个好处是,当应用切到后台,onVisibilityChanged里调用stop,帧回调就完全停下来,不会再做无意义的绘制。

4.4 用绝对时间代替累加时间

这里再补充一个在弹幕动画里特别重要的细节:千万不要用"每帧位移增量累加"的方式更新x坐标。

比如每帧位移deltaX = speed * frameDelta,然后把x = x + deltaX。一旦有一帧卡顿,frameDelta会突然变大,弹幕会瞬移一小段,视觉上就是轻微跳变。而且多次累加会引入浮点误差,时间越长越不准。

弹幕君的写法是:

item.x = width.toFloat() - (now - item.startTime) * item.speed

每一帧都从startTime重新计算当前位置。就算上一帧卡了100ms,这一帧也能直接算出正确的x,不会产生跳变累积。这个思路可以推广到任何需要连续动画的领域,不只是弹幕。

4.5 优化结果与内存注意点

我在同一台测试机上做了三版对比:

版本同屏80条帧率单帧绘制方式备注
初版约22fps80次drawText有明显卡顿
Bitmap缓存约45fps80次drawBitmap卡顿基本消失
Bitmap缓存+Choreographer约58fps80次drawBitmap接近满帧

当然这个数据跟设备、文本宽度、描边复杂度都有关,但趋势是一致的:把文本栅格化从每帧移到预处理,收益非常明显。

内存方面也要给个提醒。单条弹幕Bitmap按300x60算,大约70KB。LruCache我设置了16MB上限,大约能缓存200多张,足够应付大部分场景。如果弹幕样式特别花哨,比如带阴影和高斯模糊,单张Bitmap可能超过200KB,上限要重新评估。另外,Bitmap复用尽量通过LruCache的entryRemoved回调去回收,不要让引用一直悬空。

5. 第一版做完之后的边界,以及下一期准备解决的事

5.1 现在的"弹幕君"能做什么

经过第一轮优化,弹幕君已经具备了一个最小可用弹幕层:支持普通滚动弹幕、颜色字号描边配置、自动轨道分配,中端机同屏80条也能保持流畅。代码量不大,核心逻辑集中在DanmakuView、TrackManager和BitmapCache三个类里,后面要扩展也比较清楚。

但这个版本还有明显的边界。第一,它只管"在屏幕上飘字",还没有跟视频播放器做时间轴同步。第二,弹幕数据源是我手动添加的,没有真正的弹幕协议和解析模块。第三,交互能力基本为零,比如点击某条弹幕高亮、长按屏蔽,第一版都没做。这些不是不重要,而是得有一个稳定的渲染底座之后再做,否则交互和渲染耦合在一起,排查问题会非常痛苦。

5.2 还没啃下的硬骨头:视频时间轴同步

弹幕和普通字幕不一样,它必须和视频内容处在同一个时间轴上。用户暂停视频,弹幕要跟着冻结;用户拖动进度条,弹幕要立刻跳到对应的时间点重新计算位置。

这个问题的难度在于:弹幕的位置不能只用"系统当前时间"推算,而要用"视频播放进度"推算。我在弹幕君第二期里打算引入一个统一的Clock层,视频插件和弹幕层都从同一个时钟读时间。用户暂停时,时钟暂停;用户seek时,时钟重置。快到期的弹幕要重新入队,已经出屏的弹幕要重新拉回。相比第一版的简单线性移动,这部分的复杂度是一个量级的提升,也是我觉得最值得继续写的部分。

5.3 弹幕源与数据模型的初步设计

弹幕数据源方面,为了给后续接服务端做准备,我会在第二期定义一套弹幕协议。每条弹幕至少包含以下字段:

{ "timeMs": 123456, "type": 1, "content": "第一期的内容先到这里", "color": "#FFFFFF", "size": 32, "pos": 0.5 }

timeMs表示弹幕应该出现在视频的哪个时间点,pos表示弹幕在屏幕上的垂直位置比例。用比例而不是绝对像素的好处是,不同分辨率设备上都能保持一致。解析和渲染解耦,弹幕层只需要消费排好序的列表,不需要关心数据来自本地文件还是网络轮询。

5.4 写给同样在踩坑的人

最后说点实在的体会。自定义View做动画,最难的不是写出第一版,而是当需求从"能飘字"变成"稳定飘字"时,你有没有耐心去定位瓶颈。我第一版掉到22fps时,第一反应是"换一个更快的开源库",后来才意识到,问题根本不在调度,而在绘制方式。同样是画一条弹幕,drawText和drawBitmap的帧率差距非常明显。很多Android性能问题都是这样,表象是卡顿,根源往往在某个看似不起眼的API选择上。

第二期我会接着写视频时间轴同步和弹幕协议解析。那个部分会比今天这篇更偏工程,因为要处理播放器状态机、缓存窗口、时钟同步,每一块都有坑。如果你也在做类似功能,欢迎先从今天这版跑起来,再一起往下啃。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询