UI层与导航实现

在现代Android应用开发中,UI层与导航系统的设计直接影响用户体验与代码可维护性。本章基于Jetpack Compose与Navigation Compose,从页面实现、通用组件封装到导航架构设计,构建完整的任务管理应用交互体系,实现响应式UI与流畅的页面跳转体验。

一、页面实现:响应式状态驱动的UI构建

页面是用户交互的核心载体,需通过状态管理实现数据与UI的实时同步。本节聚焦任务管理应用的三大核心页面,结合StateFlow与Compose的声明式特性,实现响应式更新逻辑。

1. 任务列表页:LazyColumn与StateFlow数据收集

任务列表页作为应用入口,需展示任务集合、支持状态切换(加载中/加载完成/空数据)及点击跳转详情页。其核心实现依赖LazyColumn的高效列表渲染与StateFlow的数据流订阅。

状态管理逻辑:通过ViewModel暴露uiState(类型为StateFlow<TasksUiState>),页面使用collectAsStateWithLifecycle收集状态,确保数据变化时UI自动重组。TasksUiState需包含加载中(Loading)、成功(Success)、错误(Error)三种状态,分别对应不同UI展示[9][24]。

UI实现代码

kotlin
复制代码
@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]。
  • LazyColumnkey参数:指定task.id作为唯一标识,确保列表项重组时保持状态一致性(如动画状态)。
  • 状态驱动UI:通过when分支处理不同状态,实现UI的条件渲染,符合单一职责原则[47]。
2. 任务详情页:导航参数接收与数据展示

任务详情页需根据列表页传递的taskId加载并展示单个任务的详细信息,支持“编辑”与“返回”操作。其核心是从导航参数中解析taskId,并基于ID获取任务数据。

参数接收逻辑:通过Navigation Compose的backStackEntry.arguments获取路由中的taskId,指定参数类型为Long以确保类型安全。若参数解析失败,可返回错误提示或自动回退[19][25]。

数据展示实现

kotlin
复制代码
@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]。
  • 单一职责的UI层:页面仅负责数据展示与用户交互(如点击事件),业务逻辑(如加载任务)委托给ViewModel,符合“ dumb rendering component”设计理念[46]。
3. 任务编辑页:表单输入与状态双向绑定

任务编辑页提供表单界面,支持修改任务标题、描述及完成状态,需实现输入状态的实时同步与提交保存。其核心是通过Compose的状态API实现表单输入与ViewModel的双向绑定。

状态绑定逻辑:使用remember { mutableStateOf }管理本地输入状态,或直接观察ViewModel暴露的MutableStateFlow,实现“输入更新→状态变化→UI反馈”的闭环。对于复杂表单,推荐使用ViewModel集中管理状态,确保数据一致性[51][52]。

表单实现代码

kotlin
复制代码
@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("返回")
                }
            }
        }
    }
}

关键技术点

  • 本地状态与ViewModel状态协同:简单表单可使用remember { mutableStateOf }管理本地状态,复杂场景(如跨页面共享)可直接观察ViewModel的MutableStateFlow
  • 表单验证:通过enabled控制按钮状态,isError显示错误提示,提升用户体验。
  • 协程作用域:使用rememberCoroutineScope处理保存操作的异步逻辑,避免阻塞UI线程[51]。

二、通用组件封装:提升代码复用与一致性

通用组件是UI层模块化的核心,通过封装高频复用的UI元素(如TaskItemEmptyState),可减少重复代码、统一视觉风格,并提升维护效率。

1. 任务项组件TaskItem

TaskItem用于在列表中展示单个任务,包含复选框(标记完成状态)、标题及点击事件。需支持状态变化(如已完成任务的文字样式)与交互反馈。

实现代码

kotlin
复制代码
@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]。
2. 空数据提示组件EmptyState

EmptyState在列表无数据时显示,包含图标、提示文本及可选操作按钮(如“添加任务”),用于引导用户交互。

实现代码

kotlin
复制代码
@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("添加任务")
            }
        }
    }
}

使用示例

