Compose 组件之LazyColumn入门-带分页的下拉刷新列表

最近有点闲,想起了Compose还只是了解而已,是时候熟悉下并做个笔记,供自己以后抄自己代码了。😎本文代码比较多,但知识点介绍较少,更多的是提供一个真实的代码模板,并对Paging3做了一定的讲解,需要对Compose有一定的了解。

Compose的简介

Compose 是 Jetpack 库中的现代 Android UI 工具包,用于构建原生 Android 界面。

它采用 声明式 UI,让我们不再关心"怎么修改 UI",而是直接声明"UI 在某个状态下长什么样"。

Compose 是 Android UI 的未来,最终会完全替代传统的 View + XML 开发方式。

数据来源

我们使用 JSONPlaceholder 提供的免费 REST API。

它包含多个接口,比如:

  • 获取用户
  • 获取帖子(Posts)
  • 获取评论
  • 支持分页查询

示例接口: jsonplaceholder.typicode.com/posts?_page...

一、引入依赖配置

app/build.gradle 中添加以下依赖。这里包括 Compose、Retrofit、协程和 Paging(可选,后文介绍)。

gradle 复制代码
dependencies {
    implementation "androidx.compose.foundation:foundation:1.7.0"
    implementation "androidx.compose.material3:material3:1.3.2"
    implementation "androidx.lifecycle:lifecycle-runtime-compose:2.8.3"
    implementation "androidx.activity:activity-compose:1.9.0"

    // Retrofit + Gson
    implementation "com.squareup.retrofit2:retrofit:2.11.0"
    implementation "com.squareup.retrofit2:converter-gson:2.11.0"

    // 协程
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1"
    // Paging 3(可选,用于后文介绍) 
    implementation("androidx.paging:paging-compose:3.3.6")
}-

二、数据模型和网络接口

我们仍然使用真实接口:
https://jsonplaceholder.typicode.com/posts?_page=1&_limit=10

kotlin 复制代码
/**
 帖子相关对象模型
*/
data class Post(
    val id: Int,
    val userId: Int,
    val title: String,
    val body: String
)

网络接口定义

kotlin 复制代码
// network/ApiService.kt
package com.example.demo.network

import com.example.demo.model.Post
import retrofit2.http.GET
import retrofit2.http.Query

interface ApiService {
    // 分页获取帖子
    @GET("posts")
    suspend fun getPosts(
        @Query("_page") page: Int,
        @Query("_limit") limit: Int = 10
    ): List<Post>
}

RetrofitClient

kotlin 复制代码
object RetrofitClient {
    private const val BASE_URL = "https://jsonplaceholder.typicode.com/"

    val api: ApiService by lazy {
        Retrofit.Builder()
            .baseUrl(BASE_URL)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(ApiService::class.java)
    }
}

三、ViewModel层

kotlin 复制代码
/**
 *  管理列表数据、刷新和加载更多逻辑。使用 StateFlow 观察状态。
 * viewmodel/PostViewModel.kt
 */
class PostViewModel : ViewModel() {
    private val _posts = MutableStateFlow<List<Post>>(emptyList())
    val posts: StateFlow<List<Post>> = _posts

    private val _isRefreshing = MutableStateFlow(false)
    val isRefreshing: StateFlow<Boolean> = _isRefreshing

    private val _isLoadingMore = MutableStateFlow(false)
    val isLoadingMore: StateFlow<Boolean> = _isLoadingMore

    private var currentPage = 1
    private val pageSize = 10

    init {
        loadPosts()
    }

    /**
     * 首次加载数据
     */
    private fun loadPosts() {
        viewModelScope.launch {
            _isRefreshing.value = true
            try {
                val result = RetrofitClient.api.getPosts(page = 1, limit = pageSize)
                _posts.value = result
                currentPage = 1
            } finally {
                _isRefreshing.value = false
            }
        }
    }

