一步一步实现直播和弹幕

http://www.2cto.com/kf/201609/550318.html


序言

最近在研究直播的弹幕,东西有点多,准备记录一下免得自己忘了又要重新研究,也帮助有这方面需要的同学少走点弯路。关于直播的技术细节其实就是两个方面一个是推流一个是拉流,而弹幕的实现核心在即时聊天,使用聊天室的就能实现,只是消息的展示方式不同而已。在大多数的项目中还是使用第三方的直播平台实现推流功能,因此关于直播平台的选择也是至关重要。下面由我娓娓道来。

效果

为了演示方便我把屏幕录像上传到优酷了,这是视频地址

这里写图片描述

功能

1.缓冲进度

这里写图片描述

2.弹幕

这里写图片描述

3.横竖屏切换

这里写图片描述

实现

1.直播SDK的选择

提供直播功能的厂商有很多,比如七牛云,乐视,百度云,腾讯云,金山云,等等。功能也大同小异,常见的缩略图,视频录制,转码,都可以实现。但是对于SDK的易用程度还是不敢恭维的。下面我说说我遇到的一些问题。

1.乐视

乐视云 移动直播

优点:
乐视直播的注册流程还是很方便的,选择个人开发者,然后验证身份信息就可以使用了,每人每月免费10GB的流量。

缺点

最大的缺点就是稳定性,至少在我测试的时候也就是2016年9月份稳定性很差,不是说视频的稳定性,而是推流的稳定性,我有一台在同样的网络下我的ViVO X7能推流,但是魅蓝NOTE2不能推流。然而ViVO X7推出去的流在电脑上用VLC能播放,在其他手机上显示黑屏,既不报错也没画面。随后使用同样的网络,同样的魅蓝NOTE2,百度的SDK就能推流。看来乐视的直播技术方面还有待改进,直接pass。

这里写图片描述

2.七牛云

七牛云官网

优点
态度好,服务周到,其他方面的不能再评价了,因为没有真正使用过,这的确很尴尬,不过态度的确很好,会有客服打电话过来询问需求,会有技术支持人员主动沟通,这是很值得肯定的。

缺点
倒不能算是缺点,可能算特点吧,七牛云需要使用域名别名解析来做RTMP直播流域名,也就是说你要使用七牛云必须要有一个备案过的域名,由于我司的域名我不能轻易去改,而且我也没有备案过的域名,所以不能测试。

这里写图片描述

3.腾讯云

还没有通过审核,效率太低。

4.阿里云

也需要域名,跳过。

5.百度云

百度音视频直播 LSS

优点

审核速度挺快的,实名认证大概15分钟搞定(这是我的速度,仅供参考),不需要域名,为个人开发者免费提供10G流量测试,这点很良心。而且功能很全面,推流很简单。下面是价格表:

这里写图片描述

缺点

企业用户需要认证,否则单月最大流量为1TB,个人用户总流量限制在1000GB。

经过以上对比最终选择了百度云来实现直播。

2.及时聊天SDK的选择

这里边倒没有太多的考虑,环信,融云,LeanCloud都可以,但是长期使用leancloud发现其文档质量很高,SDK简单易用。所以使用了LeanCloud来实现即时通讯。

LearnCloud Android 实时通信开发指南

3.弹幕实现

弹幕说白了就是聊天室,只是聊天室的消息需要在视频节目上显示而已,所以首先要实现一个聊天室,此处使用LeanCloud实现。

第一步:初始化

这里写图片描述

第二步:登录
?
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
package com.zgh.livedemo;
 
import android.content.Intent;
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.text.TextUtils;
import android.view.View;
import android.widget.EditText;
import android.widget.Toast;
 
import com.avos.avoscloud.im.v2.AVIMClient;
import com.avos.avoscloud.im.v2.AVIMException;
import com.avos.avoscloud.im.v2.callback.AVIMClientCallback;
 
public class LoginActivity extends AppCompatActivity {
     EditText et_name;
 
