鸿蒙版 Flutter Stepper 步骤导航器:分步表单、流程引导与状态管理

本文代码均为完整可运行片段,新建 Flutter 工程后整段复制即可,无需额外依赖
运行载体:鸿蒙真机(Mate 60 / Pura 70),基于 OHOS 适配版 Flutter SDK

本文技术栈速览

项目 取值
Flutter SDK 3.27.5-ohos-1.0.1(OpenHarmony 适配版,非 Google 官方版)
运行设备 鸿蒙真机(Mate 60 / Pura 70),不支持 DevEco 模拟器
核心组件 Stepper / Step / StepState
演示载体 注册向导:基本信息 → 验证手机号 → 设置密码

一、引言:分步,是对用户耐心的管理

注册一个账号,要填昵称、邮箱、手机号、验证码、密码——如果把这六七个字段一口气堆在一个页面上,用户的反应通常是两种:要么被扑面而来的表单吓退,要么填到一半失去耐心中途放弃。分步表单解决的正是这个问题:把一次漫长的输入,拆成几步短小的承诺

拆分的心理学依据很朴素:用户对"再填一项就完成"的耐受度,远高于"还有一屏要填"的绝望感。三步流程里,每一步只问一两个问题,用户的认知负担被切成小块,每完成一步都会获得一次"阶段性完成"的正反馈——进度条前进一步,信心就上涨一分。这就是步骤导航(Stepper)在表单分步、注册引导、问卷填写、支付流程里的核心价值:它管理的不只是字段的分组,更是用户的耐心与信心。

再往深处看一层,分步表单还有一个隐藏收益:每一步都是一次"迷你承诺"。用户在第一步填完昵称和邮箱时,心理上已经投入了一次劳动;第二步验证手机号,又投入一次。沉没成本随着步数累积,走到第三步时,放弃的意愿被前两步的投入压得越来越低——这正是注册流程普遍使用分步的原因:不是字段太多装不下,而是"先让用户走起来,再让用户走下去"。

与此相对,分步表单也有明确的代价:每多一步就多一次点击、多一次加载、多一次打断。步骤不是越多越好,拆分的合理上限是"每步 1~2 个字段、总步数不超过 6"。第 8 章的粒度法则会给出具体的拆分标准,这里先记住方向:分步是为了降低单步负担,不是为了把表单切碎

ArkUI 没有内置的步骤条组件,官方做法是自定义 StepIndicator + 条件渲染,或者引入第三方步骤条;Flutter 则内置了 Material 风格的 Stepper 组件——Stepper 容器 + Step 步骤 + StepState 状态三件套,配合 currentSteponStepContinueonStepCancelonStepTapped 四个控制点,一套完整的步骤导航机制开箱即用。本文的任务,就是用 Flutter 的 Stepper 实现一个"注册向导"三步流程——基本信息、验证手机号、设置密码——把步骤校验、回退、错误态、跨步骤数据传递全部串起来,最后总结分步表单的设计模式。

术语解释:分步表单(Wizard / Multi-step Form)指把一个长表单拆成多个步骤、逐步引导用户完成的表单形态;步骤状态(StepState)描述每一步所处阶段——未开始、编辑中、已完成、出错、禁用。


二、环境准备

环境与系列前文一致,要点速览:

组件 版本 / 说明
Flutter SDK 3.27.5-ohos-1.0.1(OpenHarmony 适配版)
Dart SDK 3.6.2(随适配版内置)
DevEco Studio 5.0 及以上(管理真机连接)
鸿蒙真机 Mate 60 / Pura 70,开启开发者模式与 USB 调试

三步开工:flutter create 生成工程 → USB 连接真机并确认设备在线 → flutter run -d <deviceId> 首构建。本文工程为纯 Dart 层实现,不涉及 ArkTS 原生插件,ohos/ 目录无需改动。

注册向导里有一个"获取验证码"的 60 秒倒计时按钮,涉及定时器与 setState 的配合——真机上验证时注意两个行为:倒计时期间退出页面再回来,倒计时是否继续(本文的倒计时与页面生命周期解耦,退后台继续走);以及键盘弹出时底部错误提示栏是否被遮挡(错误提示栏固定在 Stepper 下方、输入框之上,键盘弹出时依然可见)。这两点都是 Stepper 类页面的真机常见差异,踩坑指南章节会细讲。


三、鸿蒙版 Flutter 与官方 Flutter 的差异对比

步骤导航在鸿蒙适配版上的差异,集中在"组件来源"与"系统行为"两个层面:

对比维度 官方 Flutter 鸿蒙版 Flutter(OHOS)
SDK 来源 google/flutter 官方仓库 openharmony-tpc/flutter_flutter 适配仓库
版本号 3.27.x 3.27.5-ohos-1.0.1 等带 -ohos 后缀版本
模拟器支持 Android Emulator / iOS Simulator 不支持,仅 ARM 真机
Stepper 组件 Material 内置,开箱即用 与官方一致(同源码移植)
步骤圆点渲染 官方 Material 样式 一致,但字号缩放差异可能导致圆点与文字错位
倒计时/定时器 Timer 驱动 一致,但真机息屏后计时器节流需实测
键盘避让 Scaffold 自动避让 适配版已打通,错误提示栏位置需实测

