最近在给公司内部的一个OpenHarmony商城项目做Flutter适配,正好要补“忘记密码”这个功能模块。原本以为这就是个标准的三段式表单页,结果在OpenHarmony环境下一路踩到不少坑,从hdc连接、插件依赖,到输入框焦点和软键盘遮挡,光是排查问题就花了两天。这篇文章把这个模块从需求拆解、环境准备、页面实现到问题排查的完整过程记录下来,涉及的核心关键词是Flutter、OpenHarmony、商城App和忘记密码实现,希望能给同样在做鸿蒙端Flutter适配的同行一点参考,也帮新手少走一些弯路。
先说一下项目背景。这是一套已经跑在Android和iOS上的商城App,Flutter 3.x编写,服务端接口复用。现在要适配OpenHarmony,我手上拿到的是rk3568/RK3588开发板和一台OpenHarmony测试机。整个项目里业务模块很多,为什么单独把“忘记密码”拎出来写?因为这个功能非常典型:它有表单校验、倒计时按钮、接口请求、页面跳转、异常分支,几乎覆盖了移动端业务开发的所有基础能力。更关键的是,这个模块在OpenHarmony上的适配问题很有代表性,能踩的坑基本都踩了一遍。
下面直接从需求设计开始,逐步把这个模块的实现过程讲清楚。
1. 需求分析与整体设计:忘记密码不只是三个表单页
1.1 密码找回的四种常见模式
先聊聊方案选型。忘记密码这个业务,市面上主流的实现方案大概有四种:
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 短信验证码 | 手机号 + 短信验证码 | 覆盖率高、用户操作成本低 | 短信有延迟、有通道费用 |
| 邮箱验证码 | 邮箱 + 邮件验证码 | 成本低、无需短信通道 | 不适合国内大多数用户习惯 |
| 安全问题验证 | 预先设置的问题回答 | 无需外部通道 | 安全问题容易被猜到或遗忘 |
| 管理员重置 | 联系客服人工处理 | 可控性强 | 用户体验最差、运营成本高 |
对于商城类App来说,用户群体是C端消费者,90%以上的订单和登录行为都发生在移动端,最普遍的还是手机号注册。所以单纯从产品逻辑上讲,短信验证码方案几乎是唯一合理的选择。而且这个商城项目已经有短信服务商接入,后端接口也都现成,不需要额外搭建邮件服务或问题库。
1.2 为什么“手机验证码+重置密码”最适合当前项目
这里有一个容易忽略的点:很多团队做“忘记密码”时,第一反应是照搬登录页的“手机号 + 验证码”结构,但实际上忘记密码流程需要多一个“设置新密码”的环节,而且这个环节涉及两个输入框(新密码 + 确认密码),一旦设计不当,用户在键盘切换和密码可见性切换之间很容易烦躁。
另外,还要考虑安全链路。用户输入手机号后,服务端发送验证码,用户拿到验证码后提交,服务端校验通过后才放行到“设置新密码”页面。整个过程的状态应该放在前端本地维护,用state判断当前处于第几步。这里不建议把每一步都做成独立页面然后互相传参,因为一旦页面被系统回收,Step 1填好的数据就丢了,用户会被打回起点。更好的做法是把三个步骤放在同一个页面容器里,用PageView或者显隐切换控制。
我在项目里用的是单页面多步骤方案:一个路由,内部维护 step 状态,每个步骤对应一组表单组件。这样用户从第一步走到第三步,数据都保存在State里,即便软键盘弹起导致重建,也不会丢信息。
1.3 整体流程与状态机设计
整个流程可以抽象成这样一个状态机:
code复制Step 1(输入手机号) → 校验通过 → 请求发送验证码 → 倒计时开始
Step 2(输入验证码) → 校验通过 → 请求重置密码接口 → 成功
Step 3(设置新密码) → 校验通过 → 请求重置密码接口 → 成功 → 跳转登录页
每一步都有失败分支:手机号格式错误、验证码超时、验证码错误、新密码强度不足、网络异常。这些分支如果在UI上不做明确反馈,用户就会反复提交、反复失败,然后投诉“你们App是个bug”。
所以我在设计时定了三条原则:
- 每个输入框的校验规则必须前置,输入时即时校验,提交时最终校验。
- 按钮的加载状态必须明确,提交中禁止重复点击。
- 错误提示必须按“轻提示”处理,不弹Dialog打断流程,用SnackBar或表单下方的错误文案即可。
这三条原则在后续编码中会反复用到,先记住。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与工程结构:OpenHarmony 下的 Flutter 项目长什么样
2.1 OpenHarmony 开发环境准备
这个模块在Android上写好后,直接跑OpenHarmony测试机,首先遇到的就是环境问题。OpenHarmony和HarmonyOS不是一回事,开发工具链也有差异,这里先把“跑起来”的链路理清楚。
我使用的是DevEco Studio配合OpenHarmony SDK,同时命令行工具是hdc(HarmonyOS Device Connector)。它的作用和adb类似,但命令参数有差异。最常用的几个命令:
bash复制# 查看已连接的设备列表
hdc list targets
# 查看设备系统版本
hdc shell param get const.product.software.version
# 查看设备型号
hdc shell param get const.product.model
# 安装hap包(OpenHarmony的安装包格式)
hdc install -r path/to/your.hap
# 查看日志
hdc hilog
这里最容易踩的坑是:设备虽然插上了,但 hdc list targets 不显示设备。原因通常有两个:一是开发板(rk3568/rk3588)不是通过USB连接而是网络连接,需要先配置网络;二是hdc服务没重启。网络连接开发板时,用下面的命令:
bash复制hdc tconn 192.168.1.100:5555
注意端口默认是5555,和adb一致。连接后再执行 hdc list targets 就能看到了。
另外,param get 这类命令在排查设备属性时非常有用。比如你要确认系统版本是否支持某个API,直接:
bash复制hdc shell param get const.product.software.version
hdc shell param get const.ohos.apiversion
后者能拿到API版本号,判断当前系统支持的OpenHarmony接口层级。
2.2 工程初始化与依赖配置
OpenHarmony的Flutter工程结构,和标准Flutter工程基本一致,区别在于编译目标和输出产物。我在项目里保留统一的 lib/ 目录,业务代码完全复用,但 android/、ios/ 之外的 ohos/ 目录需要额外配置。
如果你是从零开始建工程,要注意Flutter SDK版本和OpenHarmony Flutter SDK的对应关系。社区推荐的组合是:
| Flutter SDK | OpenHarmony Flutter SDK | 说明 |
|---|---|---|
| 3.7.x | flutter-3.7-branch | 比较稳定 |
| 3.13.x | 3.13-branch | 覆盖更多API |
| 3.16.x | 3.16-branch | 较新,注意插件兼容性 |
这个项目用的是3.16版本,因为商城App此前已经在Android/iOS端跑在3.16上。要切换到OpenHarmony,需要把Flutter SDK替换成OpenHarmony适配版,然后在 ohos/ 目录下执行构建。
依赖配置方面,pubspec.yaml 里的依赖必须是OpenHarmony插件兼容的版本。比如 dio 是纯Dart实现,OpenHarmony直接可用;但需要用 shared_preferences 做本地存储时,就要确认版本是否包含OpenHarmony实现。社区维护了一份OpenHarmony插件兼容列表,建议先在列表里查一下再引入,避免编译到一半报错。
一个典型的 pubspec.yaml 关键部分:
yaml复制dependencies:
flutter:
sdk: flutter
dio: ^5.4.0
provider: ^6.1.1
pin_code_fields: ^8.0.1
shared_preferences: ^2.2.2
crypto: ^3.0.3
这里 pin_code_fields 做验证码输入框非常好用,后面细说。
2.3 路由管理与页面划分
整个忘记密码模块,我只申请了一个路由,页面内部根据步骤切换内容。这样的好处前面说过了:状态不会丢,路由跳转逻辑简单。
路由表放在一个独立文件里集中管理:
dart复制class Routes {
static const String forgotPassword = '/forgotPassword';
}
class RouteGenerator {
static Route<dynamic> generateRoute(RouteSettings settings) {
switch (settings.name) {
case Routes.forgotPassword:
return MaterialPageRoute(
builder: (_) => ForgotPasswordPage(),
);
default:
return MaterialPageRoute(
builder: (_) => NotFoundPage(),
);
}
}
}
页面内部声明一个枚举类型 ForgotPasswordStep,然后根据当前步骤渲染对应的表单区域:
dart复制enum ForgotPasswordStep { phone, verify, reset }
到这里,环境与工程骨架就搭好了。从下一章开始,进入核心代码实现。
3. 忘记密码核心页面实现:验证码、校验与重置
3.1 手机号输入页:验证码获取与倒计时
先说第一步“手机号 + 获取验证码”。这个页面解决的问题有两个:手机号格式校验,以及点击“获取验证码”之后的倒计时逻辑。
手机号校验用正则就够了,不需要引入第三方插件:
dart复制final _phoneRegExp = RegExp(r'^1[3-9]\d{9}$');
bool isValidPhone(String phone) {
return _phoneRegExp.hasMatch(phone);
}
国内手机号目前就是11位,1开头,第二位是3-9。这个规则在项目早期就用上了,没有出过问题。
接下来是“获取验证码”按钮。这里面的核心逻辑是倒计时。我用一个 Timer.periodic 实现,并在dispose时取消,防止内存泄漏:
dart复制class _PhoneStep extends StatefulWidget {
...
}
class _PhoneStepState extends State<_PhoneStep> {
Timer? _timer;
int _countdown = 0;
bool _loading = false;
void _startCountdown() {
setState(() {
_countdown = 60;
});
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (_countdown <= 1) {
timer.cancel();
setState(() {
_countdown = 0;
});
} else {
setState(() {
_countdown--;
});
}
});
}
Future<void> _requestCode() async {
if (!isValidPhone(_phoneController.text)) {
_showError('请输入正确的手机号');
return;
}
setState(() => _loading = true);
try {
await AuthApi.sendVerifyCode(_phoneController.text);
if (!mounted) return;
_startCountdown();
} catch (e) {
if (!mounted) return;
_showError('验证码发送失败,请稍后重试');
} finally {
if (mounted) {
setState(() => _loading = false);
}
}
}
}
按钮文字在倒计时期间显示“重新获取({seconds}s)”,倒计时结束后恢复为“获取验证码”。这里有一个细节:倒计时期间按钮要禁用,否则用户连续点击会触发多次短信发送。用 onPressed: _countdown > 0 || _loading ? null : _requestCode 一行代码就解决了。
还要注意的一个点:用 mounted 判断异步回调是否安全。在 await 之后直接调用 setState,如果页面已经被销毁,会直接抛异常。这是Flutter新手很容易忽略的地方,也是最常见的崩溃原因之一。
3.2 验证码校验与密码重置表单
第二步是输入验证码。市面上很多App把验证码做成6个独立格子,体验更好,但在OpenHarmony的真机上我曾经遇到输入框联动问题,这里推荐用 pin_code_fields 这个插件,它封装好了焦点管理、粘贴板读取和自动跳格:
dart复制PinCodeTextField(
appContext: context,
length: 6,
obscureText: false,
animationType: AnimationType.fade,
pinTheme: PinTheme(
shape: PinCodeFieldShape.box,
borderRadius: BorderRadius.circular(8),
fieldHeight: 52,
fieldWidth: 46,
activeColor: Theme.of(context).primaryColor,
selectedColor: Colors.blueGrey,
inactiveColor: Colors.grey.shade300,
),
keyboardType: TextInputType.number,
onCompleted: (value) {
_verifyCode = value;
},
validator: (value) {
if (value == null || value.length != 6) {
return '请输入6位验证码';
}
return null;
},
)
pin_code_fields 在遇到用户从短信复制验证码时,能自动把6位数字填入对应格子,不需要手动一个个粘贴,这个细节在商城场景里对用户体验影响很大。不过在OpenHarmony端要注意:它的部分实现依赖剪贴板通道,如果系统剪贴板权限未授权,粘贴功能会失效。这个后面在问题排查章节单独说。
第三步“设置新密码”是模块的核心。密码校验规则我定的是:8-20位,至少包含数字和字母。用正则实现:
dart复制final _passwordRegExp = RegExp(r'^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*#?&]{8,20}$');
上面这个正则里的 (?=.*[A-Za-z]) 和 (?=.*\d) 是零宽断言,分别表示“必须包含字母”和“必须包含数字”,这两个条件同时满足才算通过。
两个密码框要做一致性校验,而不是等用户提交时再检查。我习惯在“确认密码”框的 validator 里直接比对:
dart复制TextFormField(
controller: _confirmController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: '确认密码',
suffixIcon: IconButton(
icon: Icon(_obscurePassword ? Icons.visibility_off : Icons.visibility),
onPressed: () {
setState(() => _obscurePassword = !_obscurePassword);
},
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '请再次输入密码';
}
if (value != _passwordController.text) {
return '两次输入的密码不一致';
}
return null;
},
)
这里我加了密码可见性切换,方便用户检查自己输入的密码。这个功能看起来小,但在电商场景里,用户输入长密码时,如果没有可见性切换,很容易因为看不到内容而反复输错。
最后是表单整体提交时,用Form 包裹,点击提交按钮后统一校验:
dart复制final _formKey = GlobalKey<FormState>();
void _submitReset() {
if (!_formKey.currentState!.validate()) {
return;
}
_resetPassword();
}
3.3 业务层封装:Dio请求与统一错误处理
接口请求部分用了Dio。这个商城项目有统一的封装,但忘记密码模块有它自己的特殊性:它涉及多个接口,而且每一步的错误提示都不一样。
我建议把三个接口单独抽一个 AuthApi:
dart复制class AuthApi {
static Future<void> sendVerifyCode(String phone) async {
final response = await DioClient.post(
'/auth/send_verify_code',
data: {'phone': phone},
);
if (response.code != 0) {
throw AppException(response.message);
}
}
static Future<void> verifyCode(String phone, String code) async {
final response = await DioClient.post(
'/auth/verify_code',
data: {'phone': phone, 'code': code},
);
if (response.code != 0) {
throw AppException(response.message);
}
}
static Future<void> resetPassword({
required String phone,
required String code,
required String newPassword,
}) async {
// 这里newPassword可以按服务端要求做一次md5后再提交
final encryptedPassword = md5.convert(utf8.encode(newPassword)).toString();
final response = await DioClient.post(
'/auth/reset_password',
data: {
'phone': phone,
'code': code,
'password': encryptedPassword,
},
);
if (response.code != 0) {
throw AppException(response.message);
}
}
}
这里的统一错误处理很重要。我定义了一个自定义异常 AppException,接口层把后端的业务错误码转换成异常信息,UI层捕获后展示。这样就不需要每个页面都写一堆对错误码的 switch-case 了。
dart复制class AppException implements Exception {
final String message;
AppException(this.message);
@override
String toString() => message;
}
用统一封装后,UI层的代码会清爽很多:
dart复制try {
await AuthApi.verifyCode(_phone, _verifyCode);
setState(() {
_step = ForgotPasswordStep.reset;
});
} on AppException catch (e) {
_showError(e.message);
} catch (_) {
_showError('网络异常,请稍后重试');
}
这一层封装解决了我之前项目里最常见的痛点:同一个后端错误码在不同页面重复判断,逻辑写得到处都是。现在统一收敛到API层,页面只关心“成功”还是“失败”。
还有一个细节:发送验证码的接口和后端约定好,同一个手机号60秒内不能重复发送,所以即使前端倒计时没走完,后端也会拦截重复请求。前端倒计时的主要作用只是提升用户体验,而不是安全屏障,真正的校验逻辑必须放在服务端。
4. 常见问题与排查技巧实录
4.1 hdc设备连接与版本检查
前面说过 hdc list targets 不显示设备的问题,这里再深入一下。rk3568/rk3588开发板通常有两个连接方式:USB直连和网络连接。USB直连时,如果电脑上还跑着adb服务,两个工具的端口可能存在冲突。我遇到过的情况是:插上开发板后hdc无反应,用 hdc kill 杀掉服务重启就好。
网络连接方式更常用。开发板连上路由器后,用 hdc tconn <IP>:5555 建立连接。但有个坑:开发板重启后IP会变,需要重新配置。建议在开发板上配置静态IP,或者写一个启动脚本自动上报IP到电脑端。
查看系统版本这个需求很频繁,我一般用:
bash复制hdc shell param get const.product.software.version
hdc shell param get const.product.model
hdc shell param get const.ohos.apiversion
这三个命令能一次看清“什么设备、什么系统、什么API级别”。在排查插件兼容性时,API级别是关键指标,很多插件在API 11以下表现不稳定。
4.2 Flutter插件编译与依赖问题
OpenHarmony下最头疼的是插件兼容性。常见报错是:
text复制Error resolving plugin [id: 'dev.flutter.flutter-plugin-loader', version: ...]
这个报错出现的原因通常是插件引用了Android平台的Gradle配置,但OpenHarmony构建时无法解析。排查思路分成三步:
- 确认
pubspec.yaml中插件版本是否在OpenHarmony兼容列表内。 - 检查
ohos/目录下的build-profile.json5是否存在对应插件配置。 - 清空构建缓存后重新构建:
bash复制flutter clean
cd ohos
hvigorw clean
另外一个容易遗漏的点:如果你在项目里用了 flutter pub add 新装插件,OpenHarmony的 oh_modules 不会自动更新,需要手动同步。我通常执行:
bash复制flutter pub get
后,再去 ohos/ 目录下重新同步依赖。如果不这样做,即使 pubspec.yaml 里已经写了依赖,构建时依然找不到插件。
4.3 输入框焦点与软键盘遮挡问题
搜索热词里有一条“flutter 底部弹窗内有text field”,这个场景在忘记密码模块也出现了,就是在“设置新密码”这一页,如果页面上有底部弹窗需要输入验证码,软键盘遮挡问题会非常明显。
OpenHarmony和Android在软键盘弹出时的表现不完全一样。Android的 resize 模式会自动调整页面高度,但OpenHarmony部分版本没有完全复刻这个行为,导致底部按钮被键盘挡死。
我最后的解决办法是给最外层加 SingleChildScrollView + resizeToAvoidBottomInset: true:
dart复制Scaffold(
resizeToAvoidBottomInset: true,
body: SingleChildScrollView(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: _buildStepContent(),
),
)
viewInsets.bottom 拿到的就是软键盘高度,把它作为底部padding,确保按钮始终在键盘上方。这个方法在Android上也适用,我后来把Android端的代码也顺手改成这个写法,两个端行为就统一了。
另外一个焦点管理问题:在 PinCodeTextField 里输入完6位验证码后,焦点自动跳转到下一个密码框。这个用 FocusNode 配合 unfocus 就能处理:
dart复制FocusScope.of(context).unfocus();
如果是多个输入框之间的切换,用 FocusScope.of(context).requestFocus(_nextNode) 或 _currentNode.nextFocus()。注意在OpenHarmony里,有些版本的软键盘弹出动画会截断页面滚动,此时需要结合 scrollController 手动滚动到目标输入框位置。我实测下来,给 TextFormField 包一层 Focus 监听,必要时用 ensureVisible 滚动是最稳定的。
4.4 其他常见问题速查表
这个模块实际开发中,我把遇到的典型问题整理成了一张表,发到项目群里后大家反馈还挺有用的:
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| hdc找不到设备 | USB连接不稳定 / hdc服务未启动 | hdc kill 后重新连接,或使用 hdc tconn 网络连接 |
| 获取验证码后收不到短信 | 测试环境短信通道被限流 | 用后端Mock接口替代真实短信,前端逻辑不受影响 |
| 倒计时按钮卡住不走 | Timer未在dispose时取消 | 检查 _timer?.cancel(),确保页面销毁后定时器停止 |
| 验证码输入框粘贴失效 | 系统剪贴板权限未授权 | 在OpenHarmony设置中打开对应应用的剪贴板权限 |
| 设置新密码提交后无响应 | 接口内部异常被吞掉 | 统一使用 AppException,确保错误信息能传到UI层 |
| 软键盘把提交按钮挡住 | 页面未处理 viewInsets |
用 SingleChildScrollView + viewInsets.bottom 处理 |
| OpenHarmony构建失败 | 插件Gradle配置不兼容 | 检查插件版本,清空缓存后重新构建 |
| 页面跳转后旧页面State丢失 | 路由被系统回收 | 使用单页面多步骤方案,或将关键数据持久化 |
表格里每一条都是实际踩过的坑。尤其是短信验证码收不到这个,看起来像是后端问题,但前端如果不做“发送失败”的兜底提示,用户就会反复点击,短信通道被限流得更严重,形成恶性循环。我后来在前端加了“发送失败后60秒内不可重试”的逻辑,才把这个问题压住。
4.5 性能与体感优化小技巧
除了问题排查,再说几个让模块“用起来更舒服”的优化点。
第一,验证码输入完成后的自动提交。如果用户已经在第一步拿到了验证码,第二步输入完6位数字后,不需要再点一次“下一步”,直接自动触发校验。这个逻辑用 onCompleted 回调就能实现:
dart复制onCompleted: (value) {
_verifyCode = value;
_autoVerify();
}
不过要注意:自动提交前先做个本地校验,如果验证码格式不对,就不要请求服务端了。我遇到过自动提交导致连续弹错误提示的问题,就是没做本地预检。
第二,密码框的 autofillHints 可以设置,方便系统密码管理器自动填充。OpenHarmony上部分版本对 autofillHints 支持不够好,但设置了没有坏处,万一支持呢。
第三,不同步骤之间用一个淡入淡出的过渡动画,会让整个流程更连贯。用 AnimatedSwitcher 包住步骤内容,切换时有个300ms的渐变效果,不会显得生硬:
dart复制AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: _buildStepContent(),
)
4.6 回填手机号与登录态联动
最后补充一个业务细节:如果用户是从登录页跳转过来的,建议把登录页里已经输入的手机号直接回填到忘记密码第一步,减少一次输入。这里的实现很简单:
dart复制class ForgotPasswordPage extends StatefulWidget {
final String? initialPhone;
...
}
// 跳转时
Navigator.pushNamed(
context,
Routes.forgotPassword,
arguments: {'initialPhone': _loginPhoneController.text},
);
页面初始化时,如果 widget.initialPhone 不为空,直接把值塞进 _phoneController,同时自动跳过第一步,进入验证码输入。这个交互在很多大厂App里都能见到,背后逻辑就是这么简单,但确实能省用户几秒钟。
另一个点:密码重置成功后,大多数产品会清掉当前页面栈,直接回到登录页,并且提示“密码已重置,请重新登录”。这里直接用:
dart复制Navigator.of(context).pushNamedAndRemoveUntil(
Routes.login,
(route) => route.isFirst,
);
这样就避免了用户按返回键又回到“设置新密码”的页面。如果项目有登录态的持久化,成功重置后还要清掉本地存储的token和用户信息,防止旧token继续有效。
从需求设计到环境搭建,再到代码实现和问题排查,整个“忘记密码”模块在Flutter for OpenHarmony上的落地过程就是这样。我在实际项目里最大的体会是:OpenHarmony生态还在快速完善,很多插件和工具链跟Android/iOS有差异,但好在Flutter的跨端抽象层把大部分业务逻辑隔离得比较干净,真正需要下沉到底层的适配工作其实不多。只要把环境链路打通、把状态管理做稳、把错误处理统一,剩下的就是标准Flutter套路了。这次用到的方案,包括单页面多步骤、倒计时按钮、Dio统一封装和焦点管理,在后续其他业务模块里也能直接复用。