    /**
     * 下拉刷新
     */
    fun refresh() {
        if (_isRefreshing.value) return
        viewModelScope.launch {
            _isRefreshing.value = true
            delay(1000) // 模拟网络延迟
            try {
                val result = RetrofitClient.api.getPosts(page = 1, limit = pageSize)
                _posts.value = result
                currentPage = 1
            } finally {
                _isRefreshing.value = false
            }
        }
    }

    /**
     * 上拉加载更多
     */
    fun loadMore() {
        if (_isLoadingMore.value || _isRefreshing.value) return
        viewModelScope.launch {
            _isLoadingMore.value = true
            try {
                val nextPage = currentPage + 1
                val result = RetrofitClient.api.getPosts(page = nextPage, limit = pageSize)
                if (result.isNotEmpty()) {
                    _posts.value = _posts.value + result
                    currentPage = nextPage
                }
            } finally {
                _isLoadingMore.value = false
            }
        }
    }
}

四、UI层(ui/PostListScreen)

使用 AnimatedContent 实现状态切换动画,提升用户体验,非必要。

kotlin 复制代码
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PostListScreen(viewModel: PostViewModel = viewModel()) {
    val posts by viewModel.posts.collectAsState()
    val isRefreshing by viewModel.isRefreshing.collectAsState()
    val isLoadingMore by viewModel.isLoadingMore.collectAsState()

    val listState = rememberLazyListState()
    val pullToRefreshState = rememberPullToRefreshState()

    // 监听列表是否到底,触发加载更多
    LaunchedEffect(listState, posts) {
        snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index }
            .collectLatest { lastIndex ->
                if (lastIndex != null && lastIndex >= posts.size - 2 && !isLoadingMore) {
                    viewModel.loadMore()
                }
            }
    }

    PullToRefreshBox(
        isRefreshing = isRefreshing,
        onRefresh = { viewModel.refresh() },
        state = pullToRefreshState,
        modifier = Modifier.fillMaxSize()
    ) {
        LazyColumn(
            state = listState,
            modifier = Modifier.fillMaxSize(),
            contentPadding = PaddingValues(16.dp)
        ) {
            items(posts, key = { it.id }) { post ->
                PostItem(title = post.title, body = post.body)
                Divider()
            }

            if (isLoadingMore) {
                item {
                    Box(
                        modifier = Modifier
                            .fillMaxWidth()
                            .padding(vertical = 12.dp),
                        contentAlignment = Alignment.Center
                    ) {
                        CircularProgressIndicator()
                    }
                }
            }
        }
    }
}

/**
 * 单条 Post 列表项
 */
@Composable
fun PostItem(title: String, body: String) {
    Column(modifier = Modifier.padding(vertical = 8.dp)) {
        Text(text = title, style = MaterialTheme.typography.titleMedium)
        Spacer(Modifier.height(4.dp))
        Text(text = body, style = MaterialTheme.typography.bodyMedium)
    }
}

六、MainActivity

kotlin 复制代码
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MaterialTheme {
                Surface {
                    PostListScreen()
                }
            }
        }
    }
}

上面的代码已经非常接近现实中的项目了。那我们有没有可以改进的地方?有的,真实项目中,因为网络原因,经常会加载失败,那我们是不是增加一些其他状态页面,更加友好,让用户一目了然。

下面我将调整部分代码支持"数据为空"、"加载失败"、"没有更多数据"等情况做出处理。

七、优化与进阶

1、状态新增(UiState.kt)

kotlin 复制代码
/**
 * UI 层统一状态定义
 */
sealed class UiState<out T> {
    data object Loading : UiState<Nothing>() // 加载中
    data class Success<T>(val data: T) : UiState<T>() // 加载成功
    data object Empty : UiState<Nothing>() // 空数据
    data class Error(val message: String) : UiState<Nothing>() // 加载失败
}

2、修改 ViewModel(PostViewModel.kt,支持状态管理)

kotlin 复制代码
package com.yangp.testapplication.activity.viewmodel

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.yangp.testapplication.PostUiState
import com.yangp.testapplication.bean.Post
import com.yangp.testapplication.net.RetrofitClient
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

