在现代Android应用开发中,UI层与导航系统的设计直接影响用户体验与代码可维护性。本章基于Jetpack Compose与Navigation Compose,从页面实现、通用组件封装到导航架构设计,构建完整的任务管理应用交互体系,实现响应式UI与流畅的页面跳转体验。
页面是用户交互的核心载体,需通过状态管理实现数据与UI的实时同步。本节聚焦任务管理应用的三大核心页面,结合StateFlow与Compose的声明式特性,实现响应式更新逻辑。
任务列表页作为应用入口,需展示任务集合、支持状态切换(加载中/加载完成/空数据)及点击跳转详情页。其核心实现依赖LazyColumn的高效列表渲染与StateFlow的数据流订阅。
状态管理逻辑:通过ViewModel暴露uiState(类型为StateFlow<TasksUiState>),页面使用collectAsStateWithLifecycle收集状态,确保数据变化时UI自动重组。TasksUiState需包含加载中(Loading)、成功(Success)、错误(Error)三种状态,分别对应不同UI展示[9][24]。
UI实现代码:
@Composable
fun TasksScreen(
viewModel: TasksViewModel = hiltViewModel(),
onTaskClick: (Long) -> Unit // 点击任务项触发跳转详情页
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when (uiState) {
is TasksUiState.Loading -> {
// 加载中状态:显示圆形进度指示器
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
is TasksUiState.Success -> {
val tasks = (uiState as TasksUiState.Success).tasks
if (tasks.isEmpty()) {
// 空数据状态:显示EmptyState组件
EmptyState(
message = "暂无任务",
icon = painterResource(id = R.drawable.ic_empty_tasks)
)
} else {
// 成功状态:用LazyColumn渲染任务列表
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(tasks, key = { it.id }) { task ->
TaskItem(
task = task,
onClick = { onTaskClick(task.id) },
onCheckChange = { isChecked ->
viewModel.updateTaskStatus(task.id, isChecked)
}
)
}
}
}
}
is TasksUiState.Error -> {
// 错误状态:显示错误信息与重试按钮
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "加载失败: ${(uiState as TasksUiState.Error).message}",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(bottom = 16.dp)
)
Button(onClick = { viewModel.refreshTasks() }) {
Text("重试")
}
}
}
}
}
关键技术点:
collectAsStateWithLifecycle:相比collectAsState,该API能感知生命周期,避免后台数据更新导致的无效重组,提升性能[24]。LazyColumn的key参数:指定task.id作为唯一标识,确保列表项重组时保持状态一致性(如动画状态)。when分支处理不同状态,实现UI的条件渲染,符合单一职责原则[47]。任务详情页需根据列表页传递的taskId加载并展示单个任务的详细信息,支持“编辑”与“返回”操作。其核心是从导航参数中解析taskId,并基于ID获取任务数据。
参数接收逻辑:通过Navigation Compose的backStackEntry.arguments获取路由中的taskId,指定参数类型为Long以确保类型安全。若参数解析失败,可返回错误提示或自动回退[19][25]。
数据展示实现:
@Composable
fun TaskDetailScreen(
taskId: Long, // 从导航参数中解析的任务ID
viewModel: TaskDetailViewModel = hiltViewModel(),
onEditClick: () -> Unit, // 触发跳转编辑页
onBackClick: () -> Unit // 触发返回列表页
) {
// 初始化时加载任务数据
LaunchedEffect(taskId) {
viewModel.loadTask(taskId)
}
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when (uiState) {
is TaskDetailUiState.Loading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
is TaskDetailUiState.Success -> {
val task = (uiState as TaskDetailUiState.Success).task
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Top
) {
// 顶部操作栏:返回按钮 + 编辑按钮
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
IconButton(onClick = onBackClick) {
Icon(imageVector = Icons.Default.ArrowBack, contentDescription = "返回")
}
Button(onClick = onEditClick) {
Text("编辑")
}
}
// 任务详情内容
Column(modifier = Modifier.padding(top = 24.dp)) {
Text(
text = task.title,
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
text = task.description,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "创建时间: ${task.createTime}",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 16.dp)
)
// 完成状态标签
Box(
modifier = Modifier
.padding(top = 8.dp)
.background(
color = if (task.isCompleted) Color.Green else Color.Gray,
shape = RoundedCornerShape(4.dp)
)
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = if (task.isCompleted) "已完成" else "未完成",
color = Color.White,
style = MaterialTheme.typography.labelSmall
)
}
}
}
}
is TaskDetailUiState.Error -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "任务不存在或已删除",
color = MaterialTheme.colorScheme.error
)
Button(onClick = onBackClick, modifier = Modifier.padding(top = 16.dp)) {
Text("返回列表")
}
}
}
}
}
关键技术点:
LaunchedEffect触发数据加载:以taskId为键,确保页面初始化或taskId变化时仅加载一次数据,避免重复请求。navArgument("taskId") { type = NavType.LongType }指定参数类型,避免运行时类型转换错误[50]。任务编辑页提供表单界面,支持修改任务标题、描述及完成状态,需实现输入状态的实时同步与提交保存。其核心是通过Compose的状态API实现表单输入与ViewModel的双向绑定。
状态绑定逻辑:使用remember { mutableStateOf }管理本地输入状态,或直接观察ViewModel暴露的MutableStateFlow,实现“输入更新→状态变化→UI反馈”的闭环。对于复杂表单,推荐使用ViewModel集中管理状态,确保数据一致性[51][52]。
表单实现代码:
@Composable
fun TaskEditScreen(
taskId: Long,
viewModel: TaskEditViewModel = hiltViewModel(),
onSaveClick: () -> Unit, // 保存后返回详情页
onCancelClick: () -> Unit // 取消后返回详情页
) {
// 初始化时加载任务数据
LaunchedEffect(taskId) {
viewModel.loadTaskForEdit(taskId)
}
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
when (uiState) {
is TaskEditUiState.Loading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
is TaskEditUiState.Success -> {
val task = (uiState as TaskEditUiState.Success).task
// 本地状态:标题、描述、完成状态(使用ViewModel的状态或本地状态)
var title by remember(task.title) { mutableStateOf(task.title) }
var description by remember(task.description) { mutableStateOf(task.description) }
var isCompleted by remember(task.isCompleted) { mutableStateOf(task.isCompleted) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
// 顶部操作栏:取消 + 保存
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(onClick = onCancelClick) {
Text("取消")
}
Button(
onClick = {
scope.launch {
viewModel.updateTask(
task.copy(
title = title,
description = description,
isCompleted = isCompleted
)
)
onSaveClick() // 保存成功后返回详情页
}
},
enabled = title.isNotBlank() // 标题不为空时启用保存按钮
) {
Text("保存")
}
}
// 表单内容
Column(modifier = Modifier.padding(top = 24.dp)) {
// 标题输入框
TextField(
value = title,
onValueChange = { title = it },
label = { Text("任务标题") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
isError = title.isBlank() // 标题为空时显示错误状态
)
if (title.isBlank()) {
Text(
text = "标题不能为空",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
)
}
// 描述输入框
TextField(
value = description,
onValueChange = { description = it },
label = { Text("任务描述") },
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
maxLines = 5,
minLines = 3
)
// 完成状态复选框
Row(
modifier = Modifier.padding(top = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = isCompleted,
onCheckedChange = { isCompleted = it }
)
Text(
text = "标记为完成",
modifier = Modifier.padding(start = 8.dp)
)
}
}
}
}
is TaskEditUiState.Error -> {
// 错误处理:如任务不存在
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "无法编辑此任务",
color = MaterialTheme.colorScheme.error
)
Button(onClick = onCancelClick, modifier = Modifier.padding(top = 16.dp)) {
Text("返回")
}
}
}
}
}
关键技术点:
remember { mutableStateOf }管理本地状态,复杂场景(如跨页面共享)可直接观察ViewModel的MutableStateFlow。enabled控制按钮状态,isError显示错误提示,提升用户体验。rememberCoroutineScope处理保存操作的异步逻辑,避免阻塞UI线程[51]。通用组件是UI层模块化的核心,通过封装高频复用的UI元素(如TaskItem、EmptyState),可减少重复代码、统一视觉风格,并提升维护效率。
TaskItemTaskItem用于在列表中展示单个任务,包含复选框(标记完成状态)、标题及点击事件。需支持状态变化(如已完成任务的文字样式)与交互反馈。
实现代码:
@Composable
fun TaskItem(
task: Task,
onClick: () -> Unit, // 点击整个项触发
onCheckChange: (Boolean) -> Unit, // 复选框状态变化触发
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.background(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(8.dp)
)
.clickable { onClick() } // 整行可点击
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 复选框
Checkbox(
checked = task.isCompleted,
onCheckedChange = { onCheckChange(it) },
modifier = Modifier.size(24.dp)
)
// 任务标题
Text(
text = task.title,
style = MaterialTheme.typography.bodyLarge.copy(
textDecoration = if (task.isCompleted) TextDecoration.LineThrough else null,
color = if (task.isCompleted) MaterialTheme.colorScheme.onSurfaceVariant
else MaterialTheme.colorScheme.onSurface
),
modifier = Modifier
.padding(start = 16.dp)
.weight(1f) // 占据剩余空间,推动右侧内容
)
// 右侧箭头(指示可点击跳转)
Icon(
imageVector = Icons.Default.ArrowForwardIos,
contentDescription = "查看详情",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
设计要点:
Modifier.clickable使整行可点击,提升交互便捷性。weight(1f)确保箭头图标靠右对齐,适应不同屏幕宽度。MaterialTheme.colorScheme定义颜色,支持主题切换(如明暗主题)[53]。EmptyStateEmptyState在列表无数据时显示,包含图标、提示文本及可选操作按钮(如“添加任务”),用于引导用户交互。
实现代码:
@Composable
fun EmptyState(
message: String,
icon: Painter,
onActionClick: (() -> Unit)? = null, // 可选操作按钮
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
// 图标
Image(
painter = icon,
contentDescription = null,
modifier = Modifier.size(120.dp),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant)
)
// 提示文本
Text(
text = message,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 24.dp),
textAlign = TextAlign.Center
)
// 操作按钮(如“添加任务”)
onActionClick?.let {
Button(
onClick = it,
modifier = Modifier.padding(top = 16.dp)
) {
Text("添加任务")
}
}
}
}
使用示例:
// 在TasksScreen的Success状态中,当tasks为空时调用
EmptyState(
message = "暂无任务,点击下方按钮添加",
icon = painterResource(id = R.drawable.ic_empty_tasks),
onActionClick = { navController.navigate("task_edit/new") }
)
设计要点:
Column的居中对齐确保内容在屏幕中央,视觉上更平衡。onSurfaceVariant颜色,避免与背景对比度不足[53]。采用单Activity架构结合Navigation Compose,可简化生命周期管理、提升页面切换性能,并通过导航图统一管理页面关系与参数传递。
导航图通过NavHost定义页面路由、参数及跳转逻辑,是实现页面交互的核心。需包含三个路由:任务列表(tasks_list)、任务详情(task_detail/{taskId})、任务编辑(task_edit/{taskId})。
完整导航图代码:
@Composable
fun AppNavHost(
navController: NavHostController = rememberNavController(),
modifier: Modifier = Modifier
) {
NavHost(
navController = navController,
startDestination = "tasks_list", // 初始页面为任务列表
modifier = modifier
) {
// 1. 任务列表页
composable("tasks_list") {
TasksScreen(
onTaskClick = { taskId ->
// 跳转到详情页,传递taskId
navController.navigate("task_detail/$taskId")
}
)
}
// 2. 任务详情页:接收taskId参数
composable(
route = "task_detail/{taskId}",
arguments = listOf(
navArgument("taskId") {
type = NavType.LongType // 指定参数类型为Long
nullable = false // 不允许为空
}
)
) { backStackEntry ->
// 从参数中解析taskId
val taskId = backStackEntry.arguments?.getLong("taskId")
?: run {
// 参数解析失败,显示错误并返回列表页
LaunchedEffect(Unit) { navController.popBackStack() }
return@composable
}
TaskDetailScreen(
taskId = taskId,
onEditClick = {
// 跳转到编辑页,传递taskId
navController.navigate("task_edit/$taskId")
},
onBackClick = {
// 返回上一页(列表页)
navController.popBackStack()
}
)
}
// 3. 任务编辑页:支持新建(taskId为"new")和编辑(taskId为具体ID)
composable(
route = "task_edit/{taskId}",
arguments = listOf(
navArgument("taskId") {
type = NavType.StringType // 支持"new"字符串或Long转String
}
)
) { backStackEntry ->
val taskId = backStackEntry.arguments?.getString("taskId")
?: run {
LaunchedEffect(Unit) { navController.popBackStack() }
return@composable
}
TaskEditScreen(
taskId = if (taskId == "new") -1L else taskId.toLongOrNull() ?: -1L,
onSaveClick = {
// 保存后返回详情页(若为新建,则返回列表页)
navController.popBackStack()
},
onCancelClick = {
navController.popBackStack()
}
)
}
}
}
通过navArgument定义参数类型(如LongType、StringType),确保参数在路由中传输时的类型一致性。例如,详情页的taskId指定为LongType,避免字符串解析错误[19][25]。
对于新建任务场景,可通过特殊值(如task_edit/new)区分,在编辑页中判断taskId是否为"new",从而决定加载默认数据还是已有任务数据。
Navigation Compose自动维护返回栈,通过navController.popBackStack()实现“返回上一页”,通过navController.navigate()添加新页面到栈顶。例如:
[tasks_list, task_detail/1][tasks_list, task_detail/1, task_edit/1][tasks_list, task_detail/1]最佳实践:避免使用popUpTo和launchSingleTop等复杂操作,除非需要清除返回栈(如登录后跳转主页)。简单场景下,依赖默认返回栈行为即可满足需求[54]。
在单Activity架构中,MainActivity仅作为容器,通过setContent加载AppNavHost:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
AppNavHost() // 直接加载导航图
}
}
}
}
这种设计使Activity不再承担UI逻辑,符合“单一职责”原则,且便于后续迁移到多模块架构[26][47]。
将路由字符串定义为常量,避免硬编码错误:
object Screen {
const val TASKS_LIST = "tasks_list"
const val TASK_DETAIL = "task_detail/{taskId}"
const val TASK_EDIT = "task_edit/{taskId}"
// 获取详情页路由(带参数)
fun taskDetailRoute(taskId: Long) = "task_detail/$taskId"
}
使用时直接引用常量,提升可读性:
navController.navigate(Screen.taskDetailRoute(task.id))
页面内不直接依赖NavController,而是通过onTaskClick等回调暴露导航意图,由父组件(如AppNavHost)处理实际导航。这种设计使页面组件更易于单元测试(可传入模拟回调)[21]。
例如,TasksScreen的onTaskClick由AppNavHost实现为navController.navigate(...),测试时可传入{ println("点击了任务 $it") }验证交互逻辑。
本章通过Jetpack Compose实现了响应式UI层,结合Navigation Compose构建了单Activity导航架构,核心亮点包括:
StateFlow与collectAsStateWithLifecycle,实现数据变化驱动UI自动更新,确保状态一致性。TaskItem、EmptyState等通用组件,减少重复代码,统一视觉风格。NavType指定参数类型,避免运行时类型错误,提升代码健壮性。NavHost集中管理,符合清晰架构设计。通过以上实践,可构建出易维护、可扩展且用户体验流畅的Android应用UI层与导航系统。