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.百度云
优点
审核速度挺快的,实名认证大概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