B站弹幕库DanmakuFlameMaster源码浅析

4,770 阅读6分钟

对于视频网站来说弹幕是一个十分常见的功能, 目前业界比较出名的弹幕库是B站的DanmakuFlameMaster(不过很久没有更新了),这个弹幕库的功能还是十分完善和稳定的,它里面的弹幕主要分为两种:

  1. 视频播放时用户实时发送的
  2. 视频加载时服务端下发的弹幕集合

由于整个弹幕库涉及到的逻辑还是非常多的, 本文主要分析一下用户发送一条从右向左滚动的弹幕的实现逻辑(不涉及视频弹幕时间同步等相关逻辑):

下图是用户发送一条弹幕时DanmakuFlameMaster的大致工作逻辑图:

涉及到的各个类大致的作用

  • R2LDanmaku : 一个弹幕对象, 里面包含x、y坐标, 缓存的Bitmap等属性
  • DanmakuView : 用来承载弹幕显示的ViewGroup, 除了它之外还有DanmakuSurfaceViewDanmakuTextureView
  • DrawHandler : 一个绑定了异步HandlerThreadHandler, 控制整个弹幕的显示逻辑
  • CacheManagingDrawTask : 维护需要绘制的弹幕列表, 控制弹幕缓存逻辑
  • DrawingCacheHolder : 弹幕缓存的实现,缓存的是Bitmap, 与BaseDanmaku绑定
  • DanmakuRenderer : 对弹幕做一些过滤、碰撞检测、测量、布局、缓存等工作
  • Displayer : 持有Canvas画布, 绘制弹幕

在向DanmakuView中添加弹幕时会触发弹幕的显示流程:

DanmakuView.java

public void addDanmaku(BaseDanmaku item) {
    if (handler != null) {
        handler.addDanmaku(item);
    }
}

DrawHandler调度引起DanmakuView的渲染

  1. 将弹幕添加到CacheManagingDrawTask的弹幕集合danmakuList
  2. CacheManagingDrawTask.CacheManager创建弹幕缓存DrawingCache
  3. 通过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步:

  1. 寻找完全可以复用的弹幕,即内容、颜色等完全相同的, 同屏上完全相同的弹幕时可以复用同一个缓存的
  2. 寻找差不多可以复用的,这里的差不多其实是指找到一个比要画的弹幕大的弹幕(当然要大在一定范围内的)
  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.DrawingCacheHoldercanvas管关联起来。

这里在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);
    ...
}

上面builddraw两步做的事简单来说就是: 在异步线程中给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;
    }   
}

首先这里的canvasDanmakuView.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来完成弹幕的渲染。

更多Android相关文章见Android进阶计划