     @Override
     protected void onCreate(Bundle savedInstanceState) {
         super .onCreate(savedInstanceState);
         setContentView(R.layout.activity_login);
         et_name = (EditText) findViewById(R.id.et_name);
         findViewById(R.id.btn_login).setOnClickListener( new View.OnClickListener() {
             @Override
             public void onClick(View v) {
                 String name = et_name.getText().toString();
                 if (TextUtils.isEmpty(name)) {
                     Toast.makeText(LoginActivity. this , "登录名不能为空" , Toast.LENGTH_SHORT).show();
                     return ;
                 }
                 login(name);
             }
         });
     }
 
 
     public void login(String name) {
           //使用name作为cliendID
         AVIMClient jerry = AVIMClient.getInstance(name);
         jerry.open( new AVIMClientCallback() {
 
             @Override
             public void done(AVIMClient client, AVIMException e) {
                 if (e == null ) {
                     Toast.makeText(LoginActivity. this , "登录成功" , Toast.LENGTH_SHORT).show();
                     //保存client
                     MyApp.mClient = client;
                     startActivity( new Intent(LoginActivity. this , MainActivity. class ));
                 } else {
                     Toast.makeText(LoginActivity. this , "登录失败:" + e.getMessage(), Toast.LENGTH_SHORT).show();
                 }
             }
         });
     }
 
 
}
第三步,进入聊天室

在进入直播界面的时候调用此方法,进入聊天室。conversationId应该从服务器获取,此处用于测试使用了一个固定的ID。

?
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
private void join() {
     MyApp.mClient.open( new AVIMClientCallback() {
 
         @Override
         public void done(AVIMClient client, AVIMException e) {
             if (e == null ) {
                 //登录成功
                 conv = client.getConversation( "57d8b2445bbb50005e420535" );
                 conv.join( new AVIMConversationCallback() {
                     @Override
                     public void done(AVIMException e) {
                         if (e == null ) {
                             //加入成功
                             Toast.makeText(MainActivity. this , "加入聊天室成功" , Toast.LENGTH_SHORT).show();
                             et_send.setEnabled( true );
                         } else {
                             Toast.makeText(MainActivity. this , "加入聊天室失败:" + e.getMessage(), Toast.LENGTH_SHORT).show();
                             et_send.setEnabled( false );
                             android.util.Log.i( "zzz" , "加入聊天室失败 :" + e.getMessage());
                         }
                     }
                 });
             }
         }
     });
}

登录成功以后,在onResum的时候将此Activity注册为消息处理者,在onPause的时候取消注册。而在application的onCreate的时候注册一个默认的处理器,也就是说当APP在后头运行的时候,通过默认处理器处理消息,即弹出状态栏弹出通知,而在聊天界面由当前界面处理消息。

?
1
2
3
4
5
6
7
8
9
10
11
@Override
protected void onResume() {
     super .onResume();
     AVIMMessageManager.registerMessageHandler(AVIMTextMessage. class , roomMessageHandler);
}
 
@Override
protected void onPause() {
     super .onPause();
     AVIMMessageManager.unregisterMessageHandler(AVIMTextMessage. class , roomMessageHandler);
}

在接收到消息以后把消息显示在弹幕控件上。

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class RoomMessageHandler extends AVIMMessageHandler {
     //接收到消息后的处理逻辑
     @Override
     public void onMessage(AVIMMessage message, AVIMConversation conversation, AVIMClient client) {
         if (message instanceof AVIMTextMessage) {
             String info = ((AVIMTextMessage) message).getText();
             //添加消息到屏幕
             addMsg(info);
         }
     }
 
}
 
private void addMsg(String msg) {
     TextView textView = new TextView(MainActivity. this );
     textView.setText(msg);
     ViewGroup.MarginLayoutParams params = new ViewGroup.MarginLayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
     params.setMargins( 5 , 10 , 5 , 10 );
     textView.setLayoutParams(params);
     ll_room.addView(textView, 0 );
     barrageView.addMessage(msg);
}

弹幕的控件

?
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
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
package com.zgh.livedemo.view;
 
