在Android&Jetpack中使用分页3的缓存和分页构成
#kotlin #android #jetpackcompose #caching

在本文中,我们将使用页面3实施缓存和分页。我们将使用JetPack撰写,但您也可以遵循本文并从中学习,即使您不想使用JetPack Compose。除了UI层,其中大部分将相似。

目录

  • 入门

  • API端点&创建模型

  • 房间和改造设置

  • 远程中介

  • pager

  • UI层

  • 列表设置

  • 加载和错误处理

先决条件

我们将在本文中使用房间,改造和刀柄,所以更好地知道它们是如何工作的。

我还假设您知道分页3的工作原理。如果您不这样做,我建议您在此之前检查本文。
Pagination in Jetpack Compose with and without Paging 3

入门

应用程序级别build.gradle文件,

//Paging 3
def paging_version = "3.1.1"
implementation "androidx.paging:paging-runtime:$paging_version"
implementation "androidx.paging:paging-compose:1.0.0-alpha17"

//Retrofit
def retrofit_version = "2.9.0"
implementation "com.squareup.retrofit2:retrofit:$retrofit_version"
implementation "com.squareup.retrofit2:converter-gson:$retrofit_version"

//Hilt
def hilt_version = "2.44"
implementation "com.google.dagger:hilt-android:$hilt_version"
kapt "com.google.dagger:hilt-compiler:$hilt_version"
implementation "androidx.hilt:hilt-navigation-compose:1.0.0"

//Room
def room_version = "2.4.3"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version"
implementation "androidx.room:room-paging:$room_version"

//Coil
implementation "io.coil-kt:coil-compose:2.2.2"

不要忘记在androidmanifest.xml中添加Internet许可,

<uses-permission android:name="android.permission.INTERNET" />

API端点和创建模型

我们将使用ThemoviedB API版本3。您可以register并从this link获取API键。我们将使用/movie/popular端点。

API Key

响应模型,

请将它们放入不同的文件中。我将它们放入一个代码块中,以使其更容易阅读。

data class MovieResponse(
    val page: Int,
    @SerializedName(value = "results")
    val movies: List<Movie>,
    @SerializedName("total_pages")
    val totalPages: Int,
    @SerializedName("total_results")
    val totalResults: Int
)

@Entity(tableName = "movies")
data class Movie(
    @PrimaryKey(autoGenerate = false)
    val id: Int,
    @ColumnInfo(name = "original_title")
    @SerializedName("original_title")
    val ogTitle: String,
    @ColumnInfo(name = "overview")
    val overview: String,
    @ColumnInfo(name = "popularity")
    val popularity: Double,
    @ColumnInfo(name = "poster_path")
    @SerializedName("poster_path")
    val posterPath: String?,
    @ColumnInfo(name = "release_date")
    @SerializedName("release_date")
    val releaseDate: String,
    @ColumnInfo(name = "title")
    val title: String,
    @ColumnInfo(name = "page")
    var page: Int,
)

这是此部分的。

房间和改造设置

让我们从创建和实施改造开始。 API服务将非常简单,因为我们将仅使用1个端点。

interface MoviesApiService {
    @GET("movie/popular?api_key=${MOVIE_API_KEY}&language=en-US")
    suspend fun getPopularMovies(
        @Query("page") page: Int
    ): MovieResponse
}

API服务已经准备就绪,我们将在完成房间实施后在此部分的末尾创建Raterofit实例。

这是进行改造的,现在我们可以实现空间。在开始之前,我们需要创建一个用于缓存的新模型。

@Entity(tableName = "remote_key")
data class RemoteKeys(
    @PrimaryKey(autoGenerate = false)
    @ColumnInfo(name = "movie_id")
    val movieID: Int,
    val prevKey: Int?,
    val currentPage: Int,
    val nextKey: Int?,
    @ColumnInfo(name = "created_at")
    val createdAt: Long = System.currentTimeMillis()
)

