Drawing the empty space-Explore the implementation principle of Jetpack Compose

Drawing the empty space-Explore the implementation principle of Jetpack Compose

In Flutter, Dart specifically optimizes how to efficiently recycle frequently created and destroyed objects, while Compose's implementation on the Android platform is essentially ordinary Kotlin/JVM code. How to design Compose to have reliable performance is an interesting question.

Grouped on the tree

In 2019, Leland Richardson briefly described the implementation principle and underlying data structure of Compose in the Understanding Compose speech at the Google Android Dev Summit , and published two blogs ( Part 1 and Part 2 ) on Medium based on the content of the speech , so Here is only a brief restatement.

Mobile space

Compose Runtime uses a special data structure called Slot Table.

Slot Table is similar to Gap Buffer, another data structure commonly used in text editors. This is a type that stores data in a continuous space, and the bottom layer is implemented by an array. The difference from the usual array method is that its remaining space, called Gap, can be moved to any area in the Slot Table as needed, which makes it more efficient when inserting and deleting data.

Simply put, a Slot Table may look like this, where

_
Represents an unused array element, these spaces constitute Gap:

ABCDE _ _ _ _ _Copy code

Suppose to be in

C
After inserting new data, move Gap to after C:

ABC _ _ _ _ _ DECopy code

Then you can

C
Insert new data directly after:

ABCFG _ _ _ DECopy code

Slot Table is essentially a linear data structure, so you can store the view tree in the Slot Table by storing the tree in the array, plus the Slot Table's movable insertion point feature, so that the view tree does not need to be renewed after the change. Create the entire data structure, so Slot Table actually uses an array to store the tree.

It should be noted that, compared to ordinary arrays, Slot Table implements the function of inserting data at any position, which is an impossible leap, but actually due to element copy reasons, Gap movement is still an inefficient operation that needs to be avoided as much as possible. The reason Google chose this data structure is that they expect most of the interface updates to be data changes, that is, only the view tree node data needs to be updated, and the view tree structure does not change frequently.

The reason why Google does not use data structures such as trees or linked lists, it is guessed that the memory continuous data structure of the array can meet the requirements of Compose Runtime in terms of access efficiency.

For example, the view tree of a login interface like the following, where the hierarchy is displayed by indentation.

VerticalLinearLayout HorizontalLinearLayout AccountHintTextView AccountEditText HorizontalLinearLayout PasswordHintTextView PasswordEditText LoginButton Copy code

In Slot Table, the child nodes of the tree are called Node, and the non-child nodes are called Node.

The underlying array itself has no way to record information related to the tree, so other data structures are actually maintained internally to store some node information, such as the number of Nodes contained in the Group and the Group to which the Node directly belongs.

surroundings

@Composable
Is one of the cores of the Compose system and is
@Composable
The annotation function is called may be combined function , hereinafter also referred to as such.

This is not an ordinary annotation. The function to which the annotation is added will actually change the type, change the way and

suspend
Similarly, processing is performed at compile time, but Compose is not a language feature and cannot be implemented in the form of language keywords.

Take the Compose App template generated by Android Studio as an example, which contains such a composable function:

@Composable fun Greeting (name: String ) { Text(text = "Hello $name !" ) } Copy code

The actual code can be obtained by decompiling with the tool:

public static final void Greeting (String name, Composer $composer, int $changed) { Intrinsics.checkNotNullParameter(name, HintConstants.AUTOFILL_HINT_NAME); Composer $composer2 = $composer.startRestartGroup( 105642380 ); ComposerKt.sourceInformation($composer2, "C(Greeting)51@1521L27:MainActivity.kt#xfcxsz" ); int $dirty = $changed; if (($changed & 14 ) == 0 ) { $dirty |= $composer2.changed(name)? 4 : 2 ; } if ((($dirty & 11 ) ^ 2 ) != 0 || !$composer2.getSkipping()) { TextKt.m866Text6FffQQw(LiveLiterals$MainActivityKt.INSTANCE.m4017String$ 0 $str$arg0$callText$funGreeting() + name + LiveLiterals$MainActivityKt.INSTANCE.m4018String$ 2 $str$arg0$callText$funGreeting(), null , Color. m1136constructorimpl(ULong.m2785constructorimpl( 0 )), TextUnit.m2554constructorimpl( 0 ), null , null , null , TextUnit.m2554constructorimpl( 0 ), null , null , TextUnit.m2554constructorimpl( 0 ), null , false , 0 , null ,null , $composer2, 0 , 0 , 65534 ); } else { $composer2.skipToGroupEnd(); } ScopeUpdateScope endRestartGroup = $composer2.endRestartGroup(); if (endRestartGroup != null ) { endRestartGroup.updateScope( new MainActivityKt$Greeting$ 1 (name, $changed)); } } public final class MainActivityKt $ Greeting $1 extends Lambda implements Function2 < Composer , Integer , Unit > { final int $$changed; final String $name; MainActivityKt$Greeting$ 1 (String str, int i) { super ( 2 ); this .$name = str; this .$$changed = i; } @Override public Unit invoke (Composer composer, Integer num) { invoke(composer, num.intValue()); return Unit.INSTANCE; } public final void invoke (Composer composer, int i) { MainActivityKt.Greeting( this .$name, composer, this .$$changed | 1 ); } } Copy code

Converted to the equivalent and readable Kotlin pseudo code is as follows:

fun Greeting (name: String , parentComposer: Composer , changed: Int ) { val composer = parentComposer.startRestartGroup(GROUP_HASH) val dirty = calculateState(changed) if (stateHasChanged(dirty) || composer.skipping) { Text( "Hello $name " , composer = composer, changed = ...) } else { composer.skipToGroupEnd() } composer.endRestartGroup()?.updateScope { Greeting(name, changed) } } Copy code

Visible be

@Composable
After the annotation, the function adds additional parameters, of which
Composer
The type parameter runs through the entire composable function call chain as the operating environment, so the composable function cannot be called in the ordinary function because the corresponding environment is not included. Because of the incoming environment, two identical composable function calls with different calling positions have different implementation effects.

The start and end of the composable function implementation are passed

Composer.startRestartGroup()
versus
Composer.endRestartGroup()
Create a Group in the Slot Table, and the composable function called inside the composable function creates a new Group between the two calls, thus completing the construction of the view tree inside the Slot Table.

Composer determines the type of implementation of these calls based on whether the view tree is currently being modified.

After the view tree is constructed, if the data update causes some views to be refreshed, the call of the non-refreshing part corresponding to the composable function is no longer to construct the view tree, but to access the view tree, as in the code

Composer.skipToGroupEnd()
Call, which means to jump directly to the end of the current Group during the access process.

Composer's operation on the Slot Table is separated from reading and writing, and all written content is updated to the Slot Table only after the write operation is completed.

In addition, the composable function will also judge whether the internal composable function is executed or skipped through the bit operation of the passed mark parameter, which can avoid accessing nodes that do not need to be updated and improve execution efficiency.

Reorganization

The previous text and code mentioned two points. One is that the combinable function can be executed or skipped by the bit operation of the passed mark parameter to determine the execution or skip of the internal combinable function, and the other is the combinable function

Composer.endRestartGroup()
Returned a
ScopeUpdateScope
Type object, its
ScopeUpdateScope.updateScope()
The function is called, and the Lambda that calls the current composable function is passed in. These contents indicate that Compose Runtime can determine the calling range of composable functions according to the current environment.

When the view data changes, Compose Runtime will determine the composable functions that need to be re-executed according to the scope of data influence. This step is called reorganization and is executed in the previous code.

ScopeUpdateScope.updateScope()
The role of is to register and reorganize the composable function that needs to be executed.

updateScope
The name of this function is confusing. The incoming Lambda is a callback and will not be executed immediately. The name that is more conducive to understanding is
onScopeUpdate
or
setUpdateScope
.

