Comprensión profunda del núcleo de Jetpack Compose: sistema SlotTable

introducción

El dibujo de Compose tiene tres etapas, composición > maquetación > dibujo. Los dos últimos procesos son similares al proceso de representación de las vistas tradicionales, excepto que la composición es exclusiva de Compose. Compose genera un árbol de representación a través de la combinación, que es la capacidad central del marco Compose, y SlotTable implementa principalmente este proceso.Este artículo presenta el sistema SlotTable.

1. Comience con el proceso de renderizado Componer

La esencia del proceso de desarrollo basado en la vista nativa de Android es construir un árbol de representación basado en la vista. Cuando llega la señal del cuadro, comienza a atravesar profundamente desde el nodo raíz y llama a medir/diseñar/dibujar en secuencia hasta la representación de todo el árbol está completo. Para Compose, también existe un árbol de representación de este tipo, al que llamamos Composición . El nodo en el árbol es LayoutNode . Composición completa el proceso de medir/diseñar/dibujar a través de LayoutNode y finalmente muestra la interfaz de usuario en la pantalla. La composición se basa en la ejecución de las funciones de Composable para crear y actualizar, las llamadas composición y reorganización .

Por ejemplo, el código Composable anterior generará la Composición a la derecha después de la ejecución.

¿Cómo se convierte una función en un LayoutNode después de la ejecución? Después de profundizar en el código fuente de Text, descubrimos que Layout se llama internamente. Layout es un Composable que puede personalizar el diseño. Todos los tipos de Composables que usamos directamente finalmente logran diferentes diseños y efectos de visualización llamando a Layout.

//Layout.kt
@Composable inline fun Layout(
    content: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    measurePolicy: MeasurePolicy
) {
    
    
    val density = LocalDensity.current
    val layoutDirection = LocalLayoutDirection.current
    val viewConfiguration = LocalViewConfiguration.current
    ReusableComposeNode<ComposeUiNode, Applier<Any>>(
        factory = ComposeUiNode.Constructor, 
        update = {
    
    
            set(measurePolicy, ComposeUiNode.SetMeasurePolicy)
            set(density, ComposeUiNode.SetDensity)
            set(layoutDirection, ComposeUiNode.SetLayoutDirection)
            set(viewConfiguration, ComposeUiNode.SetViewConfiguration)
        },
        skippableUpdate = materializerOf(modifier),
        content = content
    )
}

Layout crea LayoutNode internamente a través de ReusableComposeNode.

  • factoryEs la fábrica que crea LayoutNode.
  • updateSe utiliza para registrar el estado del Nodo actualizado para su posterior renderizado.

Continúe en ReusableComposeNode:

//Composables.kt
inline fun <T, reified E : Applier<*>> ReusableComposeNode(
    noinline factory: () -> T,
    update: @DisallowComposableCalls Updater<T>.() -> Unit,
    noinline skippableUpdate: @Composable SkippableUpdater<T>.() -> Unit,
    content: @Composable () -> Unit
) {
    
    
    //... 
    $composer.startReusableNode()
    //...
    $composer.createNode(factory)
    //...
    Updater<T>(currentComposer).update()
    //...
    $composer.startReplaceableGroup(0x7ab4aae9)
    content()
    $composer.endReplaceableGroup()
    $composer.endNode()
}

Sabemos que la función Composable se pasará a Composer después de la compilación, y se completan una serie de operaciones en el código basadas en el Composer pasado. La lógica principal es muy clara:

  • Composer#createNodecrear nodo
  • Updater#updateActualizar estado del nodo
  • content()Continúe ejecutando el Composable interno, creando nodos secundarios.

Además, algunos startXXX/endXXX están intercalados en el código, tales llamadas emparejadas son como empujar/hacer estallar cuando se atraviesa un árbol.

startReusableNode
    NodeData // Node数据
    startReplaceableGroup
        GroupData //Group数据
        ... // 子Group
    endGroup
endNode

No solo el Composable incorporado como ReusableComposeNode, el código compilado del cuerpo de la función Composable escrito por nosotros mismos también insertará una gran cantidad de startXXX/endXXX. Estos son en realidad el proceso por el cual Composer accede a SlotTable. La función de Composer es leer y escribir SlotTable para crear y actualizar Composición .

La siguiente figura es el diagrama de clase de relación de Composición, Compositor y SlotTable

2. Conociendo SlotTable primero

En el artículo anterior, llamamos al árbol de renderizado generado después de la ejecución de Composable Compositioin. De hecho, para ser más precisos, hay dos árboles en Composición, uno es el árbol LayoutNode, que es el árbol que realmente realiza el renderizado, y LayoutNode puede completar el proceso de renderizado específico, como medir/diseñar/dibujar como View; y el otro árbol es SlotTable, que registra los diversos estados de datos en la Composición . El estado de la vista tradicional se registra en el objeto View. En Compose, está orientado a la programación funcional en lugar de estar orientado a objetos, por lo que estos estados deben ser administrados y mantenidos por SlotTable.

Todos los datos generados durante la ejecución de las funciones componibles se almacenarán en SlotTable, incluido el estado, la composición local, la clave y el valor de recordar, etc. Estos datos no desaparecerán cuando la función se saque de la pila y pueden existir a través de la recombinación. La función componible actualizará SlotTable si se generan nuevos datos durante la reorganización.

Los datos de SlotTable se almacenan en Slot, y uno o más Slots pertenecen a un Grupo. El grupo puede entenderse como cada nodo en el árbol. Se dice que SlotTable es un árbol. De hecho, no es una estructura de datos de árbol real. Utiliza matrices lineales para expresar la semántica de un árbol.
Esto se puede ver en la definición de SlotTable:

