手把手教你封裝一個高效視訊播放器
ofollow,noindex">原始碼下載,歡迎star

本專案使用播放器是 ijkplay , 並且進行封裝和修改
主要功能: 1.重新編輯ijkplay的so庫, 使其更精簡和支援https協議 2.自定義MediaDataSource, 使用okhttp重寫網路框架, 網路播放更流暢 3.實現視訊快取, 並且自定義LRUCache演算法管理快取檔案 4.全域性使用一個播放器, 實現視訊在多個Activity之前無縫切換, 流暢播放 5.加入更多相容性判斷, 適配絕大數機型 複製程式碼
①匯入ijkplay:

//需要的許可權 <uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> 首先將lib資料夾下的so庫貼上過來, (因為官方自帶的so庫是不支援https的, 我重新編譯的這個so庫支援https協議, 並且使用的是精簡版的配置, 網上關於ijkplay編譯的流程和配置挺多的, 可以根據自己的需求自定義) 然後在module的build中加入 "implementation 'tv.danmaku.ijk.media:ijkplayer-java:0.8.8'" 複製程式碼
②使用播放器的方法:
1.我封裝了一個MediaPlayerTool工具類包含的初始化so庫和一些回撥等等
//通過單例得到媒體播放工具 mMediaPlayerTool = MediaPlayerTool.getInstance(); //這裡會自動初始化so庫 有些手機會找不到so, 會自動使用系統的播放器 private MediaPlayerTool(){ try { IjkMediaPlayer.loadLibrariesOnce(null); IjkMediaPlayer.native_profileBegin("libijkplayer.so"); loadIjkSucc = true; }catch (UnsatisfiedLinkError e){ e.printStackTrace(); loadIjkSucc = false; } } //一些生命週期回撥 public static abstract class VideoListener { //視訊開始播放 public void onStart(){}; //視訊被停止播放 public void onStop(){}; //視訊播放完成 public void onCompletion(){}; //視訊旋轉角度引數初始化完成 public void onRotationInfo(int rotation){}; //播放進度 0-1 public void onPlayProgress(long currentPosition){}; //快取速度 1-100 public void onBufferProgress(int progress){}; } 複製程式碼
2.因為我使用的是RecyclerView,所以先找到當前螢幕中 處於可以播放範圍的item
//首先迴圈RecyclerView中所有itemView, 找到在螢幕可見範圍內的item private void checkPlayVideo(){ currentPlayIndex = 0; videoPositionList.clear(); int childCount = rv_video.getChildCount(); for (int x = 0; x < childCount; x++) { View childView = rv_video.getChildAt(x); //isPlayRange()這個方法很重要 boolean playRange = isPlayRange(childView.findViewById(R.id.rl_video), rv_video); if(playRange){ int position = rv_video.getChildAdapterPosition(childView); if(position>=0 && !videoPositionList.contains(position)){ videoPositionList.add(position); } } } } //檢查當前item是否在RecyclerView可見的範圍內 private boolean isPlayRange(View childView, View parentView){ if(childView==null || parentView==null){ return false; } int[] childLocal = new int[2]; childView.getLocationOnScreen(childLocal); int[] parentLocal = new int[2]; parentView.getLocationOnScreen(parentLocal); boolean playRange = childLocal[1]>=parentLocal[1] && childLocal[1]<=parentLocal[1]+parentView.getHeight()-childView.getHeight(); return playRange; } 複製程式碼
3.我還封裝了一個TextureView, 裡面包含一些初始化SurfaceTexture和視訊裁剪播放的方法
//視訊居中播放 private void setVideoCenter(float viewWidth, float viewHeight, float videoWidth, float videoHeight){ Matrix matrix = new Matrix(); float sx = viewWidth/videoWidth; float sy = viewHeight/videoHeight; float maxScale = Math.max(sx, sy); matrix.preTranslate((viewWidth - videoWidth) / 2, (viewHeight - videoHeight) / 2); matrix.preScale(videoWidth/viewWidth, videoHeight/viewHeight); matrix.postScale(maxScale, maxScale, viewWidth/2, viewHeight/2); mTextureView.setTransform(matrix); mTextureView.postInvalidate(); } //初始化SurfaceTexture public SurfaceTexture newSurfaceTexture(){ int[] textures = new int[1]; GLES20.glGenTextures(1, textures, 0); int texName = textures[0]; SurfaceTexture surfaceTexture = new SurfaceTexture(texName); surfaceTexture.detachFromGLContext(); return surfaceTexture; } 複製程式碼
4.接下來就是播放程式碼了
private void playVideoByPosition(int position){ //根據傳進來的position找到對應的ViewHolder final MainAdapter.MyViewHolder vh = (MainAdapter.MyViewHolder) rv_video.findViewHolderForAdapterPosition(position); if(vh == null){ return ; } currentPlayView = vh.rl_video; //初始化一些播放狀態, 如進度條,播放按鈕,載入框等 //顯示正在載入的介面 vh.iv_play_icon.setVisibility(View.GONE); vh.pb_video.setVisibility(View.VISIBLE); vh.iv_cover.setVisibility(View.VISIBLE); vh.tv_play_time.setText(""); //初始化播放器 mMediaPlayerTool.initMediaPLayer(); mMediaPlayerTool.setVolume(0); //設定視訊url String videoUrl = dataList.get(position).getVideoUrl(); mMediaPlayerTool.setDataSource(videoUrl); myVideoListener = new MediaPlayerTool.VideoListener() { @Override public void onStart() { //將播放圖示和封面隱藏 vh.iv_play_icon.setVisibility(View.GONE); vh.pb_video.setVisibility(View.GONE); //防止閃屏 vh.iv_cover.postDelayed(new Runnable() { @Override public void run() { vh.iv_cover.setVisibility(View.GONE); } }, 300); } @Override public void onStop() { //播放停止 vh.pb_video.setVisibility(View.GONE); vh.iv_cover.setVisibility(View.VISIBLE); vh.iv_play_icon.setVisibility(View.VISIBLE); vh.tv_play_time.setText(""); currentPlayView = null; } @Override public void onCompletion() { //播放下一個 currentPlayIndex++; playVideoByPosition(-1); } @Override public void onRotationInfo(int rotation) { //設定旋轉播放 vh.playTextureView.setRotation(rotation); } @Override public void onPlayProgress(long currentPosition) { //顯示播放時長 String date = MyUtil.fromMMss(mMediaPlayerTool.getDuration() - currentPosition); vh.tv_play_time.setText(date); } }; mMediaPlayerTool.setVideoListener(myVideoListener); //這裡重置一下TextureView vh.playTextureView.resetTextureView(); mMediaPlayerTool.setPlayTextureView(vh.playTextureView); mMediaPlayerTool.setSurfaceTexture(vh.playTextureView.getSurfaceTexture()); //準備播放 mMediaPlayerTool.prepare(); } 複製程式碼
③重寫MediaDataSource, 使用okhttp實現邊下邊播和視訊快取
1.一共需要重寫3個方法getSize(),close()和readAt(); 先說getSize()
public long getSize() throws IOException { //開始播放時, 播放器會呼叫一下getSize()來初始化視訊大小, 這時我們就要初始化一條視訊播放流 if(networkInPutStream == null) { initInputStream(); } return contentLength; } //初始化一個視訊流出來, 可能是本地或網路 private void initInputStream() throws IOException{ File file = checkCache(mMd5); if(file != null){ //更新一下快取檔案 VideoLRUCacheUtil.updateVideoCacheBean(mMd5, file.getAbsolutePath(), file.length()); //讀取的本地快取檔案 isCacheVideo = true; localVideoFile = file; //開啟一個本地視訊流 localStream = new RandomAccessFile(localVideoFile, "rw"); contentLength = file.length(); }else { //沒有快取 開啟一個網路流, 並且開啟一個快取流, 實現視訊快取 isCacheVideo = false; //開啟一個網路視訊流 networkInPutStream = openHttpClient(0); //要寫入的本地快取檔案 localVideoFile = VideoLRUCacheUtil.createCacheFile(MyApplication.mContext, mMd5, contentLength); //要寫入的本地快取視訊流 localStream = new RandomAccessFile(localVideoFile, "rw"); } } 複製程式碼
2.然後是readAt()方法, 也是最重要的一個方法
/** * @param position 視訊流讀取進度 * @param buffer 要把讀取到的資料存到這個陣列 * @param offset 資料開始寫入的座標 * @param size 本次一共讀取資料的大小 * @throws IOException */ //記錄當前讀取流的索引 long mPosition = 0; @Override public int readAt(long position, byte[] buffer, int offset, int size) throws IOException { if(position>=contentLength || localStream==null){ return -1; } //是否將此位元組快取到本地 boolean isWriteVideo = syncInputStream(position); //讀取的流的長度不能大於contentLength if (position+size > contentLength) { size -= position+size-contentLength; } //讀取指定大小的視訊資料 byte[] bytes; if(isCacheVideo){ //從本地讀取 bytes = readByteBySize(localStream, size); }else{ //從網路讀取 bytes = readByteBySize(networkInPutStream, size); } if(bytes != null) { //寫入到播放器的陣列中 System.arraycopy(bytes, 0, buffer, offset, size); if (isWriteVideo && !isCacheVideo) { //將視訊快取到本地 localStream.write(bytes); } //記錄資料流讀取到哪步了 mPosition += size; } return size; } /** * 從inputStream裡讀取size大小的資料 */ private byte[] readByteBySize(InputStream inputStream, int size) throws IOException{ ByteArrayOutputStream out = new ByteArrayOutputStream(); byte[] buf = new byte[size]; int len; while ((len = inputStream.read(buf)) != -1) { out.write(buf, 0, len); if (out.size() == size) { return out.toByteArray(); } else { buf = new byte[size - out.size()]; } } return null; } /** * 刪除file一部分位元組, 從position到file.size */ private void deleteFileByPosition(long position) throws IOException{ FileInputStream in = new FileInputStream(localVideoFile); File tempFile = VideoLRUCacheUtil.createTempFile(MyApplication.mContext); FileOutputStream out = new FileOutputStream(tempFile); byte[] buf = new byte[8192]; int len; while ((len = in.read(buf)) != -1) { if(position <= len){ out.write(buf, 0, (int) position); out.close(); in.close(); localVideoFile.delete(); tempFile.renameTo(localVideoFile); localStream = new RandomAccessFile(localVideoFile, "rw"); return ; }else{ position -= len; out.write(buf, 0, len); } } tempFile.delete(); } 複製程式碼
3.主要說一下syncInputStream(), 因為有可能出現一種情況, 比如一個視訊長度100, 播放器首先讀取視訊的1到10之間的資料, 然後在讀取90到100之間的資料, 然後在從1播放到100; 所以這時我們需要同步視訊流, 和播放進度保持一致這時就需要重新開啟一個IO流(如果在讀取本地快取時可以直接使用RandomAccessFile.seek()方法跳轉)
//同步資料流 private boolean syncInputStream(long position) throws IOException{ boolean isWriteVideo = true; //判斷兩次讀取資料是否連續 if(mPosition != position){ if(isCacheVideo){ //如果是本地快取, 直接跳轉到該索引 localStream.seek(position); }else{ if(mPosition > position){ //同步本地快取流 localStream.close(); deleteFileByPosition(position); localStream.seek(position); }else{ isWriteVideo = false; } networkInPutStream.close(); //重新開啟一個網路流 networkInPutStream = openHttpClient((int) position); } mPosition = position; } return isWriteVideo; } 複製程式碼
4.最後一個是close()方法, 主要播放停止後釋放一些資源
public void close() throws IOException { if(networkInPutStream != null){ networkInPutStream.close(); networkInPutStream = null; } if(localStream != null){ localStream.close(); localStream = null; } if(localVideoFile.length()!=contentLength){ localVideoFile.delete(); } } 複製程式碼
④視訊快取和LRUCache管理
1.首先建立快取檔案, 在剛才的MediaDataSource.getSize()方法裡有一句程式碼
localVideoFile = VideoLRUCacheUtil.createCacheFile(MyApplication.mContext, mMd5, contentLength); public static File createCacheFile(Context context, String md5, long fileSize){ //建立一個視訊快取檔案, 在data/data目錄下 File filesDir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS); File cacheFile = new File(filesDir, md5); if(!cacheFile.exists()) { cacheFile.createNewFile(); } //將快取資訊存到資料庫 VideoLRUCacheUtil.updateVideoCacheBean(md5, cacheFile.getAbsolutePath(), fileSize); return cacheFile; } 複製程式碼
2.然後是讀取快取檔案, 在剛才的MediaDataSource.getSize()方法裡還有一句程式碼
//檢查本地是否有快取, 2步確認, 資料庫中是否存在, 本地檔案是否存在 private File checkCache(String md5){ //查詢資料庫 VideoCacheBean bean = VideoCacheDBUtil.query(md5); if(bean != null){ File file = new File(bean.getVideoPath()); if(file.exists()){ return file; } } return null; } 複製程式碼
3.LRUCache的實現
//清理超過大小和儲存時間的視訊快取檔案 VideoLRUCacheUtil.checkCacheSize(mContext); public static void checkCacheSize(Context context){ ArrayList<VideoCacheBean> videoCacheList = VideoCacheDBUtil.query(); //檢查一下資料庫裡面的快取檔案是否存在 for (VideoCacheBean bean : videoCacheList){ if(bean.getFileSize() == 0){ File videoFile = new File(bean.getVideoPath()); //如果檔案不存在或者檔案大小不匹配, 那麼刪除 if(!videoFile.exists() && videoFile.length()!=bean.getFileSize()){ VideoCacheDBUtil.delete(bean); } } } long currentSize = 0; long currentTime = System.currentTimeMillis(); for (VideoCacheBean bean : videoCacheList){ //太久遠的檔案刪除 if(currentTime-bean.getPlayTime() > maxCacheTime){ VideoCacheDBUtil.delete(bean); }else { //大於儲存空間的刪除 if (currentSize + bean.getFileSize() > maxDirSize) { VideoCacheDBUtil.delete(bean); } else { currentSize += bean.getFileSize(); } } } //刪除不符合規則的快取 deleteDirRoom(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), VideoCacheDBUtil.query()); } //更新快取檔案的播放次數和最後播放時間 public static void updateVideoCacheBean(String md5, String videoPath, long fileSize){ VideoCacheBean videoCacheBean = VideoCacheDBUtil.query(md5); if(videoCacheBean == null){ videoCacheBean = new VideoCacheBean(); videoCacheBean.setKey(md5); videoCacheBean.setVideoPath(videoPath); videoCacheBean.setFileSize(fileSize); } videoCacheBean.setPlayCount(videoCacheBean.getPlayCount()+1); videoCacheBean.setPlayTime(System.currentTimeMillis()); VideoCacheDBUtil.save(videoCacheBean); } 複製程式碼
⑤關於多個Activity同步播放狀態, 無縫切換
1.首先在跳轉時, 通知被覆蓋的activity不關閉播放器
//首先跳轉時通知一下activity mainActivity.jumpNotCloseMediaPlay(position); //然後在onPause裡 protected void onPause() { super.onPause(); //如果要跳轉播放, 那麼不關閉播放器 if (videoPositionList.size()>currentPlayIndex && jumpVideoPosition==videoPositionList.get(currentPlayIndex)) { ...這裡就不關閉播放器 }else{ //如果不要求跳轉播放, 那麼就重置播放器 mMediaPlayerTool.reset(); } } 複製程式碼
2.然後在新頁面初始化播放器
private void playVideoByPosition(int position){ ......一切初始化程式碼照舊(注意不要重置播放器), 這裡省略不提 //把播放器當前繫結的SurfaceTexture取出起來, 設定給當前介面的TextureView vh.playTextureView.resetTextureView(mMediaPlayerTool.getAvailableSurfaceTexture()); mMediaPlayerTool.setPlayTextureView(vh.playTextureView); //最後重新整理一下view vh.playTextureView.postInvalidate(); } 複製程式碼