Compose 实战

24 阅读4分钟

项目概述

基于实际项目,从头搭一个纯 Compose App。页面不多但链路完整——Splash 引导 → 登录校验 → 首页四 Tab → 列表点击进详情页。页面流转路径:

Splash → Login → Home (4 Tab)
                    ├── 微信 — 聊天列表 → ChatDetail
                    ├── 通讯录 — 联系人列表 → ContactDetail
                    ├── 发现 — 朋友圈入口 → MomentDetail
                    └── 我 — 个人中心

技术栈:Compose(声明式 UI)+ Navigation Compose(路由)+ ViewModel + StateFlow(状态)+ Kotlin Coroutines(异步)+ Material3(组件)。本地模拟数据,直接能跑。

项目结构

一个 Activity + 按功能分包,没有 Fragment。

com.compose.demo/
├── MainActivity.kt                   # 唯一入口
├── data/
│   └── MockData.kt                   # 数据模型 + 模拟数据
├── ui/
│   ├── navigation/
│   │   └── AppNavigation.kt          # NavHost 路由图
│   ├── splash/
│   │   └── SplashScreen.kt           # 启动页 (动画 + 延迟)
│   ├── login/
│   │   ├── LoginScreen.kt            # 登录 UI
│   │   └── LoginViewModel.kt         # 表单状态 + 模拟校验
│   ├── home/
│   │   └── HomeScreen.kt             # Scaffold + NavigationBar 4 Tab
│   ├── chat/
│   │   ├── ChatListScreen.kt         # 聊天列表
│   │   └── ChatDetailScreen.kt       # 聊天气泡 + 发送
│   ├── contacts/
│   │   ├── ContactsScreen.kt         # 联系人列表
│   │   └── ContactDetailScreen.kt    # 联系人详情
│   ├── discover/
│   │   ├── DiscoverScreen.kt         # 发现页
│   │   └── MomentDetailScreen.kt     # 朋友圈详情
│   └── me/
│       └── MeScreen.kt               # 我的

依赖

除了 Compose BOM 本身,额外引入了两个库:

# gradle/libs.versions.toml
navigationCompose = "2.8.5"
lifecycleViewmodelCompose = "2.6.1"
// app/build.gradle.kts
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
  • navigation-compose 负责页面路由和参数传递
  • lifecycle-viewmodel-compose 在 Composable 中直接通过 viewModel() 获取 ViewModel 实例

入口:MainActivity

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            ComposeDemoTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    AppNavigation()
                }
            }
        }
    }
}

setContent {} 替代了 setContentViewenableEdgeToEdge() 让内容延伸到状态栏和导航栏,配合 Scaffold 的 innerPadding 做到沉浸式。Activity 只负责挂载 Compose 根节点——页面跳转、状态管理、事件分发,全在 Composable 层完成。

导航:AppNavigation

所有页面路由集中在 AppNavigation.kt,用 Navigation Compose 管理。路由常量用 object 收拢,辅助函数拼参数——Routes.chatDetail(3) 生成 "chat_detail/3",避免各处手拼字符串出错:

object Routes {
    const val SPLASH = "splash"
    const val LOGIN = "login"
    const val HOME = "home"
    const val CHAT_DETAIL = "chat_detail/{chatId}"
    const val CONTACT_DETAIL = "contact_detail/{contactId}"
    const val MOMENT_DETAIL = "moment_detail/{momentId}"

    fun chatDetail(chatId: Long) = "chat_detail/$chatId"
    fun contactDetail(contactId: Long) = "contact_detail/$contactId"
    fun momentDetail(momentId: Long) = "moment_detail/$momentId"
}

NavHost 把所有 composable 注册好:

@Composable
fun AppNavigation() {
    val navController = rememberNavController()

    NavHost(navController = navController, startDestination = Routes.SPLASH) {
        composable(Routes.SPLASH) {
            SplashScreen(
                onNavigateToLogin = {
                    navController.navigate(Routes.LOGIN) {
                        popUpTo(Routes.SPLASH) { inclusive = true }
                    }
                }
            )
        }

        composable(Routes.LOGIN) {
            LoginScreen(
                onLoginSuccess = {
                    navController.navigate(Routes.HOME) {
                        popUpTo(Routes.LOGIN) { inclusive = true }
                    }
                }
            )
        }

        composable(Routes.HOME) {
            HomeScreen(
                onChatClick = { chatId ->
                    navController.navigate(Routes.chatDetail(chatId))
                },
                onContactClick = { contactId ->
                    navController.navigate(Routes.contactDetail(contactId))
                },
                onMomentClick = { momentId ->
                    navController.navigate(Routes.momentDetail(momentId))
                }
            )
        }

        composable(
            Routes.CHAT_DETAIL,
            arguments = listOf(navArgument("chatId") { type = NavType.LongType })
        ) { backStackEntry ->
            val chatId = backStackEntry.arguments?.getLong("chatId") ?: 0L
            ChatDetailScreen(chatId = chatId, onBack = { navController.popBackStack() })
        }

        // CONTACT_DETAIL、MOMENT_DETAIL 同理……
    }
}

