程序员人生 网站导航

[置顶] 一步一步实现直播和弹幕

栏目:综合技术时间:2016-11-21 08:43:22

叙言

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

效果

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

这里写图片描述

功能

1.缓冲进度

这里写图片描述

2.弹幕

这里写图片描述

3.横竖屏切换

这里写图片描述

实现

1.直播SDK的选择

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

1.乐视

乐视云 移动直播

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

缺点

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

这里写图片描述

2.7牛云

7牛云官网

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

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

这里写图片描述

3.腾讯云

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

4.阿里云

也需要域名,跳过。

5.百度云

百度音视频直播 LSS

优点

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

这里写图片描述

缺点

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

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

2.及时聊天SDK的选择

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

LearnCloud Android 实时通讯开发指南

3.弹幕实现

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

第1步:初始化

这里写图片描述

第2步:登录

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(); } } }); } }

第3步,进入聊天室

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

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的时候注册1个默许的处理器,也就是说当APP在后头运行的时候,通过默许处理器处理消息,即弹出状态栏弹出通知,而在聊天界面由当前界面处理消息。

@Override protected void onResume() { super.onResume(); AVIMMessageManager.registerMessageHandler(AVIMTextMessage.class, roomMessageHandler); } @Override protected void onPause() { super.onPause(); AVIMMessageManager.unregisterMessageHandler(AVIMTextMessage.class, roomMessageHandler); }

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

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); }

弹幕的控件

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); } } }

剩下的细节看demo吧。

4.视频播放

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

这里写图片描述

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

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,若不加上判断就会出现缓冲图标1闪1闪的卡顿现象 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,布局可以参考源码中这个文件
这里写图片描述

<io.vov.vitamio.widget.MediaController android:id="@+id/mediacontroller" android:layout_width="match_parent" android:layout_height="40dp" android:layout_alignParentBottom="true" android:background="#ff0000"> <RelativeLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal"> <ImageButton android:id="@+id/mediacontroller_play_pause" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerVertical="true" android:layout_marginLeft="5dp" android:background="@drawable/mediacontroller_button" android:contentDescription="@string/mediacontroller_play_pause" android:src="@drawable/mediacontroller_pause" /> </RelativeLayout> </io.vov.vitamio.widget.MediaController>

5.视频的全屏模式

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

android:configChanges="keyboardHidden|orientation|screenSize"

核心代码

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接口中了,大家只需要配置好就好了

下载地址

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

这里写图片描述

------分隔线----------------------------
------分隔线----------------------------

最新技术推荐