Android 音频开发(二):MediaPlayer 与 Media3 ExoPlayer 播放实战

发布时间:2026/10/16 6:36:34

Android 音频开发(二):MediaPlayer 与 Media3 ExoPlayer 播放实战 前言上一篇我们从一条语音消息入手介绍了 PCM、采样率、位深度、声道数以及音频从采集到播放的基本流程。理解音频数据之后接下来就要解决一个实际问题如何在 Android 中播放声音在日常开发中音频播放看起来很简单。例如社交 App 点击一条语音消息开始播放。音乐 App 播放本地或网络歌曲。新闻 App 朗读文章内容。短视频 App 播放视频中的音频。直播、语音房应用播放背景音乐。如果只是让设备发出声音几行代码就能实现。但真正用于项目时还需要考虑网络音频尚未加载完成用户就点击了播放。用户连续点击不同音频导致多个声音同时播放。播放过程中切换页面播放器没有释放。网络加载失败界面一直显示正在播放。用户拖动进度条播放位置没有正确更新。耳机拔出或其他 App 开始播放音乐时如何处理音频这些问题通常都和播放器的状态管理、生命周期以及系统音频机制有关。本文将通过 MediaPlayer 和 Media3 ExoPlayer分别实现同一套音频播放功能比较两者的使用方式并分析实际开发中的注意事项。一、MediaPlayer 与 ExoPlayer 有什么区别1. MediaPlayerMediaPlayer 是 Android Framework 自带的媒体播放 API。它提供了常见的音频、视频播放能力不需要额外添加第三方依赖。例如val mediaPlayer MediaPlayer.create( context, R.raw.demo_audio ) mediaPlayer?.start()这段代码就能播放res/raw/demo_audio对应的音频文件。不过上面只是最简单的使用方式。当我们需要播放网络音频、切换文件、处理错误时就必须了解 MediaPlayer 的状态。例如Idle ↓ setDataSource() Initialized ↓ prepareAsync() Preparing ↓ onPrepared() Prepared ↓ start() Started ↓ pause() Paused ↓ start() Started ↓ 播放完成 PlaybackCompletedMediaPlayer 还包括 Stopped、Error、End 等状态上面只展示了常见的一条播放路径。需要特别注意MediaPlayer 并不是任何时候都能调用 start()、pause() 或 seekTo()。例如在prepareAsync()尚未完成时就直接调用start()可能引发状态异常。因此MediaPlayer 开发最重要的事情之一就是管理播放器状态。2. Media3 ExoPlayerExoPlayer 是 AndroidX Media3 中的播放器实现。它同样支持本地和网络音频但提供了更统一的播放接口、播放状态、事件监听以及播放列表等能力。最简单的播放流程如下val player ExoPlayer.Builder(context).build() player.setMediaItem( MediaItem.fromUri(audioUri) ) player.prepare() player.play()与 MediaPlayer 相比ExoPlayer 把播放器能力抽象到了Player接口中。例如player.play() player.pause() player.seekTo(10_000L)同时可以通过Player.Listener监听加载、缓冲、播放完成和错误等事件。对于需要长期维护的播放器业务这种设计通常更方便扩展。这里也需要区分两个概念ExoPlayer 不是音频格式也不是音频编码器。它是播放器。播放 AAC、MP3 等音频时仍然涉及数据读取、媒体容器解析、解码和 Android 底层音频输出机制。二、准备 Android 实战项目这一篇我们不做复杂 UI而是实现一个简单的播放器对比 Demo。功能包括MediaPlayer 与 ExoPlayer 切换。播放 App 内置的本地 MP3。输入 HTTPS 音频地址进行网络播放。播放、暂停、停止。显示当前播放状态。显示播放进度与总时长。拖动进度条跳转。处理加载失败和播放异常。页面退出时释放资源。1. 项目环境示例使用开发语言KotlinUI传统 Android View最低系统版本Android 6.0API 23Media31.11.1编译 SDK建议 36 或更高可用版本创建一个普通的 Android Views 项目即可。本文使用com.example.audioplayback作为包名实际项目请替换成自己的包名。2. 添加依赖在 App 模块的build.gradle.kts中添加dependencies { implementation( androidx.media3:media3-exoplayer:1.11.1 ) }本文只进行音频播放不需要视频播放器控件因此不必额外引入media3-ui。如果后续需要使用 HLS、DASH 等流媒体格式可以根据实际需求增加对应的 Media3 模块。注意 Media3 各模块应当使用相同版本。3. 配置网络权限在AndroidManifest.xml中声明uses-permission android:nameandroid.permission.INTERNET /本篇使用 HTTPS 网络地址不需要开放明文 HTTP 流量。内置res/raw音频文件以及网络音频播放也不需要申请读取设备音乐文件的媒体权限。如果后续需要读取用户媒体库则应根据 Android 版本和访问方式另行处理。4. 添加本地音频在项目中创建app/ └── src/ └── main/ └── res/ └── raw/ └── demo_audio.mp3demo_audio.mp3可以使用自己录制或有权使用的 MP3 文件。这里要注意放在res/raw中的 MP3 是带有音频编码格式的媒体文件而不是直接将裸 PCM 数据改名为.mp3。本地音频对应的资源 URI 可以这样构造val audioUri Uri.parse( android.resource://$packageName/${R.raw.demo_audio} )MediaPlayer 和 ExoPlayer 都可以基于这个资源地址进行播放。三、编写播放器界面创建res/layout/activity_main.xml?xml version1.0 encodingutf-8? ScrollView xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent android:fillViewporttrue LinearLayout android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationvertical android:padding20dp TextView android:layout_widthmatch_parent android:layout_heightwrap_content android:textAndroid 音频播放器 android:textSize22sp android:textStylebold / RadioGroup android:idid/groupEngine android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationhorizontal RadioButton android:idid/rbMediaPlayer android:layout_widthwrap_content android:layout_heightwrap_content android:checkedtrue android:textMediaPlayer / RadioButton android:idid/rbExoPlayer android:layout_widthwrap_content android:layout_heightwrap_content android:textExoPlayer / /RadioGroup EditText android:idid/etUrl android:layout_widthmatch_parent android:layout_heightwrap_content android:hint输入 HTTPS MP3 音频地址 android:inputTypetextUri android:singleLinetrue / LinearLayout android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationhorizontal Button android:idid/btnLocal android:layout_width0dp android:layout_heightwrap_content android:layout_weight1 android:text本地音频 / Button android:idid/btnNetwork android:layout_width0dp android:layout_heightwrap_content android:layout_weight1 android:text网络音频 / /LinearLayout TextView android:idid/tvStatus android:layout_widthmatch_parent android:layout_heightwrap_content android:paddingTop16dp android:text状态未播放 android:textSize16sp / SeekBar android:idid/seekBar android:layout_widthmatch_parent android:layout_heightwrap_content android:max1000 / TextView android:idid/tvTime android:layout_widthmatch_parent android:layout_heightwrap_content android:text00:00 / 00:00 / LinearLayout android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationhorizontal Button android:idid/btnPlay android:layout_width0dp android:layout_heightwrap_content android:layout_weight1 android:text播放 / Button android:idid/btnPause android:layout_width0dp android:layout_heightwrap_content android:layout_weight1 android:text暂停 / Button android:idid/btnStop android:layout_width0dp android:layout_heightwrap_content android:layout_weight1 android:text停止 / /LinearLayout /LinearLayout /ScrollView这个界面同时服务于 MediaPlayer 和 ExoPlayer。使用同一套 UI有助于比较两种播放器的差异后续也方便改成独立的播放组件。四、封装统一的音频播放器接口如果直接在 Activity 中同时处理 MediaPlayer 和 ExoPlayer容易出现大量判断逻辑。例如if (useExoPlayer) { exoPlayer.play() } else { mediaPlayer.start() }随着播放状态、进度、异常等功能增加这些判断会越来越多。因此我们先定义一个简单接口。创建AudioEngine.kt先添加以下内容package com.example.audioplayback import android.net.Uri interface AudioEngine { fun load(uri: Uri) fun play() fun pause() fun seekTo(positionMs: Long) val durationMs: Long val positionMs: Long fun release() }这里将不同播放器统一成几个基础操作load()加载音频并在准备完成后自动播放。play()开始或恢复播放。pause()暂停播放。seekTo()调整播放进度。durationMs获取音频总时长。positionMs获取当前播放位置。release()释放资源。需要说明的是这只是教学 Demo 使用的简单抽象并不是一套完整的播放器架构。正式项目中通常还需要独立的状态模型、错误类型、音频焦点管理以及生命周期管理。五、使用 MediaPlayer 实现音频播放继续在AudioEngine.kt中添加下面的类并补充对应 importimport android.content.Context import android.media.AudioAttributes import android.media.MediaPlayer完整实现class PlatformAudioEngine( private val context: Context, private val onStatus: (String) - Unit ) : AudioEngine { private var player: MediaPlayer? null private var prepared false // 记录用户是否希望准备完成后自动播放 private var autoPlay true override fun load(uri: Uri) { release() autoPlay true val mp MediaPlayer() player mp try { val attributes AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_MEDIA) .setContentType( AudioAttributes.CONTENT_TYPE_MUSIC ) .build() mp.setAudioAttributes(attributes) mp.setOnPreparedListener { current - if (player ! current) { returnsetOnPreparedListener } prepared true onStatus(MediaPlayer 准备完成) if (autoPlay) { current.start() onStatus(MediaPlayer 播放中) } } mp.setOnCompletionListener { current - if (player current) { onStatus(MediaPlayer 播放完成) } } mp.setOnErrorListener { current, what, extra - if (player current) { prepared false onStatus( MediaPlayer 错误$what / $extra ) player null current.release() } true } mp.setDataSource(context, uri) onStatus(MediaPlayer 加载中) // 异步准备避免阻塞主线程 mp.prepareAsync() } catch (e: Exception) { onStatus( MediaPlayer 加载失败${e.message} ) release() } } override fun play() { autoPlay true if (prepared) { player?.start() onStatus(MediaPlayer 播放中) } } override fun pause() { autoPlay false if (prepared) { player?.let { if (it.isPlaying) { it.pause() } } } onStatus(MediaPlayer 已暂停) } override fun seekTo(positionMs: Long) { if (!prepared) return player?.seekTo( positionMs.coerceIn( 0L, Int.MAX_VALUE.toLong() ).toInt() ) } override val durationMs: Long get() runCatching { if (prepared) { player?.duration?.toLong() ?: 0L } else { 0L } }.getOrDefault(0L) override val positionMs: Long get() runCatching { if (prepared) { player?.currentPosition?.toLong() ?: 0L } else { 0L } }.getOrDefault(0L) override fun release() { prepared false autoPlay false player?.release() player null } }1. 为什么使用 prepareAsync()MediaPlayer 提供了两种准备方式mediaPlayer.prepare()以及mediaPlayer.prepareAsync()prepare()是同步方法。对于网络音频它可能需要等待网络请求、缓冲数据和媒体解析。如果直接在主线程调用可能造成 UI 卡顿严重时甚至触发 ANR。而prepareAsync()会异步执行准备过程准备完成后通过OnPreparedListener通知调用方。所以这里采用mp.setOnPreparedListener { it.start() } mp.prepareAsync()实际代码还增加了prepared和autoPlay两个变量防止用户在准备过程中点击播放或暂停时产生不符合预期的行为。2. 为什么需要记录 prepared 状态因为 MediaPlayer 的 API 对状态有要求。例如在 Preparing 阶段播放器还没完成准备不能直接把它当作已经可以播放的播放器使用。所以if (prepared) { player?.start() }这里的prepared并不完全等于 MediaPlayer 内部的全部状态而是为了在这个简单 Demo 中标记播放器是否已经完成准备并且尚未被释放或进入错误状态。对于更加复杂的播放器建议定义完整的业务状态机而不是只使用一个 Boolean。3. 为什么每次加载新音频前都 release()假设用户连续点击三条语音语音 A ↓ 语音 B ↓ 语音 C如果每次都创建新的 MediaPlayer却没有释放旧实例就可能发生多个音频同时播放的问题。同时每个播放器实例都可能持有解码器、音频输出等底层资源。所以本例采用单播放器策略播放新音频前先释放旧播放器。这种策略比较适合社交 App 的普通语音消息播放场景。六、使用 Media3 ExoPlayer 实现音频播放下面实现相同功能的 ExoPlayer 版本。继续在AudioEngine.kt中增加 importimport androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.PlaybackException import androidx.media3.common.AudioAttributes as Media3AudioAttributes import androidx.media3.exoplayer.ExoPlayer然后添加以下类class Media3AudioEngine( private val context: Context, private val onStatus: (String) - Unit ) : AudioEngine { private var player: ExoPlayer? null override fun load(uri: Uri) { release() val exo ExoPlayer.Builder(context) .setHandleAudioBecomingNoisy(true) .build() player exo val attributes Media3AudioAttributes.Builder() .setUsage(C.USAGE_MEDIA) .setContentType( C.AUDIO_CONTENT_TYPE_MUSIC ) .build() // 第二个参数为 true // 由 ExoPlayer 管理音频焦点 exo.setAudioAttributes( attributes, true ) exo.addListener( object : Player.Listener { override fun onPlaybackStateChanged( state: Int ) { if (player ! exo) return when (state) { Player.STATE_BUFFERING - { onStatus(ExoPlayer 缓冲中) } Player.STATE_READY - { onStatus(ExoPlayer 准备完成) } Player.STATE_ENDED - { onStatus(ExoPlayer 播放完成) } Player.STATE_IDLE - { if (exo.playerError null) { onStatus(ExoPlayer 空闲) } } } } override fun onIsPlayingChanged( isPlaying: Boolean ) { if (player ! exo) return if (isPlaying) { onStatus(ExoPlayer 播放中) } } override fun onPlayerError( error: PlaybackException ) { if (player ! exo) return onStatus( ExoPlayer 错误 ${error.errorCodeName} ${error.message} ) } } ) exo.setMediaItem( MediaItem.fromUri(uri) ) exo.prepare() exo.play() } override fun play() { player?.play() } override fun pause() { player?.pause() onStatus(ExoPlayer 已暂停) } override fun seekTo(positionMs: Long) { player?.seekTo(positionMs) } override val durationMs: Long get() { val duration player?.duration ?: 0L return if ( duration C.TIME_UNSET || duration 0L ) { 0L } else { duration } } override val positionMs: Long get() player?.currentPosition ?.coerceAtLeast(0L) ?: 0L override fun release() { player?.release() player null } }1. ExoPlayer 为什么不需要自己维护 prepared因为 ExoPlayer 已经有明确的播放状态。常见状态包括状态作用STATE_IDLE尚未准备、已停止或失败后的空闲状态STATE_BUFFERING正在等待足够的媒体数据STATE_READY已具备从当前位置播放的条件STATE_ENDED当前媒体播放结束它还提供playWhenReady用来表达用户是否希望播放器开始播放。例如exo.prepare() exo.play()play()可以在播放器尚未准备完成时调用。播放器会记录播放意图等到满足播放条件后再实际播放。这与 MediaPlayer 的状态使用方式有所不同。还有一个容易忽略的细节STATE_READY不代表声音一定正在播放。例如播放器已经准备完成但用户点击了暂停此时依然可能处于 READY 状态。所以业务中判断是否真正播放更适合通过player.isPlaying或者监听onIsPlayingChanged(isPlaying)2. 什么是 MediaItemExoPlayer 使用 MediaItem 描述需要播放的媒体资源。例如val item MediaItem.fromUri(audioUri) exoPlayer.setMediaItem(item)这里的audioUri可以是受支持的本地资源 URI也可以是网络音频地址。后续如果需要播放多条语音或歌曲也可以使用exoPlayer.addMediaItem(item)构建播放列表。3. 为什么设置 setAudioAttributes()我们在代码中使用exo.setAudioAttributes( attributes, true )这里的true表示让 ExoPlayer 根据音频属性处理音频焦点。例如其他应用请求音频焦点时播放器可以按系统音频焦点策略作出相应响应。这不是一个永久占有音频焦点的开关也不意味着可以绕过 Android 对后台播放的限制。同样我们通过.setHandleAudioBecomingNoisy(true)配置耳机拔出等输出路由变化导致音频可能从设备扬声器突然外放时的自动暂停行为。这两个配置在实际音频播放业务中都很有用。七、在 Activity 中使用两种播放器接下来将两种播放器连接到前面定义的 UI。创建MainActivity.ktpackage com.example.audioplayback import android.app.Activity import android.net.Uri import android.os.Bundle import android.os.Handler import android.os.Looper import android.widget.Button import android.widget.EditText import android.widget.RadioButton import android.widget.RadioGroup import android.widget.SeekBar import android.widget.TextView import java.util.Locale class MainActivity : Activity() { private lateinit var rbMediaPlayer: RadioButton private lateinit var groupEngine: RadioGroup private lateinit var etUrl: EditText private lateinit var tvStatus: TextView private lateinit var tvTime: TextView private lateinit var seekBar: SeekBar private var engine: AudioEngine? null private val handler Handler(Looper.getMainLooper()) private var isSeeking false // 用于忽略旧播放器迟到的回调 private var requestId 0 private val progressTask object : Runnable { override fun run() { updateProgress() handler.postDelayed(this, 500L) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) rbMediaPlayer findViewById(R.id.rbMediaPlayer) groupEngine findViewById(R.id.groupEngine) etUrl findViewById(R.id.etUrl) tvStatus findViewById(R.id.tvStatus) tvTime findViewById(R.id.tvTime) seekBar findViewById(R.id.seekBar) findViewByIdButton(R.id.btnLocal) .setOnClickListener { val uri Uri.parse( android.resource://$packageName/ R.raw.demo_audio ) loadAudio(uri) } findViewByIdButton(R.id.btnNetwork) .setOnClickListener { val url etUrl.text.toString().trim() val uri Uri.parse(url) if ( uri.scheme ! https || uri.host.isNullOrBlank() ) { tvStatus.text 请输入有效的 HTTPS 音频地址 returnsetOnClickListener } loadAudio(uri) } findViewByIdButton(R.id.btnPlay) .setOnClickListener { engine?.play() } findViewByIdButton(R.id.btnPause) .setOnClickListener { engine?.pause() } findViewByIdButton(R.id.btnStop) .setOnClickListener { clearPlayback() } groupEngine.setOnCheckedChangeListener { _, _ - clearPlayback() } setupSeekBar() } private fun loadAudio(uri: Uri) { clearPlayback() val currentRequestId requestId val statusCallback: (String) - Unit { status - if (requestId currentRequestId) { tvStatus.text status } } engine if (rbMediaPlayer.isChecked) { PlatformAudioEngine( this, statusCallback ) } else { Media3AudioEngine( this, statusCallback ) } engine?.load(uri) } private fun setupSeekBar() { seekBar.isEnabled false seekBar.setOnSeekBarChangeListener( object : SeekBar.OnSeekBarChangeListener { override fun onProgressChanged( seekBar: SeekBar, progress: Int, fromUser: Boolean ) { if (fromUser) { val duration engine?.durationMs ?: 0L tvTime.text ${formatTime(progress.toLong())} / ${formatTime(duration)} } } override fun onStartTrackingTouch( seekBar: SeekBar ) { isSeeking true } override fun onStopTrackingTouch( seekBar: SeekBar ) { engine?.seekTo( seekBar.progress.toLong() ) isSeeking false } } ) } private fun updateProgress() { val audioEngine engine val duration audioEngine?.durationMs ?: 0L val position audioEngine?.positionMs ?: 0L val safeDuration duration.coerceIn( 0L, Int.MAX_VALUE.toLong() ) val safePosition position.coerceIn( 0L, safeDuration ) seekBar.max safeDuration .coerceAtLeast(1L) .toInt() seekBar.isEnabled duration 0L if (!isSeeking) { seekBar.progress safePosition.toInt() tvTime.text ${formatTime(position)} / ${formatTime(duration)} } } private fun formatTime(ms: Long): String { val totalSeconds ms.coerceAtLeast(0L) / 1000L val minutes totalSeconds / 60L val seconds totalSeconds % 60L return String.format( Locale.getDefault(), %02d:%02d, minutes, seconds ) } private fun clearPlayback() { requestId engine?.release() engine null isSeeking false seekBar.progress 0 seekBar.isEnabled false tvTime.text 00:00 / 00:00 tvStatus.text 状态未播放 } override fun onStart() { super.onStart() handler.post(progressTask) } override fun onStop() { handler.removeCallbacks(progressTask) // 本 Demo 不支持后台播放 clearPlayback() super.onStop() } }1. 为什么只保留一个 AudioEngine这是一种简单的单实例播放管理方式。不管用户选择 MediaPlayer 还是 ExoPlayerActivity 始终通过统一接口控制当前播放器。例如engine?.play() engine?.pause() engine?.seekTo(5000L)这样 UI 不需要关心底层具体使用哪一种播放器。在项目中这种抽象可以进一步封装到 ViewModel、播放管理器或独立 SDK 中。但要注意播放器本身持有系统资源不适合不加区分地放入任何长生命周期组件。2. 为什么需要 requestId考虑一个比较常见的情况用户点击语音 A网络还没加载完成就立刻点击了语音 B。此时可能发生点击语音 A ↓ A 开始网络加载 ↓ 点击语音 B ↓ 释放 A开始加载 B ↓ A 的旧状态回调迟到如果界面无条件处理所有回调就可能显示与当前播放内容不一致的状态。所以这里使用requestId区分加载任务。旧播放器的回调即使迟到也不会再更新当前界面。需要说明这个机制只负责隔离过期 UI 回调。资源释放与取消旧任务仍然要由播放器正确处理。3. 为什么用 Handler 更新进度播放器的状态监听负责告诉我们加载、播放、错误等事件但不适合依赖它每隔几百毫秒产生一次播放进度事件。这里使用handler.postDelayed(this, 500L)定期读取播放器的durationMs positionMs然后更新 SeekBar。500ms 对普通音频播放器进度显示通常已经足够。但这段代码只是 UI 进度同步并不意味着音频数据每 500ms 才进行一次处理。音频解码和输出由播放器内部完成。另外onStop()中移除了定时任务并释放播放器避免界面退出后仍然持续更新。这个 Demo 明确采用“离开页面停止播放”的策略。如果后续需要支持锁屏播放和后台播放则应改成 MediaSessionService 等合适的结构而不是简单删除资源释放代码。八、本地音频和网络音频有什么不同虽然两者最终都通过播放器输出声音但数据获取方式不同。1. 本地播放本地媒体文件 ↓ 文件读取 ↓ 媒体容器解析 ↓ 音频解码 ↓ PCM ↓ Android 音频输出系统本地文件通常不需要等待网络请求。但仍然需要考虑文件损坏、编码格式不支持、资源释放等问题。2. 网络播放HTTPS 音频地址 ↓ 网络连接与数据接收 ↓ 缓冲数据 ↓ 媒体解析与解码 ↓ PCM ↓ Android 音频输出系统网络播放还需要考虑网络连接失败。服务端返回 403、404 等错误。服务端返回的内容不是媒体数据。网络中断或传输速度下降。缓冲区暂时没有足够的数据。服务端不支持适合的范围请求导致某些格式无法准确拖动进度。因此网络播放不能简单地把setDataSource()后面紧接一个不受状态保护的start()。对 MediaPlayer应该等待onPrepared()。对 ExoPlayer则可以先prepare()、play()通过播放器状态监听加载和缓冲情况。九、实际开发中最容易出现的六类问题1. MediaPlayer 报 IllegalStateException常见原因是调用 API 时播放器不处于合法状态。例如val player MediaPlayer() player.start()这不是正确的加载流程。应该先设置数据源再准备播放器最后启动播放。如果播放器已经stop()也不能简单把它当作 Paused 状态直接恢复需要重新准备。因此业务中要明确区分暂停、停止和释放。本文的“停止”按钮采用释放当前播放器的方式之后如需播放需要重新选择音频资源。2. 切换音频后出现多个声音通常是旧播放器没有正确停止和释放。尤其在聊天列表中如果每个 ViewHolder 都自己创建播放器就容易出现播放状态分散、资源管理混乱的问题。更合理的方式是建立统一的播放管理器让多个语音消息复用同一个播放入口。3. 播放进度为 0 或者未知网络媒体刚开始加载时播放器可能还没有获得有效的总时长。ExoPlayer 会使用C.TIME_UNSET表示某些未知时间值。因此不能直接假设player.duration 0一定成立。应该先判断时长是否有效再决定能否拖动进度。对于直播流、部分动态媒体以及不可随机访问的资源进度控制还需要进一步区分。4. 网络音频无法播放不要只检查播放器代码。还应该确认URL 是否能够正常访问。服务端实际返回的是不是音频。音频编码和容器格式是否受到支持。HTTPS 证书是否有效。服务端是否有鉴权或防盗链限制。播放器具体返回了什么错误信息。如果使用 ExoPlayer可以重点观察onPlayerError()中的错误代码及异常原因。5. 退出页面后声音还在播放这是典型的生命周期管理问题。如果只调用player.pause()播放器对象及内部部分资源仍可能保留。而player.release()表示释放播放器占用的资源。实际项目中需要根据业务确定播放器到底属于页面生命周期、独立管理器生命周期还是后台播放服务生命周期。6. 耳机拔出后突然外放对于音乐、语音消息等场景耳机拔出后突然从扬声器播放可能影响用户体验。ExoPlayer 可以配置.setHandleAudioBecomingNoisy(true)处理相应的音频输出路由变化。MediaPlayer 如果也需要实现类似行为需要在应用中配合系统广播及音频路由策略自行处理。另外音频焦点和音频输出路由是两个不同的问题不能混为一谈。十、MediaPlayer 和 ExoPlayer 底层有什么关系从 Android 音频系统的角度来看它们都需要利用 Android 提供的媒体处理与音频输出能力。可以用下面的简化流程理解Android App │ ┌─────────┴─────────┐ │ │ MediaPlayer Media3 ExoPlayer │ │ │ 媒体解析 / 缓冲管理 │ │ │ MediaCodec 等解码能力 │ │ └─────────┬─────────┘ │ PCM / 音频输出 │ AudioTrack 等系统接口 │ AudioFlinger │ Audio HAL / 驱动 │ 音频设备注意这是一张概念简化图并非两种播放器严格一致的源码调用链。MediaPlayer 通常通过 Framework 和系统原生媒体服务实现播放而 ExoPlayer 很多媒体数据读取、缓冲与解析逻辑由播放器库自身负责再根据媒体类型利用 Android 提供的解码和输出能力。所以两者在上层架构中有明显区别但最终都需要接入 Android 的音频输出体系。后面的 Framework 系列我们会单独分析 AudioTrack、AudioFlinger、AudioPolicyService 和 Audio HAL 的关系。十一、实际项目应该选择哪一种不能简单地认为 MediaPlayer 已经过时或者所有项目都必须使用 ExoPlayer。需要根据业务来决定。场景一播放一个简单的提示音如果只是播放一个非常短的提示音MediaPlayer 可以实现。不过如果需要频繁、低延迟地触发大量短音效还应该考虑 SoundPool 等更适合的 API。场景二播放普通语音消息MediaPlayer 和 ExoPlayer 都可以使用。如果项目只需要简单的录音文件播放并且已有稳定的 MediaPlayer 封装可以继续使用。如果需要统一的状态管理、多个音频资源切换、复杂播放控制或持续扩展ExoPlayer 更值得优先考虑。场景三音乐播放器更推荐 Media3 ExoPlayer。特别是在需要播放列表、媒体会话、通知控制、后台播放或流媒体支持的情况下Media3 提供了更完整的配套能力。当然这些能力仍然需要进行正确的系统集成不是创建 ExoPlayer 后就自动具备全部后台播放功能。场景四实时语音聊天实时语音通话与普通音乐播放是不同的业务。例如 1V1 语音通话、多人语音房需要考虑音频采集与编码。实时数据发送和接收。端到端延迟。网络抖动与丢包。回声消除。音频路由切换。低延迟音频输出。这种场景通常会采用 WebRTC 或专业实时音频方案底层也可能涉及 AudioRecord、AudioTrack、AAudio 等能力。不能简单地把 MediaPlayer 或 ExoPlayer 当作实时音频通话引擎。十二、本文 Demo 的限制与后续优化为了让这一篇重点放在两种播放器的使用和差异上示例仍然做了一些简化。正式项目还需要继续完善音频焦点MediaPlayer 示例只配置音频属性未实现完整的 AudioFocusRequest 管理。网络容错还没有加入重试策略、错误分类和重连控制。播放状态目前通过回调显示字符串正式项目建议使用明确的状态模型。长时音频SeekBar 示例以 Int 毫秒值表示进度生产环境可改用 Long 时间映射。生命周期当前离开 Activity 就停止播放还没有后台播放服务。缓存没有实现磁盘缓存、预加载或离线播放。验证应在不同 Android 版本、网络条件和音频格式下进行测试。需要强调的是本文提供的是可以进一步扩展的学习 Demo而不是未经项目验证就可以直接用于生产环境的完整播放器 SDK。十三、总结这一篇我们分别通过 MediaPlayer 和 Media3 ExoPlayer 实现了本地与网络音频播放。主要掌握了以下内容MediaPlayer 是 Framework 内置播放器使用时必须注意状态约束。ExoPlayer 属于 Media3提供统一的 Player 接口与事件监听机制。播放网络音频时应正确处理异步准备、加载、缓冲与错误。播放进度可以通过播放器当前播放位置定期同步到 UI。在切换播放资源和销毁页面时应当正确释放播放器资源。在完整的音频业务中还需要考虑音频焦点、路由、后台播放和生命周期。对于简单的音频播放两者都能完成任务。但如果开发的是需要持续迭代的音乐、音频内容或复杂播放业务Media3 ExoPlayer 通常会是更合适的选择。下一篇《Android 音频开发三MediaRecorder 实现录音、暂停与保存》下一篇我们将从播放器转到录音功能实现一个完整的录音 Demo重点介绍麦克风录音权限。MediaRecorder 初始化与配置。开始、暂停、恢复、结束录音。录音文件格式与保存路径。录音异常和资源释放。Android 不同版本的录音注意事项。之后将前面的播放功能与录音功能组合起来为实现完整的语音消息组件打下基础。
延伸阅读

更多相关文章

2026/10/16 6:31:34

Python循环结构深度解析:for/while与break/continue的实战应用

循环结构是编程语言中最核心的控制流机制之一,它允许程序重复执行某段代码,从而高效处理批量数据和复杂逻辑。Python提供了两种主要的循环结构:for循环和while循环,并配合break和continue语句实现灵活的流程控制。本报告将深入探讨…

2026/10/16 6:31:34

Python作为一门高级编程语言,以其简洁优雅的语法和强大的功能在软件开发、数据分析、人工智能等领域得到广泛应用

Python作为一门高级编程语言,以其简洁优雅的语法和强大的功能在软件开发、数据分析、人工智能等领域得到广泛应用。基础数据类型和运算符是Python编程的基石,掌握这些基础知识对于编写高效、正确的Python程序至关重要。本报告将深入探讨Python中的三种基…

2026/10/16 9:48:07

AI辅助数据可视化:从提示词到成图的完整工作流

先交代一个背景:我平时的工作里有很大一块是在跟数据打交道,报表、看板、临时分析,几乎每周都要折腾几个图表。以前最烦的不是数据拿不到,而是拿到数据之后,光是为了“让图能看”就得耗掉大半天——坐标轴单位、颜色体…

2026/10/16 9:48:07

全国河流水系SHP数据实战:从图层解析到流域分析与PostGIS管理

简介:这份全国最新最详细河流水系数据以SHP矢量格式提供,面向GIS从业者、水文分析人员及地理信息相关专业师生,用于解决全国尺度水系要素提取、空间制图与水文建模中基础底图缺失的问题。资源包共18个文件,涵盖shp、shx、dbf、prj…

2026/10/16 9:48:07

Java导出Excel全攻略:从POI到EasyExcel,大数据量性能优化与避坑指南

做Java开发这些年,几乎每个项目都会碰到同一个需求——导出Excel。后台管理系统的报表导出、运营数据的定期汇总、业务流程中的清单下载,不管业务多简单,只要涉及数据列表,最终大概率都会提一句“导个Excel吧”。“java导出excel表…

2026/10/16 9:48:07

Python requests 请求 Django 站点 CSRF 校验失败解决指南

1. 从一个真实场景说起:为什么你的请求总是被拒用 Python 的 requests 库去请求一个 Django 搭建的网站,代码写得好好的,URL 没错、参数也对,可返回的状态码偏偏是 403。打开返回内容一看,提示 CSRF verification fail…

2026/10/16 9:48:07

SQL注入之报错注入全解析:原理、姿势与防御技巧

有的新手朋友第一次接触 SQL注入时都会忍不住想:既然有 union 注入可以直接回显数据,那报错注入到底还有什么用?说实话,最初我也有同样的疑问。后来在实际测试里碰了几次壁,才真正意识到报错注入的特殊价值——它不需要…

2026/10/16 9:42:45

阿里云K8s部署Vue2+SpringBoot2.5+Nacos2.0.3全流程

简介:这份资源面向需要在阿里云Kubernetes集群上落地前后端分离项目的开发与运维人员,提供一套可直接参考的部署方案,覆盖Vue2前端、Nginx、SpringBoot2.5后端与Nacos2.0.3注册配置中心的完整编排。包内共16个文件,以8个yaml清单为…

2026/10/16 5:33:43

Python调用Gemini Structured Outputs实现工单路由门禁

客服工单最怕的不是模型“答错一句话”,而是它给出一段看起来合理的说明,程序却从中猜错优先级。通俗做法是:要求模型只交 JSON(JavaScript Object Notation,轻量数据格式),再让代码验证它。Gem…

2026/10/16 4:03:48

Spring Boot超市进销存系统毕设实战:从需求拆解到答辩通关

最近带的一个学生项目组里,有A同学跑来问我:选什么毕设题目最稳妥,既能让评审老师觉得工作量够,又不会在答辩时被问到语无伦次。我第一反应就是推荐基于Spring Boot的超市仓库管理系统——也就是超市进销存系统。这个题目乍一看平…

2026/10/15 13:42:16

Flutter StatefulWidget 生命周期核心解析

很多刚开始接触 Flutter 的朋友,在看完一堆“Hello World”和基础组件之后,大概率都会撞上同一堵墙:StatefulWidget 里那堆 initState、build、dispose 方法,到底什么时候被调用?为什么顺序是那样?在里面到…

2026/10/16 0:01:11

Cephadm实战指南:Linux系统工程师的生产级Ceph集群部署与排障

简介:本资源是一份完整的Ceph分布式存储系统中文技术文档合集,面向Linux系统运维工程师、云平台架构师及存储方向开发者,帮助读者系统掌握Ceph集群的部署、配置与运维核心能力。文档覆盖对象存储(RGW)、块设备&#xf…

2026/10/16 0:01:11

ITK中SpatialObject几何体系解析:坐标系、变换与管道集成

1. 空间对象的定位:为什么 ITK 需要一套几何描述体系很多刚开始接触 ITK 的朋友,包括我自己,最初都是被它的图像滤波、配准、分割这些“正统”功能吸引进来的。等真正上手做点实际项目,比如把分割出来的器官边界做量化分析、给血管…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