class PostViewMode : ViewModel() {
    private val _uiState = MutableStateFlow<PostUiState<List<Post>>>(PostUiState.Loading)
    val uiState = _uiState.asStateFlow()

    private val _isRefreshing = MutableStateFlow(false)
    val isRefreshing = _isRefreshing.asStateFlow()

    private val _isLoadingMore = MutableStateFlow(false)
    val isLoadingMore = _isLoadingMore.asStateFlow()
    
        //是否还有更多数据
    private var _hasMoreData = MutableStateFlow(true)
    val hasMore: StateFlow<Boolean> = _hasMoreData.asStateFlow()

    private var currentPage = 1
    val pageSize = 10

    init {
        loadPosts()
    }

    /**
     * 首次加载数据
     */
    private fun loadPosts() {
        viewModelScope.launch {
            _uiState.value = PostUiState.Loading
            runCatching {
                val newData = RetrofitClient.apiService.getPosts(page = 1, limit = pageSize)
                _uiState.value = if (newData.isEmpty()) {
                    PostUiState.Empty
                } else {
                    PostUiState.Success(newData)
                }
                _hasMoreData.value = newData.size >= pageSize
                currentPage = 1
            }.onFailure { ex ->
                _uiState.value = PostUiState.Error(ex.message ?: "加载失败")
            }
        }
    }

    /**
     * 下拉刷新
     */
    fun refresh() {
        if (_isRefreshing.value) {
            return
        }
        viewModelScope.launch {
            _isRefreshing.value = true
            runCatching {
                val newData = RetrofitClient.apiService.getPosts(page = 1, limit = pageSize)
                currentPage = 1
                _uiState.value = if (newData.isEmpty()) {
                    PostUiState.Empty
                } else {
                    PostUiState.Success(newData)
                }
                _hasMoreData.value = newData.size >= pageSize
            }.onFailure {
                _uiState.value = PostUiState.Error(it.message ?: "加载失败")
            }.also {
                _isRefreshing.value = false
            }
        }
    }

    /**
     * 加载更多
     */

    fun loadMore() {
        if (_isLoadingMore.value || !_hasMoreData.value || isRefreshing.value) {
            return
        }

        viewModelScope.launch {
            _isLoadingMore.value = true
            runCatching {
                val nextPage = currentPage + 1
                val newData = RetrofitClient.apiService.getPosts(nextPage)
                if (newData.isEmpty()) {
                    _hasMoreData.value = false
                } else {
                    val currentList = when (val state = _uiState.value) {
                        is PostUiState.Success -> state.data
                        else -> emptyList()
                    }
                    currentPage = nextPage
                    _uiState.value = PostUiState.Success(currentList + newData)
                    _hasMoreData.value = newData.size >= pageSize
                }
            }.onFailure {
                _uiState.value = PostUiState.Error(it.message ?: "加载更多失败")
            }.also {
                _isLoadingMore.value = false
            }
        }
    }

    /**
     * 重新加载,用于错误状态重试
     */
    fun retry() {
        loadPosts()
    }
}

3、修改UI(PostListScreen.kt)

kotlin 复制代码
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PostListScreen(viewModel: PostViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsState()
//    val posts by viewModel.posts.collectAsState()
    val isRefreshing by viewModel.isRefreshing.collectAsState()
    val isLoadingMore by viewModel.isLoadingMore.collectAsState()
    val hasMore by viewModel.hasMore.collectAsState()

    val listState = rememberLazyListState()
    //下拉刷新状态
    val pullToRefreshState = rememberPullToRefreshState()

    // 监听列表是否到底,当还有两个item时,触发加载更多
    LaunchedEffect(listState, uiState) {
        snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index }
            .collectLatest { lastIndex ->
                val posts = (uiState as? PostUiState.Success)?.data ?: return@collectLatest
                if (lastIndex != null && lastIndex >= posts.size - 2 && !isLoadingMore) {
                    viewModel.loadMore()
                }
            }
    }