kotlin
复制代码
// 在TasksScreen的Success状态中,当tasks为空时调用
EmptyState(
    message = "暂无任务,点击下方按钮添加",
    icon = painterResource(id = R.drawable.ic_empty_tasks),
    onActionClick = { navController.navigate("task_edit/new") }
)

设计要点

  • 可定制性:支持自定义提示文本、图标及操作按钮,适应不同场景(如无网络、无搜索结果)。
  • 居中布局:通过Column的居中对齐确保内容在屏幕中央,视觉上更平衡。
  • 主题一致性:图标使用onSurfaceVariant颜色,避免与背景对比度不足[53]。

三、导航实现:单Activity架构与页面跳转管理

采用单Activity架构结合Navigation Compose,可简化生命周期管理、提升页面切换性能,并通过导航图统一管理页面关系与参数传递。

1. 导航图配置

导航图通过NavHost定义页面路由、参数及跳转逻辑,是实现页面交互的核心。需包含三个路由:任务列表(tasks_list)、任务详情(task_detail/{taskId})、任务编辑(task_edit/{taskId})。

完整导航图代码

kotlin
复制代码
@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() 
                }
            )
        }
    }
}
2. 导航核心能力解析
(1)参数传递与类型安全

通过navArgument定义参数类型(如LongTypeStringType),确保参数在路由中传输时的类型一致性。例如,详情页的taskId指定为LongType,避免字符串解析错误[19][25]。

对于新建任务场景,可通过特殊值(如task_edit/new)区分,在编辑页中判断taskId是否为"new",从而决定加载默认数据还是已有任务数据。

(2)返回栈管理

Navigation Compose自动维护返回栈,通过navController.popBackStack()实现“返回上一页”,通过navController.navigate()添加新页面到栈顶。例如:

  • 列表页 → 详情页:栈变为[tasks_list, task_detail/1]
  • 详情页 → 编辑页:栈变为[tasks_list, task_detail/1, task_edit/1]
  • 编辑页保存后返回:栈恢复为[tasks_list, task_detail/1]

最佳实践:避免使用popUpTolaunchSingleTop等复杂操作,除非需要清除返回栈(如登录后跳转主页)。简单场景下,依赖默认返回栈行为即可满足需求[54]。

(3)与Activity集成

在单Activity架构中,MainActivity仅作为容器,通过setContent加载AppNavHost

kotlin
复制代码
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MaterialTheme {
                AppNavHost() // 直接加载导航图
            }
        }
    }
}

这种设计使Activity不再承担UI逻辑,符合“单一职责”原则,且便于后续迁移到多模块架构[26][47]。

3. 可测试性与可维护性优化
(1)路由常量定义

将路由字符串定义为常量,避免硬编码错误:

kotlin
复制代码
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"
}

使用时直接引用常量,提升可读性:

kotlin
复制代码
navController.navigate(Screen.taskDetailRoute(task.id))
(2)导航事件解耦

页面内不直接依赖NavController,而是通过onTaskClick等回调暴露导航意图,由父组件(如AppNavHost)处理实际导航。这种设计使页面组件更易于单元测试(可传入模拟回调)[21]。

例如,TasksScreenonTaskClickAppNavHost实现为navController.navigate(...),测试时可传入{ println("点击了任务 $it") }验证交互逻辑。

总结

本章通过Jetpack Compose实现了响应式UI层,结合Navigation Compose构建了单Activity导航架构,核心亮点包括:

  1. 响应式状态管理:基于StateFlowcollectAsStateWithLifecycle,实现数据变化驱动UI自动更新,确保状态一致性。
  2. 组件化设计:封装TaskItemEmptyState等通用组件,减少重复代码,统一视觉风格。
  3. 类型安全的导航:通过NavType指定参数类型,避免运行时类型错误,提升代码健壮性。
  4. 单一职责原则:页面组件专注于UI渲染,业务逻辑委托给ViewModel,导航逻辑由NavHost集中管理,符合清晰架构设计。

通过以上实践,可构建出易维护、可扩展且用户体验流畅的Android应用UI层与导航系统。