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() } + } + } + } +}