14
5

More than 3 years have passed since last update.

Jetpack Composeの公式サンプル JetNewsのFavoriteボタンを押した時の流れ

Last updated at Posted at 2020-04-29

現在Jetpack Composeは絶賛開発中ですが、以下で新しいバージョンを使ったサンプルアプリがあります。そのサンプルの中で、いいねボタンが存在します。これがどうやって動くのかを見ていきます。
https://github.com/android/compose-samples/tree/master/JetNews
toggle.gif
まだbeta版でもないので、きっと変わっていくとは思います。
0.1.0-dev09での情報です。

JetNewsのコードを読んでいく。

まずこの行はPostCardSimpleのようで、以下のようにプレビューされています
image.png

@Composable
fun PostCardSimple(post: Post) {
    Clickable(
        modifier = Modifier.ripple(),
        onClick = { navigateTo(Screen.Article(post.id)) }
    ) {
        Row(modifier = Modifier.padding(16.dp)) {
            PostImage(post, Modifier.padding(end = 16.dp))
            Column(modifier = Modifier.weight(1f)) {
                PostTitle(post)
                AuthorAndReadTime(post)
            }
            // **↓Bookmarkボタン**
            BookmarkButton(
                isBookmarked = isFavorite(postId = post.id),
                onBookmark = { toggleBookmark(postId = post.id) }
            )
            // **↑Bookmarkボタン**
        }
    }
}

ちなみにBookmarkボタンは以下のようになっており、IconToggleButtonをラップして、一般的に使いやすくコンポーネント化しているという感じになります。(Composeでの開発では、こういうコンポーネントに分けていくのが大切になりそう)

@Composable
fun BookmarkButton(
    isBookmarked: Boolean,
    onBookmark: (Boolean) -> Unit
) {
    IconToggleButton(checked = isBookmarked, onCheckedChange = onBookmark) {
        if (isBookmarked) {
            Icon(vectorResource(R.drawable.ic_bookmarked), Modifier.fillMaxSize())
        } else {
            Icon(vectorResource(R.drawable.ic_bookmark), Modifier.fillMaxSize())
        }
    }
}

BookmarkButtonの呼び出しに戻りましょう。
このisFavorite(postId)とtoggleBookmark(postId)の実装が気になります。

BookmarkButton(
    isBookmarked = isFavorite(postId = post.id),
    onBookmark = { toggleBookmark(postId = post.id) }
)

isFavorite()の実装は以下のようになっています。普通にlist内にpostIdがあるかをチェックするだけでみたいです。

fun isFavorite(postId: String) = JetnewsStatus.favorites.contains(postId)

またJetnewsStatusは以下のようになっており、ModelListというものを使って、お気に入りした記事のid一覧を保持しているようです。ModelListandroidx.compose.frames.ModelListパッケージに存在するクラスです。

@Model
object JetnewsStatus {
    var currentScreen: Screen = Screen.Home
    val favorites = ModelList<String>()
    val selectedTopics = ModelList<String>()
}

toggleBookmark()の実装は以下のようになっています。
普通のリストの操作という感じです。

fun toggleBookmark(postId: String) {
    with(JetnewsStatus) {
        if (favorites.contains(postId)) {
            favorites.remove(postId)
        } else {
            favorites.add(postId)
        }
    }
}

さて、toggleBookmark()やisFavorite()には@Composableなどがついていない普通の関数ですが、どのように変更を検知して、更新されるのでしょうか?

@Model
object JetnewsStatus {
    var currentScreen: Screen = Screen.Home
    val favorites = ModelList<String>()
    val selectedTopics = ModelList<String>()
}

JetnewsStatusは@Modelがついています。@Modelについてはcodelabにいかが書かれています。観測可能にしてくれるということでした。そのため、変更したときにちゃんと変更が反映されているようです。
The @Model annotation will cause the Compose compiler to rewrite the class to make it observable and thread-safe.
https://codelabs.developers.google.com/codelabs/jetpack-compose-basics/?hl=ja#4

実際内部で何が起きるのか?