当远程密钥与列表项目没有直接关联时,最好将它们存储在本地数据库中的单独表中。虽然可以在电影表中完成此操作,但为与电影关联的下一个和上一个远程键创建新表使我们能够拥有更好的separation of concerns

此模型是跟踪分页的必要条件。当我们从打架状态下加载最后一个项目时,就无法知道其属于页面的索引。为了解决此问题,我们添加了另一个表,该表存储每个电影的下一个,当前和上一页键。密钥是页码。创建的AT是缓存超时所必需的。如果您不需要检查我们上次何时何时降低数据,则可以将其删除。

现在我们可以为电影和远程钥匙创建DAO,

@Dao
interface MoviesDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(movies: List<Movie>)

    @Query("Select * From movies Order By page")
    fun getMovies(): PagingSource<Int, Movie>

    @Query("Delete From movies")
    suspend fun clearAllMovies()
}

@Dao
interface RemoteKeysDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(remoteKey: List<RemoteKeys>)

    @Query("Select * From remote_key Where movie_id = :id")
    suspend fun getRemoteKeyByMovieID(id: Int): RemoteKeys?

    @Query("Delete From remote_key")
    suspend fun clearRemoteKeys()

    @Query("Select created_at From remote_key Order By created_at DESC LIMIT 1")
    suspend fun getCreationTime(): Long?
}

最后,我们需要创建数据库类。

@Database(
    entities = [Movie::class, RemoteKeys::class],
    version = 1,
)
abstract class MoviesDatabase: RoomDatabase() {
    abstract fun getMoviesDao(): MoviesDao
    abstract fun getRemoteKeysDao(): RemoteKeysDao
}

就是这样。现在让我们创建改造和房间实例。

@Module
@InstallIn(SingletonComponent::class)
class SingletonModule {
    @Singleton
    @Provides
    fun provideRetrofitInstance(): MoviesApiService =
        Retrofit.Builder()
            .baseUrl("https://api.themoviedb.org/3/")
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(MoviesApiService::class.java)

    @Singleton
    @Provides
    fun provideMovieDatabase(@ApplicationContext context: Context): MoviesDatabase =
        Room
            .databaseBuilder(context, MoviesDatabase::class.java, "movies_database")
            .build()

    @Singleton
    @Provides
    fun provideMoviesDao(moviesDatabase: MoviesDatabase): MoviesDao = moviesDatabase.getMoviesDao()

    @Singleton
    @Provides
    fun provideRemoteKeysDao(moviesDatabase: MoviesDatabase): RemoteKeysDao = moviesDatabase.getRemoteKeysDao()
}

远程调解人

在我们开始实施之前,让我们尝试了解什么是远程调解人以及为什么需要它。

远程调解员在应用程序用完时,从编码库中充当信号。您可以使用此信号从网络加载其他数据并将其存储在本地数据库中,Pagingsource可以将其加载并提供给UI以显示。

需要其他数据时,分页库从远程调解员实现中调用load()方法。此功能通常从网络源获取新数据并将其保存到本地存储中。

远程调解员实现有助于将网络中的分类数据加载到数据库中,但不会将数据直接加载到UI中。相反,该应用将数据库用作source of truth。换句话说,该应用仅显示已在数据库中缓存的数据。

Graph

现在,我们可以开始实施远程调解人。让我们一部分实施。首先,我们将实施负载方法。

