项目概述
基于实际项目,从头搭一个纯 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 {} 替代了 setContentView。enableEdgeToEdge() 让内容延伸到状态栏和导航栏,配合 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.postDelayed 或 view.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 = "手机号或密码错误") }
}
}
}
}
_uiState 用 MutableStateFlow 而非 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:navigation → Routes 对象(纯 Kotlin 常量,无 Android 依赖)
:feature:chat → ChatListScreen(), ChatDetailScreen()(只暴露 Composable)
:feature:contacts → ContactsScreen(), ContactDetailScreen()
:app → NavHost 组装(依赖所有 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-compose | navigation-compose + DI |
| 适用规模 | <5 feature | 5~15 feature | 15+ 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 枚举里每个值带了 selectedIconRes 和 unselectedIconRes 两个资源 ID——Image 通过 if (selected) 判断加载哪张。Color.Unspecified 防止 NavigationBarItem 给图标加 tint,保留图片原始颜色。
Tab 切换用 var selectedTab by remember { mutableStateOf(Tab.CHAT) } 驱动,没有嵌套 NavHost。四个 Tab 页之间不互跳(底栏切换只改状态变量),跳详情页才通过外层 NavController.navigate()。
indicatorColor = Color.Transparent 和 MutableInteractionSource 分别去掉 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。