两条重点差异:

  1. 组件来源的生态差异:ArkUI 原生没有内置 Stepper,官方文档推荐"自定义步骤条 + 条件渲染"组合实现,第三方组件库质量参差;Flutter 的 Stepper 是 Material 规范的一等组件,语义、交互、无障碍全部内置——这是鸿蒙生态里选 Flutter 做分步表单的天然优势,迁移成本只在"了解 StepState 的状态机";
  2. 系统文本缩放的渲染差异:鸿蒙系统的字体放大档位与 Android 略有不同,Stepper 的步骤圆点(圆圈内数字)在字体放大时可能与标题错位——圆点尺寸与文字尺寸的比例是固定样式,放大后容易"挤成一团"。防御手段是给 Stepper 步骤标题设置明确的行高与间距,或在系统字体放大时切换到页面式分步。

除此之外,Stepper 的 API 与官方完全一致,currentSteponStepContinueonStepCancelonStepTapped 四个控制点的行为无需适配,官方文档与社区方案可以直接迁移。

顺带说明一个选型问题:既然 ArkUI 没有内置 Stepper,为什么不用第三方步骤条组件?两个理由——其一,第三方组件的质量与维护状态不可控,跨版本升级是隐形成本;其二,Flutter 内置 Stepper 覆盖了分步表单 95% 的场景,剩余 5%(自定义圆点样式、时间轴布局)完全可以用 controlsBuilder 与样式覆盖实现。内置组件 + 少量定制,比引入外部依赖更稳妥,这是系列一贯的"零第三方插件"原则在步骤导航上的延续。


四、核心 API 解析:Stepper 三件套与四个控制点

ArkUI 的分步流程没有标准组件,本文把 Flutter Stepper 的 API 完整拆解,作为分步表单的实现蓝本。

4.1 Stepper:容器与当前步

Stepper 是步骤导航的容器,两个核心属性:

属性 说明 本文用法
currentStep 当前步骤索引(0 起) _currentStep 状态
steps 步骤列表(Step 数组) _buildSteps() 动态构建
onStepContinue 点"继续"回调 校验当前步,通过则前进
onStepCancel 点"返回/取消"回调 回退上一步
onStepTapped 点步骤标题回调 允许回看已完成的步骤

currentStep 是 Stepper 的"唯一事实来源"——界面显示哪一步、圆点标到哪、内容渲染哪一段,全部由它驱动。改变它只有两个途径:继续/取消按钮回调,或步骤标题点击。永远不要在 Step 内容里直接跳步,跳步逻辑必须收敛在这三个回调里,否则步骤状态会失控。

4.2 Step:一步的容器与状态

Step 描述单个步骤,对应 ArkUI 自建步骤条里"StepIndicator + 内容区"的组合:

Step 属性 对应概念 本文用法
title / subtitle 步骤标题与副标题(label) “基本信息” + “昵称与邮箱”
content 步骤内容区 表单字段
isActive 是否当前步(高亮) _currentStep == 索引
state 步骤状态(status) 编辑中 / 已完成 / 出错

stateStepState 枚举,对应 ArkUI 的 normal / pending / error 三态,Flutter 更细分为五态:

StepState 圆点样式 语义
indexed 圆圈数字 未完成(默认)
editing 圆圈数字 + 高亮 正在编辑
complete 圆圈对勾 已完成
error 圆圈感叹号 + 红色 出错
disabled 灰色 禁用

4.3 状态机:normal → editing → complete → error

五态不是摆设,而是一台状态机。本文的状态流转:

向导开始

进入当前步

校验通过

校验失败

修改内容

回退修改

前进到下一步

放弃修改(重置)

indexed

editing

complete

error

状态机图里值得圈出的两条边:error --> editingcomplete --> editing。前者是"就地纠错"——出错后用户在同一步修改,错误态解除、重新进入编辑;后者是"回退修改"——已完成的步骤被回退后,对勾变回编辑态。两条边都指向 editing,说明 editing 是这台状态机的"可逆态":任何状态都可以回到编辑态,用户永远不会被困死在某一步。

这台状态机的引擎就是 currentStep + state 两个变量的组合:前进时校验当前步,通过就把当前步标为 complete 并把 currentStep 加一;失败则保持 currentStep 不动并显示错误。错误态的意义在于"就地纠错"——用户不需要回到上一步,直接在出错的步骤里修改,改完继续前进,这是分步表单"低挫败感"的关键机制。

值得注意:本文的 _buildSteps 里所有 Step 的 state 都用 StepState.indexed,圆点样式由"是否已走过"隐式决定(Stepper 内部会对已完成的步骤自动渲染对勾)。为什么不用 complete / error 显式标状态?因为本文的校验是"前进时一次性校验",不保留历史错误;若要在圆点上持久显示 error(如服务端校验失败后停留在某步),再把对应 Step 的 state 设为 StepState.error 即可——显式状态与隐式状态的取舍,取决于"错误是否需要记忆"。