@OptIn(ExperimentalPagingApi::class)
class MoviesRemoteMediator (
    private val moviesApiService: MoviesApiService,
    private val moviesDatabase: MoviesDatabase,
): RemoteMediator<Int, Movie>() {

    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, Movie>
    ): MediatorResult {
        val page: Int = when (loadType) {
            LoadType.REFRESH -> {
                //...
            }
            LoadType.PREPEND -> {
                //...
            }
            LoadType.APPEND -> {
                //...
            }
        }

        try {
            val apiResponse = moviesApiService.getPopularMovies(page = page)

            val movies = apiResponse.movies
            val endOfPaginationReached = movies.isEmpty()

            moviesDatabase.withTransaction {
                if (loadType == LoadType.REFRESH) {
                    moviesDatabase.getRemoteKeysDao().clearRemoteKeys()
                    moviesDatabase.getMoviesDao().clearAllMovies()
                }
                val prevKey = if (page > 1) page - 1 else null
                val nextKey = if (endOfPaginationReached) null else page + 1
                val remoteKeys = movies.map {
                    RemoteKeys(movieID = it.id, prevKey = prevKey, currentPage = page, nextKey = nextKey)
                }

                moviesDatabase.getRemoteKeysDao().insertAll(remoteKeys)
                moviesDatabase.getMoviesDao().insertAll(movies.onEachIndexed { _, movie -> movie.page = page })
            }
            return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
        } catch (error: IOException) {
            return MediatorResult.Error(error)
        } catch (error: HttpException) {
            return MediatorResult.Error(error)
        }
    }
}

状态参数为我们提供了有关以前加载的页面的信息,列表中的最新访问索引以及我们在初始化分页流时定义的PIGGENFIG。
LoadType告诉我们是我们之前是否需要加载数据(loadType.append)还是在数据的开头(LoadType.prepend),我们以前已加载,
或者,如果我们第一次重新加载数据(loadType.fresh)。

我们将稍后实现页面属性,所以让我们从try/catch块开始。首先,我们提出API请求并获取电影,并将EndofPaginationReach设置为Movie.isempty。如果没有要加载的物品,我们假设它已经耗尽。

然后我们开始数据库事务。在其中,我们检查LoadType是否刷新,并删除缓存。之后,我们通过映射电影并提取Movie.ID来创建远程钥匙。最后,我们缓存所有检索电影和远程钥匙。

现在,让我们检查我们如何用远程键检索页码,

@OptIn(ExperimentalPagingApi::class)
class MoviesRemoteMediator (
    private val moviesApiService: MoviesApiService,
    private val moviesDatabase: MoviesDatabase,
): RemoteMediator<Int, Movie>() {
    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, Movie>
    ): MediatorResult {
        val page: Int = when (loadType) {
            LoadType.REFRESH -> {
                val remoteKeys = getRemoteKeyClosestToCurrentPosition(state)
                remoteKeys?.nextKey?.minus(1) ?: 1
            }
            LoadType.PREPEND -> {
                val remoteKeys = getRemoteKeyForFirstItem(state)
                val prevKey = remoteKeys?.prevKey
                prevKey ?: return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
            }
            LoadType.APPEND -> {
                val remoteKeys = getRemoteKeyForLastItem(state)
                val nextKey = remoteKeys?.nextKey
                nextKey ?: return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
            }
        }

        try {
          //Previously implemented
        } //...
    }

    private suspend fun getRemoteKeyClosestToCurrentPosition(state: PagingState<Int, Movie>): RemoteKeys? {
        return state.anchorPosition?.let { position ->
            state.closestItemToPosition(position)?.id?.let { id ->
                moviesDatabase.getRemoteKeysDao().getRemoteKeyByMovieID(id)
            }
        }
    }

    private suspend fun getRemoteKeyForFirstItem(state: PagingState<Int, Movie>): RemoteKeys? {
        return state.pages.firstOrNull {
            it.data.isNotEmpty()
        }?.data?.firstOrNull()?.let { movie ->
            moviesDatabase.getRemoteKeysDao().getRemoteKeyByMovieID(movie.id)
        }
    }

    private suspend fun getRemoteKeyForLastItem(state: PagingState<Int, Movie>): RemoteKeys? {
        return state.pages.lastOrNull {
            it.data.isNotEmpty()
        }?.data?.lastOrNull()?.let { movie ->
            moviesDatabase.getRemoteKeysDao().getRemoteKeyByMovieID(movie.id)
        }
    }
}

loadType.refresh,在我们第一次加载数据时或称为refresh()时被调用。

loadType.prepend,当我们需要在当前加载的数据集的开头加载数据时,负载参数为loadType.prepend。

loadType.append,当我们需要在当前加载数据集的末尾加载数据时,负载参数为loadType.append。

