• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.systemui.lifecycle
18 
19 import android.os.Handler
20 import android.os.Looper
21 import android.view.Choreographer
22 import android.view.View
23 import androidx.collection.MutableScatterSet
24 import androidx.compose.runtime.snapshots.Snapshot
25 import androidx.compose.runtime.snapshots.SnapshotStateObserver
26 import androidx.core.os.HandlerCompat
27 import com.android.systemui.res.R
28 
29 /**
30  * [SnapshotViewBindingRoot] is installed on the root view of an attached view hierarchy and
31  * coordinates all [SnapshotViewBinding]s for the window.
32  *
33  * This class is not thread-safe. It should only be accessed from the thread corresponding to the UI
34  * thread referenced by the [handler] and [choreographer] constructor parameters. These two
35  * parameters must refer to the same UI thread.
36  *
37  * Lazily created and installed on a root attached view by [bindingRoot].
38  */
39 private class SnapshotViewBindingRoot(
40     private val handler: Handler,
41     private val choreographer: Choreographer
42 ) {
43     /** Multiplexer for all snapshot state observations; see [start] and [stop] */
44     private val observer = SnapshotStateObserver { task ->
45         if (Looper.myLooper() === handler.looper) task() else handler.post(task)
46     }
47 
48     /** `true` if a [Choreographer] frame is currently scheduled */
49     private var isFrameScheduled = false
50 
51     /**
52      * Unordered set of [SnapshotViewBinding]s that have been invalidated and are awaiting handling
53      * by an upcoming frame.
54      */
55     private val invalidatedBindings = MutableScatterSet<SnapshotViewBinding>()
56 
57     /**
58      * Callback for [SnapshotStateObserver.observeReads] allocated once for the life of the
59      * [SnapshotViewBindingRoot] and reused to avoid extra allocations during frame operations.
60      */
61     private val onBindingChanged: (SnapshotViewBinding) -> Unit = {
62         invalidatedBindings += it
63         if (!isFrameScheduled) {
64             choreographer.postFrameCallback(frameCallback)
65             isFrameScheduled = true
66         }
67     }
68 
69     /** Callback for [Choreographer.postFrameCallback] */
70     private val frameCallback =
71         Choreographer.FrameCallback {
72             try {
73                 bindInvalidatedBindings()
74             } finally {
75                 isFrameScheduled = false
76             }
77         }
78 
79     /**
80      * Perform binding of all [SnapshotViewBinding]s in [invalidatedBindings] within a single
81      * mutable snapshot. The snapshot will be committed if no exceptions are thrown from any
82      * binding's `onError` handler.
83      */
84     private fun bindInvalidatedBindings() {
85         Snapshot.withMutableSnapshot {
86             // removeIf is used here to perform a forEach where each element is removed
87             // as the invalid bindings are traversed. If a performBindOf throws we want
88             // the rest of the unhandled invalidations to remain.
89             invalidatedBindings.removeIf { binding ->
90                 performBindOf(binding)
91                 true
92             }
93         }
94     }
95 
96     /**
97      * Perform the view binding for [binding] while observing its snapshot reads. Once this method
98      * is called for a [binding] this [SnapshotViewBindingRoot] may retain hard references back to
99      * [binding] via [observer], [invalidatedBindings] or both. Use [forgetBinding] to drop these
100      * references once a [SnapshotViewBinding] is no longer relevant.
101      *
102      * This method should only be called after [start] has been called and before [stop] has been
103      * called; failing to obey this constraint may result in lingering hard references to [binding]
104      * or missed invalidations in response to snapshot state that was changed prior to [start] being
105      * called.
106      */
107     fun performBindOf(binding: SnapshotViewBinding) {
108         try {
109             observer.observeReads(binding, onBindingChanged, binding.performBind)
110         } catch (error: Throwable) {
111             // Note: it is valid (and the default) for this call to re-throw the error
112             binding.onError(error)
113         }
114     }
115 
116     /**
117      * Forget about [binding], dropping all observed tracking and invalidation state. After calling
118      * this method it is safe to abandon [binding] to the garbage collector.
119      */
120     fun forgetBinding(binding: SnapshotViewBinding) {
121         observer.clear(binding)
122         invalidatedBindings.remove(binding)
123     }
124 
125     /**
126      * Start tracking snapshot commits that may affect [SnapshotViewBinding]s passed to
127      * [performBindOf] calls. Call this method before invoking [performBindOf].
128      *
129      * Once this method has been called, [stop] must be called prior to abandoning this
130      * [SnapshotViewBindingRoot] to the garbage collector, as a hard reference to it will be
131      * retained by the snapshot system until [stop] is invoked.
132      */
133     fun start() {
134         observer.start()
135     }
136 
137     /**
138      * Stop tracking snapshot commits that may affect [SnapshotViewBinding]s that have been passed
139      * to [performBindOf], cancel any pending [choreographer] frame callback, and forget all
140      * [invalidatedBindings].
141      *
142      * Call [stop] prior to abandoning this [SnapshotViewBindingRoot] to the garbage collector.
143      *
144      * Calling [start] again after [stop] will begin tracking invalidations again, but any
145      * [SnapshotViewBinding]s must be re-bound using [performBindOf] after the [start] call returns.
146      */
147     fun stop() {
148         observer.stop()
149         choreographer.removeFrameCallback(frameCallback)
150         isFrameScheduled = false
151         invalidatedBindings.clear()
152     }
153 }
154 
155 /**
156  * Return the [SnapshotViewBindingRoot] for this [View], lazily creating it if it does not yet
157  * exist. This [View] must be currently attached to a window and this property should only be
158  * accessed from this [View]'s UI thread.
159  *
160  * The [SnapshotViewBindingRoot] will be [started][SnapshotViewBindingRoot.start] before this
161  * property get returns, making it safe to call [SnapshotViewBindingRoot.performBindOf] for the
162  * [bindingRoot] of an attached [View].
163  *
164  * When the [View] becomes attached to a window the [SnapshotViewBindingRoot] will automatically be
165  * [started][SnapshotViewBindingRoot.start]. When it becomes detached from its window it will
166  * automatically be [stopped][SnapshotViewBindingRoot.stop].
167  *
168  * This should generally only be called on the [View] returned by [View.getRootView] for an attached
169  * [View].
170  */
171 private val View.bindingRoot: SnapshotViewBindingRoot
172     get() {
173         val tag = getTag(R.id.snapshot_view_binding_root) as? SnapshotViewBindingRoot
174         if (tag != null) return tag
175         val newRoot =
176             SnapshotViewBindingRoot(
177                 // Use an async handler for processing invalidations; this ensures invalidations
178                 // are tracked for the upcoming frame and not the next frame.
179                 handler =
180                     HandlerCompat.createAsync(
181                         handler?.looper ?: error("$this is not attached to a window")
182                     ),
183                 choreographer = Choreographer.getInstance()
184             )
185         setTag(R.id.snapshot_view_binding_root, newRoot)
186         addOnAttachStateChangeListener(
187             object : View.OnAttachStateChangeListener {
onViewAttachedToWindownull188                 override fun onViewAttachedToWindow(view: View) {
189                     newRoot.start()
190                 }
191 
onViewDetachedFromWindownull192                 override fun onViewDetachedFromWindow(view: View) {
193                     newRoot.stop()
194                 }
195             }
196         )
197         if (isAttachedToWindow) newRoot.start()
198         return newRoot
199     }
200 
201 /**
202  * A single [SnapshotViewBinding] set on a [View] by [setSnapshotBinding]. The [SnapshotViewBinding]
203  * is responsible for invoking [SnapshotViewBindingRoot.performBindOf] when the associated [View]
204  * becomes attached to a window in order to register it for invalidation tracking and rebinding as
205  * relevant snapshot state changes. When the [View] becomes detached the binding will invoke
206  * [SnapshotViewBindingRoot.forgetBinding] for itself.
207  */
208 private class SnapshotViewBinding(
209     val performBind: () -> Unit,
210     val onError: (Throwable) -> Unit,
211 ) : View.OnAttachStateChangeListener {
212 
onViewAttachedToWindownull213     override fun onViewAttachedToWindow(view: View) {
214         Snapshot.withMutableSnapshot { view.rootView.bindingRoot.performBindOf(this) }
215     }
216 
onViewDetachedFromWindownull217     override fun onViewDetachedFromWindow(view: View) {
218         view.rootView.bindingRoot.forgetBinding(this)
219     }
220 }
221 
222 /**
223  * Set binding logic for this [View] that will be re-invoked for UI frames where relevant [Snapshot]
224  * state has changed. This can be especially useful for codebases with mixed usage of both Views and
225  * [Jetpack Compose](https://d.android.com/compose), enabling the same patterns of snapshot-backed
226  * state management when using either UI toolkit.
227  *
228  * In the following example the sender name and message text of a message item view will be kept up
229  * to date with the snapshot-backed `model.senderName` and `model.messageText` properties:
230  * ```
231  * val view = layoutInflater.inflate(R.layout.single_message, parent, false)
232  * val senderNameView = view.findViewById<TextView>(R.id.sender_name)
233  * val messageTextView = view.findViewById<TextView>(R.id.message_text)
234  * view.setSnapshotBinding {
235  *     senderNameView.text = model.senderName
236  *     messageTextView.text = model.messageText
237  * }
238  * ```
239  *
240  * Snapshot binding may also be used in concert with
241  * [View binding](https://developer.android.com/topic/libraries/view-binding):
242  * ```
243  * val binding = SingleMessageBinding.inflate(layoutInflater)
244  * binding.root.setSnapshotBinding {
245  *     binding.senderName.text = model.senderName
246  *     binding.messageText.text = model.messageText
247  * }
248  * ```
249  *
250  * When a snapshot binding is set [performBind] will be invoked immediately before
251  * [setSnapshotBinding] returns if this [View] is currently attached to a window. If the view is not
252  * currently attached, [performBind] will be invoked when the view becomes attached to a window.
253  *
254  * If a snapshot commit changes state accessed by [performBind] changes while the view remains
255  * attached to its window and the snapshot binding is not replaced or [cleared][clearBinding], the
256  * binding will be considered _invalidated,_ a rebinding will be scheduled for the upcoming UI
257  * frame, and [performBind] will be re-executed prior to the layout and draw phases for the frame.
258  * [performBind] will only be re-executed **once** for any given UI frame provided that
259  * [setSnapshotBinding] is not called again.
260  *
261  * [performBind] is always invoked from a [mutable snapshot][Snapshot.takeMutableSnapshot], ensuring
262  * atomic consistency of all snapshot state reads within it. **All** rebinding performed for
263  * invalidations of bindings within the same window for a given UI frame are performed within the
264  * **same** snapshot, ensuring that same atomic consistency of snapshot state for **all** snapshot
265  * bindings within the same window.
266  *
267  * As [performBind] is invoked for rebinding as part of the UI frame itself, [performBind]
268  * implementations should be both fast and idempotent to avoid delaying the UI frame.
269  *
270  * There are no mutual ordering guarantees between separate snapshot bindings; the [performBind] of
271  * separate snapshot bindings may be executed in any order. Similarly, no ordering guarantees exist
272  * between snapshot binding rebinding and Jetpack Compose recomposition. Snapshot bindings and
273  * Compose UIs both should obey
274  * [unidirectional data flow](https://developer.android.com/topic/architecture/ui-layer#udf)
275  * principles, consuming state from mutual single sources of truth and avoid consuming state
276  * produced by the rebinding or recomposition of other UI components.
277  */
setSnapshotBindingnull278 fun View.setSnapshotBinding(onError: (Throwable) -> Unit = { throw it }, performBind: () -> Unit) {
279     clearBinding()
280     val newBinding = SnapshotViewBinding(performBind, onError)
281     setTag(R.id.snapshot_view_binding, newBinding)
282     addOnAttachStateChangeListener(newBinding)
283     if (isAttachedToWindow) newBinding.onViewAttachedToWindow(this)
284 }
285 
286 /**
287  * Remove a snapshot binding that was set by [setSnapshotBinding]. It is not necessary to call this
288  * function before abandoning a [View] with a snapshot binding to the garbage collector.
289  */
clearBindingnull290 fun View.clearBinding() {
291     val oldBinding = getTag(R.id.snapshot_view_binding) as? SnapshotViewBinding
292     if (oldBinding != null) {
293         removeOnAttachStateChangeListener(oldBinding)
294         if (isAttachedToWindow) {
295             oldBinding.onViewDetachedFromWindow(this)
296         }
297     }
298 }
299