popUpTo { inclusive = true } — 跳转后从栈中清掉当前页。Splash 跳 Login、Login 跳 Home 时都要加上,否则按返回键会回到 Splash 或 Login,不是拉回桌面。

参数传递用 URL 占位符——chat_detail/{chatId} 定义路由模板,navArgument("chatId") 注册参数类型,backStackEntry.arguments?.getLong("chatId") 取值。Navigation Compose 自动做序列化,Long/Int/String 直接拼 URL。复杂对象可以传 JSON string 再反序列化,但这里模拟数据直接用 Long ID,不涉及跨页面传对象。

事件回调——HomeScreen 通过 lambda onChatClick: (Long) -> Unit 把用户操作传回 AppNavigation,在这里调 navController.navigate()

Splash 页

@Composable
fun SplashScreen(onNavigateToLogin: () -> Unit) {
    var visible by remember { mutableStateOf(false) }
    val alpha by animateFloatAsState(
        targetValue = if (visible) 1f else 0f,
        animationSpec = tween(800)
    )

    LaunchedEffect(Unit) {
        visible = true
        delay(1500)
        onNavigateToLogin()
    }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(MaterialTheme.colorScheme.primary),
        contentAlignment = Alignment.Center
    ) {
        Column(horizontalAlignment = Alignment.CenterHorizontally) {
            Image(
                painter = painterResource(R.mipmap.icon_wx_select),
                contentDescription = "Logo",
                modifier = Modifier.size(64.dp)
            )
            Spacer(modifier = Modifier.height(16.dp))
            Text(
                text = "WeChat Demo",
                fontSize = 28.sp,
                fontWeight = FontWeight.Bold,
                color = MaterialTheme.colorScheme.onPrimary,
                modifier = Modifier.alpha(alpha)
            )
            Spacer(modifier = Modifier.height(8.dp))
            Text(
                text = "Compose + Coroutines + Flow + ViewModel",
                fontSize = 13.sp,
                color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
                modifier = Modifier.alpha(alpha)
            )
        }
    }
}

LaunchedEffect(Unit) + delay:进来先启动淡入动画(animateFloatAsState),1.5 秒后回调 onNavigateToLogin()

LaunchedEffect(Unit) 的 key 是 Unit,代表在 Composition 生命周期中执行一次,Splash 页销毁时协程自动取消。比起 Handler.postDelayedview.post,协程写法跟 Compose 生命周期一致,不在页面上时不会泄露。

登录页 + ViewModel

登录页有两个输入框 + 状态反馈 + 加载中动画。用 ViewModel + StateFlow 管理状态,View 只负责渲染。

LoginViewModel

data class LoginUiState(
    val phone: String = "",
    val password: String = "",
    val isLoading: Boolean = false,
    val error: String? = null
)

class LoginViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(LoginUiState())
    val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()

    fun onPhoneChange(phone: String) {
        _uiState.update { it.copy(phone = phone, error = null) }
    }

    fun onPasswordChange(password: String) {
        _uiState.update { it.copy(password = password, error = null) }
    }

    fun login(onSuccess: () -> Unit) {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true, error = null) }
            delay(1200) // 模拟网络请求
            val state = _uiState.value
            if (state.phone == "13800000000" && state.password == "123456") {
                _uiState.update { it.copy(isLoading = false) }
                onSuccess()
            } else {
                _uiState.update { it.copy(isLoading = false, error = "手机号或密码错误") }
            }
        }
    }
}

_uiStateMutableStateFlow 而非 MutableLiveData——StateFlow 跟 Compose 的 collectAsState() 原生绑定,不需要像 LiveData 再加 observeAsState()update { it.copy(...) } 是纯函数式更新——每次调不修改旧对象,创建新状态。Compose 用 StateFlow 的引用变化判断是否需要重组。

