16 Paging3 + Room + LazyColumn 实现首页文章列表

252

首页文字列表 Paging3 使用可以分为三个步骤

  1. 实现 PagingSource
  2. 使用 PagingSource + PagingConfig 创建 Pager 对外提供 PagingData
  3. 使用 PagingData + LazyColumn 显示数据

1666663700003.jpg

添加依赖到 buildSrc

        object Paging{
            private const val version = "3.1.1"
          	//paging 依赖
            const val paging = "androidx.paging:paging-runtime:$version"
          	//paging compose 依赖 
            const val compose = "androidx.paging:paging-compose:1.0.0-alpha16"
        }
        object Room {
          	//paging room 依赖 
            const val paging = "androidx.room:room-paging:2.5.0-alpha01"
        }

实现 PagingSource

PagingSource 分页数据的数据源,用来从网络或者数据库中加载分页数据。

网络数据源

继承 PagingSource 实现 load() 和 getRefreshKey()即可。

class ArticlePagingSource (private val service: WanAndroidService) :PagingSource<Int,Article>(){

    override fun getRefreshKey(state: PagingState<Int, Article>): Int? = null

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
        return try {
            val pageNum = params.key ?: 0 //分页加载的页数
            val response = service.getArticle(pageNum,params.loadSize)//网络接口获取分页数据
            response.handleResponse { pagedData ->
                //prevKey 上一页页数;
                val prevKey = if (pageNum == 0) null else pageNum.minus(1)
                // nextKey 下一页页数; pagedData.over 文章加载完
                val nextKey = if (pagedData.over) null else  pageNum.plus(1)

                LoadResult.Page(data = pagedData.datas, prevKey = prevKey, nextKey = nextKey)
            }
        }catch (e:Throwable){
            if (e is CancellationException) throw e
            LoadResult.Error(e)
        }
    }
}

getRefreshKey() 方法 :根据当前 PagingState 获取刷新的页数,返回 state.anchorPosition(所显示列表中的第一个可见位置)对应的 RefreshKey。

一般情况下我们都是在列表显示第一页的顶部通过下拉的方式触发刷新,这种情况不需要实现 getRefreshKey() ,如果需要在列表滑动到其他页后,在当前位置原地刷新时就需要实现这个方法。

这里只是介绍用法项目中我们使用下面的方式来实现数据源

数据库数据源 和 RemoteMediator

Paging 提供了 Room 依赖,添加依赖可以直接在 Dao 中声明返回数据源的方法,多配合 RemoteMediator 一起使用。

@Dao
abstract class ArticleDao : BaseDao<Article>() {
    //置顶文章 type = 1
    @Query("SELECT * FROM Article WHERE type = 1 AND id in(:ids)")
    abstract fun getTopics(ids: List<Int>): List<Article>

    @Query("SELECT * FROM Article WHERE type = 0 ORDER BY publishTime DESC")
    abstract fun pagingSource(): PagingSource<Int, Article>

    @Query("SELECT * FROM Article WHERE type = 0 ORDER BY publishTime DESC LIMIT 1")
    abstract suspend fun firstOrNullArticle():Article?

    @Query("DELETE FROM Article WHERE type = 0")
    abstract suspend fun clearArticle()
}

RemoteMediator

RemoteMediator 的作用是从网络加载数据保存到数据库,Paging 使用的分页数据源依然来自数据库中缓存的数据。

0E38A9D6-9507-477F-8385-9C6B7E6CDF71.png