In order to explain Compose's reorganization mechanism, we need to talk about the structure of Compose's management data, State.

Because Compose is a declarative framework, State uses the observer mode to realize the interface is automatically updated with data. 1. an example is used to illustrate the use of State.

@Composable fun Content () { val state by remember {mutableStateOf( 1 )} Column { Button(onClick = {state++ }) { Text(text = "click to change state" ) } Text( "state value: $state " ) } } Copy code

remember()
Is a composable function, similar to
lazy
, Its role is to memorize objects in composable function calls. The composable function is called under the condition that the position of the call chain does not change
remember()
You can get the content memorized the last time it was called.

This is related to the characteristics of the composable function and can be understood as

remember()
Recording data at the current position of the tree also means that the same composable function is called at different calling positions. The internal
remember()
The obtained content is not the same, because the calling location is different, and the nodes on the corresponding tree are also different.

Due to the design of the observer mode, when

state
Reorganization is triggered when data is written, so it can be guessed that the implementation of triggering reorganization is in the implementation of State writing.

mutableStateOf()
Will eventually return
ParcelableSnapshotMutableState
Object, the relevant code is in its superclass
SnapshotMutableStateImpl
.

/** * A single value holder whose reads and writes are observed by Compose. * * Additionally, writes to it are transacted as part of the [Snapshot] system. * * @param value the wrapped value * @param policy a policy to control how changes are handled in a mutable snapshot. * * @see mutableStateOf * @see SnapshotMutationPolicy */ internal open class SnapshotMutableStateImpl < T > ( value: T, override val policy: SnapshotMutationPolicy<T> ): StateObject, SnapshotMutableState<T> { @Suppress( "UNCHECKED_CAST" ) override var value: T get () = next.readable( this ).value set (value) = next.withCurrent { if (!policy.equivalent(it.value, value)) { next.overwritable( this , it) { this .value = value} } } private var next: StateStateRecord<T> = StateStateRecord(value) ... } Copy code

StateStateRecord.overwritable()
Will eventually call
notifyWrite()
Implement observer notification.

@PublishedApi internal fun notifyWrite (snapshot: Snapshot , state: StateObject ) { snapshot.writeObserver?.invoke(state) } Copy code

The next step is to determine the callback, which can be quickly located through the Debugger

writeObserver
in
GlobalSnapshotManager.ensureStarted()
Registered in:

/** * Platform-specific mechanism for starting a monitor of global snapshot state writes * in order to schedule the periodic dispatch of snapshot apply notifications. * This process should remain platform-specific; it is tied to the threading and update model of * a particular platform and framework target. * * Composition bootstrapping mechanisms for a particular platform/framework should call * [ensureStarted] during setup to initialize periodic global snapshot notifications. * For Android, these notifications are always sent on [AndroidUiDispatcher.Main]. Other platforms * may establish different policies for these notifications. */ internal object GlobalSnapshotManager { private val started = AtomicBoolean( false ) fun ensureStarted () { if (started.compareAndSet( false , true )) { val channel = Channel< Unit >(Channel.CONFLATED) CoroutineScope(AndroidUiDispatcher.Main).launch { channel.consumeEach { Snapshot.sendApplyNotifications() } } Snapshot.registerGlobalWriteObserver { channel.offer( Unit ) } } } } Copy code

When

channel
Push objects in the main thread trigger
Snapshot.sendApplyNotifications()
After the call, the call chain will arrive
advanceGlobalSnapshot()
, The callback of the data update listener is implemented here.

