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省心多了。

⚠️ 注意: SavedStateHandle 存储的数据不能太大。如果表单包含图片或大段文本,建议用 Room 数据库保存草稿。我曾经有个项目,用户上传多张图片,用 SavedStateHandle 直接崩了——因为 Bundle 有 1MB 限制。

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拿到后直接定位到对应的输入框。

🔑 关键点: 跳转控制的核心是——验证通过才允许导航。不要在Fragment里直接写 navigate(),而是通过ViewModel返回结果,由Fragment决定是否跳转。这样测试起来也方便。

27.4 多步骤表单的完整流程图

下面这张图展示了整个流程:从第一步开始,验证通过才能前进,验证失败则停留在当前步骤并显示错误。每一步的数据都会保存到ViewModel中,最终提交时统一处理。

多步骤表单导航与验证流程 步骤1:基本信息 姓名、邮箱 验证通过 → 下一步 验证失败 显示错误信息 停留在当前步骤 验证通过 步骤2:验证手机 手机号、验证码 验证通过 → 下一步 验证失败 显示错误信息 停留在当前步骤 验证通过 步骤3:设置密码 密码、确认密码 验证通过 → 完成 验证失败 显示错误信息 停留在当前步骤 全部验证通过 提交成功 清除草稿 返回上一步 返回上一步 每一步的数据自动保存到 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,问题就解决了。

⚠️ 注意: 不要依赖Fragment的 onSaveInstanceState 来保存表单数据。它只保存视图状态,不保存ViewModel里的业务数据。用SavedStateHandle才是正解。

好了,多步骤表单的导航、草稿保存、验证与跳转控制,核心就是这些。记住一个原则:数据在ViewModel里统一管理,导航由验证结果驱动,草稿靠SavedStateHandle保底。这样写出来的代码,既稳定又容易维护。