Android 原生 VideoView 功能有限,難以實現微信般的沉浸式播放體驗(如全屏手勢控制、邊緣縮放、彈幕覆蓋等)。本文實作一個繼承 SurfaceView 的自定義播放控件 MoviePlayerView,內部封裝 MediaPlayer,提供乾淨的播放/釋放/完成回調 API,適合作為影音類 App 的基礎播放組件。
package com.example.player;
import android.content.Context;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.util.AttributeSet;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
public class MoviePlayerView extends SurfaceView
implements SurfaceHolder.Callback, MediaPlayer.OnCompletionListener {
private SurfaceHolder holder;
private MediaPlayer mediaPlayer;
private OnPlayCompletionListener completionListener;
private String pendingPath; // 等待 Surface 建立後播放的路徑
public MoviePlayerView(Context context) { this(context, null); }
public MoviePlayerView(Context context, AttributeSet attrs) { this(context, attrs, 0); }
public MoviePlayerView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
holder = getHolder();
holder.addCallback(this);
// 必須設定,否則部分舊機型無法顯示
holder.setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS);
}
/** 公開播放介面 */
public void play(String filePath, OnPlayCompletionListener listener) {
this.completionListener = listener;
if (holder.getSurface().isValid()) {
startPlayback(filePath);
} else {
pendingPath = filePath; // Surface 尚未就緒,稍後在 surfaceCreated 執行
}
}
private void startPlayback(String filePath) {
releasePlayer(); // 確保乾淨狀態
mediaPlayer = new MediaPlayer();
mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
mediaPlayer.setDisplay(holder); // 關鍵:綁定 Surface
mediaPlayer.setOnCompletionListener(this);
try {
mediaPlayer.setDataSource(filePath);
mediaPlayer.prepareAsync(); // 非同步準備,避免阻塞 UI 執行緒
mediaPlayer.setOnPreparedListener(mp -> mp.start());
} catch (IOException e) {
e.printStackTrace();
if (completionListener != null) completionListener.onPlayCompletion(); // 視為播放結束
}
}
/** SurfaceHolder.Callback */
@Override public void surfaceCreated(SurfaceHolder holder) {
this.holder = holder;
if (pendingPath != null) {
startPlayback(pendingPath);
pendingPath = null;
}
}
@Override public void surfaceChanged(SurfaceHolder holder, int format, int w, int h) {
this.holder = holder; // 尺寸變更更新 holder
}
@Override public void surfaceDestroyed(SurfaceHolder holder) {
this.holder = null;
releasePlayer(); // Surface 銷毀時釋放播放器
}
/** MediaPlayer.OnCompletionListener */
@Override public void onCompletion(MediaPlayer mp) {
releasePlayer();
if (completionListener != null) completionListener.onPlayCompletion();
}
/** 釋放資源 */
public void release() { releasePlayer(); }
private void releasePlayer() {
if (mediaPlayer != null) {
mediaPlayer.release();
mediaPlayer = null;
}
}
/** 播放完成回調介面 */
public interface OnPlayCompletionListener {
void onPlayCompletion();
}
}
| 決策點 | 選擇 | 理由 |
|---|---|---|
繼承 SurfaceView 而非 TextureView | SurfaceView | 硬體覆蓋層,效能更低延遲、功耗更低;TextureView 需 GPU 合成,適合需動畫變換的場景 |
prepareAsync() 非同步準備 | 是 | 避免主執行緒卡頓(ANR),大檔案/網路流準備可能耗時秒級 |
setOnPreparedListener 啟動播放 | 是 | 確保 MediaPlayer 進入 Prepared 狀態後再 start,否則會拋 IllegalStateException |
surfaceDestroyed 釋放 MediaPlayer | 是 | Surface 銷毀意味著顯示面消失,必須釋放解碼器與硬體資源,否則洩漏 |
回調介面 OnPlayCompletionListener | 自定義 | 解耦播放器與業務邏輯(如自動播放下一條、顯示推薦頁) |
| Pending Path 機制 | 是 | 處理「先呼叫 play(),Surface 稍後才建立」的競態條件 |
<com.example.player.MoviePlayerView
android:id="@+id/moviePlayer"
android:layout_width="match_parent"
android:layout_height="200dp"
android:layout_gravity="center" />
public class PlayerActivity extends AppCompatActivity {
private MoviePlayerView playerView;
@Override protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_player);
playerView = findViewById(R.id.moviePlayer);
String videoUrl = "https://example.com/video.mp4"; // 或本地路徑 /sdcard/xxx.mp4
playerView.play(videoUrl, () -> runOnUiThread(() -> {
Toast.makeText(this, "播放完成", Toast.LENGTH_SHORT).show();
// 此處可實作:播放下一條、顯示互動卡片等
}));
}
@Override protected void onDestroy() {
super.onDestroy();
playerView.release(); // 必須釋放,否則記憶體洩漏
}
}
FrameLayout 包含播放/暫停、進度條、全螢幕按鈕,監聽 MediaPlayer.OnBufferingUpdateListener、OnSeekCompleteListener。onTouchEvent,左右滑動求進/退,上下滑動音量/亮度(參考 ijkplayer/ExoPlayer 控制器邏輯)。dispatchDraw 或獨立 TextureView 覆蓋層繪製文字軌跡。ExoPlayer 或 ijkplayer 替換 MediaPlayer,支援 DASH/HLS/平滑串流、快取策略。enterPictureInPictureMode(),配合 MediaSessionCompat 處理媒體按鍵。| 現象 | 原因 | 解決 |
|---|---|---|
| 黑屏無畫面、聲音正常 | Surface 未正確綁定、或 PixelFormat 不支援 | 確認 holder.setType(PUSH_BUFFERS)、Activity 設定 getWindow().setFormat(PixelFormat.TRANSLUCENT) |
| 旋轉螢幕後播放異常 | Activity 重建導致 Surface 變更 | 在 surfaceChanged 更新 holder,或鎖定螢幕方向 android:screenOrientation="landscape" |
| 釋放後再次播放崩潰 | MediaPlayer 狀態機違規 | 嚴格遵循 reset() → setDataSource → prepare → start,或直接 new 新實例 |
| 記憶體洩漏 (LeakCanary 偵測) | 未在 onDestroy/release() 呼叫 mediaPlayer.release() | 生命週期綁定:Activity/Fragment onDestroy 統一釋放 |
MoviePlayerView 以不足 100 行核心程式碼,封裝了 SurfaceView + MediaPlayer 的標準播放流程與生命週期管理,提供乾淨的介面供上層業務擴充。理解其狀態機與 Surface 互動細節,是進階客製化播放器(如接入 ExoPlayer、實現微信級互動體驗)的必經基石。