從零構(gòu)建一個(gè)Android全功能視頻播放器
一、Android 播放器架構(gòu)全景
1.1 為什么 Android 播放器開(kāi)發(fā)值得深入
Android 平臺(tái)的視頻播放器開(kāi)發(fā),幾乎涵蓋了音視頻技術(shù)的所有核心問(wèn)題:
- 編解碼:硬解碼(MediaCodec + OMX)、軟解碼(FFmpeg)
- 渲染:SurfaceView、TextureView、GLSurfaceView、SurfaceTexture
- 同步:音畫(huà)同步、緩沖管理、卡頓處理
- 協(xié)議:HTTP(ExoPlayer)、RTSP(Live555)、RTMP(FFmpeg)
- 系統(tǒng):OMX 服務(wù)、MediaCodec 狀態(tài)機(jī)、BufferQueue 機(jī)制
掌握這些,就掌握了整個(gè) Android 多媒體子系統(tǒng)的半壁江山。
1.2 Android 播放器的技術(shù)演進(jìn)
Android 1.0 ~ 2.3(2008-2010) └── VideoView(底層封裝 MediaPlayer,僅 SurfaceView) └── MediaPlayer(C 層實(shí)現(xiàn),不透明,擴(kuò)展性極差) Android 3.0(2011)—— 里程碑 └── MediaCodec API 引入(OpenMax 標(biāo)準(zhǔn)化) └── SurfaceTexture(TextureView 基礎(chǔ)) └── OMX IL 1.1.2 成為標(biāo)準(zhǔn)接口 Android 4.1+(2012)—— 開(kāi)放能力 └── MediaCodec 完全開(kāi)放(非僅 HAL) └── SurfaceView 雙緩沖機(jī)制完善 └── MediaCodec + Surface 組合成為主流 Android 4.4+(2013)—— ExoPlayer 開(kāi)源 └── Google 內(nèi)部播放器框架開(kāi)源 └── DASH/HLS 全面支持 └── MediaCodec 無(wú)鎖緩沖隊(duì)列 Android 7.0+(2016)—— 現(xiàn)代化 └── MediaCodec 2.0 API(Codename:Nougat) └── Surface枕持化 └──MediaMuxer 支持 HEVC Android 10+(2019)—— 隱私與 MediaSession └── Scoped Storage └── MediaSession 標(biāo)準(zhǔn)化 Android 11+(2020)—— 現(xiàn)代框架 └── ExoPlayer 2.x 成為 Jetpack 組件 └── Media3 統(tǒng)一 API(MediaSession + ExoPlayer)
1.3 三種播放方案橫向?qū)Ρ?/h3>
| 方案 | 架構(gòu)層次 | 適用場(chǎng)景 | 優(yōu)點(diǎn) | 缺點(diǎn) |
|---|---|---|---|---|
| MediaPlayer + SurfaceView | 系統(tǒng)級(jí) | 快速集成、簡(jiǎn)單播放 | 一行代碼、硬件加速 | 無(wú)法自定義解碼、無(wú)法縮略圖 |
| MediaCodec + Surface | 應(yīng)用層(裸 API) | 需要自定義解碼/濾鏡 | 完全可控、靈活 | 代碼量大、狀態(tài)機(jī)復(fù)雜 |
| ExoPlayer(Media3) | 應(yīng)用層(框架層) | 直播/點(diǎn)播/DRM | 功能完整、社區(qū)活躍 | 框架包袱、定制需深挖源碼 |
入門建議:先掌握 MediaCodec 裸 API(理解原理),再過(guò)渡到 ExoPlayer(工程實(shí)用)。
1.4 Android 播放器核心組件
┌─────────────────────────────────────────────────────────────┐
│ Application 層 │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ 播放器外殼(PlayerActivity / PlayerFragment) │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────────┼───────────────────────────┐ │
│ │ 控制層 │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │ │
│ │ │ ExoPlayer / │ │ 音畫(huà)同步器 │ │ 緩沖管理 │ │ │
│ │ │ MediaCodec │ │ SyncEngine │ │ BufferMgr│ │ │
│ │ └──────────────┘ └──────────────┘ └──────────┘ │ │
│ └───────────────────────────┬───────────────────────────┘ │
│ │ │
│ ┌───────────────────────────┼───────────────────────────┐ │
│ │ 解碼層 │ │
│ │ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ VideoDecoder│ │ AudioDecoder│ │ │
│ │ │ (MediaCodec)│ │ (MediaCodec)│ │ │
│ │ └──────────────┘ └──────────────┘ │ │
│ └───────────────────────────┬───────────────────────────┘ │
│ │ │
│ ┌───────────────────────────┼───────────────────────────┐ │
│ │ 渲染層 │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │ │
│ │ │ SurfaceView │ │ TextureView │ │ GLSurface│ │ │
│ │ │ (雙緩沖) │ │ (UI線程合成) │ │ View │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────┘ │ │
│ └───────────────────────────┬───────────────────────────┘ │
└──────────────────────────────┼───────────────────────────────┘
│
┌──────────────────────────────┼───────────────────────────────┐
│ 系統(tǒng)服務(wù)層(Binder IPC) │
│ ┌─────────────────┐ ┌─────────────────┐ ┌───────────────┐ │
│ │ OMX Master │ │ MediaCodec │ │ AudioFlinger │ │
│ │ (OMX IL 核心) │ │ (Codec 服務(wù)) │ │ (音頻輸出) │ │
│ └─────────────────┘ └─────────────────┘ └───────────────┘ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌───────────────┐ │
│ │ SurfaceFlinger│ │ AudioPolicy │ │ MediaPlayer │ │
│ │ (幀合成/顯示) │ │ Service │ │ Service │ │
│ └─────────────────┘ └─────────────────┘ └───────────────┘ │
└──────────────────────────────────────────────────────────────┘二、核心技術(shù)概念詳解
2.1 MediaCodec:編解碼器的核心 API
MediaCodec 是 Android 提供的一套訪問(wèn)硬件/軟件編解碼器的底層 API,本質(zhì)上是 生產(chǎn)者-消費(fèi)者 模式的隊(duì)列處理器。
2.1.1 架構(gòu)模型
┌─────────────────────────────────────┐
│ MediaCodec │
│ │
編碼后數(shù)據(jù) ──────→│ InputBuffer 隊(duì)列(生產(chǎn)者寫入) │
(AVPacket) │ │
│ ┌───────────────────────────────┐ │
│ │ Codec Core(硬件/軟件) │ │
│ │ ┌─────────┐ ┌─────────┐ │ │
│ │ │ 解碼器 │ │ 編碼器 │ │ │
│ │ └─────────┘ └─────────┘ │ │
│ └───────────────────────────────┘ │
│ │
解碼后數(shù)據(jù) ←──────│ OutputBuffer 隊(duì)列(消費(fèi)者讀?。? │
(Surface/YUV) │ │
└─────────────────────────────────────┘2.1.2 編解碼器狀態(tài)機(jī)
configure()
┌───────────────────────────────────────────────┐
│ ↓
┌───┴───┐ ┌─────┴─────┐
│ │ │ │
│ Unconfigured │ Configured │
│ │ │ │
└───────┘ └──────┬───┘
↑ │
│ ┌────────────────────────────────┘
│ │ start()
│ ↓
┌───┴───────────────────────────────────────────┐
│ │
│ ┌─────────┐ dequeueInputBuffer() ┌───────┴───────┐
│ │ │────────────────────────→│ │
│ │ │ │ InputPending │
queueInput │ └───────┬───────┘
│ │ Buffer │
│ │ │ ┌──┴──────────┐
│ │ │ ←─── dequeueOutputBuffer │ │
│ │ Flushed │ │OutputPending │
│ │ │ └───────┬──────┘
│ └─────────┘ │
│ ↑ │
└─────────┼────────────────────────────────────┘
│ flush() / endOfStream / error
┌─────┴─────┐
│ │
│ Executing │ ←─── start() 后進(jìn)入此狀態(tài)
│ │
└───────────┘狀態(tài)說(shuō)明:
| 狀態(tài) | 說(shuō)明 | 可調(diào)用方法 |
|---|---|---|
| Uninitialized | 初始狀態(tài) | configure() |
| Configured | 已配置 | start() / release() |
| Flushed | 刷新?tīng)顟B(tài)(start 后立即進(jìn)入) | queueInputBuffer() |
| Running | 運(yùn)行中 | dequeueInputBuffer() / dequeueOutputBuffer() |
| End of Stream | 流結(jié)束 | queueInputBuffer (EOS) |
| Error | 錯(cuò)誤狀態(tài) | reset() / release() |
關(guān)鍵 API:
// ===== 創(chuàng)建解碼器 =====
val mediaFormat = MediaFormat.createVideoFormat(
MediaFormat.KEY_MIME, // "video/avc" / "video/hevc" / "audio/mp4a-latm"
width, // 視頻寬
height // 視頻高
)
// 可選參數(shù)
mediaFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT,
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, 5_000_000)
mediaFormat.setInteger(MediaFormat.KEY_FRAME_RATE, 30)
mediaFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1) // 1s 一個(gè) I 幀
val decoder = MediaCodec.createDecoderByType("video/avc")
// ===== 配置解碼器 =====
decoder.configure(mediaFormat, surface, crypto, 0)
// 參數(shù)說(shuō)明:
// mediaFormat:格式(寬/高/碼率/幀率/I幀間隔)
// surface:輸出 Surface(視頻直接渲染到 Surface,跳過(guò) APP 層 YUV 拷貝)
// crypto:DRM 加密(null = 無(wú)加密)
// flags:0 = 解碼器,CONFIGURE_FLAG_ENCODE = 編碼器
// ===== 啟動(dòng)解碼器 =====
decoder.start()
// ===== 解碼循環(huán) =====
while (isPlaying) {
// 1. 取輸入緩沖區(qū),寫入壓縮數(shù)據(jù)
val inputBufIndex = decoder.dequeueInputBuffer(TIMEOUT_US)
if (inputBufIndex >= 0) {
val inputBuf = decoder.getInputBuffer(inputBufIndex)
val sampleSize = extractor.readSampleData(inputBuf, 0)
if (sampleSize < 0) {
decoder.queueInputBuffer(inputBufIndex, 0, 0, 0,
MediaCodec.BUFFER_FLAG_END_OF_STREAM)
} else {
decoder.queueInputBuffer(
inputBufIndex, 0, sampleSize,
extractor.sampleTime, // PTS(微秒)
0 // flags
)
extractor.advance()
}
}
// 2. 取輸出緩沖區(qū),獲取解碼后數(shù)據(jù)
val bufferInfo = MediaCodec.BufferInfo()
val outputBufIndex = decoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_US)
when {
outputBufIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
val newFormat = decoder.outputFormat
Log.d("Decoder", "Output format changed: $newFormat")
}
outputBufIndex >= 0 -> {
// 可選:檢查是否為關(guān)鍵幀
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME != 0) {
Log.d("Decoder", "Key frame output")
}
// 渲染到 Surface(第三個(gè)參數(shù) = 延遲渲染的時(shí)間戳,微秒)
decoder.releaseOutputBuffer(outputBufIndex, bufferInfo.presentationTimeUs)
}
}
}2.1.3 MediaFormat:格式描述核心類
// ===== 常見(jiàn)視頻格式參數(shù) =====
val videoFormat = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, 1920, 1080).apply {
setInteger(MediaFormat.KEY_COLOR_FORMAT,
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface) // 直接渲染模式
setInteger(MediaFormat.KEY_BIT_RATE, 5_000_000) // 5 Mbps
setInteger(MediaFormat.KEY_FRAME_RATE, 30) // 30 fps
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1) // I 幀間隔 1 秒
setLong(MediaFormat.KEY_MAX_INPUT_SIZE, 1920 * 1080 * 4) // 最大輸入緩沖
}
// ===== 常見(jiàn)音頻格式參數(shù) =====
val audioFormat = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, 48000, 2).apply {
setInteger(MediaFormat.KEY_BIT_RATE, 128_000) // 128 kbps
setInteger(MediaFormat.KEY_AAC_PROFILE,
MediaCodecInfo.CodecProfileLevel.AACObjectLC)
setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 16384)
}
// ===== 查詢可用編碼器/解碼器 =====
fun findDecoder(mime: String): MediaCodecInfo? {
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
for (info in codecList.codecInfos) {
if (info.isEncoder) continue
for (type in info.supportedTypes) {
if (type.equals(mime, ignoreCase = true)) {
return info
}
}
}
return null
}2.2 Surface:解碼輸出的渲染目標(biāo)
Surface 是 Android 圖形系統(tǒng)的核心接口,代表生產(chǎn)者-消費(fèi)者共享的原生隊(duì)列。
2.2.1 Surface 的三重身份
┌─────────────────────────────────────────────────────────┐ │ Surface │ │ │ │ ① 視頻解碼器輸出目標(biāo)(生產(chǎn)者) │ │ MediaCodec.configure(format, surface, ...) │ │ → 解碼器直接寫入 Surface,跳過(guò) APP 層 YUV 拷貝 │ │ │ │ ② 圖形消費(fèi)者(SurfaceView / TextureView) │ │ SurfaceView:Surface 嵌入窗口,雙緩沖,獨(dú)立的 GPU 合成的 │ │ TextureView:Surface 作為紋理,UI 線程合成 │ │ │ │ ③ ANativeWindow(Native 層) │ │ 可直接對(duì)接 Camera NDK / FFmpeg libavcodec │ └─────────────────────────────────────────────────────────┘
2.2.2 SurfaceView vs TextureView
| 特性 | SurfaceView | TextureView |
|---|---|---|
| 渲染位置 | SurfaceFlinger(獨(dú)立合成) | UI 線程(View 樹(shù)合成) |
| 雙緩沖 | ? 硬件雙緩沖(翻頁(yè)式) | ? 單緩沖(可能導(dǎo)致撕裂) |
| 性能 | ? 高(獨(dú)立層,無(wú) UI 線程開(kāi)銷) | ?? 中(UI 線程參與合成) |
| 截屏 | ?? 需特殊處理 | ? 直接截圖 |
| 動(dòng)畫(huà)/變換 | ? 不支持 View 變換 | ? 支持旋轉(zhuǎn)/縮放/alpha |
| Z-order | 可置于任意位置 | 服從 View 層級(jí) |
| 延遲 | 最低(直接寫入幀緩沖區(qū)) | 略高(UI 合成延遲) |
| 適用場(chǎng)景 | 直播播放器、Camera 預(yù)覽 | 視頻編輯、特效疊加 |
SurfaceView 使用示例:
// XML 布局
<SurfaceView
android:id="@+id/surfaceView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
// 代碼中獲取 Surface
class MainActivity : AppCompatActivity(), SurfaceHolder.Callback2 {
private lateinit var surfaceView: SurfaceView
private var surface: Surface? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
surfaceView = findViewById(R.id.surfaceView)
surfaceView.holder.addCallback(this)
// 可選:設(shè)置 Surface 的像素格式和緩沖數(shù)量
surfaceView.holder.setFormat(PixelFormat.RGBA_8888)
}
override fun surfaceCreated(holder: SurfaceHolder) {
// Surface 創(chuàng)建后,立即配置解碼器
surface = holder.surface
setupDecoder(surface!!)
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int,
width: Int, height: Int) {
Log.d("Player", "Surface changed: ${width}x$height")
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
// 釋放解碼器
releaseDecoder()
surface = null
}
}TextureView 使用示例:
// XML
<TextureView
android:id="@+id/textureView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
// 代碼
class TexturePlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
private lateinit var textureView: TextureView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
textureView = findViewById(R.id.textureView)
textureView.surfaceTextureListener = this
}
override fun onSurfaceTextureAvailable(surface: SurfaceTexture,
width: Int, height: Int) {
// SurfaceTexture 可用后創(chuàng)建 Surface
val surface = Surface(surface)
setupDecoder(surface)
}
override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {
// 每幀更新時(shí)調(diào)用(UI 線程)
}
override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
releaseDecoder()
surface.release()
return true
}
}2.3 MediaExtractor:流解復(fù)用
MediaExtractor 負(fù)責(zé)從容器文件(MP4/MKV/TS 等)中提取軌道(Track),包括視頻軌、音頻軌、字幕軌。
// ===== 創(chuàng)建 Extractor 并設(shè)置數(shù)據(jù)源 =====
val extractor = MediaExtractor()
extractor.setDataSource(filePath) // 或 URL、FileDescriptor
// ===== 枚舉所有軌道 =====
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
if (mime.startsWith("video/")) {
Log.d("Extractor", "Video track: $i, $format")
extractor.selectTrack(i) // 選擇視頻軌
} else if (mime.startsWith("audio/")) {
Log.d("Extractor", "Audio track: $i, $format")
}
}
// ===== 獲取當(dāng)前選中軌道的格式 =====
val videoFormat = extractor.getTrackFormat(selectedTrack)
val width = videoFormat.getInteger(MediaFormat.KEY_WIDTH)
val height = videoFormat.getInteger(MediaFormat.KEY_HEIGHT)
val duration = videoFormat.getLong(MediaFormat.KEY_DURATION) // 微秒
val frameRate = videoFormat.getIntegerOrDefault(MediaFormat.KEY_FRAME_RATE, 30)
val colorFormat = videoFormat.getIntegerOrDefault(MediaFormat.KEY_COLOR_FORMAT, 0)
// ===== 讀取壓縮數(shù)據(jù) =====
val buffer = ByteBuffer.allocate(1024 * 1024) // 1MB 緩沖
val sampleSize = extractor.readSampleData(buffer, 0)
if (sampleSize < 0) {
// 流結(jié)束
}
val sampleTime = extractor.sampleTime // 當(dāng)前幀 PTS(微秒)
val sampleFlags = extractor.sampleFlags // BUFFER_FLAG_*
// ===== 跳到指定時(shí)間 =====
extractor.seekTo(positionUs, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
// SEEK_TO_CLOSEST_SYNC:跳到最近的同步點(diǎn)(I 幀)
// SEEK_TO_PREVIOUS_SYNC:跳到上一個(gè) I 幀
// SEEK_TO_NEXT_SYNC:跳到下一個(gè) I 幀
// ===== 釋放 =====
extractor.release()2.4 音畫(huà)同步:PTS 驅(qū)動(dòng)的顯示控制
Android 播放器的音畫(huà)同步,是在解碼器輸出幀后,通過(guò)比較 幀的 PTS 與 當(dāng)前音視頻時(shí)鐘 的差值來(lái)決定立即顯示、延遲等待還是丟棄。
2.4.1 同步策略分類
| 策略 | 主時(shí)鐘 | 原理 | 優(yōu)點(diǎn) | 缺點(diǎn) |
|---|---|---|---|---|
| 視頻同步到音頻 | 音頻 | 音頻精確,音頻播放,視頻等音頻 | 音頻不卡頓 | 視頻可能掉幀 |
| 音頻同步到視頻 | 視頻 | 視頻固定幀率,音頻調(diào)整采樣率 | 視頻流暢 | 音頻可能有 Pop |
| 同步到系統(tǒng)時(shí)鐘 | NTP | 以系統(tǒng)時(shí)間為基準(zhǔn) | 外部一致性好 | 漂移難控制 |
行業(yè)最佳實(shí)踐:視頻同步到音頻(音頻為主時(shí)鐘),因?yàn)橐纛l采樣率由硬件晶振控制,精度遠(yuǎn)高于視頻幀率。
2.4.2 同步算法實(shí)現(xiàn)
class SyncController(private val masterClock: MasterClock) {
// 同步閾值
companion object {
private const val MAX_FRAME_DROP_MS = 100L // 超過(guò) 100ms 即丟幀
private const val SYNC_THRESHOLD_MS = 50L // 50ms 內(nèi)無(wú)需同步
private const val SLEEP_GRANULARITY_MS = 10L // 睡眠粒度
}
/**
* @param framePtsUs 幀的 PTS(微秒)
* @param currentTimeUs 當(dāng)前主時(shí)鐘時(shí)間(微秒)
* @return SyncResult:立即顯示 / 等待后顯示 / 丟棄
*/
fun getSyncResult(framePtsUs: Long, currentTimeUs: Long): SyncResult {
val diffMs = (framePtsUs - currentTimeUs) / 1000
return when {
// 視頻超前:需要等待
diffMs > SYNC_THRESHOLD_MS -> {
SyncResult.WaitAndDisplay(diffMs)
}
// 視頻落后超過(guò)閾值:丟棄
diffMs < -MAX_FRAME_DROP_MS -> {
Log.w("Sync", "Frame late by ${-diffMs}ms, dropping")
SyncResult.Drop
}
// 在可接受范圍內(nèi):立即顯示
else -> SyncResult.DisplayNow
}
}
sealed class SyncResult {
object DisplayNow : SyncResult()
data class WaitAndDisplay(val waitMs: Long) : SyncResult()
object Drop : SyncResult()
}
}
/**
* 主時(shí)鐘(音頻作為主時(shí)鐘)
*/
class AudioMasterClock(private val audioTrack: AudioTrack) : MasterClock {
override fun getCurrentTimeUs(): Long {
// AudioTrack.getPlaybackHeadPosition() 返回已播放采樣數(shù)
val headPos = audioTrack.playbackHeadPosition
// 將采樣位置轉(zhuǎn)換為微秒
val sampleRate = audioTrack.sampleRate
return headPos * 1_000_000L / sampleRate
}
}2.5 緩沖管理:避免卡頓的核心
2.5.1 三級(jí)緩沖架構(gòu)
┌─────────────────────────────────────────────────────────────┐
│ 網(wǎng)絡(luò)/HTTP 緩沖 │
│ OkHttp / URLConnection │
│ 典型緩沖:2–10 MB │
└─────────────────────────────┬───────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 解碼器輸入緩沖 │
│ MediaCodec Input Buffer │
│ 典型緩沖:3–5 幀 │
│ dequeueInputBuffer() → queueInputBuffer() │
└─────────────────────────────┬───────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 解碼器輸出緩沖 │
│ MediaCodec Output Buffer │
│ 典型緩沖:5–10 幀 │
│ dequeueOutputBuffer() → releaseOutputBuffer() │
└─────────────────────────────┬───────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 渲染緩沖 │
│ Surface/TextureView 幀緩沖 │
│ 典型緩沖:2–3 幀 │
│ SurfaceView 雙緩沖(自動(dòng)) │
└─────────────────────────────────────────────────────────────┘2.5.2 緩沖監(jiān)控與自適應(yīng)
class BufferMonitor(private val decoder: MediaCodec) {
private val bufferHistory = ArrayDeque<Long>(100)
// 監(jiān)控解碼器緩沖隊(duì)列深度
fun checkBufferHealth(): BufferHealth {
val inputCount = bufferHistory.filter { it > 0 }.size
val outputCount = bufferHistory.filter { it < 0 }.size
return when {
inputCount > 3 && outputCount < 1 -> BufferHealth.UNDERFLOW
inputCount < 1 && outputCount > 5 -> BufferHealth.OVERFLOW
else -> BufferHealth.NORMAL
}
}
enum class BufferHealth {
NORMAL,
UNDERFLOW, // 解碼器"餓"了(輸入不足)
OVERFLOW // 解碼器"撐"了(輸出積壓)
}
// 動(dòng)態(tài)調(diào)整:緩沖不足時(shí)降低解碼幀率,積壓時(shí)加速
fun adaptiveDecodeSpeed(health: BufferHealth): Float {
return when (health) {
BufferHealth.UNDERFLOW -> 1.2f // 加速解碼
BufferHealth.OVERFLOW -> 0.8f // 減速解碼
BufferHealth.NORMAL -> 1.0f
}
}
}三、ExoPlayer(Media3):工程級(jí)播放器框架
3.1 ExoPlayer vs MediaCodec:何時(shí)選擇
小場(chǎng)景 → MediaPlayer + SurfaceView(一行代碼)
需要自定義解碼流程 → MediaCodec 裸 API
需要:直播(HLS/DASH/RTMP)、多音軌、DRM、縮略圖、
預(yù)加載、播放器狀態(tài)機(jī) → ExoPlayer (Media3)
3.2 ExoPlayer(Media3)架構(gòu)
┌─────────────────────────────────────────────────────────────┐ │ Media3 ExoPlayer │ │ │ │ ┌────────────────────────────────────────────────────────┐ │ │ │ Player UI(PlayerView) │ │ │ └────────────────────────────────────────────────────────┘ │ │ │ │ │ ┌───────────────────────────┼───────────────────────────┐ │ │ │ ExoPlayer 核心 │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐ │ │ │ │ │ MediaSource │ │ TrackSelector │ │LoadControl │ │ │ │ │ │ (數(shù)據(jù)源管理) │ │ (軌道選擇) │ │(緩沖控制) │ │ │ │ │ └──────────────┘ └──────────────┘ └─────────────┘ │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐ │ │ │ │ │ Renderers │ │ MediaCodec │ │ Clock/Sync │ │ │ │ │ │ (渲染器) │ │ (解碼器) │ │(時(shí)鐘同步) │ │ │ │ │ └──────────────┘ └──────────────┘ └─────────────┘ │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ │ ┌───────────────────────────┼───────────────────────────┐ │ │ │ 數(shù)據(jù)源層(MediaSource) │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ │ │Progressive│ │ HlsMediaSource│ │DashMediaSource│ │RtspMediaSource│ │ │ │ │(普通文件)│ │(HTTP直播流) │ │(自適應(yīng)流) │ │(實(shí)時(shí)流) │ │ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ │ └───────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘
3.3 ExoPlayer 快速上手
3.3.1 依賴引入(Media3 / ExoPlayer 2.19+)
// build.gradle.kts (Module)
dependencies {
// Media3 ExoPlayer(推薦)
implementation("androidx.media3:media3-exoplayer:1.2.1")
implementation("androidx.media3:media3-ui:1.2.1") // 播放器控件
implementation("androidx.media3:media3-exoplayer-hls:1.2.1") // HLS 支持
implementation("androidx.media3:media3-exoplayer-dash:1.2.1") // DASH 支持
implementation("androidx.media3:media3-exoplayer-rtsp:1.2.1") // RTSP 支持
implementation("androidx.media3:media3-session:1.2.1") // MediaSession
}3.3.2 基礎(chǔ)播放(10 行代碼)
class MainActivity : AppCompatActivity() {
private var player: ExoPlayer? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// ===== 1. 創(chuàng)建 Player =====
player = ExoPlayer.Builder(this).build().also { exoPlayer ->
// ===== 2. 綁定視圖 =====
findViewById<PlayerView>(R.id.playerView).also { pv ->
pv.player = exoPlayer
pv.useController = true // 顯示播放控制條
}
// ===== 3. 創(chuàng)建 MediaItem(數(shù)據(jù)源)=====
val mediaItem = MediaItem.Builder()
.setUri("https://www.w3schools.com/html/mov_bbb.mp4")
// 或本地文件:Uri.parse("file:///sdcard/video.mp4")
.setMimeType("video/mp4")
.build()
// ===== 4. 設(shè)置媒體并播放 =====
exoPlayer.setMediaItem(mediaItem)
exoPlayer.prepare()
exoPlayer.playWhenReady = true // 準(zhǔn)備好后自動(dòng)播放
}
}
override fun onStart() {
super.onStart()
player?.playWhenReady = player?.playWhenReady ?: true
}
override fun onStop() {
super.onStop()
player?.playWhenReady = false // 切后臺(tái)暫停
}
override fun onDestroy() {
super.onDestroy()
player?.release() // 務(wù)必釋放!
player = null
}
}3.3.3 XML 布局(PlayerView)
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.media3.ui.PlayerView
android:id="@+id/playerView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:show_buffering="when_playing" <!-- 播放時(shí)顯示緩沖 -->
app:use_controller="true" <!-- 顯示控制條 -->
app:controller_layout_id="@layout/exo_player_control_view" <!-- 自定義控制條 -->
app:resize_mode="fit" <!-- 自適應(yīng) -->
app:surface_type="surface_view" <!-- SurfaceView(性能優(yōu)先)-->
/>
</androidx.constraintlayout.widget.ConstraintLayout>3.4 ExoPlayer 進(jìn)階配置
3.4.1 自定義 LoadControl(緩沖控制)
val loadControl = DefaultLoadControl.Builder()
.setAllocator(DefaultAllocator(true, 32))
.setBufferDurationsMs(
/* minBufferMs = */ 15_000, // 最小緩沖 15 秒
/* maxBufferMs = */ 50_000, // 最大緩沖 50 秒
/* bufferForPlaybackMs = */ 2_500, // 開(kāi)始播放需緩沖 2.5 秒
/* bufferForPlaybackAfterRebufferMs = */ 5_000 // 重緩沖需緩沖 5 秒
)
.setTargetBufferBytes(25 * 1024 * 1024) // 目標(biāo)緩沖 25 MB
.build()
val player = ExoPlayer.Builder(context)
.setLoadControl(loadControl)
.build()3.4.2 軌道選擇(多音軌/多字幕)
// 監(jiān)聽(tīng)軌道變化
player.addListener(object : Player.Listener {
override fun onTracksChanged(tracks: Tracks) {
for (trackGroup in tracks.trackGroups) {
for (i in 0 until trackGroup.length) {
val format = trackGroup.getFormat(i)
Log.d("Track", "軌道 ${trackGroup.type}: ${format.sampleMimeType} "
+ "${format.width}x${format.height} @ ${format.bitrate}")
}
}
}
})
// 手動(dòng)選擇軌道
val trackSelector = DefaultTrackSelector(context)
trackSelector.setParameters(
trackSelector.buildUponParameters()
.setPreferredTextLanguage("zh") // 首選中文字幕
.setPreferredAudioLanguage("zh-CN") // 首選中文音頻
.setMaxVideoSizeSd() // 最大 DVD 畫(huà)質(zhì)(省流量)
)3.4.3 HLS 直播流播放
// HLS 直播流(自動(dòng)處理 #EXTINF 和分片下載)
val hlsMediaItem = MediaItem.Builder()
.setUri("https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build()
player.setMediaItem(hlsMediaItem)
player.prepare()
// DASH 直播流
val dashMediaItem = MediaItem.Builder()
.setUri("https://storage.googleapis.com/shaka-demo-assets/angel-one/dash.mpd")
.setMimeType(MimeTypes.APPLICATION_DASH)
.build()3.4.4 播放事件監(jiān)聽(tīng)
player.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
when (state) {
Player.STATE_IDLE -> Log.d("Player", "空閑")
Player.STATE_BUFFERING -> Log.d("Player", "緩沖中...")
Player.STATE_READY -> Log.d("Player", "就緒")
Player.STATE_ENDED -> Log.d("Player", "播放結(jié)束")
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
Log.d("Player", "播放狀態(tài): ${if (isPlaying) "播放中" else "暫停"}")
}
override fun onPlayerError(error: PlaybackException) {
Log.e("Player", "播放錯(cuò)誤: ${error.errorCode}")
when (error.errorCode) {
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED ->
showRetryDialog("網(wǎng)絡(luò)錯(cuò)誤")
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED ->
showRetryDialog("解碼器初始化失敗")
}
}
override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
) {
Log.d("Player", "位置不連續(xù): ${oldPosition.positionMs} → ${newPosition.positionMs}")
}
})3.5 ExoPlayer 完整 Demo 項(xiàng)目結(jié)構(gòu)
app/
├── build.gradle.kts
└── src/main/
├── AndroidManifest.xml
├── java/com/example/androidplayer/
│ ├── MainActivity.kt # 主播放器界面
│ ├── player/
│ │ ├── PlayerManager.kt # 播放器管理器(單例)
│ │ ├── CustomPlayerActivity.kt # 自定義播放器(不用 PlayerView)
│ │ ├── HlsPlayerActivity.kt # HLS 直播播放器
│ │ └── PlayerService.kt # 后臺(tái)播放服務(wù)(MediaSession)
│ ├── decoder/
│ │ ├── MediaCodecDecoder.kt # 裸 MediaCodec 解碼示例
│ │ └── SurfaceRenderer.kt # Surface 渲染輔助
│ └── utils/
│ └── BufferMonitor.kt # 緩沖監(jiān)控工具
└── res/
├── layout/
│ ├── activity_main.xml # 基礎(chǔ) ExoPlayer 布局
│ ├── activity_custom_player.xml # 自定義布局
│ └── activity_hls_player.xml # HLS 直播布局
└── values/strings.xml四、裸 MediaCodec 解碼器:手把手實(shí)現(xiàn)
4.1 完整的 MediaCodec 解碼流程
以下代碼展示如何使用 MediaCodec 裸 API 解碼 MP4 文件并渲染到 Surface,不依賴 ExoPlayer:
/**
* MediaCodecVideoDecoder:裸 MediaCodec 解碼器
* 支持:H.264 / H.265 / VP8 / VP9
*/
class MediaCodecVideoDecoder(
private val context: Context,
private val surface: Surface
) {
private var decoder: MediaCodec? = null
private var extractor: MediaExtractor? = null
private var videoTrackIndex = -1
private var isRunning = false
private var thread: Thread? = null
// 幀率控制
private var frameIntervalUs = 33_333L // 30fps ≈ 33333us 每幀
/**
* 解碼主循環(huán)
*/
fun start(filePath: String) {
if (isRunning) return
isRunning = true
thread = Thread {
try {
// ===== 步驟 1:解復(fù)用 =====
val ext = MediaExtractor()
ext.setDataSource(filePath)
extractor = ext
// ===== 步驟 2:找到視頻軌道 =====
for (i in 0 until ext.trackCount) {
val format = ext.getTrackFormat(i)
val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
if (mime.startsWith("video/")) {
ext.selectTrack(i)
videoTrackIndex = i
// ===== 步驟 3:創(chuàng)建解碼器 =====
val mimeType = mime
decoder = MediaCodec.createDecoderByType(mimeType)
// ===== 步驟 4:配置解碼器(關(guān)鍵?。?====
// configure 的第三個(gè)參數(shù) surface:將解碼輸出直接送入 Surface
decoder!!.configure(format, surface, null, 0)
decoder!!.start()
break
}
}
if (videoTrackIndex < 0 || decoder == null) {
throw IllegalStateException("未找到視頻軌道或無(wú)法創(chuàng)建解碼器")
}
// ===== 步驟 5:解碼循環(huán) =====
decodeLoop(ext, decoder!!)
} catch (e: Exception) {
Log.e("Decoder", "解碼錯(cuò)誤", e)
} finally {
release()
}
}
thread?.start()
}
private fun decodeLoop(ext: MediaExtractor, dec: MediaCodec) {
val bufferInfo = MediaCodec.BufferInfo()
var inputDone = false
var outputDone = false
while (!outputDone && isRunning) {
// ===== A. 喂入壓縮數(shù)據(jù)(生產(chǎn)者端)=====
if (!inputDone) {
val inputBufIndex = dec.dequeueInputBuffer(10_000) // 10ms 超時(shí)
when {
inputBufIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> {
// 無(wú)可用輸入緩沖,稍后再試
}
inputBufIndex >= 0 -> {
val inputBuf = dec.getInputBuffer(inputBufIndex)!!
val sampleSize = ext.readSampleData(inputBuf, 0)
if (sampleSize < 0) {
// 流結(jié)束,發(fā)送 EOS
dec.queueInputBuffer(
inputBufIndex, 0, 0, 0,
MediaCodec.BUFFER_FLAG_END_OF_STREAM
)
inputDone = true
Log.d("Decoder", "Input EOS sent")
} else {
// 寫入壓縮數(shù)據(jù)及 PTS
dec.queueInputBuffer(
inputBufIndex, 0, sampleSize,
ext.sampleTime, // PTS(微秒)
0
)
ext.advance()
}
}
}
}
// ===== B. 取出解碼數(shù)據(jù)(消費(fèi)者端)=====
val outputBufIndex = dec.dequeueOutputBuffer(bufferInfo, 10_000)
when {
outputBufIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> {
// 無(wú)可用輸出
}
outputBufIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
val newFormat = dec.outputFormat
Log.d("Decoder", "輸出格式: $newFormat")
// 可在此獲取輸出寬高
val outWidth = newFormat.getInteger(MediaFormat.KEY_WIDTH)
val outHeight = newFormat.getInteger(MediaFormat.KEY_HEIGHT)
Log.d("Decoder", "輸出分辨率: ${outWidth}x${outHeight}")
}
outputBufIndex >= 0 -> {
val render = (bufferInfo.size != 0)
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
outputDone = true
Log.d("Decoder", "Output EOS received")
}
// ===== 步驟 6:渲染到 Surface =====
// 第二個(gè)參數(shù) = true → 按 PTS 定時(shí)渲染(音畫(huà)同步核心)
// false → 立即渲染
dec.releaseOutputBuffer(outputBufIndex, render)
if (render) {
// 記錄幀信息
val frameMs = bufferInfo.presentationTimeUs / 1000
// Log.v("Decoder", "Frame @ ${frameMs}ms")
}
}
}
}
}
fun stop() {
isRunning = false
thread?.join(2000)
}
fun release() {
isRunning = false
try {
decoder?.stop()
decoder?.release()
decoder = null
extractor?.release()
extractor = null
} catch (e: Exception) {
Log.e("Decoder", "釋放錯(cuò)誤", e)
}
}
}4.2 帶音畫(huà)同步的完整播放器
/**
* SimpleMediaPlayer:帶音畫(huà)同步的完整播放器
* 使用 MediaCodec 裸 API 實(shí)現(xiàn)
*/
class SimpleMediaPlayer(
private val context: Context,
private val surfaceView: SurfaceView
) {
private var videoDecoder: MediaCodec? = null
private var audioTrack: AudioTrack? = null
private var extractor: MediaExtractor? = null
private var videoThread: Thread? = null
private var audioThread: Thread? = null
private var isRunning = AtomicBoolean(false)
private val videoStartTimeUs = AtomicLong(0)
// 視頻緩沖隊(duì)列
private val videoFrameQueue = LinkedBlockingQueue<Frame>(5)
// 音頻采樣隊(duì)列
private val audioSampleQueue = LinkedBlockingQueue<AudioSample>(50)
data class Frame(val data: ByteBuffer, val ptsUs: Long, val size: Int)
data class AudioSample(val data: ByteBuffer, val ptsUs: Long, val size: Int)
fun play(filePath: String) {
if (isRunning.get()) return
isRunning.set(true)
// 初始化 Extractor
val ext = MediaExtractor()
ext.setDataSource(filePath)
extractor = ext
// 分別啟動(dòng)音視頻解碼線程
videoThread = Thread { decodeVideo(ext, surfaceView.holder.surface) }
audioThread = Thread { playAudio(ext) }
videoThread?.start()
audioThread?.start()
}
private fun decodeVideo(ext: MediaExtractor, surface: Surface) {
// 找到視頻軌道
var trackIndex = -1
var format: MediaFormat? = null
for (i in 0 until ext.trackCount) {
val f = ext.getTrackFormat(i)
val mime = f.getString(MediaFormat.KEY_MIME) ?: continue
if (mime.startsWith("video/")) {
ext.selectTrack(i)
trackIndex = i
format = f
break
}
}
if (trackIndex < 0 || format == null) return
// 創(chuàng)建解碼器
val mime = format.getString(MediaFormat.KEY_MIME)!!
videoDecoder = MediaCodec.createDecoderByType(mime)
videoDecoder!!.configure(format, surface, null, 0)
videoDecoder!!.start()
val bufferInfo = MediaCodec.BufferInfo()
var inputDone = false
while (isRunning.get()) {
// 喂入數(shù)據(jù)
if (!inputDone) {
val bufIdx = videoDecoder!!.dequeueInputBuffer(10_000)
if (bufIdx >= 0) {
val buf = videoDecoder!!.getInputBuffer(bufIdx)!!
val size = ext.readSampleData(buf, 0)
if (size < 0) {
videoDecoder!!.queueInputBuffer(bufIdx, 0, 0, 0,
MediaCodec.BUFFER_FLAG_END_OF_STREAM)
inputDone = true
} else {
videoDecoder!!.queueInputBuffer(bufIdx, 0, size,
ext.sampleTime, 0)
ext.advance()
}
}
}
// 取出渲染
val outIdx = videoDecoder!!.dequeueOutputBuffer(bufferInfo, 10_000)
if (outIdx >= 0) {
// 設(shè)置 PTS 參考時(shí)間(第一幀時(shí)記錄)
if (videoStartTimeUs.get() == 0L && bufferInfo.size > 0) {
videoStartTimeUs.set(System.nanoTime() / 1000 - bufferInfo.presentationTimeUs)
}
val renderTimeUs = videoStartTimeUs.get() + bufferInfo.presentationTimeUs
val currentTimeUs = System.nanoTime() / 1000
val sleepUs = renderTimeUs - currentTimeUs
if (sleepUs > 0) {
// 音畫(huà)同步:視頻等待音頻
Thread.sleep(sleepUs / 1000, (sleepUs % 1000).toInt() * 1000)
}
val doRender = (bufferInfo.size != 0)
videoDecoder!!.releaseOutputBuffer(outIdx, doRender)
}
}
}
private fun playAudio(ext: MediaExtractor) {
// 找到音頻軌道
var trackIndex = -1
var format: MediaFormat? = null
for (i in 0 until ext.trackCount) {
val f = ext.getTrackFormat(i)
val mime = f.getString(MediaFormat.KEY_MIME) ?: continue
if (mime.startsWith("audio/")) {
ext.selectTrack(i)
trackIndex = i
format = f
break
}
}
if (trackIndex < 0 || format == null) return
val sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
val channelCount = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
// 創(chuàng)建 AudioTrack
val channelMask = if (channelCount == 1) AudioFormat.CHANNEL_OUT_MONO
else AudioFormat.CHANNEL_OUT_STEREO
val bufSize = AudioTrack.getMinBufferSize(sampleRate, channelMask,
AudioFormat.ENCODING_PCM_16BIT)
audioTrack = AudioTrack.Builder()
.setAudioFormat(AudioFormat.Builder()
.setSampleRate(sampleRate)
.setChannelMask(channelMask)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.build())
.setBufferSizeInBytes(bufSize * 2)
.setTransferMode(AudioTrack.MODE_STREAM)
.build()
audioTrack!!.play()
// 音頻解碼(使用 MediaCodec)
val mime = format.getString(MediaFormat.KEY_MIME)!!
val audioDecoder = MediaCodec.createDecoderByType(mime)
audioDecoder.configure(format, null, null, 0)
audioDecoder.start()
val bufferInfo = MediaCodec.BufferInfo()
val audioBuf = ByteArray(4096)
var inputDone = false
while (isRunning.get()) {
if (!inputDone) {
val bufIdx = audioDecoder.dequeueInputBuffer(10_000)
if (bufIdx >= 0) {
val buf = audioDecoder.getInputBuffer(bufIdx)!!
val size = ext.readSampleData(buf, 0)
if (size < 0) {
audioDecoder.queueInputBuffer(bufIdx, 0, 0, 0,
MediaCodec.BUFFER_FLAG_END_OF_STREAM)
inputDone = true
} else {
audioDecoder.queueInputBuffer(bufIdx, 0, size,
ext.sampleTime, 0)
ext.advance()
}
}
}
val outIdx = audioDecoder.dequeueOutputBuffer(bufferInfo, 10_000)
if (outIdx >= 0) {
val buf = audioDecoder.getOutputBuffer(outIdx)!!
buf.get(audioBuf, 0, bufferInfo.size)
audioTrack?.write(audioBuf, 0, bufferInfo.size)
audioDecoder.releaseOutputBuffer(outIdx, false)
}
}
audioDecoder.stop()
audioDecoder.release()
audioTrack?.stop()
audioTrack?.release()
}
fun stop() {
isRunning.set(false)
videoThread?.join(2000)
audioThread?.join(2000)
}
}五、實(shí)戰(zhàn):完整可運(yùn)行的 Demo 項(xiàng)目
5.1 項(xiàng)目配置
文件:app/build.gradle.kts(完整依賴)
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.example.androidplayer"
compileSdk = 34
defaultConfig {
applicationId = "com.example.androidplayer"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
// AndroidX Core
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
// Media3 / ExoPlayer(推薦使用 Media3)
implementation("androidx.media3:media3-exoplayer:1.2.1")
implementation("androidx.media3:media3-ui:1.2.1")
implementation("androidx.media3:media3-exoplayer-hls:1.2.1")
implementation("androidx.media3:media3-exoplayer-dash:1.2.1")
implementation("androidx.media3:media3-exoplayer-rtsp:1.2.1")
implementation("androidx.media3:media3-session:1.2.1")
// Lifecycle
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// Testing
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}5.2 AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- 網(wǎng)絡(luò)權(quán)限(播放 HTTP 流媒體)-->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- 存儲(chǔ)權(quán)限(播放本地文件)-->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<!-- SurfaceView 硬件加速(默認(rèn)開(kāi)啟)-->
<uses-feature
android:name="android.hardware.surface.virtual"
android:required="false" />
<uses-feature
android:name="android.hardware.microphone"
android:required="false" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="AndroidPlayer"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.AndroidPlayer"
android:hardwareAccelerated="true"
tools:targetApi="34">
<!-- 主播放器 Activity -->
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:exported="true"
android:screenOrientation="landscape"
android:theme="@style/Theme.AndroidPlayer.Fullscreen">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- 自定義播放器(裸 MediaCodec) -->
<activity
android:name=".player.CustomPlayerActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:screenOrientation="landscape" />
<!-- HLS 直播播放器 -->
<activity
android:name=".player.HlsPlayerActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:screenOrientation="landscape" />
</application>
</manifest>5.3 MainActivity:ExoPlayer 基礎(chǔ)播放
package com.example.androidplayer
import android.Manifest
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.ui.PlayerView
import com.example.androidplayer.databinding.ActivityMainBinding
/**
* MainActivity:ExoPlayer 基礎(chǔ)播放器
* 展示最簡(jiǎn)單的 ExoPlayer 集成方式
*/
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private var player: ExoPlayer? = null
// ===== 示例媒體 URL =====
private val sampleVideos = listOf(
// Sintel 開(kāi)放電影(MP4)
MediaItem.Builder()
.setUri("https://download.sintel.org/sintel.mp4")
.setMimeType("video/mp4")
.build(),
// Big Buck Bunny
MediaItem.Builder()
.setUri("https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4")
.setMimeType("video/mp4")
.build(),
// Tears of Steel
MediaItem.Builder()
.setUri("https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/TearsOfSteel.mp4")
.setMimeType("video/mp4")
.build(),
)
private var currentVideoIndex = 0
// 文件選擇器
private val pickFileLauncher = registerForActivityResult(
ActivityResultContracts.GetContent()
) { uri: Uri? ->
uri?.let { playUri(it) }
}
// 權(quán)限請(qǐng)求
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
openFilePicker()
} else {
Toast.makeText(this, "需要存儲(chǔ)權(quán)限以打開(kāi)本地文件", Toast.LENGTH_SHORT).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setupClickListeners()
}
private fun setupClickListeners() {
// 點(diǎn)擊屏幕:選擇視頻
binding.playerView.setOnClickListener {
showVideoSelector()
}
// 雙擊:切換下一個(gè)視頻
binding.playerView.setOnClickListener { /* prevent double-listener */ }
}
private fun showVideoSelector() {
val titles = sampleVideos.mapIndexed { index, item ->
when (index) {
0 -> "Sintel (開(kāi)放電影)"
1 -> "Big Buck Bunny"
2 -> "Tears of Steel"
else -> "視頻 ${index + 1}"
}
}.toMutableList().apply { add("打開(kāi)本地文件...") }
android.app.AlertDialog.Builder(this)
.setTitle("選擇視頻")
.setItems(titles.toTypedArray()) { _, which ->
if (which < sampleVideos.size) {
playMediaItem(sampleVideos[which])
} else {
requestStoragePermission()
}
}
.show()
}
private fun requestStoragePermission() {
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
// Android 13+ 使用 READ_MEDIA_VIDEO
if (ContextCompat.checkSelfPermission(this,
Manifest.permission.READ_MEDIA_VIDEO) == PackageManager.PERMISSION_GRANTED) {
openFilePicker()
} else {
permissionLauncher.launch(Manifest.permission.READ_MEDIA_VIDEO)
}
}
else -> {
if (ContextCompat.checkSelfPermission(this,
Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) {
openFilePicker()
} else {
permissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
}
}
private fun openFilePicker() {
pickFileLauncher.launch("video/*")
}
private fun playMediaItem(mediaItem: MediaItem) {
ensurePlayerReady()
player?.setMediaItem(mediaItem)
player?.prepare()
player?.playWhenReady = true
}
private fun playUri(uri: Uri) {
ensurePlayerReady()
val item = MediaItem.Builder()
.setUri(uri)
.build()
player?.setMediaItem(item)
player?.prepare()
player?.playWhenReady = true
}
private fun ensurePlayerReady() {
if (player == null) {
initializePlayer()
}
}
private fun initializePlayer() {
// 軌道選擇器(自動(dòng)選擇最佳質(zhì)量)
val trackSelector = DefaultTrackSelector(this)
trackSelector.setParameters(
trackSelector.buildUponParameters()
.setMaxVideoSizeHd() // 最大 720p(省流量)
)
// 渲染工廠(優(yōu)先使用硬件解碼)
val renderersFactory = DefaultRenderersFactory(this)
.setEnableDecoderFallback(false)
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER)
player = ExoPlayer.Builder(this, renderersFactory)
.setTrackSelector(trackSelector)
.setSeekForwardIncrementMs(10_000) // 快進(jìn) 10 秒
.setSeekBackIncrementMs(10_000) // 快退 10 秒
.build()
.also { exo ->
binding.playerView.player = exo
binding.playerView.useController = true
// 播放事件監(jiān)聽(tīng)
exo.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
val stateName = when (state) {
Player.STATE_IDLE -> "空閑"
Player.STATE_BUFFERING -> "緩沖中"
Player.STATE_READY -> "就緒"
Player.STATE_ENDED -> "播放結(jié)束"
else -> "未知"
}
Toast.makeText(this@MainActivity, "播放器: $stateName",
Toast.LENGTH_SHORT).show()
}
override fun onPlayerError(error: PlaybackException) {
val msg = when (error.errorCode) {
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED ->
"網(wǎng)絡(luò)連接失敗"
PlaybackException.ERROR_CODE_IO_BAD_URI ->
"無(wú)效的媒體地址"
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED ->
"解碼器初始化失敗"
PlaybackException.ERROR_CODE_TIMEOUT ->
"網(wǎng)絡(luò)超時(shí)"
else -> "播放錯(cuò)誤: ${error.errorCodeName}"
}
Toast.makeText(this@MainActivity, msg, Toast.LENGTH_LONG).show()
}
})
}
// 自動(dòng)播放第一個(gè)視頻
playMediaItem(sampleVideos[0])
}
override fun onStart() {
super.onStart()
if (player == null) {
initializePlayer()
}
}
override fun onResume() {
super.onResume()
player?.playWhenReady = player?.playWhenReady ?: true
}
override fun onPause() {
super.onPause()
// 播放狀態(tài)自動(dòng)保存
}
override fun onStop() {
super.onStop()
player?.playWhenReady = false
}
override fun onDestroy() {
super.onDestroy()
releasePlayer()
}
private fun releasePlayer() {
player?.release()
player = null
}
}5.4 activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black">
<androidx.media3.ui.PlayerView
android:id="@+id/playerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:show_buffering="when_playing"
app:use_controller="true"
app:resize_mode="fit"
app:surface_type="surface_view"
app:controller_layout_id="@layout/exo_player_control_view" />
<!-- 加載指示器 -->
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
<!-- 提示文字 -->
<TextView
android:id="@+id/hintText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:padding="16dp"
android:text="點(diǎn)擊選擇視頻"
android:textColor="@android:color/white"
android:textSize="16sp" />
</FrameLayout>5.5 CustomPlayerActivity:裸 MediaCodec 播放
package com.example.androidplayer.player
import android.os.Bundle
import android.util.Log
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.example.androidplayer.databinding.ActivityCustomPlayerBinding
/**
* CustomPlayerActivity:使用裸 MediaCodec API 的自定義播放器
* 展示完整的解復(fù)用 → 解碼 → 渲染流程
* 對(duì)比 ExoPlayer 方案:代碼量更大,但完全可控
*/
class CustomPlayerActivity : AppCompatActivity(), SurfaceHolder.Callback2 {
private lateinit var binding: ActivityCustomPlayerBinding
private var decoder: MediaCodecVideoDecoder? = null
private var videoPath: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityCustomPlayerBinding.inflate(layoutInflater)
setContentView(binding.root)
// 從 Intent 獲取視頻路徑
videoPath = intent.getStringExtra("video_path")
setupUI()
setupSurface()
}
private fun setupUI() {
binding.surfaceView.holder.addCallback(this)
// 全屏
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
)
}
private fun setupSurface() {
binding.surfaceView.holder.setFormat(android.graphics.PixelFormat.RGBA_8888)
}
// ===== SurfaceHolder.Callback2 實(shí)現(xiàn) =====
override fun surfaceCreated(holder: SurfaceHolder) {
Log.d("CustomPlayer", "Surface created, starting decoder...")
startPlayback(holder.surface)
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int,
width: Int, height: Int) {
Log.d("CustomPlayer", "Surface changed: ${width}x${height}")
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
Log.d("CustomPlayer", "Surface destroyed, stopping decoder...")
stopPlayback()
}
override fun surfaceRedrawNeeded(holder: SurfaceHolder) {
// 不需要額外處理
}
private fun startPlayback(surface: android.view.Surface) {
val path = videoPath
if (path == null) {
Toast.makeText(this, "未指定視頻路徑", Toast.LENGTH_SHORT).show()
finish()
return
}
// 啟動(dòng)裸 MediaCodec 解碼器
decoder = MediaCodecVideoDecoder(this, surface)
decoder?.start(path)
}
private fun stopPlayback() {
decoder?.stop()
decoder?.release()
decoder = null
}
override fun onPause() {
super.onPause()
stopPlayback()
}
override fun onDestroy() {
super.onDestroy()
stopPlayback()
}
}5.6 activity_custom_player.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black">
<!-- SurfaceView:性能最優(yōu)的視頻渲染方式 -->
<SurfaceView
android:id="@+id/surfaceView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<!-- 進(jìn)度顯示 -->
<TextView
android:id="@+id/statusText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|start"
android:padding="16dp"
android:textColor="@android:color/white"
android:textSize="12sp"
android:background="#80000000"
android:text="解碼中..." />
</FrameLayout>六、常見(jiàn)問(wèn)題與調(diào)試技巧
6.1 解碼器創(chuàng)建失敗
// 常見(jiàn)原因 1:未找到對(duì)應(yīng) Mime 的解碼器
// 解決:先查詢可用解碼器
fun listAvailableCodecs() {
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
for (info in codecList.codecInfos) {
if (!info.isEncoder) {
Log.d("Codec", "Decoder: ${info.name}")
for (type in info.supportedTypes) {
Log.d("Codec", " - $type")
}
}
}
}
// 常見(jiàn)原因 2:Surface 已銷毀后嘗試配置解碼器
// 解決:在 Surface 可用時(shí)再配置
// 常見(jiàn)原因 3:Android 10+ 作用域存儲(chǔ)限制
// 解決:使用 SAF (Storage Access Framework)
private val pickerLauncher = registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let {
// 持久化權(quán)限
contentResolver.takePersistableUriPermission(
it, Intent.FLAG_GRANT_READ_URI_PERMISSION)
playUri(it)
}
}6.2 EGL 初始化失?。═extureView)
// TextureView 需要 EGL 支持,在部分設(shè)備上可能失敗
// 解決:降級(jí)到 SurfaceView
try {
val surfaceTexture = textureView.surfaceTexture ?: return
surfaceTexture.setDefaultBufferSize(videoWidth, videoHeight)
val surface = Surface(surfaceTexture)
// 配置解碼器
} catch (e: Exception) {
Log.e("EGL", "TextureView EGL 失敗,降級(jí)到 SurfaceView", e)
switchToSurfaceView()
}6.3 音畫(huà)不同步
// 診斷:打印 PTS vs 實(shí)際渲染時(shí)間
Log.d("Sync", "Frame PTS: ${ptsUs}us, "
+ "Render delay: ${renderDelayUs}us, "
+ "Total buffer: ${totalBufferedMs}ms")
// 常見(jiàn)原因:
// 1. 視頻關(guān)鍵幀間隔太大 → 減少 I_FRAME_INTERVAL
// 2. 緩沖時(shí)間太長(zhǎng) → 減少 bufferForPlaybackMs
// 3. SurfaceView vs TextureView 延遲差異 → 使用 SurfaceView
6.4 解碼器緩沖區(qū)溢出/下溢
// 緩沖區(qū)溢出(Output Buffer 積壓): // 原因:渲染速度 < 解碼速度 // 解決:增加 Surface 緩沖,或加速渲染 // 緩沖區(qū)下溢(Input Buffer 不足): // 原因:網(wǎng)絡(luò)卡頓 / 文件讀取慢 // 解決:增加網(wǎng)絡(luò)緩沖,或使用 ExoPlayer 的 AdaptiveLoadControl
6.5 使用 adb 調(diào)試播放
# 查看 MediaCodec 狀態(tài) adb shell dumpsys media.codec # 查看 SurfaceFlinger 層 adb shell dumpsys SurfaceFlinger # 查看 MediaPlayer 服務(wù) adb shell dumpsys media.player # 查看 ExoPlayer 日志(需要開(kāi)啟調(diào)試模式) # 在代碼中:ExoPlayer.setLogLevel(Util.SDK_INT >= 21 ? Log.VERBOSE : Log.WARN) adb logcat | grep -i "ExoPlayer\|MediaCodec\|Decoder" # 抓取視頻幀進(jìn)行驗(yàn)證 adb shell screencap /sdcard/frame.png adb pull /sdcard/frame.png
七、性能優(yōu)化與進(jìn)階方向
7.1 性能對(duì)比參考
| 渲染方式 | 內(nèi)存占用 | CPU 占用 | 渲染延遲 | 適合場(chǎng)景 |
|---|---|---|---|---|
| SurfaceView | 低 | 低 | ~1 幀 | 直播、大屏播放器 |
| TextureView | 中 | 中 | ~2 幀 | 需 View 變換/截圖 |
| GLSurfaceView | 高 | 高 | ~3 幀 | 視頻濾鏡/OpenGL 特效 |
| ExoPlayer (默認(rèn)) | 中 | 低 | ~1 幀 | 工程級(jí)播放器 |
7.2 進(jìn)階功能速覽
| 功能 | 實(shí)現(xiàn)方式 | 參考 |
|---|---|---|
| 視頻截圖 | textureView.bitmap / SurfaceView.lockCanvas() | Android 官方 |
| 倍速播放 | player.setPlaybackParameters(PlaybackParameters(2.0f)) | ExoPlayer |
| 多音軌切換 | trackSelector.setParameters(...) | ExoPlayer |
| DRM 加密播放 | MediaDrmCallback | ExoPlayer Widevine |
| 后臺(tái)播放 | MediaSessionService + MediaSession | Media3 |
| 投屏/CAST | MediaRouteButton + Presentation | Android Presentation |
| 視頻濾鏡 | GLSurfaceView + OpenGL ES 2.0 | GPUImage Android |
| 硬件解碼器選擇 | MediaCodecList 遍歷 + isHardwareAccelerated() | CodecCapabilities |
7.3 ExoPlayer 后臺(tái)播放 + MediaSession
/**
* PlayerService:后臺(tái)播放服務(wù)
* 支持通知欄控制、耳機(jī)線控、藍(lán)牙控制
*/
class PlayerService : MediaSessionService() {
private var mediaSession: MediaSession? = null
private lateinit var player: ExoPlayer
override fun onCreate() {
super.onCreate()
player = ExoPlayer.Builder(this).build()
mediaSession = MediaSession.Builder(this, player)
.setCallback(MediaSessionCallback())
.build()
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
return mediaSession
}
override fun onTaskRemoved(rootIntent: Intent?) {
if (!player.playWhenReady) {
stopSelf()
}
}
override fun onDestroy() {
mediaSession?.run {
player.release()
release()
mediaSession = null
}
}
private inner class MediaSessionCallback : MediaSession.Callback {
override fun onAddMediaItems(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
mediaItems: MutableList<MediaItem>
): com.google.common.util.concurrent.ListenableFuture<MutableList<MediaItem>> {
// 將 Uri 轉(zhuǎn)換為真實(shí)可播放的 MediaItem
val resolved = mediaItems.map { item ->
item.buildUpon()
.setUri(item.requestMetadata.mediaUri ?: item.localConfiguration?.uri)
.build()
}.toMutableList()
return Futures.immediateFuture(resolved)
}
}
}八、調(diào)試命令與工具速查
| 工具 | 命令/用途 | 示例 |
|---|---|---|
| adb dumpsys | 查看系統(tǒng)多媒體服務(wù)狀態(tài) | adb shell dumpsys media.codec |
| adb logcat | 過(guò)濾 ExoPlayer/MediaCodec 日志 | adb logcat -s ExoPlayerImpl:* MediaCodec:* |
| adb screencap | 截取當(dāng)前幀 | adb shell screencap /sdcard/frame.png |
| adb screenrecord | 錄制播放過(guò)程 | adb shell screenrecord --time-limit 10 /sdcard/play.mp4 |
| MediaCodecList | 查詢?cè)O(shè)備支持的編解碼器 | 代碼中遍歷 MediaCodecList |
| GPU Render | 調(diào)試 GPU 渲染 | 設(shè)置 → 開(kāi)發(fā)者 → GPU 渲染分析 |
| perfetto | 系統(tǒng)級(jí)性能追蹤 | adb shell perfetto -c config.txt -o /sdcard/trace.perfetto |
| net槐 | 網(wǎng)絡(luò)抓包 | Wireshark / mitmproxy 分析 HLS/DASH |
九、總結(jié)
9.1 學(xué)習(xí)路徑推薦
第一階段(1–2周): MediaPlayer + SurfaceView → 實(shí)現(xiàn)簡(jiǎn)單播放 ↓ 第二階段(2–4周): MediaExtractor + MediaCodec → 理解解復(fù)用和解碼原理 ↓ 第三階段(4–6周): ExoPlayer 基礎(chǔ)使用 → 掌握軌道選擇、緩沖控制 ↓ 第四階段(持續(xù)): ExoPlayer 深度定制 → MediaSession、后服務(wù)、DRM
9.2 關(guān)鍵知識(shí)點(diǎn)回顧
| 層次 | 核心概念 | 關(guān)鍵 API |
|---|---|---|
| 容器解析 | 軌道提取、壓縮數(shù)據(jù)讀取 | MediaExtractor |
| 解碼 | 編解碼器狀態(tài)機(jī)、輸入/輸出緩沖 | MediaCodec.configure/start/dequeue |
| 渲染 | Surface 生產(chǎn)者/消費(fèi)者、雙緩沖 | SurfaceView / TextureView |
| 同步 | PTS 驅(qū)動(dòng)、音畫(huà)對(duì)齊策略 | SyncController |
| 框架 | ExoPlayer 組件化、MediaSource | ExoPlayer + PlayerView |
| 后臺(tái) | MediaSession、通知欄控制 | MediaSessionService |
9.3 項(xiàng)目快速啟動(dòng)
# 1. 創(chuàng)建 Android Studio 項(xiàng)目 # File → New → New Project → Empty Activity # 2. 添加 Media3 依賴(見(jiàn) 5.1 節(jié)) # 3. 將 MainActivity.kt 和 activity_main.xml 復(fù)制到項(xiàng)目中 # 4. 運(yùn)行到設(shè)備(推薦真機(jī),模擬器性能有限)
以上就是從零構(gòu)建一個(gè)Android全功能視頻播放器的詳細(xì)內(nèi)容,更多關(guān)于Android視頻播放器的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Android中Activity的四種啟動(dòng)模式和onNewIntent()
android 中activity的啟動(dòng)模式分為四種,(standard、singleTop、singTask、singleInstance),本文通過(guò)實(shí)例代碼給大家介紹的非常詳細(xì),具有一定的參考借鑒價(jià)值,需要的朋友參考下吧2018-08-08
詳解Android運(yùn)行時(shí)權(quán)限及APP適配方法
本篇文章給大家詳細(xì)分析了Android運(yùn)行時(shí)權(quán)限及APP適配方法,并把重要知識(shí)點(diǎn)做了說(shuō)明,有需要的朋友參考下。2018-03-03
Android 使用手機(jī)NFC的讀取NFC標(biāo)簽數(shù)據(jù)的方法
這篇文章主要介紹了Android 使用手機(jī)NFC的讀取NFC標(biāo)簽數(shù)據(jù)的方法,小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2018-07-07
Android中監(jiān)聽(tīng)軟鍵盤輸入的使用方式
今天我們來(lái)討論一下Android中監(jiān)聽(tīng)軟鍵盤輸入的使用方式,它允許用戶輸入文本和執(zhí)行其他操作,但是,有時(shí)候我們需要在用戶輸入文本時(shí)進(jìn)行一些特殊的處理,比如實(shí)時(shí)驗(yàn)證輸入內(nèi)容、限制輸入字符的類型等,因此,了解如何監(jiān)聽(tīng)軟鍵盤輸入是非常重要的2023-10-10
Activity實(shí)例詳解之啟動(dòng)activity并返回結(jié)果
這篇文章主要介紹了Activity實(shí)例詳解之啟動(dòng)activity并返回結(jié)果 的相關(guān)資料,非常不錯(cuò),具有參考借鑒價(jià)值,需要的朋友可以參考下2016-09-09
Android修行手冊(cè)之ConstraintLayout布局使用詳解
這篇文章主要為大家介紹了Android修行手冊(cè)之ConstraintLayout使用示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-09-09
Android自定義谷歌風(fēng)格ProgressBar
這篇文章主要為大家詳細(xì)介紹了Android自定義谷歌風(fēng)格ProgressBar的相關(guān)資料,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-02-02
零基礎(chǔ)學(xué)習(xí)教程之Linux下搭建android開(kāi)發(fā)環(huán)境
這篇文章主要介紹了Linux下搭建android開(kāi)發(fā)環(huán)境,特別適合零基礎(chǔ)的同學(xué)學(xué)習(xí),想要在Linux及ubuntu11.10下配置android4.0.3開(kāi)發(fā)環(huán)境的朋友可以參考一下2015-12-12
Android實(shí)戰(zhàn)教程第九篇之短信高效備份
這篇文章主要為大家詳細(xì)介紹了Android實(shí)戰(zhàn)教程第九篇之短信高效備份,利用xml序列化器備份短信,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2016-11-11
Kotlin中實(shí)現(xiàn)多線程數(shù)據(jù)刷新的完整方案
這篇文章主要介紹了Kotlin中實(shí)現(xiàn)多線程數(shù)據(jù)刷新的完整方案,本文通過(guò)實(shí)例代碼給大家介紹的非常詳細(xì),感興趣的朋友一起看看吧2025-04-04