/**
 在不同页面切换时使用动画
*/
    AnimatedContent(
        targetState = uiState,
        transitionSpec = {
            fadeIn() togetherWith fadeOut()
        },
        modifier = Modifier.fillMaxSize(),
        label = "UiStateTransition"
    ) { state ->
        when (state) {
            is PostUiState.Loading -> {
                LoadingScreen()
            }

            is PostUiState.Success -> {
                val posts = state.data
                PostList(posts = posts,
                    isRefreshing = isRefreshing,
                    isLoadingMore = isLoadingMore,
                    pullToRefreshState = pullToRefreshState,
                    listState = listState,
                    hasMore = hasMore) {
                    viewModel.refresh()
                }
            }

            is PostUiState.Empty -> {
                EmptyScreen()
            }

            is PostUiState.Error -> ErrorScreen(error = (uiState as PostUiState.Error).error) {
                viewModel.retry()
            }
        }
    }

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PostList(
    posts: List<Post>,
    isRefreshing: Boolean,
    isLoadingMore: Boolean,
    hasMore: Boolean,
    pullToRefreshState: PullToRefreshState,
    listState: LazyListState,
    onRefresh: () -> Unit
) {
    /**
     * 手势触发逻辑
     * 当用户下拉到一定拒绝(超过触发阈值)时启动刷新
     */
    PullToRefreshBox(
        isRefreshing = isRefreshing,
        onRefresh = { onRefresh() },
        modifier = Modifier.fillMaxSize(),
        state = pullToRefreshState
    ) {
        LazyColumn(
            state = listState,
            modifier = Modifier.fillMaxSize(),
            contentPadding = PaddingValues(16.dp)
        ) {

            items(posts, key = { it.id }) { post ->
                PostItem(title = post.userId.toString() +"-" + post.id, body = post.body)
                HorizontalDivider()
            }

            item {
                Box(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(vertical = 12.dp),
                    contentAlignment = Alignment.Center
                ) {
                    when {
                        isLoadingMore -> CircularProgressIndicator(modifier = Modifier.size(28.dp))
                        else -> AnimatedVisibility(visible = !hasMore) {
                            Text(text = "------没有更多数据了------", style = MaterialTheme.typography.bodyLarge)
                        }
                    }
                }
            }
        }
    }
}

/**
 * 单条 Post 列表项
 */
@Composable
fun PostItem(title: String, body: String) {
    Column(modifier = Modifier.padding(vertical = 8.dp).clickable { },) {
        Text(text = title, style = MaterialTheme.typography.titleMedium)
        Spacer(Modifier.height(4.dp))
        Text(text = body, style = MaterialTheme.typography.bodyMedium)
    }
}

/**
 * 加载中页面
 */
@Composable
fun LoadingScreen() {
    Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        CircularProgressIndicator()
    }
}

/**
 * 空数据页面
 */
@Composable
fun EmptyScreen() {
    Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        Text(text = "暂无数据", style = MaterialTheme.typography.bodyLarge)
    }
}

/**
 * 错误页面(可重试)
 */
@Composable
fun ErrorScreen(error: String, onRetryClick: () -> Unit) {
    Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        Column(horizontalAlignment = Alignment.CenterHorizontally) {
            Text(
                text = "加载失败,${error}",
                style = MaterialTheme.typography.bodyLarge,
                textAlign = TextAlign.Center
            )
            Spacer(modifier = Modifier.height(16.dp))
            Button(onClick = onRetryClick) {
                Text(text = "重试")
            }
        }
    }
}

其他类保持不变。

大家已经发现,"加载更多"是监听列表控件是否已经到达底部。当接近底部时,发送请求,手动维护PageIndex,这也是大多数人采用的方式,简单明了。这里我想介绍一个新的方式,Compose中的Paging3,但我个人感觉不如手动维护灵活方便,但还是简单介绍下用法。

八、Paging3介绍(作为手动分页的替代)

