Deep Dive: MediaPlayer Best Practices

MediaPlayer seems to be deceptively simple to use, but complexity lives just below the surface. For example, it can be tempting to write something like this:

MediaPlayer.create(context, R.raw.cowbell).start()

This works fine the first and probably the second, third, or even more times. However, each new MediaPlayer consumes system resources, such as memory and, codecs. This can degrade the performance of your app, and possibly the entire device.

Fortunately, it’s possible to use MediaPlayer in a way that is both simple and safe by following a few simple rules.

The Simple Case

The most basic case is that we have a sound file, perhaps a raw resource, that we just want to play. In this case we’ll create a single player reuse it each time we need to play a sound. The player should be created with something like this:

private val mediaPlayer = MediaPlayer().apply {
setOnPreparedListener { start() }
setOnCompletionListener { reset() }
}

The player is created with two listeners:

With the player created, the next step is to make a function that takes a resource ID and uses that MediaPlayer to play it:

override fun playSound(@RawRes rawResId: Int) {
val assetFileDescriptor = context.resources.openRawResourceFd(rawResId) ?: return
mediaPlayer.run {
reset()
setDataSource(assetFileDescriptor.fileDescriptor, assetFileDescriptor.startOffset, assetFileDescriptor.declaredLength)
prepareAsync()
}
}

There’s quite a bit happening in this short method:

  • The resource ID must be converted to an AssetFileDescriptor because this is what MediaPlayer uses to play raw resources. The null check ensures the resource exists.
  • Calling reset() ensures the player is in the Initialized state. This works no matter what state the player is in.
  • Set the data source for the player.
  • prepareAsync prepares the player to play and returns immediately, keeping the UI responsive. This works because attached OnPreparedListener starts playing after the source has been prepared.

It’s important to note we don’t call release() on our player or set it to null. We want to reuse it! So instead we call reset(), which frees the memory and codecs it was using.

Playing a sound is as simple as calling:

playSound(R.raw.cowbell)

Simple!

More Cowbells

Playing one sound at a time is easy, but what if you want to start another sound while the first one is still playing? Calling playSound() multiple times like this won’t work:

playSound(R.raw.big_cowbell)
playSound(R.raw.small_cowbell)

In this case, R.raw.big_cowbell starts to get prepared, but the second call resets the player before anything can happen, so only you only hear R.raw.small_cowbell.

And what if we wanted to play multiple sounds together at the same time? We’d need to create a MediaPlayer for each one. The simplest way to do this is to have a list of active players. Perhaps something like this:

class MediaPlayers(context: Context) {
private val context: Context = context.applicationContext
private val playersInUse = mutableListOf<MediaPlayer>()

private fun buildPlayer() = MediaPlayer().apply {
setOnPreparedListener { start() }
setOnCompletionListener {
it.release()
playersInUse -= it
}
}

override fun
playSound(@RawRes rawResId: Int) {
val assetFileDescriptor = context.resources.openRawResourceFd(rawResId) ?: return
val
mediaPlayer = buildPlayer()

mediaPlayer.run {
playersInUse += it
setDataSource(assetFileDescriptor.fileDescriptor, assetFileDescriptor.startOffset,
assetFileDescriptor.declaredLength)
prepareAsync()
}
}
}

Now that every sound has its own player it’s possible to play both R.raw.big_cowbell and R.raw.small_cowbell together! Perfect!

… Well, almost perfect. There’s not anything in our code that limits the number of sounds that can play at once, and MediaPlayer still needs to have memory and codecs to work with. When they run out, MediaPlayer fails silently, only noting “E/MediaPlayer: Error (1,-19)” in logcat.

Enter MediaPlayerPool

We want to support playing multiple sounds at once, but we don’t want to run out of memory or codecs. The best way to manage these things is to have a pool of players and then pick one to use when we want to play a sound. We could update our code to be like this:

class MediaPlayerPool(context: Context, maxStreams: Int) {
private val context: Context = context.applicationContext

private val mediaPlayerPool = mutableListOf<MediaPlayer>().also {
for
(i in 0..maxStreams) it += buildPlayer()
}
private val playersInUse
= mutableListOf<MediaPlayer>()

private fun buildPlayer() = MediaPlayer().apply {
setOnPreparedListener { start() }
setOnCompletionListener { recyclePlayer(it) }
}

/**
* Returns a
[MediaPlayer] if one is available,
* otherwise null.
*/
private fun requestPlayer(): MediaPlayer? {
return if (!mediaPlayerPool.isEmpty()) {
mediaPlayerPool.removeAt(0).also {
playersInUse
+= it
}
} else null
}

private fun recyclePlayer(mediaPlayer: MediaPlayer) {
mediaPlayer.reset()
playersInUse -= mediaPlayer
mediaPlayerPool += mediaPlayer
}

fun playSound(@RawRes rawResId: Int) {
val assetFileDescriptor = context.resources.openRawResourceFd(rawResId) ?: return
val
mediaPlayer = requestPlayer() ?: return

mediaPlayer.run {
setDataSource(assetFileDescriptor.fileDescriptor, assetFileDescriptor.startOffset,
assetFileDescriptor.declaredLength)
prepareAsync()
}
}
}

Now multiple sounds can play at once, and we can control the maximum number of simultaneous players to avoid using too much memory or too many codecs. And, since we’re recycling the instances, the garbage collector won’t have to run to clean up all of the old instances that have finished playing.

There are a few downsides to this approach:

  • After maxStreams sounds are playing, any additional calls to playSound are ignored until a player is freed up. You could work around this by “stealing” a player that’s already in use to play a new sound.
  • There can be significant lag between calling playSound and actually playing the sound. Even though the MediaPlayer is being reused, it’s actually a thin wrapper that controls an underlying C++ native object via JNI. The native player is destroyed each time you call MediaPlayer.reset(), and it must be recreated whenever the MediaPlayer is prepared.

Improving latency while maintaining the ability to reuse players is harder to do. Fortunately, for certain types of sounds and apps where low latency is required, there’s another option that we’ll look into next time: SoundPool.