最近有点闲,想起了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 列表、下拉刷新、上拉加载更多、错误处理、分页的全链路流程,非常适合用来开发时抄代码,一切从实际出发。