@OptIn(ExperimentalPagingApi::class)
@ViewModelScoped
class ArticlePagingMediator @Inject constructor(
    private val articleDao: ArticleDao,
    private val db: WanAndroidDB,
    private val remoteKeyDao: ArticleRemoteKeyDao,
    private val articleService: WanAndroidService,
): RemoteMediator<Int, Article>() {
	//dao 中的 Paging 数据库数据源 每次获取都返回新的 pagingSource
    val pagingSource
        get() = articleDao.pagingSource()

    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, Article>
    ): MediatorResult {
		
        val page = when(loadType){
            LoadType.REFRESH -> {//刷新
                val remoteKey = getRemoteKeyClosestToCurrentPosition(state)
                val page = remoteKey?.nextKey?.minus(1) ?: 0
                Log.e(TAG, "load: LoadType.REFRESH:$page", )
                page
            }
            LoadType.PREPEND -> {//向前加载
                val remoteKey = getRemoteKeyForFirstItem(state)

                val prevKey = remoteKey?.prevKey
                    ?: return MediatorResult.Success(endOfPaginationReached = remoteKey != null)
                Log.e(TAG, "load: LoadType.PREPEND:$prevKey", )
                prevKey
            }
            LoadType.APPEND ->{//向后追加
                val remoteKey = getRemoteKeyForLastItem(state)
                val nextKey = remoteKey?.nextKey
                    ?: return MediatorResult.Success(endOfPaginationReached = remoteKey != null)
                Log.e(TAG, "load: LoadType.APPEND:$nextKey", )
                nextKey
            }
        }


        try {
            val pagedArticles = articleService.getArticle(page,state.config.pageSize).handleResponse { it }
            val articles = pagedArticles.datas
            val endOfPaginationReached = pagedArticles.over

            db.withTransaction {
                if (loadType == LoadType.REFRESH){
                    remoteKeyDao.clearRemoteKeys()
                    articleDao.clearArticle()
                }

                val prevKey = if (page == 0) null else page - 1
                val nextKey = if (endOfPaginationReached) null else page + 1
                Log.e(TAG, "load:withTransaction  prevKey=$prevKey <> next = $nextKey", )
              	//为每个 article 生成 RemoteKey 
                val keys = articles.map { article ->  ArticleRemoteKey(article.id,prevKey,nextKey)}
                remoteKeyDao.insertAll(keys)
                articleDao.insertAll(articles)
            }

            return MediatorResult.Success(endOfPaginationReached)
        }catch (e:Exception){
            if (e is CancellationException){
                throw e
            }
            return MediatorResult.Error(e)
        }
    }

    /*
    返回 state.anchorPosition(所显示列表中的第一个可见位置)对应的 RemoteKey
    首次加载数据时返回 null
     */
    private suspend fun getRemoteKeyClosestToCurrentPosition(state: PagingState<Int, Article>):ArticleRemoteKey?{
        return  state.anchorPosition?.let { position ->
            state.closestItemToPosition(position)?.id?.let { articleId ->
                remoteKeyDao.getRemoteKeyBy(articleId)
            }
        }
    }

    private suspend fun getRemoteKeyForLastItem(state: PagingState<Int, Article>):ArticleRemoteKey?{

        val lastPage = state.pages.lastOrNull()
        val lastArticle = lastPage?.data?.lastOrNull()
        Log.e(TAG, "getRemoteKeyForLastItem: ${lastArticle?.id}", )
        val lastKey = lastArticle?.let {
            remoteKeyDao.getRemoteKeyBy(it.id)
        }
        return  lastKey
    }
    private suspend fun getRemoteKeyForFirstItem(state: PagingState<Int, Article>):ArticleRemoteKey?{
        return state.pages.firstOrNull() { it.data.isNotEmpty() }?.data?.firstOrNull()
            ?.let {
                remoteKeyDao.getRemoteKeyBy(it.id)
            }
    }

    override suspend fun initialize(): InitializeAction {
        val remoteSourceChanged =  remoteSourceChanged()
        Log.e(TAG, "initialize ->remoteSourceChanged :$remoteSourceChanged")
        return  if (remoteSourceChanged) InitializeAction.LAUNCH_INITIAL_REFRESH else InitializeAction.SKIP_INITIAL_REFRESH
    }

    private suspend fun remoteSourceChanged():Boolean = withContext(Dispatchers.IO) {
        val localDeferred = async { articleDao.firstOrNullArticle() }
        val remoteDeferred = async { articleService.getArticle(0, 2).data.datas.firstOrNull() }
        val localFirstArticle =  localDeferred.await()
        val remoteFirstArticle =  remoteDeferred.await()
        localFirstArticle == null || remoteFirstArticle == null || localFirstArticle.id != remoteFirstArticle.id
    }
}

