編輯:關於Android編程
最近在研究直播的彈幕,東西有點多,准備記錄一下免得自己忘了又要重新研究,也幫助有這方面需要的同學少走點彎路。關於直播的技術細節其實就是兩個方面一個是推流一個是拉流,而彈幕的實現核心在即時聊天,使用聊天室的就能實現,只是消息的展示方式不同而已。在大多數的項目中還是使用第三方的直播平台實現推流功能,因此關於直播平台的選擇也是至關重要。下面由我娓娓道來。
為了演示方便我把屏幕錄像上傳到優酷了,這是視頻地址
提供直播功能的廠商有很多,比如七牛雲,樂視,百度雲,騰訊雲,金山雲,等等。功能也大同小異,常見的縮略圖,視頻錄制,轉碼,都可以實現。但是對於SDK的易用程度還是不敢恭維的。下面我說說我遇到的一些問題。
樂視雲 移動直播
優點:
樂視直播的注冊流程還是很方便的,選擇個人開發者,然後驗證身份信息就可以使用了,每人每月免費10GB的流量。
缺點
最大的缺點就是穩定性,至少在我測試的時候也就是2016年9月份穩定性很差,不是說視頻的穩定性,而是推流的穩定性,我有一台在同樣的網絡下我的ViVO X7能推流,但是魅藍NOTE2不能推流。然而ViVO X7推出去的流在電腦上用VLC能播放,在其他手機上顯示黑屏,既不報錯也沒畫面。隨後使用同樣的網絡,同樣的魅藍NOTE2,百度的SDK就能推流。看來樂視的直播技術方面還有待改進,直接pass。
七牛雲官網
優點
態度好,服務周到,其他方面的不能再評價了,因為沒有真正使用過,這的確很尴尬,不過態度的確很好,會有客服打電話過來詢問需求,會有技術支持人員主動溝通,這是很值得肯定的。
缺點
倒不能算是缺點,可能算特點吧,七牛雲需要使用域名別名解析來做RTMP直播流域名,也就是說你要使用七牛雲必須要有一個備案過的域名,由於我司的域名我不能輕易去改,而且我也沒有備案過的域名,所以不能測試。
還沒有通過審核,效率太低。
也需要域名,跳過。
百度音視頻直播 LSS
優點
審核速度挺快的,實名認證大概15分鐘搞定(這是我的速度,僅供參考),不需要域名,為個人開發者免費提供10G流量測試,這點很良心。而且功能很全面,推流很簡單。下面是價格表:
缺點
企業用戶需要認證,否則單月最大流量為1TB,個人用戶總流量限制在1000GB。
經過以上對比最終選擇了百度雲來實現直播。
這裡邊倒沒有太多的考慮,環信,融雲,LeanCloud都可以,但是長期使用leancloud發現其文檔質量很高,SDK簡單易用。所以使用了LeanCloud來實現即時通訊。
LearnCloud Android 實時通信開發指南
彈幕說白了就是聊天室,只是聊天室的消息需要在視頻節目上顯示而已,所以首先要實現一個聊天室,此處使用LeanCloud實現。
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。
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在後頭運行的時候,通過默認處理器處理消息,即彈出狀態欄彈出通知,而在聊天界面由當前界面處理消息。
@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 ListmessageList = new ArrayList<>(); // private String[] itemText = {"是否需要幫忙", "what are you 弄啥來", "哈哈哈哈哈哈哈", "搶占沙發。。。。。。", "************", "是否需要幫忙", // "我不會輕易的狗帶", "嘿嘿", "這是我見過的最長長長長長長長長長長長的評論"}; private int textCount; // private List itemList = new ArrayList (); 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吧。
視頻的播放使用的是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,若不加上判斷就會出現緩沖圖標一閃一閃的卡頓現象 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,布局可以參考源碼中這個文件
其核心的邏輯是點擊按鈕,改變屏幕方向,在改變方向的時候隱藏聊天室,輸入框等。同時改變控件的大小。要讓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中的配置信息,我抽取到相關的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
我們將使用微信公眾賬號方倍工作室作為講解的例子,二維碼見底部。本系列教程將引導你完成如下任務:創建新浪雲計算平台應用啟用微信公眾平台開發模式基礎接口消息及事件微信公眾平台
最近想寫篇關於Activity啟動過程源碼分析的博客,在此之前先總結下Android中Activity必須要知道的一些基礎知識,以方便後面能看懂Activity的源碼。一
自從騰訊QQ中的圓形頭像,火了起來後,現在我們在一些應用中都能看到圓形頭像的身影,在個人主頁或者個人資料面板中使用圓形頭像,會使整個布局變得更加優雅現在我們來進行第一步,
我們知道要想繪制一些特別的效果的話,離不開Paint和Canvas,Paint是你所畫圖形的一些基本屬性,按照面向對象的思想,你要把一個圓畫在畫布上,那麼是有畫筆和畫布,