InteractiveFrameClock.kt
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.glance.session
import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.BroadcastFrameClock
import androidx.compose.runtime.MonotonicFrameClock
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.yield
/**
* A frame clock implementation that supports interactive mode.
*
* By default, this frame clock sends frames at its baseline rate. When startInteractive() is
* called, the frame clock sends frames at its interactive rate so that awaiters can respond more
* quickly to user interactions. After the interactive timeout is passed, the frame rate is reset to
* its baseline.
*/
internal class InteractiveFrameClock(
private val scope: CoroutineScope,
private val baselineHz: Int = 5,
private val interactiveHz: Int = 20,
private val interactiveTimeoutMs: Long = 5_000,
private val nanoTime: () -> Long = { System.nanoTime() }
) : MonotonicFrameClock {
companion object {
private const val NANOSECONDS_PER_SECOND = 1_000_000_000L
private const val NANOSECONDS_PER_MILLISECOND = 1_000_000L
private const val TAG = "InteractiveFrameClock"
private const val DEBUG = false
}
private val frameClock: BroadcastFrameClock = BroadcastFrameClock { onNewAwaiters() }
private val lock = Any()
private var currentHz = baselineHz
private var lastFrame = 0L
private var interactiveCoroutine: CancellableContinuation<Unit>? = null
/**
* Set the frame rate to [interactiveHz]. After [interactiveTimeoutMs] has passed, the frame
* rate is reset to [baselineHz]. If this function is called concurrently with itself, the
* previous call is cancelled and a new interactive period is started.
*/
suspend fun startInteractive() = withTimeoutOrNull(interactiveTimeoutMs) {
stopInteractive()
suspendCancellableCoroutine { co ->
if (DEBUG) Log.d(TAG, "Starting interactive mode at ${interactiveHz}hz")
synchronized(lock) {
currentHz = interactiveHz
interactiveCoroutine = co
}
co.invokeOnCancellation {
if (DEBUG) Log.d(TAG, "Resetting frame rate to baseline at ${baselineHz}hz")
synchronized(lock) {
currentHz = baselineHz
interactiveCoroutine = null
}
}
}
}
/**
* Cancel the call to startInteractive() if running, and reset the frame rate to baseline.
*/
fun stopInteractive() {
synchronized(lock) {
interactiveCoroutine?.cancel()
}
}
override suspend fun <R> withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R {
if (DEBUG) Log.d(TAG, "received frame to run")
return frameClock.withFrameNanos(onFrame)
}
private fun onNewAwaiters() {
val now = nanoTime()
val period: Long
val minPeriod: Long
synchronized(lock) {
period = now - lastFrame
minPeriod = NANOSECONDS_PER_SECOND / currentHz
}
scope.launch {
if (period >= minPeriod) {
// Our SessionWorker updates the Session whenever Recomposer.currentState is Idle.
// When a new frame is awaiting, it usually means that the currentState is
// PendingWork. Once the frame is run, the currentState will return to Idle.
// Sometimes, the currentState can transition from Idle to PendingWork and back to
// Idle without suspending, which means that the SessionWorker cannot collect the
// intermediate PendingWork state. Because currentState is a StateFlow,
// SessionWorker will not be notified of the second Idle because it is the same
// as the state that was collected last.
// Yielding here gives the SessionWorker an opportunity to collect the PendingWork
// state and the following Idle state as distinct states.
yield()
sendFrame(now)
} else {
delay((minPeriod - period) / NANOSECONDS_PER_MILLISECOND)
sendFrame(nanoTime())
}
}
}
private fun sendFrame(now: Long) {
if (DEBUG) Log.d(TAG, "Sending next frame")
frameClock.sendFrame(now)
synchronized(lock) {
lastFrame = now
}
}
@VisibleForTesting
internal fun currentHz() = synchronized(lock) { currentHz }
}