Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/*
* 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.react.fabric.mounting

import android.os.Handler
import android.os.Looper
import android.view.View
import android.view.ViewTreeObserver
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.systrace.Systrace
import java.util.ArrayDeque

/** Experimental UI-thread cleanup, gated on a draw rather than ReactChoreographer.IDLE_EVENT. */
internal class DeferredViewCleanupQueue {
private val handler = Handler(Looper.getMainLooper())

private class Task(
val afterDraw: Long,
val estimatedOperations: Int,
val step: () -> Boolean,
) {
val traceId = nextTaskId++
}

internal companion object {
// Queue operations are confined to the UI thread, including task ID allocation.
private var nextTaskId = 0
const val MAX_PENDING_OPERATIONS = 2048
const val MAX_PENDING_TASKS = 8
}

private val tasks = ArrayDeque<Task>()
private var pendingOperations = 0
private var drawGeneration = 0L
private var root: View? = null
private var observer: ViewTreeObserver? = null
private var posted = false

internal val size: Int
get() = tasks.size

internal fun hasCapacityFor(estimatedOperations: Int): Boolean =
tasks.size < MAX_PENDING_TASKS &&
estimatedOperations > 0 &&
estimatedOperations <= MAX_PENDING_OPERATIONS &&
pendingOperations <= MAX_PENDING_OPERATIONS - estimatedOperations

private val afterDraw: Runnable = Runnable {
removeDrawListener()
handler.removeCallbacks(checkVisibility)
posted = false
drainSlice()
}

private val drawListener =
ViewTreeObserver.OnDrawListener {
drawGeneration++
if (!posted) {
posted = true
// onDraw runs inside traversal. Posting moves cleanup outside that traversal; this does
// not imply that the GPU has finished presenting the frame.
handler.post(afterDraw)
}
}

private val checkVisibility =
object : Runnable {
override fun run() {
val view = root ?: return
if (!view.isAttachedToWindow || !view.isShown || view.windowVisibility != View.VISIBLE) {
handler.removeCallbacks(afterDraw)
removeDrawListener()
posted = false
drainSlice(requireDraw = false)
} else {
// A detach/reattach can replace (and kill) the observer while work is pending.
if (observer?.isAlive != true && !posted) {
removeDrawListener()
observer = view.viewTreeObserver.also { it.addOnDrawListener(drawListener) }
}
// A visible surface must cross a draw boundary. Do not replace that guarantee with a
// timeout on a slow frame. Hidden/detached surfaces have no frame to protect.
view.postInvalidateOnAnimation()
handler.postDelayed(this, 100)
}
}
}

/** Each invocation performs one bounded unit and returns true once its task is complete. */
internal fun enqueue(view: View, estimatedOperations: Int = 1, step: () -> Boolean) {
UiThreadUtil.assertOnUiThread()
check(hasCapacityFor(estimatedOperations)) {
"Deferred cleanup operation budget exceeded: $estimatedOperations"
}
val task = Task(drawGeneration + 1, estimatedOperations, step)
tasks.addLast(task)
pendingOperations += estimatedOperations
Systrace.beginAsyncSection(
Systrace.TRACE_TAG_REACT,
"FabricDeferredCleanup::task",
task.traceId,
)
if (root == null) {
root = view
awaitDraw()
}
}

internal fun flush() {
UiThreadUtil.assertOnUiThread()
handler.removeCallbacks(afterDraw)
handler.removeCallbacks(checkVisibility)
removeDrawListener()
posted = false
while (tasks.isNotEmpty()) {
val task = tasks.first
if (task.step() && tasks.peekFirst() === task) complete(task)
}
root = null
}

private fun awaitDraw() {
val view = checkNotNull(root)
observer = view.viewTreeObserver.also { it.addOnDrawListener(drawListener) }
view.postInvalidateOnAnimation()
handler.postDelayed(checkVisibility, 100)
}

private fun complete(task: Task) {
tasks.removeFirst()
pendingOperations -= task.estimatedOperations
Systrace.endAsyncSection(Systrace.TRACE_TAG_REACT, "FabricDeferredCleanup::task", task.traceId)
}

private fun removeDrawListener() {
observer?.let { if (it.isAlive) it.removeOnDrawListener(drawListener) }
// Android migrates listeners from a dead observer to the new window's observer.
root?.viewTreeObserver?.let {
if (it !== observer && it.isAlive) it.removeOnDrawListener(drawListener)
}
observer = null
}

private fun drainSlice(requireDraw: Boolean = true) {
Systrace.beginSection(Systrace.TRACE_TAG_REACT, "FabricDeferredCleanup::slice")
var operations = 0
try {
val deadline = System.nanoTime() + 1_000_000L
do {
val task = tasks.peekFirst() ?: break
if (requireDraw && task.afterDraw > drawGeneration) break
// Detachment listeners can synchronously stop the surface and flush this queue.
if (task.step() && tasks.peekFirst() === task) complete(task)
operations++
} while (operations < 256 && System.nanoTime() < deadline)
} finally {
Systrace.traceCounter(
Systrace.TRACE_TAG_REACT,
"FabricDeferredCleanup::operations",
operations,
)
Systrace.endSection(Systrace.TRACE_TAG_REACT)
}
if (tasks.isEmpty()) root = null else awaitDraw()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.ViewManager
import com.facebook.react.uimanager.ViewManagerRegistry
import com.facebook.react.uimanager.events.EventCategoryDef
import com.facebook.react.views.view.ReactViewGroup
import com.facebook.react.views.view.ReactViewManager
import com.facebook.systrace.Systrace
import java.util.ArrayDeque
import java.util.LinkedList
Expand Down Expand Up @@ -90,6 +92,7 @@ internal constructor(
private val registryLock = ReentrantReadWriteLock()

private val onViewAttachMountItems: Queue<MountItem> = ArrayDeque()
private var deferredViewCleanup: DeferredViewCleanupQueue? = null

// These are all non-null, until StopSurface is called
private var jsResponderHandler: JSResponderHandler? = jsResponderHandler
Expand Down Expand Up @@ -253,6 +256,8 @@ internal constructor(
}

val runnable = Runnable {
// Retired views no longer live in tagToViewState. Drain before evicting recycling pools.
deferredViewCleanup?.flush()
if (ReactNativeFeatureFlags.enableViewRecycling()) {
viewManagerRegistry?.onSurfaceStopped(surfaceId)
}
Expand Down Expand Up @@ -395,6 +400,176 @@ internal constructor(
}
}

@UiThread
internal fun tryDeferPureViewUnmount(removes: List<IntArray>, deletes: List<Int>): Boolean {
UiThreadUtil.assertOnUiThread()
val plan: DeferredUnmount?
Systrace.beginSection(Systrace.TRACE_TAG_REACT, "FabricDeferredUnmount::validate")
try {
plan = validateDeferredUnmount(removes, deletes)
} finally {
Systrace.endSection(Systrace.TRACE_TAG_REACT)
}
if (plan == null) return false

Systrace.beginSection(Systrace.TRACE_TAG_REACT, "FabricDeferredUnmount::detachRoots")
try {
for ((tag, parent, index) in plan.roots) removeViewAt(tag, parent, index)
} finally {
Systrace.endSection(Systrace.TRACE_TAG_REACT)
}
if (isStopped) return true
Systrace.beginSection(Systrace.TRACE_TAG_REACT, "FabricDeferredUnmount::retireReferences")
try {
registryLock.write {
for (state in plan.states) {
tagToViewState.remove(state.reactTag)
tagToSynchronousMountProps.remove(state.reactTag)
// Never retain JSI pointers until a later draw or VM shutdown.
state.stateWrapper?.destroyState()
state.stateWrapper = null
state.eventEmitter?.destroy()
state.eventEmitter = null
}
}
} finally {
Systrace.endSection(Systrace.TRACE_TAG_REACT)
}
var removeIndex = 0
var deleteIndex = 0
val queue = deferredViewCleanup ?: DeferredViewCleanupQueue().also { deferredViewCleanup = it }
queue.enqueue(plan.root, plan.states.size * 2) {
if (removeIndex < plan.childParents.size) {
val parent = plan.childParents[removeIndex]
val index = plan.childIndices[removeIndex++]
// Object references belong to retired states, never look up a potentially reused tag.
getViewGroupManager(parent.state).removeViewAt(parent.view, index)
} else {
onViewStateDeleted(plan.states[deleteIndex++])
}
removeIndex == plan.childParents.size && deleteIndex == plan.states.size
}
return true
}

private class DeferredUnmount(
val root: View,
val states: List<ViewState>,
val roots: List<IntArray>,
val childParents: List<DeferredUnmountParent>,
val childIndices: IntArray,
)

private class DeferredUnmountParent(val state: ViewState, val view: ViewGroup) {
var lastIndex: Int = Int.MAX_VALUE
}

private fun validateDeferredUnmount(
removes: List<IntArray>,
deletes: List<Int>,
): DeferredUnmount? {
if (
isStopped ||
deletes.size !in 2..4096 ||
deletes.size > DeferredViewCleanupQueue.MAX_PENDING_OPERATIONS / 2 ||
deferredViewCleanup?.hasCapacityFor(deletes.size * 2) == false
) {
return null
}
val root = getNullableViewState(surfaceId)?.view ?: return null
if (!root.isAttachedToWindow) return null
if (removes.size != deletes.size) return null
// One index handles membership, duplicate DELETEs, and state lookup.
val stateIndices = HashMap<Int, Int>(deletes.size)
for (index in deletes.indices) {
if (stateIndices.put(deletes[index], index) != null) return null
}
val removed = BooleanArray(deletes.size)
val removeStateIndices = IntArray(removes.size)
for (index in removes.indices) {
val remove = removes[index]
if (remove.size != 3) return null
val stateIndex = stateIndices.getOrDefault(remove[0], -1)
if (stateIndex < 0 || removed[stateIndex]) return null
removed[stateIndex] = true
removeStateIndices[index] = stateIndex
}

val states = ArrayList<ViewState>(deletes.size)
for (tag in deletes) {
val state = getNullableViewState(tag) ?: return null
val view = state.view as? ReactViewGroup ?: return null
// An exact class check is deliberate: arbitrary manager callbacks may observe attachment.
if (
state.isRoot ||
state.viewManager?.javaClass != ReactViewManager::class.java ||
view.javaClass != ReactViewGroup::class.java ||
view.removeClippedSubviews ||
view.animation != null ||
view.hasTransientState() ||
view.layoutTransition != null ||
tag in viewsWithActiveTouches ||
tag in erroneouslyReaddedReactTags
)
return null
states.add(state)
}
for (state in states) {
val view = state.view as ReactViewGroup
for (index in 0 until view.childCount) {
val child = view.getChildAt(index)
val childIndex = stateIndices.getOrDefault(child.id, -1)
if (childIndex < 0 || states[childIndex].view !== child) return null
}
}

val parents = MutableIntObjectMap<DeferredUnmountParent>()
val roots = ArrayList<IntArray>()
val childParents = ArrayList<DeferredUnmountParent>(removes.size)
val childIndices = IntArray(removes.size)
for (removeIndex in removes.indices) {
val remove = removes[removeIndex]
val parentTag = remove[1]
val index = remove[2]
val stateIndex = removeStateIndices[removeIndex]
var parent = parents[parentTag]
if (parent == null) {
val parentIndex = stateIndices.getOrDefault(parentTag, -1)
val parentState =
if (parentIndex >= 0) states[parentIndex]
else getNullableViewState(parentTag) ?: return null
val parentView = parentState.view as? ViewGroup ?: return null
if (
(!parentState.isRoot &&
(parentState.viewManager?.javaClass != ReactViewManager::class.java ||
(parentView as? ReactViewGroup)?.removeClippedSubviews != false)) ||
parentView.layoutTransition != null ||
parentView.animation != null ||
parentView.hasTransientState()
)
return null
parent = DeferredUnmountParent(parentState, parentView)
parents[parentTag] = parent
}
if (
index < 0 ||
index >= parent.lastIndex ||
parent.view.getChildAt(index) !== states[stateIndex].view
)
return null
parent.lastIndex = index
if (!stateIndices.containsKey(parentTag)) {
roots.add(remove)
} else {
childIndices[childParents.size] = index
childParents.add(parent)
}
}
if (roots.isEmpty() || childParents.isEmpty()) return null
// Validation is read-only. All unsupported batches take the original path unchanged.
return DeferredUnmount(root, states, roots, childParents, childIndices)
}

@UiThread
public fun removeViewAt(tag: Int, parentTag: Int, index: Int) {
UiThreadUtil.assertOnUiThread()
Expand Down
Loading
Loading