getRemoteKeyClosestTocurrentPosition ,基于州的锚定,我们可以通过调用Closestitemtoposition并从数据库中检索远程钥匙来获取最接近该位置的电影项目。如果远程行动为null,我们返回示例中的第一页号。

getRemoteKeyKeykeyForfirstitem ,我们从数据库中加载了第一个电影项。

** getRemoteKeykeyForlastitem,**我们从数据库中加载了最后一个电影项。

最后,让我们实现缓存超时,

@OptIn(ExperimentalPagingApi::class)
class MoviesRemoteMediator (
    private val moviesApiService: MoviesApiService,
    private val moviesDatabase: MoviesDatabase,
): RemoteMediator<Int, Movie>() {

    override suspend fun initialize(): InitializeAction {
        val cacheTimeout = TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS)

        return if (System.currentTimeMillis() - (moviesDatabase.getRemoteKeysDao().getCreationTime() ?: 0) < cacheTimeout) {
            InitializeAction.SKIP_INITIAL_REFRESH
        } else {
            InitializeAction.LAUNCH_INITIAL_REFRESH
        }
    }

    //...
}

初始化此方法是检查缓存数据是否已过时,并决定是否触发远程刷新。此方法在执行任何加载之前运行,因此您可以在触发任何本地或远程加载之前操纵数据库(例如,清除旧数据)。

如果需要完全刷新本地数据,则初始化应返回lunage_initial_refresh。这会导致远程调解员执行远程刷新以完全重新加载数据。

如果需要刷新本地数据,则初始化应返回skip_initial_refresh。这会导致远程调解员跳过远程刷新并加载缓存的数据。

在我们的示例中,我们将超时设置为1小时,并从远程钥匙数据库中检索缓存时间。

就是这样。您可以找到seletemediator代码here,也可以在本文末尾找到完整的代码。

寻呼机

这将很简单,

const val PAGE_SIZE = 20

@HiltViewModel
class MoviesViewModel @Inject constructor(
    private val moviesApiService: MoviesApiService,
    private val moviesDatabase: MoviesDatabase,
): ViewModel() {
    @OptIn(ExperimentalPagingApi::class)
    fun getPopularMovies(): Flow<PagingData<Movie>> =
        Pager(
            config = PagingConfig(
                pageSize = PAGE_SIZE,
                prefetchDistance = 10,
                initialLoadSize = PAGE_SIZE,
            ),
            pagingSourceFactory = {
                moviesDatabase.getMoviesDao().getMovies()
            },
            remoteMediator = MoviesRemoteMediator(
                moviesApiService,
                moviesDatabase,
            )
        ).flow
}

这类似于从简单的网络数据源创建Pager,但是您必须有两件事不同:
您必须提供从DAO返回Pagingsource对象的查询方法,而不是直接传递Pagingsource构造函数。
您必须提供realotemediator实现的实例作为remotemediator参数。

PagingsourceFactory lambda当被调用为Pagingsource实例时,应始终返回全新的Pagingsource。

最后,我们可以开始实现UI层。

UI层

列表设置

列表实现将非常简单,

@Composable
fun MainScreen() {
    val moviesViewModel = hiltViewModel<MoviesViewModel>()

    val movies = moviesViewModel.getPopularMovies().collectAsLazyPagingItems()

    LazyColumn {
        items(
            items = movies
        ) { movie ->
            movie?.let {
                Row(
                    horizontalArrangement = Arrangement.Center,
                    verticalAlignment = Alignment.CenterVertically,
                ) {
                    if (movie.posterPath != null) {
                        var isImageLoading by remember { mutableStateOf(false) }

                        val painter = rememberAsyncImagePainter(
                            model = "https://image.tmdb.org/t/p/w154" + movie.posterPath,
                        )

                        isImageLoading = when(painter.state) {
                            is AsyncImagePainter.State.Loading -> true
                            else -> false
                        }

                        Box (
                            contentAlignment = Alignment.Center
                        ) {
                            Image(
                                modifier = Modifier
                                    .padding(horizontal = 6.dp, vertical = 3.dp)
                                    .height(115.dp)
                                    .width(77.dp)
                                    .clip(RoundedCornerShape(8.dp)),
                                painter = painter,
                                contentDescription = "Poster Image",
                                contentScale = ContentScale.FillBounds,
                            )

                            if (isImageLoading) {
                                CircularProgressIndicator(
                                    modifier = Modifier
                                        .padding(horizontal = 6.dp, vertical = 3.dp),
                                    color = MaterialTheme.colors.primary,
                                )
                            }
                        }
                    }
                    Text(
                        modifier = Modifier
                            .padding(vertical = 18.dp, horizontal = 8.dp),
                        text = it.title
                    )
                }
                Divider()
            }
        }
    }
}

