【Android效果集】弹幕效果

之前在网上有看到过iOS的弹幕效果实现,搜了一下发现Android实现弹幕效果的帖子比较少,而且写得都不是很好理解,于是尝试自己做了一下,写成这篇博客,分享出来。

最终效果展示:
【Android效果集】弹幕效果_第1张图片

实现思路:

1.自定义一个弹幕View,继承自TextView,专门用来显示一条弹幕
2.弹幕View能够自动从最右边匀速滚动到最左边
3.弹幕的颜色和大小设置为随机值
4.弹幕View的高度随机,区域在屏幕范围内
5.在Activity中循环定时加入自定义弹幕View,形成最后的弹幕
6.自定义文字资源,随机从文件资源中读取文字显示

详细过程:

1.改变应用属性为横屏,无标题栏,黑色背景

AndroidManifest.xml文件中,让MainActivity方向属性为landscape,并且加上主题设置

<activity android:name=".MainActivity" android:screenOrientation="landscape" android:theme="@style/AppTheme">

然后在styles.xml文件中,设置如下(parent是系统自动生成的,我用的版本比较新,可能大家的不一样,这个不要紧)

    <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
        <item name="android:windowBackground">@color/black</item>
        <item name="android:windowFullscreen">true</item>
        <item name="android:windowNoTitle">true</item>
    </style>

2.新建BarrageView

首先新建一个弹幕View,我取名为BarrageView,它继承自TextView
需要为它实现两个构造方法和onDraw()方法。
在onDraw方法里我们绘制文字。

public class BarrageView extends TextView {
    private Paint paint = new Paint(); //画布参数

    public BarrageView(Context context) {
        super(context);
        init();
    }

    public BarrageView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }
    /** * 初始化 */
    protected void init() {}

    @Override
    protected void onDraw(Canvas canvas) {
        paint.setTextSize(30);
        paint.setColor(0xffffffff); //白色
        canvas.drawText(getText(), 0, 30, paint);
    }
}

PS:这里需要注意一点,就是y值我们没有设置为0而是30,是因为文字的坐标是从左下角开始算的,文字大小设为了30,y也要设为30文字才会刚刚好显示在屏幕的(0, 0)处。

我们把自定义View加到主布局中,因为需要从屏幕左侧滚动到右侧,我把宽高设置为屏幕宽高

    <com.azz.azbarrage.BarrageView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:text="hello"
        />

3.滚动动画

移动可以用Animation动画,但是我这里用的是线程重绘,只要能实现最终效果,都是可以的。

