说到Android开发,很多人最头疼的就是“后台服务”和“内存泄漏”这两座大山。你以为写个MediaPlayer就能放歌?错了。当你切换App、锁屏、甚至杀进程时,音乐突然停了,或者手机开始发烫卡顿,这时候你才知道什么叫“坑”。
今天我不跟你扯那些晦涩的理论,咱们直接上手。我会带你用大约100行核心代码,写一个真正能在后台稳定工作的音乐播放器,并且顺带把那些让人头秃的内存泄漏问题一次性解决掉。
为什么后台播放这么难?
首先,你得理解Android系统的脾气。Android并不喜欢你一直在后台“偷偷”做事。为了省电和节省内存,它会把不用的进程干掉。如果你只是简单地在Activity里启动MediaPlayer,一旦用户按下Home键,几分钟后系统就可能回收你的资源,音乐就戛然而止。
更可怕的是内存泄漏。如果你在Service或者BroadcastReceiver里持有Activity的引用,哪怕只是间接的,这个Activity就没法被回收。你切出去听歌,切回来,再切出去……内存占用直线上升,最后OOM(内存溢出)崩溃。
所以,我们的目标很明确:
- 服务化:把播放逻辑从Activity移到一个独立的Service里,让它能在后台继续跑。
- 生命周期管理:确保播放器在需要时创建,不需要时释放。
- 防泄漏:彻底切断Service和Activity之间的脆弱引用链。
第一步:搭建骨架——Foreground Service
普通的Service容易被系统杀,所以我们用Foreground Service。它会显示一个通知,告诉用户“我在干活呢”,系统对这类服务的保护力度更强,不容易被回收。
我们需要先定义两个类:一个是管理播放逻辑的MusicService,一个是处理界面交互的MainActivity。
1.1 核心Service:MusicService
这个Service是我们的心脏。它负责加载媒体、控制播放、绑定UI。
class MusicService : Service() {
// 使用弱引用包装MediaPlayer,防止Service持有MediaPlayer过久导致泄漏
// 但注意:MediaPlayer本身是系统资源,必须由Service持有直到release
private var mediaPlayer: MediaPlayer? = null
private var songUrl: String = ""
// 用于处理绑定请求的Binder
private val binder = LocalBinder()
// 播放状态回调接口,用于通知Activity更新UI
interface OnPlayStateChangedListener {
fun onPlayStateChanged(isPlaying: Boolean)
}
// 这里我们用一个List来存储监听者,但为了避免内存泄漏,
// 我们在onUnbind时记得清除所有引用
private val listeners = mutableListOf<OnPlayStateChangedListener>()
inner class LocalBinder : Binder() {
fun getService(): MusicService = this@MusicService
}
override fun onBind(intent: Intent): IBinder {
return binder
}
override fun onCreate() {
super.onCreate()
// 启动前台服务,显示一个常驻通知
val notification = buildNotification()
startForeground(1, notification)
// 初始化MediaPlayer
mediaPlayer = MediaPlayer().apply {
setOnCompletionListener {
// 播放完毕,重置状态
pause()
updatePlayState(false)
}
setOnErrorListener { _, what, extra ->
Log.e("MusicService", "播放错误: $what, $extra")
false
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_PLAY -> {
songUrl = intent.getStringExtra(EXTRA_URL) ?: ""
prepareAndPlay(songUrl)
}
ACTION_PAUSE -> pause()
ACTION_RESUME -> resume()
ACTION_STOP -> {
stopPlayback()
stopSelf()
}
}
return START_NOT_STICKY // 关键:告诉系统如果内存不足可以杀掉我,不用强制重启
}
private fun prepareAndPlay(url: String) {
try {
mediaPlayer?.reset()
mediaPlayer?.setDataSource(url)
mediaPlayer?.prepareAsync() // 异步准备,避免卡顿
mediaPlayer?.setOnPreparedListener {
it.start()
updatePlayState(true)
updateNotificationIcon(true)
}
} catch (e: IOException) {
e.printStackTrace()
}
}
fun pause() {
mediaPlayer?.pause()
updatePlayState(false)
updateNotificationIcon(false)
}
fun resume() {
mediaPlayer?.start()
updatePlayState(true)
updateNotificationIcon(true)
}
private fun stopPlayback() {
mediaPlayer?.stop()
mediaPlayer?.release()
mediaPlayer = null
updatePlayState(false)
}
private fun updatePlayState(isPlaying: Boolean) {
listeners.forEach { it.onPlayStateChanged(isPlaying) }
}
fun addListener(listener: OnPlayStateChangedListener) {
if (!listeners.contains(listener)) {
listeners.add(listener)
}
}
fun removeListener(listener: OnPlayStateChangedListener) {
listeners.remove(listener)
}
override fun onDestroy() {
super.onDestroy()
// 确保所有资源释放
stopPlayback()
listeners.clear()
}
// 构建通知,这是前台服务的必要条件
private fun buildNotification(): Notification {
val intent = Intent(this, MusicActivity::class.java) // 点击通知跳转的页面
val pendingIntent = PendingIntent.getActivity(this, 0, intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
val builder = NotificationCompat.Builder(this, "channel_id")
.setContentTitle("正在播放")
.setContentText("我的音乐")
.setSmallIcon(R.drawable.ic_music_note)
.setContentIntent(pendingIntent)
.addAction(R.drawable.ic_pause, "暂停", buildPauseIntent())
.addAction(R.drawable.ic_play, "播放", buildPlayIntent())
.setStyle(NotificationCompat.MediaStyle()
.setShowActionsInCompactView(0, 1)
.setMediaSession(null)) // 暂时不用Session,简化代码
return builder.build()
}
private fun buildPauseIntent(): PendingIntent {
val intent = Intent(this, MusicService::class.java).apply { action = ACTION_PAUSE }
return PendingIntent.getService(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)
}
private fun buildPlayIntent(): PendingIntent {
val intent = Intent(this, MusicService::class.java).apply { action = ACTION_RESUME }
return PendingIntent.getService(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)
}
private fun updateNotificationIcon(isPlaying: Boolean) {
// 实际项目中应该重新构建通知并调用 notify() 更新图标
// 这里为了简洁省略具体代码,重点在于逻辑
}
companion object {
const val ACTION_PLAY = "action_play"
const val ACTION_PAUSE = "action_pause"
const val ACTION_RESUME = "action_resume"
const val ACTION_STOP = "action_stop"
const val EXTRA_URL = "extra_url"
}
}
第二步:界面与绑定——MainActivity
现在我们需要一个界面来控制这个Service。很多教程会让你在Activity里直接new MediaPlayer(),那是大忌。我们要用bindService。
关键点来了: 如何避免内存泄漏?
传统的做法是在onBind时拿到Service实例,然后调用方法。但如果你持有的是Activity的强引用,而Service又反过来持有Activity的引用(比如为了更新UI),那就死锁了。
我们的解决方案是:Service不持有Activity的引用。相反,我们让Activity实现一个接口,并注册到Service的监听列表里。更重要的是,在onUnbind时,一定要解注册。
class MainActivity : AppCompatActivity(), MusicService.OnPlayStateChangedListener {
private var musicService: MusicService? = null
private var isBound = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// 绑定Service
val intent = Intent(this, MusicService::class.java)
bindService(intent, connection, Context.BIND_AUTO_CREATE)
}
private val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val binder = service as MusicService.LocalBinder
musicService = binder.getService()
isBound = true
// 注册自己为监听者
musicService?.addListener(this@MainActivity)
}
override fun onServiceDisconnected(name: ComponentName?) {
isBound = false
musicService = null
// 注意:这里不应该移除监听者,因为unbind会触发onDestroy,
// 我们统一在onDestroy里处理
}
}
// 播放控制按钮的点击事件
fun onPlayClick(view: View) {
musicService?.let { service ->
// 假设我们从SharedPreferences或者Intent里拿到了歌曲URL
val url = "https://example.com/song.mp3"
val intent = Intent(this, service.javaClass).apply {
action = MusicService.ACTION_PLAY
putExtra(MusicService.EXTRA_URL, url)
}
startService(intent)
}
}
fun onPauseClick(view: View) {
musicService?.let { service ->
val intent = Intent(this, service.javaClass).apply {
action = MusicService.ACTION_PAUSE
}
startService(intent)
}
}
// 实现监听器接口
override fun onPlayStateChanged(isPlaying: Boolean) {
// 更新UI,比如按钮状态
runOnUiThread {
// updateButton(isPlaying)
}
}
override fun onDestroy() {
super.onDestroy()
// 最关键的一步:移除监听者,防止Service持有Activity引用导致泄漏
musicService?.removeListener(this)
if (isBound) {
unbindService(connection)
isBound = false
}
}
}
第三步:深入解析——那些容易踩的坑
上面代码虽然只有几十行,但里面埋了几个关键的设计决策,专门用来对付内存泄漏和后台存活问题。我们来逐一拆解。
3.1 为什么用LOCAL_BINDER而不是直接传Activity引用?
很多新手会这么写:
// 错误示范:Service持有Activity的弱引用
class MusicService : Service() {
private var activityRef: WeakReference<MainActivity>? = null
fun bind(activity: MainActivity) {
activityRef = WeakReference(activity)
}
}
这种写法看似安全,因为用了WeakReference。但问题是,一旦Activity被GC回收,Service就丢了联系。更糟糕的是,如果你在onDestroy时没有显式清空activityRef,Service本身还活着的话,这个弱引用队列里可能会残留一段时间,虽然不会导致泄漏,但逻辑上是混乱的。
我们采用的Binder模式,让Activity主动addListener,Service只关心“有没有人听”,而不关心“是谁在听”。这种松耦合是避免泄漏的根本。
3.2 START_NOT_STICKY vs START_STICKY
我在Service的onStartCommand里返回了START_NOT_STICKY。
START_STICKY:如果Service被系统杀掉,系统会尝试重启它,并重新调用onStartCommand,但intent参数为null。这适合音乐播放器,因为重启后你知道要恢复状态。START_NOT_STICKY:如果被杀掉,系统不会重启,除非有新的显式启动命令。
这里有个选择: 对于严格的后台音乐播放,通常推荐START_STICKY,这样即使内存紧张被杀,也能自动恢复。但是,START_STICKY有个陷阱:如果onStartCommand里没处理好intent == null的情况,可能会抛出异常或播放错误歌曲。
在我的代码里,我用了START_NOT_STICKY是为了演示更“干净”的内存管理。如果你希望更健壮,可以改为START_STICKY,并在onStartCommand里加判断:
if (intent == null) return START_NOT_STICKY // 或者恢复上一次的播放状态
3.3 前台通知的必要性
Android 8.0(API 26)以上,后台Service有严格限制。如果你想让音乐在后台继续播放,必须使用前台Service(startForeground)。
如果你不调用startForeground,系统在几秒内就会杀掉你的Service。这就是为什么我们要构建一个Notification。注意,这个通知最好做得简洁,不要吓到用户。
3.4 内存泄漏的终极检查清单
- Service里有没有持有Activity的引用? 答案是没有。只有通过接口回调。
- Activity里有没有持有Service的强引用? 有,
musicService。但这没问题,因为Activity生命周期结束时,我们会unbindService,并且removeListener。 - MediaPlayer是否被正确释放? 在
onDestroy里调用了release()。 - 是否有静态变量持有上下文? 检查整个代码,没有
static Context或static Activity。
第四步:进阶优化——让体验更丝滑
上面的代码已经能跑起来了,但作为一个“专家级”的实现,我们还可以做得更好。
4.1 使用MediaSession
通知里的播放/暂停按钮现在是假的,只是发Intent。真正的做法是使用MediaSession。这样系统锁屏界面、耳机按键、甚至Android Auto都能控制你的播放器。
private var mediaSession: MediaSession? = null
override fun onCreate() {
super.onCreate()
// 初始化MediaSession
mediaSession = MediaSession(this, "MusicService")
val controller = mediaSession?.controller
// 设置callback来处理远程控制
mediaSession?.setCallback(object : MediaSessionCompat.Callback() {
override fun onPlay() { resume() }
override fun onPause() { pause() }
override fun onStop() { stopPlayback(); stopSelf() }
})
mediaSession?.setActive(true)
}
然后在buildNotification里,把setMediaSession(mediaSession?.sessionToken)加上。这样,当你锁屏时,Android原生的媒体控件就能完美控制你的App。
4.2 处理音频焦点
如果你在听歌,突然来电话了,或者别人用其他音乐App播歌,你的歌应该自动暂停,等电话结束后再恢复。这就是音频焦点(Audio Focus)。
private val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
private fun requestAudioFocus(): Boolean {
return audioManager.requestAudioFocus(
{ /* 焦点丢失监听 */
if (mediaPlayer?.isPlaying == true) pause()
},
AudioAttributes.USAGE_MEDIA,
AudioFocusRequest.AUDIOFOCUS_GAIN
) == AudioFocusRequest.AUDIOFOCUS_GRANTED
}
每次播放前,先请求焦点。如果没有拿到,就不要播放。
4.3 持久化播放状态
如果App被杀死了,重启后用户希望从断点继续播放。这就需要把songUrl和position保存到SharedPreferences或Room数据库里。
在MusicService的onDestroy里保存状态:
val prefs = getSharedPreferences("player_prefs", MODE_PRIVATE)
prefs.edit()
.putString("last_url", songUrl)
.putInt("last_position", mediaPlayer?.currentPosition ?: 0)
.apply()
在onCreate里恢复:
val prefs = getSharedPreferences("player_prefs", MODE_PRIVATE)
val lastUrl = prefs.getString("last_url", null)
if (!lastUrl.isNullOrEmpty()) {
prepareAndPlay(lastUrl)
mediaPlayer?.seekTo(prefs.getInt("last_position", 0))
resume()
}
总结:为什么这100行代码能解决大问题
很多开发者认为后台播放器需要几百行代码,其实核心逻辑就这么多。关键在于架构的选择:
- 分离关注点:Service负责逻辑和状态,Activity负责UI。两者通过接口通信,互不持有强引用(除了Binder,但Binder是系统管理的,生命周期可控)。
- 资源生命周期管理:
MediaPlayer在onCreate创建,onDestroy释放。通知在startForeground后持续存在。 - 系统规范遵循:使用前台服务、处理音频焦点、处理MediaSession。这些不是锦上添花,而是必须,否则你的App在Android 8.0+上根本无法在后台正常工作。
内存泄漏的本质是“长生命周期的对象持有短生命周期对象的引用”。在我们的设计里,MusicService是长生命周期,MainActivity是短生命周期。我们确保Service不会持有Activity的引用(只持有Listener接口,且及时移除),Activity在销毁时主动断开与Service的联系。这样就形成了完美的闭环,没有任何泄漏的可能。
当你下次再看到“内存泄漏”四个字时,不要慌。回想一下这个模型:谁活了多久?谁该死?它们之间有没有牵绊?如果有,斩断它。
希望这个指南能帮你写出更健壮、更专业的Android应用。代码虽小,道理很深。去试试吧!