import android.content.Context;
import android.graphics.Color;
import android.graphics.Rect;
import android.os.Handler;
import android.os.Message;
import android.text.TextPaint;
import android.util.AttributeSet;
import android.view.animation.AccelerateDecelerateInterpolator;
import android.view.animation.Animation;
import android.view.animation.TranslateAnimation;
import android.widget.RelativeLayout;
import android.widget.TextView;
 
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
 
/**
  * Created by lixueyong on 16/2/19.
  */
public class BarrageView extends RelativeLayout {
     private Context mContext;
     private BarrageHandler mHandler = new BarrageHandler();
     private Random random = new Random(System.currentTimeMillis());
     private static final long BARRAGE_GAP_MIN_DURATION = 1000 ; //两个弹幕的最小间隔时间
     private static final long BARRAGE_GAP_MAX_DURATION = 2000 ; //两个弹幕的最大间隔时间
     private int maxSpeed = 10000 ; //速度,ms
     private int minSpeed = 5000 ; //速度,ms
     private int maxSize = 30 ; //文字大小,dp
     private int minSize = 15 ; //文字大小,dp
 
     private int totalHeight = 0 ;
     private int lineHeight = 0 ; //每一行弹幕的高度
     private int totalLine = 0 ; //弹幕的行数
     private List<string> messageList = new ArrayList<>();
     // private String[] itemText = {"是否需要帮忙", "what are you 弄啥来", "哈哈哈哈哈哈哈", "抢占沙发。。。。。。", "************", "是否需要帮忙",
     //        "我不会轻易的狗带", "嘿嘿", "这是我见过的最长长长长长长长长长长长的评论"};
     private int textCount;
//    private List<barrageitem> itemList = new ArrayList<barrageitem>();
 
     public BarrageView(Context context) {
         this (context, null );
     }
 
     public BarrageView(Context context, AttributeSet attrs) {
         this (context, attrs, 0 );
     }
 
     public BarrageView(Context context, AttributeSet attrs, int defStyleAttr) {
         super (context, attrs, defStyleAttr);
         mContext = context;
         init();
     }
 
     private void init() {
         // textCount = itemText.length;
 
         int duration = ( int ) ((BARRAGE_GAP_MAX_DURATION - BARRAGE_GAP_MIN_DURATION) * Math.random());
         mHandler.sendEmptyMessageDelayed( 0 , duration);
     }
 
     public void addMessage(String message) {
         messageList.add(message);
     }
 
     @Override
     public void onWindowFocusChanged( boolean hasWindowFocus) {
         super .onWindowFocusChanged(hasWindowFocus);
         totalHeight = getMeasuredHeight();
         lineHeight = getLineHeight();
         totalLine = totalHeight / lineHeight;
     }
 
     private void generateItem() {
         if (messageList.size() > 0 ) {
             BarrageItem item = new BarrageItem();
             String tx = messageList.remove( 0 );
             int sz = ( int ) (minSize + (maxSize - minSize) * Math.random());
             item.textView = new TextView(mContext);
             item.textView.setText(tx);
             item.textView.setTextSize(sz);
             item.textView.setTextColor(Color.rgb(random.nextInt( 256 ), random.nextInt( 256 ), random.nextInt( 256 )));
             item.textMeasuredWidth = ( int ) getTextWidth(item, tx, sz);
             item.moveSpeed = ( int ) (minSpeed + (maxSpeed - minSpeed) * Math.random());
             if (totalLine == 0 ) {
                 totalHeight = getMeasuredHeight();
                 lineHeight = getLineHeight();
                 totalLine = totalHeight / lineHeight;
             }
             item.verticalPos = random.nextInt(totalLine) * lineHeight;
//        itemList.add(item);
             showBarrageItem(item);
         }
     }
 
     private void showBarrageItem( final BarrageItem item) {
 
         int leftMargin = this .getRight() - this .getLeft() - this .getPaddingLeft();
 
         LayoutParams params = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
         params.addRule(RelativeLayout.ALIGN_PARENT_TOP);
         params.topMargin = item.verticalPos;
         this .addView(item.textView, params);
         Animation anim = generateTranslateAnim(item, leftMargin);
         anim.setAnimationListener( new Animation.AnimationListener() {
             @Override
             public void onAnimationStart(Animation animation) {
 
             }
 
             @Override
             public void onAnimationEnd(Animation animation) {
                 item.textView.clearAnimation();
                 BarrageView. this .removeView(item.textView);
             }
 
             @Override
             public void onAnimationRepeat(Animation animation) {
 
             }
         });
         item.textView.startAnimation(anim);
     }
 
