Refactor android view observation

Summary:
The previous approach was designed for a world that didnt happen and was extremely confusing and allowed for states that didnt make a lot of sense. E.g it was possible we were snapshotting multiple views.

The new model is much simpler. we still depend on the root view resolver to tell us about root decor views but now we just attach a predraw listener to the top most view and push out the updates. This is handled by the new class decor view tracker which is a replacement for all the observer business

Additionally we use a conflated chanel in the update queue, this means if the background processing is slow we wont keep adding new frames to the queue, we just keep 1 and the most recent frame

Partial layout traversal -> Layout traversal as traversal is now always from top to bottom of the whole application

Reviewed By: lblasa

Differential Revision: D50791527

fbshipit-source-id: 43640723aefa775aa7b74065f405cc08224ed8b8
This commit is contained in:
Luke De Feo
2023-11-02 12:29:07 -07:00
committed by Facebook GitHub Bot
parent 87cb9bd77a
commit c93c494ef4
16 changed files with 359 additions and 615 deletions

View File

@@ -26,7 +26,6 @@ import com.facebook.flipper.plugins.uidebugger.UIDebuggerFlipperPlugin;
import com.facebook.flipper.plugins.uidebugger.core.UIDContext; import com.facebook.flipper.plugins.uidebugger.core.UIDContext;
import com.facebook.flipper.plugins.uidebugger.descriptors.DescriptorRegister; import com.facebook.flipper.plugins.uidebugger.descriptors.DescriptorRegister;
import com.facebook.flipper.plugins.uidebugger.litho.UIDebuggerLithoSupport; import com.facebook.flipper.plugins.uidebugger.litho.UIDebuggerLithoSupport;
import com.facebook.flipper.plugins.uidebugger.observers.TreeObserverFactory;
import com.facebook.litho.config.ComponentsConfiguration; import com.facebook.litho.config.ComponentsConfiguration;
import com.facebook.litho.editor.flipper.LithoFlipperDescriptors; import com.facebook.litho.editor.flipper.LithoFlipperDescriptors;
import java.util.Arrays; import java.util.Arrays;
@@ -63,7 +62,6 @@ public final class FlipperInitializer {
client.addPlugin(NavigationFlipperPlugin.getInstance()); client.addPlugin(NavigationFlipperPlugin.getInstance());
DescriptorRegister descriptorRegister = DescriptorRegister.Companion.withDefaults(); DescriptorRegister descriptorRegister = DescriptorRegister.Companion.withDefaults();
TreeObserverFactory treeObserverFactory = TreeObserverFactory.Companion.withDefaults();
UIDContext uidContext = UIDContext.Companion.create((Application) context); UIDContext uidContext = UIDContext.Companion.create((Application) context);
UIDebuggerLithoSupport.INSTANCE.enable(uidContext); UIDebuggerLithoSupport.INSTANCE.enable(uidContext);
UIDebuggerComposeSupport.INSTANCE.enable(uidContext); UIDebuggerComposeSupport.INSTANCE.enable(uidContext);

View File

@@ -48,7 +48,8 @@ class UIDebuggerFlipperPlugin(val context: UIDContext) : FlipperPlugin {
MetadataUpdateEvent.serializer(), MetadataUpdateEvent.serializer(),
MetadataUpdateEvent(MetadataRegister.extractPendingMetadata()))) MetadataUpdateEvent(MetadataRegister.extractPendingMetadata())))
context.treeObserverManager.start() context.updateQueue.start()
context.decorViewTracker.start()
context.connectionListeners.forEach { it.onConnect() } context.connectionListeners.forEach { it.onConnect() }
} }
@@ -59,7 +60,9 @@ class UIDebuggerFlipperPlugin(val context: UIDContext) : FlipperPlugin {
MetadataRegister.reset() MetadataRegister.reset()
context.treeObserverManager.stop() context.decorViewTracker.stop()
context.updateQueue.stop()
context.bitmapPool.recycleAll() context.bitmapPool.recycleAll()
context.connectionListeners.forEach { it.onDisconnect() } context.connectionListeners.forEach { it.onDisconnect() }
context.clearFrameworkEvents() context.clearFrameworkEvents()

View File

@@ -17,7 +17,8 @@ class ApplicationRef(val application: Application) {
// the root view resolver will contain all root views 100% It is needed for 2 cases: // the root view resolver will contain all root views 100% It is needed for 2 cases:
// 1. In some cases an activity will not be picked up by the activity tracker, // 1. In some cases an activity will not be picked up by the activity tracker,
// the root view resolver will at least find the decor view // the root view resolver will at least find the decor view, this is the case for various
// kinds of custom overlays
// 2. Dialog fragments // 2. Dialog fragments
val rootsResolver: RootViewResolver = RootViewResolver() val rootsResolver: RootViewResolver = RootViewResolver()

View File

@@ -0,0 +1,114 @@
/*
* 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.core
import android.util.Log
import android.view.View
import android.view.ViewTreeObserver
import com.facebook.flipper.plugins.uidebugger.LogTag
import com.facebook.flipper.plugins.uidebugger.common.BitmapPool
import com.facebook.flipper.plugins.uidebugger.descriptors.ViewDescriptor
import com.facebook.flipper.plugins.uidebugger.util.StopWatch
import com.facebook.flipper.plugins.uidebugger.util.Throttler
import com.facebook.flipper.plugins.uidebugger.util.objectIdentity
/**
* The responsibility of this class is to find the top most decor view and add a pre draw observer
* to it This predraw observer triggers a full traversal of the UI. There should only ever be one
* active predraw listener at once
*/
class DecorViewTracker(val context: UIDContext) {
private var currentDecorView: View? = null
private var preDrawListener: ViewTreeObserver.OnPreDrawListener? = null
private val mStopWatch = StopWatch()
fun start() {
Log.i(LogTag, "Subscribing activity / root view changes")
val applicationRef = context.applicationRef
val rootViewListener =
object : RootViewResolver.Listener {
override fun onRootViewAdded(rootView: View) {}
override fun onRootViewRemoved(rootView: View) {}
override fun onRootViewsChanged(rootViews: List<View>) {
// remove predraw listen from current view as its going away or will be covered
currentDecorView?.viewTreeObserver?.removeOnPreDrawListener(preDrawListener)
// setup new listener on top most view
val topView = rootViews.lastOrNull()
val throttler = Throttler(500) { currentDecorView?.let { traverseSnapshotAndSend(it) } }
if (topView != null) {
preDrawListener =
ViewTreeObserver.OnPreDrawListener {
throttler.trigger()
true
}
topView.viewTreeObserver.addOnPreDrawListener(preDrawListener)
currentDecorView = topView
Log.i(LogTag, "Added pre draw listener to ${topView.objectIdentity()}")
// schedule traversal immediately when we detect a new decor view
throttler.trigger()
} else {
Log.i(LogTag, "Stack is empty")
}
}
}
context.applicationRef.rootsResolver.attachListener(rootViewListener)
// On subscribe, trigger a traversal on whatever roots we have
rootViewListener.onRootViewsChanged(applicationRef.rootsResolver.rootViews())
Log.i(LogTag, "${context.applicationRef.rootsResolver.rootViews().size} root views")
Log.i(LogTag, "${context.applicationRef.activitiesStack.size} activities")
}
fun stop() {
context.applicationRef.rootsResolver.attachListener(null)
currentDecorView?.viewTreeObserver?.removeOnPreDrawListener(preDrawListener)
currentDecorView = null
preDrawListener = null
}
private fun traverseSnapshotAndSend(decorView: View) {
val startTimestamp = System.currentTimeMillis()
val (nodes, traversalTime) =
StopWatch.time { context.layoutTraversal.traverse(context.applicationRef) }
mStopWatch.start()
var snapshotBitmap: BitmapPool.ReusableBitmap? = null
if (decorView.width > 0 && decorView.height > 0) {
snapshotBitmap = context.bitmapPool.getBitmap(decorView.width, decorView.height)
context.bitmapPool.getBitmap(decorView.width, decorView.height)
Log.i(
LogTag,
"Snapshotting view ${ViewDescriptor.getId(decorView)}",
)
ViewDescriptor.getSnapshot(decorView, snapshotBitmap.bitmap)
}
val snapshotTime = mStopWatch.stop()
context.updateQueue.enqueueUpdate(
Update(
ViewDescriptor.getId(decorView),
nodes,
startTimestamp,
traversalTime,
snapshotTime,
System.currentTimeMillis(),
snapshotBitmap))
}
}

View File

@@ -5,11 +5,10 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
package com.facebook.flipper.plugins.uidebugger.traversal package com.facebook.flipper.plugins.uidebugger.core
import android.util.Log import android.util.Log
import com.facebook.flipper.plugins.uidebugger.LogTag import com.facebook.flipper.plugins.uidebugger.LogTag
import com.facebook.flipper.plugins.uidebugger.core.UIDContext
import com.facebook.flipper.plugins.uidebugger.descriptors.Id import com.facebook.flipper.plugins.uidebugger.descriptors.Id
import com.facebook.flipper.plugins.uidebugger.descriptors.NodeDescriptor import com.facebook.flipper.plugins.uidebugger.descriptors.NodeDescriptor
import com.facebook.flipper.plugins.uidebugger.model.Node import com.facebook.flipper.plugins.uidebugger.model.Node
@@ -23,21 +22,20 @@ import com.facebook.flipper.plugins.uidebugger.util.MaybeDeferred
* - The first item in the pair is the visited nodes. * - The first item in the pair is the visited nodes.
* - The second item are any observable roots discovered. * - The second item are any observable roots discovered.
*/ */
class PartialLayoutTraversal( class LayoutTraversal(
private val context: UIDContext, private val context: UIDContext,
) { ) {
@Suppress("unchecked_cast") @Suppress("unchecked_cast")
private fun NodeDescriptor<*>.asAny(): NodeDescriptor<Any> = this as NodeDescriptor<Any> private fun NodeDescriptor<*>.asAny(): NodeDescriptor<Any> = this as NodeDescriptor<Any>
fun traverse(root: Any, parentId: Id?): Pair<List<MaybeDeferred<Node>>, List<Pair<Any, Id?>>> { fun traverse(root: Any): MutableList<MaybeDeferred<Node>> {
val visited = mutableListOf<MaybeDeferred<Node>>() val visited = mutableListOf<MaybeDeferred<Node>>()
val observableRoots = mutableListOf<Pair<Any, Id?>>()
// cur and parent Id // cur and parent Id
val stack = mutableListOf<Pair<Any, Id?>>() val stack = mutableListOf<Pair<Any, Id?>>()
stack.add(Pair(root, parentId)) stack.add(Pair(root, null))
val shallow = mutableSetOf<Any>() val shallow = mutableSetOf<Any>()
@@ -45,11 +43,6 @@ class PartialLayoutTraversal(
val (node, parentId) = stack.removeLast() val (node, parentId) = stack.removeLast()
try { try {
// If we encounter a node that has it own observer, don't traverse
if (node != root && context.observerFactory.hasObserverFor(node)) {
observableRoots.add((node to parentId))
continue
}
val descriptor = val descriptor =
context.descriptorRegister.descriptorForClassUnsafe(node::class.java).asAny() context.descriptorRegister.descriptorForClassUnsafe(node::class.java).asAny()
@@ -126,6 +119,6 @@ class PartialLayoutTraversal(
} }
} }
return Pair(visited, observableRoots) return visited
} }
} }

View File

@@ -14,10 +14,6 @@ import com.facebook.flipper.plugins.uidebugger.descriptors.DescriptorRegister
import com.facebook.flipper.plugins.uidebugger.model.FrameworkEvent import com.facebook.flipper.plugins.uidebugger.model.FrameworkEvent
import com.facebook.flipper.plugins.uidebugger.model.FrameworkEventMetadata import com.facebook.flipper.plugins.uidebugger.model.FrameworkEventMetadata
import com.facebook.flipper.plugins.uidebugger.model.TraversalError import com.facebook.flipper.plugins.uidebugger.model.TraversalError
import com.facebook.flipper.plugins.uidebugger.observers.TreeObserverFactory
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 kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
interface ConnectionListener { interface ConnectionListener {
@@ -30,16 +26,15 @@ class UIDContext(
val applicationRef: ApplicationRef, val applicationRef: ApplicationRef,
val connectionRef: ConnectionRef, val connectionRef: ConnectionRef,
val descriptorRegister: DescriptorRegister, val descriptorRegister: DescriptorRegister,
val observerFactory: TreeObserverFactory,
val frameworkEventMetadata: MutableList<FrameworkEventMetadata>, val frameworkEventMetadata: MutableList<FrameworkEventMetadata>,
val connectionListeners: MutableList<ConnectionListener>, val connectionListeners: MutableList<ConnectionListener>,
private val pendingFrameworkEvents: MutableList<FrameworkEvent> private val pendingFrameworkEvents: MutableList<FrameworkEvent>
) { ) {
val layoutTraversal: PartialLayoutTraversal = PartialLayoutTraversal(this) val decorViewTracker = DecorViewTracker(this)
val updateQueue = UpdateQueue(this)
val layoutTraversal: LayoutTraversal = LayoutTraversal(this)
val treeObserverManager = TreeObserverManager(this)
val sharedThrottle: SharedThrottle = SharedThrottle()
val bitmapPool = BitmapPool() val bitmapPool = BitmapPool()
fun addFrameworkEvent(frameworkEvent: FrameworkEvent) { fun addFrameworkEvent(frameworkEvent: FrameworkEvent) {
@@ -69,7 +64,6 @@ class UIDContext(
ApplicationRef(application), ApplicationRef(application),
ConnectionRef(null), ConnectionRef(null),
descriptorRegister = DescriptorRegister.withDefaults(), descriptorRegister = DescriptorRegister.withDefaults(),
observerFactory = TreeObserverFactory.withDefaults(),
frameworkEventMetadata = mutableListOf(), frameworkEventMetadata = mutableListOf(),
connectionListeners = mutableListOf(), connectionListeners = mutableListOf(),
pendingFrameworkEvents = mutableListOf()) pendingFrameworkEvents = mutableListOf())

View File

@@ -0,0 +1,166 @@
/*
* 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.core
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.util.Base64
import android.util.Base64OutputStream
import android.util.Log
import com.facebook.flipper.plugins.uidebugger.LogTag
import com.facebook.flipper.plugins.uidebugger.common.BitmapPool
import com.facebook.flipper.plugins.uidebugger.descriptors.Id
import com.facebook.flipper.plugins.uidebugger.descriptors.MetadataRegister
import com.facebook.flipper.plugins.uidebugger.model.FrameScanEvent
import com.facebook.flipper.plugins.uidebugger.model.MetadataUpdateEvent
import com.facebook.flipper.plugins.uidebugger.model.Node
import com.facebook.flipper.plugins.uidebugger.model.PerfStatsEvent
import com.facebook.flipper.plugins.uidebugger.model.Snapshot
import com.facebook.flipper.plugins.uidebugger.model.TraversalError
import com.facebook.flipper.plugins.uidebugger.util.MaybeDeferred
import com.facebook.flipper.plugins.uidebugger.util.StopWatch
import java.io.ByteArrayOutputStream
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.CONFLATED
import kotlinx.serialization.json.Json
data class Update(
val snapshotNode: Id,
val deferredNodes: List<MaybeDeferred<Node>>,
val startTimestamp: Long,
val traversalMS: Long,
val snapshotMS: Long,
val queuedTimestamp: Long,
val snapshotBitmap: BitmapPool.ReusableBitmap?
)
/**
* Holds an update and manages a coroutine which serially reads from queue and sends to flipper
* desktop
*/
class UpdateQueue(val context: UIDContext) {
// conflated channel means we only hold 1 item and newer values override older ones,
// there is no point processing frames that the desktop cant keep up with since we only display
// the latest
private val frameChannel = Channel<Update>(CONFLATED)
private var job: Job? = null
private val workerScope = CoroutineScope(Dispatchers.IO)
private val stopWatch = StopWatch()
fun enqueueUpdate(update: Update) {
frameChannel.trySend(update)
}
/**
* 1. Sets up the root observer
* 2. Starts worker to listen to channel, which serializers and sends data over connection
*/
@SuppressLint("NewApi")
fun start() {
job =
workerScope.launch {
while (isActive) {
try {
val update = frameChannel.receive()
sendUpdate(update)
} catch (e: CancellationException) {} catch (e: java.lang.Exception) {
Log.e(LogTag, "Unexpected Error in channel ", e)
}
}
Log.i(LogTag, "Shutting down worker")
}
}
fun stop() {
job?.cancel()
job = null
// drain channel
frameChannel.tryReceive()
}
private fun sendUpdate(update: Update) {
val queuingTimeMs = System.currentTimeMillis() - update.queuedTimestamp
stopWatch.start()
val nodes =
try {
update.deferredNodes.map { it.value() }
} catch (exception: Exception) {
context.onError(
TraversalError(
"DeferredProcessing",
exception.javaClass.simpleName,
exception.message ?: "",
exception.stackTraceToString()))
return
}
val deferredComputationEndTimestamp = stopWatch.stop()
val frameworkEvents = context.extractPendingFrameworkEvents()
var snapshot: Snapshot? = null
if (update.snapshotBitmap != null) {
val stream = ByteArrayOutputStream()
val base64Stream = Base64OutputStream(stream, Base64.DEFAULT)
update.snapshotBitmap.bitmap?.compress(Bitmap.CompressFormat.PNG, 100, base64Stream)
snapshot = Snapshot(update.snapshotNode, stream.toString())
update.snapshotBitmap.readyForReuse()
}
// it is important this comes after deferred processing since the deferred processing can create
// metadata
sendMetadata()
val (serialized, serializationTimeMs) =
StopWatch.time {
Json.encodeToString(
FrameScanEvent.serializer(),
FrameScanEvent(update.startTimestamp, nodes, snapshot, frameworkEvents))
}
val (_, sendTimeMs) =
StopWatch.time { context.connectionRef.connection?.send(FrameScanEvent.name, serialized) }
Log.i(LogTag, "Sent frame with nodes ${nodes.size}")
// Note about payload size:
// Payload size is an approximation as it assumes all characters
// are ASCII encodable, this should be true for most of the payload content.
// So, assume each character will at most occupy one byte.
val perfStats =
PerfStatsEvent(
txId = update.startTimestamp,
nodesCount = nodes.size,
start = update.startTimestamp,
traversalMS = update.traversalMS,
snapshotMS = update.snapshotMS,
queuingMS = queuingTimeMs,
deferredComputationMS = deferredComputationEndTimestamp,
serializationMS = serializationTimeMs,
socketMS = sendTimeMs,
payloadSize = serialized.length)
context.connectionRef.connection?.send(
PerfStatsEvent.name, Json.encodeToString(PerfStatsEvent.serializer(), perfStats))
}
private fun sendMetadata() {
val metadata = MetadataRegister.extractPendingMetadata()
if (metadata.isNotEmpty()) {
context.connectionRef.connection?.send(
MetadataUpdateEvent.name,
Json.encodeToString(MetadataUpdateEvent.serializer(), MetadataUpdateEvent(metadata)))
}
}
}

View File

@@ -53,7 +53,6 @@ class TraversalError(
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
class PerfStatsEvent( class PerfStatsEvent(
val txId: Long, val txId: Long,
val observerType: String,
val nodesCount: Int, val nodesCount: Int,
val start: Long, val start: Long,
val traversalMS: Long, val traversalMS: Long,

View File

@@ -1,60 +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.observers
import android.util.Log
import android.view.View
import com.facebook.flipper.plugins.uidebugger.LogTag
import com.facebook.flipper.plugins.uidebugger.core.ApplicationRef
import com.facebook.flipper.plugins.uidebugger.core.RootViewResolver
import com.facebook.flipper.plugins.uidebugger.core.UIDContext
import com.facebook.flipper.plugins.uidebugger.descriptors.Id
import com.facebook.flipper.plugins.uidebugger.util.objectIdentity
/**
* Responsible for observing the activity stack and managing the subscription to the top most
* content view (decor view)
*/
class ApplicationTreeObserver(val context: UIDContext) : TreeObserver<ApplicationRef>() {
override val type = "Application"
override fun subscribe(node: Any, parentId: Id?) {
Log.i(LogTag, "Subscribing activity / root view changes")
val applicationRef = node as ApplicationRef
val rootViewListener =
object : RootViewResolver.Listener {
override fun onRootViewAdded(rootView: View) {}
override fun onRootViewRemoved(rootView: View) {}
override fun onRootViewsChanged(rootViews: List<View>) {
Log.i(LogTag, "Root views updated, num ${rootViews.size}")
context.sharedThrottle.trigger()
}
}
context.sharedThrottle.registerCallback(this.objectIdentity()) {
traverseAndSend(null, context, applicationRef)
}
context.applicationRef.rootsResolver.attachListener(rootViewListener)
// On subscribe, trigger a traversal on whatever roots we have
rootViewListener.onRootViewsChanged(applicationRef.rootsResolver.rootViews())
Log.i(LogTag, "${context.applicationRef.rootsResolver.rootViews().size} root views")
Log.i(LogTag, "${context.applicationRef.activitiesStack.size} activities")
}
override fun unsubscribe() {
context.applicationRef.rootsResolver.attachListener(null)
context.sharedThrottle.deregisterCallback(this.objectIdentity())
}
}

View File

@@ -1,91 +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.observers
import android.util.Log
import android.view.View
import android.view.ViewTreeObserver
import com.facebook.flipper.plugins.uidebugger.LogTag
import com.facebook.flipper.plugins.uidebugger.common.BitmapPool
import com.facebook.flipper.plugins.uidebugger.core.UIDContext
import com.facebook.flipper.plugins.uidebugger.descriptors.Id
import com.facebook.flipper.plugins.uidebugger.util.objectIdentity
import java.lang.ref.WeakReference
typealias DecorView = View
/** Responsible for subscribing to updates to the content view of an activity */
class DecorViewObserver(val context: UIDContext) : TreeObserver<DecorView>() {
private var nodeRef: WeakReference<View>? = null
private var preDrawListener: ViewTreeObserver.OnPreDrawListener? = null
override val type = "DecorView"
override fun subscribe(node: Any, parentId: Id?) {
node as View
nodeRef = WeakReference(node)
Log.i(LogTag, "Subscribing to decor view changes")
context.sharedThrottle.registerCallback(this.objectIdentity()) {
nodeRef?.get()?.let { traverseAndSendWithSnapshot(parentId) }
}
preDrawListener =
ViewTreeObserver.OnPreDrawListener {
context.sharedThrottle.trigger()
true
}
node.viewTreeObserver.addOnPreDrawListener(preDrawListener)
// It can be the case that the DecorView the current observer owns has already
// drawn. In this case, manually trigger an update.
traverseAndSendWithSnapshot(parentId)
}
private fun traverseAndSendWithSnapshot(parentId: Id?) {
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(
parentId,
context,
view,
snapshotBitmap,
)
}
}
override fun unsubscribe() {
Log.i(LogTag, "Unsubscribing from decor view changes")
preDrawListener.let {
nodeRef?.get()?.viewTreeObserver?.removeOnPreDrawListener(it)
preDrawListener = null
}
context.sharedThrottle.deregisterCallback(this.objectIdentity())
nodeRef?.clear()
nodeRef = null
}
}
object DecorViewTreeObserverBuilder : TreeObserverBuilder<DecorView> {
override fun canBuildFor(node: Any): Boolean {
return node.javaClass.simpleName.contains("DecorView")
}
override fun build(context: UIDContext): TreeObserver<DecorView> {
Log.i(LogTag, "Building DecorView observer")
return DecorViewObserver(context)
}
}

View File

@@ -1,115 +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.observers
import android.util.Log
import com.facebook.flipper.plugins.uidebugger.LogTag
import com.facebook.flipper.plugins.uidebugger.common.BitmapPool
import com.facebook.flipper.plugins.uidebugger.core.UIDContext
import com.facebook.flipper.plugins.uidebugger.descriptors.Id
import com.facebook.flipper.plugins.uidebugger.descriptors.NodeDescriptor
import com.facebook.flipper.plugins.uidebugger.model.FrameworkEvent
import com.facebook.flipper.plugins.uidebugger.util.objectIdentity
/*
* Represents a stateful observer that manages some subtree in the UI Hierarchy.
* It is responsible for:
* 1. Listening to the relevant framework events
* 2. Traversing the hierarchy of the managed nodes
* 3. Diffing to previous state (optional)
* 4. Pushing out updates for its entire set of managed nodes
*
* If while traversing it encounters a node type which has its own TreeObserver, it
* does not traverse that, instead it sets up a Tree observer responsible for that subtree
*
* The parent is responsible for detecting when a child observer needs to be cleaned up.
*/
abstract class TreeObserver<T> {
protected val children: MutableMap<Int, TreeObserver<*>> = mutableMapOf()
abstract val type: String
abstract fun subscribe(node: Any, parentId: Id?)
abstract fun unsubscribe()
/** Traverses the layout hierarchy while managing any encountered child observers. */
fun traverseAndSend(
parentId: Id?,
context: UIDContext,
root: Any,
snapshotBitmap: BitmapPool.ReusableBitmap? = null,
frameworkEvents: List<FrameworkEvent>? = null
) {
val traversalStartTimestamp = System.currentTimeMillis()
val (visitedNodes, observableRoots) = context.layoutTraversal.traverse(root, parentId)
// Add any new observers
observableRoots.forEach { (observable, parentId) ->
if (!children.containsKey(observable.objectIdentity())) {
context.observerFactory.createObserver(observable, context)?.let { observer ->
Log.d(
LogTag,
"Observer ${this.type} discovered new child of type ${observer.type} Node ID ${observable.objectIdentity()}")
observer.subscribe(observable, parentId)
children[observable.objectIdentity()] = observer
}
}
}
// Remove any old observers
val observableRootsIdentifiers =
observableRoots.map { (observable, _) -> observable.objectIdentity() }
val removables = mutableListOf<Id>()
children.keys.forEach { key ->
if (!observableRootsIdentifiers.contains(key)) {
children[key]?.let { observer ->
Log.d(
LogTag,
"Observer ${this.type} cleaning up child of type ${observer.type} Node ID $key")
observer.cleanUpRecursive()
}
removables.add(key)
}
}
removables.forEach { key -> children.remove(key) }
val traversalEndTimestamp = System.currentTimeMillis()
if (snapshotBitmap != null) {
@Suppress("unchecked_cast")
val descriptor =
context.descriptorRegister.descriptorForClassUnsafe(root::class.java)
as NodeDescriptor<Any>
descriptor.getSnapshot(root, snapshotBitmap.bitmap)
}
val snapshotEndTimestamp = System.currentTimeMillis()
context.treeObserverManager.enqueueUpdate(
SubtreeUpdate(
type,
root.objectIdentity(),
visitedNodes,
traversalStartTimestamp,
snapshotEndTimestamp,
(traversalEndTimestamp - traversalStartTimestamp),
(snapshotEndTimestamp - traversalEndTimestamp),
frameworkEvents,
snapshotBitmap))
}
fun cleanUpRecursive() {
Log.i(LogTag, "Cleaning up observer $this")
children.values.forEach { it.cleanUpRecursive() }
unsubscribe()
children.clear()
}
}

View File

@@ -1,45 +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.observers
import com.facebook.flipper.plugins.uidebugger.core.UIDContext
interface TreeObserverBuilder<T> {
fun canBuildFor(node: Any): Boolean
fun build(context: UIDContext): TreeObserver<T>
}
class TreeObserverFactory {
private val builders = mutableListOf<TreeObserverBuilder<*>>()
fun <T> register(builder: TreeObserverBuilder<T>) {
builders.add(builder)
}
// TODO: Not very efficient, need to cache this. Builders cannot be removed
fun hasObserverFor(node: Any): Boolean {
return builders.any { it.canBuildFor(node) }
}
// TODO: Not very efficient, need to cache this. Builders cannot be removed.
fun createObserver(node: Any, context: UIDContext): TreeObserver<*>? {
return builders.find { it.canBuildFor(node) }?.build(context)
}
companion object {
fun withDefaults(): TreeObserverFactory {
val factory = TreeObserverFactory()
// TODO: Only builder for DecorView, maybe more are needed.
factory.register(DecorViewTreeObserverBuilder)
return factory
}
}
}

View File

@@ -1,203 +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.observers
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.os.Looper
import android.util.Base64
import android.util.Base64OutputStream
import android.util.Log
import android.view.Choreographer
import com.facebook.flipper.plugins.uidebugger.LogTag
import com.facebook.flipper.plugins.uidebugger.common.BitmapPool
import com.facebook.flipper.plugins.uidebugger.core.UIDContext
import com.facebook.flipper.plugins.uidebugger.descriptors.Id
import com.facebook.flipper.plugins.uidebugger.descriptors.MetadataRegister
import com.facebook.flipper.plugins.uidebugger.model.FrameScanEvent
import com.facebook.flipper.plugins.uidebugger.model.FrameworkEvent
import com.facebook.flipper.plugins.uidebugger.model.MetadataUpdateEvent
import com.facebook.flipper.plugins.uidebugger.model.Node
import com.facebook.flipper.plugins.uidebugger.model.PerfStatsEvent
import com.facebook.flipper.plugins.uidebugger.model.Snapshot
import com.facebook.flipper.plugins.uidebugger.model.TraversalError
import com.facebook.flipper.plugins.uidebugger.util.MaybeDeferred
import java.io.ByteArrayOutputStream
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.serialization.json.Json
data class SubtreeUpdate(
val observerType: String,
val rootId: Id,
val deferredNodes: List<MaybeDeferred<Node>>,
val timestamp: Long,
val queuedTimestamp: Long,
val traversalMS: Long,
val snapshotMS: Long,
val frameworkEvents: List<FrameworkEvent>?,
val snapshot: BitmapPool.ReusableBitmap?
)
data class BatchedUpdate(val updates: List<SubtreeUpdate>, val frameTimeMs: Long)
/** Holds the root observer and manages sending updates to desktop */
class TreeObserverManager(val context: UIDContext) {
private val rootObserver = ApplicationTreeObserver(context)
private lateinit var batchedUpdates: Channel<BatchedUpdate>
private val subtreeUpdateBuffer = SubtreeUpdateBuffer(this::enqueueBatch)
private var job: Job? = null
private val workerScope = CoroutineScope(Dispatchers.IO)
private val mainScope = CoroutineScope(Dispatchers.Main)
private val txId = AtomicInteger()
fun enqueueUpdate(update: SubtreeUpdate) {
subtreeUpdateBuffer.bufferUpdate(update)
}
private fun enqueueBatch(batchedUpdate: BatchedUpdate) {
batchedUpdates.trySend(batchedUpdate)
}
/**
* 1. Sets up the root observer
* 2. Starts worker to listen to channel, which serializers and sends data over connection
*/
@SuppressLint("NewApi")
fun start() {
if (Looper.myLooper() != Looper.getMainLooper()) {
mainScope.launch { start() }
}
batchedUpdates = Channel(Channel.UNLIMITED)
rootObserver.subscribe(context.applicationRef, null)
job =
workerScope.launch {
while (isActive) {
try {
val update = batchedUpdates.receive()
sendBatchedUpdate(update)
} catch (e: CancellationException) {} catch (e: java.lang.Exception) {
Log.e(LogTag, "Unexpected Error in channel ", e)
}
}
Log.i(LogTag, "Shutting down worker")
}
}
fun stop() {
rootObserver.cleanUpRecursive()
job?.cancel()
batchedUpdates.cancel()
}
private fun sendBatchedUpdate(batchedUpdate: BatchedUpdate) {
Log.i(
LogTag,
"Got update from ${batchedUpdate.updates.size} observers at time ${batchedUpdate.frameTimeMs}")
val workerThreadStartTimestamp = System.currentTimeMillis()
val nodes =
try {
batchedUpdate.updates.flatMap { it.deferredNodes.map { it.value() } }
} catch (exception: Exception) {
context.onError(
TraversalError(
"DeferredProcessing",
exception.javaClass.simpleName,
exception.message ?: "",
exception.stackTraceToString()))
return
}
val frameworkEvents = context.extractPendingFrameworkEvents()
val snapshotUpdate = batchedUpdate.updates.find { it.snapshot != null }
val deferredComputationEndTimestamp = System.currentTimeMillis()
var snapshot: Snapshot? = null
if (snapshotUpdate?.snapshot != null) {
val stream = ByteArrayOutputStream()
val base64Stream = Base64OutputStream(stream, Base64.DEFAULT)
snapshotUpdate.snapshot.bitmap?.compress(Bitmap.CompressFormat.PNG, 100, base64Stream)
snapshot = Snapshot(snapshotUpdate.rootId, stream.toString())
snapshotUpdate.snapshot.readyForReuse()
}
// it is important this comes after deferred processing since the deferred processing can create
// metadata
sendMetadata()
val serialized =
Json.encodeToString(
FrameScanEvent.serializer(),
FrameScanEvent(batchedUpdate.frameTimeMs, nodes, snapshot, frameworkEvents))
val serialisationEndTimestamp = System.currentTimeMillis()
context.connectionRef.connection?.send(FrameScanEvent.name, serialized)
val socketEndTimestamp = System.currentTimeMillis()
Log.i(LogTag, "Sent event for batched subtree update with nodes with ${nodes.size}")
// Note about payload size:
// Payload size is an approximation as it assumes all characters
// are ASCII encodable, this should be true for most of the payload content.
// So, assume each character will at most occupy one byte.
val perfStats =
PerfStatsEvent(
txId = batchedUpdate.frameTimeMs,
observerType = "batched",
nodesCount = nodes.size,
start = batchedUpdate.updates.minOf { it.timestamp },
traversalMS = batchedUpdate.updates.maxOf { it.traversalMS },
snapshotMS = batchedUpdate.updates.maxOf { it.snapshotMS },
queuingMS =
workerThreadStartTimestamp - batchedUpdate.updates.minOf { it.queuedTimestamp },
deferredComputationMS = (deferredComputationEndTimestamp - workerThreadStartTimestamp),
serializationMS = (serialisationEndTimestamp - deferredComputationEndTimestamp),
socketMS = (socketEndTimestamp - serialisationEndTimestamp),
payloadSize = serialized.length)
context.connectionRef.connection?.send(
PerfStatsEvent.name, Json.encodeToString(PerfStatsEvent.serializer(), perfStats))
}
private fun sendMetadata() {
val metadata = MetadataRegister.extractPendingMetadata()
if (metadata.isNotEmpty()) {
context.connectionRef.connection?.send(
MetadataUpdateEvent.name,
Json.encodeToString(MetadataUpdateEvent.serializer(), MetadataUpdateEvent(metadata)))
}
}
}
/** Buffers up subtree updates until the frame is complete, should only be called on main thread */
private class SubtreeUpdateBuffer(private val onBatchReady: (BatchedUpdate) -> Unit) {
private val bufferedSubtreeUpdates = mutableListOf<SubtreeUpdate>()
fun bufferUpdate(update: SubtreeUpdate) {
if (bufferedSubtreeUpdates.isEmpty()) {
Choreographer.getInstance().postFrameCallback { frameTime ->
val updatesCopy = bufferedSubtreeUpdates.toList()
bufferedSubtreeUpdates.clear()
onBatchReady(BatchedUpdate(updatesCopy, frameTime / 1000000))
}
}
bufferedSubtreeUpdates.add(update)
}
}

View File

@@ -1,74 +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.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())
}
}

View File

@@ -0,0 +1,30 @@
/*
* 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.util
class StopWatch() {
private var startTime: Long = 0
fun start() {
startTime = System.currentTimeMillis()
}
fun stop(): Long {
return System.currentTimeMillis() - startTime
}
companion object {
fun <T> time(fn: () -> T): Pair<T, Long> {
val start = System.currentTimeMillis()
val result = fn()
val elapsed = System.currentTimeMillis() - start
return Pair(result, elapsed)
}
}
}

View File

@@ -0,0 +1,34 @@
/*
* 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.util
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* This class will throttle calls into a callback. E.g if interval is 500ms and you receive triggers
* at t=0, 100, 300 400, the callback will only be triggered at t=500
*/
class Throttler<T>(private val intervalMs: Long, val callback: () -> T) {
private val executionScope: CoroutineScope = CoroutineScope(Dispatchers.Main)
private var throttleJob: Job? = null
fun trigger() {
if (throttleJob == null || throttleJob?.isCompleted == true) {
throttleJob =
executionScope.launch {
delay(intervalMs)
executionScope.launch { callback() }
}
}
}
}