Introduce shared throttle
Summary: Even with batching, updates can get split in half due to fact that each litho view has its own independant throttle. Eventually they will drift and and a traversal will get scheduled past the current frame for some of the views. It results in artifacts in the visualiser and will make time travelling wonky The Reviewed By: lblasa Differential Revision: D42606932 fbshipit-source-id: c4cdf729302a380928b4d8720a59d5f7f6ff645a
This commit is contained in:
committed by
Facebook GitHub Bot
parent
b5392fb818
commit
73afa391f8
@@ -12,6 +12,7 @@ import com.facebook.flipper.plugins.uidebugger.common.BitmapPool
|
|||||||
import com.facebook.flipper.plugins.uidebugger.descriptors.DescriptorRegister
|
import com.facebook.flipper.plugins.uidebugger.descriptors.DescriptorRegister
|
||||||
import com.facebook.flipper.plugins.uidebugger.observers.TreeObserverFactory
|
import com.facebook.flipper.plugins.uidebugger.observers.TreeObserverFactory
|
||||||
import com.facebook.flipper.plugins.uidebugger.observers.TreeObserverManager
|
import com.facebook.flipper.plugins.uidebugger.observers.TreeObserverManager
|
||||||
|
import com.facebook.flipper.plugins.uidebugger.scheduler.SharedThrottle
|
||||||
import com.facebook.flipper.plugins.uidebugger.traversal.PartialLayoutTraversal
|
import com.facebook.flipper.plugins.uidebugger.traversal.PartialLayoutTraversal
|
||||||
|
|
||||||
data class Context(
|
data class Context(
|
||||||
@@ -24,7 +25,7 @@ data class Context(
|
|||||||
PartialLayoutTraversal(descriptorRegister, observerFactory)
|
PartialLayoutTraversal(descriptorRegister, observerFactory)
|
||||||
|
|
||||||
val treeObserverManager = TreeObserverManager(this)
|
val treeObserverManager = TreeObserverManager(this)
|
||||||
|
val sharedThrottle: SharedThrottle = SharedThrottle()
|
||||||
val bitmapPool = BitmapPool()
|
val bitmapPool = BitmapPool()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import com.facebook.flipper.plugins.uidebugger.LogTag
|
|||||||
import com.facebook.flipper.plugins.uidebugger.core.ApplicationRef
|
import com.facebook.flipper.plugins.uidebugger.core.ApplicationRef
|
||||||
import com.facebook.flipper.plugins.uidebugger.core.Context
|
import com.facebook.flipper.plugins.uidebugger.core.Context
|
||||||
import com.facebook.flipper.plugins.uidebugger.core.RootViewResolver
|
import com.facebook.flipper.plugins.uidebugger.core.RootViewResolver
|
||||||
|
import com.facebook.flipper.plugins.uidebugger.util.objectIdentity
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Responsible for observing the activity stack and managing the subscription to the top most
|
* Responsible for observing the activity stack and managing the subscription to the top most
|
||||||
@@ -35,9 +36,14 @@ class ApplicationTreeObserver(val context: Context) : TreeObserver<ApplicationRe
|
|||||||
|
|
||||||
override fun onRootViewsChanged(rootViews: List<View>) {
|
override fun onRootViewsChanged(rootViews: List<View>) {
|
||||||
Log.i(LogTag, "Root views updated, num ${rootViews.size}")
|
Log.i(LogTag, "Root views updated, num ${rootViews.size}")
|
||||||
processUpdate(context, applicationRef)
|
context.sharedThrottle.trigger()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
context.sharedThrottle.registerCallback(this.objectIdentity()) {
|
||||||
|
traverseAndSend(context, applicationRef)
|
||||||
|
}
|
||||||
|
|
||||||
context.applicationRef.rootsResolver.attachListener(rootViewListener)
|
context.applicationRef.rootsResolver.attachListener(rootViewListener)
|
||||||
// On subscribe, trigger a traversal on whatever roots we have
|
// On subscribe, trigger a traversal on whatever roots we have
|
||||||
rootViewListener.onRootViewsChanged(applicationRef.rootsResolver.rootViews())
|
rootViewListener.onRootViewsChanged(applicationRef.rootsResolver.rootViews())
|
||||||
@@ -48,5 +54,6 @@ class ApplicationTreeObserver(val context: Context) : TreeObserver<ApplicationRe
|
|||||||
|
|
||||||
override fun unsubscribe() {
|
override fun unsubscribe() {
|
||||||
context.applicationRef.rootsResolver.attachListener(null)
|
context.applicationRef.rootsResolver.attachListener(null)
|
||||||
|
context.sharedThrottle.deregisterCallback(this.objectIdentity())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,63 +13,62 @@ import android.view.ViewTreeObserver
|
|||||||
import com.facebook.flipper.plugins.uidebugger.LogTag
|
import com.facebook.flipper.plugins.uidebugger.LogTag
|
||||||
import com.facebook.flipper.plugins.uidebugger.common.BitmapPool
|
import com.facebook.flipper.plugins.uidebugger.common.BitmapPool
|
||||||
import com.facebook.flipper.plugins.uidebugger.core.Context
|
import com.facebook.flipper.plugins.uidebugger.core.Context
|
||||||
import com.facebook.flipper.plugins.uidebugger.scheduler.throttleLatest
|
import com.facebook.flipper.plugins.uidebugger.util.objectIdentity
|
||||||
import java.lang.ref.WeakReference
|
import java.lang.ref.WeakReference
|
||||||
import kotlinx.coroutines.*
|
|
||||||
|
|
||||||
typealias DecorView = View
|
typealias DecorView = View
|
||||||
|
|
||||||
/** Responsible for subscribing to updates to the content view of an activity */
|
/** Responsible for subscribing to updates to the content view of an activity */
|
||||||
class DecorViewObserver(val context: Context) : TreeObserver<DecorView>() {
|
class DecorViewObserver(val context: Context) : TreeObserver<DecorView>() {
|
||||||
|
|
||||||
private val throttleTimeMs = 500L
|
|
||||||
|
|
||||||
private var nodeRef: WeakReference<View>? = null
|
private var nodeRef: WeakReference<View>? = null
|
||||||
private var listener: ViewTreeObserver.OnPreDrawListener? = null
|
private var preDrawListener: ViewTreeObserver.OnPreDrawListener? = null
|
||||||
|
|
||||||
override val type = "DecorView"
|
override val type = "DecorView"
|
||||||
|
|
||||||
private val waitScope = CoroutineScope(Dispatchers.IO)
|
|
||||||
private val mainScope = CoroutineScope(Dispatchers.Main)
|
|
||||||
|
|
||||||
override fun subscribe(node: Any) {
|
override fun subscribe(node: Any) {
|
||||||
node as View
|
node as View
|
||||||
nodeRef = WeakReference(node)
|
nodeRef = WeakReference(node)
|
||||||
|
|
||||||
Log.i(LogTag, "Subscribing to decor view changes")
|
Log.i(LogTag, "Subscribing to decor view changes")
|
||||||
|
|
||||||
val throttledUpdate =
|
context.sharedThrottle.registerCallback(this.objectIdentity()) {
|
||||||
throttleLatest<WeakReference<View>?>(throttleTimeMs, waitScope, mainScope) { weakView ->
|
nodeRef?.get()?.let { traverseAndSendWithSnapshot() }
|
||||||
weakView?.get()?.let { view ->
|
}
|
||||||
var snapshotBitmap: BitmapPool.ReusableBitmap? = null
|
|
||||||
if (view.width > 0 && view.height > 0) {
|
|
||||||
snapshotBitmap = context.bitmapPool.getBitmap(node.width, node.height)
|
|
||||||
}
|
|
||||||
processUpdate(context, view, snapshotBitmap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
listener =
|
preDrawListener =
|
||||||
ViewTreeObserver.OnPreDrawListener {
|
ViewTreeObserver.OnPreDrawListener {
|
||||||
throttledUpdate(nodeRef)
|
context.sharedThrottle.trigger()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
node.viewTreeObserver.addOnPreDrawListener(listener)
|
node.viewTreeObserver.addOnPreDrawListener(preDrawListener)
|
||||||
|
|
||||||
// It can be the case that the DecorView the current observer owns has already
|
// It can be the case that the DecorView the current observer owns has already
|
||||||
// drawn. In this case, manually trigger an update.
|
// drawn. In this case, manually trigger an update.
|
||||||
throttledUpdate(nodeRef)
|
traverseAndSendWithSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun traverseAndSendWithSnapshot() {
|
||||||
|
nodeRef?.get()?.let { view ->
|
||||||
|
var snapshotBitmap: BitmapPool.ReusableBitmap? = null
|
||||||
|
if (view.width > 0 && view.height > 0) {
|
||||||
|
snapshotBitmap = context.bitmapPool.getBitmap(view.width, view.height)
|
||||||
|
}
|
||||||
|
traverseAndSend(context, view, snapshotBitmap)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun unsubscribe() {
|
override fun unsubscribe() {
|
||||||
Log.i(LogTag, "Unsubscribing from decor view changes")
|
Log.i(LogTag, "Unsubscribing from decor view changes")
|
||||||
|
|
||||||
listener.let {
|
preDrawListener.let {
|
||||||
nodeRef?.get()?.viewTreeObserver?.removeOnPreDrawListener(it)
|
nodeRef?.get()?.viewTreeObserver?.removeOnPreDrawListener(it)
|
||||||
listener = null
|
preDrawListener = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
context.sharedThrottle.deregisterCallback(this.objectIdentity())
|
||||||
|
nodeRef?.clear()
|
||||||
nodeRef = null
|
nodeRef = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ abstract class TreeObserver<T> {
|
|||||||
abstract fun unsubscribe()
|
abstract fun unsubscribe()
|
||||||
|
|
||||||
/** Traverses the layout hierarchy while managing any encountered child observers. */
|
/** Traverses the layout hierarchy while managing any encountered child observers. */
|
||||||
fun processUpdate(
|
fun traverseAndSend(
|
||||||
context: Context,
|
context: Context,
|
||||||
root: Any,
|
root: Any,
|
||||||
snapshotBitmap: BitmapPool.ReusableBitmap? = null
|
snapshotBitmap: BitmapPool.ReusableBitmap? = null
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ class TreeObserverManager(val context: Context) {
|
|||||||
|
|
||||||
private fun sendBatchedUpdate(batchedUpdate: BatchedUpdate) {
|
private fun sendBatchedUpdate(batchedUpdate: BatchedUpdate) {
|
||||||
|
|
||||||
|
Log.i(
|
||||||
|
LogTag,
|
||||||
|
"Got update from ${batchedUpdate.updates.size} observers at time ${batchedUpdate.frameTimeMs}")
|
||||||
val onWorkerThread = System.currentTimeMillis()
|
val onWorkerThread = System.currentTimeMillis()
|
||||||
|
|
||||||
val nodes = batchedUpdate.updates.flatMap { it.deferredNodes.map { it.value() } }
|
val nodes = batchedUpdate.updates.flatMap { it.deferredNodes.map { it.value() } }
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
||||||
*
|
|
||||||
* This source code is licensed under the MIT license found in the
|
|
||||||
* LICENSE file in the root directory of this source tree.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.facebook.flipper.plugins.uidebugger.scheduler
|
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Throttle the execution of an executable for the specified interval.
|
|
||||||
*
|
|
||||||
* How does it work?
|
|
||||||
*
|
|
||||||
* The function `throttleLatest` returns a proxy for the given executable. This proxy captures the
|
|
||||||
* latest argument/param that was used on the last invocation. If the throttle job does not exist or
|
|
||||||
* has already completed, then create a new one.
|
|
||||||
*
|
|
||||||
* The job will wait on the waiting scope for the given amount of specified ms. Once it finishes
|
|
||||||
* waiting, then it will execute the given executable on the main scope with the latest captured
|
|
||||||
* param.
|
|
||||||
*/
|
|
||||||
fun <T> throttleLatest(
|
|
||||||
intervalMs: Long,
|
|
||||||
waitScope: CoroutineScope,
|
|
||||||
mainScope: CoroutineScope,
|
|
||||||
executable: (T) -> Unit
|
|
||||||
): (T) -> Unit {
|
|
||||||
var throttleJob: Job? = null
|
|
||||||
var latestParam: T
|
|
||||||
return { param: T ->
|
|
||||||
latestParam = param
|
|
||||||
if (throttleJob == null || throttleJob?.isCompleted == true) {
|
|
||||||
throttleJob =
|
|
||||||
waitScope.launch {
|
|
||||||
delay(intervalMs)
|
|
||||||
mainScope.launch { executable(latestParam) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.facebook.flipper.plugins.uidebugger.scheduler
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The class makes the following guarantees
|
||||||
|
* 1. All registered callbacks will be called on the same frame at the same time
|
||||||
|
* 2. The callbacks will never be called more often than the min interval
|
||||||
|
* 3. If it has been > min interval since the callbacks was last called we will call the callbacks
|
||||||
|
* immediately
|
||||||
|
* 4. If an event comes in within the min interval of the last firing we will schedule another
|
||||||
|
* firing at the next possible moment
|
||||||
|
*
|
||||||
|
* The reason we need this is because with an independent throttle per observer you end up with
|
||||||
|
* updates occurring across different frames,
|
||||||
|
*
|
||||||
|
* WARNING: Not thread safe, should only be called on main thread. Also It is important to
|
||||||
|
* deregister to avoid leaks
|
||||||
|
*/
|
||||||
|
class SharedThrottle(
|
||||||
|
private val executionScope: CoroutineScope = CoroutineScope(Dispatchers.Main)
|
||||||
|
) {
|
||||||
|
|
||||||
|
private var job: Job? = null
|
||||||
|
private val callbacks = mutableMapOf<Int, () -> Unit>()
|
||||||
|
private var latestInvocationId: Long = 0
|
||||||
|
|
||||||
|
fun registerCallback(id: Int, callback: () -> Unit) {
|
||||||
|
callbacks[id] = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deregisterCallback(id: Int) {
|
||||||
|
callbacks.remove(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun trigger() {
|
||||||
|
latestInvocationId += 1
|
||||||
|
|
||||||
|
if (job == null || job?.isCompleted == true) {
|
||||||
|
job =
|
||||||
|
executionScope.launch {
|
||||||
|
var i = 0
|
||||||
|
do {
|
||||||
|
val thisInvocationId = latestInvocationId
|
||||||
|
|
||||||
|
callbacks.values.toList().forEach { callback -> callback() }
|
||||||
|
|
||||||
|
val delayTime = exponentialBackOff(base = 250, exp = 1.5, max = 1000, i = i).toLong()
|
||||||
|
delay(delayTime)
|
||||||
|
i++
|
||||||
|
|
||||||
|
// if we haven't received an call since we executed break out and let a new job be
|
||||||
|
// created which, otherwise we loop which executes again at the next appropriate time
|
||||||
|
// since we have already waited
|
||||||
|
} while (thisInvocationId != latestInvocationId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun exponentialBackOff(base: Long, exp: Double, max: Long, i: Int): Double {
|
||||||
|
return Math.min(base * Math.pow(exp, i.toDouble()), max.toDouble())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user