前の記事では、Modifier.composed
が Compose ランタイムのパフォーマンスに影響を与えてしまって、そのきっかけで Modifier.Node
APIの開発に繋がった話を記載してます。
シリーズのコンテンツ |
---|
Part 1: なぜ Modifier.Node が必要になった |
Part 2: Modifier.Node API の紹介(この記事) |
Part 3: Modifier.Node に移行する基本手順 |
Part 4: @Composable 関数の実装を Modifier.Node に書き換える
|
Part 5: Modifier.Node を委任する、Delegate パターン |
Part 6: Modifier.Node 移行しようと思ってまだ残ってる課題 |
この記事では実際の Modifier.Node 仕組みそして、この新しい仕組みの内部的な実装について少し書いてみました。
Modifier.Node仕組み
前の記事でも書いてますが、新しい Modifier.Node 仕組みで2つのチェーンが存在してます。
- Node チェーン
- Element チェーン
まずは、それぞれの Modifier 関数がそれぞれの ModifierNodeElement オブジェクトを発行して、Element チェーンが生成されてます。
この Element チェーンを UI コンポーネントに設定されてる時、それぞれの ModifierNodeElement が対応してるそれぞれの Modifier.Node オブジェクトを発行して Node チェーンが作られてます。
そして、生成された Node チェーンが UI コンポーネントに Modifier のそれぞれのプロパティを提供してます。
この2つのチェーンについて2つの違いを知ることは大事かなと思います:
- Element チェーンはUIコンポーネントに触接的に影響を与えないです。それは Node チェーンの役割です。
- 毎回 Element チェーンの新しいインスタンスが生成されますが、Node チェーンの場合は初回生成されたインスタンスが UI コンポーネントにずっと持ち続けられます。
では、各チェーンのもっと具体的に見ましょう。
Node チェーン と Modifier.Node
先ほども記載しましたが、Node チェーンが UI コンポーネントにそれぞれの特定なプロパティを提供します。
そして、UI コンポーネント毎に1つのインスタンスを持ち続けられるので、このチェーンの各 Modifier.Node も UI コンポーネントと同じ lifespan (寿命) を持ちます。この長生きできてるため、Modifier.Node が state を保持する役割も持ってます。
interface Modifier {
interface Element: Modifier{ /* .. */ }
..
abstract class Node {
var isAttached: Boolean = false
private set
open fun onAttach() {}
open fun onDetach() {}
val coroutineScope: CoroutineScope
get() = ..
}
}
-
onAttach()
: このコールバックは、Modifier.Nodeが初めてUIコンポーネントに定義されたときに呼び出されます。
-
onDetach()
: このコールバックは、Modifier.Nodeが初めてUIコンポーネントから外されたときに呼び出されます。
-
isAttached
: 後ほど紹介しますが、幾つかの API をonAttach
とonDetach
の間でしか利用することはできない。このライフサイクルに縛られてる API を安全に呼び出すために、このフラグを利用します。 -
coroutineScope
: この CouroutineScope を使って Modifier.Node の中で coroutine を実行させることができます。
Modifier.Node の coroutineScope を使って onAttach と onDetach の間で coroutine を実行することができます。
onDetach が呼び出された後、実行中の coroutine が中止されます。
class FocusableNode(..): Modifier.Node() {
init {
- coroutineScope.launch {..} // エラーが発生!
}
override fun onAttach() {
+ couroutineScope.launch {..} // 問題なし!
}
fun update(..) {
+ couroutineScope.launch {..} // 問題なし!
}
}
Element チェーン と ModifierNodeElement
先ほど記載しましたが、Element チェーンは「Modifierが変更された」を判断するために比較される役割を持ってます。
Element チェーンのアイテム、ModifierNodeElement
が単なる Modifier.Node に対応してる Modifier.Element です。
なので、Modifier.Element と同様で、各フレームで新しいインスタンスが Modifier.then に渡され、UI コンポーネントと同じ寿命を持たず、state を保持することはできないです。
abstract class ModifierNodeELement<N: Modifier.Node>(): Modifier.Element {
abstract fun create(): N
abstract fun update(node: N)
abstract override fun equals(other: Any?): Boolean
..
}
-
create()
: このメソッドは、Modifier が Modifier チェーンに初めて連結されるときに呼び出されます。
ここで返されるModifier.Node
の新しいインスタンスがUIコンポーネントの Node チェーンについかされます。
-
update()
: 現在の Element チェーンが前のチェーンと比較されて「変更が行われた」が判断されたらこのメソッドが呼び出されます。
この関数で渡される Modifier.Node のパラメータはレイアウトの Node チェーンにある既存のインスタンスです。このインスタンスの state を更新し、UIコンポーネントを invalidate する実装を行います。
-
equals()
・hashCode()
: 「UI コンポーネントの Modifier が変わりました」という判断は Element チェーンの各 ModifierNodeElement のこのメソッドで行います。
上記の update メソッドが呼び出されるかどうかの判断もこのメソッドで行われます。
class SampleModifierNodeElement( val color: Color, val size: Dp ): ModifierNodeElement<..> { .. override fun equals(other: Any?): Boolean { val otherModifier = other as? BackgroundElement ?: return false return other.color == color && other.size == size } override fun hashCode(): Int { var result = color.hashCode() result = result * 31 + size.hashCode() return result } }
-
equals
とhashCode
を実装する代わりにクラスを data class に定義しても大丈夫ですが、ある change list のコメントでequals
メソッドを実装するメリットについて話してます。
-
コンストラクタパラメータがない ModifierNodeElement の場合はオブジェクトのタイプを比較すれば大丈夫です。
class NoParameterModifierNodeElement(): ModifierNodeElement { override fun equals(other: Any?): Boolean { return other is NoParameterModifierNodeElement } override fun hashCode(): Int = System.hashCode(this) }
-
Modifier.Node の内部的な実装
NodeChain は LayoutNode に適用される Modifier.Node チェーンを作成や変更するクラスです。各 LayoutNode はこの NodeChain の1つインスタンスを持ってます。
// ../androidx/compose/ui/node/LayoutNode.kt
internal class LayoutNode {
..
internal val nodes = NodeChain(this)
}
// ../androidx/compose/ui/node/NodeChain.kt
internal class NodeChain(val layoutNode: LayoutNode) {
// 現在提供されてる Modifier に属する ModifierNodeElements のコレクション
private var current: MutableVector<Modifier.Element>? = null
..
}
LayoutNode で Modifier が設定されたとき NodeChain.updateFrom()
が呼び出されます。このメソッドでNode チェーンが作成または更新が行われます。
// ../androidx/compose/ui/node/LayoutNode.kt
internal class LayoutNode {
..
/**
* The [Modifier] currently applied to this node.
*/
override var modifier: Modifier = Modifier
set(value) {
nodes.updateFrom(value)
..
}
}
updateFrom に渡される Modifier パラメータが ModifierNodeElement のコレクション (Element チェーン)に拡大され、現在と新しいコレクションのサイズが比較されます。比較された結果は、下記の4つのケースの可能性があります:
- 両チェーンのサイズが同じ
- 現在のチェーンは空
- 新しいチェーンは空
- どちらのチェーンもサイズが異なってる
fun updateFrom(modifier: Modifier) {
var before = current // 現在のコレクション
var beforeSize = before?.size ?: 0
var after = modifier.fillVector(..) // 新しいコレクションに拡大
..
if (after.size == beforeSize) { /* .. */ }
else if(beforeSize == 0) { /* .. */ }
else if(after.size == 0) { /* .. */ }
else { /* .. */ }
}
両チェーンのサイズが同じ
現在と新しい Element チェーンのサイズが同じ場合は、2つのチェーンは構造的に等しいかどうかをアイテム毎に順次比較されます。
// ../androidx/compose/ui/node/NodeChain.kt
if (after.size == before.size) {
var i = 0
while (i < before.size) {
val prev = before[i] // 現在のコレクションの ModifierNodeElement
val next = after[i] // 新しいコレクションの ModifierNodeElement
when (actionForModifiers(prev, next)) { /* .. */ }
}
}
..
// 2つのコレクションを ModifierNodeElement ごとに順次比較します
internal fun actionForModifiers(prev: ModifierNodeElement, next: ModifierNodeElement): Int {
return if (prev == next)
// 両アイテムのタイプとプロパティが同じ。つまり、update が必要なくそのまま
// 使用されます
ActionReuse
else if (areObjectsOfSameType(prev, next))
// 両アイテムのタイプが同じだけど、プロパティが異なってる。
// つまり、update が必要です
ActionUpdate
else
// アイテムのタイプが異なってるので update より差し替えが必要です
ActionReplace
}
チェーン全体を渡って、差分が検出されなかった場合、両チェーンが構造的に同じだということ判断され、updateFrom メソッドがそれ以上何もせずに終了されます。
if (after.size == before.size) {
while (i < before.size) {
when (actionForModifiers(prev, next)) {
is ActionReuse -> {
// no need to do anything, this is "the same" modifier
}
..
}
}
}
しかし差分が検出された場合、検出された index から始まって、最小限の追加や削除が行われるように比較処理が実行されます。
if (after.size == before.size) {
while (i < before.size) {
when (actionForModifiers(prev, next)) {
is ActionReplace -> {
// break the loop at the index where difference was encountered for the first time
break
}
..
}
}
// リスト末尾が来る前に差分が検出されました
if (i < before.size) {
structuralUpdate(offset = i, before, after)
}
}
/**
* This method utilizes a modified Myers Diff Algorithm which will diff the two modifier chains
* and execute a minimal number of insertions/deletions. ..
*/
private fun structuralUpdate(
offset: Int, // the index of both collections from where diff is to be calculated
..
) {
val differ = getDiffer(..) // modified version of DiffUtils
executeDiff(differ, offset, ..)
/* invalidate */
}
ここで使用されてる差分検出アルゴリズムは、RecyclerView の場合よく使う DiffUtils の改変版です。
現在のチェーンが空
現在の ModifierNodeElements コレクションが空ということは、レイアウトに初めて Modifier が適用されてることを示してます。
この場合は、ModifierNodeElement.create() を呼び出し、返されてる Modifier.Node のインスタンスをノードのチェーンに追加されます。
// ../androidx/compose/ui/node/NodeChain.kt
fun updateFrom(modifier: Modifier) {
..
else if (beforeSize == 0) {
var node = /* linked list の頭部 */
while (i < after.size) {
node = createAndInsertNodeAsChild(
element = next,
parent = node
)
i++
}
}
}
fun createAndInsertNodeAsChild(
element: ModifierNodeElement,
parent: Modifier.Node
): Modifier.Node {
val node = element.create() // ModifierNodeElement.create() が呼び出される
..
// linked list で親の後に子を追加する
val theChild = parent.child
if (theChild != null) {
theChild.parent = node
node.child = theChild
}
parent.child = node
node.parent = parent
return node
}
新しいチェーンが空
新しいコレクションが空の場合ということは、レイアウトから Modifier が削除されてること表してます。
この場合は、ノードのチェーンから外されます。
// ../androidx/compose/ui/node/NodeChain.kt
fun updateFrom() {
..
else if (after.size == 0) {
var node = /* linked list の頭部 */
while (node != null && i < before.size) {
node = detachAndRemoveNode(node)
..
}
}
}
fun detachAndRemoveNode(node: Modifier.Node) {
..
node.markAsDetached()
..
val child = node.child
val parent = node.parent
if (child != null) {
child.parent = parent
node.child = null
}
if (parent != null) {
parent.child = child
node.parent = null
}
return parent!!
}
どちらのチェーンもサイズが異なってる
現在と新しいコレクションのサイズが異なる場合、構造的に異なってること見なされます。
この場合は、チェーンの最初から(index = 0)から始まって、上記の「両チェーンのサイズが同じ」のケースで差分が検出された時と同様で最小限の追加や削除が行われるように比較処理が実行されます。
fun updateFrom() {
if (after.size == before.size) { /* .. */ }
else if (before.size == 0) { /* .. */ }
else if (after.size == 0) { /* .. */ }
else {
// diff is run from the index 0 itself
structuralUpdate(offset = 0, ..)
}
}
次の記事では、既存の Modifier.composed を Modifier.Node に移行するために 実際の change list を参照しながら基本手順を紹介してます。気になったらぜひ良ければ読んでみてください。