I've found some GIF animation library, which has a background thread that constantly decodes the current frame into a bitmap, being a producer to other thread :
@Volatile
private var mIsPlaying: Boolean = false
...
while (mIsRunning) {
if (mIsPlaying) {
val delay = mGifDecoder.decodeNextFrame()
Thread.sleep(delay.toLong())
i = (i + 1) % frameCount
listener.onGotFrame(bitmap, i, frameCount)
}
}
The sample POC I've made for this is available here.
This is inefficient, because when the thread gets to a point that mIsPlaying
is false, it just waits there and constantly checks it. In fact, it causes this thread to do more CPU usage somehow (I checked via the profiler).
In fact, it goes from 3-5% of CPU, to 12-14% CPU.
I had a good knowledge about threads in the past, and I know that simply putting a wait
and notify
is dangerous as it could still cause the thread to wait on some rare cases. For example when it identified that it should wait, and then before it starts to wait, the outside thread marks it that it shouldn't wait.
This behavior is called "busy spinning" or "Busy Waiting" , and there are actually some solutions about it, in the case of multiple threads that need to work together, here .
But here I think it's a bit different. The wait isn't for some thread to finish its work. It's for temporary waiting.
Another issue here is that the consumer thread is the UI thread, as it is the one that needs to get the bitmap and view it, so it can't just wait work like a consumer-producer solution (UI must never wait, as it can cause "jank") .
What's the proper way to avoid spinning here?
So I decided to use wait-notify mechanism, as I couldn't find any nice class to handle this case. This requires delicate thinking, as using threads in the wrong way can cause (on very rare cases) infinite waiting and other weird things.
I've decided to use synchronized
even on the UI thread, but I use it while promising it won't be long there, ever. That's because the UI thread should not wait for other threads, in general. I could use a thread-pool (of size 1) for this, to avoid the UI thread from waiting on the synchronized part, but I think it's good enough.
Here's my modified code for the gifPlayer:
class GifPlayer(private val listener: GifListener) : Runnable {
private var playThread: Thread? = null
private val gifDecoder: GifDecoder = GifDecoder()
private var sourceType: SourceType? = null
private var filePath: String? = null
private var sourceBuffer: ByteArray? = null
private var isPlaying = AtomicBoolean(false)
interface GifListener {
fun onGotFrame(bitmap: Bitmap, frame: Int, frameCount: Int)
fun onError()
}
@UiThread
fun setFilePath(filePath: String) {
sourceType = SourceType.SOURCE_PATH
this.filePath = filePath
}
@UiThread
fun setBuffer(buffer: ByteArray) {
sourceType = SourceType.SOURCE_BUFFER
sourceBuffer = buffer
}
@UiThread
fun start() {
if (sourceType != null) {
playThread = Thread(this)
synchronized(this) {
isPlaying.set(true)
}
playThread!!.start()
}
}
@UiThread
fun stop() {
playThread?.interrupt()
}
@UiThread
fun pause() {
synchronized(this) {
isPlaying.set(false)
(this as java.lang.Object).notify()
}
}
@UiThread
fun resume() {
synchronized(this) {
isPlaying.set(true)
(this as java.lang.Object).notify()
}
}
@UiThread
fun toggle() {
synchronized(this) {
isPlaying.set(!isPlaying.get())
(this as java.lang.Object).notify()
}
}
override fun run() {
try {
val isLoadOk: Boolean = if (sourceType == SourceType.SOURCE_PATH) {
gifDecoder.load(filePath)
} else {
gifDecoder.load(sourceBuffer)
}
val bitmap = gifDecoder.bitmap
if (!isLoadOk || bitmap == null) {
listener.onError()
gifDecoder.recycle()
return
}
var i = -1
val frameCount = gifDecoder.frameCount
gifDecoder.setCurIndex(i)
while (true) {
if (isPlaying.get()) {
val delay = gifDecoder.decodeNextFrame()
Thread.sleep(delay.toLong())
i = (i + 1) % frameCount
listener.onGotFrame(bitmap, i, frameCount)
} else {
synchronized(this@GifPlayer) {
if (!isPlaying.get())
(this@GifPlayer as java.lang.Object).wait()
}
}
}
} catch (interrupted: InterruptedException) {
} catch (e: Exception) {
e.printStackTrace()
listener.onError()
} finally {
}
}
internal enum class SourceType {
SOURCE_PATH, SOURCE_BUFFER
}
}
After some work, I've got a nice way to do it using HandlerThread. I think it's nicer and probably has better stability. Here's the code:
open class GifPlayer(private val listener: GifListener) {
private val uiHandler = Handler(Looper.getMainLooper())
private var playerHandlerThread: HandlerThread? = null
private var playerHandler: Handler? = null
private val gifDecoder: GifDecoder = GifDecoder()
private var currentFrame: Int = -1
var state: State = State.IDLE
private set
private val playRunnable: Runnable
enum class State {
IDLE, PAUSED, PLAYING, RECYCLED, ERROR
}
interface GifListener {
fun onGotFrame(bitmap: Bitmap, frame: Int, frameCount: Int)
fun onError()
}
init {
playRunnable = object : Runnable {
override fun run() {
val frameCount = gifDecoder.frameCount
gifDecoder.setCurIndex(currentFrame)
currentFrame = (currentFrame + 1) % frameCount
val bitmap = gifDecoder.bitmap
val delay = gifDecoder.decodeNextFrame().toLong()
uiHandler.post {
listener.onGotFrame(bitmap, currentFrame, frameCount)
if (state == State.PLAYING)
playerHandler!!.postDelayed(this, delay)
}
}
}
}
@Suppress("unused")
protected fun finalize() {
stop()
}
@UiThread
fun start(filePath: String): Boolean {
if (state != State.IDLE)
return false
currentFrame = -1
state = State.PLAYING
playerHandlerThread = HandlerThread("GifPlayer")
playerHandlerThread!!.start()
playerHandler = Handler(playerHandlerThread!!.looper)
playerHandler!!.post {
gifDecoder.load(filePath)
val bitmap = gifDecoder.bitmap
if (bitmap != null) {
playRunnable.run()
} else {
gifDecoder.recycle()
uiHandler.post {
state = State.ERROR
listener.onError()
}
return@post
}
}
return true
}
@UiThread
fun stop(): Boolean {
if (state == State.IDLE)
return false
state = State.IDLE
playerHandler!!.removeCallbacks(playRunnable)
playerHandlerThread!!.quit()
playerHandlerThread = null
playerHandler = null
return true
}
@UiThread
fun pause(): Boolean {
if (state != State.PLAYING)
return false
state = State.PAUSED
playerHandler?.removeCallbacks(playRunnable)
return true
}
@UiThread
fun resume(): Boolean {
if (state != State.PAUSED)
return false
state = State.PLAYING
playerHandler?.removeCallbacks(playRunnable)
playRunnable.run()
return true
}
@UiThread
fun toggle(): Boolean {
when (state) {
State.PLAYING -> pause()
State.PAUSED -> resume()
else -> return false
}
return true
}
}