private fun <T> advanceGlobalSnapshot (block: ( invalid : SnapshotIdSet ) -> T ) : T { ... //If the previous global snapshot had any modified states then notify the registered apply //observers. val modified = previousGlobalSnapshot.modified if (modified != null ) { val observers: List<(Set<Any>, Snapshot) -> Unit > = sync {applyObservers.toMutableList()} observers.fastForEach {observer -> observer(modified, previousGlobalSnapshot) } } ... } Copy code

Recompose

Debug and filter through Debugger, you can find

observers
Contains two callbacks, one of which is located in
Recomposer.recompositionRunner()
.

/** * The scheduler for performing recomposition and applying updates to one or more [Composition]s. */ //RedundantVisibilityModifier suppressed because metalava picks up internal function overrides // if'internal ' is not explicitly specified-b/171342041 //NotCloseable suppressed because this is Kotlin-only common code; [Auto]Closeable not available. @Suppress ( "RedundantVisibilityModifier" , "NotCloseable" ) @OptIn(InternalComposeApi::class) class Recomposer ( effectCoroutineContext: CoroutineContext ): CompositionContext() { ... @OptIn(ExperimentalComposeApi::class) private suspend fun recompositionRunner ( block: suspend CoroutineScope .( parentFrameClock : MonotonicFrameClock ) -> Unit ) { withContext(broadcastFrameClock) { ... //Observe snapshot changes and propagate them to known composers only from //this caller's dispatcher, never working with the same composer in parallel. //unregisterApplyObserver is called as part of the big finally below val unregisterApplyObserver = Snapshot.registerApplyObserver {changed, _ -> synchronized(stateLock) { if (_state.value >= State.Idle) { snapshotInvalidations += changed deriveStateLocked() } else null }?.resume( Unit ) } ... } } ... } Copy code

Trigger callback will increase

snapshotInvalidations
The elements in the follow-up instructions.

when

AbstractComposeView.onAttachToWindow()
When called,
Recomposer.runRecomposeAndApplyChanges()
Is called and enables the loop to wait for the reorganization event.

... class Recomposer ( effectCoroutineContext: CoroutineContext ): CompositionContext() { ... /** * Await the invalidation of any associated [Composer]s, recompose them, and apply their * changes to their associated [Composition]s if recomposition is successful. * * While [runRecomposeAndApplyChanges] is running, [awaitIdle] will suspend until there are no * more invalid composers awaiting recomposition. * * This method will not return unless the [Recomposer] is [close]d and all effects in managed * compositions complete. * Unhandled failure exceptions from child coroutines will be thrown by this method. */ suspend fun runRecomposeAndApplyChanges () = recompositionRunner {parentFrameClock -> ... while (shouldKeepRecomposing) { ... //Don't await a new frame if we don't have frame-scoped work if ( synchronized(stateLock) { if (!hasFrameWorkLocked) { recordComposerModificationsLocked() !hasFrameWorkLocked } else false } ) continue //Align work with the next frame to coalesce changes. //Note: it is possible to resume from the above with no recompositions pending, //instead someone might be awaiting our frame clock dispatch below. //We use the cached frame clock from above not just so that we don't locate it //each time, but because we've installed the broadcastFrameClock as the scope //clock above for user code to locate. parentFrameClock.withFrameNanos {frameTime -> ... trace( "Recomposer:recompose" ) { ... val modifiedValues = IdentityArraySet<Any>() try { toRecompose.fastForEach {composer -> performRecompose(composer, modifiedValues)?.let { toApply += it } } if (toApply.isNotEmpty()) changeCount++ } finally { toRecompose.clear() } ... } } } } ... } Copy code

When a reorganization event occurs,

recordComposerModificationLocked()
Will trigger,
compositionInvalidations
The content in is updated, and the update of the object depends on
snapshotInvalidations
, Which eventually leads to
hasFrameWorkLocked
Change to
true
.

AndroidUiFrameClock.withFrameNanos()
Will be called, which will register the vertical synchronization signal callback with Choreographer,
Recomposer.performRecompose()
Will eventually trigger from
ScopeUpdateScope.updateScope()
Register the call of Lambda.

class AndroidUiFrameClock ( val choreographer: Choreographer ): androidx.compose.runtime.MonotonicFrameClock { override suspend fun <R> withFrameNanos ( onFrame: ( Long ) -> R ) : R { val uiDispatcher = coroutineContext[ContinuationInterceptor] as ? AndroidUiDispatcher return suspendCancellableCoroutine {co -> //Important: this callback won't throw, and AndroidUiDispatcher counts on it. val callback = Choreographer. FrameCallback {frameTimeNanos -> co.resumeWith(runCatching {onFrame(frameTimeNanos) }) } //If we're on an AndroidUiDispatcher then we post callback to happen *after* //the greedy trampoline dispatch is complete. //This means that onFrame will run on the current choreographer frame if one is //already in progress, but withFrameNanos will *not* resume until the frame //is complete. This prevents multiple calls to withFrameNanos immediately dispatching //on the same frame. if (uiDispatcher != null && uiDispatcher.choreographer == choreographer) { uiDispatcher.postFrameCallback(callback) co.invokeOnCancellation {uiDispatcher.removeFrameCallback(callback)} } else { choreographer.postFrameCallback(callback) co.invokeOnCancellation {choreographer.removeFrameCallback(callback)} } } } } Copy code

Invalidate

Similarly, by debugging and filtering through Debugger, you can locate another callback is

SnapshotStateObserver.applyObserver
.

class SnapshotStateObserver ( private val onChangedExecutor: (callback: () -> Unit ) -> Unit ) { private val applyObserver: (Set<Any>, Snapshot) -> Unit = {applied, _ -> var hasValues = false ... if (hasValues) { onChangedExecutor { callOnChanged() } } } ... } Copy code

by

SnapshotStateObserver.callOnChanged()
Can locate the callback
LayoutNodeWrapper.Companion.onCommitAffectingLayer
.

Call chain:

SnapshotStateObserver.callOnChanged()
-->

SnapshotStateObserver.ApplyMap.callOnChanged()
-->

SnapshotStateObserver.ApplyMap.onChanged.invoke()
-implementation ->

LayoutNodeWrapper.Companion.onCommitAffectingLayer.invoke()

/** * Measurable and Placeable type that has a position. */ internal abstract class LayoutNodeWrapper ( internal val layoutNode: LayoutNode ): Placeable(), Measurable, LayoutCoordinates, OwnerScope, (Canvas) -> Unit { ... internal companion object { ... private val onCommitAffectingLayer: (LayoutNodeWrapper) -> Unit = {wrapper -> wrapper.layer?.invalidate() } ... } } Copy code

Finally at

RenderNodeLayer.invalidate()
Trigger in the top layer
AndroidComposeView
Redraw, realize the view update.

/** * RenderNode implementation of OwnedLayer. */ @RequiresApi(Build.VERSION_CODES.M) internal class RenderNodeLayer ( val ownerView: AndroidComposeView, val drawBlock: (Canvas) -> Unit , val invalidateParentLayer: () -> Unit ): OwnedLayer { ... override fun invalidate () { if (!isDirty && !isDestroyed) { ownerView.invalidate() ownerView.dirtyLayers += this isDirty = true } } ... } Copy code

Draw empty

How is Compose drawn?

The execution of the composable function completes the construction of the view tree, but does not perform the rendering of the view tree. The two implementations are separated. The system will hand over the view tree generated after the reorganization function is completed to the rendering module to run.

Composable functions may not only run in the main thread, and may even run concurrently in multiple threads, but this does not mean that time-consuming operations can be directly performed in the composable function, because the composable function may be called frequently, or even one. Frame once.

Reorganization is an optimistic operation. When the data is updated before the reorganization is completed, the reorganization may be cancelled. Therefore, the reorganizable function should be idempotent in design and has no incidental effects. You can learn about functional programming for related content.

structure

It is mentioned in the Google document about Compose and View compatibility

ComposeView
versus
AbstractComposeView
, But if you look at the code, you will find that this is different from what we mentioned earlier
AndroidComposeView
There is no inheritance relationship.

By first official example to see how the function will be converted into a combination of View:

@Composable fun CallToActionButton ( text: String , onClick: () -> Unit , modifier: Modifier = Modifier, ) { Button( colors = ButtonDefaults.buttonColors( backgroundColor = MaterialTheme.colors.secondary ), onClick = onClick, modifier = modifier, ) { Text(text) } } class CallToActionViewButton @JvmOverloads constructor ( context: Context, attrs: AttributeSet? = null , defStyle: Int = 0 ): AbstractComposeView(context, attrs, defStyle) { var text by mutableStateOf<String>( "" ) var onClick by mutableStateOf<() -> Unit >({}) @Composable override fun Content () { YourAppTheme { CallToActionButton(text, onClick) } } } Copy code

Look for

AbstractComposeView.Content()
Caller will eventually locate
ViewGroup.setContent()
Extension function,

/** * Composes the given composable into the given view. * * The new composition can be logically "linked" to an existing one, by providing a * [parent]. This will ensure that invalidations and CompositionLocals will flow through * the two compositions as if they were not separate. * * Note that this [ViewGroup] should have an unique id for the saved instance state mechanism to * be able to save and restore the values used within the composition. See [View.setId]. * * @param parent The [Recomposer] or parent composition reference. * @param content Composable that will be the content of the view. */ internal fun ViewGroup. setContent ( parent: CompositionContext , content: @ Composable () -> Unit ) : Composition { GlobalSnapshotManager.ensureStarted() val composeView = if (childCount> 0 ) { getChildAt( 0 ) as ? AndroidComposeView } else { removeAllViews(); null } ?: AndroidComposeView(context).also {addView(it.view, DefaultLayoutParams)} return doSetContent(composeView, parent, content) } Copy code

Visible, the View Group will only keep one

AndroidComposeView
View while
doSetContent()
Function sets the combined function to
AndroidComposeView
in.

Rendering

The call of the composable function will eventually build a tree containing data and view information, and the composable functions of various view types will eventually call the composable function

ReusableComposeNode()
And create a
LayoutNode
Objects are recorded in the tree as child nodes.

LayoutNode
The existence of is similar to that in Flutter
Element
, They are part of the view tree structure and are relatively stable.

The implementation of Compose on Android ultimately depends on

AndroidComposeView
And this is a
ViewGroup
, Then according to the perspective of native view rendering, take a look
AndroidComposeView
Correct
onDraw()
versus
dispatchDraw()
You can see the principle of Compose rendering.

@SuppressLint ( "ViewConstructor" , "VisibleForTests" ) @OptIn(ExperimentalComposeUiApi::class) @RequiresApi(Build.VERSION_CODES.LOLLIPOP) internal class AndroidComposeView (context: Context): ViewGroup(context), Owner, ViewRootForTest, PositionCalculator { ... override fun onDraw (canvas: android . graphics . Canvas ) { } ... override fun dispatchDraw (canvas: android . graphics . Canvas ) { ... measureAndLayout() //we don't have to observe here because the root has a layer modifier //that will observe all children. The AndroidComposeView has only the //root, so it doesn't have to invalidate itself based on model changes. canvasHolder. drawInto(canvas) {root.draw( this )} ... } ... } Copy code

CanvasHolder.drawInto()
will
android.graphics.Canvas
Converted to
androidx.compose.ui.graphics.Canvas
Realization is passed to the top level
LayoutNode
Object
root
of
LayoutNode.draw()
In the function, the rendering of the view tree is realized.

Due to the different design of the combinable functions of various view types, only the combinable functions of Bitmap are drawn here.

Image()
As an example, its implementation is as follows.

/** * A composable that lays out and draws a given [ImageBitmap]. This will attempt to * size the composable according to the [ImageBitmap]'s given width and height. However, an * optional [Modifier] parameter can be provided to adjust sizing or draw additional content (ex. * background). Any unspecified dimension will leverage the [ImageBitmap]'s size as a minimum * constraint. * * The following sample shows basic usage of an Image composable to position and draw an * [ImageBitmap] on screen * @sample androidx.compose.foundation.samples.ImageSample * * For use cases that require drawing a rectangular subset of the [ImageBitmap] consumers can use * overload that consumes a [Painter] parameter shown in this sample * @sample androidx.compose.foundation.samples.BitmapPainterSubsectionSample * * @param bitmap The [ImageBitmap] to draw * @param contentDescription text used by accessibility services to describe what this image * represents. This should always be provided unless this image is used for decorative purposes, * and does not represent a meaningful action that a user can take. This text should be * localized, such as by using [androidx.compose.ui.res.stringResource] or similar * @param modifier Modifier used to adjust the layout algorithm or draw decoration content (ex. * background) * @param alignment Optional alignment parameter used to place the [ImageBitmap] in the given * bounds defined by the width and height * @param contentScale Optional scale parameter used to determine the aspect ratio scaling to be used * if the bounds are a different size from the intrinsic size of the [ImageBitmap] * @param alpha Optional opacity to be applied to the [ImageBitmap] when it is rendered onscreen * @param colorFilter Optional ColorFilter to apply for the [ImageBitmap] when it is rendered * onscreen */ @Composable fun Image ( bitmap: ImageBitmap , contentDescription: String ?, modifier: Modifier = Modifier, alignment: Alignment = Alignment.Center, contentScale: ContentScale = ContentScale.Fit, alpha: Float = DefaultAlpha, colorFilter: ColorFilter ? = null ) { val bitmapPainter = remember(bitmap) {BitmapPainter(bitmap)} Image( painter = bitmapPainter, contentDescription = contentDescription, modifier = modifier, alignment = alignment, contentScale = contentScale, alpha = alpha, colorFilter = colorFilter ) } /** * Creates a composable that lays out and draws a given [Painter]. This will attempt to size * the composable according to the [Painter]'s intrinsic size. However, an optional [Modifier] * parameter can be provided to adjust sizing or draw additional content (ex. background) * * **NOTE** a Painter might not have an intrinsic size, so if no LayoutModifier is provided * as part of the Modifier chain this might size the [Image] composable to a width and height * of zero and will not draw any content. This can happen for Painter implementations that * always attempt to fill the bounds like [ColorPainter] * * @sample androidx.compose.foundation.samples.BitmapPainterSample * * @param painter to draw * @param contentDescription text used by accessibility services to describe what this image * represents. This should always be provided unless this image is used for decorative purposes, * and does not represent a meaningful action that a user can take. This text should be * localized, such as by using [androidx.compose.ui.res.stringResource] or similar * @param modifier Modifier used to adjust the layout algorithm or draw decoration content (ex. * background) * @param alignment Optional alignment parameter used to place the [Painter] in the given * bounds defined by the width and height. * @param contentScale Optional scale parameter used to determine the aspect ratio scaling to be used * if the bounds are a different size from the intrinsic size of the [Painter] * @param alpha Optional opacity to be applied to the [Painter] when it is rendered onscreen * the default renders the [Painter] completely opaque * @param colorFilter Optional colorFilter to apply for the [Painter] when it is rendered onscreen */ @Composable fun Image ( painter: Painter , contentDescription: String ?, modifier: Modifier = Modifier, alignment: Alignment = Alignment.Center, contentScale: ContentScale = ContentScale.Fit, alpha: Float = DefaultAlpha, colorFilter: ColorFilter ? = null ) { val semantics = if (contentDescription != null ) { Modifier.semantics { this .contentDescription = contentDescription this .role = Role.Image } } else { Modifier } //Explicitly use a simple Layout implementation here as Spacer squashes any non fixed //constraint with zero Layout( {}, modifier.then(semantics).clipToBounds().paint( painter, alignment = alignment, contentScale = contentScale, alpha = alpha, colorFilter = colorFilter ) ) {_, constraints -> layout(constraints.minWidth, constraints.minHeight) {} } } Copy code

This builds a containing

BitmapPainter
of
Modifier
Incoming
Layout()
While this
Modifier
The object will eventually be set to the corresponding
LayoutNode
Object.

As mentioned above, when

LayoutNode.draw()
When called, its
outLayoutNodeWrapper
of
LayoutNodeWrapper.draw()
Will be called.

/** * An element in the layout hierarchy, built with compose UI. */ internal class LayoutNode : Measurable , Remeasurement , OwnerScope , LayoutInfo , ComposeUiNode { ... internal fun draw (canvas: Canvas ) = outerLayoutNodeWrapper.draw(canvas) ... } /** * Measurable and Placeable type that has a position. */ internal abstract class LayoutNodeWrapper ( internal val layoutNode: LayoutNode ): Placeable(), Measurable, LayoutCoordinates, OwnerScope, (Canvas) -> Unit { ... /** * Draws the content of the LayoutNode */ fun draw (canvas: Canvas ) { val layer = layer if (layer != null ) { layer.drawLayer(canvas) } else { val x = position.x.toFloat() val y = position.y.toFloat() canvas.translate(x, y) performDraw(canvas) canvas.translate(-x, -y) } } ... } Copy code

After multiple commissions,

LayoutNodeWrapper.draw()
Will call
InnerPlaceholder.performDraw()
Realize the rendering and distribution of subviews.

internal class InnerPlaceable ( layoutNode: LayoutNode ): LayoutNodeWrapper(layoutNode), Density by layoutNode.measureScope { ... override fun performDraw (canvas: Canvas ) { val owner = layoutNode.requireOwner() layoutNode.zSortedChildren.forEach {child -> if (child.isPlaced) { child.draw(canvas) } } if (owner.showLayoutBounds) { drawBorder(canvas, innerBoundsPaint) } } ... } Copy code

When finally reaching the Image view node that renders the Bitmap,

LayoutNodeWrapper
The realization is
ModifiedDrawNode
.

internal class ModifiedDrawNode ( wrapped: LayoutNodeWrapper, drawModifier: DrawModifier ): DelegatingLayoutNodeWrapper<DrawModifier>(wrapped, drawModifier), OwnerScope { ... //This is not thread safe override fun performDraw (canvas: Canvas ) { ... val drawScope = layoutNode.mDrawScope drawScope.draw(canvas, size, wrapped) { with(drawScope) { with(modifier) { draw() } } } } ... } Copy code

What is called here is

PainterModifier
of
DrawScope.draw()
achieve.

This is a very peculiar way of using Kotlin extension functions. The extension function can be used as an interface function, which is implemented by the interface implementation class, and must pass

with()
,
apply()
,
run()
Waiting for setting
this
The scope of the function build environment.

But this way of writing is in multiple layers

this
When nesting, readability still needs to be discussed, just like the above
DrawScope.draw()
Call. If you can t understand what the code above contains something worth complaining about, you can take a look at the following example .

class Api { fun String. show () { println( this ) } } fun main () { "Hello world!" .apply { Api().apply { show() } } } Copy code

Then call

BitmapPainter
of
DrawScope.onDraw()
achieve.

/** * [Painter] implementation used to draw an [ImageBitmap] into the provided canvas * This implementation can handle applying alpha and [ColorFilter] to it's drawn result * * @param image The [ImageBitmap] to draw * @param srcOffset Optional offset relative to [image] used to draw a subsection of the * [ImageBitmap]. By default this uses the origin of [image] * @param srcSize Optional dimensions representing size of the subsection of [image] to draw * Both the offset and size must have the following requirements: * * 1) Left and top bounds must be greater than or equal to zero * 2) Source size must be greater than zero * 3) Source size must be less than or equal to the dimensions of [image] */ class BitmapPainter( private val image: ImageBitmap, private val srcOffset: IntOffset = IntOffset.Zero, private val srcSize: IntSize = IntSize(image.width, image.height) ) : Painter() { ... override fun DrawScope.onDraw() { drawImage( image, srcOffset, srcSize, dstSize = IntSize( this@onDraw.size.width.roundToInt(), this@onDraw.size.height.roundToInt() ), alpha = alpha, colorFilter = colorFilter ) } ... }

DrawScope.onDraw()
androidx.compose.ui.graphics.Canvas
android.graphics.Canvas
Compose