轻松又酷炫地实现弹幕效果——手把手教学

前言

更多代码,请查看我的github:github.com/shuaijia/Js… ,喜欢的话就给个star!^_^
也可以关注我的公众号,搜索 安卓干货营

现在越来越多的视频网站或者客户端支持弹幕功能,弹幕功能似乎也成了很多人的爱好,发弹幕,看弹幕成了大家吐槽、搞笑、发表看法的一种方式。

而国内弹幕的鼻祖应该就算A站和B站了。

弹幕(barrage),中文流行词语,原意指用大量或少量火炮提供密集炮击。而弹幕,顾名思义是指子弹多而形成的幕布,大量吐槽评论从屏幕飘过时效果看上去像是飞行射击游戏里的弹幕。

最近一直在写视频播放器,那弹幕怎么能少得了呢!所以把自己开发弹幕功能的思路写出来与大家分享。

依旧还是先上效果图:

《轻松又酷炫地实现弹幕效果——手把手教学》

大体思路

我们的目标是将各式各样的itemView展示到播放器上方,并且使之滚动起来,itemView支持自定义,这样看起来和ListView的功能很相像,但与之不一样的是,弹幕是多行多列,需要计算每个itemView的位置,且一直在滚动

所以,我采用适配器模式,仿ListView的Adapter来实现弹幕功能。 想到这里,很多人就会觉得这不典型的横向瀑布流嘛,用RecyclerView或者flexbox很轻松就实现了。

但我想自己从设计模式、实现原理来考虑、设计,从而也可以更深刻地理解适配器模式和ListView的原理,如果您想使用RecyclerView来实现,可以自己试试。

关键:

  • 使用适配器模式将各式各样的itemView进行适配、处理、展示
  • 使用hadler定时发送消息使itemView滚动
  • itemView最佳位置的计算
  • 滚动区域的设置

接下来就一起来实现:

1、实体类

实体类当然不能少了:

/**
 * Description: 弹幕实体类
 * Created by jia on 2017/9/25.
 * 人之所以能,是相信能
 */
public class DanmuModel {

    private int type;

    public int getType() {
        return type;
    }

    public void setType(int type) {
        this.type = type;
    }
}

其中的type是model实体类的类型,因为弹幕的itemView中会有多种类型,对应不同type的实体类。

使用时可以自己定义实体类,继承自DanmuModel ,也可以不继承,只要能区分不同类型就可以:因为自己稍后的adapter中没有像ListView的Adapter一样定义了获取item类型的方法,所以就在getView方法中依据type选择不同的itemView即可

2、BaseAdapter

《轻松又酷炫地实现弹幕效果——手把手教学》

首先Adapter定义为抽象类,且设置泛型M,M就是对应的实体类。

在Adapter中定义三个抽象方法:

  1. getViewTypeArray :获取itemView的类型type组成的数组
  2. getSingleLineHeight :获取单行itemView的高度
  3. getView :获取itemView,功能类似于ListView的Adapter中getView方法
public abstract class DanmuAdapter<M> {
    /**
     * 获取类型数组
     *
     * @return
     */
    public abstract int[] getViewTypeArray();

    /**
     * 获取单行弹幕 高度
     *
     * @return
     */
    public abstract int getSingleLineHeight();

    /**
     * 获取itemView
     *
     * @param entry
     * @param convertView
     * @return
     */
    public abstract View getView(M entry, View convertView);
}

这样适配器抽象类就定义好了嘛?不是的!

在显示弹幕的时候会,会创建大量的View对象,如果不做处理,很容易造成内存溢出,所以我们要进行缓存优化

A、首先创建了map集合

// 使用HashMap,以类型和对应view的栈为key-value存储,实现缓存
private HashMap<Integer, Stack<View>> cacheViews;

以view的类型为key,对应的view存入栈中,以栈为value。

B、构造中

public DanmuAdapter() {
    cacheViews = new HashMap<>();
    typeArray = getViewTypeArray();
    for (int i = 0; i < typeArray.length; i++) {
        Stack<View> stack = new Stack<>();
        cacheViews.put(typeArray[i], stack);
    }
}