LoginScreen

@Composable
fun LoginScreen(
    onLoginSuccess: () -> Unit,
    viewModel: LoginViewModel = viewModel()
) {
    val uiState by viewModel.uiState.collectAsState()

    Column(
        modifier = Modifier
            .fillMaxSize()
            .background(MaterialTheme.colorScheme.background)
            .padding(horizontal = 32.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Image(
            painterResource(R.mipmap.icon_wx_select),
            contentDescription = "头像",
            modifier = Modifier.size(56.dp)
        )
        Spacer(modifier = Modifier.height(24.dp))
        Text(
            "欢迎回来",
            fontSize = 24.sp,
            fontWeight = FontWeight.Bold,
            color = MaterialTheme.colorScheme.onBackground
        )
        Spacer(modifier = Modifier.height(8.dp))
        Text(
            "请输入手机号和密码登录",
            fontSize = 14.sp,
            color = MaterialTheme.colorScheme.onSurfaceVariant
        )
        Spacer(modifier = Modifier.height(40.dp))

        OutlinedTextField(
            value = uiState.phone,
            onValueChange = { viewModel.onPhoneChange(it) },
            label = { Text("手机号") },
            singleLine = true,
            modifier = Modifier.fillMaxWidth()
        )

        Spacer(modifier = Modifier.height(16.dp))

        OutlinedTextField(
            value = uiState.password,
            onValueChange = { viewModel.onPasswordChange(it) },
            label = { Text("密码") },
            singleLine = true,
            visualTransformation = PasswordVisualTransformation(),
            modifier = Modifier.fillMaxWidth()
        )

        val errorMsg = uiState.error
        if (errorMsg != null) {
            Text(errorMsg, color = MaterialTheme.colorScheme.error, fontSize = 13.sp)
        }

        Button(
            onClick = { viewModel.login(onLoginSuccess) },
            enabled = uiState.phone.length >= 11 && uiState.password.length >= 6 && !uiState.isLoading,
            modifier = Modifier.fillMaxWidth().height(50.dp)
        ) {
            if (uiState.isLoading) {
                CircularProgressIndicator(...)
            } else {
                Text("登录")
            }
        }
    }
}

viewModel() 是从 lifecycle-viewmodel-compose 来的——在 Composable 里直接获取 ViewModel 实例,不需要手动 ViewModelProvider

collectAsState()StateFlow<LoginUiState> 转成 Compose 能自动追踪的 State<LoginUiState>——StateFlow 里的值变了,collectAsState() 通知 Compose 重组当前 Composable 函数。

多模块项目的路由管理

上面是单模块的写法——所有路由注册在一个 AppNavigation.kt 里。如果项目按 feature 拆成多个 Gradle 模块(:feature:chat:feature:contacts:feature:discover:feature:me),所有路由都写在 app 模块会导致两个问题:

  • 依赖倒置:app 模块要依赖每一个 feature 模块,加新 feature 就得改 app 的 build.gradle.kts
  • 耦合过重:feature A 想加个页面?改 app 模块的 NavHost。feature B 想改路由参数?也改 app 模块。

拆模块后推荐三种方案,从简到全。

方案一:feature 暴露 Composable,路由常量放 core 模块

最轻量——连 navigation-compose 的依赖都不给 feature 模块。

:core:navigationRoutes 对象(纯 Kotlin 常量,无 Android 依赖)
:feature:chatChatListScreen(), ChatDetailScreen()(只暴露 Composable:feature:contactsContactsScreen(), ContactDetailScreen()
:appNavHost 组装(依赖所有 feature + navigation-compose)
// :core:navigation
object Routes {
    const val SPLASH = "splash"
    const val LOGIN = "login"
    const val HOME = "home"
    const val CHAT_DETAIL = "chat_detail/{chatId}"
    const val CONTACT_DETAIL = "contact_detail/{contactId}"

    fun chatDetail(id: Long) = "chat_detail/$id"
    fun contactDetail(id: Long) = "contact_detail/$id"
}

app 模块组装——跟单模块时一样:

// :app
NavHost(navController, startDestination = Routes.SPLASH) {
    composable(Routes.CHAT_DETAIL) {
        ChatDetailScreen(chatId = ..., onBack = { navController.popBackStack() })
    }
}

依赖方向:app → feature → core:navigation。feature 模块完全不感知 routing,不知道自己的页面在哪个路由下——app 模块负责把路由路径和 Composable 画面绑定。

方案二:feature 自注册路由扩展函数

feature 模块提供 NavGraphBuilder.xxxNavGraph() 扩展函数,app 模块只做聚合调用。

// :feature:chat(依赖 navigation-compose)
fun NavGraphBuilder.chatNavGraph(navController: NavController) {
    composable(Routes.CHAT_LIST) {
        ChatListScreen(onChatClick = { navController.navigate(Routes.chatDetail(it)) })
    }
    composable(
        Routes.CHAT_DETAIL,
        arguments = listOf(navArgument("chatId") { type = NavType.LongType })
    ) { backStackEntry ->
        val chatId = backStackEntry.arguments?.getLong("chatId") ?: 0L
        ChatDetailScreen(chatId = chatId, onBack = { navController.popBackStack() })
    }
}
// :feature:contacts
fun NavGraphBuilder.contactsNavGraph(navController: NavController) { ... }

app 模块只需要调一下:

// :app
NavHost(navController, startDestination = Routes.SPLASH) {
    chatNavGraph(navController)
    contactsNavGraph(navController)
    discoverNavGraph(navController)
    meNavGraph(navController)
}

依赖方向:app → feature → (core:navigation + navigation-compose)

好处是每个 feature 模块自治——加页面、改参数、改跳转逻辑,只改自己的模块。app 模块不知道每个 feature 下面有几个页面、路由模板是什么。缺点是 feature 模块多了一个 navigation-compose 依赖,约 200KB。

方案三:接口抽象 + DI 注入

最解耦的方式——feature 模块完全不暴露 Composable 给 app,app 不知道有哪些 feature。

// :core:navigation-api(只依赖 navigation-compose)
interface FeatureEntry {
    fun registerGraph(builder: NavGraphBuilder, navController: NavController)
}
// :feature:chat
class ChatFeatureEntry @Inject constructor() : FeatureEntry {
    override fun registerGraph(builder: NavGraphBuilder, navController: NavController) {
        builder.composable(Routes.CHAT_LIST) {
            ChatListScreen(onChatClick = { navController.navigate(Routes.chatDetail(it)) })
        }
        builder.composable(Routes.CHAT_DETAIL, arguments = ...) {
            ChatDetailScreen(...)
        }
    }
}
// :app
@Composable
fun AppNavigation(
    entries: Set<@JvmSuppressWildcards FeatureEntry>  // Hilt/Dagger 注入
) {
    val navController = rememberNavController()
    NavHost(navController, startDestination = Routes.SPLASH) {
        entries.forEach { it.registerGraph(this, navController) }
    }
}

app 模块只依赖 :core:navigation-api,不需要知道 :feature:chat:feature:contacts 这些模块的存在。加新 feature——写一个新的 FeatureEntry 实现 + DI 注册——app 模块一行不动。

这套的代价是引入 Dagger/Hilt 的 DI 机制,且所有 feature 模块的 FeatureEntry 实现类需要暴露给 DI 容器(通常用 @Binds @IntoSet)。

选哪种

ComposeDemo 当前是单模块,AppNavigation.kt 放在一起就行。如果要拆模块,推荐方案二——改动最小、额外依赖最少(每个 feature 只多一个 navigation-compose):Routes 常量抽到 :core:navigation,每个 feature 模块写自己的 NavGraphBuilder.xxxNavGraph() 扩展函数,app 模块的 NavHost 里调这四个函数,删掉 app 对 feature 内部 Screen 的直接 import。

方案一方案二方案三
feature 是否依赖 navigation不依赖依赖依赖
app 是否知道所有 feature
加新 feature 是否改 app 代码是(依赖 + NavHost)是(只调一个函数)
额外依赖navigation-composenavigation-compose + DI
适用规模<5 feature5~15 feature15+ feature

首页 + 底部 Tab

首页是整个 App 的结构骨架,使用 Scaffold + NavigationBar 组合。

Tab 定义

private enum class Tab(
    val label: String,
    val selectedIconRes: Int,
    val unselectedIconRes: Int
) {
    CHAT("微信", R.mipmap.icon_wx_select, R.mipmap.icon_wx_unselect),
    CONTACTS("通讯录", R.mipmap.icon_txl_select, R.mipmap.icon_txl_unselect),
    DISCOVER("发现", R.mipmap.icon_find_select, R.mipmap.icon_find_unselect),
    ME("我的", R.mipmap.icon_mine_select, R.mipmap.icon_mine_unselect)
}

枚举限定底栏只有四个合法值,后续加 when 分支时编译器强制检查遗漏。

Scaffold 结构

Scaffold(
    topBar = {
        TopAppBar(
            title = { Text(tabTitle) },
            colors = TopAppBarDefaults.topAppBarColors(
                containerColor = MaterialTheme.colorScheme.surface,
                titleContentColor = MaterialTheme.colorScheme.onSurface
            )
        )
    },
    bottomBar = {
        NavigationBar {
            Tab.entries.forEach { tab ->
                val selected = selectedTab == tab
                NavigationBarItem(
                    selected = selected,
                    onClick = { selectedTab = tab },
                    icon = {
                        Image(
                            painter = painterResource(
                                id = if (selected) tab.selectedIconRes else tab.unselectedIconRes
                            ),
                            contentDescription = tab.label,
                            modifier = Modifier.size(24.dp)
                        )
                    },
                    label = {
                        Text(
                            tab.label,
                            fontSize = 11.sp,
                            fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal
                        )
                    },
                    colors = NavigationBarItemDefaults.colors(
                        selectedIconColor = Color.Unspecified,
                        selectedTextColor = MaterialTheme.colorScheme.primary,
                        unselectedIconColor = Color.Unspecified,
                        unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
                        indicatorColor = Color.Transparent
                    ),
                    interactionSource = remember { MutableInteractionSource() }
                )
            }
        }
    }
) { innerPadding ->
    Box(modifier = Modifier.padding(innerPadding)) {
        when (selectedTab) {
            Tab.CHAT -> ChatListScreen(onChatClick = onChatClick)
            Tab.CONTACTS -> ContactsScreen(onContactClick = onContactClick)
            Tab.DISCOVER -> DiscoverScreen(onMomentClick = onMomentClick)
            Tab.ME -> MeScreen()
        }
    }
}

Scaffold 自动算 innerPadding——顶栏 + 底栏占用的高度,传给内容区做 Modifier.padding(innerPadding)。不用手算 layout_marginTop 或配 fitsSystemWindows

Tab 枚举里每个值带了 selectedIconResunselectedIconRes 两个资源 ID——Image 通过 if (selected) 判断加载哪张。Color.Unspecified 防止 NavigationBarItem 给图标加 tint,保留图片原始颜色。

Tab 切换用 var selectedTab by remember { mutableStateOf(Tab.CHAT) } 驱动,没有嵌套 NavHost。四个 Tab 页之间不互跳(底栏切换只改状态变量),跳详情页才通过外层 NavController.navigate()

indicatorColor = Color.TransparentMutableInteractionSource 分别去掉 NavigationBarItem 的选中背景色和水波纹。

列表页与详情页

以聊天为例。ChatList 展示对话列表,LazyColumn + 点击回调。

ChatListScreen

@Composable
fun ChatListScreen(onChatClick: (Long) -> Unit) {
    LazyColumn(modifier = Modifier.fillMaxSize()) {
        items(mockChats) { chat ->
            Row(
                modifier = Modifier.fillMaxWidth().clickable { onChatClick(chat.id) }
                    .padding(horizontal = 16.dp, vertical = 12.dp)
            ) {
                Box(Modifier.size(48.dp).clip(CircleShape).background(...)) {
                    Text(chat.avatar)
                }
                Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
                    Text(chat.name, fontWeight = FontWeight.Medium)
                    Text(chat.lastMessage, color = Color.Gray, maxLines = 1)
                }
                if (chat.unreadCount > 0) {
                    Badge { Text("${chat.unreadCount}") }
                }
            }
        }
    }
}

LazyColumn + items() 直接迭代 List<Chat>,列表项的渲染和点击都在一个 lambda 里——不像 RecyclerView 那样需要 Adapter 和 ViewHolder 中转。

ChatDetailScreen

详情页从 Navigation 拿到 chatId,通过 mockChats.find { it.id == chatId } 找到对应数据:

@Composable
fun ChatDetailScreen(chatId: Long, onBack: () -> Unit) {
    val chat = mockChats.find { it.id == chatId }
    var inputText by remember { mutableStateOf("") }
    val messages = remember(chatId) {
        mutableStateListOf<ChatMessage>().apply {
            chat?.messages?.let { addAll(it) }
        }
    }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text(chat?.name ?: "") },
                navigationIcon = {
                    IconButton(onClick = onBack) { Text("←") }
                }
            )
        },
        bottomBar = {
            Row(Modifier.fillMaxWidth().padding(8.dp)) {
                OutlinedTextField(value = inputText, onValueChange = { inputText = it },
                    modifier = Modifier.weight(1f), shape = RoundedCornerShape(20.dp))
                IconButton(onClick = {
                    if (inputText.isNotBlank()) {
                        messages.add(ChatMessage(id = ..., content = inputText, isMe = true, ...))
                        inputText = ""
                    }
                }) {
                        Image(
                            painterResource(R.mipmap.ic_launcher),
                            contentDescription = "发送",
                            modifier = Modifier.size(22.dp)
                        )
                    }
            }
        }
    ) { innerPadding ->
        LazyColumn(modifier = Modifier.padding(innerPadding)) {
            items(messages, key = { it.id }) { msg ->
                MessageBubble(msg)
            }
        }
    }
}

