Android Jetpack Compose 中的分頁與緩存展示
在幾乎任何類型的移動項目中,移動開發人員在某個時候都會處理分頁數據。如果數據列表太大,無法一次從服務器檢索完畢,這就是必需的。因此,我們的后端同事為我們提供了一個端點,返回分頁數據列表,并期望我們知道如何在客戶端處理它。
在本文中,我們將重點介紹如何使用 Android 在 2023 年 6 月推薦的最新方法來獲取、緩存和顯示分頁數據。我們將經過以下步驟:
- 從公共 GraphQL API 中按頁獲取 Pokemon 數據列表
- 使用 Room 將獲取的數據緩存到本地數據庫
- 使用最新的 Paging 庫組件來處理分頁
- 使用 LazyColumn 智能地顯示頁面項(只渲染可見內容)
對于示例項目,我將在文章末尾分享 GitHub 存儲庫鏈接,我們將使用 Hilt 作為我們的依賴注入庫,并使用干凈架構(表示層 → 領域層 ← 數據層)。因此,我將從數據層開始解釋事物,然后轉向領域層,最后結束在表示層。
數據層
這一層是關于分頁和緩存的大部分內容。因此,如果您能夠通過這一部分,您將基本完成了它。
遠程數據源
作為遠程數據源,我們將使用一個公共的 GraphQL Pokemon API。與我們用于與 REST API 交互的 Retrofit 不同,我們使用 Apollo 的 Kotlin 客戶端來處理 GraphQL API。它允許我們執行 GraphQL 查詢,并根據請求和響應自動生成 Kotlin 模型。
首先,我們需要將以下行添加到我們的模塊級別的 build.gradle 文件中:
plugins {// ...id "com.apollographql.apollo3" version "$apollo_version"
}apollo {service("pokemon") {packageName.set("dev.thunderbolt.pokemonpager.data")}
}dependencies {// ...implementation "com.apollographql.apollo3:apollo-runtime:$apollo_version"
}
在這里,我們在 apollo 塊中設置了 Apollo 庫的配置。它提供了許多設置,您可以通過其文檔查看所有設置。目前,我們只需要將包名設置為 dev.thunderbolt.pokemonpager.data
,這樣生成的 Kotlin 文件將位于正確的包中,也就是數據層。
然后,我們需要下載服務器的模式,以便庫能夠生成模型,并且我們可以使用自動完成來編寫查詢。為了下載模式,我們使用 Apollo 提供的以下命令:
./gradlew :app:downloadApolloSchema --endpoint='https://graphql-pokeapi.graphcdn.app/graphql' --schema=app/src/main/graphql/schema.graphqls
這將在 app/src/main/graphql/schema.graphqls
目錄中下載服務器的模式。
現在,是時候在一個名為 pokemon.graphql
的文件中編寫我們的查詢,該文件與模式文件位于同一文件夾中。
query PokemonList($offset: Int!$limit: Int!
) {pokemons(offset: $offset,limit: $limit) {nextOffsetresults {idnameimage}}
}
當我們構建項目時,Apollo Kotlin 將通過自動運行名為 generateApolloSources
的 Gradle 任務為此查詢生成模型。
回到 Kotlin 的世界,我們將定義我們的 PokemonApi
類,以封裝與 GraphQL 的所有交互,如下所示:
class PokemonApi {private val BASE_URL = "https://graphql-pokeapi.graphcdn.app/graphql"private val apolloClient = ApolloClient.Builder().serverUrl(BASE_URL).addHttpInterceptor(LoggingInterceptor()).build()suspend fun getPokemonList(offset: Int, limit: Int): PokemonListQuery.Pokemons? {val response = apolloClient.query(PokemonListQuery(offset = offset,limit = limit,)).execute()// IF RESPONSE HAS ERRORS OR DATA IS NULL, THROW EXCEPTIONif (response.hasErrors() || response.data == null) {throw ApolloException(response.errors.toString())}return response.data!!.pokemons}
}
在這里,我們使用所需的配置初始化 Apollo Client 實例,并實現了我們執行在 pokemon.graphql
文件中編寫的生成的 Kotlin 版本查詢的函數。該函數基本上會獲取 offset
和 limit
參數,執行查詢,如果一切順利,就會返回查詢的響應,這也是由 Apollo 自動生成的。
本地數據源/存儲
為了在本地存儲關系型數據并創建一個離線優先的應用程序,我們將依賴于 Room,這是一個在 SQLite 之上編寫的 Android 持久性庫。
首先,我們需要將 Room 依賴項添加到我們的 build.gradle
文件中:
dependencies {// ...implementation "androidx.room:room-ktx:$room_version"kapt "androidx.room:room-compiler:$room_version"implementation "androidx.room:room-paging:$room_version"
}
然后,我們將定義兩個實體類,一個用于在我們的數據庫中存儲 Pokemon 數據,另一個用于跟蹤要獲取的下一頁的頁數。
@Entity("pokemon")
data class PokemonEntity(@PrimaryKey val id: Int,val name: String,val imageUrl: String,
)@Entity("remote_key")
data class RemoteKeyEntity(@PrimaryKey val id: String,val nextOffset: Int,
)
在這方面,我們還需要兩個 DAO(數據訪問對象)類來定義其中的所有數據庫交互。
@Dao
interface PokemonDao {@Insert(onConflict = OnConflictStrategy.REPLACE)suspend fun insertAll(items: List<PokemonEntity>)@Query("SELECT * FROM pokemon")fun pagingSource(): PagingSource<Int, PokemonEntity>@Query("DELETE FROM pokemon")suspend fun clearAll()
}@Dao
interface RemoteKeyDao {@Insert(onConflict = OnConflictStrategy.REPLACE)suspend fun insert(item: RemoteKeyEntity)@Query("SELECT * FROM remote_key WHERE id = :id")suspend fun getById(id: String): RemoteKeyEntity?@Query("DELETE FROM remote_key WHERE id = :id")suspend fun deleteById(id: String)
}
在這里,我們需要特別關注的關鍵函數是 pagingSource()
。Room 可以返回數據列表作為 PagingSource
,以便我們稍后將創建的 Pager 對象將其用作生成 PagingData
流的單一源。
最后,我們需要一個 RoomDatabase
類,在本地數據庫中為這些實體創建表,并提供 DAO 以與這些表進行交互。
@Database(entities = [PokemonEntity::class, RemoteKeyEntity::class],version = 1,
)
abstract class PokemonDatabase : RoomDatabase() {abstract val pokemonDao: PokemonDaoabstract val remoteKeyDao: RemoteKeyDao
}
這兩個類,即 PokemonDatabase
和之前定義的 PokemonApi
類,都由我們數據層的 Hilt 模塊實例化并提供為單例對象。
@Module
@InstallIn(SingletonComponent::class)
class DataModule {@Provides@Singletonfun providePokemonDatabase(@ApplicationContext context: Context): PokemonDatabase {return Room.databaseBuilder(context,PokemonDatabase::class.java,"pokemon.db",).fallbackToDestructiveMigration().build()}@Provides@Singletonfun providePokemonApi(): PokemonApi {return PokemonApi()}// ...
}
遠程中介器(Remote Mediator)
現在,我們要實現我們的遠程中介器類(RemoteMediator),它將負責在需要時從遠程 API 加載分頁數據到本地數據庫中。需要注意的是,遠程中介器并不直接向用戶界面提供數據。如果分頁數據用盡,分頁庫會觸發遠程中介器的 load(…) 方法,以從遠程獲取并存儲更多的數據到本地。因此,我們的本地數據庫始終可以保持作為唯一的真實數據源。
在 load(…)
函數中,我們首先需要檢查我們正在處理哪種類型的加載。如果 LoadType 是:
- REFRESH,這意味著我們要么處于初始加載狀態,要么數據已經無效,我們需要從頭開始獲取數據。因此,如果是這種情況,我們將偏移值設置為 “0”,以獲取第一頁的數據。
- PREPEND,我們需要獲取當前頁面之前的頁面數據。在這個示例的范圍內,不需要在向上滾動時獲取任何內容。因此,我們只需返回
MediatorResult.Success(endOfPaginationReached = true)
,以指示不應再進行數據加載。 - APPEND,我們需要獲取當前頁面之后的頁面數據。在這種情況下,我們會獲取已經由前一個數據加載存儲在本地數據庫中的遠程鍵(remote key)對象。如果沒有或者其
nextOffset
值為 “0”,則表示沒有更多數據可加載和追加。順便說一下,這就是該 API 的工作方式。你的 API 可能以不同方式指示數據的結束,因此需要相應地編寫你的 APPEND 邏輯。
在確定了正確的偏移值之后,現在是時候使用此偏移值和配置中提供的 pageSize
進行 API 調用了。我們將在下一步創建 Pager 對象時設置頁面大小。
如果 API 調用成功返回新的頁面數據,我們將使用相應的 DAO 函數將項目和下一個偏移值存儲在我們的數據庫中。在這里,我們需要在事務塊中執行所有數據庫交互,以便如果任何交互失敗,數據庫不會發生任何更改。
最后,如果在數據庫調用之后一切順利,我們將返回 MediatorResult.Success
,通過將最新加載返回的項目數與我們將在配置中定義的頁面大小進行比較,來檢查是否已達到分頁的末尾。
Pager 對象
現在,我們要再次回到我們數據層的 Hilt 模塊,并創建我們的 Pager 對象。這個對象將把我們到目前為止所定義的所有內容整合在一起,作為 PagingData
流的構造函數工作。
@Module
@InstallIn(SingletonComponent::class)
class DataModule {// ...@Provides@Singletonfun providePokemonPager(pokemonDatabase: PokemonDatabase,pokemonApi: PokemonApi,): Pager<Int, PokemonEntity> {return Pager(config = PagingConfig(pageSize = 20),remoteMediator = PokemonRemoteMediator(pokemonDatabase = pokemonDatabase,pokemonApi = pokemonApi,),pagingSourceFactory = {pokemonDatabase.pokemonDao.pagingSource()},)}
}
在這里,我們向 Pager 的構造函數提供了三個要素。首先,我們設置了所需的頁面大小的 PagingConfig
,正如我之前提到的。其次,我們提供了我們的遠程中介器實例。第三,我們將由 Room 提供的分頁源設置為 Pager 的唯一數據源。
倉庫(Repository)
由于我們在遠程中介器中完成了大部分工作,所以我們的倉庫實現將相當簡單。
class PokemonRepositoryImpl @Inject constructor(private val pokemonPager: Pager<Int, PokemonEntity>
) : PokemonRepository {override fun getPokemonList(): Flow<PagingData<Pokemon>> {return pokemonPager.flow.map { pagingData ->pagingData.map { it.toPokemon() }}}
}
使用我們的 Pager 實例,我們只需將其 PagingData
流返回給使用者。但在這之前,我們還需要將 PokemonEntity
映射到領域的 Pokemon 模型。這是因為根據 Clean Architecture 的基礎,我們的領域層不了解數據或表示層,因此不應將數據模型傳遞到領域層。
領域層(Domain Layer)
在這個純 Kotlin 層中,實際上沒有太多事情發生。在這里,我們有我們的 Pokemon 模型、倉庫接口以及與該倉庫交互的簡單用例類。
// REPOSITORY INTERFACE
interface PokemonRepository {fun getPokemonList(): Flow<PagingData<Pokemon>>
}// USE CASE
class GetPokemonList @Inject constructor(private val pokemonRepository: PokemonRepository
) {operator fun invoke(): Flow<PagingData<Pokemon>> {return pokemonRepository.getPokemonList().flowOn(Dispatchers.IO)}
}// MODEL
data class Pokemon(val id: Int,val name: String,val imageUrl: String,
)
在這里,你可能會有一個問題,即如何在純 Kotlin 層中使用PagingData
,而在這里我們沒有依賴于任何 Android 組件。實際上很簡單:分頁庫為非 Android 模塊提供了特定的依賴項,因此我們可以訪問所有簡單的 Paging 組件,如 PagingSource、PagingData、Pager
,甚至是 RemoteMediator
。
dependencies {// ...implementation "androidx.paging:paging-common:$paging_version"
}
表示層(Presentation Layer)
在快速涵蓋了領域層之后,讓我們直接跳入表示層,其中的關鍵內容都在這里。但首先,我們需要將以下 Paging 依賴項添加到我們的 build.gradle 文件中:
dependencies {// ...implementation "androidx.paging:paging-runtime-ktx:$paging_version"implementation "androidx.paging:paging-compose:$paging_version"
}
除了 runtime-ktx 依賴項之外,這里還需要 compose 依賴項,因為它在我們的分頁數據流和 UI 之間提供了一些中間件。
ViewModel
這又是本文中的一個簡單類,在這里我們只需獲取由用例提供的流(該流已由倉庫提供),并將其存儲在一個值中。
@HiltViewModel
class PokemonListViewModel @Inject constructor(private val getPokemonList: GetPokemonList
) : ViewModel() {val pokemonPagingDataFlow: Flow<PagingData<Pokemon>> = getPokemonList().cachedIn(viewModelScope)
}
我們通過調用cachedIn(viewModelScope)
來存儲該流,以便在 ViewModel 的生命周期內保持其活動狀態。此外,它還可以在屏幕旋轉等配置更改時保持存活,這樣你就可以獲取相同的現有數據,而不必從頭開始獲取。
這種方法還可以保持我們的冷流狀態不變,并且不會像 stateIn(…)
方法一樣將其轉換為熱流(StateFlow)。這意味著如果流未被收集,就不會執行不必要的代碼。
屏幕(UI)
現在,我們來到了分頁的最后一步,在這一步中,我們將在LazyColumn
中顯示我們的分頁項。在 Jetpack Compose 中,不再有 RecyclerView
或適配器。所有這些都在下面進行處理,而且我們大量的項目仍然可以智能布局,而不會引起任何性能問題。
@Composable
fun PokemonListScreen(snackbarHostState: SnackbarHostState
) {val viewModel = hiltViewModel<PokemonListViewModel>()val pokemonPagingItems = viewModel.pokemonPagingDataFlow.collectAsLazyPagingItems()if (pokemonPagingItems.loadState.refresh is LoadState.Error) {LaunchedEffect(key1 = snackbarHostState) {snackbarHostState.showSnackbar((pokemonPagingItems.loadState.refresh as LoadState.Error).error.message ?: "")}}Box(modifier = Modifier.fillMaxSize()) {if (pokemonPagingItems.loadState.refresh is LoadState.Loading) {CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))} else {LazyColumn(modifier = Modifier.fillMaxSize(),horizontalAlignment = Alignment.CenterHorizontally,) {items(count = pokemonPagingItems.itemCount,key = pokemonPagingItems.itemKey { it.id },) { index ->val pokemon = pokemonPagingItems[index]if (pokemon != null) {PokemonItem(pokemon,modifier = Modifier.fillMaxWidth(),)}}item {if (pokemonPagingItems.loadState.append is LoadState.Loading) {CircularProgressIndicator(modifier = Modifier.padding(16.dp))}}}}}
}
在我們的組合屏幕中,首先要做的是創建我們的 ViewModel 實例,并使用輔助函數 collectAsLazyPagingItems()
收集其中存儲的分頁數據流。這將冷流轉換為 LazyPagingItems
實例。通過這個實例,我們可以訪問已加載的項目,以及不同的加載狀態,以相應地改變 UI。除此之外,我們甚至可以使用此實例觸發數據刷新或重新嘗試以前失敗的加載。
在 Box 布局中,如果 LazyPagingItems
的“refresh”加載狀態為 Loading,則我們知道我們正在初始加載,并且尚無項目可顯示。因此,我們顯示一個進度指示器。否則,我們會顯示一個 LazyColumn
,以及使用我們的 LazyPagingItems
實例設置的項目列表的數量和鍵參數。在每個項目中,我們只需使用給定的索引訪問相應的 Pokemon 對象,并呈現 PokemonItem
組合,出于簡單起見,這里不給出實現細節。
我們還有一種特殊情況,即需要在這些項目下方顯示加載指示器。這發生在我們正在獲取更多數據的過程中,可以通過 LazyPagingItems
的“append”加載狀態來檢測到。因此,如果是這種情況,我們將一個進度指示器追加到列表的末尾。
最后,請不要認為我們在開始部分忽略了LaunchedEffect
部分。LaunchedEffect
組合用于在組合內部安全地調用掛起函數。在 Jetpack Compose 中,我們需要協程范圍來顯示 Snackbar,因為 SnackbarHostState.showSnackbar(…)
是一個掛起函數。在這里,我們顯示一個 Snackbar 消息,以防刷新錯誤,基本上對應于我們的情況下的“初始加載”錯誤。然而,正如我之前提到的,我們在這里構建了一個離線優先的應用,因此如果我們在 Room 中已經緩存了數據,用戶將看到該數據,以及錯誤消息。
希望您在 Android Jetpack Compose 中的分頁和緩存的這段具有挑戰性的旅程中能夠與我同行。我盡力堅持最新和推薦的操作方式。請隨時指出錯誤或可以做得更好的地方。整個項目已經作為 GitHub 存儲庫共享,以便您可以下載并進行測試。
GitHub
https://github.com/thunderbolt-codes/Pokemon-Pager