获取itemView类型数组,循环创建对应type的栈。

C、itemView加入缓存

/**
  * 将弹幕itemView加入缓存(压栈)
  *
  * @param type
  * @param view
  */
synchronized public void addViewToCache(int type, View view) {
    if (cacheViews.containsKey(type)) {
        cacheViews.get(type).push(view);
    } else {
        throw new Error("your cache has not this type");
    }
}

D、将itemView移出缓存

/**
* 将itemView移出缓存(弹栈)
*
* @param type
* @return
*/
synchronized public View removeViewFromCache(int type) {
    if (cacheViews.containsKey(type) && cacheViews.get(type).size() > 0)
        return cacheViews.get(type).pop();
    else
        return null;
}

E、减小缓存大小

/**
* 减小缓存大小
*/
public void shrinkCacheSize() {
    int[] typeArray = getViewTypeArray();
    for (int i = 0; i < typeArray.length; i++) {
        if (cacheViews.containsKey(typeArray[i])) {
            Stack<View> typeStack = cacheViews.get(typeArray[i]);
            int length = typeStack.size();
            // 循环弹栈,直到大小变为原来一半
            while (typeStack.size() > (int) (length / 2.0 + 0.5)) {
                typeStack.pop();
            }
            cacheViews.put(typeArray[i], typeStack);
        }
    }
}

F、获取缓存大小

/**
* 获取缓存大小
*
* @return
*/
public int getCacheSize() {
    int size = 0;
    int[] types = getViewTypeArray();
    for (int i = 0; i < types.length; i++) {
        size = size + cacheViews.get(types[i]).size();
    }
    return size;
}

ok,到这里BaseAdapter就封装完成了

3、DanmuView

继承自ViewGroup,重写其三个构造方法是必然的。不再累赘

A、变量、以及get/set方法

// 移动速度
public static final int LOWER_SPEED = 1;
public static final int NORMAL_SPEED = 4;
public static final int HIGH_SPEED = 8;

// 出现位置
public final static int GRAVITY_TOP = 1;    //001
public final static int GRAVITY_CENTER = 2;  //010
public final static int GRAVITY_BOTTOM = 4;  //100
public final static int GRAVITY_FULL = 7;   //111

private int gravity = GRAVITY_FULL;

private int speed = 4;

private int spanCount = 6;

private int WIDTH, HEIGHT;

private int singltLineHeight;

private DanmuAdapter adapter;

public List<View> spanList;

private OnItemClickListener onItemClickListener;

首先要有这样一个思路,在适配器中抽取出方法,返回itemView的高度,在弹幕View中根据弹幕绘制区域高度,除以itemView的高度,算出合理的弹幕行数(这里大家也理解了为什么在写适配器的时候要定义getSingleLineHeight()方法了)。

B、再次封装实体类

这里只是简单得将传进来的实体类DanmuModel与计算出的对应的最佳行数进行封装。

class InnerEntity {
    public int bestLine;
    public DanmuModel model;
}

C、设置Adapter

public void setAdapter(DanmuAdapter adapter) {
    this.adapter = adapter;
    singltLineHeight = adapter.getSingleLineHeight();
    // 开线程使弹幕滚动起来,稍后会介绍     
}

D、计算最佳位置