有关列表实现的详细说明,您可以检查this link

List UI

加载和错误处理

@Composable
fun MainScreen() {
    val moviesViewModel = hiltViewModel<MoviesViewModel>()

    val movies = moviesViewModel.getPopularMovies().collectAsLazyPagingItems()

    LazyColumn {
        //... Movie items

        val loadState = movies.loadState.mediator
        item {
            if (loadState?.refresh == LoadState.Loading) {
                Column(
                    modifier = Modifier
                        .fillParentMaxSize(),
                    horizontalAlignment = Alignment.CenterHorizontally,
                    verticalArrangement = Arrangement.Center,
                ) {
                    Text(
                        modifier = Modifier
                            .padding(8.dp),
                        text = "Refresh Loading"
                    )

                    CircularProgressIndicator(color = MaterialTheme.colors.primary)
                }
            }

            if (loadState?.append == LoadState.Loading) {
                Box(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(16.dp),
                    contentAlignment = Alignment.Center,
                ) {
                    CircularProgressIndicator(color = MaterialTheme.colors.primary)
                }
            }

            if (loadState?.refresh is LoadState.Error || loadState?.append is LoadState.Error) {
                val isPaginatingError = (loadState.append is LoadState.Error) || movies.itemCount > 1
                val error = if (loadState.append is LoadState.Error)
                        (loadState.append as LoadState.Error).error
                else
                        (loadState.refresh as LoadState.Error).error

                val modifier = if (isPaginatingError) {
                    Modifier.padding(8.dp)
                } else {
                    Modifier.fillParentMaxSize()
                }
                Column(
                    modifier = modifier,
                    verticalArrangement = Arrangement.Center,
                    horizontalAlignment = Alignment.CenterHorizontally,
                ) {
                    if (!isPaginatingError) {
                        Icon(
                            modifier = Modifier
                                .size(64.dp),
                            imageVector = Icons.Rounded.Warning, contentDescription = null
                        )
                    }

                    Text(
                        modifier = Modifier
                            .padding(8.dp),
                        text = error.message ?: error.toString(),
                        textAlign = TextAlign.Center,
                    )

                    Button(
                        onClick = {
                            movies.refresh()
                        },
                        content = {
                            Text(text = "Refresh")
                        },
                        colors = ButtonDefaults.buttonColors(
                            backgroundColor = MaterialTheme.colors.primary,
                            contentColor = Color.White,
                        )
                    )
                }
            }
        }
    }
}

由于我们正在使用远程调解器,因此我们将使用LoadState.Mediator。我们将仅检查刷新和附加,

刷新是loadState。加载我们将显示加载屏幕。

refresh Loading State

附录是loadState。加载我们将显示分页加载。

append Loading

对于错误,我们检查刷新还是添加是LoadState.Error。如果我们在刷新中遇到错误的意思,我们会在初始获取时出现错误,并且显示出错误屏幕。如果我们在附加的错误上遇到了错误,那么我们在列出列表末尾出现错误时会出现错误。

让我们看到最终结果。

Result

就是这样!我希望这很有用。 ðð

完整代码

MrNtlu/JetpackCompose-PaginationCaching (github.com)

资料来源:

您可以与我联系,