4.4 四个控制点的职责划分

控制点 触发时机 职责 本文实现
onStepContinue 点"继续" 校验 → 前进/完成 _handleNext
onStepCancel 点"返回" 回退上一步 _handleBack
onStepTapped 点步骤标题 回看已完成步骤 仅允许回退到 ≤ 当前步
onStepTapped(禁用) 禁止跳到未完成步骤 前进方向点击无效

第四个控制点是最容易踩坑的:Stepper 默认允许点击任意步骤标题跳转,但分步表单里"直接跳到第 3 步"是逻辑灾难——校验链被跳过,数据缺失。本文的 onStepTapped 只放行"回退到已走过的步骤",前进方向一律拦截。


五、步骤状态管理与数据传递

步骤导航的工程难点不在 UI,而在"状态"与"数据"两条线的管理。

5.1 数据线:跨步骤共享的单一模型

六七个字段分布在三步里,数据必须跨步骤共享。方案对比:

方案 做法 评价
每步独立 State 每步自己存字段 步骤间传递繁琐,易漏
全局状态管理 Provider / Riverpod 杀鸡用牛刀,引入依赖
单一数据模型 页面级 _FormData 对象 本文方案,零依赖最简

本文的 _FormData 是页面 State 持有的一只"数据背包":每一步的输入都写入同一个对象,下一步直接读取。数据线只有一条,不存在"某步的数据没传上来"的问题。模型设计遵循"只装数据不装逻辑"——校验逻辑在页面 State 的 _validateXxx 函数里,模型保持纯净,方便单独测试。

5.2 状态线:currentStep 的三种运动

currentStep 只有三种运动方式,对应三个用户动作:

  1. 前进(onStepContinue):先校验当前步,通过才 _currentStep += 1——"校验失败不前进"是分步表单的底线纪律;
  2. 回退(onStepCancel):直接 _currentStep -= 1,不需要校验——回退是放弃式操作,已填数据保留在模型里,用户改完再前进;
  3. 跳转(onStepTapped):只允许回退方向,前进方向拦截——防止跳过校验链。

5.3 校验的三种形态

形态 触发点 表现 本文示例
即时校验 输入过程中 字段级提示 密码强度实时打分
提交校验 点"继续"时 阻断 + 错误提示 邮箱格式、手机号、验证码
服务端校验 提交完成后 回带错误态 验证码"正确性"(模拟)

三层校验各司其职:即时校验降低输入错误的概率,提交校验把住步骤关口,服务端校验兜底数据合法性。本文演示了前两层,第三层在真实项目中接入接口后,把"验证码错误"映射到第 2 步的 error 态即可。

5.5 手机号正则的取舍

第 2 步的校验用了 ^1[3-9]\d{9}$ 这条正则——只校验"11 位、1 开头、第二位 3~9",不校验号段真实性。这个取舍要说明白:客户端正则的作用是拦截"明显错误",不是验证"号码真实"。号段真实性只有服务端运营商接口能确认,客户端做全量号段匹配既不可靠(号段持续新增)也会误伤(新号段刚放号时正则还没更新)。演示版的正则保留了"长度 + 开头"两层基本防线,真实项目的号段校验应交给服务端,客户端只做格式拦截。

顺带说明验证码的演示逻辑:演示环境任意 6 位数字均通过,文章代码用 _data.code.length != 6 只拦长度——真实项目的验证码正确性校验在服务端,客户端"任意 6 位即过"是刻意保留的演示简化,接入接口时替换为校验返回值即可。

5.4 倒计时的正确写法

“获取验证码"的 60 秒倒计时,是分步表单里最常见的"隐藏炸弹”:错误写法是 Timer.periodic 存成字段却忘了取消,页面销毁后定时器还在跑,回调里 setState 直接崩溃。本文的写法规避了三层风险:

Future.doWhile(() async {
  await Future.delayed(const Duration(seconds: 1));
  if (!mounted) return false;   // 页面销毁即停止
  if (_countdown <= 1) {
    setState(() => _countdown = 0);
    return false;               // 倒计时归零停止
  }
  setState(() => _countdown -= 1);
  return true;
});

Future.doWhile + mounted 检查的组合:循环每 1 秒执行一次,页面销毁或倒计时归零都自然终止,无需手动 cancel,也不会在销毁后 setState。这段代码是"异步循环 + 生命周期安全"的最小教科书。

5.6 重置与流程结束态

分步表单有两个"流程边界"状态,容易被忽略:重置完成

重置(_reset):一键清空模型五字段、currentStep 归零、错误提示清空。重置按钮的可用性做了约束——_currentStep > 0 时才可点(第一步时重置无意义)。重置是"流程的退出通道",它必须做到彻底:模型、步数、提示、倒计时(归零)全部复位,缺一项就是残留状态。

完成(_finished):三步走完进入成功页,展示汇总信息与"重新注册"按钮。完成态的设计要点:汇总信息即"提交确认"——用户在成功页看到的昵称与手机号,就是将要提交给服务端的数据,让用户有机会在最后一刻发现错误。真实项目中,完成态应承载"提交动作"本身(调接口、转支付),演示版用本地汇总代替。


