27、导航与表单:多步骤表单的导航、保存表单草稿、表单验证与跳转控制
多步骤表单,说白了就是让用户分几页填完一个完整表单。比如注册流程、订单填写、个人资料编辑——这些场景在App里太常见了。用Navigation组件来做,你会发现流程控制变得特别清晰。
我个人习惯把每个步骤定义为一个独立的Fragment,然后用Safe Args传递数据。这样每个Fragment只关心自己那一步的逻辑,互不干扰。
27.1 多步骤表单的导航设计
先看一个典型的例子:用户注册流程,分三步——填写基本信息、验证手机号、设置密码。
在nav_graph.xml里,我会这样定义:
<navigation
android:id="@+id/registration_graph"
app:startDestination="@id/step1BasicInfoFragment">
<fragment
android:id="@+id/step1BasicInfoFragment"
android:name=".Step1BasicInfoFragment">
<action
android:id="@+id/action_step1_to_step2"
app:destination="@id/step2VerifyPhoneFragment" />
</fragment>
<fragment
android:id="@+id/step2VerifyPhoneFragment"
android:name=".Step2VerifyPhoneFragment">
<action
android:id="@+id/action_step2_to_step3"
app:destination="@id/step3SetPasswordFragment" />
<action
android:id="@+id/action_step2_to_step1"
app:destination="@id/step1BasicInfoFragment"
app:popUpTo="@id/step1BasicInfoFragment" />
</fragment>
<fragment
android:id="@+id/step3SetPasswordFragment"
android:name=".Step3SetPasswordFragment">
<action
android:id="@+id/action_step3_to_step2"
app:destination="@id/step2VerifyPhoneFragment"
app:popUpTo="@id/step2VerifyPhoneFragment" />
<action
android:id="@+id/action_step3_to_complete"
app:destination="@id/completeFragment" />
</fragment>
</navigation>
注意看,返回上一步时我用了popUpTo。为什么?因为如果不pop,用户按返回键就会一层层退回去,体验很差。加上popUpTo并指定目标,就能直接回到上一步,同时清除中间状态。
app:popUpTo,并且 app:popUpToInclusive="false"。这样用户点击返回时,不会看到空白页面。
27.2 保存表单草稿
用户填到一半突然接电话、切后台、甚至App被杀了——数据丢了怎么办?
我在项目中遇到过这种情况:用户填了十几项信息,一个电话进来,回来发现全没了。从那以后,我坚持用ViewModel + SavedStateHandle来做草稿保存。
看代码:
class RegistrationViewModel : ViewModel() {
// 使用 SavedStateHandle 保存草稿
private val savedStateHandle = SavedStateHandle()
val basicInfo = savedStateHandle.getLiveData<BasicInfo>("basic_info")
val phoneVerification = savedStateHandle.getLiveData<PhoneVerification>("phone_verification")
val password = savedStateHandle.getLiveData<Password>("password")
fun saveBasicInfo(name: String, email: String) {
savedStateHandle.set("basic_info", BasicInfo(name, email))
}
fun savePhoneVerification(phone: String, code: String) {
savedStateHandle.set("phone_verification", PhoneVerification(phone, code))
}
fun savePassword(passwordStr: String) {
savedStateHandle.set("password", Password(passwordStr))
}
fun clearDraft() {
savedStateHandle.remove("basic_info")
savedStateHandle.remove("phone_verification")
savedStateHandle.remove("password")
}
}
SavedStateHandle的好处是——进程被杀了也能恢复。它内部自动序列化到Bundle,Activity重建时自动恢复。你想想看,这比手动写SharedPreferences省心多了。
27.3 表单验证与跳转控制
验证逻辑放在哪里?我建议放在ViewModel里。Fragment只负责展示错误信息,不负责判断对错。
来看一个完整的验证流程:
class Step1BasicInfoFragment : Fragment() {
private val viewModel: RegistrationViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.btnNext.setOnClickListener {
val name = binding.etName.text.toString()
val email = binding.etEmail.text.toString()
// 调用 ViewModel 验证
val result = viewModel.validateBasicInfo(name, email)
if (result.isSuccess) {
viewModel.saveBasicInfo(name, email)
findNavController().navigate(R.id.action_step1_to_step2)
} else {
// 显示错误
showErrors(result.errors)
}
}
}
}
ViewModel里的验证逻辑:
class RegistrationViewModel : ViewModel() {
fun validateBasicInfo(name: String, email: String): ValidationResult {
val errors = mutableListOf<FieldError>()
if (name.isBlank()) {
errors.add(FieldError("name", "姓名不能为空"))
} else if (name.length < 2) {
errors.add(FieldError("name", "姓名至少2个字符"))
}
if (email.isBlank()) {
errors.add(FieldError("email", "邮箱不能为空"))
} else if (!android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches()) {
errors.add(FieldError("email", "邮箱格式不正确"))
}
return if (errors.isEmpty()) {
ValidationResult.success()
} else {
ValidationResult.failure(errors)
}
}
}
这里有个细节:验证结果用sealed class封装,比返回布尔值更灵活。你可以携带错误字段名和错误信息,Fragment拿到后直接定位到对应的输入框。
navigate(),而是通过ViewModel返回结果,由Fragment决定是否跳转。这样测试起来也方便。
27.4 多步骤表单的完整流程图
下面这张图展示了整个流程:从第一步开始,验证通过才能前进,验证失败则停留在当前步骤并显示错误。每一步的数据都会保存到ViewModel中,最终提交时统一处理。
27.5 草稿恢复与生命周期处理
用户退出App再回来,怎么恢复草稿?
在Fragment的onViewCreated里检查SavedStateHandle是否有数据:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// 恢复草稿
viewModel.basicInfo.observe(viewLifecycleOwner) { info ->
if (info != null) {
binding.etName.setText(info.name)
binding.etEmail.setText(info.email)
}
}
}
这里有个坑:SavedStateHandle的LiveData在Activity重建后会自动恢复,但如果你在Fragment的onCreate里观察,可能会收到旧数据。我建议在onViewCreated里观察,确保视图已经准备好。
clearDraft() 清除草稿。不然下次打开App,发现上次填的数据还在,用户会一脸懵。
27.6 跳转控制的边界情况
实际项目中,跳转控制不只是「验证通过就跳」这么简单。还有几种情况要处理:
- 用户点击返回键:应该回到上一步,并且保留已填数据。Navigation默认支持返回栈,但你要确保ViewModel里的数据不被清除。
- 用户从步骤3直接跳到步骤1:比如点击了「修改基本信息」。这时候要用
popUpTo清除中间步骤,避免返回时出现混乱。 - 用户切换App再回来:如果App被系统回收,SavedStateHandle会自动恢复数据。但如果用户长时间不操作,我建议在
onStop里主动保存一次。
我曾经遇到一个bug:用户在步骤2验证手机号,收到短信后切到短信App查看验证码,回来发现步骤1的数据丢了。原因是我在onSaveInstanceState里没有保存ViewModel的状态。后来改用SavedStateHandle,问题就解决了。
onSaveInstanceState 来保存表单数据。它只保存视图状态,不保存ViewModel里的业务数据。用SavedStateHandle才是正解。
好了,多步骤表单的导航、草稿保存、验证与跳转控制,核心就是这些。记住一个原则:数据在ViewModel里统一管理,导航由验证结果驱动,草稿靠SavedStateHandle保底。这样写出来的代码,既稳定又容易维护。