     private TranslateAnimation generateTranslateAnim(BarrageItem item, int leftMargin) {
         TranslateAnimation anim = new TranslateAnimation(leftMargin, -item.textMeasuredWidth, 0 , 0 );
         anim.setDuration(item.moveSpeed);
         anim.setInterpolator( new AccelerateDecelerateInterpolator());
         anim.setFillAfter( true );
         return anim;
     }
 
     /**
      * 计算TextView中字符串的长度
      *
      * @param text 要计算的字符串
      * @param Size 字体大小
      * @return TextView中字符串的长度
      */
     public float getTextWidth(BarrageItem item, String text, float Size) {
         Rect bounds = new Rect();
         TextPaint paint;
         paint = item.textView.getPaint();
         paint.getTextBounds(text, 0 , text.length(), bounds);
         return bounds.width();
     }
 
     /**
      * 获得每一行弹幕的最大高度
      *
      * @return
      */
     private int getLineHeight() {
       /*  BarrageItem item = new BarrageItem();
         String tx = itemText[0];
         item.textView = new TextView(mContext);
         item.textView.setText(tx);
         item.textView.setTextSize(maxSize);
 
         Rect bounds = new Rect();
         TextPaint paint;
         paint = item.textView.getPaint();
         paint.getTextBounds(tx, 0, tx.length(), bounds);
         return bounds.height();*/
         return 50 ;
     }
 
     class BarrageHandler extends Handler {
         @Override
         public void handleMessage(Message msg) {
             super .handleMessage(msg);
             generateItem();
             //每个弹幕产生的间隔时间随机
             int duration = ( int ) ((BARRAGE_GAP_MAX_DURATION - BARRAGE_GAP_MIN_DURATION) * Math.random());
             this .sendEmptyMessageDelayed( 0 , duration);
         }
     }
 
}</barrageitem></barrageitem></string>

剩下的细节看demo吧。

4.视频播放

视频的播放使用的是vitamio框架关于具体的API请参考这里这里

这里写图片描述

需要注意的是在状态的获取,通过设置不同的监听来实现的。

?
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
mVideoView.setOnInfoListener( new MediaPlayer.OnInfoListener() {
        public boolean onInfo(MediaPlayer mp, int what, int extra) {
            //缓冲开始
            if (what == MediaPlayer.MEDIA_INFO_BUFFERING_START) {
                layout_loading.setVisibility(View.VISIBLE);
                android.util.Log.i( "zzz" , "onStart" );
            //缓冲结束
            } else if (what == MediaPlayer.MEDIA_INFO_BUFFERING_END) {
                //此接口每次回调完START就回调END,若不加上判断就会出现缓冲图标一闪一闪的卡顿现象
                android.util.Log.i( "zzz" , "onEnd" );
                layout_loading.setVisibility(View.GONE);
             //   mp.start();
                mVideoView.start();
            }
            return true ;
        }
    });
    //获取缓存百分比
    mVideoView.setOnBufferingUpdateListener( new MediaPlayer.OnBufferingUpdateListener() {
        @Override
        public void onBufferingUpdate(MediaPlayer mp, int percent) {
            if (!mp.isPlaying()) {
                layout_loading.setVisibility(View.VISIBLE);
                tv_present.setText( "正在缓冲" + percent + "%" );
            } else {
                layout_loading.setVisibility(View.GONE);
            }
 
        }
    });
 
    mVideoView.setOnPreparedListener( new MediaPlayer.OnPreparedListener() {
        @Override
        public void onPrepared(MediaPlayer mediaPlayer) {
            mediaPlayer.setPlaybackSpeed( 1 .0f);
        }
    });
    //出错处理
    mVideoView.setOnErrorListener( new MediaPlayer.OnErrorListener() {
        @Override
        public boolean onError(MediaPlayer mp, int what, int extra) {
 
            tv_present.setText( "加载失败" );
            return true ;
        }
    });

