ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Flutter短信验证登录全攻略:从状态管理到防抖与安全存储

Flutter短信验证登录全攻略:从状态管理到防抖与安全存储

1. 为什么短信验证登录远没有看上去那么简单

短信验证登录这个功能,几乎每个带用户体系的App都绕不开。表面上看,无非就是“输入手机号 → 点获取验证码 → 填验证码 → 登录成功”四步,很多刚接触Flutter的朋友第一反应是:这不就是一个TextField加一个倒计时按钮的事吗?我一开始也这么想,直到真正在几个上线项目里反复打磨这个模块之后才发现,这里面的坑远比想象中多。

一个“考虑最周全”的短信验证登录模块,要同时处理好这些事:手机号格式的实时校验与区号适配、验证码倒计时的状态持久化、请求防抖与重复提交拦截、验证码错误次数限制、网络异常与超时的友好提示、登录态的安全存储、页面销毁时的资源释放、以及不同平台(Android/iOS/Web)的键盘与输入体验差异。任何一环没考虑到,用户就会在某个边缘场景里卡住,然后流失。

这篇内容适合两类人:一是正在用Flutter做第一个带登录功能App的新手,需要一份能直接抄作业的完整方案;二是已经写过登录页但总被测试挑出各种边界问题的开发者,想看看别人是怎么把细节补全的。我会从整体设计思路讲起,再逐层拆到具体实现,最后把我踩过的坑和排查经验整理成速查表。全程用可复现的代码和参数说明,不玩虚的。

2. 模块整体设计与技术选型思路

2.1 先想清楚状态该放在哪里

短信登录模块的核心难点不在UI,而在状态管理。手机号输入状态、验证码倒计时状态、请求加载状态、错误提示状态、登录成功后的用户态,这五种状态如果全塞进一个StatefulWidget的setState里,代码会迅速膨胀到难以维护,而且倒计时一旦遇到页面重建就会重置,用户体验直接崩掉。

我的做法是把状态拆成两层:UI局部状态用StatefulWidget自己管,比如输入框的焦点、密码可见性;跨组件、需要持久化的状态交给一个独立的Controller或状态管理方案。倒计时就是典型的跨重建状态,必须放在Widget生命周期之外。我试过用Provider、Riverpod和简单的ChangeNotifier三种方案,对于登录这种相对独立的模块,一个继承ChangeNotifier的AuthController配合Provider就足够了,没必要上更重的方案。

提示:倒计时如果放在State里,用户切到后台再回来、或者因为键盘弹出触发重建,倒计时就会归零,这是新手最常见的翻车点。

2.2 为什么不用现成的登录UI库

市面上确实有一些封装好的登录组件库,但我建议短信登录这块自己写。原因有三个:第一,登录页是App的门面,样式定制需求极高,用现成库改样式的时间往往超过自己写;第二,短信登录涉及合规和风控逻辑(比如验证码频率限制),这些必须和后端约定,通用库很难贴合;第三,自己写一遍才能真正理解每个状态流转,出问题时排查起来心里有底。

技术选型上,网络请求我用Dio,因为它对拦截器、超时、错误类型的支持比http包完善,登录这种需要统一处理token和错误码的场景特别合适。本地存储用flutter_secure_storage存token,普通配置用shared_preferences。表单校验不引入额外库,Flutter自带的TextInputFormatter和自定义校验函数足够用。

2.3 完整的状态流转设计

在动手写代码前,我习惯先把状态流转画清楚(这里用文字描述)。整个模块的状态机是这样的:

  • 初始态:手机号为空,获取验证码按钮禁用。
  • 手机号输入中:实时校验格式,格式合法后按钮才可点击。
  • 请求验证码中:按钮显示加载态,禁止重复点击。
  • 倒计时中:按钮显示剩余秒数,禁用,同时手机号输入框建议锁定,防止用户改了号码但验证码还是发给旧号码。
  • 验证码输入中:可以提交登录。
  • 登录请求中:提交按钮加载态,禁止重复提交。
  • 登录成功:存储token,跳转。
  • 各类失败态:网络错误、验证码错误、手机号未注册等,分别给出不同提示。

把这套状态想清楚,代码结构自然就清晰了。下面进入具体实现。

3. 核心细节拆解与实操要点

3.1 手机号校验:别只用正则一刀切

手机号校验看起来简单,但要做周全得考虑几件事。首先是区号,如果你的App只面向国内用户,可以固定+86;但如果要支持国际,就得引入区号选择器,并且不同国家的号码长度和格式完全不同,正则不能写死。

