
有了账号密码还不够现在的App基本都得过验证码这一关。短信验证码、图形验证码、行为验证码轮番上阵登录、注册、改密、支付每一步都有它的身影。之前做项目的时候我发现很多团队对验证码这块的处理特别“随缘”——随便拉个EditText再配个Button倒计时能用就行。结果就是各种奇怪的问题倒计时和界面不同步、输入框粘贴校验码时错位、屏旋转之后已输入的验证码全没了、连点两下发了两次验证码。这篇文章打算把你我都在做的事情聊透——用Android从零做一个专门的验证码表其实就是一套可复用的验证码输入与校验组件把需求拆解、状态管理、倒计时逻辑、自定义绘制、校验策略和状态恢复这些核心环节一次理清。不管你是刚入门想搞懂验证码交互怎么实现还是想在项目里落地一套稳健的验证码组件这篇文章都值得花几分钟过一遍。1. 验证码表到底是个什么组件需求形态与适用场景1.1 从登录页痛点说起我还记得第一次接到“优化登录注册流程”这个需求时光看那页面就头疼验证码输入框用的是最原始的EditText四个数字挤在一个输入框里用户输完还要手动跳到下一步拿不到短信时的重发等待只能靠用户自己记时间。这种体验别说用户了我自己测试都觉得难受。所谓“验证码表”本质上不是指某张数据库表而是指一个专门承载验证码输入、展示、倒计时、校验整套交互逻辑的组件单元。把验证码的UI和逻辑收敛到一起对外提供统一的回调接口这是它和普通EditText最本质的区别。拿生活中的例子类比普通输入框就像一张白纸你想写什么、写成什么样都行但要让它变成一张标准化的答题卡就需要在纸上印好格子、写好填充规则、规定得分标准——验证码表做的就是这件“印格子”的事。1.2 验证码表的三种主流形态做组件之前先把形态理清楚否则后面容易做成四不像。我在实际项目中见到的验证码表主要有三类形态典型样式适用场景独立输入框单输入框键盘唤起后数字逐个录入最简单适合交互频率低的后台类页面分段格栅式4/6个独立格子输入自动跳下一个格子主流App的默认选择视觉反馈强组合区块式验证码输入区 倒计时按钮 错误提示完整页面登录/注册/支付一体式布局三种形态没有绝对的好坏纯看业务场景。我做“专门的验证码表”时选的是第二种——分段格栅式因为它在视觉上最能体现“一个格一个数字”的确定性用户输入时天然有节奏感也方便做“第几位输入错误”这类精确提示。当然组件的设计要留出切换形态的余地所以底层数据结构一定要通用不能和某一种UI强绑定。1.3 为什么不能直接拿开源库一抄了事很多人一听“做验证码输入框”第一反应是去GitHub找一个Star高的开源库。这没错我也这么干过但实际用下来会发现三个痛点第一开源库的功能往往过剩。我找过一个开源验证码控件支持图形拖拽、滑块拼接、短信自动填充结果我只想用4位数字输入光剔除多余代码就花了一个下午。第二样式定制困难。UI稿里的下划线颜色、聚焦动画、边框粗细每个项目都不一样开源库的API未必覆盖得了最后还是得自己改源码。第三项目里的验证码需求往往不是单一控件而是“输入框 倒计时 发请求 校验”这个完整闭环。自己做一个代码表组件表面上多花了时间实际上是把整个闭环的掌控权拿在手里——后续服务端加加密字段、换验证码策略都只改一个模块而不是在五个Activity里到处找逻辑。所以自己动手做一个专门的验证码表在长期维护上的收益远大于刚开始省下来的那点时间。2. 核心架构设计状态机、数据模型与模块分层2.1 先用状态机理清验证码的一生写代码之前我习惯先画状态图。验证码从“未发送”到“校验完成”会经历这几种状态待发送、发送中、待输入已发送等待用户输入、校验中、校验成功、校验失败、倒计时中、已过期。如果不把这些状态收拢到一个明确的模型里而是散落在Activity的回调里就会出现“倒计时还剩3秒但按钮已经可以点击了”“验证码还没输完就发起了校验请求”这类错乱。我定义的状态枚举长这样enum class VerifyState { IDLE, // 初始状态什么也没发生 SENDING, // 正在请求后端发送验证码 COUNTING_DOWN, // 发送成功按钮进入倒计时 INPUTTING, // 倒计时结束或进行中用户可输入验证码 VERIFYING, // 正在向服务端提交校验 VERIFY_SUCCESS, // 校验通过 VERIFY_FAILED, // 校验失败 EXPIRED // 验证码已过期超时/失效 }状态机的好处是每个事件点击发送、文本变化、收到响应、点击校验只做一件事——把当前状态转移成下一个合法状态。如果转移不合法组件直接忽略或抛出明确错误不会产生“幽灵状态”。比如只有COUNTING_DOWN或INPUTTING时才能进入VERIFYING如果IDLE状态下传来“校验成功”的事件那一定是逻辑写错了。2.2 数据模型设计验证码表的数据模型别整太复杂但要完整覆盖业务需要的字段。我长期这么用data class VerifyCodeConfig( val length: Int 4, // 验证码位数 val timeoutSeconds: Int 60, // 倒计时时长 val expireAfterSeconds: Int 300, // 验证码有效期跟后端约定 val onlyDigit: Boolean true, // 是否只允许数字 val autoSubmitWhenFull: Boolean true, // 输满自动提交 val totalRequestLimit: Int 5 // 一天内的最大发送次数防滥用 )length决定有几位格子timeoutSeconds决定按钮文字变化节奏expireAfterSeconds这个字段很多人会漏——我见过不少项目只做了倒计时倒计时结束之后验证码其实已经失效了用户再输完提交才发现错误体验很差。倒计时和有效期要分开倒计时控制的是“什么时候可以再次发送”有效期控制的是“当前这个验证码什么时候作废”它们是两个维度的概念。2.3 模块分层与回调设计组件对外暴露的接口要干净。我的分层思路是三层View层处理绘制、点击、文本变化事件不持有业务请求逻辑。ViewModel层持有VerifyState状态机和倒计时逻辑对外暴露StateFlowVerifyState。Callback层仅暴露三个逻辑回调——onSendRequest()、onVerifyRequest(code)、onStateChanged(state)。为什么要这样分层因为验证码表可能被用在完全不同的页面里登录页需要发送短信支付页可能发送的是邮件验证码后台审核页甚至可能对接的是企业内部IM验证码。View层和业务层解耦之后页面只需关心“发送成功后怎么处理结果”组件自己内部把倒计时和UI刷新管好。3. 倒计时逻辑与重发按钮最容易出bug的地方3.1 倒计时的标准实现倒计时的坑我在多个项目里踩过说实话网上教程里大部分倒计时写法都有问题——最常见的做法是在Activity里直接Handler.postDelayed递归倒数页面销毁时忘了移除回调结果用户把页面关了后台还在刷Log严重的直接内存泄漏。我现在的做法是把它收敛到ViewModel里用viewModelScope来管理协程生命周期。核心逻辑是每1秒发射一次倒计时数值fun startCountdown(totalSeconds: Int config.timeoutSeconds) { if (countdownJob?.isActive true) return _stateFlow.value VerifyState.SENDING countdownJob viewModelScope.launch { for (second in totalSeconds downTo 0) { _remainingSeconds.value second _stateFlow.value VerifyState.COUNTING_DOWN delay(1_000L) } _stateFlow.value VerifyState.INPUTTING } }用协程而不是Handler的好处在于viewModelScope会在ViewModel销毁时自动取消协程不需要手动移除回调。另外要注意delay(1000)不是精确间隔如果系统繁忙可能会多等几百毫秒所以我在循环里记录的是“目标结束时间”而不是每次remaining - 1这样能保证最终时间准确val endTime System.currentTimeMillis() totalSeconds * 1000L while (System.currentTimeMillis() endTime) { val remain ((endTime - System.currentTimeMillis()) / 1000L).toInt() _remainingSeconds.postValue(remain) delay(500L) // 每500ms校准一次 }3.2 生命周期带来的坑倒计时最经典的Bug是“应用退到后台回来之后倒计时还是原来的数”。原因很简单delay在进程暂停期间不会正常触发有的设备会把协程挂起等回到前台才继续执行导致倒计时错乱。我实测下来解决这个问题至少需要两步在启动倒计时时把endTime存到持久化介质我用SavedStateHandle后面会细说。在回到前台时ON_RESUME读回endTime如果当前时间已经超过endTime直接进入INPUTTING状态如果没超过用差值重新校准倒计时数值。fun onScreenResume() { val remaining ((savedEndTime ?: 0) - System.currentTimeMillis()) / 1000L if (remaining 0) { _stateFlow.value VerifyState.INPUTTING } else { startCountdown(remaining.toInt()) } }这个细节一开始容易被忽略但生产环境里用户切后台的频率特别高不做处理验证码页面会一直处于倒计时完成的错误状态。3.3 服务端校验一次还是两次这里插一个容易被忽略的业务决策发送验证码和校验验证码服务端到底要不要分开成两个接口按我的经验必须分开。发送接口只负责下发校验接口负责验证两个接口都有独立的限流策略。如果为了省事只做一个接口就会出现“用户点一次获取验证码请求超时重试时又把验证码改发了一遍”这种问题。组件层面对这个决策的呼应方式是发送请求必须携带一个requestId客户端生成的UUID服务端用这个id做幂等处理。同样的requestId重复请求服务端直接返回上次的结果不会重复发短信。这个设计放到验证码表组件里就是sendRequest()回调里必须能传requestId给页面层。4. 自定义输入框的绘制与交互从EditText到纯手绘4.1 用EditText还是自定义View验证码表的输入层有两个方案一是用EditText限制字符长度监听文本变化后手动把字符拆到几个格子View里二是完全自定义一个View自己处理绘制和触摸事件。先说结论我最终选了“自定义View 隐藏EditText输入法桥接”的组合方案。用EditText做桥接的好处是不用自己实现输入法的兼容逻辑带收音机麦克风的输入法、中文输入法、Gboard等真的非常难搞但UI层完全自己绘制响应TextWatcher后刷新格子。这个方案的核心结构是class VerifyCodeInputView JvmOverloads constructor( context: Context, attrs: AttributeSet? null ) : View(context, attrs) { private val hiddenEditText EditText(context) private var inputText: String init { // 隐藏EditText宽度和高度设为0但保持可聚焦 hiddenEditText.setTextColor(Color.TRANSPARENT) hiddenEditText.setBackgroundColor(Color.TRANSPARENT) hiddenEditText.setPadding(0, 0, 0, 0) hiddenEditText.inputType InputType.TYPE_CLASS_NUMBER hiddenEditText.addTextChangedListener(object : TextWatcher { override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { // 只保留前length位 inputText s.toString().filter { it.isDigit() }.take(length) hiddenEditText.removeTextChangedListener(this) hiddenEditText.setText(inputText) hiddenEditText.setSelection(inputText.length) hiddenEditText.addTextChangedListener(this) invalidate() onInputChange?.invoke(inputText) } override fun afterTextChanged(s: Editable?) {} override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} }) } }为什么用透明EditText桥接而不是直接重写onKeyDown因为现在的输入法形态太多了手写、语音、滑动输入一部分字符事件根本不会以KeyEvent的形式传给View。架一个透明EditText让输入法把所有兼容性工作做完我再从TextWatcher拿结果是最省心也最稳妥的路线。4.2 绘制格子与下划线绘制部分的核心是算格子位置。宽度减去左右padding平均分给length个格子每个格子之间留一点间距。绘制两种样式矩形边框或下划线。我默认做成下划线样式因为iOS端视觉稿最喜欢这种Android侧自绘也不复杂override fun onDraw(canvas: Canvas) { super.onDraw(canvas) val gap dp2px(12f) val slotWidth (width - paddingLeft - paddingRight - gap * (length - 1)) / length for (i in 0 until length) { val left paddingLeft i * (slotWidth gap) val right left slotWidth val underlineY height - dp2px(8f) val paint if (i inputText.length) focusedPaint else defaultPaint canvas.drawLine(left, underlineY, right, underlineY, paint) } }字体绘制也要注意数字不是画在下划线上面就能垂直对齐的需要动态计算FontMetrics的baseline位置否则数字会偏上或偏下。我一般这样处理val fontMetrics textPaint.fontMetrics val baseline (height - fontMetrics.bottom - fontMetrics.top) / 24.3 光标与点击定位分段输入框最容易被轻视的交互是光标。如果光标一直在透明EditText里屏幕上看不到用户就不知道“现在焦点在第几个格子”。我的方案是自绘一个模拟光标当输入长度为k时k length在k1个格子中央绘制一条闪烁的竖线。闪烁效果用一个循环动画实现val cursorBlinkValue animate( initialValue 1f, targetValue 0f, animationSpec infiniteRepeatable( animation tween(durationMillis 600), repeatMode RepeatMode.Reverse ) )点击定位的处理方式是点击事件触发时把隐藏EditText的光标位置移动到对应数字前面。注意这里的“移动”不是setSelection到格子索引而是要回退多位——因为EditText里的字符串就是整个验证码想把光标定位到第3个格子就要setSelection(2)。4.4 粘贴事件处理有一类高频操作很容易被忽略用户收到短信后会直接长按粘贴验证码。如果验证码表只有格子而没有针对粘贴的优化粘贴的6位数字会一次性涌入EditText再被我按take(length)截断顺序没问题但用户看不到“逐位填充”的效果体验打折。更好的做法是拦截粘贴行为逐位填充并播放轻微的填充动画。实现方式是重写隐藏EditText的onTextContextMenuItemoverride fun onTextContextMenuItem(id: Int): Boolean { if (id android.R.id.paste) { val clipboard context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val pasteText clipboard.primaryClip?.getItemAt(0)?.text?.toString() ?: return super.onTextContextMenuItem(id) val digits pasteText.filter { it.isDigit() }.take(length) animateFillDigits(digits) // 逐位填充 return true } return super.onTextContextMenuItem(id) }5. 校验策略与安全细节防爆破、防篡改、防傻提交5.1 本地校验和远程校验的分工验证码表组件内部不应该只做UI还需要把校验策略吃透否则它充其量是个好看点的输入框。校验分成两个层次本地即时校验检查是否输满位数、是否全是数字、是否在有效期内。这些不要求网络请求纯本地判断能在用户点击“下一步”之前就拦截明显错误。远程校验把用户输入的验证码提交到服务端服务端结合会话信息验证正确性、是否过期、是否已被使用过。远程校验的结果以2xx/4xx/5xx划分4xx里还要细分验证码错误、验证码过期、请求过于频繁等业务语义。这两个层次必须分开因为提交失败的错误提示完全不一样本地校验失败要提示“请输入完整的验证码”远程校验失败要提示“验证码错误请重新获取”。如果混在一起用户会看到一串不符合场景的话术。5.2 请求去重与防抖动防抖是另一个容易踩坑的地方。用户连点两次“获取验证码”按钮是不是就发了两次短信连点两次“提交校验”是不是就提交了两次请求在组件层我用两个手段防按钮点击后立即置为不可用结合倒计时状态COUNTING_DOWN让第二次点击事件根本不会触发。校验提交时加一个“进行中”状态锁——VERIFYING状态下任何新的提交动作被直接忽略直到服务端响应返回并回到新的状态。fun verifyCode(code: String) { if (stateFlow.value ! VerifyState.INPUTTING) return // 非可校验状态直接忽略 _stateFlow.value VerifyState.VERIFYING onVerifyClick?.invoke(code) }这里有个设计细节状态机不只是给组件自己看的页面层也要根据VERIFYING状态显示加载动画、屏蔽按钮。一份状态变化能同时驱动UI和逻辑这是我坚持状态机方案的原因。5.3 传输加密要点验证码本身属于敏感信息客户端提交到服务端一般要走HTTPS这是底线。但我在实际审计代码时发现不少人忽略了另一个点验证码在日志里不要明文打印。很多团队在开发期Debug时把请求体和响应体全量打到Log里验证码自然也打了进去。一旦用户手机被root、日志被抓取验证码就泄露了。我的规矩是发送请求和响应解析的地方统一脱敏验证码字段在日志里统一打码为****。这个可以在拦截器层面做也可以在组件回调前处理但要写进团队的代码规范里。另外Android项目的混淆规则也要给验证码相关类增加keep规则否则发布版混淆后服务端的联调排错会变得很痛苦——报错日志里类名变成了a.b.c只能靠接口路径反推。6. 状态保存与恢复旋转屏幕不丢已输验证码6.1 配置变更导致的内容丢失验证码表有一个很隐蔽但影响很大的问题屏幕旋转、切到系统深色模式、分屏操作时Activity会重建如果组件没有做状态保存用户刚输完的3位验证码瞬间清空倒计时归零整个体验直接崩掉。大概两年前我做过一个统计客服反馈的登录问题里“输入验证码后旋转屏幕导致又要重新获取”占了不小比例可见这问题在真实用户群体里非常普遍——根本不是边缘场景。6.2 用SavedStateHandle保存从SavedStateHandle入手是目前Android官方的推荐做法。使用by viewModels()获取ViewModel时系统会默认把SavedStateHandle注入进来你可以直接在里面存取数据class CodeViewModel( private val savedStateHandle: SavedStateHandle ) : ViewModel() { fun saveInput(code: String) { savedStateHandle[input_code] code } fun restoreInput(): String { return savedStateHandle[input_code] ?: } fun saveCountdownEnd(endTime: Long) { savedStateHandle[countdown_end] endTime } }SavedStateHandle会自动处理“进程被系统杀死后重建”的场景数据会走到Bundle里比单纯在onSaveInstanceState里处理更省心也不需要自定义的Parcelable。6.3 倒计时恢复的完整链路完整的恢复触发点有三个Activity重建、进程被杀后重建、用户切后台后再回来。Activity重建ViewModel还活着SavedStateHandle里的endTime可以直接读取重新校准剩余秒数。进程被杀后重建SavedStateHandle从Bundle恢复需要重新启动倒计时协程。切后台再回前台onScreenResume里统一做一次校准前面已经提过。我把这三个触发点的恢复逻辑收敛到同一个入口方法里避免重复代码。这样不管用户怎么折腾验证码表的输入框内容和倒计时都能平滑恢复。也因为这个原因组件的 View 层不要把inputText存在它的成员变量里——必须和ViewModel同步否则配置变更时自定义View状态就丢了。做法是TextWatcher里每次变化都同步到savedStateHandle[input_code]。7. 上线前的实测与调优记录真实设备上的那些意外7.1 低端机上的绘制问题一次在测试机上验证码表出现了输入卡顿。仔细排查后发现不是文本逻辑的问题而是onDraw里画格子、画数字、画光标动画时贴图重绘过于频繁且那台旧设备 GPU 渲染能力弱。解决方式把不变的部分格子下划线背景拆成单独的静态Layer不必每次invalidate()都重画。光标闪烁动画只重绘光标区域而非整个View——用postInvalidateOnAnimation只刷新局部脏区。避免在onDraw里创建新的Paint对象——Paint在构造里就初始化好绘制方法里只复用。优化前后绘制帧率有明显提升输入跟手性回到正常水平。7.2 输入法遮挡还有一个高频问题验证码表放在页面下方键盘弹起时被挡得只露出半个格子。大家习惯性做法是adjustResize 软键盘监听但实际上不同厂商ROM的软键盘insets行为不完全一致部分设备adjustResize无效。我在组件文档里写了一条明确指导验证码输入框尽量放在页面中部往上避免和软键盘区域重叠如果必须放底部配合WindowCompat.setDecorFitsSystemWindows(window, false)并监听ViewCompat.setOnApplyWindowInsetsListener来动态增加paddingBottom。这个比依赖adjustResize行为更可控。7.3 自动化测试的回归价值验证码表的逻辑比较密集输入截断、倒计时校准、状态机流转、粘贴解析、恢复逻辑随便改一行就可能影响另一处。我写过一轮基于Espresso的UI测试用例覆盖这些核心路径Test fun inputFourDigits_renderFourSlots() { onView(withId(R.id.inputVerifyCode)).perform(click()) onView(withId(R.id.inputVerifyCode)).perform(typeText(1234)) // 断言界面显示4个数字且自动提交回调已经触发 } Test fun countdownFinished_buttonEnableAgain() { // 通过mock把倒计时初始值设为1秒 // 等待2秒后断言按钮文字变回获取验证码且可点击 }自动化测试看起来多花时间但每次改组件或者升级依赖库之后跑一遍能省下大把手工回归时间对一个要长期维护的基础组件来说非常值。写在最后做一个“专门的验证码表”表面上是把输入框加几个格子深了看反而是把一个业务闭环的所有环节都理顺状态机如何流转、倒计时如何和生命周期共存、校验如何和安全诉求匹配、状态如何在各种极端情况下恢复。这个过程里我踩过的坑远比我写出来的多但正是一次次的实测复盘才让这套组件能在多个项目里稳定跑下来。如果你目前正打算给项目写验证码组件我的建议是先别急着写代码把你的验证码状态流转图画清楚把倒计时、输入、校验、恢复这几条线理顺再动手会顺畅很多。如果你已经写完并上线了也欢迎回来对照这篇文章看看有没有哪一块是你当初没考虑到的。