Android Jetpack Compose 中的分页与缓存展示

这篇具有很好参考价值的文章主要介绍了Android Jetpack Compose 中的分页与缓存展示。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

Android Jetpack Compose 中的分页与缓存展示,Android架构,android jetpack,缓存,android

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
    ) {
        nextOffset
        results {
            id
            name
            image
        }
    }
}

当我们构建项目时,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 EXCEPTION
        if (response.hasErrors() || response.data == null) {
            throw ApolloException(response.errors.toString())
        }
        return response.data!!.pokemons
    }
}

在这里,我们使用所需的配置初始化 Apollo Client 实例,并实现了我们执行在 pokemon.graphql 文件中编写的生成的 Kotlin 版本查询的函数。该函数基本上会获取 offsetlimit 参数,执行查询,如果一切顺利,就会返回查询的响应,这也是由 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: PokemonDao
    abstract val remoteKeyDao: RemoteKeyDao
}

这两个类,即 PokemonDatabase 和之前定义的 PokemonApi 类,都由我们数据层的 Hilt 模块实例化并提供为单例对象。

@Module
@InstallIn(SingletonComponent::class)
class DataModule {

    @Provides
    @Singleton
    fun providePokemonDatabase(@ApplicationContext context: Context): PokemonDatabase {
        return Room.databaseBuilder(
            context,
            PokemonDatabase::class.java,
            "pokemon.db",
        ).fallbackToDestructiveMigration().build()
    }

    @Provides
    @Singleton
    fun 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
    @Singleton
    fun 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文章来源地址https://www.toymoban.com/news/detail-648464.html

到了这里,关于Android Jetpack Compose 中的分页与缓存展示的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • Android Jetpack Compose 别裁

    一、简介 二、compose优缺点 三、compose好学吗 四、Android Jetpack Compose 跟 fluter 哪个更好 五、Android Jetpack Compose 跟 fluter 技能学习选择 之所以叫Android Jetpack Compose别裁是希望能取舍网上的对compose的资料,推出别出心裁的文章,文章结束都会有一个案例,通过实践学习,让学习的

    2024年02月03日
    浏览(44)
  • Jetpack Compose: Hello Android

    Jetpack Compose 是一个现代化的工具包,用于使用声明式方法构建原生 Android UI。在本博文中,我们将深入了解一个基本的 “Hello Android” 示例,以帮助您开始使用 Jetpack Compose。我们将探讨所提供代码片段中使用的函数和注解。 在深入代码之前,请确保您已经准备好使用 Jetpac

    2024年03月10日
    浏览(39)
  • Jetpack Compose中的Accompanist

    accompanist是Jetpack Compose官方提供的一个辅助工具库,以提供那些在Jetpack Compose sdk中目前还没有的功能API。 权限 依赖配置: 单个权限申请 例如,我们需要获取相机权限,可以通过 rememberPermissionState(Manifest.permission.CAMERA) 创建一个 PermissionState 对象,然后通过 PermissionState.statu

    2024年02月03日
    浏览(23)
  • Jetpack Compose中的动画

    Jetpack Compose中没有沿用Android原有的View动画和属性动画,而是新创建了一套全新的动画系统API,这是理所当然的,因为旧的动画系统主要是基于View体系的,而Compose中需要针对的是Composable可组合函数进行处理,那么势必要创造一套新的玩具出来,同时,这也无疑增加了开发者

    2024年02月08日
    浏览(29)
  • Android Jetpack Compose — Slider滑动条

            在Android Jetpack Compose中,Slider(滑动条)是一个常用的用户界面控件,它允许通过滑动条来选择一个范围或数值。Slider控件非常适用于调整音量、亮度、进度等需要连续调整的场景。 一、Slider的属性         Slider是Android Jetpack Compose中的一个控件,用于实现滑动条

    2024年02月11日
    浏览(29)
  • Android Jetpack Compose之RadioGroup的使用

    Android Jetpack Compose是一个现代化的UI工具包,帮助开发者以声明式的方式构建出美观且功能强大的Android应用。在本文中,我们将详细介绍其中的一个重要组件—— RadioGroup 。 一. RadioGroup简介 Jetpack Compose中并没有像传统View系统中那样直接提供 RadioGroup ,但我们可以很方便地通

    2024年02月06日
    浏览(37)
  • 探索Android Jetpack Compose的Surface组件

    随着声明性 UI 框架 Jetpack Compose 的出现,Android 开发变得更加简洁和直观。在这篇博客中,我们将深入探讨其中的一项基本构建块 —— Surface 组件,了解它如何影响 UI 的显示和设计。 一、Jetpack Compose和Surface组件 二、Surface组件的基本使用 三、影响Surface的属性 一、Jetpack Co

    2024年02月11日
    浏览(35)
  • Jetpack Compose中的副作用

    从本质上讲,副作用是任何超出函数控制和作用域的东西。副作用会使函数变得不确定,因此它们使开发人员难以推理代码。 想象有一个函数,它被期望对两个数相加: 对于相同的输入值,其结果永远不会变化,也就是说,不会因为运行次数的增加导致输出结果的不同,因

    2024年02月03日
    浏览(25)
  • 现代化 Android 开发:Jetpack Compose 最佳实践

    作者:古哥E下 如果一直关注 Compose 的发展的话,可以明显感受到 2022 年和 2023 年的 Compose 使用讨论的声音已经完全不一样了, 2022 年还多是观望,2023 年就有很多团队开始采纳 Compose 来进行开发了。不过也有很多同学接触了下 Compose,然后就放弃了。要么使用起来贼特么不顺手

    2024年02月17日
    浏览(44)
  • Android Jetpack Compose实现轮播图效果

    在最近思索如何使用Compose方式改进我的开源TMDB电影列表应用程序的主屏幕时,一个激动人心的概念浮现在我的脑海中——为什么不整合一个吸引人的轮播图来展示即将上映的电影呢?在本文中,我将分享我的开发和实现自定义轮播图的经历,提供涉及不同步骤的见解。 首先

    2024年02月08日
    浏览(81)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包