//SlotTable.kt
internal class SlotTable : CompositionData, Iterable<CompositionGroup> {
    
    

    /**
     * An array to store group information that is stored as groups of [Group_Fields_Size]
     * elements of the array. The [groups] array can be thought of as an array of an inline
     * struct.
     */
    var groups = IntArray(0)
        private set
 
    /**
     * An array that stores the slots for a group. The slot elements for a group start at the
     * offset returned by [dataAnchor] of [groups] and continue to the next group's slots or to
     * [slotsSize] for the last group. When in a writer the [dataAnchor] is an anchor instead of
     * an index as [slots] might contain a gap.
     */
    var slots = Array<Any?>(0) {
    
     null }
        private set

SlotTable tiene dos miembros de matriz, groupsla matriz almacena información de grupo y slotsalmacena los datos gobernados por el grupo. La ventaja de usar matrices en lugar de almacenamiento estructurado es que se puede mejorar la velocidad de acceso a los "árboles". La frecuencia de reorganización en Compose es muy alta. Durante el proceso de reorganización, SlotTable se leerá y escribirá continuamente, y la complejidad de tiempo para acceder a la matriz es solo O (1), por lo que usar una estructura de matriz lineal puede ayudar a mejorar el rendimiento. de reorganización.

grupos es un IntArray, y cada 5 Ints forman un Grupo de información

  • key: Identificador del grupo en SlotTable, único dentro del alcance del Grupo principal
  • Group info: El Bit de Int almacena alguna información del Grupo, como si es un Nodo, si contiene Datos, etc. Esta información se puede obtener a través de una máscara de bits.
  • Parent anchor: Posición de los padres en grupos, es decir, el desplazamiento relativo al puntero de la matriz
  • Size: Group: El número de Slots incluidos
  • Data anchor: La posición inicial de la ranura asociada en la matriz de ranuras

Las ranuras son donde realmente se almacenan los datos.Cualquier tipo de datos puede generarse durante la ejecución de Composable, por lo que el tipo de matriz es Any?. El número de Slots asociados con cada Grupo es variable, y los Slots se almacenan en los slots en el orden del Grupo al que pertenecen.

los grupos y slots no son listas enlazadas, por lo que cuando la capacidad sea insuficiente, se ampliarán.

3. Comprensión profunda del Grupo

El papel de los grupos

Los datos de la SlotTable se almacenan en el Slot, ¿por qué la unidad que actúa como nodo en el árbol no es un Slot sino un Grupo? Porque Group proporciona las siguientes funciones:

  • Cree una estructura de árbol : durante la primera ejecución de Composable, se creará un nodo de grupo en startXXXGroup y se almacenará en SlotTable, y la relación padre-hijo del grupo se construirá configurando el ancla principal. el Grupo es la base para construir el árbol de renderizado.

  • Cambio de estructura de reconocimiento : cuando se inserta el código startXXXGroup durante la compilación, $keyse generará uno identificable (único dentro del ámbito principal) en función de la posición del código. Cuando se combina por primera vez, $keyse almacenará en SlotTable junto con el Grupo. Durante la reorganización, Composer $keypuede reconocer la adición, eliminación o movimiento de posición del Grupo en función de la comparación. En otras palabras, el Grupo registrado en la SlotTable lleva información de posición, por lo que este mecanismo también se denomina Memoización Posicional . La memorización posicional puede descubrir cambios en la estructura de SlotTable y, finalmente, traducirse en actualizaciones del árbol LayoutNode.

