跳到正文 Lab Week 3 Summary · Jimmy Huang's Blog
、
DatePicker
这类组件里处理用户输入
如何判断一个值应该是普通变量,还是应该成为 Compose state
如何把本周模式迁移到后续 assignment 的表单、输入框、日期选择器等功能上
Task 0: 新建项目与基础准备 创建一个新的 Android Compose 项目,项目名为 TextFieldLab
找到本周主要修改位置: MainActivity.kt
确认你能正常运行 emulator 或真机
后续几个 composable 会轮流放进 setContent {} 里测试
Step 0.1:创建 Compose 项目 在 Android Studio 中创建一个新项目:
Step 0.2:确认 MainActivity.kt 可以正常运行 先直接运行一次模板项目,确认 emulator 或手机能启动。
Step 0.3:确认后续主要修改位置 先明确 Lab 的工作区,避免后面把 composable 写错位置。
Task 1: 使用 TextField 实现 RememberName创建一个新的 composable: RememberName()
在里面放一个输入姓名的 OutlinedTextField
当用户输入名字时,界面动态显示 Hello, xxx!
用 remember { mutableStateOf("") } 保存输入内容
在 setContent {} 里调用这个 composable 并运行
Step 1.1:补齐本任务常用 import 如果 Android Studio 没有自动导入,可以补上下面这些 import:
先把 Compose state 和 UI 组件会用到的 import 补齐。
官方 Lab 里特别点名:如果你使用 var name by remember { mutableStateOf("") } 这种写法,就需要 getValue 和 setValue 这两个 import。
Step 1.2:编写 RememberName() composable 把下面这个函数加到 MainActivity.kt 中:
这里创建了一个受 state 控制的输入框。用户每次输入字符时, name 会变化,Compose 观察到这个 state 改变后,就会自动重新执行这个 composable,并更新画面。
本周最关键的点就是: Compose 不是你手动刷新界面,而是状态变了,UI 自动跟着变。
var name by remember { mutableStateOf("") }
创建一个可观察、可保留的字符串状态,初始值为空字串
remember {}
让这个状态在重组时不要每次都重新初始化
mutableStateOf("")
创建一个 Compose 可以观察的 state
if (name.isNotEmpty())
只有输入不为空时才显示欢迎文字
OutlinedTextField(...)
输入框当前显示的值由 name 决定
onValueChange = { name = it }
用户输入新内容时,把新内容写回 state
也就是说, it 就是 lambda 里默认的那个新输入值。
Step 1.3:在 setContent {} 中调用 RememberName()把 MainActivity 里原本模板生成的显示内容,换成对 RememberName() 的调用。你可以参考下面这种写法:
如果你的模板代码和这里不完全一样,也没关系。重点只有一个:
把刚刚写好的 composable 放进 app 的入口界面中运行。
Step 1.4:理解这一步真正学到的东西 这一小段功能很简单,但它其实把 Compose 交互 UI 的核心模式已经讲完了:
用户操作组件
组件把新值传给 onValueChange
你把新值写回 state
state 改变,Compose 触发 recomposition
界面重新读取最新 state 并显示出来
Task 2: 比较两种 MutableState 写法,并观察不用 state 的区别
Step 2.1:把 state 改成不用 by 的写法 这里仍然是在使用 Compose state,但你不再通过 by 委托直接拿到里面的字符串值,而是直接拿到了一个 MutableState<String> 对象本身。
这是官方 Lab 要你对比的重点: 同样是 state,语法可以不同。
Step 2.2:用 .value 修复所有报错 改完之后,原来直接使用 name 的地方会报错,因为现在的 name 不再是 String,而是 MutableState<String>。
因为现在 name 是一个 state 对象,所以必须通过 .value 读取和修改里面真正的字符串内容。
if (name.value.isNotEmpty())
text = "Hello, ${name.value}!"
value = name.value
onValueChange = { name.value = it }
为什么字符串插值要写成 ${name.value}
因为 .value 是一个表达式,放在字串里时要用 ${...} 包起来。
项目重新编译通过
输入名字后仍然能正常显示欢迎文字
功能表现和上一版一致
Step 2.3:故意改成普通变量,观察差异 接下来再把 state 注释掉,故意改成普通变量:
这时候你的 RememberName() 大致会变成这种形式:
这里是在验证: 不是所有变量都能驱动 Compose UI。
输入内容无法正确保留
UI 不会稳定显示你刚输入的内容
欢迎文字不会按预期更新
实验问题回答
问题 1:把 var name by remember { mutableStateOf("") } 改成 var name = remember { mutableStateOf("") } 后,为什么会报错?怎么修? 因为第一种写法使用了 by 委托, name 可以直接当成 String 使用;而第二种写法没有使用 by,所以 name 本身是一个 MutableState<String> 对象,不是字符串。要修复错误,就必须把所有读取或写入 name 的地方改成使用 name.value。
问题 2:为什么把 state 改成普通变量后,输入框表现会不一样? 因为普通变量不是 Compose 的 observable state,变量值变化时不会通知 Compose 重新绘制界面。同时,composable 每次重组时普通局部变量都会重新初始化,所以输入内容无法被正确保留。 remember { mutableStateOf(...) } 的作用就是让状态既能被观察,又能在重组时保留。
Task 3: 实现 RandomNumberGenerator新建一个 RandomNumberGenerator() composable
页面包含一个输入最大值的 TextField
包含一个按钮,点击后随机生成 1 到最大值之间的数字
页面用 Text 显示随机结果
max 和 result 都要用 remember {} + mutableStateOf() 管理
这是原本分组完成的题,这里整理成可直接运行的版本
Step 3.1:准备本任务常用 import 如果 Android Studio 没有自动导入,可补充下面这些:
Step 3.2:编写 RandomNumberGenerator() composable 建议直接使用下面这个版本,输入更稳,不容易因为空字串或非数字导致错误:
这里把 Task 1 的“输入框 + state + 动态更新”模式扩展到了一个更完整的交互功能里:
maxInput 负责记住用户输入的最大值
result 负责记住按钮点击后生成的随机结果
点击按钮时读取输入,转换成数字,再随机生成结果
TextField 的 value 本来就应该是 String
用户输入过程中可能会出现空字串、删空、还没输完等情况
所以更推荐把输入框 state 先存成字串,等按钮点击时再做数字转换
maxInput by remember { mutableStateOf("10") }
默认最大值先显示为 10
result by remember { mutableStateOf<Int?>(null) }
还没生成前,结果为空
toIntOrNull()
安全转换,避免输入非法内容时直接崩溃
coerceAtLeast(1)
保证最大值至少是 1,避免随机范围无效
Random.nextInt(1, max + 1)
生成 1 到 max 之间的随机整数,右边界要加 1
Step 3.3:把 setContent {} 暂时切换成 RandomNumberGenerator()
Step 3.4:如果你想更贴近老师给的草稿思路 老师给的思路是: max 和 result 都可以直接作为 state 保存。本质没有问题,只是 TextField 最终还是要显示字符串,所以会不断做 Int ↔︎ String 转换。
哪些值需要成为 state
什么时候读取 state
什么时候更新 state
更新 state 以后 UI 为什么会自动变化
Task 4: Additional exercise — DatePicker新建一个 DisplayDatePicker() composable
页面显示一个只读的生日输入框
点击输入框或尾部图标时,弹出 DatePickerDialog
确认后把所选日期格式化后显示在输入框中
这是额外练习,但对后续 assignment 很有参考价值
Step 4.1:先处理图标问题 这意味着你需要有一个名为 calendar_icon 的 drawable 资源。
Step 4.2:补齐本任务常用 import 如果 Android Studio 没有自动导入,可以补充下面这些:
Step 4.3:编写 DisplayDatePicker() composable 这里把“输入值不是直接靠键盘打,而是通过弹窗组件选择后再写回 state”的模式跑通了。
因为你后面做 registration form、booking form、assignment 表单时,经常会遇到“用户不该随便乱打,而是应该从受控组件里选值”的场景。 DatePicker 就是这种典型案例。
birthday
用来保存最终显示在输入框里的日期文本
showDatePicker
控制 dialog 要不要显示
selectedDate
记录当前选中的日期毫秒值
rememberDatePickerState(...)
创建 Compose DatePicker 使用的 state
readOnly = true
不让用户直接手输,避免格式错误
clickable { showDatePicker = true }
点击输入框或图标时打开日期选择器
confirmButton
点击 OK 后,把选中的日期格式化成字串,再写回 birthday
Step 4.4:切换 setContent {} 并测试两种输入方式 把 setContent {} 里当前显示的 composable 临时换成:
页面出现一个 Birthday 输入框
点击输入框后会跳出日期选择器
选中日期并按 OK 后,输入框里显示格式化结果
再次打开时,依旧能继续交互
Lab 里提到“有两种方式输入日期”,你至少要试一下:
方式 1:直接在日历上点选日期 方式 2:在 DatePicker 内切换到文本输入模式再输入日期
Summary 这周真正要学会的,不是某一个 TextField 或某一个 DatePicker 组件,而是 Compose 写交互界面的核心思路:
RememberName() 让你看到了最基本的状态绑定;
by 和 .value 的对比让你看到了 state 的两种访问方式;
普通变量的失败案例让你知道为什么 Compose 里的“可观察状态”这么重要;
RandomNumberGenerator() 和 DatePicker 则把同样的模式扩展到了更完整的交互场景。
只要你把这条主线真正吃透,后面无论是登录表单、注册表单、计算器、小工具还是 assignment 里的交互页面,写法都会越来越顺。
本周必须理解的知识点
1. remember {} 是干什么的 remember {} 的作用是:在 composable 重组时,把里面创建的对象“记住”,不要每次都重新初始化。
如果没有 remember,那么每次重组时你声明的值都可能回到初始值。
2. mutableStateOf() 是干什么的 mutableStateOf() 会创建一个 Compose 可观察的状态对象 。
当它的值发生变化时,Compose 就知道要重新执行依赖这个 state 的 UI。
3. 为什么两者通常要一起用 这个值是可观察的,会触发 UI 更新
这个值在重组时不会丢失
4. 什么是 recomposition Compose 发现某个被 UI 使用的 state 变了,于是重新执行相关的 composable,并用最新数据重画界面。
5. 为什么 TextField 特别适合拿来学 state 用户输入字符
onValueChange 收到新值
你把新值写回 state
UI 立刻显示新的内容
关键概念对比
remember {} vs mutableStateOf()组合起来才形成本周最常见的 Compose state 模式。
by 写法 vs .value 写法
普通变量 vs Compose state
常见报错与排查
1. getValue / setValue 报 unresolved reference
2. 改成不用 by 之后到处报错 你是不是把 name 直接当字符串用了
这时应该统一改成 name.value
3. TextField 报类型不匹配
4. 随机数生成器点击按钮后崩溃 输入框是不是空的
输入的是不是非数字
最大值是不是小于 1
你有没有直接用 toInt() 而不是 toIntOrNull()
5. calendar_icon 找不到
6. 选完日期后没有显示
老师可能会问什么
1. 为什么 Compose 里输入框一般要绑定 state? 因为输入框的值要由 UI 状态驱动。用户输入时更新 state,Compose 再根据最新 state 重新显示界面,这样数据和界面才能保持一致。
2. 为什么 remember 很重要? 因为 composable 会被重新执行。如果不 remember,你定义的值在重组时可能会被重新初始化,导致用户输入内容丢失。
3. mutableStateOf() 为什么叫 observable state? 因为 Compose 会观察它的变化。只要它变了,依赖它的 UI 就会被重新组合并更新。
4. by 和 .value 到底是什么关系? by 是委托写法,让你可以像普通变量一样直接读写;
不用 by 时,就必须手动通过 .value 访问里面的值。
5. 为什么普通 var name = "" 不行? 因为它不是 Compose state,值变了不会触发 recomposition,而且 composable 重组时它还会重新初始化。
可直接交作业的简短总结 本周实验通过 TextField、随机数生成器和 DatePicker 练习了 Jetpack Compose 的状态管理。核心是使用 remember { mutableStateOf(...) } 保存可观察状态,让用户输入或按钮点击后能自动触发 recomposition 并更新 UI。实验还对比了 by 与 .value 两种 state 写法,并说明了普通变量无法替代 Compose state,因为它既不会被观察,也不能在重组时可靠地保留数据。
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @Composable fun RememberName() { // 定义一个可被 Compose 观察的状态变量 name // 初始值是空字符串 "" // remember: 让这个值在界面重组时还能保留 // mutableStateOf: 创建一个“可变状态” // by: 让我们后面可以直接把 name 当普通字符串来用 var name by remember { mutableStateOf( "" ) } // Column 表示把里面的组件“竖着排列” // padding(16.dp) 表示整个内容四周留 16dp 的内边距 Column(modifier = Modifier.padding(16.dp)) { // Spacer 是一个“空白占位组件” // 这里用来在顶部留出 32dp 的空白,让界面不要太挤 Spacer(modifier = Modifier.height(32.dp)) // 如果 name 不是空字符串,就显示问候语 // isNotEmpty() 的意思是“字符串不为空” if (name.isNotEmpty()) { Text( // 把用户输入的名字显示出来 // 例如输入 Tom,这里就显示 Hello, Tom! text = "Hello, $name!" , // 让这段文字和下面输入框之间留一点距离 modifier = Modifier.padding(bottom = 16.dp), // 设置文字大小为 32sp fontSize = 32.sp ) } // OutlinedTextField 是一个带边框的输入框 OutlinedTextField( // 输入框当前显示的内容来自 name // 也就是说,输入框显示什么,由状态 name 决定 value = name, // 当用户在输入框里输入新内容时, // it 就代表“最新输入的字符串” // 把它赋值给 name,状态一变,界面就会自动刷新 onValueChange = { name = it }, // 输入框上方的小标签文字 label = { Text( "Name" ) } ) } } onValueChange = { name = it } onValueChange = { newName -> name = newName } setContent { TextFieldLabTheme { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { RememberName() } } } var name by remember { mutableStateOf( "" ) } var name = remember { mutableStateOf( "" ) } @Composable fun RememberName() { var name = remember { mutableStateOf( "" ) } Column(modifier = Modifier.padding(16.dp)) { Spacer(modifier = Modifier.height(32.dp)) if (name. value .isNotEmpty()) { Text( text = "Hello, ${ name.value } !" , modifier = Modifier.padding(bottom = 16.dp), fontSize = 32.sp ) } OutlinedTextField( value = name. value , onValueChange = { name. value = it }, label = { Text( "Name" ) } ) } } @Composable fun RememberName() { var name = "" Column(modifier = Modifier.padding(16.dp)) { Spacer(modifier = Modifier.height(32.dp)) if (name.isNotEmpty()) { Text( text = "Hello,$name!" , modifier = Modifier.padding(bottom = 16.dp), fontSize = 32.sp ) } OutlinedTextField( value = name, onValueChange = { name = it }, label = { Text( "Name" ) } ) } } import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardOptions import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import kotlin.random.Random @Composable fun RandomNumberGenerator() { // maxInput:保存输入框里的内容 // 注意这里是字符串,不是 Int // 因为 TextField 里的内容本质上就是文字 // 初始值设为 "10",所以输入框默认会显示 10 var maxInput by remember { mutableStateOf( "10" ) } // result:保存随机生成出来的结果 // Int? 表示它可以是整数,也可以是 null // 一开始还没点击按钮,所以先设为 null var result by remember { mutableStateOf<Int?>(null) } // Column:把里面的组件从上到下排列 Column( // 整体四周留 16dp 空白 modifier = Modifier.padding(16.dp), // 垂直方向尽量居中 verticalArrangement = Arrangement.Center, // 水平方向居中 horizontalAlignment = Alignment.CenterHorizontally ) { // 顶部加一块 40dp 的空白,让界面别太挤 Spacer(modifier = Modifier.height(40.dp)) // 带边框的输入框 OutlinedTextField( // 输入框当前显示的内容来自 maxInput value = maxInput, // 当用户修改输入框内容时,把新内容保存回 maxInput // it 就是用户最新输入的字符串 onValueChange = { maxInput = it }, // 输入框标签 label = { Text( "Max" ) }, // 提示系统尽量弹出数字键盘 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), // 输入框横向占满宽度 modifier = Modifier.fillMaxWidth() ) // 输入框和按钮之间留一点空白 Spacer(modifier = Modifier.height(16.dp)) // 按钮:点击后生成随机数 Button( onClick = { // 先尝试把输入框字符串转成 Int // toIntOrNull():转换失败不会报错,而是返回 null // ?.coerceAtLeast(1):如果成功转成数字,就保证它至少为 1 // ?: 10:如果前面是 null(例如空输入),就默认用 10 val max = maxInput.toIntOrNull()?.coerceAtLeast(1) ?: 10 // 生成 1 到 max 之间的随机整数 // 注意 nextInt 的第二个参数是“上限但不包含”,所以要写 max + 1 result = Random.nextInt(1, max + 1) }, // 按钮横向占满宽度 modifier = Modifier.fillMaxWidth() ) { // 按钮上的文字 Text( "Random Number" ) } // 按钮和结果文字之间留一点空白 Spacer(modifier = Modifier.height(24.dp)) // 显示结果 Text( // 如果 result 不是 null,就显示数字 // 如果还是 null(说明还没点按钮),就显示 "-" text = "Result: ${ result ?: "-" } " , // 使用较大的正文样式 style = MaterialTheme.typography.bodyLarge ) } } setContent { TextFieldLabTheme { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { RandomNumberGenerator() } } } import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.material3.rememberDatePickerState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import java.text.SimpleDateFormat import java.util.Calendar import java.util.Date import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable fun DisplayDatePicker() { // 获取当前日历对象,后面可以拿到“当前时间”的毫秒值 val calendar = Calendar.getInstance() // 定义日期显示格式,例如 2025-01-15 val formatter = SimpleDateFormat( "yyyy-MM-dd" , Locale.getDefault()) // birthday:输入框里最终显示的生日文本 // 例如 "DoB:2025-01-15" // 一开始先设为空字符串 var birthday by remember { mutableStateOf( "" ) } // showDatePicker:控制日期选择弹窗是否显示 // false = 不显示 // true = 显示 var showDatePicker by remember { mutableStateOf(false) } // selectedDate:当前选中的日期,内部用毫秒值保存 // 初始值先设成“当前时间” var selectedDate by remember { mutableStateOf(calendar.timeInMillis) } // DatePicker 自己需要的状态对象 // initialSelectedDateMillis 表示一开始默认选中的日期 val datePickerState = rememberDatePickerState( initialSelectedDateMillis = selectedDate ) // Column:把组件从上到下排列 Column(modifier = Modifier.padding(50.dp)) { // 显示生日的输入框 TextField( // 输入框当前显示的内容由 birthday 决定 value = birthday, // 这里不给用户手动输入,所以留空 onValueChange = {}, // 只读,不能直接编辑 readOnly = true, // 输入框标签 label = { Text( "Birthday" ) }, modifier = Modifier // 横向占满宽度 .fillMaxWidth() // 点击输入框时,打开日期选择器 .clickable { showDatePicker = true }, // 输入框右边的图标 trailingIcon = { Icon( // 加载 drawable 里的日历图标资源 painter = painterResource(id = R.drawable.calendar_icon), // 无障碍说明文字 contentDescription = "Select Date" , modifier = Modifier // 点击图标也可以打开日期选择器 .clickable { showDatePicker = true } // 设置图标大小 .size(40.dp) ) } ) // 只有当 showDatePicker 为 true 时,才显示日期选择弹窗 if (showDatePicker) { DatePickerDialog( // 当用户用系统方式关闭弹窗时,也要把状态改回 false onDismissRequest = { showDatePicker = false }, // 确认按钮:点击 OK confirmButton = { TextButton( onClick = { // 先关闭弹窗 showDatePicker = false // 取出用户在 DatePicker 中选中的日期 // 如果为空,就继续保留原来的 selectedDate selectedDate = datePickerState.selectedDateMillis ?: selectedDate // 把选中的日期格式化成字符串,再显示到输入框里 birthday = "DoB: ${ formatter.format(Date(selectedDate)) } " } ) { Text( "OK" ) } }, // 取消按钮:点击 Cancel dismissButton = { TextButton( onClick = { // 只关闭弹窗,不修改生日内容 showDatePicker = false } ) { Text( "Cancel" ) } } ) { // 真正的日期选择器内容 DatePicker(state = datePickerState) } } } } remember { mutableStateOf(...) } var name by remember { mutableStateOf( "" ) } var name = remember { mutableStateOf( "" ) } var name by remember { mutableStateOf( "" ) } import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue val max = maxInput.toIntOrNull()?.coerceAtLeast(1) ?: 10