From c93c494ef448cbd50f558bc554a74230cb6cdc66 Mon Sep 17 00:00:00 2001 From: Luke De Feo Date: Thu, 2 Nov 2023 12:29:07 -0700 Subject: [PATCH] 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 --- .../flipper/sample/FlipperInitializer.java | 2 - .../uidebugger/UIDebuggerFlipperPlugin.kt | 7 +- .../plugins/uidebugger/core/ApplicationRef.kt | 3 +- .../uidebugger/core/DecorViewTracker.kt | 114 ++++++++++ .../LayoutTraversal.kt} | 17 +- .../plugins/uidebugger/core/UIDContext.kt | 12 +- .../plugins/uidebugger/core/UpdateQueue.kt | 166 ++++++++++++++ .../plugins/uidebugger/model/Events.kt | 1 - .../observers/ApplicationTreeObserver.kt | 60 ------ .../observers/DecorViewTreeObserver.kt | 91 -------- .../uidebugger/observers/TreeObserver.kt | 115 ---------- .../observers/TreeObserverFactory.kt | 45 ---- .../observers/TreeObserverManager.kt | 203 ------------------ .../uidebugger/scheduler/SharedThrottle.kt | 74 ------- .../plugins/uidebugger/util/StopWatch.kt | 30 +++ .../plugins/uidebugger/util/Throttler.kt | 34 +++ 16 files changed, 359 insertions(+), 615 deletions(-) create mode 100644 android/src/main/java/com/facebook/flipper/plugins/uidebugger/core/DecorViewTracker.kt rename android/src/main/java/com/facebook/flipper/plugins/uidebugger/{traversal/PartialLayoutTraversal.kt => core/LayoutTraversal.kt} (86%) create mode 100644 android/src/main/java/com/facebook/flipper/plugins/uidebugger/core/UpdateQueue.kt delete mode 100644 android/src/main/java/com/facebook/flipper/plugins/uidebugger/observers/ApplicationTreeObserver.kt delete mode 100644 android/src/main/java/com/facebook/flipper/plugins/uidebugger/observers/DecorViewTreeObserver.kt delete mode 100644 android/src/main/java/com/facebook/flipper/plugins/uidebugger/observers/TreeObserver.kt delete mode 100644 android/src/main/java/com/facebook/flipper/plugins/uidebugger/observers/TreeObserverFactory.kt delete mode 100644 android/src/main/java/com/facebook/flipper/plugins/uidebugger/observers/TreeObserverManager.kt delete mode 100644 android/src/main/java/com/facebook/flipper/plugins/uidebugger/scheduler/SharedThrottle.kt create mode 100644 android/src/main/java/com/facebook/flipper/plugins/uidebugger/util/StopWatch.kt create mode 100644 android/src/main/java/com/facebook/flipper/plugins/uidebugger/util/Throttler.kt diff --git a/android/sample/src/debug/java/com/facebook/flipper/sample/FlipperInitializer.java b/android/sample/src/debug/java/com/facebook/flipper/sample/FlipperInitializer.java index 6ac940110..76a8c20f2 100644 --- a/android/sample/src/debug/java/com/facebook/flipper/sample/FlipperInitializer.java +++ b/android/sample/src/debug/java/com/facebook/flipper/sample/FlipperInitializer.java @@ -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.descriptors.DescriptorRegister; 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.editor.flipper.LithoFlipperDescriptors; import java.util.Arrays; @@ -63,7 +62,6 @@ public final class FlipperInitializer { client.addPlugin(NavigationFlipperPlugin.getInstance()); DescriptorRegister descriptorRegister = DescriptorRegister.Companion.withDefaults(); - TreeObserverFactory treeObserverFactory = TreeObserverFactory.Companion.withDefaults(); UIDContext uidContext = UIDContext.Companion.create((Application) context); UIDebuggerLithoSupport.INSTANCE.enable(uidContext); UIDebuggerComposeSupport.INSTANCE.enable(uidContext); diff --git a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/UIDebuggerFlipperPlugin.kt b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/UIDebuggerFlipperPlugin.kt index 003c51d8b..df2c25396 100644 --- a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/UIDebuggerFlipperPlugin.kt +++ b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/UIDebuggerFlipperPlugin.kt @@ -48,7 +48,8 @@ class UIDebuggerFlipperPlugin(val context: UIDContext) : FlipperPlugin { MetadataUpdateEvent.serializer(), MetadataUpdateEvent(MetadataRegister.extractPendingMetadata()))) - context.treeObserverManager.start() + context.updateQueue.start() + context.decorViewTracker.start() context.connectionListeners.forEach { it.onConnect() } } @@ -59,7 +60,9 @@ class UIDebuggerFlipperPlugin(val context: UIDContext) : FlipperPlugin { MetadataRegister.reset() - context.treeObserverManager.stop() + context.decorViewTracker.stop() + context.updateQueue.stop() + context.bitmapPool.recycleAll() context.connectionListeners.forEach { it.onDisconnect() } context.clearFrameworkEvents() diff --git a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/core/ApplicationRef.kt b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/core/ApplicationRef.kt index 1886cec59..82663d432 100644 --- a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/core/ApplicationRef.kt +++ b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/core/ApplicationRef.kt @@ -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: // 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 val rootsResolver: RootViewResolver = RootViewResolver() diff --git a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/core/DecorViewTracker.kt b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/core/DecorViewTracker.kt new file mode 100644 index 000000000..c4e98e59e --- /dev/null +++ b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/core/DecorViewTracker.kt @@ -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) { + // 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)) + } +} diff --git a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/traversal/PartialLayoutTraversal.kt b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/core/LayoutTraversal.kt similarity index 86% rename from android/src/main/java/com/facebook/flipper/plugins/uidebugger/traversal/PartialLayoutTraversal.kt rename to android/src/main/java/com/facebook/flipper/plugins/uidebugger/core/LayoutTraversal.kt index fd724ee57..2f228bab3 100644 --- a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/traversal/PartialLayoutTraversal.kt +++ b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/core/LayoutTraversal.kt @@ -5,11 +5,10 @@ * 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 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.NodeDescriptor 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 second item are any observable roots discovered. */ -class PartialLayoutTraversal( +class LayoutTraversal( private val context: UIDContext, ) { @Suppress("unchecked_cast") private fun NodeDescriptor<*>.asAny(): NodeDescriptor = this as NodeDescriptor - fun traverse(root: Any, parentId: Id?): Pair>, List>> { + fun traverse(root: Any): MutableList> { val visited = mutableListOf>() - val observableRoots = mutableListOf>() // cur and parent Id val stack = mutableListOf>() - stack.add(Pair(root, parentId)) + stack.add(Pair(root, null)) val shallow = mutableSetOf() @@ -45,11 +43,6 @@ class PartialLayoutTraversal( val (node, parentId) = stack.removeLast() 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 = context.descriptorRegister.descriptorForClassUnsafe(node::class.java).asAny() @@ -126,6 +119,6 @@ class PartialLayoutTraversal( } } - return Pair(visited, observableRoots) + return visited } } diff --git a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/core/UIDContext.kt b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/core/UIDContext.kt index bd2a275d3..6c7680322 100644 --- a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/core/UIDContext.kt +++ b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/core/UIDContext.kt @@ -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.FrameworkEventMetadata 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 interface ConnectionListener { @@ -30,16 +26,15 @@ class UIDContext( val applicationRef: ApplicationRef, val connectionRef: ConnectionRef, val descriptorRegister: DescriptorRegister, - val observerFactory: TreeObserverFactory, val frameworkEventMetadata: MutableList, val connectionListeners: MutableList, private val pendingFrameworkEvents: MutableList ) { - 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() fun addFrameworkEvent(frameworkEvent: FrameworkEvent) { @@ -69,7 +64,6 @@ class UIDContext( ApplicationRef(application), ConnectionRef(null), descriptorRegister = DescriptorRegister.withDefaults(), - observerFactory = TreeObserverFactory.withDefaults(), frameworkEventMetadata = mutableListOf(), connectionListeners = mutableListOf(), pendingFrameworkEvents = mutableListOf()) diff --git a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/core/UpdateQueue.kt b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/core/UpdateQueue.kt new file mode 100644 index 000000000..d768ea4f3 --- /dev/null +++ b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/core/UpdateQueue.kt @@ -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>, + 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(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))) + } + } +} diff --git a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/model/Events.kt b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/model/Events.kt index def4febe7..742699500 100644 --- a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/model/Events.kt +++ b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/model/Events.kt @@ -53,7 +53,6 @@ class TraversalError( @kotlinx.serialization.Serializable class PerfStatsEvent( val txId: Long, - val observerType: String, val nodesCount: Int, val start: Long, val traversalMS: Long, diff --git a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/observers/ApplicationTreeObserver.kt b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/observers/ApplicationTreeObserver.kt deleted file mode 100644 index 0eafaf3ef..000000000 --- a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/observers/ApplicationTreeObserver.kt +++ /dev/null @@ -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() { - - 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) { - 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()) - } -} diff --git a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/observers/DecorViewTreeObserver.kt b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/observers/DecorViewTreeObserver.kt deleted file mode 100644 index 003fe4f1d..000000000 --- a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/observers/DecorViewTreeObserver.kt +++ /dev/null @@ -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() { - - private var nodeRef: WeakReference? = 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 { - override fun canBuildFor(node: Any): Boolean { - return node.javaClass.simpleName.contains("DecorView") - } - - override fun build(context: UIDContext): TreeObserver { - Log.i(LogTag, "Building DecorView observer") - return DecorViewObserver(context) - } -} diff --git a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/observers/TreeObserver.kt b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/observers/TreeObserver.kt deleted file mode 100644 index 7e23a9b2d..000000000 --- a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/observers/TreeObserver.kt +++ /dev/null @@ -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 { - - protected val children: MutableMap> = 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? = 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() - 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 - 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() - } -} diff --git a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/observers/TreeObserverFactory.kt b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/observers/TreeObserverFactory.kt deleted file mode 100644 index 33f480156..000000000 --- a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/observers/TreeObserverFactory.kt +++ /dev/null @@ -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 { - fun canBuildFor(node: Any): Boolean - - fun build(context: UIDContext): TreeObserver -} - -class TreeObserverFactory { - - private val builders = mutableListOf>() - - fun register(builder: TreeObserverBuilder) { - 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 - } - } -} diff --git a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/observers/TreeObserverManager.kt b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/observers/TreeObserverManager.kt deleted file mode 100644 index 7abd8db7b..000000000 --- a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/observers/TreeObserverManager.kt +++ /dev/null @@ -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>, - val timestamp: Long, - val queuedTimestamp: Long, - val traversalMS: Long, - val snapshotMS: Long, - val frameworkEvents: List?, - val snapshot: BitmapPool.ReusableBitmap? -) - -data class BatchedUpdate(val updates: List, 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 - - 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() - - 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) - } -} diff --git a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/scheduler/SharedThrottle.kt b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/scheduler/SharedThrottle.kt deleted file mode 100644 index 795f3b0d9..000000000 --- a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/scheduler/SharedThrottle.kt +++ /dev/null @@ -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 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()) - } -} diff --git a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/util/StopWatch.kt b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/util/StopWatch.kt new file mode 100644 index 000000000..12698ebac --- /dev/null +++ b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/util/StopWatch.kt @@ -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 time(fn: () -> T): Pair { + val start = System.currentTimeMillis() + val result = fn() + val elapsed = System.currentTimeMillis() - start + return Pair(result, elapsed) + } + } +} diff --git a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/util/Throttler.kt b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/util/Throttler.kt new file mode 100644 index 000000000..1069ddd80 --- /dev/null +++ b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/util/Throttler.kt @@ -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(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() } + } + } + } +}