# B站弹幕库DanmakuFlameMaster源码分析 站点:答源(https://www.dandroid.cn/) 原文:https://www.dandroid.cn/archives/20136 Canonical:https://www.dandroid.cn/archives/20136 作者:admin 发布日期:2023-09-12 最后更新:2023-09-12 17:04 分类:技术方案 内容类型:post 说明:本页是原文的纯文本镜像,供大模型与检索器完整读取;不含导航、广告、付费框。引用请以上方「原文」链接为准。 ## 正文 对于视频网站来说弹幕是一个十分常见的功能, 目前业界比较出名的弹幕库是B站的DanmakuFlameMaster(不过很久没有更新了),这个弹幕库的功能还是十分完善和稳定的,它里面的弹幕主要分为两种: 视频播放时用户实时发送的 视频加载时服务端下发的弹幕集合 由于整个弹幕库涉及到的逻辑还是非常多的, 本文主要分析一下用户发送一条从右向左滚动的弹幕的实现逻辑(不涉及视频弹幕时间同步等相关逻辑): 下图是用户发送一条弹幕时DanmakuFlameMaster的大致工作逻辑图: 涉及到的各个类大致的作用 R2LDanmaku : 一个弹幕对象, 里面包含x、y坐标, 缓存的Bitmap等属性 DanmakuView : 用来承载弹幕显示的ViewGroup, 除了它之外还有DanmakuSurfaceView、DanmakuTextureView DrawHandler : 一个绑定了异步HandlerThread的Handler, 控制整个弹幕的显示逻辑 CacheManagingDrawTask : 维护需要绘制的弹幕列表, 控制弹幕缓存逻辑 DrawingCacheHolder : 弹幕缓存的实现,缓存的是Bitmap, 与BaseDanmaku绑定 DanmakuRenderer : 对弹幕做一些过滤、碰撞检测、测量、布局、缓存等工作 Displayer : 持有Canvas画布, 绘制弹幕 在向DanmakuView中添加弹幕时会触发弹幕的显示流程: DanmakuView.java public void addDanmaku(BaseDanmaku item) { if (handler != null) { handler.addDanmaku(item); } } DrawHandler调度引起DanmakuView的渲染 将弹幕添加到CacheManagingDrawTask的弹幕集合danmakuList中 CacheManagingDrawTask.CacheManager创建弹幕缓存DrawingCache 通过Choreographer来不断渲染DanmakuView 第一步其实就是把弹幕添加到一个集合中,这里就不细看了,直接看DrawingCache.DrawingCacheHolder的创建 创建弹幕缓存DrawingCacheHolder 其实这里的缓存说白了就是一个Bitmap对象, 因为DanmakuFlameMaster的弹幕绘制的实现是 : 先把弹幕画在一个Bitmap上, 然后再把Bitmap绘制在Canvas上 CacheManagingDrawTask.CacheManager里面有一个HandlerThread,他会异步创建DrawingCache.DrawingCacheHolder,不过在创建DrawingCache前,会先尝试从缓存池中复用(找有没有可以复用的Bitmap): byte buildCache(BaseDanmaku item, boolean forceInsert) { ... DrawingCache cache = null; // 找有没有可以完全复用的弹幕,文字,宽,高,颜色等都相同 BaseDanmaku danmaku = findReusableCache(item, true, mContext.cachingPolicy.maxTimesOfStrictReusableFinds); //完全复用 if (danmaku != null) { cache = (DrawingCache) danmaku.cache; } if (cache != null) { ... cache.increaseReference(); //增加引用, 同屏上完全相同的弹幕时可以复用同一个缓存的 item.cache = cache; mCacheManager.push(item, 0, forceInsert); return RESULT_SUCCESS; } // 找有没有差不多可以复用的弹幕 danmaku = findReusableCache(item, false, mContext.cachingPolicy.maxTimesOfReusableFinds); if (danmaku != null) { cache = (DrawingCache) danmaku.cache; } if (cache != null) { danmaku.cache = null; cache = DanmakuUtils.buildDanmakuDrawingCache(item, mDisp, cache, mContext.cachingPolicy.bitsPerPixelOfCache); //redraw item.cache = cache; mCacheManager.push(item, 0, forceInsert); return RESULT_SUCCESS; } ... cache = mCachePool.acquire(); //直接创建出来一个弹幕 cache = DanmakuUtils.buildDanmakuDrawingCache(item, mDisp, cache, mContext.cachingPolicy.bitsPerPixelOfCache); item.cache = cache; boolean pushed = mCacheManager.push(item, sizeOf(item), forceInsert); .... } 上面这个方法其实主要分为3步: 寻找完全可以复用的弹幕,即内容、颜色等完全相同的, 同屏上完全相同的弹幕时可以复用同一个缓存的 寻找差不多可以复用的,这里的差不多其实是指找到一个比要画的弹幕大的弹幕(当然要大在一定范围内的) 没有缓存的话就创建一个 上面2、3两步都要走一个核心方法DanmakuUtils.buildDanmakuDrawingCache(): DrawingCache buildDanmakuDrawingCache(BaseDanmaku danmaku, IDisplayer disp, DrawingCache cache, int bitsPerPixel) { ... cache.build((int) Math.ceil(danmaku.paintWidth), (int) Math.ceil(danmaku.paintHeight), disp.getDensityDpi(), false, bitsPerPixel); DrawingCacheHolder holder = cache.get(); if (holder != null) { ... ((AbsDisplayer) disp).drawDanmaku(danmaku, holder.canvas, 0, 0, true); //直接把内容画上去 ... } return cache; } 即先build,然后draw: DrawingCache.build(): public void buildCache(int w, int h, int density, boolean checkSizeEquals, int bitsPerPixel) { boolean reuse = checkSizeEquals ? (w == width && h == height) : (w <= width && h <= height); if (reuse && bitmap != null) { bitmap.eraseColor(Color.TRANSPARENT); canvas.setBitmap(bitmap); recycleBitmapArray(); //一般没什么用 return; } ... bitmap = NativeBitmapFactory.createBitmap(w, h, config); if (density > 0) { mDensity = density; bitmap.setDensity(density); } if (canvas == null){ canvas = new Canvas(bitmap); canvas.setDensity(density); }else canvas.setBitmap(bitmap); } 其实就是如果这个DrawingCache中有Bitmap的话,那么就擦干净。如果没有Bitmap,那么就在native heap上创建一个Bitmap,这个Bitmap会和DrawingCache.DrawingCacheHolder的canvas管关联起来。 这里在native heap上创建Bitmap会减小java heap的压力,避免OOM AbsDisplayer.drawDanmaku() 这个方法的调用逻辑挺长的,就不把源码展开分析了,其实最终是通过DrawingCacheHolder.canvas把弹幕画在了DrawingCacheHolder.bitmap上: SimpleTextCacheStuffer.java @Override public void drawDanmaku(BaseDanmaku danmaku, Canvas canvas...) { ... drawBackground(danmaku, canvas, _left, _top); ... drawText(danmaku, lines[0], canvas, left, top - paint.ascent(), paint, fromWorkerThread); ... } 上面build和draw两步做的事简单来说就是: 在异步线程中给Danmaku准备好一个渲染完成的Bitmap ok, 走完上面这些步骤,其实一个绘制完成的弹幕的Bitmap就已经就绪了,接下来就是把这个Bitmap画到真正显示在平面上的画布Canvas上了 通过Choreographer来不断渲染DanmakuView 在最开始就已经知道DrawHandler用来控制整个弹幕逻辑,它会通过Choreographer来引起DanmakuView的渲染(draw): private void updateInChoreographer() { ... Choreographer.getInstance().postFrameCallback(mFrameCallback); ... d = mDanmakuView.drawDanmakus(); ... } mFrameCallback其实就是个套娃,即不断调用updateInChoreographer,mDanmakuView.drawDanmakus()其实是一个抽象方法,对于DanmakuView来说, 它会调用到View.postInvalidateCompat(),即触发DanmakuView.onDraw(), 从这里之后其实又有很复杂的逻辑, 也不把源码一一展开了, 最终调用到DanmakuRenderer.accept(): //main thread public int accept(BaseDanmaku drawItem) { ... // measure if (!drawItem.isMeasured()) { drawItem.measure(disp, false); } ... // layout 算x, y坐标 mDanmakusRetainer.fix(drawItem, disp, mVerifier); ... drawItem.draw(disp); } measure()这里就不看了,其实就是根据弹幕内容测量应该占多大空间; mDanmakusRetainer.fix()最终会调用到R2LDanmaku.layout() : public class R2LDanmaku extends BaseDanmaku { @Override public void layout(IDisplayer displayer, float x, float y) { if (mTimer != null) { long currMS = mTimer.currMillisecond; long deltaDuration = currMS - getActualTime(); if (deltaDuration > 0 && deltaDuration < duration.value) { this.x = getAccurateLeft(displayer, currMS); // 根据时间进度, 和当前显示器的宽度,来确定当前显示的x坐标 if (!this.isShown()) { this.y = y; this.setVisibility(true); } mLastTime = currMS; return; } mLastTime = currMS; } ... } } y坐标其实是由更上一个层的类确定好的, R2LDanmaku.layout主要是确定x坐标的逻辑,他的核心算法是 : 根据时间进度,和当前显示器的宽度,来确定当前显示的x坐标 接下来看怎么绘制一个弹幕的, 这里其实会调用到AndroidDisplayer.draw() public int draw(BaseDanmaku danmaku) { boolean cacheDrawn = sStuffer.drawCache(danmaku, canvas, left, top, alphaPaint, mDisplayConfig.PAINT); int result = IRenderer.CACHE_RENDERING; if (!cacheDrawn) { ... drawDanmaku(danmaku, canvas, left, top, false); // 绘制bitmap result = IRenderer.TEXT_RENDERING; } } 首先这里的canvas是DanmakuView.onDraw(canvas)的canvas, sStuffer.drawCache()其实就是把前面画好的Bitmap画在这个Canvas上, 如果没有现存的Bitmap可以去画,直接把画到Canvas上。 其实这里几乎90%的情况下都会走到sStuffer.drawCache()中 到这里就简单的分析完了整个实现流程,上面讲的可能不是很详细,不过基本流程都讲到了 DanmakuSurfaceView 单独开辟一个Surface来处理弹幕的绘制操作,即绘制操作是可以在子线程(DrawHandler),不会造成主线程的卡顿 public long drawDanmakus() { ... Canvas canvas = mSurfaceHolder.lockCanvas(); ... RenderingState rs = handler.draw(canvas); mSurfaceHolder.unlockCanvasAndPost(canvas); ... return dtime; } DanmakuTextureView 直接继承自TextureView, TextureView与View和SurfaceView的不同之处是 : 不同于SurfaceView, 它可以像普通View那样能被缩放、平移,也能加上动画 不同于普通View的硬件加速渲染, TextureView不具有Display List,它们是通过一个称为Layer Renderer的对象以Open GL纹理的形式来绘制的, 不过依然要同步于主线程的绘制操作 简单的性能分析 将DanmakuFlameMaster的Demo运行1分钟后,通过CPU Memory Profiler可以看到 : DanmakuView的Graphics占用内存比较多 , 其实主要原因是因为View硬件加速渲染时大量纹理由CPU同步到GPU消耗了大量的内存 那么如何优化呢? 个人感觉可以在现有的基础上使用GLSurfaceView或者GLTextureView通过Open GL来完成弹幕的渲染。 --- 由 答源 DAN 底座 1.4.0 生成 · 2026-11-08T12:51:16+00:00