还有就是MediaController的使用,可以参考农民伯伯的vitamio中文API

需要注意的是在xml中使用MediaController时需要这样使用位置为VideoView之上,高度为需要显示的控制条的高度,内部需要包括控制控件,id必须为指定的ID,布局可以参考源码中这个文件
这里写图片描述

?
1
2
3
4
5
6
7
<io.vov.vitamio.widget.mediacontroller android:background= "#ff0000" android:id= "@+id/mediacontroller" android:layout_alignparentbottom= "true" android:layout_height= "40dp" android:layout_width= "match_parent" >
 
           <relativelayout android:layout_height= "wrap_content" android:layout_width= "match_parent" android:orientation= "horizontal" >
 
               <imagebutton android:background= "@drawable/mediacontroller_button" android:contentdescription= "@string/mediacontroller_play_pause" android:id= "@+id/mediacontroller_play_pause" android:layout_centervertical= "true" android:layout_height= "wrap_content" android:layout_marginleft= "5dp" android:layout_width= "wrap_content" android:src= "@drawable/mediacontroller_pause" >
           </imagebutton></relativelayout>
       </io.vov.vitamio.widget.mediacontroller>

5.视频的全屏模式

其核心的逻辑是点击按钮,改变屏幕方向,在改变方向的时候隐藏聊天室,输入框等。同时改变控件的大小。要让Activity在屏幕切换的时候不重新创建需要添加这个选项。

?
1
android:configChanges= "keyboardHidden|orientation|screenSize"

核心代码

?
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
private void fullScreen() {
        if (isScreenOriatationPortrait( this )) { // 当屏幕是竖屏时
            full( true );
            // 点击后变横屏
            setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE);
            // 设置当前activity为横屏
            // 当横屏时 把除了视频以外的都隐藏
            //隐藏其他组件的代码
            ll_room.setVisibility(View.GONE);
            et_send.setVisibility(View.GONE);
            int width=getResources().getDisplayMetrics().widthPixels;
            int height=getResources().getDisplayMetrics().heightPixels;
            layout_video.setLayoutParams( new LinearLayout.LayoutParams(height, width));
            mVideoView.setLayoutParams( new RelativeLayout.LayoutParams(height,width));
 
 
        } else {
            full( false );
            setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); // 设置当前activity为竖屏
            //显示其他组件
            ll_room.setVisibility(View.VISIBLE);
            et_send.setVisibility(View.VISIBLE);
            int width=getResources().getDisplayMetrics().heightPixels;
            int height= ( int ) (width* 9.0 / 16 );
            layout_video.setLayoutParams( new LinearLayout.LayoutParams(width, height));
            mVideoView.setLayoutParams( new RelativeLayout.LayoutParams(width,height));
 
        }
    }
 
    //动态隐藏状态栏
    private void full( boolean enable) {
        if (enable) {
            WindowManager.LayoutParams lp = getWindow().getAttributes();
            lp.flags |= WindowManager.LayoutParams.FLAG_FULLSCREEN;
            getWindow().setAttributes(lp);
            getWindow().addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
        } else {
            WindowManager.LayoutParams attr = getWindow().getAttributes();
            attr.flags &= (~WindowManager.LayoutParams.FLAG_FULLSCREEN);
            getWindow().setAttributes(attr);
            getWindow().clearFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
        }
    }

Demo

关于demo中的配置信息,我抽取到相关的config接口中了,大家只需要配置好就行了

下载地址

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.zgh.livedemo;
 
/**
  * Created by zhuguohui on 2016/9/20.
  */
public interface Config {
     /**
      * learnCloud APP_ID
      */
     String APP_ID = "" ;
     /**
      * learnCloud APP_KEY
      */
     String APP_KEY = "" ;
     /**
      * learnCloud 聊天室ID
      */
     String CONVERSATION_ID = "" ;
     /**
      * rtmp 视频地址
      */
     String VIDEO_URL = "" ;
}

关于推流用的是百度直播SDK的官方的Demo

这里写图片描述




评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值