鸿蒙 ArkUI Stepper 步骤导航器:分步表单、流程引导与状态管理
鸿蒙版 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 状态三件套,配合 currentStep、onStepContinue、onStepCancel、onStepTapped 四个控制点,一套完整的步骤导航机制开箱即用。本文的任务,就是用 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 自动避让 | 适配版已打通,错误提示栏位置需实测 |
两条重点差异:
- 组件来源的生态差异:ArkUI 原生没有内置 Stepper,官方文档推荐"自定义步骤条 + 条件渲染"组合实现,第三方组件库质量参差;Flutter 的 Stepper 是 Material 规范的一等组件,语义、交互、无障碍全部内置——这是鸿蒙生态里选 Flutter 做分步表单的天然优势,迁移成本只在"了解 StepState 的状态机";
- 系统文本缩放的渲染差异:鸿蒙系统的字体放大档位与 Android 略有不同,Stepper 的步骤圆点(圆圈内数字)在字体放大时可能与标题错位——圆点尺寸与文字尺寸的比例是固定样式,放大后容易"挤成一团"。防御手段是给 Stepper 步骤标题设置明确的行高与间距,或在系统字体放大时切换到页面式分步。
除此之外,Stepper 的 API 与官方完全一致,currentStep、onStepContinue、onStepCancel、onStepTapped 四个控制点的行为无需适配,官方文档与社区方案可以直接迁移。
顺带说明一个选型问题:既然 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) | 编辑中 / 已完成 / 出错 |
state 取 StepState 枚举,对应 ArkUI 的 normal / pending / error 三态,Flutter 更细分为五态:
| StepState | 圆点样式 | 语义 |
|---|---|---|
| indexed | 圆圈数字 | 未完成(默认) |
| editing | 圆圈数字 + 高亮 | 正在编辑 |
| complete | 圆圈对勾 | 已完成 |
| error | 圆圈感叹号 + 红色 | 出错 |
| disabled | 灰色 | 禁用 |
4.3 状态机:normal → editing → complete → error
五态不是摆设,而是一台状态机。本文的状态流转:
状态机图里值得圈出的两条边:error --> editing 与 complete --> 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 只有三种运动方式,对应三个用户动作:
- 前进(onStepContinue):先校验当前步,通过才
_currentStep += 1——"校验失败不前进"是分步表单的底线纪律; - 回退(onStepCancel):直接
_currentStep -= 1,不需要校验——回退是放弃式操作,已填数据保留在模型里,用户改完再前进; - 跳转(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 的铁律。
倒计时(_startCountdown):Future.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)=[∣p∣≥6]+[∣p∣≥10]+[含大小写]+[含数字]+[含符号]
其中 [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} 强度=⎩ ⎨ ⎧强中弱未设置S≥53≤S<51≤S<3S=0
公式的意义在于把"强度"从感觉变成规则:每个条件对应一行正则,五条规则全过就是强密码。真实项目可在此基础上升级为熵计算(E=∣p∣⋅log2(∣Σ∣)E = |p| \cdot \log_2(|\Sigma|)E=∣p∣⋅log2(∣Σ∣)),演示版用规则评分已足够直观。
规则评分的局限也顺带指出:它只能衡量"组成复杂度",衡量不了"可预测性"——Abc123! 在规则评分里是满分"强",但作为常见弱密码毫无安全性。真实项目应在规则评分之上叠加"常见弱密码黑名单"与"不与账号信息重复"两条检查,强度提示才有实际意义。演示版保留规则评分,是取其直观性与教学价值。
七、真机运行与效果展示
7.1 运行步骤
- USB 连接鸿蒙真机,DevEco Studio 设备列表确认在线(图 1);
flutter run -d <deviceId>首构建,hvigor 编译原生层;- 真机呈现注册向导第一步(图 2);
- 按演示脚本逐项操作(图 3~图 6);
- 终端确认编译日志无 error(图 7)。
7.2 截图占位
截图占位共 7 张,覆盖三步流程与错误态:
图 1:DevEco Studio 设备列表(鸿蒙真机在线)