在onDraw里面新建一个线程,该线程会一直运行,每次运行主函数时会对BarrageViewx值产生影响(减少)。

    private int posX; //x坐标

    class RollThread extends Thread {
        @Override
        public void run() {
            while(true) {
                //1.动画逻辑
                animLogic();
                //2.绘制图像
                postInvalidate();
                //3.延迟,不然会造成执行太快动画一闪而过
                try {
                    Thread.sleep(30);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    /** * 动画逻辑处理 */
    private void animLogic() {
        posX -= 8;
    }

可以看到线程里面就三步,1.把x坐标减少8像素,2.调用postInvalidate()方法重绘,该方法会自动调用onDraw()方法,3.停顿30毫秒,代码执行非常快,如果不停顿的话,我们甚至看不到滚动动画。

接下来我们把滚动线程加到上面的代码里去。

在加之前,需要思考一下,我们的posX第一次绘制时应该为屏幕宽,表示从屏幕最右边开始移动,然后调用滚动线程,直到滚出屏幕。

这里要提一下getWidth()方法,这个方法如果在构造函数里面调用,得到的是0,在onDraw()方法里面调用得到的是本view的宽度,这是因为自定义View的机制,要在调用过onMeasure()后才能得到自身的宽高。

我这里用getWindowVisibleDisplayFrame(rect);能在初始化时就得到屏幕宽高。

    private int posX; //x坐标

    private int windowWidth; //屏幕宽
    private int windowHeight; //屏幕高

    private RollThread rollThread; //滚动线程

    /** * 初始化 */
    protected void init() {
        //得到屏幕宽高
        Rect rect = new Rect();
        getWindowVisibleDisplayFrame(rect);
        windowWidth = rect.width();
        windowHeight = rect.height();

        //设置x为屏幕宽
        posX = windowWidth;
    }

    protected void onDraw(Canvas canvas) {
        paint.setTextSize(30);
        paint.setColor(0xffffffff); //白色
        canvas.drawText(getText(), posX, 30, paint);

        if (rollThread == null) {
            rollThread = new RollThread();
            rollThread.start();
        }
    }

现在的效果是这个样子:
【Android效果集】弹幕效果_第2张图片

当弹幕从左边完全滚出时,其实线程还是在运行的,这样积累多了线程对系统负荷加大,我们需要加上判断,如果滚出了屏幕,线程也跳出while(true)循环,然后由系统自己回收。

    class RollThread extends Thread {
        @Override
        public void run() {
            while(true) {

                ...

                //关闭线程逻辑判断
                if (needStopRollThread()) {
                    Log.i("azzz", getText() + " -线程停止!");
                    break;
                }
            }
        }
    }

    private boolean needStopRollThread() {
        if (posX <= -paint.measureText(getText())) {
            return true;
        }
        return false;
    }

paintmeasureText(String text)方法来得到文字的宽度,进行判断是否需要退出线程循环。

4.随机大小和颜色

在初始化的时候,我们就给定一条弹幕一个随机的大小和颜色,使得每一条弹幕看起来都不一样。

    private int textSize = 30; //字体大小
    public static final int TEXT_MIN = 10;
    public static final int TEXT_MAX = 60;
    //字体颜色
    private int color = 0xffffffff;

   /** * 初始化 */
    protected void init() {
        //1.设置文字大小
        textSize = TEXT_MIN + random.nextInt(TEXT_MAX - TEXT_MIN);
        paint.setTextSize(textSize);

        //2.设置文字颜色
        color = Color.rgb(random.nextInt(256), random.nextInt(256), random.nextInt(256));
        paint.setColor(color);

        ...
    }

    protected void onDraw(Canvas canvas) {
        //paint.setTextSize(30);
        //paint.setColor(0xffffffff); //白色
        canvas.drawText(getText(), posX, 30, paint);

        ...
    }

这里得到随机数用到的是randomnextInt(n)方法,其中值域是[0,n)

把通过随机数生成的字体大小和颜色加到paint里,在onDraw()方法里面就能起效果了。别忘了把之前在onDraw()里设置的颜色和字体大小去掉。

5.随机高度

高度也是在init()方法通过随机数生成。

    /** * 初始化 */
    protected void init() {
        ...

        //3.得到屏幕宽高
        Rect rect = new Rect();
        getWindowVisibleDisplayFrame(rect);
        windowWidth = rect.width();
        windowHeight = rect.height();

        //4.设置x为屏幕宽
        posX = windowWidth;

        //5.设置y为屏幕高度内内随机,需要注意的是,文字是以左下角为起始点计算坐标的,所以要加上TextSize的大小
        posY = textSize + random.nextInt(windowHeight - textSize);
    }

之前说过文字的坐标系是在左下角,当y为0时文字是看不到的,所以这里高度的初始化要写在文字大小的后面,在随机数前加上一个字体大小的高度,同时最大值也应该减去一个字体大小的高度,不然最大ytextSize + windowHeight就超出了屏幕显示。

6.动态生成弹幕

单条弹幕属性差不多定义完了,现在我们去MainActivity中动态加入多条弹幕。

    //两两弹幕之间的间隔时间
    public static final int DELAY_TIME = 800;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        ...

        //设置宽高全屏
        final ViewGroup.LayoutParams lp = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);

        final Handler handler = new Handler();
        Runnable createBarrageView = new Runnable() {
            @Override
            public void run() {
                //新建一条弹幕,并设置文字
                final BarrageView barrageView = new BarrageView(MainActivity.this);
                barrageView.setText("你好");
                addContentView(barrageView, lp);

                //发送下一条消息
                handler.postDelayed(this, DELAY_TIME);
            }
        };
        handler.post(createBarrageView);
    }

Activity中有个addContentView(view, lp)方法能够新加view到根视图下。

lp动态设置为全屏的方法是new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);

handler.postDelayed(this, DELAY_TIME);的意思是弹幕出来的时间不一致,这样就不会像兵列一样整齐地出来了。

7.自定义文字资源

我们可以在string.xml中自定义字符串数组<string-array>,然后再Activity中随机引用。

    private Random random = new Random();

    protected void onCreate(Bundle savedInstanceState) {
        ...

       //读取文字资源
        final String[] texts = getResources().getStringArray(R.array.default_text_array);

        Runnable createBarrageView = new Runnable() {
            @Override
            public void run() {
                //新建一条弹幕,并设置文字
                final BarrageView barrageView = new BarrageView(MainActivity.this);
                barrageView.setText(texts[random.nextInt(texts.length)]); //随机设置文字
                addContentView(barrageView, lp);

                ..
            }
        };
        ...
    }

好了!再来一张效果:

关于资源回收

在iOS中的UIView中有个方法叫removeFromSuperView,调用该方法就能达到资源回收的作用,在Android中没有这种方法,只能从父控件调用remove方法,我查了很久资料,也没查到是否这样做可以达到销毁View的作用,不过为此我还是在BarrageView中留了一个监听器,用来做销毁动作的。

    /** * 滚动结束接听器 */
    interface OnRollEndListener {
        void onRollEnd();
    }

    private OnRollEndListener mOnRollEndListener;

    /** * @param onRollEndListener 设置滚动结束监听器 */
    public void setOnRollEndListener(OnRollEndListener onRollEndListener) {
        this.mOnRollEndListener = onRollEndListener;
    }

     class RollThread extends Thread {
        @Override
        public void run() {
            while(true) {
                 ...

                //关闭线程逻辑判断
                if (needStopRollThread()) {
                    Log.i("azzz", getText() + " -线程停止!");
                    if (mOnRollEndListener != null) {
                        mOnRollEndListener.onRollEnd();
                    }
                    break;
                }
            }
        }
    }

后记:现在是2015年10月19日01:14:59,一兴奋就忍不住把博客写完了,因为也担心一拖再拖就成坑了,明天星期一还要上班,源码什么的就明天再来弄了!~这篇博客写得还算有诚意,希望能有好评。

2015.10.19 更新:

关于以上最后一点「关于资源回收」,经过博乐的指导,知道可以在子控件中通过getParent()方法得到父控件,这样就可以直接在子控件中把自己从父控件中移除,达到回收资源的效果。

    class RollThread extends Thread {
        @Override
        public void run() {
            while(true) {
                 ...

                 //关闭线程逻辑判断
                 if (needStopRollThread()) {
                    ...

                    post(new Runnable() { //从父类中移除本view
                        @Override
                        public void run() {
                            ((ViewGroup) BarrageView.this.getParent()).removeView(BarrageView.this);
                        }
                    });
                    break;
                } //if-end
            } //while-end
        } //run-end
    } //RollThread-end

以上注意两点,

第一,一定要在主线程调用移除方法,因为只有主线程可以更改UI。View方法本身自带post(runnable)方法能够在主线程中运行。(关于快速切换到主线程的方法可看《【Android和iOS】快速切换到主线程更新UI》)

第二,getParent()得到的返回类型是View,而View方法并没有remove(view)方法,所以只要强制转换成ViewGroup就可以了。

2015.11.02 更新:

感谢18楼网友提出的问题,今天把这个问题解决了一下
这里写图片描述

问题非常好,这个问题也很容易复现,打开AZBarrage应用,按Home键回到桌面,等待一分钟(时间越长问题越明显),然后通过最近访问再次打开应用,会看到右侧有很多堆积的弹幕。

(演示图片做了暂停处理,实际中在Home界面等待了很久。)

在上示效果图的下方可以看到打印“发送弹幕”,这个打印写在了MainAcitivity中的createBarrageView这个Runnable

protected void onCreate(Bundle savedInstanceState) {
    ...
    Runnable createBarrageView = new Runnable() {
            @Override
            public void run() {
                Log.e("azzz", "发送弹幕");
                ...
            }
    };
    ...
}

其实问题的原因就在这,当按下Home键返回桌面时,应用的线程createBarrageView并不会停止,而是在后台继续运行,那么就导致不停地创建弹幕,再打开时就有上面的情况了。

基于此修改思路也很简单,我们加一个pauseFlag,在onPause()的时候置为trueonRusume()的时候置为false,然后在createBarrageView线程中先进行判断是否是pause状态,再选择是否要发送弹幕。

public class MainActivity extends Activity {
    ...
    private boolean isOnPause = false;
    protected void onCreate(Bundle savedInstanceState) {
        ...
        Runnable createBarrageView = new Runnable() {
            @Override
            public void run() {
                //加上判断
                if (!isOnPause) {
                    Log.e("azzz", "发送弹幕");
                    ...
                }
                //发送下一条消息
                handler.postDelayed(this, DELAY_TIME);
            }
        };
        handler.post(createBarrageView);
    }
    @Override
    protected void onPause() {
        super.onPause();
        isOnPause = true;
    }

    @Override
    protected void onResume() {
        super.onResume();
        isOnPause = false;
    }
}

现在的效果:

发现光是这样改后,还是有问题。本来正常播放的弹幕,在返回桌面后,还是继续滚动(可以注意下面的打印,回到桌面后还是有线程停止的打印提示),导致再次打开应用时,全部都重新开始。

这时候我们需要的是弹幕有个暂停功能就好了!当返回主页时,弹幕滚到哪个位置就停到哪个位置,当我再次打开时,又继续滚动。

需求想清楚了,接下来想实现。弹幕的滚动是由一个线程决定的,在BarrageView中自定义了一个RollThread,我们可以给它加两个方法,一个是暂停(挂起),一个是继续(恢复)。(关于线程的挂起和恢复,不会的可以看《Android : 线程的结束,挂起和恢复(下)》)

public class BarrageView extends TextView {
    ...
    private RollThread rollThread; //滚动线程

    class RollThread extends Thread {
        private Object mPauseLock; //线程锁
        private boolean mPauseFlag; //标签:是否暂停

        RollThread() {
            mPauseLock = new Object();
            mPauseFlag = false;
        }
        @Override
        public void run() {
            while (true) {
                //首先检查是否挂起
                checkPause();
                ...
            }
        }
        public void onPause() {
            synchronized (mPauseLock) {
                mPauseFlag = true;
            }
        }
        public void onResume() {
            synchronized (mPauseLock) {
                mPauseFlag = false;
                Log.i(TAG, "线程恢复-" + getText());
                mPauseLock.notify();
            }
        }
        private void checkPause() {
            synchronized (mPauseLock) {
                if (mPauseFlag) {
                    try {
                        Log.e(TAG, "线程挂起-" + getText());
                        mPauseLock.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    }
}

那我们怎么知道什么时候暂停滚动(挂起线程)呢?

经我调查发现,View中有个方法叫onWindowVisibilityChanged(int visibility),当view显示在窗口的时候,回调的visibility等于View.VISIBLE,当view不显示在窗口时,回调的visibility等于View.GONE。基于此,我们可以在这里进行判断什么时候暂停,什么时候恢复。

public class BarrageView extends TextView {
    ...
    @Override
    protected void onWindowVisibilityChanged(int visibility) {
        super.onWindowVisibilityChanged(visibility);
        if (rollThread == null) {
            return;
        }
        if (View.GONE == visibility) {
            rollThread.onPause();
        } else {
            rollThread.onResume();
        }
    }
}

最终效果:(可以注意下打印,Home退出时线程挂起(且绝对不会有线程停止的打印),返回应用时线程恢复)

源码地址:https://github.com/Xieyupeng520/AZBarrage(求星星^3^)

欢迎继续收看《【Android效果集】下雨效果》

Reference:
《 iOS之弹幕效果 》
视频 -《自定义 View 基础和原理》

如果你有任何问题,欢迎留言告诉我!~

你可能感兴趣的:(动画,android,线程,自定义view,弹幕)