关键的来了,先上代码

    /**
     * 计算最佳位置
     *
     * @return
     */
    private int getBestLine() {
        // 转换为2进制
        int gewei = gravity % 2;
        int temp = gravity / 2;
        int shiwei = temp % 2;
        temp = temp / 2;
        int baiwei = temp % 2;

        // 将所有的行分为三份,前两份行数相同,将第一份的行数四舍五入
        int firstLine = (int) (spanCount / 3.0 + 0.5);

        List<Integer> legalLines = new ArrayList<>();
        if (gewei == 1) {
            for (int i = 0; i < firstLine; i++) {
                legalLines.add(i);
            }
        }
        if (shiwei == 1) {
            for (int i = firstLine; i < firstLine * 2; i++) {
                legalLines.add(i);
            }
        }
        if (baiwei == 1) {
            for (int i = firstLine * 2; i < spanCount; i++) {
                legalLines.add(i);
            }
        }

        int bestLine = 0;
        // 如果有空行,将空行返回
        for (int i = 0; i < spanCount; i++) {
            if (spanList.get(i) == null) {
                bestLine = i;
                if (legalLines.contains(bestLine))
                    return bestLine;
            }
        }

        float minSpace = Integer.MAX_VALUE;
        // 没有空行,就找最大空间的
        for (int i = spanCount - 1; i >= 0; i--) {
            if (legalLines.contains(i)) {
                if (spanList.get(i).getX() + spanList.get(i).getWidth() <= minSpace) {
                    minSpace = spanList.get(i).getX() + spanList.get(i).getWidth();
                    bestLine = i;
                }
            }
        }

        return bestLine;
    }

不知是否有注意到,在定义显示位置的常亮的时候,只用了1,2,4,7,因为它们转化为二进制数为001,010,100,111,这里用了一个巧妙的思路,三位数代表屏幕三个位置,0表示不显示弹幕,1表示显示弹幕(有没有豁然开朗)

大家可以参照代码来看,计算最佳位置的思路是这样的:

  1. 将设置的位置转为二进制数,判断显示位置
  2. 将所有的行分为三份,前两份行数相同,将第一份的行数四舍五入,将所有要显示弹幕的行数放入一集合中
  3. 由上至下循环判断是否有空行,有空行则直接返回,此行就是这个itemView的最佳位置
  4. 没有空行的话,由下至上寻找最大空间返回,就是该itemView的最佳位置

E、根据类型设置View

    /**
     * 添加view
     */
    public void addTypeView(DanmuModel model, View child, boolean isReused) {
        super.addView(child);
        child.measure(0, 0);
        //把宽高拿到,宽高都是包含ItemDecorate的尺寸
        int width = child.getMeasuredWidth();
        int height = child.getMeasuredHeight();

        //获取最佳行数
        int bestLine = getBestLine();
        // 设置子view位置
        child.layout(WIDTH, singltLineHeight * bestLine, WIDTH + width, singltLineHeight * bestLine + height);

        InnerEntity innerEntity = null;
        innerEntity = (InnerEntity) child.getTag(R.id.tag_inner_entity);
        if (!isReused || innerEntity == null) {
            innerEntity = new InnerEntity();
        }
        innerEntity.model = model;
        innerEntity.bestLine = bestLine;
        child.setTag(R.id.tag_inner_entity, innerEntity);

        spanList.set(bestLine, child);
    }

这里就不多说了,将itemView的model与最佳位置对应起来并设置位置

然后将spanList(itemView集合)对应view设置进去。

一定要注意:super.addView(child); child.measure(0, 0); 这两句话不能少!

F、添加弹幕

    /**
     * 添加弹幕view
     *
     * @param model
     */
    public void addDanmu(final DanmuModel model) {
        if (adapter == null) {
            throw new Error("DanmuAdapter(an interface need to be implemented) can't be null,you should call setAdapter firstly");
        }

        View dmView = null;
        if (adapter.getCacheSize() >= 1) {
            dmView = adapter.getView(model, adapter.removeViewFromCache(model.getType()));
            if (dmView == null)
                addTypeView(model, dmView, false);
            else
                addTypeView(model, dmView, true);
        } else {
            dmView = adapter.getView(model, null);
            addTypeView(model, dmView, false);
        }

        //添加监听
        dmView.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                if(onItemClickListener != null)
                    onItemClickListener.onItemClick(model);
            }
        });
    }

此方法则是暴露外部的设置弹幕view的方法,这里注意一下,itemView有缓存就复用,没缓存就不复用,就ok了。