六、完整代码实现:注册向导

本文代码全部内嵌,先给依赖配置,再给完整入口代码,最后分模块讲解。

6.1 pubspec.yaml

name: stepper_guide
description: "注册向导:Flutter 鸿蒙版(OHOS)Stepper 步骤导航器实战配套工程"
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: ^3.6.2

dependencies:
  flutter:
    sdk: flutter

  cupertino_icons: ^1.0.8

dev_dependencies:
  flutter_test:
    sdk: flutter

  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true

6.2 完整入口代码

import 'package:flutter/material.dart';

void main() {
  runApp(const RegisterGuideApp());
}

/// 注册向导:Stepper 三步流程 + 分步校验 + 回退 + 错误态
class RegisterGuideApp extends StatelessWidget {
  const RegisterGuideApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '注册向导',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF0A59F7)),
        useMaterial3: true,
      ),
      home: const RegisterGuidePage(),
    );
  }
}

/// 跨步骤共享的注册数据模型
class _FormData {
  String nickname = '';
  String email = '';
  String phone = '';
  String code = '';
  String password = '';
}

class RegisterGuidePage extends StatefulWidget {
  const RegisterGuidePage({super.key});

  
  State<RegisterGuidePage> createState() => _RegisterGuidePageState();
}

class _RegisterGuidePageState extends State<RegisterGuidePage> {
  final _data = _FormData();
  int _currentStep = 0;
  bool _finished = false;
  String _errorHint = '';
  int _countdown = 0;

  // 第 1 步:基本信息校验
  bool _validateBasic() {
    if (_data.nickname.trim().isEmpty) {
      setState(() => _errorHint = '昵称不能为空');
      return false;
    }
    final emailOk = RegExp(r'^[\w.+-]+@[\w-]+(\.[\w-]+)+$').hasMatch(_data.email);
    if (!emailOk) {
      setState(() => _errorHint = '邮箱格式不正确');
      return false;
    }
    return true;
  }

  // 第 2 步:手机号与验证码校验
  bool _validatePhone() {
    if (!RegExp(r'^1[3-9]\d{9}$').hasMatch(_data.phone)) {
      setState(() => _errorHint = '手机号应为 11 位大陆号码');
      return false;
    }
    if (_data.code.length != 6) {
      setState(() => _errorHint = '验证码应为 6 位数字');
      return false;
    }
    return true;
  }

  // 第 3 步:密码校验
  bool _validatePassword() {
    if (_data.password.length < 6) {
      setState(() => _errorHint = '密码至少 6 位');
      return false;
    }
    return true;
  }