  • La unidad de reorganización más pequeña : la reorganización de Compose es "inteligente", y las funciones Composable o Lambdas pueden omitir la ejecución innecesaria durante la reorganización. En SlotTtable, estas funciones o lambdas se empaquetarán en RestartGroups uno por uno, por lo que Group es la unidad más pequeña para participar en la reorganización.

Tipo de Grupo

Composable generará una variedad de diferentes tipos de startXXXGroup durante la compilación. Cuando se insertan en el Grupo en SlotTable, almacenarán información auxiliar para lograr diferentes funciones:

inicioXXXGrupo ilustrar
startNode/startReusableNode Inserte un grupo que contenga un nodo. Por ejemplo, en el ejemplo de ReusableComposeNode al comienzo del artículo, se muestra que se llama a startReusableNode y luego se llama a createNode para insertar LayoutNode en la ranura.
startRestartGroup Inserte un grupo repetible, que se puede ejecutar nuevamente con la reorganización, por lo que RestartGroup es la unidad de reorganización más pequeña.
startReplaceableGroup Inserte un grupo que se pueda reemplazar, por ejemplo, un bloque de código if/else es un grupo reemplazable, se puede insertar y eliminar de SlotTable en la reorganización.
startMovableGroup Inserte un grupo que se pueda mover, y el movimiento de posición puede ocurrir entre grupos hermanos durante la reorganización.
startReusableGroup Inserte un grupo reutilizable cuyos datos internos se puedan reutilizar entre LayoutNodes, como el mismo tipo de elemento en LazyList.

Por supuesto, startXXXGroup no solo se usa para insertar un nuevo grupo, sino que también se usa para rastrear el grupo existente en SlotTable durante la reorganización y
compararlo con el código que se está ejecutando actualmente. A continuación, veamos en qué tipo de códigos aparecen varios tipos diferentes de startXXXGroup.

4. startXXXGroup generado durante la compilación

Anteriormente se introdujeron varios tipos de startXXXGroup. Cuando escribimos código Compose entre semana, no tenemos percepción de ellos, entonces, ¿bajo qué circunstancias se generan? Veamos varios tiempos comunes de generación de startXXXGroup:

startReplaceableGroup

Anteriormente se mencionó el concepto de Memoización Posicional, es decir, cuando el Grupo se almacena en la SlotTable, se generará en función de la posición $key, lo que ayuda a identificar los cambios estructurales de la SlotTable. El siguiente código explica esta característica más claramente

@Composable
fun ReplaceableGroupTest(condition: Boolean) {
    
    
    if (condition) {
    
    
        Text("Hello") //Text Node 1
    } else {
    
    
        Text("World") //Text Node 2
    }
}

Este código, cuando la condición cambia de verdadero a falso, significa que el árbol de representación debe eliminar el antiguo Nodo de texto 1 y agregar el nuevo Nodo de texto 2. En el código fuente, no agregamos una clave identificable al Texto. Si solo lo ejecutamos de acuerdo con el código fuente, el programa no puede reconocer la diferencia entre el Nodo antes y después del cambio de estado, lo que puede causar el estado del nodo anterior. permanecer, y la interfaz de usuario no cumple con las expectativas.

¿Cómo resuelve Compose este problema? Eche un vistazo a cómo se ve el código anterior después de la compilación (pseudocódigo):

@Composable
fun ReplaceableGroupTest(condition: Boolean, $composer: Composer?, $changed: Int) {
    
    
    if (condition) {
    
    
        $composer.startReplaceableGroup(1715939608)
        Text("Hello")
        $composer.endReplaceableGroup()
    } else {
    
    
        $composer.startReplaceableGroup(1715939657)
        Text("World")
        $composer.endReplaceableGroup()
    }
}

Como puede ver, el compilador inserta un RestaceableGroup para cada rama condicional del if/else y agrega uno diferente $key. De esta forma, cuando conditionse produce , podemos identificar que el Grupo ha cambiado, cambiando así estructuralmente la SlotTable en lugar de simplemente actualizar el Nodo original.

Incluso si se llaman varios Composables dentro de if/else (por ejemplo, pueden aparecer varios Textos), solo se envolverán en un RestartGroup, porque siempre se insertan/eliminan juntos, y no hay necesidad de generar un Grupo por separado.

startMovableGroup

@Composable
fun MoveableGroupTest(list: List<Item>) {
    
    
    Column {
    
    
        list.forEach {
    
     
            Text("Item:$it")
        }
    }
}

El código anterior es un ejemplo de visualización de una lista. Dado que cada línea de la lista se genera en un bucle for, la memorización posicional no se puede implementar en función de la posición del código Si la lista de parámetros cambia, como insertar un nuevo elemento, Composer no puede reconocer el desplazamiento del grupo en este momento, y eliminará y reconstruirá, lo que afecta el rendimiento de la reorganización.

Para esos problemas que no pueden ser $keygenerados , Compose proporciona una solución Puede key {...}agregar manualmente una clave de índice única para identificar fácilmente nuevos elementos y mejorar el rendimiento de la reorganización. El código optimizado es el siguiente:

//Before Compiler
@Composable
fun MoveableGroupTest(list: List<Item>) {
    
    
    Column {
    
    
        list.forEach {
    
     
            key(izt.id) {
    
     //Unique key
                Text("Item:$it")
            }
            
        }
    }
}

Después de compilar el código anterior, se insertará en startMoveableGroup:

@Composable
fun MoveableGroupTest(list: List<Item>, $composer: Composer?, $changed: Int) {
    
    
    Column {
    
    
        list.forEach {
    
     
            key(it.id) {
    
    
                $composer.startMovableGroup(-846332013, Integer.valueOf(it));
                Text("Item:$it")
                $composer.endMovableGroup();
            }
        }
    }
}

En el parámetro de startMoveableGroup, además de GroupKey, se pasa una DataKey auxiliar. Cuando hay adición/eliminación o desplazamiento en los datos de la lista de entrada, MoveableGroup puede identificar si se trata de desplazamiento en lugar de destrucción y reconstrucción en función de DataKey, para mejorar el rendimiento de la reorganización.

startRestartGroup

RestartGroup es una unidad reorganizable. Cada función Composable que definimos en nuestro código diario puede participar en la reorganización de forma independiente, por lo que startRestartGroup/endRestartGroup se insertará en sus cuerpos de función. El código antes y después de la compilación es el siguiente:

// Before compiler (sources)
@Composable
fun RestartGroupTest(str: String) {
    
    
    Text(str)
}

// After compiler
@Composable
fun RestartGroupTest(str: String, $composer: Composer<*>, $changed: Int) {
    
    
    $composer.startRestartGroup(-846332013)
    // ...
    Text(str)
    $composer.endRestartGroup()?.updateScope {
    
     next ->
        RestartGroupTest(str, next, $changed or 0b1)
    }
}

Eche un vistazo a lo que hace startRestartGroup

//Composer.kt
fun startRestartGroup(key: Int): Composer {
    
    
    start(key, null, false, null)
    addRecomposeScope() 
    return this
}

private fun addRecomposeScope() {
    
    
    //...
    val scope = RecomposeScopeImpl(composition as CompositionImpl)
    invalidateStack.push(scope) 
    updateValue(scope)
    //...
}

Aquí es principalmente para crear RecomposeScopeImply almacenar en SlotTable.