Paging 3 是 Android官方的分页库,专为从网络/数据库加载大数据集设计。它自动处理预取、缓存、加载状态,但配置稍复杂。适合大规模数据的滚动列表。

为什么用 Paging 3?

  • 自动预取:在用户滚动时提前加载下一页。
  • 内置状态:处理加载/错误/占位符。
  • 与 Compose 集成:使用 collectAsLazyPagingItems() 无缝渲染。

简单用法示例

1. 核心组件

  • PagingSource: 数据源,负责从网络或数据库加载数据
  • Pager: 构建 Flow 的主要入口
  • PagingData: 包含分页数据的容器
  • PagingDataAdapter: RecyclerView 适配器,用于显示分页数据

2. 添加依赖:

复制代码
已在上文添加 androidx.paging:paging-compose:3.3.6。

3. PagingSource(定义数据源):

kotlin 复制代码
/**
* 分页数据源
*/
class PostPagingSource(private val api: ApiService): PagingSource<Int, Post>() {
   val pageSize = 10
   override fun getRefreshKey(state: PagingState<Int, Post>): Int? {
       return state.anchorPosition?.let { anchor ->
           state.closestPageToPosition(anchor)?.prevKey?.plus(1)
               ?: state.closestPageToPosition(anchor)?.nextKey?.minus(1)
       }
   }

   override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Post> {
      return runCatching {
           val page = params.key ?: 1
           val loadSize = params.loadSize
           Log.d("PostPagingSource", "page: $page, pageSize: $loadSize")
           val response = api.getPosts(page = page, limit = pageSize)
           LoadResult.Page(
               data = response,
               prevKey = if (page == 1) null else page - 1,
               nextKey = if (response.isEmpty()) null else page + 1
           )
       }.getOrElse {
           LoadResult.Error(it)
       }
   }
}

4. ViewModel(Pager入口,使用 Pager 创建 Flow)

PostViewMode.kt

kotlin 复制代码
private val api = RetrofitClient.apiService
val postsFlow: Flow<PagingData<Post>> = Pager(
       config = PagingConfig(pageSize = 10, enablePlaceholders = false, prefetchDistance = 2)
   ) {
       PostPagingSource(api)
}.flow.cachedIn(viewModelScope)

5. UI集成(在 LazyColumn 中使用)

修改PostListScreen.kt(修改部分节选)

kotlin 复制代码
val loadState = pagingItems.loadState
val isRefreshing = loadState.refresh is LoadState.Loading && pagingItems.itemCount > 0
val isFirstLoad = loadState.refresh is LoadState.Loading && pagingItems.itemCount == 0
val isError = loadState.refresh is LoadState.Error
val isEmpty = loadState.refresh is LoadState.NotLoading && pagingItems.itemCount == 0

// 根据 loadState 渲染不同界面
AnimatedContent(
    targetState = when {
        isFirstLoad -> "loading"
        isError -> "error"
        isEmpty -> "empty"
        else -> "success"
    },
    transitionSpec = { (fadeIn() + expandVertically()).togetherWith(fadeOut() + shrinkVertically()) },
    label = "pagingState",
    modifier = Modifier.fillMaxSize()
) { state ->
    when (state) {
        "loading" -> LoadingScreen()
        "error" -> ErrorScreen(
            error = (loadState.refresh as? LoadState.Error)?.error?.localizedMessage
                ?: "加载失败",
            onRetryClick = { pagingItems.retry() }
        )
        "empty" -> EmptyScreen()
        "success" -> {
            // 获取成功内容 + 下拉刷新
            PullToRefreshBox(
                isRefreshing = isRefreshing,
                onRefresh = { pagingItems.refresh() },
                state = pullToRefreshState,
                modifier = Modifier.fillMaxSize()
            ) {
                PostList(pagingItems)
            }
        }
    }
}
    