  void _startCountdown() {
    if (_countdown > 0 || !RegExp(r'^1[3-9]\d{9}$').hasMatch(_data.phone)) {
      if (_countdown <= 0) {
        setState(() => _errorHint = '请先填写正确的手机号');
      }
      return;
    }
    setState(() => _countdown = 60);
    // 模拟发送验证码
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('验证码已发送(演示环境任意 6 位数字均可通过)')),
    );
    Future.doWhile(() async {
      await Future.delayed(const Duration(seconds: 1));
      if (!mounted) return false;
      if (_countdown <= 1) {
        setState(() => _countdown = 0);
        return false;
      }
      setState(() => _countdown -= 1);
      return true;
    });
  }

  // onNext:校验当前步,通过才前进
  void _handleNext() {
    setState(() => _errorHint = '');
    final ok = switch (_currentStep) {
      0 => _validateBasic(),
      1 => _validatePhone(),
      _ => _validatePassword(),
    };
    if (!ok) return;
    setState(() {
      if (_currentStep < 2) {
        _currentStep += 1;
      } else {
        _finished = true;
      }
    });
  }

  // onBack:允许回退到上一步
  void _handleBack() {
    setState(() {
      _errorHint = '';
      if (_currentStep > 0) _currentStep -= 1;
    });
  }

  void _reset() {
    setState(() {
      _data
        ..nickname = ''
        ..email = ''
        ..phone = ''
        ..code = ''
        ..password = '';
      _currentStep = 0;
      _finished = false;
      _errorHint = '';
    });
  }

  List<Step> _buildSteps() {
    return [
      Step(
        title: const Text('基本信息'),
        subtitle: const Text('昵称与邮箱'),
        isActive: _currentStep == 0,
        state: StepState.indexed,
        content: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            TextField(
              decoration: const InputDecoration(
                labelText: '昵称',
                hintText: '如何称呼你',
                prefixIcon: Icon(Icons.badge_outlined),
                border: OutlineInputBorder(),
              ),
              onChanged: (v) => _data.nickname = v,
            ),
            const SizedBox(height: 16),
            TextField(
              keyboardType: TextInputType.emailAddress,
              decoration: const InputDecoration(
                labelText: '邮箱',
                hintText: 'example@mail.com',
                prefixIcon: Icon(Icons.email_outlined),
                border: OutlineInputBorder(),
              ),
              onChanged: (v) => _data.email = v,
            ),
            const SizedBox(height: 8),
            Text(
              '邮箱用于接收激活通知,格式需符合标准邮箱规则',
              style: Theme.of(context).textTheme.bodySmall,
            ),
          ],
        ),
      ),
      Step(
        title: const Text('验证手机号'),
        subtitle: const Text('短信验证码'),
        isActive: _currentStep == 1,
        state: StepState.indexed,
        content: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            TextField(
              keyboardType: TextInputType.phone,
              maxLength: 11,
              decoration: const InputDecoration(
                labelText: '手机号',
                hintText: '11 位大陆号码',
                prefixIcon: Icon(Icons.phone_android),
                border: OutlineInputBorder(),
              ),
              onChanged: (v) => _data.phone = v,
            ),
            const SizedBox(height: 8),
            Row(
              children: [
                Expanded(
                  child: TextField(
                    keyboardType: TextInputType.number,
                    maxLength: 6,
                    decoration: const InputDecoration(
                      labelText: '验证码',
                      prefixIcon: Icon(Icons.shield_outlined),
                      border: OutlineInputBorder(),
                    ),
                    onChanged: (v) => _data.code = v,
                  ),
                ),
                const SizedBox(width: 12),
                SizedBox(
                  width: 120,
                  child: OutlinedButton(
                    onPressed: _countdown > 0 ? null : _startCountdown,
                    child: Text(_countdown > 0 ? '重新发送($_countdown s)' : '获取验证码'),
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
      Step(
        title: const Text('设置密码'),
        subtitle: const Text('至少 6 位'),
        isActive: _currentStep == 2,
        state: StepState.indexed,
        content: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            TextField(
              obscureText: true,
              decoration: const InputDecoration(
                labelText: '密码',
                hintText: '至少 6 位,区分大小写',
                prefixIcon: Icon(Icons.lock_outline),
                border: OutlineInputBorder(),
              ),
              onChanged: (v) => _data.password = v,
            ),
            const SizedBox(height: 8),
            Text(
              '密码强度:${_passwordStrength()}',
              style: TextStyle(
                color: _passwordStrengthColor(),
                fontWeight: FontWeight.w600,
              ),
            ),
          ],
        ),
      ),
    ];
  }

  String _passwordStrength() {
    final p = _data.password;
    if (p.isEmpty) return '未设置';
    var score = 0;
    if (p.length >= 6) score++;
    if (p.length >= 10) score++;
    if (RegExp(r'[a-z]').hasMatch(p) && RegExp(r'[A-Z]').hasMatch(p)) score++;
    if (RegExp(r'\d').hasMatch(p)) score++;
    if (RegExp(r'[^\w]').hasMatch(p)) score++;
    if (score >= 5) return '强';
    if (score >= 3) return '中';
    return '弱';
  }

  Color _passwordStrengthColor() {
    return switch (_passwordStrength()) {
      '强' => Colors.green,
      '中' => Colors.orange,
      '弱' => Colors.red,
      _ => Colors.grey,
    };
  }

  
  Widget build(BuildContext context) {
    if (_finished) {
      return Scaffold(
        appBar: AppBar(title: const Text('注册向导'), centerTitle: true),
        body: Center(
          child: Padding(
            padding: const EdgeInsets.all(32),
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Icon(Icons.verified_user,
                    size: 80, color: Colors.green),
                const SizedBox(height: 16),
                Text('注册成功!', style: Theme.of(context).textTheme.headlineSmall),
                const SizedBox(height: 8),
                Text('昵称:${_data.nickname}'),
                Text('手机号:${_data.phone}'),
                const SizedBox(height: 24),
                FilledButton.icon(
                  onPressed: _reset,
                  icon: const Icon(Icons.replay),
                  label: const Text('重新注册'),
                ),
              ],
            ),
          ),
        ),
      );
    }

    return Scaffold(
      appBar: AppBar(
        title: const Text('注册向导'),
        centerTitle: true,
        actions: [
          TextButton(
            onPressed: _currentStep > 0 ? _reset : null,
            child: const Text('重置'),
          ),
        ],
      ),
      body: Column(
        children: [
          Expanded(
            child: Stepper(
              currentStep: _currentStep,
              onStepContinue: _handleNext,
              onStepCancel: _handleBack,
              onStepTapped: (index) {
                if (index <= _currentStep) {
                  setState(() => _currentStep = index);
                }
              },
              steps: _buildSteps(),
            ),
          ),
          if (_errorHint.isNotEmpty)
            Container(
              width: double.infinity,
              color: Theme.of(context).colorScheme.errorContainer,
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
              child: Row(
                children: [
                  Icon(Icons.error_outline,
                      color: Theme.of(context).colorScheme.error),
                  const SizedBox(width: 8),
                  Expanded(
                    child: Text(
                      _errorHint,
                      style: TextStyle(
                        color: Theme.of(context).colorScheme.onErrorContainer,
                      ),
                    ),
                  ),
                ],
              ),
            ),
        ],
      ),
    );
  }
}