国内手机号的校验,很多人直接写^1[3-9]\d{9}$,这个基本够用,但要注意它会把一些虚拟号段也放进来。更稳妥的做法是维护一个当前有效的号段前缀列表,定期更新。不过对于大多数应用,用这个正则已经能拦住99%的明显错误输入。

实操中更重要的是实时校验的时机。我的经验是:不要在用户每输入一个字符就弹错误提示,那样很烦人。正确的做法是——输入过程中只做长度限制(最多11位数字),当输入长度达到11位时再触发格式校验,如果格式不对才提示。这样用户输入过程中不会被打断,体验顺畅很多。

// 手机号输入格式化:只允许数字,最多11位 TextInputFormatter phoneFormatter() { return FilteringTextInputFormatter.digitsOnly; } // 校验函数 bool isValidPhone(String phone) { if (phone.length != 11) return false; return RegExp(r'^1[3-9]\d{9}$').hasMatch(phone); }

配合maxLength: 11和counterText: ''(隐藏默认字数计数器),输入框就干净了。

3.2 倒计时实现:Timer的正确打开方式

倒计时是短信登录模块里最容易出bug的地方。核心要求是:点击获取验证码后开始60秒倒计时,期间按钮禁用并显示剩余秒数,倒计时结束后恢复可点击。同时,如果用户在倒计时期间退出页面再进来,倒计时应该继续(或者至少不能出现能重复发送的情况)。

实现上,我用Timer.periodic每秒递减一个计数器,但关键是把Timer和计数器放在Controller里,并在dispose时取消Timer,否则会造成内存泄漏和setState报错。

class AuthController extends ChangeNotifier { Timer? _timer; int _countdown = 0; bool get canSendCode => _countdown == 0 && !_isSendingCode; void startCountdown() { _countdown = 60; notifyListeners(); _timer?.cancel(); _timer = Timer.periodic(const Duration(seconds: 1), (timer) { _countdown--; if (_countdown <= 0) { timer.cancel(); _countdown = 0; } notifyListeners(); }); } @override void dispose() { _timer?.cancel(); super.dispose(); } }

注意:notifyListeners()在倒计时期间每秒调用一次,如果整个页面都监听这个Controller,会导致每秒重建整个页面。优化办法是把倒计时按钮单独抽成一个组件,只让它监听,或者用Selector只订阅countdown字段。

3.3 防抖与重复提交拦截

用户手快连点两下“获取验证码”,如果不做拦截,就会发出两次请求,浪费短信条数还可能触发风控。拦截分两层:UI层在请求发出后立即把按钮置为禁用态,逻辑层在Controller里加一个_isSendingCode标志位,请求返回前直接return。

登录提交同理,加_isSubmitting标志。这里有个细节:标志位要在finally里重置,否则一旦请求抛异常,按钮就永远禁用了,用户只能杀进程重来。

