越来越多的app增加了直播的功能,既然是直播那么弹幕也是一个逃不过的话题。最近项目也添加了弹幕,前前后后也忙活了好几天。本文就简单的记录弹幕的开发。
目标
先来看一下,弹幕要做成什么样子的。图片来源于美芽。
根据这张图片我们可以将分成几个需要完成的小目标:
- 弹幕方向从右向左
- 只显示两行弹幕
- 两种显示类型:1 包含头像和一个心形;2 包含头像和文字,同时有三种背景颜色
- 上下弹幕之间的间距以及两条弹幕之间的间距
开发
本着不重复造轮子的麻烦事,从github上找到了大名鼎鼎的B站开源的弹幕库DanmakuFlameMaster。
配置
根据demo我们来进行开发。首先是添加显示弹幕的控件。提供了三个控件:’DanmakuSurfaceView ‘、’ DanmakuTextureView’以及’ DanmakuView’。我们这里采用’ DanmakuView’。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | <FrameLayout android:layout_width="match_parent" android:layout_height="300dp" android:layout_centerInParent="true"> <ImageView android:layout_width="match_parent" android:layout_height="match_parent" android:scaleType="centerCrop" android:src="@mipmap/wat"/> <master.flame.danmaku.ui.widget.DanmakuView android:id="@+id/danmakuView" android:layout_width="match_parent" android:layout_height="80dp" android:layout_gravity="bottom"/> </FrameLayout> |
接下来是做一些初始化的配置。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | /** * 初始化配置 */ private void initDanmuConfig() { // 设置最大显示行数 HashMap<Integer, Integer> maxLinesPair = new HashMap<Integer, Integer>(); maxLinesPair.put(BaseDanmaku.TYPE_SCROLL_RL, 2); // 滚动弹幕最大显示2行 // 设置是否禁止重叠 HashMap<Integer, Boolean> overlappingEnablePair = new HashMap<Integer, Boolean>(); overlappingEnablePair.put(BaseDanmaku.TYPE_SCROLL_RL, true); overlappingEnablePair.put(BaseDanmaku.TYPE_FIX_TOP, true); mDanmakuContext = DanmakuContext.create(); mDanmakuContext .setDanmakuStyle(IDisplayer.DANMAKU_STYLE_NONE) .setDuplicateMergingEnabled(false) .setScrollSpeedFactor(1.2f)//越大速度越慢 .setScaleTextSize(1.2f) .setCacheStuffer(new BackgroundCacheStuffer(), mCacheStufferAdapter) .setMaximumLines(maxLinesPair) .preventOverlapping(overlappingEnablePair); } |
然后是对DanmuView进行配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | if (mDanmakuView != null) { mDanmakuView.setCallback(new DrawHandler.Callback() { @Override public void prepared() { mDanmakuView.start(); } @Override public void updateTimer(DanmakuTimer timer) { } @Override public void danmakuShown(BaseDanmaku danmaku) { } @Override public void drawingFinished() { } }); } //这里原本是一个解析器,可以使用库里提供A站或B站的解析器,也可以自己写一个,但由于项目中获取到的数据已经是model,这里就没有使用这个。 mDanmakuView.prepare(new BaseDanmakuParser() { @Override protected Danmakus parse() { return new Danmakus(); } }, mDanmakuContext); mDanmakuView.enableDanmakuDrawingCache(true); |
好了,到这里我们初始化的配置就已经完成。但是在配置‘DanmakuContext’的时候还有两个没介绍‘BackgroundCacheStuffer’和‘BaseCacheStuffer.Proxy’。
1 ’BackgroundCacheStuffer’从这个命名就可以看出这个用来做一些背景绘制的操作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | /** * 绘制背景(自定义弹幕样式) */ private class BackgroundCacheStuffer extends SpannedCacheStuffer { // 通过扩展SimpleTextCacheStuffer或SpannedCacheStuffer个性化你的弹幕样式 final Paint paint = new Paint(); @Override public void measure(BaseDanmaku danmaku, TextPaint paint, boolean fromWorkerThread) { // danmaku.padding = 20; // 在背景绘制模式下增加padding super.measure(danmaku, paint, fromWorkerThread); } @Override public void drawBackground(BaseDanmaku danmaku, Canvas canvas, float left, float top) { paint.setAntiAlias(true); if (!danmaku.isGuest && danmaku.userId == mGoodUserId && mGoodUserId != 0) { paint.setColor(PINK_COLOR);//粉红 楼主 } else if (!danmaku.isGuest && danmaku.userId == mMyUserId && danmaku.userId != 0) { paint.setColor(ORANGE_COLOR);//橙色 我 } else { paint.setColor(BLACK_COLOR);//黑色 普通 } if (danmaku.isGuest) {//如果是赞 就不要设置背景 paint.setColor(Color.TRANSPARENT); } //由于该库并没有提供margin的设置,所以我这边试出这种方法:将danmaku.padding也就是内间距设置大一点,并在这里的RectF中设置绘制弹幕的位置,就可以形成类似margin的效果 canvas.drawRoundRect(new RectF(left + DANMU_PADDING_INNER, top + DANMU_PADDING_INNER , left + danmaku.paintWidth - DANMU_PADDING_INNER + 6, top + danmaku.paintHeight - DANMU_PADDING_INNER + 6),//+6 主要是底部被截得太厉害了,+6是增加padding的效果 DANMU_RADIUS, DANMU_RADIUS, paint); } @Override public void drawStroke(BaseDanmaku danmaku, String lineText, Canvas canvas, float left, float top, Paint paint) { // 禁用描边绘制 } } |
2 ‘BaseCacheStuffer.Proxy ’这个类提供了两个方法‘prepareDrawing ’在弹幕绘制之前是否要做一些更新操作,比如更换图片、文字。‘releaseResource ’这个顾名思义就是释放一些资源。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | private BaseCacheStuffer.Proxy mCacheStufferAdapter = new BaseCacheStuffer.Proxy() { @Override public void prepareDrawing(final BaseDanmaku danmaku, boolean fromWorkerThread) { // if (danmaku.text instanceof Spanned) { // 根据你的条件检查是否需要需要更新弹幕 // } } @Override public void releaseResource(BaseDanmaku danmaku) { // TODO 重要:清理含有ImageSpan的text中的一些占用内存的资源 例如drawable if (danmaku.text instanceof Spanned) { danmaku.text = ""; } } }; |
弹幕显示效果开发
完成了配置之后,我们可以发现背景的绘制、上下之间的间距和行数已经完成了。还剩下‘两种显示类型:1 包含头像和一个心形;2 包含头像和文字,同时有三种背景颜色’。
由于这个库是支持图文混排的,那么实现头像和文字的显示就简单了许多,我们只要将bitmap转成drawable,然后通过‘SpannableStringBuilder’和‘ImageSpan’来完成图文混排。
包含头像和一个心形:这个原本是想在背景绘制的时候同时绘制心形,但是发现绘制出来的心形处在图片的下方被挡住了,所以后面还是在drawable中同时将心形绘制出来。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 | /** * 圆形的Drawable * Created by feiyang on 16/2/18. */ public class CircleDrawable extends Drawable { private Paint mPaint; private Bitmap mBitmap; private Bitmap mBitmapHeart; private boolean mHasHeart; private static final int BLACK_COLOR = 0xb2000000;//黑色 背景 private static final int BLACKGROUDE_ADD_SIZE = 4;//背景比图片多出来的部分 public CircleDrawable(Bitmap bitmap) { mBitmap = bitmap; BitmapShader bitmapShader = new BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP); mPaint = new Paint(); mPaint.setAntiAlias(true); mPaint.setShader(bitmapShader); } /** * 右下角包含一个‘心’的圆形drawable * * @param context * @param bitmap * @param hasHeart */ public CircleDrawable(Context context, Bitmap bitmap, boolean hasHeart) { this(bitmap); mHasHeart = hasHeart; if (hasHeart) { setBitmapHeart(context); } } private void setBitmapHeart(Context context) { Bitmap bitmap = BitmapFactory.decodeResource(context.getResources(), R.mipmap.ic_liked); if (bitmap != null) { Matrix matrix = new Matrix(); matrix.postScale(0.8f, 0.8f); mBitmapHeart = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true); } } @Override public void setBounds(int left, int top, int right, int bottom) { super.setBounds(left, top, right, bottom); } @Override public void draw(Canvas canvas) { if (mHasHeart && mBitmapHeart != null) { //设置背景 Paint backgroundPaint = new Paint(); backgroundPaint.setAntiAlias(true); backgroundPaint.setColor(BLACK_COLOR); canvas.drawCircle(getIntrinsicWidth() / 2 + BLACKGROUDE_ADD_SIZE, getIntrinsicHeight() / 2 + BLACKGROUDE_ADD_SIZE, getIntrinsicWidth() / 2 + BLACKGROUDE_ADD_SIZE, backgroundPaint); //先将画布平移,防止图片不在正中间,然后绘制图片 canvas.translate(BLACKGROUDE_ADD_SIZE, BLACKGROUDE_ADD_SIZE); canvas.drawCircle(getIntrinsicWidth() / 2, getIntrinsicHeight() / 2, getIntrinsicWidth() / 2, mPaint); //在右下角绘制‘心’ Rect srcRect = new Rect(0, 0, mBitmapHeart.getWidth(), mBitmapHeart.getHeight()); Rect desRect = new Rect(getIntrinsicWidth() - mBitmapHeart.getWidth() + BLACKGROUDE_ADD_SIZE * 2, getIntrinsicHeight() - mBitmapHeart.getHeight() + BLACKGROUDE_ADD_SIZE * 2, getIntrinsicWidth() + BLACKGROUDE_ADD_SIZE * 2, getIntrinsicHeight() + BLACKGROUDE_ADD_SIZE * 2); Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); paint.setFilterBitmap(true); paint.setDither(true); canvas.drawBitmap(mBitmapHeart, srcRect, desRect, paint); } else { canvas.drawCircle(getIntrinsicWidth() / 2, getIntrinsicHeight() / 2, getIntrinsicWidth() / 2, mPaint); } } @Override public int getIntrinsicWidth() { return mBitmap.getWidth(); } @Override public int getIntrinsicHeight() { return mBitmap.getHeight(); } @Override public void setAlpha(int alpha) { mPaint.setAlpha(alpha); } @Override public void setColorFilter(ColorFilter cf) { mPaint.setColorFilter(cf); } @Override public int getOpacity() { return PixelFormat.TRANSLUCENT; } } |
这样就完成了效果的显示,但是这样还存在一个问题,就是图文混排的时候,文字在竖直方向上并不是处于中间位置,后面继承了‘ImageSpan’来完成效果。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 | /** * 图文混排使图片文字基于中线对齐 * Created by feiyang on 16/2/18. * 参考:http://stackoverflow.com/questions/25628258/align-text-around-imagespan-center-vertical */ public class CenteredImageSpan extends ImageSpan { private WeakReference<Drawable> mDrawableRef; public CenteredImageSpan(final Drawable drawable) { super(drawable); } @Override public int getSize(Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) { Drawable d = getCachedDrawable(); Rect rect = d.getBounds(); if (fm != null) { Paint.FontMetricsInt pfm = paint.getFontMetricsInt(); // keep it the same as paint's fm fm.ascent = pfm.ascent; fm.descent = pfm.descent; fm.top = pfm.top; fm.bottom = pfm.bottom; } return rect.right; } @Override public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint) { Drawable b = getCachedDrawable(); canvas.save(); int drawableHeight = b.getIntrinsicHeight(); int fontAscent = paint.getFontMetricsInt().ascent; int fontDescent = paint.getFontMetricsInt().descent; int transY = bottom - b.getBounds().bottom + // align bottom to bottom (drawableHeight - fontDescent + fontAscent) / 2; // align center to center canvas.translate(x, transY); b.draw(canvas); canvas.restore(); } // Redefined locally because it is a private member from DynamicDrawableSpan private Drawable getCachedDrawable() { WeakReference<Drawable> wr = mDrawableRef; Drawable d = null; if (wr != null) d = wr.get(); if (d == null) { d = getDrawable(); mDrawableRef = new WeakReference<>(d); } return d; } } |
添加弹幕
效果完成之后,我们就可以添加弹幕了。首先是创建一个弹幕
1
| BaseDanmaku danmaku = mDanmakuContext.mDanmakuFactory.createDanmaku(BaseDanmaku.TYPE_SCROLL_RL);
|
然后往显示弹幕的控件这边是‘mDanmakuView’添加该弹幕
1
| mDanmakuView.addDanmaku(danmaku);
|
目前在这个库中提供了5中类型的弹幕了
1 2 3 4 5 | TYPE_SCROLL_RL = 1;//从右向左 TYPE_SCROLL_LR = 6;//从左向右 TYPE_FIX_TOP = 5;//停留在顶部 TYPE_FIX_BOTTOM = 4;//停留在底部 TYPE_SPECIAL = 7;//特殊弹幕 注:这个没试过不知道是什么效果 |
添加弹幕部分完整的代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 | public void addDanmu(Danmu danmu, int i) { BaseDanmaku danmaku = mDanmakuContext.mDanmakuFactory.createDanmaku(BaseDanmaku.TYPE_SCROLL_RL); danmaku.userId = danmu.userId; danmaku.isGuest = danmu.type.equals("Like");//isGuest此处用来判断是赞还是评论 SpannableStringBuilder spannable; Bitmap bitmap = getDefaultBitmap(danmu.avatarUrl); CircleDrawable circleDrawable = new CircleDrawable(mContext, bitmap, danmaku.isGuest); circleDrawable.setBounds(0, 0, BITMAP_WIDTH, BITMAP_HEIGHT); spannable = createSpannable(circleDrawable, danmu.content); danmaku.text = spannable; danmaku.padding = DANMU_PADDING; danmaku.priority = 0; // 1:一定会显示, 一般用于本机发送的弹幕,但会导致行数的限制失效 danmaku.isLive = false; danmaku.time = mDanmakuView.getCurrentTime() + (i * ADD_DANMU_TIME); danmaku.textSize = DANMU_TEXT_SIZE/* * (mDanmakuContext.getDisplayer().getDensity() - 0.6f)*/; danmaku.textColor = Color.WHITE; danmaku.textShadowColor = 0; // 重要:如果有图文混排,最好不要设置描边(设textShadowColor=0),否则会进行两次复杂的绘制导致运行效率降低 mDanmakuView.addDanmaku(danmaku); } private Bitmap getDefaultBitmap(int drawableId) { Bitmap mDefauleBitmap = null; Bitmap bitmap = BitmapFactory.decodeResource(mContext.getResources(), drawableId); if (bitmap != null) { int width = bitmap.getWidth(); int height = bitmap.getHeight(); Log.d(TAG, "width = " + width); Log.d(TAG, "height = " + height); Matrix matrix = new Matrix(); matrix.postScale(((float) BITMAP_WIDTH) / width, ((float) BITMAP_HEIGHT) / height); mDefauleBitmap = Bitmap.createBitmap(bitmap, 0, 0, width, height, matrix, true); Log.d(TAG, "mDefauleBitmap getWidth = " + mDefauleBitmap.getWidth()); Log.d(TAG, "mDefauleBitmap getHeight = " + mDefauleBitmap.getHeight()); } return mDefauleBitmap; } private SpannableStringBuilder createSpannable(Drawable drawable, String content) { String text = "bitmap"; SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder(text); CenteredImageSpan span = new CenteredImageSpan(drawable); spannableStringBuilder.setSpan(span, 0, text.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE); if (!TextUtils.isEmpty(content)) { spannableStringBuilder.append(" "); spannableStringBuilder.append(content.trim()); } return spannableStringBuilder; } |
适配
完成了弹幕之后,千万别忘了适配、适配、适配,重要的事说三遍。适配一直都是android中很重要的一环,也是很痛苦的一个过程。
由于弹幕库使用的大部分是像素,所以我们可以通过dp来进行转换。
1 2 3 4 5 6 7 8 9 10 11 12 | /** * 对数值进行转换,适配手机,必须在初始化之前,否则有些数据不会起作用 */ private void setSize(Context context) { BITMAP_WIDTH = DpOrSp2PxUtil.dp2pxConvertInt(context, BITMAP_HEIGHT); BITMAP_HEIGHT = DpOrSp2PxUtil.dp2pxConvertInt(context, BITMAP_HEIGHT); // EMOJI_SIZE = DpOrSp2PxUtil.dp2pxConvertInt(context, EMOJI_SIZE); DANMU_PADDING = DpOrSp2PxUtil.dp2pxConvertInt(context, DANMU_PADDING); DANMU_PADDING_INNER = DpOrSp2PxUtil.dp2pxConvertInt(context, DANMU_PADDING_INNER); DANMU_RADIUS = DpOrSp2PxUtil.dp2pxConvertInt(context, DANMU_RADIUS); DANMU_TEXT_SIZE = DpOrSp2PxUtil.sp2px(context, DANMU_TEXT_SIZE); } |
适配之后我们来看一下最终的效果。gif看起来有些卡顿,但在真机上并不会,有些模糊,将就一下。
至此,我们就完成了弹幕的开发。
资源
- 弹幕库:DanmakuFlameMaster
- Demo源码:DanmuDemo