发送消息的逻辑在 Composable 内部——向 mutableStateListOf 追加一条新消息,LazyColumn 自动重组。没有"通知 Adapter 刷新"这一步。

MessageBubble 根据 isMe 决定气泡左右对齐和颜色——自己发的绿色靠右,收到的灰色靠左:

@Composable
private fun MessageBubble(msg: ChatMessage) {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = if (msg.isMe) Arrangement.End else Arrangement.Start
    ) {
        Surface(
            shape = RoundedCornerShape(16.dp, 16.dp,
                if (msg.isMe) 4.dp else 16.dp, if (msg.isMe) 16.dp else 4.dp),
            color = if (msg.isMe) MaterialTheme.colorScheme.primary
                    else MaterialTheme.colorScheme.surfaceVariant
        ) {
            Text(msg.content, modifier = Modifier.padding(12.dp, 8.dp),
                color = if (msg.isMe) MaterialTheme.colorScheme.onPrimary
                        else MaterialTheme.colorScheme.onSurfaceVariant)
        }
    }
}

联系人列表、发现页、个人中心的结构相似——列表 + onXxxClick 回调 + 外层路由 navigate

数据层:MockData

为了让 App 不依赖后端就能跑,全部数据暂时用 Kotlin 常量。

data class Chat(val id: Long, val name: String, val avatar: Int = R.mipmap.icon_avatar,
    val lastMessage: String, val time: String, val unreadCount: Int,
    val messages: List<ChatMessage> = emptyList())