@Composable
fun PostList(pagingItems: LazyPagingItems<Post>) {
    LazyColumn(
        modifier = Modifier.fillMaxSize(),
        contentPadding = PaddingValues(16.dp)
    ) {
        items(pagingItems.itemCount) { index ->
            pagingItems[index]?.let { post ->
                PostItem(post.id.toString() + "-"+ post.userId, post.body)
                HorizontalDivider()
            }
        }

        // 底部加载状态
        item {
            LoadStateFooter(pagingItems)
        }
    }
}

/**
 * 底部加载状态
 */
@Composable
fun LoadStateFooter(pagingItems: LazyPagingItems<Post>) {
    when (val state = pagingItems.loadState.append) {
        is LoadState.Loading -> {
            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(16.dp),
                contentAlignment = Alignment.Center
            ) {
                CircularProgressIndicator()
            }
        }

        is LoadState.Error -> {
            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(16.dp),
                contentAlignment = Alignment.Center
            ) {
                Column(horizontalAlignment = Alignment.CenterHorizontally) {
                    Text("加载失败: ${state.error.message}", color = MaterialTheme.colorScheme.error)
                    Spacer(modifier = Modifier.height(8.dp))
                    Button(onClick = { pagingItems.retry() }) {
                        Text("重试")
                    }
                }
            }
        }

        else -> {
            if (pagingItems.loadState.append.endOfPaginationReached && pagingItems.itemCount > 0) {
                Box(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(vertical = 12.dp),
                    contentAlignment = Alignment.Center
                ) {
                    Text("------ 没有更多数据了 ------", style = MaterialTheme.typography.bodySmall)
                }
            }
        }
    }
}

6.有个小坑提前了解

我们看下面PostViewMode.kt中的这一串代码,这是Pager的构造函数,已明确设置了单页请求数量。

kotlin 复制代码
Pager( 
    config = PagingConfig(pageSize = 10, 
    enablePlaceholders = false, 
    prefetchDistance = 2) 
)

再看PagingSource.kt中的这串代码,这也是很多介绍Paging3文章写的代码。

kotlin 复制代码
val response = api.getPosts(pageIndex, pageSize = params.loadSize)

理论上没问题,可实际测试发现,第一次请求pageSize = 30,第二次请求才恢复正常,这就导致部分数据重复。

直接说答案:

  • 原因:官方设计行为,用于在首次渲染时提前获取更多的数据,避免频繁触发分页。第一次被调用时,parms.loadSize = initialLoadSize,而initialLoadSize如果在PagingConfig创建时不设置就默认是3 * pageSize。如果你使用偏移量分页可能就没有这个问题了。
  • 解决方案:永远分页参数使用固定的 pageSize,而不是 params.loadSize,或者设置initialLoadSize。

九、总结

对比项 手动分页 Paging3
触发方式 手动监听滑动到底部 自动预取(prefetchDistance
状态处理 自定义 UiState 内置 LoadState
代码量 简单直观 稍复杂但可扩展
性能 适合小数据列表 适合大数据长列表

Compose 真正解放了我们在 UI 层的生产力,而结合 ViewModel + 协程 + Paging3, 本文完整涵盖了从 Retrofit 到 Compose 列表、下拉刷新、上拉加载更多、错误处理、分页的全链路流程,非常适合用来开发时抄代码,一切从实际出发。

相关推荐
苏琢玉2 天前
被问性能后,我封装了这个 PHP 错误上报工具
php·composer
JavaEdge.5 天前
Cursor 2.0 扩展 Composer 功能,助力上下文感知式开发
php·composer
laocaibulao6 天前
mac电脑composer命令如何指定PHP版本
macos·php·composer
梁正雄21 天前
扩展、Docker-compose-1
docker·容器·composer
小张课程1 个月前
新-Jetpack Compose:从上手到进阶再到高手
composer
Flash Dog1 个月前
Composer 版本不匹配问题:
php·composer
苏琢玉1 个月前
一个小项目的记录:PHP 分账组件
php·composer
U_U20461 个月前
Java在云原生时代下的微服务架构优化与实践
composer
Pika1 个月前
深入浅出 Compose 测量机制
android·android jetpack·composer