現在Jetpack Composeは絶賛開発中ですが、以下で新しいバージョンを使ったサンプルアプリがあります。そのサンプルの中で、いいねボタンが存在します。これがどうやって動くのかを見ていきます。
https://github.com/android/compose-samples/tree/master/JetNews
まだbeta版でもないので、きっと変わっていくとは思います。
0.1.0-dev09での情報です。
JetNewsのコードを読んでいく。
まずこの行はPostCardSimpleのようで、以下のようにプレビューされています
@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一覧を保持しているようです。ModelList
はandroidx.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という変数をみるため、両方変更されます。
3行まとめ
-
@Model
がついたクラスのインスタンスのプロパティからgetするときに、FrameManagerに@Model
がついたクラスのインスタンスとRecomposeScope(getした場所)が保存される。 -
@Model
がついたクラスのインスタンスのプロパティをsetするときに、保存された情報を使って、getした場所のRecomposeScopeをinvalidate()する - invalidate()するとrecompose()され、最終的にプロパティからgetしたときの場所が呼び出される
コードを読んだ履歴
まずはModelList
ではなく、普通の@Model
がついているクラスでの変更についてちょっと見てみましょう。
インスタンス変更されたときに更新されているのかを探ってみましょう。このあたりのコードをもっと読んだほうが良さそうなどあれば教えて下さい。
まずこう変更してみます。
fun toggleBookmark(postId: String) {
with(JetnewsStatus) {
AllFavoriteModeled.isFavorite = !AllFavoriteModeled.isFavorite
}
}
fun isFavorite(postId: String) = AllFavoriteModeled.isFavorite
@Model
object AllFavoriteModeled {
var isFavorite: Boolean = false
}
この場合、全体で一つのisFavoriteという変数をみるため、両方変更されます。
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
が行われ、変更が検知されます。
private val writeObserver: (write: Any, isNew: Boolean) -> Unit = { value, isNew ->
if (!commitPending) {
commitPending = true
// **↓ここでhandlerにpost
schedule {
commitPending = false
// **↓ここでnextFrameが作られる
nextFrame()
}
}
recordWrite(value, isNew)
}