  • Una función Composable está envuelta en RecomposeScopeImpl. Cuando necesita participar en la recombinación, Compose la encontrará en SlotTable y llamará para RecomposeScopeImpl#invalide()marcar Cuando llegue la recombinación, la función Composable se volverá a ejecutar.
  • RecomposeScopeImpl se almacena en caché invalidateStacky se devuelve Composer#endRestartGroup()en formato .
  • updateScopeEstablecer la función Composable que necesita participar en la reorganización es en realidad una llamada recursiva a la función actual. Tenga en cuenta que el valor de retorno de endRestartGroup admite valores NULL.Si RestartGroupTest no depende de ningún estado, no es necesario que participe en la reorganización y se devolverá NULL en este momento.

Se puede ver que el código generado es el mismo independientemente de que Compsoable sea necesario para participar en la reorganización. Esto reduce la complejidad de la lógica de generación de código y deja el juicio al procesamiento en tiempo de ejecución.

5. Dif y recorrido de SlotTable

Diferencias entre ranuras y tablas

En el marco declarativo, la actualización del árbol de representación se implementa a través de Diff. Por ejemplo, React implementa una actualización parcial del árbol Dom a través de Diff de VirtualDom para mejorar el rendimiento de la actualización de la interfaz de usuario.

SlotTable es el "Virtual Dom" de Compose. Cuando se ejecuta Composable por primera vez, los datos del grupo y de la ranura correspondiente se insertan en SlotTable. Cuando Composable participa en la reorganización, Diff se realiza en función del estado del código y el estado en SlotTable, y se encuentra el estado que debe actualizarse en la Composición y finalmente se aplica al árbol LayoutNode.

Este proceso Diff también se completa en el proceso startXXXGroup, y la implementación específica se concentra en Composer#start():

//Composer.kt
private fun start(key: Int, objectKey: Any?, isNode: Boolean, data: Any?) {
    
    
    //...
    
    if (pending == null) {
    
    
        val slotKey = reader.groupKey
        if (slotKey == key && objectKey == reader.groupObjectKey) {
    
    
            // 通过 key 的比较,确定 group 节点没有变化,进行数据比较
            startReaderGroup(isNode, data)
        } else {
    
    
            // group 节点发生了变化,创建 pending 进行后续处理
            pending = Pending(
                reader.extractKeys(),
                nodeIndex
            )
        }
    }
    //...
    if (pending != null) {
    
    
        // 寻找 gorup 是否在 Compositon 中存在
        val keyInfo = pending.getNext(key, objectKey)
        if (keyInfo != null) {
    
    
            // group 存在,但是位置发生了变化,需要借助 GapBuffer 进行节点位移
            val location = keyInfo.location
            reader.reposition(location)
            if (currentRelativePosition > 0) {
    
    
                // 对 Group 进行位移
                recordSlotEditingOperation {
    
     _, slots, _ ->
                    slots.moveGroup(currentRelativePosition)
                }
            }
            startReaderGroup(isNode, data)
        } else {
    
    
            //...
            val startIndex = writer.currentGroup
            when {
    
    
                isNode -> writer.startNode(Composer.Empty)
                data != null -> writer.startData(key, objectKey ?: Composer.Empty, data)
                else -> writer.startGroup(key, objectKey ?: Composer.Empty)
            }
        }
    }
    
    //...
}

El método de inicio tiene cuatro parámetros:

  • key: Generado según la ubicación del código durante la compilación$key
  • objectKey: clave auxiliar agregada usando clave{}
  • isNode: si el grupo actual es un nodo, en startXXXNode, aquí se pasará verdadero
  • data: si el grupo actual tiene datos, los proveedores se pasarán en startProviders

Hay muchas llamadas al lector y al escritor en el método de inicio, y se presentarán más adelante. Aquí, solo necesita saber que pueden rastrear la ubicación actual en SlotTable y completar la operación de lectura/escritura. El código anterior ha sido refinado y la lógica es relativamente clara:

  • Compare si el grupo es el mismo en función de la clave (registros en SlotTable y estado del código); si el grupo no ha cambiado, llame a startReaderGroup para determinar si los datos del grupo han cambiado.
  • Si el Grupo ha cambiado, significa que el Grupo en el inicio necesita ser agregado o movido. Use pendent.getNext para averiguar si la clave existe en la Composición. Si existe, significa que el Grupo necesita ser movido, y el turno se realiza a través de slot.moveGroup
  • Si es necesario agregar el grupo, de acuerdo con el tipo de grupo, llame a otro escritor #startXXX para insertar el grupo en SlotTable

La comparación de datos en el Grupo se lleva a cabo en startReaderGroup, que es relativamente simple de implementar

private fun startReaderGroup(isNode: Boolean, data: Any?) {
    
    
    //...
    if (data != null && reader.groupAux !== data) {
    
    
        recordSlotTableOperation {
    
     _, slots, _ ->
            slots.updateAux(data)
        }
    }
    //...    
}
  • reader.groupAuxObtenga los datos en la ranura actual y compárelos con los datos
  • Si es diferente llamar recordSlotTableOperationpara actualizar los datos.

Tenga en cuenta que las actualizaciones de SlotTble no son inmediatas, como se describe más adelante.

Lector de tragamonedas y escritor de tragamonedas

Como se vio anteriormente, la lectura y escritura de SlotTable en el proceso de inicio debe ser completada por el lector y escritor de Composición.

Tanto el escritor como el lector tienen métodos startGroup/endGroup correspondientes. Para el escritor, startGroup representa el cambio de datos en SlotTable, como insertar o eliminar un grupo; para el lector, startGroup representa mover el puntero de currentGroup a la última posición. currentGroupy currentSlotapuntar a la posición del grupo y la ranura a la que se accede actualmente en la tabla de ranuras.

Eche un vistazo a la implementación de insertar un grupo SlotWriter#startGroupen :

private fun startGroup(key: Int, objectKey: Any?, isNode: Boolean, aux: Any?) {
    
    

    //...
    insertGroups(1) // groups 中分配新的位置
    val current = currentGroup 
    val currentAddress = groupIndexToAddress(current)
    val hasObjectKey = objectKey !== Composer.Empty
    val hasAux = !isNode && aux !== Composer.Empty
    groups.initGroup( //填充 Group 信息
        address = currentAddress, //Group 的插入位置
        key = key, //Group 的 key
        isNode = isNode, //是否是一个 Node 
        hasDataKey = hasObjectKey, //是否有 DataKey
        hasData = hasAux, //是否包含数据
        parentAnchor = parent, //关联Parent
        dataAnchor = currentSlot //关联Slot地址
    )
    //...
    val newCurrent = current + 1
    this.parent = current //更新parent
    this.currentGroup = newCurrent 
    //...
}
  • insertGroupsSe utiliza para asignar espacio para insertar grupos en grupos.Aquí estará involucrado el concepto de Gap Buffer, que presentaremos en detalle más adelante.
  • initGroup: inicialice la información del grupo en función de los parámetros pasados ​​por startGroup. Estos parámetros se generan con diferentes tipos de startXXXGroup durante la compilación, y en realidad se escriben en SlotTable aquí.
  • Finalmente, actualice la última posición de currentGroup.

Mire nuevamente la implementación SlotReader#startGroupde :

fun startGroup() {
    
    
    //...
    parent = currentGroup
    currentEnd = currentGroup + groups.groupSize(currentGroup)
    val current = currentGroup++
    currentSlot = groups.slotAnchor(current)
    //...
}

El código es muy simple, lo principal es actualizar las posiciones de currentGroup, currentSlot, etc.

SlotTable crea un escritor/lector a través de openWriter/openReader, y necesita llamar al respectivo cierre para cerrar después de su uso. El lector puede abrir varios al mismo tiempo, pero el escritor solo puede abrir uno a la vez. Para evitar problemas de simultaneidad, el escritor y el lector no se pueden ejecutar al mismo tiempo, por lo que la operación de escritura en SlotTable debe retrasarse hasta después de la reorganización. Por lo tanto, vemos muchos métodos recordXXX en el código fuente, que registran la operación de escritura como un cambio en la lista de cambios y luego la aplican juntos una vez que se completa la combinación.

6. Efecto retardado de los cambios de SlotTable

Usar cambios para registrar la lista de cambios en Composer

//Composer.kt
internal class ComposerImpl {
    
    
    //...
    private val changes: MutableList<Change>,
    //...
    
    private fun record(change: Change) {
    
    
        changes.add(change)
    }
}

ChangeEs una función que ejecuta una lógica de cambio específica. La firma de la función y los parámetros son los siguientes:

//Composer.kt
internal typealias Change = (
    applier: Applier<*>,
    slots: SlotWriter,
    rememberManager: RememberManager
) -> Unit
  • applier: El Applier se pasa para aplicar los cambios al árbol de LayoutNode, y el Applier se presentará en detalle más adelante.
  • slots: Pase en SlotWriter para actualizar SlotTable
  • rememberManger: Presente RememberManager para registrar las devoluciones de llamada del ciclo de vida de Composición, que pueden completar servicios específicos en puntos específicos en el tiempo, como LaunchedEffect crea CoroutineScope cuando ingresa a Composición por primera vez, y DesechableEffect llama a Dispose cuando sale de Composición, todo lo cual se implementa mediante el registro de devoluciones de llamada aquí de.

Cambio de registro

Tomemos como remember{}ejemplo para ver cómo se registra el Cambio.
La clave y el valor de Remember{} se registrarán en SlotTable como el estado en Composición. Durante la reorganización, cuando cambia la clave de la memoria, el valor volverá a calcular el valor y actualizará SlotTable.

//Composables.kt
@Composable
inline fun <T> remember(
    key1: Any?,
    calculation: @DisallowComposableCalls () -> T
): T {
    
    
    return currentComposer.cache(currentComposer.changed(key1), calculation)
}

//Composer.kt
@ComposeCompilerApi
inline fun <T> Composer.cache(invalid: Boolean, block: () -> T): T {
    
    
    @Suppress("UNCHECKED_CAST")
    return rememberedValue().let {
    
    
        if (invalid || it === Composer.Empty) {
    
    
            val value = block()
            updateRememberedValue(value)
            value
        } else it
    } as T
}

Lo anterior es el código fuente de Remember

  • Composer#changedEn el método, la clave almacenada en SlotTable se leerá y comparará con key1
  • Composer#cache, recordedValue leerá el valor actual almacenado en caché en SlotTable.
  • Si se encuentra una diferencia en la comparación de claves en este momento, llame al bloque para calcular y devolver un nuevo valor, y llame a updateRememberedValue para actualizar el valor en SlotTable.

updateRememberedValue eventualmente se llamará Composer#updateValue, echemos un vistazo a la implementación específica:

//Composer.kt
internal fun updateValue(value: Any?) {
    
    
    //...
    val groupSlotIndex = reader.groupSlotIndex - 1 //更新位置Index
    
    recordSlotTableOperation(forParent = true) {
    
     _, slots, rememberManager ->
        if (value is RememberObserver) {
    
    
            rememberManager.remembering(value) 
        }
        when (val previous = slots.set(groupSlotIndex, value)) {
    
    //更新
            is RememberObserver ->
                rememberManager.forgetting(previous)
            is RecomposeScopeImpl -> {
    
    
                val composition = previous.composition
                if (composition != null) {
    
    
                    previous.composition = null
                    composition.pendingInvalidScopes = true
                }
            }
        }
    }
    //...
}

//记录更新 SlotTable 的 Change

private fun recordSlotTableOperation(forParent: Boolean = false, change: Change) {
    
    
    realizeOperationLocation(forParent)
    record(change) //记录 Change
}

El código clave aquí es recordSlotTableOperationla llamada a:

  • Agregue Change a la lista de cambios, donde el contenido de Change es actualizar el valor a la posición especificada de SlotTable a través de SlotWriter#set, y groupSlotIndexes el desplazamiento del valor calculado en las ranuras.
  • previousDevuelve el valor anterior de recordar, que se puede usar para algún procesamiento posterior. También se puede ver desde aquí que RememberObserver y RecomposeScopeImpl también son estados en Composición.
    • RememberObserver es una devolución de llamada del ciclo de vida, que está registrada por RememberManager#forgetting, y se notificará a RememberObserver cuando se elimine la anterior de Composición.
    • RecomposeScopeImpl es una unidad de recomposición, pendingInvalidScopes = truelo que significa que esta unidad de recomposición parte de Composición.

Además de recordar, otros cambios relacionados con la estructura de SlotTable, como eliminar, mover nodos, etc., también tendrán efecto con la ayuda del retraso de cambios (la operación de inserción tiene poco efecto en el lector, por lo que se aplicará de inmediato ). En el ejemplo, el cambio de la escena recordada no implica la actualización de LayoutNode, por lo que Applierel parámetro . Pero cuando la carrera hace que la estructura de SlotTable cambie, el cambio debe aplicarse al árbol de LayoutNoel, y el Aplicador se utilizará en este momento.

Aplicar cambio

Como se mencionó anteriormente, los cambios registrados esperan a que se complete la combinación antes de ejecutarse.

Recomposer#composeIntialLa composición de Composables se realiza en Composables cuando se ejecutan por primera vez.

//Composition.kt
override fun setContent(content: @Composable () -> Unit) {
    
    
    //...
    this.composable = content
    parent.composeInitial(this, composable)
}

//Recomposer.kt
internal override fun composeInitial(
    composition: ControlledComposition,
    content: @Composable () -> Unit
) {
    
    
    //...
    composing(composition, null) {
    
    
        composition.composeContent(content) //执行组合
    }
    //...

    composition.applyChanges() //应用 Changes
    //...
}

Como puede ver, justo después de la composición, se llama Composition#applyChanges()apply changes. Del mismo modo, se llama a applyChanges después de cada reorganización.

override fun applyChanges() {
    
    
      
      val manager = ...
      //...
      applier.onBeginChanges()
      // Apply all changes
      slotTable.write {
    
     slots ->
          val applier = applier
          changes.fastForEach {
    
     change ->
              change(applier, slots, manager)
          }
          hanges.clear()
       }
       applier.onEndChanges()
       //...
}

Vea el recorrido y la ejecución de cambios dentro de applyChanges. Además, Applier devolverá la llamada al inicio y al final de applyChanges.

7. UiApplier y LayoutNode

¿Cómo se refleja el cambio de la estructura de SlotTable en el árbol de LayoutNode?

Anteriormente llamamos al árbol de renderizado generado después de la ejecución de Composición componible. De hecho, Composición es una macrocognición de este árbol de representación Para ser precisos, Composición mantiene el árbol LayoutNode a través de Applier y realiza una representación específica. Los cambios en la estructura de SlotTable se reflejarán en el árbol de LayoutNode con la aplicación de la lista de cambios.

Al igual que View, LayoutNode completa la representación específica a través de una serie de métodos measure/layout/draw, como etc. Además, también proporciona métodos como insertAt/removeAt para realizar el cambio de estructura de subárbol. Estos métodos serán llamados en UiApplier:

//UiApplier.kt
internal class UiApplier(
    root: LayoutNode
) : AbstractApplier<LayoutNode>(root) {
    
    

    override fun insertTopDown(index: Int, instance: LayoutNode) {
    
    
        // Ignored
    }

    override fun insertBottomUp(index: Int, instance: LayoutNode) {
    
    
        current.insertAt(index, instance)
    }

    override fun remove(index: Int, count: Int) {
    
    
        current.removeAt(index, count)
    }

    override fun move(from: Int, to: Int, count: Int) {
    
    
        current.move(from, to, count)
    }

    override fun onClear() {
    
    
        root.removeAll()
    }

}

UiApplier se utiliza para actualizar y modificar el árbol de LayoutNode:

  • down()/up()Se utiliza para mover la posición de la corriente para completar la navegación en el árbol.
  • insertXXX/remove/moveSe utiliza para modificar la estructura del árbol. Ambos insertTopDowny insertBottomUpse utilizan para insertar nuevos nodos, pero los métodos de inserción son diferentes, uno es de abajo hacia arriba y el otro es de arriba hacia abajo. Elegir diferentes órdenes de inserción para diferentes estructuras de árbol puede ayudar a mejorar el rendimiento. Por ejemplo, UiApplier en el lado de Android se basa principalmente en insertBottomUp para insertar nuevos nodos, porque bajo la lógica de representación de Android, el cambio de los nodos secundarios afectará la nueva medición de los nodos principales.Desde entonces, la inserción hacia abajo puede evitar afectar demasiados nodos principales y mejorar el rendimiento, porque adjuntar se hace al final.

El proceso de ejecución de Composable solo depende de la interfaz abstracta de Applier. UiApplier y LayoutNode son solo las implementaciones correspondientes de la plataforma Android. En teoría, podemos crear nuestro propio motor de renderizado personalizando Applier y Node. Por ejemplo, Jake Wharton tiene un proyecto llamado Mosaic, que implementa una lógica de representación personalizada mediante la personalización de Applier y Node.

Creación de nodo raíz

En la plataforma Android, llamamos Composable Activity#setContenten :

//Wrapper.android.kt
internal fun AbstractComposeView.setContent(
    parent: CompositionContext,
    content: @Composable () -> Unit
): Composition {
    
    
    //...
    val composeView = ...
    return doSetContent(composeView, parent, content)
}

private fun doSetContent(
    owner: AndroidComposeView,
    parent: CompositionContext,
    content: @Composable () -> Unit
): Composition {
    
    
    //...
    val original = Composition(UiApplier(owner.root), parent)
    val wrapped = owner.view.getTag(R.id.wrapped_composition_tag)
        as? WrappedComposition
        ?: WrappedComposition(owner, original).also {
    
    
            owner.view.setTag(R.id.wrapped_composition_tag, it)
        }
    wrapped.setContent(content)
    return wrapped
}
  • doSetContentCree una instancia de Composición en y pase el Aplicador vinculado al Nodo raíz. El Nodo Raíz se AndroidComposeViewmantiene , y dispatchDraw desde el mundo Ver y KeyEvent, touchEventetc. se pasan al mundo Componer a través del Nodo Raíz desde aquí.
  • WrappedCompositionEs un decorador, y también se usa para establecer una conexión entre Composición y AndroidComposeView.Muchos de los CompositionLocals de Android que usamos comúnmente se construyen aquí, por ejemplo, y así LocalContextsucesivamente LocalConfiguration.

8. Ciclo de vida de SlotTable y Composable

El ciclo de vida de Composable se puede resumir en las siguientes tres etapas, ahora que conocemos SlotTable, podemos explicarlo también desde la perspectiva de SlotTable:

  • Enter: En startRestartGroup, almacene el Grupo correspondiente a Composable en SlotTable
  • Recompose: Busque Composable (por RecomposeScopeImpl) en SlotTable, vuelva a ejecutar y actualice SlotTable
  • Leave: El Grupo correspondiente al Componable se elimina de la SlotTable.

El uso de la API de efectos secundarios en Composable puede actuar como una devolución de llamada del ciclo de vida de Composable para usar

DisposableEffect(Unit) {
    
    
    //callback when entered the Composition & recomposed
    onDispose {
    
     
        //callback for leaved the Composition
    }
}

Tomemos el efecto desechable como ejemplo para ver cómo se completa la devolución de llamada del ciclo de vida según el sistema SlotTable. Eche un vistazo a la implementación de AvailableEffect, el código es el siguiente:

@Composable
@NonRestartableComposable
fun DisposableEffect(
    key1: Any?,
    effect: DisposableEffectScope.() -> DisposableEffectResult
) {
    
    
    remember(key1) {
    
     DisposableEffectImpl(effect) }
}


private class DisposableEffectImpl(
    private val effect: DisposableEffectScope.() -> DisposableEffectResult
) : RememberObserver {
    
    
    private var onDispose: DisposableEffectResult? = null

    override fun onRemembered() {
    
    
        onDispose = InternalDisposableEffectScope.effect()
    }

    override fun onForgotten() {
    
    
        onDispose?.dispose()
        onDispose = null
    }

    override fun onAbandoned() {
    
    
        // Nothing to do as [onRemembered] was not called.
    }
}

Se puede ver que la esencia de DesechableEffect es usar recordar para almacenar un DesechableEffectImpl en SlotTable, que es una implementación de RememberObserver. DesechableEffectImpl recibirá onRememberedy onForgottendevolverá la llamada cuando el grupo principal entre y salga de SlotTable.

Recuerde el applyChanges mencionado anteriormente, sucede después de que se completa la reorganización

override fun applyChanges() {
    
    
      
  val manager = ... // 创建 RememberManager
  //...
  // Apply all changes
  slotTable.write {
    
     slots ->
      //...
      changes.fastForEach {
    
     change ->
          //应用 changes, 将 ManagerObserver 注册进 RememberMananger
          change(applier, slots, manager)
      }
      //...
  }
  //...
  manager.dispatchRememberObservers() //分发回调
}

Como se mencionó anteriormente, los cambios que ocurren durante la operación de escritura de SlotTable se aplicarán de manera uniforme aquí y, por supuesto, también incluyen los cambios del registro cuando se inserta/elimina AvailableEffectImpl. dispatchRememberObserversen

La reestructuración es optimista

En el documento del sitio web oficial, hay un pasaje de este tipo en la introducción de la reorganización: La reorganización es "optimista"

Cuando se cancela la recomposición, Compose descarta el árbol de la interfaz de usuario de la recomposición. Si tiene efectos secundarios que dependen de la interfaz de usuario que se muestra, el efecto secundario se aplicará incluso si se cancela la composición. Esto puede conducir a un estado de aplicación inconsistente.

Asegúrese de que todas las funciones componibles y lambdas sean idempotentes y no tengan efectos secundarios para manejar la recomposición optimista.

https://developer.android.com/jetpack/compose/mental-model#optimistic

Muchas personas no entenderán este pasaje a primera vista, pero después de leer el código fuente, creo que pueden entender su significado. El llamado "optimista" aquí significa que la reorganización de Compose siempre se supone que es ininterrumpida. Una vez que ocurre una interrupción, las operaciones realizadas en Composable no se reflejarán realmente en SlotTable, porque sabemos por el código fuente que applyChanges ocurre después la composición finaliza con éxito.

Si se interrumpe la composición, es probable que el estado que lea en la función Composable no sea coherente con la SlotTable final. Por lo tanto, si necesitamos realizar algún procesamiento de efectos secundarios basado en el estado de Composición, debemos usar un paquete de API de efectos secundarios como DesechableEffect, porque a través del código fuente también sabemos que la devolución de llamada de DesechableEffect es ejecutada por applyChanges. tiempo, podemos asegurar que la reorganización se ha completado, y el estado obtenido es el mismo que SlotTable es consistente.

9. SlotTable 与 GapBuffer

Como se mencionó anteriormente, startXXXGroup se diferenciará del grupo en SlotTable. Si la comparación no es igual, significa que la estructura de SlotTable ha cambiado y es necesario insertar/eliminar/mover el grupo. Este proceso se implementa en función de Gap Buffer.

El concepto de Gap Buffer proviene de la estructura de datos en el editor de texto. Puede entenderse como un área de búfer deslizable y escalable en una matriz lineal. Específicamente, en SlotTable, es un área no utilizada en grupos. Esta área se puede mover en groups , para mejorar la eficiencia de la actualización cuando cambia la estructura de SlotTble, los siguientes ejemplos ilustran:

@Composable
fun Test(condition: Boolean) {
    
     
    if (condition) {
    
    
        Node1()
        Node2()
    }
    Node3()
    Node4()
}

SlotTable inicialmente solo tiene el Nodo 3 y el Nodo 4, y luego, de acuerdo con el cambio de estado, es necesario insertar el Nodo 1 y el Nodo 2. Si no hay un Gap Buffer durante este proceso, el cambio de SlotTable se muestra en la siguiente figura:

Cada vez que se inserta un nuevo nodo, los nodos existentes en SlotTable se moverán, lo cual es ineficiente. Echemos un vistazo al comportamiento después de introducir Gap Buffer:

Al insertar un nuevo nodo, el espacio en la matriz se moverá a la posición que se insertará y luego se insertará el nuevo nodo. Insertar el Nodo1, el Nodo2 e incluso sus sub-Nodos llenan el área libre del Gap, lo que no afectará el movimiento del Nodo.
Eche un vistazo a la implementación específica de Gap móvil, el código relevante es el siguiente:

//SlotTable.kt
private fun moveGroupGapTo(index: Int) {
    
    

    //...
            val groupPhysicalAddress = index * Group_Fields_Size
            val groupPhysicalGapLen = gapLen * Group_Fields_Size
            val groupPhysicalGapStart = gapStart * Group_Fields_Size
            if (index < gapStart) {
    
    
                groups.copyInto(
                    destination = groups,
                    destinationOffset = groupPhysicalAddress + groupPhysicalGapLen,
                    startIndex = groupPhysicalAddress,
                    endIndex = groupPhysicalGapStart
                )
            } 
      //...     
}
  • Indexes la posición para insertar el Grupo, es decir, el Gap debe moverse aquí
  • Group_Fields_SizeEs la longitud de la unidad Grupo en grupos, que actualmente es una constante de 5.

El significado de varias variables temporales también es muy claro:

  • groupPhysicalAddress: actualmente necesita insertar la dirección del grupo
  • groupPhysicalGapLen: la longitud de la brecha actual
  • groupPhysicalGapStart: La dirección inicial del Gap actual

En ese index < gapStatemomento , el espacio debe moverse hacia adelante a la posición de índice para prepararse para la nueva inserción. A partir de los copyIntosiguientes parámetros, podemos ver que el movimiento hacia adelante del Gap en realidad se logra moviendo el grupo hacia atrás, es decir, startIndexel Nodo en se copia en la nueva posición del Gap, como se muestra en la siguiente figura:

De esta manera, no necesitamos realmente mover el Gap, groupPyhsicalAddresssimplemente , y el nuevo Node1 se insertará aquí. Por supuesto, después de mover los grupos, la información asociada, como el ancla, también debe actualizarse en consecuencia.

Finalmente, mire el movimiento Gap al eliminar Node.El principio es similar:

Mueva la Brecha antes del Grupo que se va a eliminar y luego elimine el Nodo. ​​De esta manera, el proceso de eliminación en realidad solo mueve la posición final de la Brecha, lo cual es muy eficiente y asegura la continuidad de la Brecha.

10. Resumen

El sistema SlotTable es el eslabón más importante en todo el proceso de Compose desde la composición hasta el renderizado en pantalla, combinado con la siguiente figura, repasemos todo el proceso:

  1. El código fuente componible se insertará en el código de la plantilla startXXXGroup/endXXXGroup durante la compilación para el recorrido del árbol de SlotTable.
  2. En la primera combinación de Composable, startXXXGroup inserta el Grupo en la SlotTable y usa $key para identificar la posición del Grupo en el código
  3. Durante la reorganización, startXXXGroup atravesará y diferenciará SlotTable, y retrasará la actualización de SlotTable a través de cambios, y lo aplicará al árbol de LayoutNode al mismo tiempo.
  4. Cuando llega el marco renderizado, LayoutNode realiza medida > diseño > dibujar para la parte cambiada para completar la actualización parcial de la interfaz de usuario.

Supongo que te gusta

Origin blog.csdn.net/vitaviva/article/details/125478624
Recomendado
Clasificación