G、子线程计算时间,发送消息,handler处理view平移

    private class MyRunnable implements Runnable {
        @Override
        public void run() {
            int count = 0;
            Message msg = null;
            while(true){
                if(count < 7500){
                    count ++;
                }
                else{
                    count = 0;
                    if(DanmuView.this.getChildCount() < adapter.getCacheSize() / 2){
                        adapter.shrinkCacheSize();
                        System.gc();
                    }
                }
                if(DanmuView.this.getChildCount() >= 0){
                    msg = new Message();
                    msg.what = 1; //移动view
                    handler.sendMessage(msg);
                }

                try {
                    Thread.sleep(16);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }

        }
    }

这里注意:
Adapter缓存过大要及时清理;
每隔16毫秒让itemView位置刷新一次,这样视觉效果好一些;
在setAdapter中开启线程 new Thread(new MyRunnable()).start();

    Handler handler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            super.handleMessage(msg);
            if (msg.what == 1) {
                for(int i=0;i<DanmuView.this.getChildCount();i++){
                    View view = DanmuView.this.getChildAt(i);
                    if(view.getX()+view.getWidth() >= 0)
                        // 向左滑动
                        view.offsetLeftAndRight(0 - speed);
                    else{
                        //添加到缓存中
                        int type = ((InnerEntity)view.getTag(R.id.tag_inner_entity)).model.getType();
                        adapter.addViewToCache(type,view);
                        DanmuView.this.removeView(view);

                    }
                }
            }

        }
    };

不再累赘,如果阅读完整代码,可以到我github查看源码和issue我^_^github.com/shuaijia/Js…

使用举例:

1、实体类

/**
 * Description:弹幕实体类
 * Created by jia on 2017/9/25.
 * 人之所以能,是相信能
 */
public class MyDanmuModel extends DanmuModel {

    public String content;
    public int textColor;
    public String time;

    public String getTime() {
        return time;
    }

    public void setTime(String time) {
        this.time = time;
    }

    public String getContent() {
        return content;
    }

    public void setContent(String content) {
        this.content = content;
    }

    public int getTextColor() {
        return textColor;
    }

    public void setTextColor(int textColor) {
        this.textColor = textColor;
    }
}

2、适配器

/**
 * Description: 弹幕适配器
 * Created by jia on 2017/9/25.
 * 人之所以能,是相信能
 */
public class MyDanmuAdapter extends DanmuAdapter<MyDanmuModel> {

    private Context context;

    public MyDanmuAdapter(Context c){
        super();
        context = c;
    }

    @Override
    public int[] getViewTypeArray() {
        int type[] = {0};
        return type;
    }

    @Override
    public int getSingleLineHeight() {
        View view = LayoutInflater.from(context).inflate(R.layout.item_danmu, null);
        //指定行高
        view.measure(0, 0);

        return view.getMeasuredHeight();
    }

    @Override
    public View getView(MyDanmuModel entry, View convertView) {
        ViewHolder vh=null;
        if(convertView==null){
            convertView= LayoutInflater.from(context).inflate(R.layout.item_danmu,null);
            vh=new ViewHolder();
            vh.tv=convertView.findViewById(R.id.tv_danmu);
            convertView.setTag(vh);
        }else{
            vh= (ViewHolder) convertView.getTag();
        }

        vh.tv.setText(entry.getContent());
        vh.tv.setTextColor(entry.getTextColor());

        return convertView;
    }

    class ViewHolder{
        TextView tv;
    }
}

有木有很像ListView的Adapter! 相信大家一看就能明白,就不再多说。

3、配置基本信息

jsplayer_danmu.setDanMuAdapter(new MyDanmuAdapter(this));
jsplayer_danmu.setDanMuGravity(3);
jsplayer_danmu.setDanMuSpeed(DanmuView.NORMAL_SPEED);

4、创建实体类并设置给DanmuView

MyDanmuModel danmuEntity = new MyDanmuModel();
danmuEntity.setType(0);
danmuEntity.setContent(DANMU[random.nextInt(8)]);
danmuEntity.setTextColor(COLOR[random.nextInt(4)]);
jsplayer_danmu.addDanmu(danmuEntity);

更多精彩内容,请关注我的微信公众号——安卓干货营
《轻松又酷炫地实现弹幕效果——手把手教学》

    原文作者:算法小白
    原文地址: https://juejin.im/entry/59dafc7a5188250e8e1042b7
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