Coordinate update event when litho scrolls or is shifted

Summary: See doc comment for explanation

Reviewed By: lblasa

Differential Revision: D40587610

fbshipit-source-id: f0909440c4e6e3cc9f5c7b557198a93ba8809bd9
This commit is contained in:
Luke De Feo
2022-10-25 07:10:38 -07:00
committed by Facebook GitHub Bot
parent a447712865
commit b1bee28f08
5 changed files with 136 additions and 75 deletions

View File

@@ -7,11 +7,17 @@
package com.facebook.flipper.plugins.uidebugger.litho package com.facebook.flipper.plugins.uidebugger.litho
import android.annotation.SuppressLint
import android.util.Log import android.util.Log
import android.view.ViewTreeObserver import android.view.ViewTreeObserver
import com.facebook.flipper.plugins.uidebugger.LogTag import com.facebook.flipper.plugins.uidebugger.LogTag
import com.facebook.flipper.plugins.uidebugger.core.Context import com.facebook.flipper.plugins.uidebugger.core.Context
import com.facebook.flipper.plugins.uidebugger.descriptors.ViewDescriptor
import com.facebook.flipper.plugins.uidebugger.descriptors.nodeId import com.facebook.flipper.plugins.uidebugger.descriptors.nodeId
import com.facebook.flipper.plugins.uidebugger.litho.descriptors.LithoViewDescriptor
import com.facebook.flipper.plugins.uidebugger.model.Bounds
import com.facebook.flipper.plugins.uidebugger.model.Coordinate
import com.facebook.flipper.plugins.uidebugger.observers.CoordinateUpdate
import com.facebook.flipper.plugins.uidebugger.observers.TreeObserver import com.facebook.flipper.plugins.uidebugger.observers.TreeObserver
import com.facebook.flipper.plugins.uidebugger.observers.TreeObserverBuilder import com.facebook.flipper.plugins.uidebugger.observers.TreeObserverBuilder
import com.facebook.flipper.plugins.uidebugger.scheduler.throttleLatest import com.facebook.flipper.plugins.uidebugger.scheduler.throttleLatest
@@ -22,30 +28,32 @@ import kotlinx.coroutines.*
/** /**
* There are 2 ways a litho view can update: * There are 2 ways a litho view can update:
* 1. a view was added / updated / removed through a mount ,we use the mount extension to capture * 1. A view was added / updated / removed through a mount, This should be refelected in a change in
* these * props / state so we use the mount extension to capture these including the entire component tree
* 2. The user scrolled. This does not cause a mount to the litho view but it may cause new * 2. The coordinate of the litho view changes externally and doesn't cause a mount, examples:
* components to mount as they come on screen On the native side we capture scrolls as it causes the * - Sibling changed size or position and shifted this view
* draw listener to first but but the layout traversal would stop once it sees the lithoview. * - User scrolled
* *
* Therefore we need a way to capture the changes in the position of views in a litho view hierarchy * These are not interesting from UI debugger perspective, we don't want to send the whole subtree
* as they are scrolled. A property that seems to hold for litho is if there is a scrolling view in * as only the Coordinate of the root litho view has changed. For this situation we send a
* the heierachy, its direct children are lithoview. * lightweight coordinate update event to distinguish these 2 cases
* *
* Given that we are observing a litho view in this class for mount extension we can also attach a * If an external event such as a scroll does does lead to a mount (new view in recycler view) this
* on scroll changed listener to it to be notified by android when it is scrolled. We just need to * will be picked up by the mount extension
* then update the bounds for this view as nothing else has changed. If this scroll does lead to a
* mount this will be picked up by the mount extension
*/ */
class LithoViewTreeObserver(val context: Context) : TreeObserver<LithoView>() { class LithoViewTreeObserver(val context: Context) : TreeObserver<LithoView>() {
override val type = "Litho" override val type = "Litho"
private val throttleTimeMs = 500L private val throttleTimeMs = 100L
private val waitScope = CoroutineScope(Dispatchers.IO) private val waitScope = CoroutineScope(Dispatchers.IO)
private val mainScope = CoroutineScope(Dispatchers.Main) private val mainScope = CoroutineScope(Dispatchers.Main)
var lastBounds: Bounds? = null
var nodeRef: LithoView? = null var nodeRef: LithoView? = null
private var preDrawListener: ViewTreeObserver.OnPreDrawListener? = null private var preDrawListener: ViewTreeObserver.OnPreDrawListener? = null
@SuppressLint("PrivateApi")
override fun subscribe(node: Any) { override fun subscribe(node: Any) {
Log.d(LogTag, "Subscribing to litho view ${node.nodeId()}") Log.d(LogTag, "Subscribing to litho view ${node.nodeId()}")
@@ -55,21 +63,28 @@ class LithoViewTreeObserver(val context: Context) : TreeObserver<LithoView>() {
val lithoDebuggerExtension = LithoDebuggerExtension(this) val lithoDebuggerExtension = LithoDebuggerExtension(this)
node.registerUIDebugger(lithoDebuggerExtension) node.registerUIDebugger(lithoDebuggerExtension)
val throttledUpdate = val throttledCordinateUpdate =
throttleLatest<Any>(throttleTimeMs, waitScope, mainScope) { node -> throttleLatest<LithoView>(throttleTimeMs, waitScope, mainScope) { node ->
// todo only send bounds for the view rather than the entire hierachy // use the descriptor to get the bounds since we do some magic in there
processUpdate(context, node) val bounds = ViewDescriptor.onGetBounds(node)
if (bounds != lastBounds) {
context.treeObserverManager.enqueueUpdate(
CoordinateUpdate(this.type, node.nodeId(), Coordinate(bounds.x, bounds.y)))
lastBounds = bounds
}
} }
preDrawListener = preDrawListener =
ViewTreeObserver.OnPreDrawListener { ViewTreeObserver.OnPreDrawListener {
throttledUpdate(node) // this cases case 2
throttledCordinateUpdate(node)
true true
} }
node.viewTreeObserver.addOnPreDrawListener(preDrawListener) node.viewTreeObserver.addOnPreDrawListener(preDrawListener)
// we have already missed the first mount so we trigger it manually on subscribe // we have already missed the first mount so we trigger it manually on subscribe
lastBounds = LithoViewDescriptor.onGetBounds(node)
processUpdate(context, node) processUpdate(context, node)
} }
@@ -93,7 +108,7 @@ class LithoDebuggerExtension(val observer: LithoViewTreeObserver) : MountExtensi
*/ */
override fun afterMount(state: ExtensionState<Void?>) { override fun afterMount(state: ExtensionState<Void?>) {
Log.i(LogTag, "After mount called for litho view ${observer.nodeRef?.nodeId()}") Log.i(LogTag, "After mount called for litho view ${observer.nodeRef?.nodeId()}")
// todo sparse update observer.lastBounds = ViewDescriptor.onGetBounds(state.rootHost)
observer.processUpdate(observer.context, state.rootHost as Any) observer.processUpdate(observer.context, state.rootHost as Any)
} }
} }

View File

@@ -29,6 +29,17 @@ data class SubtreeUpdateEvent(
} }
} }
@kotlinx.serialization.Serializable
data class CoordinateUpdateEvent(
val observerType: String,
val nodeId: Id,
val coordinate: Coordinate
) {
companion object {
const val name = "coordinateUpdate"
}
}
/** Separate optional performance statistics event */ /** Separate optional performance statistics event */
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
data class PerfStatsEvent( data class PerfStatsEvent(

View File

@@ -16,6 +16,8 @@ import com.facebook.flipper.plugins.uidebugger.LogTag
import com.facebook.flipper.plugins.uidebugger.common.BitmapPool import com.facebook.flipper.plugins.uidebugger.common.BitmapPool
import com.facebook.flipper.plugins.uidebugger.core.Context import com.facebook.flipper.plugins.uidebugger.core.Context
import com.facebook.flipper.plugins.uidebugger.descriptors.Id import com.facebook.flipper.plugins.uidebugger.descriptors.Id
import com.facebook.flipper.plugins.uidebugger.model.Coordinate
import com.facebook.flipper.plugins.uidebugger.model.CoordinateUpdateEvent
import com.facebook.flipper.plugins.uidebugger.model.Node import com.facebook.flipper.plugins.uidebugger.model.Node
import com.facebook.flipper.plugins.uidebugger.model.PerfStatsEvent import com.facebook.flipper.plugins.uidebugger.model.PerfStatsEvent
import com.facebook.flipper.plugins.uidebugger.model.SubtreeUpdateEvent import com.facebook.flipper.plugins.uidebugger.model.SubtreeUpdateEvent
@@ -25,6 +27,11 @@ import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
sealed interface Update
data class CoordinateUpdate(val observerType: String, val nodeId: Id, val coordinate: Coordinate) :
Update
data class SubtreeUpdate( data class SubtreeUpdate(
val observerType: String, val observerType: String,
val rootId: Id, val rootId: Id,
@@ -33,19 +40,19 @@ data class SubtreeUpdate(
val traversalCompleteTime: Long, val traversalCompleteTime: Long,
val snapshotComplete: Long, val snapshotComplete: Long,
val snapshot: BitmapPool.ReusableBitmap? val snapshot: BitmapPool.ReusableBitmap?
) ) : Update
/** Holds the root observer and manages sending updates to desktop */ /** Holds the root observer and manages sending updates to desktop */
class TreeObserverManager(val context: Context) { class TreeObserverManager(val context: Context) {
private val rootObserver = ApplicationTreeObserver(context) private val rootObserver = ApplicationTreeObserver(context)
private lateinit var treeUpdates: Channel<SubtreeUpdate> private lateinit var updates: Channel<Update>
private var job: Job? = null private var job: Job? = null
private val workerScope = CoroutineScope(Dispatchers.IO) private val workerScope = CoroutineScope(Dispatchers.IO)
private val txId = AtomicInteger() private val txId = AtomicInteger()
fun enqueueUpdate(update: SubtreeUpdate) { fun enqueueUpdate(update: Update) {
treeUpdates.trySend(update) updates.trySend(update)
} }
/** /**
@@ -55,14 +62,33 @@ class TreeObserverManager(val context: Context) {
@SuppressLint("NewApi") @SuppressLint("NewApi")
fun start() { fun start() {
treeUpdates = Channel(Channel.UNLIMITED) updates = Channel(Channel.UNLIMITED)
rootObserver.subscribe(context.applicationRef) rootObserver.subscribe(context.applicationRef)
job = job =
workerScope.launch { workerScope.launch {
while (isActive) { while (isActive) {
try { try {
val treeUpdate = treeUpdates.receive()
val update = updates.receive()
when (update) {
is SubtreeUpdate -> sendSubtreeUpdate(update)
is CoordinateUpdate -> {
val event =
CoordinateUpdateEvent(update.observerType, update.nodeId, update.coordinate)
val serialized = Json.encodeToString(CoordinateUpdateEvent.serializer(), event)
context.connectionRef.connection?.send(CoordinateUpdateEvent.name, serialized)
}
}
} catch (e: CancellationException) {} catch (e: java.lang.Exception) {
Log.e(LogTag, "Unexpected Error in channel ", e)
}
}
Log.i(LogTag, "Shutting down worker")
}
}
fun sendSubtreeUpdate(treeUpdate: SubtreeUpdate) {
val onWorkerThread = System.currentTimeMillis() val onWorkerThread = System.currentTimeMillis()
val txId = txId.getAndIncrement().toLong() val txId = txId.getAndIncrement().toLong()
@@ -82,11 +108,7 @@ class TreeObserverManager(val context: Context) {
Json.encodeToString( Json.encodeToString(
SubtreeUpdateEvent.serializer(), SubtreeUpdateEvent.serializer(),
SubtreeUpdateEvent( SubtreeUpdateEvent(
txId, txId, treeUpdate.observerType, treeUpdate.rootId, treeUpdate.nodes, snapshot))
treeUpdate.observerType,
treeUpdate.rootId,
treeUpdate.nodes,
snapshot))
treeUpdate.snapshot.readyForReuse() treeUpdate.snapshot.readyForReuse()
} }
@@ -113,17 +135,11 @@ class TreeObserverManager(val context: Context) {
context.connectionRef.connection?.send( context.connectionRef.connection?.send(
PerfStatsEvent.name, Json.encodeToString(PerfStatsEvent.serializer(), perfStats)) PerfStatsEvent.name, Json.encodeToString(PerfStatsEvent.serializer(), perfStats))
} catch (e: CancellationException) {} catch (e: java.lang.Exception) {
Log.e(LogTag, "Unexpected Error in channel ", e)
}
}
Log.i(LogTag, "Shutting down worker")
}
} }
fun stop() { fun stop() {
rootObserver.cleanUpRecursive() rootObserver.cleanUpRecursive()
job?.cancel() job?.cancel()
treeUpdates.cancel() updates.cancel()
} }
} }

View File

@@ -24,6 +24,18 @@ export function plugin(client: PluginClient<Events>) {
const nodesAtom = createState<Map<Id, UINode>>(new Map()); const nodesAtom = createState<Map<Id, UINode>>(new Map());
const snapshotsAtom = createState<Map<Id, Snapshot>>(new Map()); const snapshotsAtom = createState<Map<Id, Snapshot>>(new Map());
client.onMessage('coordinateUpdate', (event) => {
nodesAtom.update((draft) => {
const node = draft.get(event.nodeId);
if (!node) {
console.warn(`Coordinate update for non existing node `, event);
} else {
node.bounds.x = event.coordinate.x;
node.bounds.y = event.coordinate.y;
}
});
});
client.onMessage('subtreeUpdate', (event) => { client.onMessage('subtreeUpdate', (event) => {
snapshotsAtom.update((draft) => { snapshotsAtom.update((draft) => {
draft.set(event.rootId, event.snapshot); draft.set(event.rootId, event.snapshot);

View File

@@ -10,9 +10,16 @@
export type Events = { export type Events = {
init: InitEvent; init: InitEvent;
subtreeUpdate: SubtreeUpdateEvent; subtreeUpdate: SubtreeUpdateEvent;
coordinateUpdate: CoordinateUpdateEvent;
perfStats: PerfStatsEvent; perfStats: PerfStatsEvent;
}; };
export type CoordinateUpdateEvent = {
observerType: String;
nodeId: Id;
coordinate: Coordinate;
};
export type SubtreeUpdateEvent = { export type SubtreeUpdateEvent = {
txId: number; txId: number;
rootId: Id; rootId: Id;
@@ -39,7 +46,7 @@ export type UINode = {
name: string; name: string;
attributes: Record<string, Inspectable>; attributes: Record<string, Inspectable>;
children: Id[]; children: Id[];
bounds?: Bounds; bounds: Bounds;
tags: Tag[]; tags: Tag[];
activeChild?: Id; activeChild?: Id;
}; };