簡易的に以下のように変更したときの挙動を追ってみました。

fun toggleBookmark(postId: String) {
    with(JetnewsStatus) {
        AllFavoriteModeled.isFavorite = !AllFavorite.isFavorite
    }
}

fun isFavorite(postId: String) = AllFavorite.isFavorite

@Model
object AllFavorite {
    var isFavorite: Boolean = false
}

この場合、全体で一つのisFavoriteという変数をみるため、両方変更されます。

change.gif

3行まとめ

  • @Modelがついたクラスのインスタンスのプロパティからgetするときに、FrameManagerに@ModelがついたクラスのインスタンスとRecomposeScope(getした場所)が保存される。
  • @Modelがついたクラスのインスタンスのプロパティをsetするときに、保存された情報を使って、getした場所のRecomposeScopeをinvalidate()する
  • invalidate()するとrecompose()され、最終的にプロパティからgetしたときの場所が呼び出される

image.png

image.png


コードを読んだ履歴

まずはModelListではなく、普通の@Modelがついているクラスでの変更についてちょっと見てみましょう。
インスタンス変更されたときに更新されているのかを探ってみましょう。このあたりのコードをもっと読んだほうが良さそうなどあれば教えて下さい。 :pray:

まずこう変更してみます。

fun toggleBookmark(postId: String) {
    with(JetnewsStatus) {
        AllFavoriteModeled.isFavorite = !AllFavoriteModeled.isFavorite
    }
}

fun isFavorite(postId: String) = AllFavoriteModeled.isFavorite

@Model
object AllFavoriteModeled {
    var isFavorite: Boolean = false
}

この場合、全体で一つのisFavoriteという変数をみるため、両方変更されます。

change.gif

AllFavoriteModeledはバイトコード的には AllFavoriteManual以下に作ったクラスと同じものになっています。
少し長いですが、とりあえずsetterだけ見ていきましょう。

object AllFavoriteManual : Framed {
    private var record: Record = BooleanRecord()

    init {
        _created(this)
    }

    override val firstFrameRecord: Record
        get() = record

    override fun prependFrameRecord(value: Record) {
        value.next = record
        record = value
    }

    var isFavorite: Boolean
        set(value) {
            val booleanRecord = record.writable(
                framed = this,
                frame = currentFrame()
            ) as BooleanRecord
            booleanRecord.field = value
        }
        get(): Boolean {
            val booleanRecord = record.readable(this) as BooleanRecord
            return booleanRecord.field
        }

    class BooleanRecord : Record {
        override var frameId: Int = currentFrame().id
        override var next: Record? = null

        @JvmField
        var field: Boolean = false

        override fun assign(value: Record) {
            @Suppress("UNCHECKED_CAST")
            (value as? BooleanRecord)?.let {
                this.field = it.field
            }
        }

        override fun create() = BooleanRecord()
    }
}

以下のコードで_writable()を呼び出しています。

            val booleanRecord = _writable(record, this) as BooleanRecord
            booleanRecord.field = value

_writable()の呼び出しによって、以下の関数が呼び出されます。まずreadable

fun <T : Record> T.writable(framed: Framed, frame: Frame): T {
    if (frame.readonly) throw IllegalStateException("In a readonly frame")
    val id = frame.id
    val readData = readable<T>(this, id, frame.invalid)
...

    // The first write to an framed in frame
    frame.writeObserver?.let { it(framed, false) }
...
    val newData = synchronized(framed) {
...
    }
    newData.assign(readData)
    newData.frameId = id

    frame.modified?.add(framed)

    return newData
}

以下のようなObserverが動きます。 schedule{}を呼び出しており、ここでHandler.postが行われ、変更が検知されます。

FrameManager.kt
    private val writeObserver: (write: Any, isNew: Boolean) -> Unit = { value, isNew ->
        if (!commitPending) {
            commitPending = true
            // **↓ここでhandlerにpost
            schedule {
                commitPending = false
                // **↓ここでnextFrameが作られる
                nextFrame()
            }
        }
        recordWrite(value, isNew)
    }
14
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
5