class: chapter-1, hero, center, middle #
Paging
Architecture Components 勉強会 #6 2019/04/22 荒木佑一 --- class: chapter-1, normal # はじめに 今日の課題のリポジトリ [github.com/yaraki/CheesePage](https://github.com/yaraki/CheesePage) あらかじめクローンして Android Studio で開いておくと後で楽です ## Architecture Components 勉強会 第5回: Lifecycle, ViewModel, LiveData の復習 (@yanzm)
**第6回: Room の復習、Paging (@yuichi_araki)** ← 今ココ
第7回: WorkManager (@t_egg)
第8回: Navigation (@wasabeef_jp)
--- class: chapter-2, hero, center, middle # これまでのあらすじ --- class: chapter-2, normal # Lifecycle Activity や Fragment のライフサイクルを他のオブジェクトでも扱えるようにする onCreate / onStart / onResume / onPause / onStop / onDestroy どのライフサイクル イベントで 自分が何をすればいいか知っているオブジェクトを作る --- class: chapter-2, normal # ViewModel ![ViewModel のライフサイクル](viewmodel-lifecycle.png) Activity や Fragment が再生成されてもずっと生きている UI に表示する動的な情報を読み込んで保持するのに最適 --- class: chapter-2, normal # LiveData ## バックグラウンド処理と UI の橋渡し、監視 画面に表示する情報は ViewModel が LiveData の形で保持する UI は LiveData を observe することで表示 ## Lifecycle-aware 画面が表示されてないときに必要のない処理は行わない --- class: chapter-3, hero, center, middle # Room の復習 --- class: chapter-3, normal # Room とは何か ![Jetpack](jetpack-hero.svg) データベース ライブラリ - アノテーション プロセッサーで処理を生成 - できる限りコンパイル時に検証 - SQL と Java/Kotlin を型安全に対応 - LiveData でのテーブル監視 - テスト - マイグレーション - RxJava もサポート --- class: chapter-3, normal # Room の構成 データベース ```kotlin @Database(version = 1, entities = [Cheese::class]) abstract class CheeseDatabase : RoomDatabase { abstract fun cheese(): CheeseDao } ``` Entity: テーブルのスキーマを決める。そのテーブルの 1 行分のデータ。 ```kotlin @Entity data class Cheese(@PrimaryKey val id: Long, val name: String) ``` DAO: SQL と Java/Kotlin の橋渡し ```kotlin @Dao interface CheeseDao { @Insert fun add(cheese: Cheese) } ``` --- class: chapter-3, normal # データベース ```kotlin @Database(version = 1, entities = [Cheese::class]) abstract class CheeseDatabase : RoomDatabase { abstract fun cheese(): CheeseDao } ``` そのデータベースで利用する Entity と DAO を作る ```kotlin val db = Room.databaseBuilder(context, CheeseDatabase::class.java, "cheese.db") .build() ``` アプリ全体で一つのインスタンスを参照するようにする ??? シングルトンにする/シングルトンに入れる --- class: chapter-3, normal # Entity ```kotlin @Entity data class Cheese( @PrimaryKey val id: Long, val name: String, val favorite: Boolean ) ``` @PrimaryKey は必須 Java で private なフィールドを使う場合、適切な getter/setter が必要 ??? - @ColumnInfo で SQLite 上でのフィールド名を変更 - @ForeignKey で外部キー制約 - @Index でインデックス - @Embedded で他のクラスを埋め込み --- class: chapter-3, normal # Dao 読み込み ```kotlin @Dao interface CheeseDao { @Query("SELECT * FROM Cheese") fun all(): List<Cheese> @Query("SELECT * FROM Cheese WHERE id = :id") fun find(id: Long): Cheese? @Query("SELECT * FROM Cheese") fun liveAll(): LiveData<List<Cheese>> @Query("SELECT COUNT(*) FROM Cheese") fun count(): Int } ``` ??? - SQL の SELECT 文の返り値と Java/Kotlin のメソッドの返り値を比べて、名前と型が一致していれば OK --- class: chapter-3, normal # Dao 書き込み ```kotlin @Dao interface CheeseDao { @Insert fun insert(cheese: Cheese) @Insert fun insert(cheeses: List<Cheese>) @Update fun update(cheese: Cheese) @Delete fun delete(cheese: Cheese) @Query("UPDATE Cheese SET name = :name WHERE id = :id") fun updateName(id: Long, name: String) @Query("DELETE FROM Cheese") fun deleteAll() } ``` ??? - @Insert/@Udpate の引数は Entity またはそのリストか配列 - リストを渡した場合、トランザクションで処理 - @Query は UPDATE か DELETE だけだったが、最近は INSERT もできる --- class: chapter-3, normal # リポジトリについて ![リポジトリ](repository.png) ```kotlin class CheeseRepository( private val db: CheeseDatabase, private val api: CheeseApi, private val executor: Executor ) { fun listCheeses(): LiveData<Cheese> { executor.execute { val cheeses = api.fetchCheeses() db.cheese().insert(cheeses) } return db.cheese().all() } } ``` --- class: chapter-4, hero, center, middle # Room の新機能 --- class: chapter-4, normal # ビュー .version[2.1.0] ```kotlin @DatabaseView(""" SELECT m.id, m.body, m.userId, u.id AS user_id, m.name AS user_name FROM Message AS m LEFT INNER JOIN User AS u ON m.userId = u.id """) data class MessageDetail( @Embedded val message: Message, @Embedded(prefix = "user_") val user: User ) ``` ```kotlin @Dao interface MessageDao { @Query("SELECT * FROM MessageDetail WHERE id = :id") fun detailById(id: Long): LiveData<MessageDetail> } ``` ??? - JOIN に限らず SELECT 文でかければ何でも OK - 書き込みできない以外は Entity と同じように扱える - LiveData で監視もできる --- class: chapter-4, normal # 全文検索 .version[2.1.0] ```kotlin @Entity @Fts4 data class Mail { @PrimaryKey val id: Long, val subject: String, val body: String, val timestamp: Long } ``` ```kotlin @Dao interface MailDao { @Query("SELECT * FROM Mail WHERE Mail `MATCH` :q") fun search(q: String): List<Mail> } ``` --- class: chapter-4, normal # その他の新機能 .version[2.1.0] ## RxJava のさらなるサポート @Insert や @Update も Rx で非同期化 ## Kotlin コルーチン Dao メソッドを `suspend` で定義すればコルーチンになる ## enableMultiInstanceInvalidation() プロセス間で Room のデータベースを同期 ??? --- class: chapter-5, hero, middle, center # 最近の RecyclerView の使い方 --- class: chapter-5, normal # ListAdapter と DiffUtil.ItemCallback ```kotlin class CheeseAdapter: `ListAdapter`<Cheese, CheeseViewHolder>(DIFF_CALLBACK) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CheeseViewHolder { // …… } override fun onBindViewHolder(holder: CheeseViewHolder, position: Int) { // …… } } private val DIFF_CALLBACK = object : `DiffUtil.ItemCallback`<Cheese>() { override fun areItemsTheSame(oldItem: Cheese, newItem: Cheese): Boolean { return oldItem.id == newItem.id } override fun areContentsTheSame(oldItem: Cheese, newItem: Cheese): Boolean { return oldItem == newItem } } ``` ??? - androidx.recyclerview.widget.ListAdapter --- class: chapter-5, normal # LiveData を observe して Adapter に渡す ViewModel がリストの LiveData を持つ ```kotlin class CheeseListViewModel: ViewModel() { val cheeses: LiveData<List<Cheese>> = // …… } ``` Fragment などで ```kotlin val cheeseAdapter = CheeseAdapter() recyclerView.adapter = cheeseAdapter viewModel.cheeses.observe(viewLifecycleObserver, Observer { cheeses: List<Cheese> -> cheeseAdapter.`submitList`(cheeses) }) ``` --- class: chapter-6, hero, middle, center # Paging --- class: chapter-6, normal # Paging とは ![データのとり方](paging-library-data-flow.webp) androidx.paging RecyclerView に大量の項目を表示するとき、データベースやネットワークから一定数個ごとに分割してデータを取得し、表示する - データベース - ネットワーク - 組み合わせ ```groovy dependencies { // …… implementation \ 'androidx.paging:paging-runtime-ktx:2.1.0' } ``` --- class: chapter-6, normal # トピック - データベースから取得する (Room) - 表示する - プレースホルダー - ネットワークから取得する (カスタム) - 項目の順番が固定 - 項目が並ぶ - ページが順番に並ぶ - データベースとネットワークを組み合わせる --- class: chapter-7, normal # データベースから取得する ```kotlin @Dao interface CheeseDao { // 通常: リストを LiveData でクエリ @Query("SELECT * FROM Cheese") fun all(): LiveData<List<Cheese>> // Paging に対応したクエリ @Query("SELECT * FROM Cheese") fun allPaged(): `DataSource.Factory<Int, Cheese>` } ``` テーブル変更の監視もそのまま動く --- class: chapter-7, normal # DataSource と DataSource.Factory ## DataSource データ自体ではなく、データを取得する手段を抽象化したもの データベースなどの **その時の状態** (snapshot) を表す - データに変更があると無効化 (invalidate) される ## DataSource.Factory DataSource を生成する DataSource が無効化された時に再生成するために必要 --- class: chapter-8, normal # 表示する ## DataSource.Factory から LiveData を作る ```kotlin // ViewModel や Repository で val factory: DataSource.Factory<Int, Cheese> = dao.allPaged() val cheeses: LiveData<`PagedList`<Cheese>> = factory.`toLiveData`(pageSize = 10) ``` UI はこの LiveData を observe する ## PagedList List ページごとに DataSource からデータを取得する --- class: chapter-8, normal # 表示する ## RecyclerView 用の Adapter ```kotlin class CheeseAdapter : `PagedListAdapter`<Cheese, CheeseViewHolder>(DIFF_CALLBACK) { override fun onCreateViewHolder(parent: ViewGroup) = CheeseViewHolder(parent) override fun onBindViewHolder(holder: CheeseViewHolder, position: Int) { val cheese: `Cheese?` = getItem(position) // … holder 内の View に cheese の内容を反映 } } ``` getItem の返り値が null になり得ることに注意 (null のときはプレースホルダー) ```kotlin // Fragment など // viewModel.cheeses は LiveData<PagedList<Cheese>> viewModel.cheeses.observe(viewLifecycleObserver, Observer { cheeses: PagedList<Cheese> -> cheeseAdapter.submitList(cheeses) }) ``` --- class: chapter-8, normal # ここまでのまとめ ## Room で Paging を使う方法 DAO メソッドの返り値を `DataSource.Factory<Int, Cheese>` にする `DataSource.Factory` を `toLiveData(pageSize = n)` で `LiveData<PagedList<Cheese>>` にする RecyclerView の Adapter は PagedListAdapter を継承する Adapter の getItem が null になり得ることに注意 --- class: chapter-9, normal # ネットワークから取得する カスタムの DataSource と DataSource.Factory を作る ## PositionalDataSource 「n 番目から m 個を取得」という要求に応えられる場合 (ランダムアクセス) ## ItemKeyedDataSource 「この項目の前 / 後の m 個を取得」という要求に応えられる場合 (隣接リスト) ## PageKeyedDataSource 「このページの前 / 後のページを取得」という要求に応えられる場合
--- class: chapter-9, normal # PositionalDataSource ```kotlin class CheeseDataSource: PositionalDataSource<Cheese>() { // 初回 override fun loadInitial(params: LoadInitialParams, callback: LoadInitialCallback<Cheese>) { val startPosition = params.requestedStartPosition // 最初は 0 val loadSize = params.requestedLoadSize // …… (API を呼んでデータを取得) val cheeses: List<Cheese> = // 取得したデータ val totalCount = // 全件の数 (このページの項目数ではない) callback.onResult(cheeses, startPosition, totalCount) } // 2 回目以降 override fun loadRange(params: LoadRangeParams, callback: LoadRangeCallback<Cheese>) { // …… (API を呼んでデータを取得) callback.onResult(cheeses) } } ``` ??? - これらのメソッドはバックグラウンド スレッドで呼ばれる - totalCount が分からないときは 2 引数のメソッドを呼べばいい - loadInitial は invalidate された後の初回でも呼ばれる --- class: chapter-9, normal # ItemKeyedDataSource ```kotlin class CheeseDataSource: ItemKeyedDataSource<Long, Cheese>() { override fun getKey(item: Cheese): Long = item.id override fun loadInitial(params: LoadInitialParams<Long>, callback: LoadInitialCallback<Cheese>) { val initialKey = params.requestedInitialKey ?: 1L // …… (API を呼んでデータを取得) callback.onResult(cheeses, position, totalCount) } override fun loadAfter(params: LoadParams<Long>, callback: LoadCallback<Cheese>) { val lastKnownKey = params.key // …… (API を呼んでデータを取得) callback.onResult(cheeses) } override fun loadBefore(params: LoadParams<Long>, callback: LoadCallback<Cheese>) { val firstKnownKey = params.key // …… (API を呼んでデータを取得) callback.onResult(cheeses) } } ``` --- class: chapter-9, normal # PageKeyedDataSource ## 例えばこんな API Google Calendar API ([developers.google.com/calendar/v3/pagination](https://developers.google.com/calendar/v3/pagination)) ```http GET /calendars/primary/events?maxResults=10&singleEvents=true ``` 結果の JSON ```json { // …… "nextPageToken": "`CiAKGjBpNDd2Nmp2Zml2cXRwYjBpOXA`", } ``` 次のページを取得するには ```http GET /calendars/primary/events?maxResults=10&singleEvents=true&pageToken=`CiAKGjBpNDd2Nmp2Zml2cXRwYjBpOXA` ``` --- class: chapter-9, normal # PageKeyedDataSource ```kotlin class CheeseDataSource: PageKeyedDataSource<String, Cheese>() { override fun loadInitial(params: LoadInitialParams<String>, callback: LoadInitialCallback<String, Cheese>) { // …… (API を呼んでデータを取得) val previousPageKey = result["previousPageToken"] val nextPageKey = result["nextPageToken"] callback.onResult(cheeses, position, totalCount, previousPageKey, nextPageKey) } override fun loadAfter(params: LoadParams<String>, callback: LoadCallback<String, Cheese>) { val pageKey = params.key // …… (API を呼んでデータを取得) val nextPageKey = result["nextPageToken"] callback.onResult(cheeses, nextPageKey) } override fun loadBefore(params: LoadParams<String>, callback: LoadCallback<String, Cheese>) { val pageKey = params.key // …… (API を呼んでデータを取得) val previousPageKey = result["previousPageToken"] callback.onResult(cheeses, previousPageKey) } } ``` --- class: chapter-9, normal # DataSource.Factory と LiveData ```kotlin fun listCheesesFromNetwork(): LiveData<PagedList<Cheese>> { val factory = object : DataSource.Factory<Int, Cheese>() { override fun create(): DataSource<Int, Cheese> { return CheeseDataSource() } } return factory.toLiveData(pageSize = 10) } ``` ??? - Room のときといっしょ --- class: chapter-9, normal # カスタム DataSource のまとめ 利用する API の形態によって適切な DataSource を選んで継承する - PositionalDataSource - ItemKeyedDataSource - PageKeyedDataSource DataSource (と Factory) を作れば、使い方は Room の場合と同様 --- class: chapter-10, normal # データベース + ネットワーク 1. 自分でカスタム DataSource を作って頑張る - データベースにあれば返す - なければネットワークから取得して返す - 同時にデータベースに保存 - データベースへの書き込みを検知して DataSource を invalidate する 2. 基本的に Room から返す - Room のテーブルに*もうページがないとき*だけネットワークから取得し、Room に保存する - 書き込みの反映は Room がしてくれる もうページがないとき? --- class: chapter-10, normal # PagedList.BoundaryCallback ```kotlin fun listCheeses(): LiveData<PagedList<Cheese>> { return db.cheese().allPaged().toLiveData( pageSize = 10, boundaryCallback = object : `PagedList.BoundaryCallback`<Cheese>() { private val loading = AtomicBoolean(false) override fun onItemAtEndLoaded(itemAtEnd: CheeseSummary) { if (loading.compareAndSet(false, true)) { executor.execute { val cheeses: List<Cheese> = // API から取得 db.cheese().insert(cheeses) // Room で保存 loading.set(false) } } } }) } ``` --- class: chapter-16, hero, middle, center # ハンズオン --- class: chapter-16, normal # CheesePage [github.com/yaraki/CheesePage](https://github.com/yaraki/CheesePage) チーズのタウンページ的なことです --- ![詳細](detail.png) ![リスト](list.png) --- class: chapter-16, normal # 開始状態 Run configurations で **"app-start" を選択** まだ Paging を導入していない
Room で一度に全件取ってきて RecyclerView に表示している :app モジュールは答え (まだ見ない) --- class: chapter-16, normal # リーディング 1. Activity, Fragment, ViewModel はそれぞれいくつあるか 2. どんな LiveData をどのように使っているか 3. データベースのインスタンスはどこにあるか - どうやってアプリ全体で一つのインスタンスが使われることを保証しているか 4. Entity (テーブル) はいくつあるか 5. データはいつどうやって API からロードしているか 6. 評価はどこでどうやって保存されているか 7. 評価が即座にリスト画面に反映されるのはなぜか 8. データを毎度消して API からロードしているが、評価が消えないのはなぜか --- class: chapter-16, normal # 構成 ## - MainActivity - CheeseListFragment - CheeseListViewModel : リスト読み込み - CheeseDetailFragment - CheeseDetailViewModel : 一件読み込み、書き込み ## データ - CheeseRepository - CheeseDatabase - CheeseApi --- class: chapter-16, normal # やること コード中の TODO に従ってすすめる (Android Studio 左下の TODO を開くと楽) ## TODO(1): DAO メソッドの返り値 DAO メソッドの返り値が `LiveData<List<CheeseSummary>>` では全件取ってきてしまう。ページごとに取得するには? ## TODO(2): LiveData を作るには DataSource.Factory から LiveData を作って UI 側に渡したい。 ## TODO(3): LiveData の型 型が `LiveData<List<CheeseSummary>>` ではなくなった --- class: chapter-16, normal # やること 続 ## TODO(4): RecyclerView の ListAdapter 通常の ListAdapter では Paging に対応できない。Paging に対応するには?
## TODO(5): Adapter で null に備える null が渡されるときはその項目をプレースホルダーとして表示したい。
この時点で Room + Paging が動く ## TODO(6): ネットワークから取得する API 上にはまだチーズがある。リストの最後が来たら API を呼んでデータベースにチーズを追加したい。 --- class: chapter-17, normal # まとめ Paging ライブラリを使えばデータを一定数個ごとに取得するプログラムが簡単にできる 不具合報告・要望は [issuetracker.google.com](https://issuetracker.google.com/issues/new?component=192731&template=842428) から [AOSP AndroidX Contribution Guide](https://android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/README.md) --- class: chapter-17, hero, middle, center # ありがとうございました