data class ChatMessage(val id: Long, val senderName: String,
    val content: String, val time: String, val isMe: Boolean)

val mockChats = listOf(
    Chat(1, "文件传输助手", R.mipmap.icon_avatar, "文件已发送", "12:30", 0),
    Chat(2, "Alice", R.mipmap.icon_avatar, "晚上一起吃饭?", "11:45", 2,
        messages = listOf(
            ChatMessage(1, "Alice", "今天天气真好", "10:00", false),
            ChatMessage(2, "张三", "是的,适合出去走走", "10:05", true),
            ChatMessage(3, "Alice", "晚上一起吃饭?", "11:45", false)
        )
    ),
    // …
)

每个 Chat 对象内嵌 messages——打开聊天页时直接用,不需要额外请求。接网络层时,把 mockChats 替换为 Repository 从 Room/Retrofit 拉取的数据流即可,UI 层不改。

状态管理总结

场景方案
输入框文本remember { mutableStateOf("") } — Composable 内部状态
表单提交状态 (loading/error)ViewModel + StateFlow + collectAsState()
Tab 选中remember { mutableStateOf(Tab.CHAT) } — 页面内部 UI 状态
聊天消息列表mutableStateListOf — 可观察列表,增删自动重组
模拟数据val mockChats = listOf(...) — 常量,Composable 外
生命周期LaunchedEffect(Unit) — 绑定 Composable 生命周期

单页面内部的小状态(输入框、Switch 开关)用 remember + mutableStateOf;跨页面的、需要 ViewModel 管理的用 StateFlow。不要所有状态都丢 ViewModel——Model-View 分离是好的,但 5 行的 TextField 也要 ViewModel 就是过度设计。

最后

项目的结构——Navigation 管路由、Scaffold 管骨架、ViewModel + StateFlow 管状态、LazyColumn 管列表、Modifier 管样式。

项目不涉及 Fragment、没有 layout XML、——从 Navigation 路由到四个 Tab 的列表页再到详情跳转,代码比 View 体系少。

完整项目代码在 Terry999555/ComposeDemo