initialize()

初始化时调用 ,如果此时网络数据与本地数据一致就返回 InitializeAction.SKIP_INITIAL_REFRESH Paging 直接使用缓存在数据库中的数据,反之返回 InitializeAction.LAUNCH_INITIAL_REFRESH 执行刷新重新加载数据。

load()

load() 作用是从网络加载数据保存到数据库中,方法参数是 LoadType 和 PagingState 。

LoadType 有三种:

  • LoadType.REFRESH 刷新
  • LoadType.PREPEND 向前加载
  • LoadType.APPEND 向后加载

PagingState 中 持有所有加载过的分页数据。

load() 中实现的步骤:

  1. 根据 LoadType 和 PagingState 获取当前需要从网络加载数据的页数(配合 RemoteKey)
  2. 从网络加载当前页数据
  3. 将数据保存到数据库后返回 MediatorResult

RemoteKey

有了 LoadType 和 PagingState 如何获取当前要加载数据的页数这取决于网络接口设计。

如果网络接口设计成以一条数据的 key 为条件向前或向后加载,这样就可以直接通过 PagingState 拿到“页数”,根据 LoadType 来确定向前或向后加载。

如果网络接口设计成以 pageNum 来获取数据,那么就需要将加载后的每一条数据与页数关联起来,关联数据我们以 RemoteKey 的形式保持包数据库中。

@Entity
data class ArticleRemoteKey(
    @PrimaryKey
    val articleId:Int,
    val prevKey :Int?,
    val nextKey :Int?
)

@Dao
abstract class ArticleRemoteKeyDao: BaseDao<ArticleRemoteKey>() {

    @Query("SELECT * FROM ArticleRemoteKey WHERE articleId = :articleId")
    abstract suspend fun getRemoteKeyBy(articleId:Int):ArticleRemoteKey

    @Query("DELETE FROM ArticleRemoteKey")
    abstract suspend fun clearRemoteKeys()
}

数据加载成功将 RemoteKey 保持起来

val keys = articles.map { article ->
	ArticleRemoteKey(article.id,prevKey,nextKey)
}
remoteKeyDao.insertAll(keys)

根据 state 获取与数据关联的 RemoteKey