6.3 分模块讲解

数据模型(_FormData:五个字段跨三步共享,每步输入实时写入,下一步直接读取——数据线只有一条,没有"传参"动作,自然也不会"传丢"。

校验函数(_validateXxx:三步各配一个校验函数,返回 bool;失败时通过 _errorHint 注入错误文案,由页面底部的错误提示栏展示。校验失败不前进——这是 _handleNext 的铁律。

倒计时(_startCountdownFuture.doWhile 每秒循环,mounted 检查保证页面销毁即停;倒计时中按钮禁用(onPressed: null),文案显示"重新发送(59 s)"。

步骤回退(_handleBack:回退不需要校验,已填数据保留——回退是"回去改",不是"重新填"。

完成页(_finished:三步走完切换到成功页,展示注册汇总信息 + "重新注册"按钮一键重置——演示了"流程结束态"的处理。

6.4 密码强度的评分公式

密码强度是即时校验的演示,评分规则可以写成数学形式。设密码为 ppp,定义五个条件:

S(p)=[∣p∣≥6]+[∣p∣≥10]+[含大小写]+[含数字]+[含符号]S(p) = [|p| \ge 6] + [|p| \ge 10] + [\text{含大小写}] + [\text{含数字}] + [\text{含符号}]S(p)=[p6]+[p10]+[含大小写]+[含数字]+[含符号]

其中 [x][x][x] 为艾弗森括号(条件成立记 1,否则记 0),总分 S∈[0,5]S \in [0, 5]S[0,5]。强度分级:

强度={强S≥5中3≤S<5弱1≤S<3未设置S=0 \text{强度} = \begin{cases} \text{强} & S \ge 5 \\ \text{中} & 3 \le S < 5 \\ \text{弱} & 1 \le S < 3 \\ \text{未设置} & S = 0 \end{cases} 强度= 未设置S53S<51S<3S=0

公式的意义在于把"强度"从感觉变成规则:每个条件对应一行正则,五条规则全过就是强密码。真实项目可在此基础上升级为熵计算(E=∣p∣⋅log⁡2(∣Σ∣)E = |p| \cdot \log_2(|\Sigma|)E=plog2(∣Σ∣)),演示版用规则评分已足够直观。

规则评分的局限也顺带指出:它只能衡量"组成复杂度",衡量不了"可预测性"——Abc123! 在规则评分里是满分"强",但作为常见弱密码毫无安全性。真实项目应在规则评分之上叠加"常见弱密码黑名单"与"不与账号信息重复"两条检查,强度提示才有实际意义。演示版保留规则评分,是取其直观性与教学价值。


七、真机运行与效果展示

7.1 运行步骤

  1. USB 连接鸿蒙真机,DevEco Studio 设备列表确认在线(图 1);
  2. flutter run -d <deviceId> 首构建,hvigor 编译原生层;
  3. 真机呈现注册向导第一步(图 2);
  4. 按演示脚本逐项操作(图 3~图 6);
  5. 终端确认编译日志无 error(图 7)。

7.2 截图占位

截图占位共 7 张,覆盖三步流程与错误态:

图 1:DevEco Studio 设备列表(鸿蒙真机在线)

在这里插入图片描述

*图 1 说明:设备列表中 Mate 60 状态为 Online*

图 2:注册向导第一步(基本信息)
在这里插入图片描述

*图 2 说明:步骤圆点 1/2/3,当前步高亮,昵称与邮箱两个输入框,底部继续按钮*

在这里插入图片描述

7.3 演示脚本

步骤 操作 预期结果
1 第一步直接点"继续" 底部提示"昵称不能为空",步骤不前进
2 填昵称,邮箱填"abc"点继续 提示"邮箱格式不正确",步骤不前进
3 邮箱填合法地址,点继续 进入第二步,步骤 1 圆点打勾
4 手机号填 10 位点继续 提示"手机号应为 11 位大陆号码"
5 填 11 位号码,点"获取验证码" 按钮变"重新发送(60 s)"并倒计时,SnackBar 提示发送成功
6 验证码填 3 位点继续 提示"验证码应为 6 位数字"
7 填任意 6 位数字,点继续 进入第三步,步骤 2 圆点打勾
8 密码填 3 位点继续 提示"密码至少 6 位";强度显示"弱"
9 密码填"Abc123!",点继续 强度"强",进入成功页
10 成功页点"重新注册" 回到第一步,所有字段清空

八、分步表单设计模式总结

分步表单不是"把表单切几刀",而是有一套完整的设计模式。本节把它提炼成四条规则。

8.1 拆几步:粒度法则

步骤内容 推荐步数 理由
纯文本字段 3 个以内 单页(不分步) 一步能填完就别分
4~8 个字段可分组 3~4 步 每步 1~2 个字段最优
含验证码/上传/支付 按阶段切 每阶段一个独立动作
超过 6 步 合并步骤或改流程 步骤太多用户记不住进度

粒度法则一句话:每步只问 1~2 个问题。步骤是给用户"喘口气"的,不是给表单"分组"的。

8.2 步骤顺序:成本递增

字段排序遵循"低敏感 → 高敏感":昵称、邮箱这类低成本字段放前面,手机号验证、密码这类高成本字段放后面。用户在前期投入越多(已填完两步),越不愿意在最后一步放弃——前轻后重是分步表单的心理学骨架。本文的三步顺序正是这个结构:基本信息(零成本)→ 手机号验证(中成本)→ 密码(高成本)。

排序的另一个维度是"信息依赖":后一步的校验可能依赖前一步的输入(如验证码发送依赖手机号),依赖方必须排在被依赖方之后。两个维度(成本递增、依赖顺延)同时满足,步骤顺序就稳定了——本文的"手机号 → 验证码 → 密码"顺序里,验证码依赖手机号,密码独立,恰好同时满足两个维度。

8.3 校验节奏:前置优于拦截

校验 节奏 用户感受
格式校验(邮箱/手机号) 输入后即时 改错成本低
必填校验 点"继续"时 一次拦截
服务端校验 提交时 不可避免

规则:能即时校验的不要拖到步骤关口。密码强度实时打分就是即时校验的示范——用户还没点"继续",已经知道密码行不行。

8.4 回退与数据保留

回退是分步表单的"后悔药",两个纪律:

  1. 回退不丢数据:用户从第 3 步退回第 1 步改邮箱,改完应该能直接回到第 3 步,而不是重填——数据保留在 _FormData,回退只是改 currentStep
  2. 回退不重新校验:往回走不需要校验(校验是前进的关卡),但回到前面改完再前进时,当前步的校验照常执行。

8.5 进度感知:看得见的进展

分步表单的进度感知有两种形态:

形态 表现 适用
进度条 顶部线性进度 1/3 → 2/3 步骤多、耗时长的流程
步骤圆点 当前步高亮 + 完成打勾 步骤少(≤5)的流程

Stepper 的步骤圆点(本文形态)属于后者:每完成一步,前一个圆点变成对勾——这就是"看得见的进展"。对勾的激励价值远超装饰:它告诉用户"你走过的路都算数",而步骤圆点相比进度条的额外优势是可回看——点已完成步骤的标题回到上一步修改,圆点会实时反映修改后的完成状态。设计要点:圆点的完成态(对勾)必须与状态机严格同步,圆点与内容脱节(显示已完成但内容被清空)是分步表单最常见的状态不同步 bug。


九、无障碍与步骤语义

步骤导航的无障碍比普通表单多一层"位置感"——用户需要知道自己走到第几步了。四项硬要求:

要求 做法 落点
位置播报 步骤标题含序号语义 读屏播报"第 2 步,共 3 步"
状态播报 已完成/出错状态可感知 圆点对勾/感叹号 + 语义
控件可达 继续/返回按钮语义清晰 “继续”"返回"动词化
错误可定位 错误提示与字段关联 错误栏 + 字段级提示

两个容易被忽略的细节:

  1. 错误提示的可达性:错误提示栏出现在页面底部,读屏用户无法"看见"红色——错误文案应同时由字段本身承载(如 InputDecoration 的 errorText),让焦点落在出错字段时能听到错误原因;
  2. 倒计时的读屏语义:倒计时按钮文案每秒变化,"重新发送(59 s)"这类动态文案对读屏是噪音——倒计时期间按钮应为 disabled 状态(读屏跳过),倒计时结束恢复可点。

第三个细节是完成态的读屏确认:成功页的"注册成功!"不能只靠绿色对勾图标传达,读屏用户需要听到文字播报——本文成功页用了 Text('注册成功!') 做主标题,语义正确;若用纯图标庆祝,务必补 Semantics(label: '注册成功')。步骤导航的读屏验证标准:从第一步到成功页全程走一遍,每一步的位置、状态、错误原因都能被完整播报,流程才算无障碍闭环。


十、真机调试踩坑指南

症状 根因 解法
点"继续"没反应 校验失败被拦截,无提示 检查 _errorHint 是否渲染;校验失败必给可见提示
直接点第 3 步跳过去了 onStepTapped 未拦截 只允许 index <= currentStep 跳转
倒计时按钮点了没反应 手机号未通过正则 倒计时前先校验手机号,失败给提示
倒计时停止后 setState 崩溃 页面已销毁定时器还在跑 Future.doWhile + mounted 检查
步骤圆点与文字错位 鸿蒙字体放大 固定标题行高,或字体放大时换页面式分步
错误提示栏被键盘遮挡 错误栏位置固定 错误栏放 Stepper 下方、输入框上方(本文布局)
回退后数据丢失 回退时清空了模型 回退只改 currentStep,模型数据保留
完成页点不到按钮 键盘未收起遮挡 成功页前 FocusScope.unfocus()
验证码 6 位数字不弹数字键盘 keyboardType 设置错 验证码输入框用 TextInputType.number
真机日志找不到 Flutter 输出 日志走 hdc 而非 adb hdc shell hilog 过滤 flutter 关键字

10.1 一段典型的踩坑实录

初版遇到"点击第 3 步标题直接跳过去"的问题:Stepper 默认允许点击任意步骤标题跳转,用户在第 1 步时点了第 3 步的标题,页面直接切到设置密码——校验链被整个跳过,两步数据全空。修复就是 onStepTapped 里的拦截:if (index <= _currentStep) 才放行。这个 bug 的教训是:分步表单的"顺序"是流程的一部分,跳步必须被当作逻辑漏洞来防御,不能依赖用户自觉。

另一个高频问题是"回退后数据消失":初版把 _handleBack 写成了重置逻辑,从第 3 步退回第 1 步后所有输入清空——用户当场心态崩了。回退语义是"回去改",不是"重新来",数据必须保留。

10.3 验证码演示的环境依赖

演示工程的"获取验证码"不接真实短信服务,点击即提示"验证码已发送"、任意 6 位数字可通过——这是刻意为之的演示简化。真机演示时有两点注意:

  1. 别在真机上点太多次Future.doWhile 的倒计时循环在每次点击时启动,重复点击会叠加多个循环(按钮禁用只防了同一时刻,防不了快速双击的时序窗口)——演示时点一次等倒计时走完再点下一次;
  2. 息屏后倒计时行为:鸿蒙息屏后定时器可能被系统节流,倒计时数字暂停、亮屏后继续——这是系统行为,不是 bug,演示时保持屏幕常亮即可。

若要在真实项目里做扎实,验证码按钮应加"点击后立即禁用 + 防重复提交标记",倒计时改用 Timer.periodic 并持有引用、dispose 时取消——本文的 Future.doWhile 方案在演示场景足够,工程化升级路径在此说明。

10.2 分步表单的测试姿势

分步表单的逻辑密度高,widget 测试的价值比普通页面更大。四个高频用例:

  1. 前进拦截:第一步空字段点继续 → 断言仍停留在第 0 步、错误提示可见;
  2. 校验放行:填合法数据点继续 → 断言 currentStep 前进、圆点对勾出现;
  3. 回退保数据:走到第 2 步退回第 1 步 → 断言昵称输入框仍显示已填内容;
  4. 完成闭环:三步合法走完 → 断言成功页出现、汇总文案正确。

这四个用例恰好覆盖分步表单最容易回归的三个机制:校验关口、数据保留、流程终点。Stepper 的测试要注意一点:onStepTapped 的跳转拦截也要测——"从第 1 步直接点第 3 步标题"这一行为,在自动化测试里补一条断言,防止拦截逻辑在重构中被删掉。


十一、总结与扩展

分步表单拆的是字段,管的是耐心。本文用 Flutter 内置 Stepper 实现了注册向导三步流程,把"步骤状态管理"与"数据传递"两条线完整打通:数据线用 _FormData 单一模型跨步骤共享,状态线用 currentStep + 校验函数把住前进关口,倒计时用 Future.doWhile + mounted 保证生命周期安全。四条设计规则值得背下来:每步只问 1~2 个问题、字段前轻后重、能即时校验别拖到关口、回退永不丢数据

回看引言的问题:分步到底分的是什么?答案已经清晰——分的是认知负担,管的是放弃率。每一步的圆点、对勾、进度,都在回答用户心里那个"还要多久"的问题;每步只问一两个字段,都在降低"这一步好难"的门槛。Stepper 组件只是载体,真正让用户走完流程的,是这套"小步快走 + 看得见进展 + 错得起改得起"的设计。

从本文工程出发可以扩展的方向:

  1. 步骤条自定义:用 controlsBuilder 定制底部按钮("上一步/下一步"样式、步骤摘要展示);
  2. 垂直布局:Stepper 的 type: StepperType.horizontal / vertical 切换,长表单用垂直更合适;
  3. 状态持久化:把 _FormData 接入本地存储,应用被杀后恢复向导进度;
  4. 动态步骤:根据第 1 步的选择动态增删后续步骤(如"是否企业用户"分支流程);
  5. 服务端校验闭环:验证码真实校验、密码强度服务端复检,把 error 态映射到具体步骤;
  6. 向导数据提交:完成页接入真实接口,把 _FormData 序列化为请求体,处理提交中、提交失败重试等状态——本文的完成页是流程终点,真实项目是提交起点。

最后用甘特图回顾注册向导的开发节奏,延续本系列(Button → TextInput → Search → Grid → CustomDialog → Stepper)的工程化节奏:

2026-09-03 2026-09-04 2026-09-05 2026-09-06 2026-09-07 2026-09-08 2026-09-09 2026-09-10 2026-09-11 2026-09-12 环境与真机联调 Stepper 状态机梳理 三步流程与数据模型 校验与错误态 验证码倒计时 完成页与重置 真机验证与截图采集 文章撰写与修订 准备 开发 验证 注册向导开发计划

分步表单的终点不是"把表单填完",而是"让用户愿意填完"。Stepper 提供的每一步的圆点、对勾与进度,本质是给用户的"里程牌"——看得见的进展,是最便宜的激励。把每一步做得小而清楚,用户就会愿意一步一步走下去。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