
1. 为什么 Flutter 原生 TextField 做验证码总差点意思做 Flutter 验证码输入框这件事我前后返工过三次。核心检索词先摆出来Flutter 验证码 Widget 是基于 TextField 与 FocusNode 封装的自定义组件能实现逐格输入、自动聚焦、粘贴分发和错误态回显适合所有需要在登录、注册、支付确认页放 4 位或 6 位数字验证码的移动端开发者。原生 TextField 直接拿来当验证码用问题集中在三个地方。第一是光标Flutter 的 Cursor 高度没有对外暴露属性只能靠调 FontSize 间接影响安卓和 iOS 字体渲染不一致光标位置经常对不齐格子。第二是焦点切换如果你用多个 TextField 拼格子就得在每次输入后手动把 FocusNode 交给下一个iOS 上要不停切换第一响应者键盘才认安卓的键盘监听又偏底层Flutter 作为 UI 引擎在这块生态确实不够顺。第三是粘贴用户从短信里复制 6 位码原生方案要么整段塞进第一个格子要么直接丢失体验很割裂。我最后采用的思路是只用一个隐藏光标的 TextField 承接全部输入视觉层用 Row 画出 N 个格子自己用 Timer 画一个闪烁光标。这样输入、删除、粘贴、焦点全交给一个 TextField 处理UI 完全自定义安卓 iOS 表现一致。下面把完整源码、参数表和真机验证步骤拆开讲同时说明怎么通过 TaoToken 统一 Key 和 API 通道去接验证码校验接口避免每个环境各配一套密钥。2. TaoToken 前置准备统一 Key 与 API 通道验证码组件本身是纯 UI但它最终要调后端校验接口。多环境开发时最烦的就是测试、预发、生产各有一套 Key散落在不同配置文件里改一次要翻半天。我的做法是用 TaoToken 把模型和接口调用统一到一个通道上Key 只维护一份。TaoToken 的定位是统一的 API 接入层官网在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 。你需要先在控制台创建一个 Key控制台地址 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 创建完在 API Keys 页面 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 能看到完整密钥。文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。这里要强调一个概念验证码校验接口和模型调用走的是同一套 Base URL 和 Key 体系。也就是说你在 Flutter 里配一次 Base URL 和 Key既能调验证码校验也能调模型对话不用为每个服务单独管理凭证。模型对话入口在 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 如果你后面要做验证码识别或风控判断可以直接复用同一个 Key。对于长期做编码和 Agent 开发的团队Coding Plan 页面 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 提供了更稳定的配额方案。Claude Code 接入可以参考 https://taotoken.net/claude-code?utm_sourcetaotoken_aicg_blog_endutm_contentclaudecodeutm_campaignrewrite 。这些都属于前置准备配好之后 Flutter 端只需要读环境变量不硬编码密钥。3. 可复制配置Widget 源码与参数表先给配置片段。Flutter 项目里我建议把接口配置放在一个独立的 dart 文件用--dart-define注入避免密钥进版本库。下面是一个api_config.dart的写法// lib/config/api_config.dart class ApiConfig { static const String baseUrl String.fromEnvironment( TAOTOKEN_BASE_URL, defaultValue: https://taotoken.net/api, ); static const String apiKey String.fromEnvironment( TAOTOKEN_API_KEY, defaultValue: , ); static const String verifyPath /verify/code; }运行的时候这样传flutter run --dart-defineTAOTOKEN_BASE_URLhttps://taotoken.net/api \ --dart-defineTAOTOKEN_API_KEY你的Key接下来是核心 Widget。我把它拆成AuthCodeTextfield支持singleItem和bottomLine两种模式。关键点showCursor: false关掉原生光标自己用AnimatedOpacity画闪烁光标用一个TextEditingController承接全部输入FocusNode只维护一个点击任意格子都聚焦到它。// lib/widgets/auth_code_textfield.dart import dart:async; import package:flutter/material.dart; import package:flutter/services.dart; enum AuthCodeMode { singleItem, bottomLine } class AuthCodeTextfield extends StatefulWidget { final AuthCodeMode mode; final int length; final double itemWidth; final double itemHeight; final double itemSpacing; final Color? itemBackgroundColor; final Color? itemBottomLineColor; final Color? textColor; final Color? cursorColor; final double cursorWidth; final double cursorHeight; final bool enabled; final bool hasError; final ValueChangedString? onChanged; final ValueChangedString? onCompleted; const AuthCodeTextfield({ super.key, this.mode AuthCodeMode.singleItem, this.length 4, this.itemWidth 50, this.itemHeight 50, this.itemSpacing 12, this.itemBackgroundColor, this.itemBottomLineColor, this.textColor, this.cursorColor, this.cursorWidth 1, this.cursorHeight 30, this.enabled true, this.hasError false, this.onChanged, this.onCompleted, }); override StateAuthCodeTextfield createState() _AuthCodeTextfieldState(); } class _AuthCodeTextfieldState extends StateAuthCodeTextfield { late final TextEditingController _controller; late final FocusNode _focusNode; Timer? _cursorTimer; bool _cursorVisible true; override void initState() { super.initState(); _controller TextEditingController(); _focusNode FocusNode(); _startCursorBlink(); } void _startCursorBlink() { _cursorTimer?.cancel(); _cursorTimer Timer.periodic(const Duration(milliseconds: 500), (_) { if (mounted) setState(() _cursorVisible !_cursorVisible); }); } override void dispose() { _cursorTimer?.cancel(); _controller.dispose(); _focusNode.dispose(); super.dispose(); } void _handleChanged(String value) { final digits value.replaceAll(RegExp(r\D), ); final clipped digits.length widget.length ? digits.substring(0, widget.length) : digits; if (clipped ! value) { _controller.value TextEditingValue( text: clipped, selection: TextSelection.collapsed(offset: clipped.length), ); } widget.onChanged?.call(clipped); if (clipped.length widget.length) { widget.onCompleted?.call(clipped); } } override Widget build(BuildContext context) { final text _controller.text; final activeIndex text.length widget.length ? -1 : text.length; return Stack( alignment: Alignment.center, children: [ Opacity( opacity: 0, child: TextField( controller: _controller, focusNode: _focusNode, enabled: widget.enabled, keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], maxLength: widget.length, showCursor: false, enableInteractiveSelection: false, onChanged: _handleChanged, decoration: const InputDecoration( counterText: , border: InputBorder.none, ), ), ), GestureDetector( onTap: () { if (widget.enabled) _focusNode.requestFocus(); }, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: List.generate(widget.length, (i) { final char i text.length ? text[i] : ; final isActive i activeIndex _focusNode.hasFocus; return Padding( padding: EdgeInsets.symmetric(horizontal: widget.itemSpacing / 2), child: _buildItem(i, char, isActive), ); }), ), ), ], ); } Widget _buildItem(int index, String char, bool isActive) { final borderColor widget.hasError ? Colors.red : (isActive ? (widget.cursorColor ?? Colors.blue) : Colors.grey.shade300); if (widget.mode AuthCodeMode.bottomLine) { return Container( width: widget.itemWidth, height: widget.itemHeight, alignment: Alignment.center, decoration: BoxDecoration( border: Border( bottom: BorderSide(color: borderColor, width: 2), ), ), child: _buildContent(char, isActive), ); } return Container( width: widget.itemWidth, height: widget.itemHeight, alignment: Alignment.center, decoration: BoxDecoration( color: widget.itemBackgroundColor ?? Colors.grey.shade100, borderRadius: BorderRadius.circular(8), border: Border.all(color: borderColor, width: 1.5), ), child: _buildContent(char, isActive), ); } Widget _buildContent(String char, bool isActive) { if (char.isNotEmpty) { return Text( char, style: TextStyle( fontSize: 22, fontWeight: FontWeight.w600, color: widget.textColor ?? Colors.black87, ), ); } if (isActive) { return AnimatedOpacity( opacity: _cursorVisible ? 1 : 0, duration: const Duration(milliseconds: 100), child: Container( width: widget.cursorWidth, height: widget.cursorHeight, color: widget.cursorColor ?? Colors.blue, ), ); } return const SizedBox.shrink(); } }参数对照表如下方便你按设计稿调参数类型默认值作用modeAuthCodeModesingleItem格子风格方块或下划线lengthint4验证码位数4 或 6itemWidthdouble50单格宽度itemHeightdouble50单格高度itemSpacingdouble12格子间距itemBackgroundColorColor?灰singleItem 模式背景itemBottomLineColorColor?灰bottomLine 模式底线cursorWidthdouble1自绘光标宽度cursorHeightdouble30自绘光标高度enabledbooltrue禁用态开关hasErrorboolfalse错误态回显边框变红onChangedValueChangednull每次输入回调onCompletedValueChangednull输满回调调用示例AuthCodeTextfield( mode: AuthCodeMode.singleItem, length: 6, itemWidth: 48, itemHeight: 48, itemSpacing: 10, cursorColor: const Color(0xFF5EB28A), hasError: _hasError, onChanged: (code) setState(() _code code), onCompleted: (code) _verify(code), )4. 验证请求与真机成功结果组件画好了得接上校验接口才算闭环。我用 TaoToken 的 Base URL 统一发请求这样 Key 和通道只有一份。下面是一个最小可跑的校验函数// lib/service/verify_service.dart import dart:convert; import package:http/http.dart as http; import ../config/api_config.dart; class VerifyService { static Futurebool verifyCode(String code) async { final resp await http.post( Uri.parse(${ApiConfig.baseUrl}${ApiConfig.verifyPath}), headers: { Content-Type: application/json, Authorization: Bearer ${ApiConfig.apiKey}, }, body: jsonEncode({code: code}), ); if (resp.statusCode 200) { final data jsonDecode(resp.body); return data[valid] true; } return false; } }真机验证我按四个动作走一遍每个动作都有明确预期输入点第一个格子键盘弹出光标在第一个格子闪烁。输入 1光标跳到第二格连续输满 6 位onCompleted触发控制台打印完整码。粘贴从短信复制 6 位数字长按输入区粘贴。预期是 6 位一次性分发到 6 个格子而不是全塞进第一格。这里依赖_handleChanged里的正则过滤和截断逻辑多余字符会被丢掉。删除按退格键最后一位消失光标回退到对应格子。因为只有一个 controller删除行为天然正确不需要手动切 FocusNode。禁用态把enabled设为 false点击格子不弹键盘边框变灰。错误态则把hasError设为 true边框变红配合接口返回的valid: false使用。实测下来安卓和 iOS 表现一致光标高度完全由cursorHeight控制不再受字体影响。接口返回 200 且valid: true时页面跳转返回 false 时把hasError置 true同时清空 controller 让用户重输。5. 本篇常见错排查第一个高频报错是 401 Unauthorized。原因通常是 Key 没通过--dart-define传进去ApiConfig.apiKey为空。排查方法在main里打印ApiConfig.apiKey.isEmpty如果为 true检查运行命令有没有带--dart-defineTAOTOKEN_API_KEY...。注意 Key 不要写进代码再提交否则轮换时很痛苦。第二个是 local proxy failed。这个一般出现在你本地配了网络代理工具请求发不出去。解决方式是确认请求直连https://taotoken.net/api检查http包有没有被全局代理拦截。如果你在模拟器里跑模拟器网络和宿主机不一定一致建议先用真机验证。第三个是 reading choices 相关报错。这个多出现在你复用同一个 Key 去调模型对话接口时返回体结构和校验接口不同代码里按data[valid]取值就会解析失败。排查方法先把resp.body原样打印出来确认返回结构再决定取哪个字段。校验接口和模型接口的返回格式不一样别混用解析逻辑。第四个是 OAuth 相关报错。如果你用 Claude Code 或 Codex 这类工具接 TaoToken认证方式可能走 OAuth 流程报错通常提示 token 过期或 scope 不对。这时候去 API Keys 页面重新生成一个 Key确认权限范围包含你要调的服务。Codex 的auth.json里 Base URL 要写https://taotoken.net/apiKey 写 Bearer 后面的那串Model ID 按文档填三件套缺一不可。第五个是粘贴不生效。检查enableInteractiveSelection是否被设成 false 导致长按菜单出不来。我上面的源码里设了 false 是为了防止用户选中隐藏 TextField 的文字但粘贴菜单也会受影响。折中方案是保留一个可见的粘贴入口或者在onTap里主动读剪贴板onTap: () async { final data await Clipboard.getData(text/plain); if (data?.text ! null) _handleChanged(data!.text!); _focusNode.requestFocus(); }这样点格子时自动尝试粘贴体验比长按菜单更顺。6. 把验证码校验接进统一通道组件和接口都跑通之后剩下的事就是别让 Key 散落各处。我的习惯是Flutter 端只认TAOTOKEN_BASE_URL和TAOTOKEN_API_KEY两个环境变量CI 里按环境注入不同值代码零改动。验证码校验、模型对话、后续的风控判断全部走同一个 Base URL换环境只换注入参数。如果你还在用多个 Key 分别管不同服务建议去 API Keys 页面 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 收敛成一个接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 有完整的请求示例。需要长期跑编码和 Agent 任务的Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 的配额更稳Claude Code 接入参考 https://taotoken.net/claude-code?utm_sourcetaotoken_aicg_blog_endutm_contentclaudecodeutm_campaignrewrite 。最后留一个我踩过的坑自绘光标的 Timer 一定要在dispose里 cancel否则页面销毁后 Timer 还在跑setState会报 mounted 错误。上面源码里已经处理了你复制时别把_cursorTimer?.cancel()删掉。