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

相关推荐
林戈的IT生涯20 小时前
windows 安装 composer 报SSL错误的问题 以及windows11上CMD命令下中文总乱码的问题解决
php·idea·composer·error14090086·cmd中文乱码·ja-netfilter
顾道长生'5 天前
(Arxiv-2025)ID-COMPOSER:具有分层身份保持的多主体视频合成
计算机视觉·音视频·composer
z***I39410 天前
PHP Composer
开发语言·php·composer
王煜苏18 天前
contos7安装dokcer遇到的坑,docker-composer
docker·eureka·composer
catchadmin19 天前
PHP 依赖管理器 Composer 2.9 发布
开发语言·php·composer
苏琢玉23 天前
被问性能后,我封装了这个 PHP 错误上报工具
php·composer
JavaEdge.1 个月前
Cursor 2.0 扩展 Composer 功能,助力上下文感知式开发
php·composer
laocaibulao1 个月前
mac电脑composer命令如何指定PHP版本
macos·php·composer
梁正雄1 个月前
扩展、Docker-compose-1
docker·容器·composer