Future<void> sendCode(String phone) async { if (_isSendingCode || _countdown > 0) return; _isSendingCode = true; notifyListeners(); try { await authApi.sendSmsCode(phone); startCountdown(); } catch (e) { // 错误处理 } finally { _isSendingCode = false; notifyListeners(); } }

3.4 验证码输入框的体验细节

验证码一般是4到6位数字。我推荐用6个独立的方格输入框,视觉上更清晰,也符合大多数用户的预期。但独立方格实现起来比单个输入框复杂,需要处理焦点自动跳转、删除时回退、粘贴整串验证码自动分配等。

如果时间紧,用单个输入框配合letterSpacing也能做出类似效果,成本低很多。我的建议是:MVP阶段用单输入框,等产品稳定了再升级成方格。粘贴自动填充这个功能一定要做,用户从短信里复制验证码后,长按粘贴应该能自动填满,这个体验提升很明显。

另外,Android上可以监听短信自动填充(需要申请权限,且各厂商ROM行为不一致),iOS有系统级的验证码自动填充(textContentType: TextContentType.oneTimeCode),这个一定要加上,能省去用户手动输入的麻烦。

TextField( keyboardType: TextInputType.number, textContentType: TextContentType.oneTimeCode, // iOS自动填充 autofillHints: const [AutofillHints.oneTimeCode], maxLength: 6, inputFormatters: [FilteringTextInputFormatter.digitsOnly], )

4. 完整实操流程与关键代码实现

4.1 项目结构与依赖准备

先规划目录结构,我习惯按功能模块划分:

lib/ features/ auth/ auth_controller.dart // 状态管理 auth_api.dart // 网络请求 login_page.dart // 登录页 widgets/ phone_input.dart // 手机号输入组件 code_input.dart // 验证码输入组件 countdown_button.dart // 倒计时按钮 core/ network/ dio_client.dart // Dio封装 storage/ secure_storage.dart // 安全存储

依赖方面,在pubspec.yaml里加上:

dependencies: dio: ^5.4.0 provider: ^6.1.1 flutter_secure_storage: ^9.0.0 shared_preferences: ^2.2.2

版本号以你项目实际能拉取到的为准,这里给的是我最近用的稳定版本区间。

4.2 Dio封装与统一错误处理

登录接口的错误处理必须统一,否则每个页面都要写一堆try-catch。我用Dio的拦截器统一处理业务错误码和网络异常,把它转换成自定义的异常类型,上层只需要catch一种异常。

class ApiException implements Exception { final int code; final String message; ApiException(this.code, this.message); } Dio createDio() { final dio = Dio(BaseOptions( baseUrl: 'https://your-api-host.com', connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10), )); dio.interceptors.add(InterceptorsWrapper( onError: (error, handler) { // 把Dio的错误转成统一的ApiException if (error.type == DioExceptionType.connectionTimeout || error.type == DioExceptionType.receiveTimeout) { handler.reject(DioException( requestOptions: error.requestOptions, error: ApiException(-1, '网络超时,请检查网络后重试'), )); } else { handler.next(error); } }, )); return dio; }

超时时间设10秒是我的经验值。太短了弱网下容易误报,太长了用户等得焦虑。10秒是个平衡点。

4.3 登录页的组装

登录页本身不复杂,关键是把各个组件拼起来,并处理好键盘遮挡问题。用SingleChildScrollView包裹内容,配合resizeToAvoidBottomInset: true(默认就是true),键盘弹出时页面会自动上推。

class LoginPage extends StatelessWidget { @override Widget build(BuildContext context) { return ChangeNotifierProvider( create: (_) => AuthController(), child: Scaffold( appBar: AppBar(title: const Text('登录')), body: SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: const [ PhoneInput(), SizedBox(height: 16), CodeInput(), SizedBox(height: 32), SubmitButton(), ], ), ), ), ), ); } }

4.4 发送验证码的完整链路

从用户点击到倒计时启动,完整链路是这样的:校验手机号格式 → 检查是否在倒计时 → 置加载态 → 调用接口 → 成功则启动倒计时,失败则根据错误码提示 → 重置加载态。

后端返回的错误码要区分对待。比如“手机号格式错误”和“发送过于频繁”要给不同提示,“发送过于频繁”最好还能告诉用户还要等多久。这些细节产品经理不一定想得到,但开发者主动做了,用户投诉会少很多。

Future<void> sendCode() async { if (!isValidPhone(_phone)) { _errorMessage = '请输入正确的手机号'; notifyListeners(); return; } if (_isSendingCode || _countdown > 0) return; _isSendingCode = true; _errorMessage = null; notifyListeners(); try { await authApi.sendSmsCode(_phone); startCountdown(); } on ApiException catch (e) { _errorMessage = e.message; } catch (e) { _errorMessage = '发送失败,请稍后重试'; } finally { _isSendingCode = false; notifyListeners(); } }

4.5 登录成功后的token存储与跳转

登录成功后拿到的token,绝对不能存在shared_preferences里,那是明文存储,root过的设备能直接读到。用flutter_secure_storage,它在Android上走Keystore,iOS上走Keychain,安全性高一个量级。

final storage = FlutterSecureStorage(); await storage.write(key: 'access_token', value: token); await storage.write(key: 'refresh_token', value: refreshToken);

存完之后跳转,记得用Navigator.pushReplacement而不是push,否则用户按返回键还能回到登录页,体验很怪。如果用了路由框架(比如go_router),就用对应的replace方法。

4.6 页面销毁时的资源清理

这一步很多人会漏。Controller里的Timer、TextEditingController、FocusNode,都要在dispose里释放。特别是Timer,不取消的话页面销毁后它还在跑,回调里调notifyListeners会直接抛异常。

@override void dispose() { _timer?.cancel(); _phoneController.dispose(); _codeController.dispose(); _phoneFocus.dispose(); _codeFocus.dispose(); super.dispose(); }

我见过不止一个项目因为漏了Timer.cancel导致线上偶发崩溃,排查起来还特别费劲,因为它是概率性的。养成习惯,凡是创建了需要手动释放的对象,写完立刻在dispose里补上。

5. 常见问题与排查技巧实录

5.1 高频问题速查表

问题现象可能原因排查方向与解决
倒计时切后台回来归零倒计时状态存在State里移到Controller,或用时间戳计算剩余秒数
连点两次发出两条短信未做防抖拦截UI禁用+逻辑层标志位双重拦截
请求异常后按钮永久禁用标志位未在finally重置检查try-catch-finally结构
页面销毁后报setState异常Timer未取消dispose里cancel所有Timer
iOS验证码不能自动填充未设置textContentType加上oneTimeCode相关配置
键盘弹出遮挡输入框未用可滚动容器外层套SingleChildScrollView
token被读取到明文用了shared_preferences改用flutter_secure_storage
弱网下请求一直转圈未设超时Dio配置connectTimeout和receiveTimeout

5.2 倒计时跨页面持久化的进阶方案

如果你的产品要求用户退出登录页再进来,倒计时还得继续,那单纯存在Controller里就不够了,因为Controller随页面销毁。这时候要用时间戳方案:发送成功时记录一个sendTime,存到全局(比如一个单例或本地存储),每次进入页面时用当前时间 - sendTime算出剩余秒数。这样无论页面怎么重建,倒计时都是准的。

int get remainingSeconds { if (_sendTimestamp == 0) return 0; final elapsed = DateTime.now().millisecondsSinceEpoch - _sendTimestamp; final remaining = 60 - (elapsed / 1000).floor(); return remaining > 0 ? remaining : 0; }

这个方案还有个好处:它不依赖Timer的精度。Timer在App被系统挂起时是不走的,但时间戳一直在走,所以切后台再回来,剩余时间依然准确。

5.3 验证码错误次数限制的处理

很多后端会对同一手机号的验证码错误次数做限制,比如错5次锁定10分钟。前端要做的不是自己计数(因为可以绕过),而是正确展示后端返回的错误信息。当后端返回“验证码错误,还剩X次机会”时,把这个提示原样展示给用户,比前端自己编提示准确得多。

但有个体验优化点:当错误次数接近上限时,可以提前给用户一个温和的提醒,比如“连续错误多次将暂时锁定,请仔细核对”。这个提示不阻断操作,只是降低用户继续乱试的概率。

5.4 我踩过的几个真实坑

第一个坑是输入框焦点管理。手机号和验证码两个输入框,用户填完手机号点获取验证码后,理想情况是焦点自动跳到验证码框。但如果获取验证码失败,焦点就不该跳。我一开始无脑跳焦点,结果用户还没看到错误提示就被跳到下一个框了,很困惑。后来改成只有发送成功才跳焦点。

第二个坑是错误提示的展示位置。一开始我用SnackBar弹错误,但SnackBar会遮挡底部内容,而且多个错误连续弹出时会排队,用户要等好几秒才能看完。后来改成在输入框下方用固定区域展示错误文字,配合输入框边框变红,直观且不打断操作。

第三个坑是Android的返回键。在登录页按返回键,如果倒计时还在跑,直接退出页面会导致Timer泄漏。解决办法是在WillPopScope(现在用PopScope)里拦截,或者确保dispose逻辑足够健壮。我选择后者,因为拦截返回键会让用户觉得App卡住了。

5.5 安全层面的几个必要措施

短信登录涉及账号安全,有几个措施不能省。第一,验证码在传输过程中必须走HTTPS,这个现在基本是标配。第二,登录接口要做频率限制,同一IP或同一设备短时间内大量请求要拦截,这个主要靠后端,但前端可以在请求头里带上设备标识辅助风控。第三,token要有过期时间,并且支持刷新,不能让一个token永久有效。第四,敏感操作(比如修改密码)应该要求重新验证,不能只凭登录态。

前端能做的还有一点:不要在日志里打印手机号和验证码。我见过开发阶段为了方便调试把验证码打进日志,上线时忘了删,这是很严重的信息泄露。用kDebugMode包一层,release版本自动不打印。

6. 写在最后的几点个人体会

这个模块我来来回回改过好几版,最大的感受是:登录功能的复杂度不在于代码量,而在于边界场景的覆盖。正常流程谁都能写出来,真正拉开差距的是那些“用户手滑了”“网络断了”“切后台了”“连点了”的场景。每覆盖一个边界,线上就少一类客诉。

另外,别过度设计。我见过有人为了登录模块引入了一整套状态机框架,结果维护成本比业务代码还高。对于大多数App,一个ChangeNotifier加几个标志位就够了。等业务真的复杂到需要更重的方案时再重构也不迟。

最后分享一个调试小技巧:测试短信登录时,让后端在测试环境返回固定的验证码(比如123456),这样不用真的收短信就能跑通全流程。但一定要确保这个逻辑不会带到生产环境,通常用环境变量控制。这个技巧能省下大量等待短信的时间,尤其是需要反复测试错误分支的时候。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进