private suspend fun getRemoteKeyForFirstItem(state: PagingState<Int, Article>):ArticleRemoteKey?{
        return state.pages.firstOrNull() { it.data.isNotEmpty() }?.data?.firstOrNull(
            ?.let {
                remoteKeyDao.getRemoteKeyBy(it.id)
            }
}

getRemoteKeyClosestToCurrentPosition()

这个方法实现跟前面网络数据源中 getRefreshKey() 实现逻辑一样,返回 state.anchorPosition(所显示列表中的第一个可见位置)对应的 RemoteKey ,首次加载数据时返回 null

实现了这个方法后就可以支持 “原地刷新功能”,例如现在列表中的第一个可见位置是第五页是数据,此时原地刷新加载的流程是 REFRESH 5 -> PREPEND 4 -> APPEND 6 -> PREPEND 3 -> PREPEND 2 -> PREPEND 1 -> PREPEND 0

创建 Pager 对外提供 PagingData

这个就很简单了,将前面的实现做为参数生成 Pager 对象再调用 flow 就可以了。

但是有一点需要注意要保证 每次调用 pagingSourceFactory 返回的 PagingSource 都是新对象,即 PagingSource 不可以复用

添加 PagingInteractor 定义

无关代码已省略

abstract class PagingInteractor<P : PagingInteractor.Parameters<T>, T : Any> : SubjectInteractor<P, PagingData<T>>() {
    interface Parameters<T : Any> {
        val pagingConfig: PagingConfig
    }
}
//默认的
val PAGING_CONFIG = PagingConfig(
    pageSize = 20,
    initialLoadSize = 60 // paging 默认加载 3 页数据
)
@ViewModelScoped
class ArticlePagingInteractor @Inject constructor(
    private val articlePagingMediator: ArticlePagingMediator
): PagingInteractor<ArticlePagingInteractor.Params, Article>() {

    data class Params(override val pagingConfig: PagingConfig):Parameters<Article>

    @OptIn(ExperimentalPagingApi::class)
    override fun createObservable(params: Params): Flow<PagingData<Article>> {
        return Pager(
            config = params.pagingConfig,
            remoteMediator = articlePagingMediator
        ){
            articlePagingMediator.pagingSource
        }.flow
    }
}

VM 中提供 PagingData

无关代码已省略

@HiltViewModel
class HomeViewModel @Inject constructor(
    articlePagingInteractor: ArticlePagingInteractor,
) :BaseViewModel<HomeAction>() {

    init {
        articlePagingInteractor(ArticlePagingInteractor.Params(PAGING_CONFIG))
    }

    val articlesPagingDataFlow : Flow<PagingData<Article>> = articlePagingInteractor.flow.cachedIn(viewModelScope)
   
}
@Composable
fun rememberHomeState(viewModel: HomeViewModel,navController: NavController):HomeState{
    val pagedArticle = viewModel.articlesPagingDataFlow.collectAsLazyPagingItems()
    return remember(topics){
        HomeState(
            pagedArticle = pagedArticle,
        )
    }
}

class HomeState(
    val pagedArticle: LazyPagingItems<Article>,
):ComposeVmState<HomeAction>(){

    override fun onAction(action: HomeAction) {
        when(action){
            is HomeAction.RefreshList -> refreshList()
        }
    }

    private fun refreshList() {
        pagedArticle.refresh()
    }
}

LazyColumn 显示数据

添加 Paging Compose 依赖后可以在 LazyColumn 中直接使用以 LazyPagingItems 为参数的 DSL items() 来显示数据。

自定义下拉刷新 LazyColumn

实现方式也很简单,使用 Accompanist 中的 SwipeRefresh 将 SwipeRefresh 和 LazyColumn 包装成一个组件

SwipeRefresh 依赖

    object Accompanist {
        private const val version = "0.26.5-rc"
        const val swipeRefresh = "com.google.accompanist:accompanist-swiperefresh:$version
    }
@Composable
fun RefreshLazyColumn(
    modifier: Modifier = Modifier,
    state: LazyListState = rememberLazyListState(),
    contentPadding: PaddingValues = PaddingValues(0.dp),
    reverseLayout: Boolean = false,
    verticalArrangement: Arrangement.Vertical =
        if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
    horizontalAlignment: Alignment.Horizontal = Alignment.Start
    flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
    onRefresh :(()->Unit)? = null,
    isRefreshing:Boolean = false,
    shouldShowLoadingState:() -> Boolean = {false},
    content: LazyListScope.()-> Unit

) {
    val refreshState = rememberSwipeRefreshState(isRefreshing)
    SwipeRefresh(
        state = refreshState,
        onRefresh = { onRefresh?.invoke()},
        indicatorPadding = contentPadding,
        indicator = { _state, trigger ->
            SwipeRefreshIndicator(
                state = _state,
                refreshTriggerDistance = trigger,
                scale = true
            )
        }) {
        LazyColumn(
            modifier = modifier,
            state = state,
            contentPadding = contentPadding,
            reverseLayout = reverseLayout,
            verticalArrangement = verticalArrangement,
            horizontalAlignment = horizontalAlignment,
            flingBehavior = flingBehavior,
        ) {
            content()
            //底部显示加载中 UI
            if (shouldShowLoadingState()) {
                item(key = "RefreshLazyColumn_Load_Indicator") {
                    Box(Modifier.fillMaxWidth().padding(24.dp)
                    ) {
                        CircularProgressIndicator(Modifier.align(Alignment.Center))
                    }
                }
            }
        }
    }
}

在 UiHome 中显示首页文章列表

定义一个简单的 ArticleCard 用于显示 Item

@Composable
fun ArticleCard(
    modifier: Modifier = Modifier,
    title:String?,
    author:String?,
    date:String?,
    collected:Boolean = false,
    showPlaceHolder:Boolean = false,
    onCollectedClick:(Boolean)->Unit,
    onClick:()->Unit,
){
    val collectedState = remember(collected) { mutableStateOf(collected) }

    Card(
        modifier = Modifier.then(modifier).clickable(onClick = onClick),
        shape = RoundedCornerShape(4.dp),
        backgroundColor = Color.Whit
    ) {

        Row{
            Column(modifier = Modifier
                .weight(1f)
                .padding(8.dp)) {
                Text(
                    modifier = Modifier.defaultPlaceHolder(showPlaceHolder).defaultMinSize(minWidth = 150.dp),
                    text = title ?: "",
                    fontSize = 16.sp,
                    fontWeight = FontWeight.Bold,
                    color = Color.Black,
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis
                )
                Spacer(modifier = Modifier.height(8.dp))
                Row {
                    Text(
                        modifier = Modifier.defaultPlaceHolder(showPlaceHolder).defaultMinSize(minWidth = 20.dp),
                        text = author ?: "",
                        fontSize = 12.sp,
                        color = Color.Gray
                    )
                    if (author?.isEmpty() == false) {
                        Spacer(modifier = Modifier.width(6.dp))
                    }

                    Text(
                        modifier = Modifier.defaultPlaceHolder(showPlaceHolder).defaultMinSize(minWidth = 30.dp),
                        text = date ?: "",
                        fontSize = 12.sp,
                        color = Color.Gray
                    )
                }
            }

            IconToggleButton(
                modifier = Modifier.align(Alignment.CenterVertically),
                checked = collectedState.value,
                onCheckedChange = { onCollectedClick(it) }) {
                val tint by animateColorAsState(targetValue = if (collectedState.value) Color.Magenta else Color.LightGray)
                Icon(Icons.Filled.Favorite, tint = tint, contentDescription = "")
            }
        }


    }
}


@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UiHome(navController: NavController,viewModel: HomeViewModel = hiltViewModel()){
    val homeState = rememberHomeState(viewModel = viewModel, navController = navController)

    Scaffold (
        floatingActionButton = {
            if (homeState.shouldShowToTopButton){
                FloatingActionButton(onClick = {homeState.dispatchAction(HomeAction.RefreshList)}) {
                    Icon(imageVector = Icons.Default.Refresh, contentDescription = "" )
                }
            }
        
            ){ paddingValues ->
        Column (modifier = Modifier.padding(paddingValues)){
            Banner(items = homeState.banners) {
                ImageBannerItem(modifier = Modifier.height(200.dp),imageUrl = it.imagePath)
            }
            RefreshLazyColumn(
                modifier = Modifier.fillMaxSize().padding(top = 4.dp),
                state = homeState.lazyListState,
                contentPadding = PaddingValues(horizontal = 4.dp),
                verticalArrangement = Arrangement.spacedBy(2.dp),
                onRefresh = { homeState.dispatchAction(HomeAction.RefreshList)},
                isRefreshing = homeState.pagedArticle.loadState.refresh == LoadState.Loading,
                shouldShowLoadingState = { homeState.pagedArticle.loadState.append == LoadState.Loading }
            ){
                //置顶文章
                items(homeState.topics, key = { it.id }) { topic ->
                    val title = topic.title.fromHtml()
                    ArticleCard(
                        modifier = Modifier.background(Color.Cyan).padding(2.dp),
                        title = title,
                        author = topic.author,
                        date = topic.niceDate,
                        showPlaceHolder = topic == null,
                        onCollectedClick = {},
                        onClick = {}
                    )
                }
                //分页文章
                items(homeState.pagedArticle, key = {it.id}) { article ->
                    val title = article?.title?.fromHtml()
                    ArticleCard(
                        title = title,
                        author = article?.author,
                        date = article?.niceDate,
                        showPlaceHolder = article == null,
                        onCollectedClick = {},
                        onClick = {}
                    )
                }
            }
        }
    }
}

Untitled.gif

完整代码见 Git