图 2:注册向导第一步(基本信息)

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 回退与数据保留
回退是分步表单的"后悔药",两个纪律:
- 回退不丢数据:用户从第 3 步退回第 1 步改邮箱,改完应该能直接回到第 3 步,而不是重填——数据保留在
_FormData,回退只是改currentStep; - 回退不重新校验:往回走不需要校验(校验是前进的关卡),但回到前面改完再前进时,当前步的校验照常执行。
8.5 进度感知:看得见的进展
分步表单的进度感知有两种形态:
| 形态 | 表现 | 适用 |
|---|---|---|
| 进度条 | 顶部线性进度 1/3 → 2/3 | 步骤多、耗时长的流程 |
| 步骤圆点 | 当前步高亮 + 完成打勾 | 步骤少(≤5)的流程 |
Stepper 的步骤圆点(本文形态)属于后者:每完成一步,前一个圆点变成对勾——这就是"看得见的进展"。对勾的激励价值远超装饰:它告诉用户"你走过的路都算数",而步骤圆点相比进度条的额外优势是可回看——点已完成步骤的标题回到上一步修改,圆点会实时反映修改后的完成状态。设计要点:圆点的完成态(对勾)必须与状态机严格同步,圆点与内容脱节(显示已完成但内容被清空)是分步表单最常见的状态不同步 bug。
九、无障碍与步骤语义
步骤导航的无障碍比普通表单多一层"位置感"——用户需要知道自己走到第几步了。四项硬要求:
| 要求 | 做法 | 落点 |
|---|---|---|
| 位置播报 | 步骤标题含序号语义 | 读屏播报"第 2 步,共 3 步" |
| 状态播报 | 已完成/出错状态可感知 | 圆点对勾/感叹号 + 语义 |
| 控件可达 | 继续/返回按钮语义清晰 | “继续”"返回"动词化 |
| 错误可定位 | 错误提示与字段关联 | 错误栏 + 字段级提示 |
两个容易被忽略的细节:
- 错误提示的可达性:错误提示栏出现在页面底部,读屏用户无法"看见"红色——错误文案应同时由字段本身承载(如 InputDecoration 的 errorText),让焦点落在出错字段时能听到错误原因;
- 倒计时的读屏语义:倒计时按钮文案每秒变化,"重新发送(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 位数字可通过——这是刻意为之的演示简化。真机演示时有两点注意:
- 别在真机上点太多次:
Future.doWhile的倒计时循环在每次点击时启动,重复点击会叠加多个循环(按钮禁用只防了同一时刻,防不了快速双击的时序窗口)——演示时点一次等倒计时走完再点下一次; - 息屏后倒计时行为:鸿蒙息屏后定时器可能被系统节流,倒计时数字暂停、亮屏后继续——这是系统行为,不是 bug,演示时保持屏幕常亮即可。
若要在真实项目里做扎实,验证码按钮应加"点击后立即禁用 + 防重复提交标记",倒计时改用 Timer.periodic 并持有引用、dispose 时取消——本文的 Future.doWhile 方案在演示场景足够,工程化升级路径在此说明。
10.2 分步表单的测试姿势
分步表单的逻辑密度高,widget 测试的价值比普通页面更大。四个高频用例:
- 前进拦截:第一步空字段点继续 → 断言仍停留在第 0 步、错误提示可见;
- 校验放行:填合法数据点继续 → 断言 currentStep 前进、圆点对勾出现;
- 回退保数据:走到第 2 步退回第 1 步 → 断言昵称输入框仍显示已填内容;
- 完成闭环:三步合法走完 → 断言成功页出现、汇总文案正确。
这四个用例恰好覆盖分步表单最容易回归的三个机制:校验关口、数据保留、流程终点。Stepper 的测试要注意一点:onStepTapped 的跳转拦截也要测——"从第 1 步直接点第 3 步标题"这一行为,在自动化测试里补一条断言,防止拦截逻辑在重构中被删掉。
十一、总结与扩展
分步表单拆的是字段,管的是耐心。本文用 Flutter 内置 Stepper 实现了注册向导三步流程,把"步骤状态管理"与"数据传递"两条线完整打通:数据线用 _FormData 单一模型跨步骤共享,状态线用 currentStep + 校验函数把住前进关口,倒计时用 Future.doWhile + mounted 保证生命周期安全。四条设计规则值得背下来:每步只问 1~2 个问题、字段前轻后重、能即时校验别拖到关口、回退永不丢数据。
回看引言的问题:分步到底分的是什么?答案已经清晰——分的是认知负担,管的是放弃率。每一步的圆点、对勾、进度,都在回答用户心里那个"还要多久"的问题;每步只问一两个字段,都在降低"这一步好难"的门槛。Stepper 组件只是载体,真正让用户走完流程的,是这套"小步快走 + 看得见进展 + 错得起改得起"的设计。
从本文工程出发可以扩展的方向:
- 步骤条自定义:用
controlsBuilder定制底部按钮("上一步/下一步"样式、步骤摘要展示); - 垂直布局:Stepper 的
type: StepperType.horizontal / vertical切换,长表单用垂直更合适; - 状态持久化:把
_FormData接入本地存储,应用被杀后恢复向导进度; - 动态步骤:根据第 1 步的选择动态增删后续步骤(如"是否企业用户"分支流程);
- 服务端校验闭环:验证码真实校验、密码强度服务端复检,把 error 态映射到具体步骤;
- 向导数据提交:完成页接入真实接口,把
_FormData序列化为请求体,处理提交中、提交失败重试等状态——本文的完成页是流程终点,真实项目是提交起点。
最后用甘特图回顾注册向导的开发节奏,延续本系列(Button → TextInput → Search → Grid → CustomDialog → Stepper)的工程化节奏:
分步表单的终点不是"把表单填完",而是"让用户愿意填完"。Stepper 提供的每一步的圆点、对勾与进度,本质是给用户的"里程牌"——看得见的进展,是最便宜的激励。把每一步做得小而清楚,用户就会愿意一步一步走下去。
更多推荐



所有评论(0)