对于视频网站来说弹幕是一个十分常见的功能, 目前业界比较出名的弹幕库是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); } } 复制代码
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()
中
到这里就简单的分析完了整个实现流程,上面讲的可能不是很详细,不过基本流程都讲到了
单独开辟一个Surface
来处理弹幕的绘制操作,即绘制操作是可以在子线程(DrawHandler),不会造成主线程的卡顿
public long drawDanmakus() { ... Canvas canvas = mSurfaceHolder.lockCanvas(); ... RenderingState rs = handler.draw(canvas); mSurfaceHolder.unlockCanvasAndPost(canvas); ... return dtime; } 复制代码
直接继承自TextureView, TextureView与View和SurfaceView的不同之处是 :
Layer Renderer
的对象以Open GL
纹理的形式来绘制的, 不过依然要同步于主线程的绘制操作将DanmakuFlameMaster
的Demo运行1分钟后,通过CPU Memory Profiler
可以看到 : DanmakuView的Graphics占用内存比较多 , 其实主要原因是因为View硬件加速渲染时大量纹理由CPU同步到GPU消耗了大量的内存
那么如何优化呢?
个人感觉可以在现有的基础上使用GLSurfaceView
或者GLTextureView
通过Open GL
来完成弹幕的渲染。
更多Android相关文章见Android进阶计划