晚上十点多,某项目登录模块的负责人把监控截图甩到群里,说过去两天有大量异常请求绕过短信验证码接口,注册量曲线平白无故多出一截。产品那边的诉求很直接:登录页加一道人机校验,而且要快,最好一周内上线。我们前后试过几个现成方案,要么包体积往上窜,要么交互样式跟设计稿差得远,最后决定自己写一个Android滑块验证码组件。这篇就聊这件事——一个滑块验证码从画出来、摸得动、校验得准,到最后能当组件塞进任何页面,中间要踩多少细节。文章适合已经写过自定义View、但对"人机校验"这条链路没完整做过的 Android 同学;如果你只是想找个能复制的实现,文中的代码和参数也可以直接拿走改。至于标题里那句"5行代码",后面我会拆开讲它到底指什么,以及为什么我坚持认为调用方只该写5行。
1. 把"5行代码"这句话拆开看:封装边界到底该划在哪
先把这个容易引起误会说法讲清楚。所谓"5行代码打造",不是说滑块验证码这个功能本身只要5行就能实现——那不可能,光绘制逻辑就够写几百行。它指的是调用方接入侧的代码量:在你的 Activity 或 Fragment 里,注册回调、塞入初始参数、启动加载,加起来差不多5行。真正的复杂度全部压在组件内部。
我见过的反面案例不少。有的库把Bitmap、Rect、Paint这些东西全抖落到调用方,接入时要自己算背景图缩放、自己判断校验结果、自己处理重置动画,最后业务代码里塞了六七十行跟业务毫无关系的绘图代码。这种做法的问题不在于长,而在于耦合:背景图的裁剪方式一旦要改,所有接入页面都得跟着改。
所以封装边界的第一条原则是:调用方不该知道拼图块的坐标是怎么算出来的,也不该知道校验结果是从本地还是从服务端拿到的。
1.1 调用方真正需要关心的只有三件事
我把整个组件的对外接口压缩到三个维度:
| 维度 | 调用方要传什么 | 为什么是它 |
|---|---|---|
| 初始化参数 | 验证码图片地址或 Bitmap、拼图块的偏移基准 | 只有调用方知道这次请求该拿哪张图、对应哪个会话 |
| 结果回调 | 成功 / 失败 / 网络异常三种状态 | 业务侧要根据结果决定是放行、重试还是提示 |
| 生命周期 | 绑定与解绑、手动重置 | 避免页面销毁后回调仍然打到已经不存在的 View 上 |
除此之外的一切——拖动手柄的样式、进度条颜色、失败提示文案——都应该是内部默认值,需要改的时候用可选的配置项覆盖。这里有个经验:可选配置项超过15个,基本说明这个组件的默认设计有问题。我第一版写了三十多个 setter,结果每个接入方都配得不一样,出了问题根本没法统一排查,后来砍到11个,只保留颜色、圆角、文字大小这类视觉项,行为相关的全部内部固化。
1.2 从"能跑"到"能上线"之间的三层复杂度
很多人第一次写滑块验证码,卡在"能跑"这一层就停了。Demo 阶段确实简单:画个背景图,画个能拖的方块,松手判断 x 坐标差值,够了。但要上线,至少还要跨过三层。
第一层是绘制的正确性。背景图从服务端拿下来,尺寸千奇百怪,你要做等比缩放和居中裁剪;拼图块的"洞"要在背景图上挖出来,位置还得跟用户拖动的方块严格对应;不同屏幕密度下,用 px 还是 dp 画线,直接决定在高分屏上是不是一根头发丝。
第二层是交互的手感。跟手度这个东西很难量化,但用户一摸就知道。手指按下去了,滑块要立刻有按压反馈;手指移动了,滑块要严丝合缝跟着走,不能有延迟感,也不能超出轨道两端;松手了,要么吸附到正确位置,要么带一点回弹动画滑回去。这些细节没有一条写在需求文档里,但少一条都会被吐槽"这验证码好难用"。
第三层是校验的可信度。这一层最容易被忽略。如果正确答案(那个拼图块该偏移多少像素)存在客户端内存里,那它一定能被读出来,甚至能被直接改判断逻辑。滑块验证码从设计之初就是个"防君子不防专业选手"的方案,但至少要做到:客户端不持有标准答案,校验在服务端完成,客户端的角色退化成一个"行为数据采集器"。
这三层里,第一层花的时间大概占三成,第二层五成,第三层两成——但第三层出问题,前面做得再漂亮也白搭。
2. 一块滑块面板的绘制链路:背景、挖洞与手柄
讲绘制之前得先定路线。安卓上做一个带拼图的滑块,有两条路可以走:一是继承ViewGroup,把背景图、拼图块、遮罩层、手柄各自做成子 View,用布局和属性动画拼起来;二是继承View,在onDraw里用Canvas一次性画完。
这两条路的差别,用一个比喻说清楚:前者像用积木搭房子,每块积木能单独换、单独动;后者像在画布上画一幅画,改一笔就得重画局部。
2.1 单View自绘与ViewGroup拼装,选哪个
我两个都实现过,第一版用的是ViewGroup,理由很朴素——做动效方便,手柄的按压反馈直接给子 View 加scaleX/scaleY就行。但上线后遇到两个麻烦。
第一个麻烦是层级与裁剪。拼图块要在背景图上"挖洞",如果用子 View 方案,就得让拼图块所在的 View 盖在背景图上方并设置PorterDuff混合模式,而PorterDuffXfermode在跨 View 混合时不生效——它只在同一个 Canvas 的绘制批次内有效。想跨 View 做遮罩,只能开setLayerType(LAYER_TYPE_SOFTWARE, null)强制软件渲染,代价是性能直接掉一档,低端机上滑一下能看出卡顿。
第二个麻烦是坐标系。拼图块的"洞"位置、用户拖动的方块位置、手柄的位置,分属三个子 View,各自的getX()是相对自己父容器的,一旦某个子 View 有 padding 或者 margin,坐标换算就要多一层。我在一次改版里给手柄加了marginStart,结果校验判定全线偏移,排查了两个多小时才定位到。
第二版换成单View自绘,问题一次性消失:所有元素共享同一个 Canvas 坐标系,挖洞就是drawBitmap时切Xfermode,手柄位置就是一个Float变量。代价是动效要自己写插值,不过滑块的动效本来就简单——位移和缩放两个属性,ValueAnimator加个OvershootInterpolator就够了。
结论很明确:滑块验证码这种元素数量少、位置强相关、需要遮罩混合的场景,单 View 自绘是更优解。
2.2 背景图缩放与拼图块的坐标系对齐
绘制里最容易出问题的一步,是背景图从服务端的原始尺寸映射到控件尺寸。
服务端下发的图一般比控件大,比如给一张 600×300 的图,控件实际宽度只有 320dp。这里不能简单拉伸,否则拼图块会变形,用户看到的图形比例跟正确答案对不上。正确做法是按宽度等比缩放,高度不够的部分居中裁剪或者上下留白。
// 计算背景图在控件内的绘制区域(等比缩放 + 居中裁剪) private fun calcBackgroundRect(srcW: Int, srcH: Int, viewW: Int, viewH: Int): RectF { val scale = maxOf(viewW.toFloat() / srcW, viewH.toFloat() / srcH) val drawW = srcW * scale val drawH = srcH * scale val left = (viewW - drawW) / 2f val top = (viewH - drawH) / 2f return RectF(left, top, left + drawW, top + drawH) }注意这里用的是maxOf而不是minOf。用minOf是"完整显示"策略(FIT_CENTER),会留黑边;用maxOf是"填满"策略(CENTER_CROP),超出部分被裁掉。滑块验证码必须用后者,因为留白会让拼图块的边缘暴露答案——用户看到一块纯色区域,就知道洞一定在那边。
算出绘制区域之后,拼图块的位置也要跟着换算。设服务端给的拼图块原始坐标是(srcX, srcY),换算到控件坐标系就是:
val pieceLeft = bgRect.left + srcX * scale val pieceTop = bgRect.top + srcY * scale val pieceW = pieceWidthSrc * scale这里有一个隐蔽的坑:scale要在每次onSizeChanged时重算,而不是在onDraw里算。我见过有人在onDraw里做浮点除法,一秒钟六十次,虽然现代设备扛得住,但这是没必要的开销,而且容易因为浮点误差导致拼图块边缘出现一像素的抖动。
2.3 用PorterDuff挖洞时那几个容易翻车的参数
"挖洞"这个视觉效果,本质是在背景图上叠加一个半透明黑色遮罩,然后把拼图块所在区域从遮罩里抠掉,露出下面的清晰背景。
实现方式是给Paint设置Xfermode,在saveLayer出来的图层上先画遮罩,再用PorterDuff.Mode.CLEAR画一个圆角矩形把洞抠出来。
private val maskPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = 0x66000000 xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) } override fun onDraw(canvas: Canvas) { // 1. 画背景图 canvas.drawBitmap(bgBitmap, srcRect, bgRect, null) // 2. 开图层画遮罩 val sc = canvas.saveLayer(bgRect, null) canvas.drawRect(bgRect, maskPaint.apply { xfermode = null }) maskPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) canvas.drawRoundRect(holeRect, holeRadius, holeRadius, maskPaint) canvas.restoreToCount(sc) // 3. 画用户拖动的拼图块 canvas.drawBitmap(pieceBitmap, null, movingRect, null) }几个必须注意的点:
saveLayer一定要配对restoreToCount,漏掉会导致后续所有绘制都在图层里,表现是整块区域变黑或者变透明。- 遮罩的 alpha 值别设太高。0x66 大概是40%不透明度,已经够区分了;设到 0x99 以上,用户几乎看不清洞里的内容,体验直线下降。
- 开启硬件加速时,
CLEAR模式在某些老设备上对Bitmap的处理会有毛边。稳妥的做法是给Paint加上isFilterBitmap = true和抗锯齿,另外在onAttachedToWindow里检查一次硬件加速开关状态。 - 拼图块的形状要和洞的形状一致。如果用圆角矩形,
drawRoundRect的半径和拼图块 Bitmap 的圆角裁剪半径要调成同一个值,否则边缘会出现错位的一圈描边。
我在一个中低端机型上遇到过拼图块边缘出现锯齿状白边的情况,排查下来是Xfermode与saveLayer的边界裁剪问题。解法是把saveLayer的边界向内收 1px,或者干脆把遮罩层的边界设成背景图的RectF缩小一像素的范围。这个问题的表现很随机,跟具体 GPU 有关,所以最好在多种设备上过一遍。
3. 拖动跟手度:触摸事件里几个决定手感的具体数值
绘制解决的是"看起来对",触摸解决的是"摸起来对"。滑块验证码的交互逻辑简单到可以用一句话描述:按下手柄,左右拖,松手校验。但就这一句话,能拆出十几处细节。
3.1 DOWN/MOVE/UP三态与边界钳制
onTouchEvent的第一件事是判断ACTION_DOWN的落点是否在手柄区域内。这里有个容易被忽略的点:判定区域要比手柄的视觉尺寸大一圈。手柄画出来可能是 40dp 宽,但手指接触面积普遍在 45dp 以上,如果严格按视觉边界判定,用户会有"点上去没反应"的感觉。我的做法是给判定区域加 8dp 的容差,纵向也加,因为用户很少能精确落在手柄正中间。
override fun onTouchEvent(event: MotionEvent): Boolean { when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { if (isInHandle(event.x, event.y)) { dragging = true downX = event.x handleOffset = event.x - handleRect.left parent.requestDisallowInterceptTouchEvent(true) return true } } MotionEvent.ACTION_MOVE -> { if (!dragging) return false val targetLeft = (event.x - handleOffset).coerceIn(trackLeft, trackRight - handleWidth) handleRect.offsetTo(targetLeft, handleRect.top) invalidate() return true } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { if (!dragging) return false dragging = false onRelease() return true } } return false }几个数值上的判断:
coerceIn的上下界必须是手柄可移动的真实范围,而不是整个控件宽度。手柄走到最右边时,它的右边缘要刚好贴着轨道右端,不能越界。requestDisallowInterceptTouchEvent(true)这行不能省。如果滑块被放在ScrollView或ViewPager2里,不拦截父容器的话,用户横向拖到一半,父容器会把手势抢走,表现是滑块拖到中途突然停住、页面开始滚动。ACTION_CANCEL要跟ACTION_UP一起处理。来电、系统弹窗、多指操作都会触发 CANCEL,只处理 UP 的话,手柄会卡在半路上。
还有一点:ACTION_DOWN里如果落点不在手柄上,应该返回false而不是true。返回true会把整个控件变成触摸目标,用户在空白区域滑动时什么都不会发生,但事件被吃掉了,体验很怪。
3.2 容差、防抖与"松手才校验"的取舍
松手的瞬间要决定两件事:什么时候校验,以及允许多大的误差。
什么时候校验,有两种设计。一种是拖动过程中实时校验,滑块一进正确区域就变色提示;另一种是松手才校验。前者反馈快,但会把答案位置暴露给用户——他左右滑两下就知道该停在哪,人机校验的意义就没了。后者虽然反馈慢半拍,但安全性高得多。我选后者,并且松手后加一个短暂的延迟(大概120ms),让回弹动画先动起来再出结果,视觉上更自然。
允许多大误差,这个值直接决定通过率。定太严,用户明明对齐了还是失败,非常挫败;定太松,暴力枚举的成本就低了。
我的经验值是:容差 = 拼图块宽度的 4%~6%,且不小于 3dp。以一个 320dp 宽的面板、拼图块 50dp 为例,容差大概在 2~3dp,再加一个基础 1dp 的宽容度,最终 ±4dp 左右。这个范围在手指操作精度之内,同时意味着理论上至少要做 40 次左右的尝试才能蒙对一次——当然,这个"至少"只对真人成立,脚本可以无限次尝试,所以真正的防线还在服务端。
防抖同样重要。用户松开手指后如果立刻又按下拖动,上一轮的校验请求可能还在路上。我的处理是引入一个verifying状态位:校验进行中,手柄锁定不可拖动,同时显示一个很轻的加载指示;回来之后要么重置,要么放行。
3.3 失败降级与重置动画
失败之后怎么做,很考验设计。
第一种是原地重置:手柄滑回起点,拼图块回到初始位置,用户可以再试。这种方式最友好,但给了脚本无限次试错的机会。第二种是失败即刷新图片:重新拉一张背景图,重新生成拼图块位置。这种方式安全性高,但每次失败都要走一次网络请求,弱网下体验很差。
我的折中方案是累计失败次数:前两次原地重置,第三次开始强制刷新图片,第五次直接锁定并提示"稍后再试"。同时在服务端记录每个会话的尝试次数,超过阈值就换用更严格的校验方式。
重置动画这块,ValueAnimator从当前handleRect.left插值回起点,时长 260ms,DecelerateInterpolator。这个时长是试出来的:低于 180ms 感觉像瞬移,没有过程感;高于 350ms 又显得拖沓,用户会等得不耐烦。260ms 大概是手指从松开到视线重新聚焦的时间,比较舒服。
4. 校验逻辑的归属:客户端比对为什么不安全
前面铺垫了这么多绘制和交互,现在说最要命的部分——校验放在哪。
我见过不少实现,把"正确答案"作为一个Float存在 View 里,松手后直接if (abs(currentX - answerX) < tolerance)判断。这种写法在 Demo 里没问题,上线就是给攻击者送答案。原因很简单:安卓应用的代码和内存对有一定基础的人来说是可及的,反编译看一下,或者用调试工具挂上去读一下那个字段,答案就出来了。更省事的办法是直接 hook 那个判断方法,让它永远返回true。
4.1 本地比对的适用场景与致命伤
本地比对不是完全不能用,但要认清它的定位——它只能防"最粗糙的自动化脚本",防不了人工和有针对性的攻击。
如果产品的场景是防止批量注册灌水,且攻击者技术水平一般,本地比对加上一定的干扰手段(比如每次随机微调答案、加密字段名)勉强够用。但只要是涉及资金、账号安全、敏感操作的入口,本地比对就不能作为唯一防线。
它的致命伤有两个:
一是答案在客户端。无论你怎么混淆,只要客户端需要做比对,答案或者答案的某种可逆变换就必须存在于客户端。逆变换的成本对专业选手来说极低。
二是结果可篡改。客户端做的判断,客户端就能改。即使你把判断结果加密后传给服务端,攻击者也可以直接伪造那个加密串——除非加密的密钥不在客户端,那又回到了"答案在服务端"的路线。
4.2 一套最小可用的服务端校验协议
真正的做法是:客户端从头到尾不知道正确答案,它只负责采集数据、上传数据、接收结论。
具体流程我拆成四步:
第一步,客户端向业务服务端申请一次校验会话。服务端返回三样东西:
{ "session_id": "sess_a1b2c3", "background_url": "https://.../cap_bg_8392.jpg", "piece_url": "https://.../cap_piece_8392.png", "expires_in": 120 }注意这里下发的背景图和拼图块是已经挖好洞的成品,或者更常见的是背景图带洞、拼图块单独一张。无论哪种,下发的图片里不能包含任何坐标信息。有些人图省事,把拼图块的 x 坐标写在图片的 EXIF 或者 URL 参数里,这等于把答案贴在脑门上。
第二步,用户拖动完成,客户端把偏移量上传。这个偏移量应该是相对背景图原始宽度的归一化比例,而不是像素值:
{ "session_id": "sess_a1b2c3", "offset_ratio": 0.4273, "duration_ms": 1840, "track": [[12,40,0],[19,41,16],[27,42,32]] }用比例而不是像素,是为了让服务端的判断不受客户端分辨率影响。同一张图在 320dp 和 411dp 的屏幕上,拼图块该偏移的像素数差很多,但比例是一样的。
第三步,服务端拿offset_ratio跟真实答案比对,同时对track做特征分析(下一小节细说)。
第四步,服务端返回结论,附带一个一次性凭证。业务侧的登录接口校验这个凭证,才算真正放行。
{ "success": true, "ticket": "tk_9f8e7d6c", "ticket_expires_in": 60 }这套协议的关键点在于:客户端全程没有见过正确答案,它只是一个"把用户动作翻译成数字"的传感器。即使攻击者反编译了整个 App,他拿到的也只是一套上传接口,仍然需要对每张图重新计算位置。
4.3 轨迹采样:从"验证码"到"轻量风控"
如果只上传一个最终偏移量,那脚本的成本依然很低——每次随机猜一个值,猜中的概率是2×容差/面板宽度,试几百次总能中一次。
加入轨迹之后,成本会高一个量级。因为人的拖动轨迹和程序的模拟轨迹,特征差异很明显:
| 特征 | 真人拖动 | 程序模拟 |
|---|---|---|
| 速度曲线 | 起步慢、中段快、末端减速 | 匀速或线性加速 |
| 纵向抖动 | 有明显的 y 轴微小波动 | y 恒定不变 |
| 采样点间隔 | 不均匀,受手指和屏幕采样率影响 | 极其均匀 |
| 停顿 | 中途常有短促停顿(尤其接近目标时) | 无停顿 |
| 起手位置 | 大多落在手柄中心附近,有偏移 | 常常是精确中心 |
客户端采集的时候,不需要记录所有点,按 16ms 的间隔采样就够,一次拖动大概产生 60~120 个点。这些点序列化之后一般不超过 2KB,网络开销可以忽略。
服务端判断的时候,不要求精确,看几个粗粒度指标就行:总时长的合理区间(比如 300ms~8000ms,低于300ms基本确定是程序)、y 轴标准差是否大于某个阈值、相邻点的 x 增量方差是否落在人类操作区间。把这些指标做成一个简单的打分模型,分低的直接拒绝,分高的通过,中间的走二次校验。这就是滑块验证码从"验证码"往"轻量风控"演进的路子,它不再是二元的通过与否,而是给整条链路提供一个风险评分。
5. 上线后才暴露的适配与性能问题
组件在测试机上跑得再顺,上线之后总会有意想不到的机型给你惊喜。这一节讲讲我实际撞到的几个。
5.1 一张背景图能吃掉多少内存
滑块验证码的背景图,服务端一般给 600×300 左右的尺寸,解码成ARGB_8888大概是 600×300×4 = 720KB。看起来不多,但如果每个页面实例都各自解码一份,加上拼图块、加上遮罩图层,很容易在低内存设备上触发 GC 抖动。
我的处理有两条:
一是强制降采样。解码前先用inJustDecodeBounds读尺寸,算出目标宽高对应的inSampleSize,让解码出来的 Bitmap 不要超过控件实际尺寸的两倍。控件在 xxhdpi 设备上可能只有 960px 宽,就没必要把原始大图全解出来。
val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeStream(input, null, opts) var sample = 1 while (opts.outWidth / (sample * 2) >= targetW) sample *= 2 val realOpts = BitmapFactory.Options().apply { inSampleSize = sample }二是复用同一个背景图实例。如果同一个页面里可能多次触发验证(比如失败重试),就让 Bitmap 跟着会话走,会话失效时统一recycle。注意recycle之后要把引用置空,否则下次绘制会抛Canvas: trying to use a recycled bitmap。这个异常在线上日志里非常常见,多数是生命周期管理没做好。
5.2 深色模式与屏幕尺寸的连锁反应
深色模式这块,坑主要在背景图上。服务端下发的图是固定的,不会随系统主题变。如果组件里所有文字、手柄、轨道都用了主题色,切到深色模式后,浅色背景图配深色文字还勉强能看,但如果正好下发了一张深色背景图,文字就直接看不见了。
解法是让组件内部的文字和手柄颜色不跟随主题,而是固定成一套在任何背景上都可读的配色:白色文字加 30% 黑色描边,手柄用半透明白加投影。这样无论背景图是亮是暗都能保证可读性。
屏幕尺寸的连锁反应更微妙。折叠屏和平板上有两种情况:
一种是宽度特别大,比如展开后 800dp。这时候如果背景图还是按比例放大填满,拼图块也会跟着变得巨大,拖动的物理距离变长了,体验反而下降。我的做法是给面板设一个最大宽度(大概 400dp),超过就居中留白,不无限拉伸。
另一种是高度受限,比如横屏手机只有 300dp 高。背景图按宽度填满之后,高度会超出控件,居中裁剪掉的部分可能正好是拼图块所在的区域。这个要在计算bgRect的时候做边界检查——如果拼图块的 y 坐标落在裁剪区之外,说明这张图在这个尺寸下不可用,应该请求服务端换一张比例更接近的图,或者调整拼图块的生成范围。
5.3 低端机掉帧与硬件加速的排查过程
这个问题花了我差不多两天,值得完整讲一下排查链路。
现象是:在某批低端设备上,拖动滑块的时候明显掉帧,手指移动和画面更新之间有肉眼可见的延迟,松手之后动画也会卡一下。
第一步我先怀疑是绘制开销。用 GPU 渲染分析工具看了一帧的耗时,发现onDraw里saveLayer那一段占了大头。saveLayer本身是重操作,它要分配一块离屏缓冲,每次重绘都分配一次,开销很可观。
第二步确认了这个猜测:把手柄的绘制从saveLayer里挪出来,单独画,掉帧现象减轻了一半。说明离屏缓冲确实是瓶颈之一。
第三步继续追。剩下的一半卡顿出现在ACTION_MOVE频繁触发的时候。MotionEvent的采样率在低端机上可能是 60Hz,一秒 60 次invalidate,如果每次onDraw都要重新做一次Xfermode混合,GPU 那边的填充率跟不上。
第四步的解法是缓存静态部分。遮罩层和背景图在整个拖动过程中是不变的,只有手柄和拼图块在动。所以我把"背景 + 遮罩 + 洞"这三层预先渲染到一张 Bitmap 上,onDraw里直接drawBitmap一次搞定,只对动态部分做实时绘制。
private var staticLayer: Bitmap? = null private fun buildStaticLayer(w: Int, h: Int) { staticLayer?.recycle() staticLayer = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) val c = Canvas(staticLayer!!) c.drawBitmap(bgBitmap, srcRect, bgRect, null) val sc = c.saveLayer(bgRect, null) c.drawRect(bgRect, maskPaint.apply { xfermode = null }) maskPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) c.drawRoundRect(holeRect, holeRadius, holeRadius, maskPaint) c.restoreToCount(sc) }改完之后,滑动帧率从四十多帧提到了接近满帧,动画卡顿也消失了。这套"静态层缓存 + 动态层实时绘制"的思路,放在任何带遮罩的自定义 View 里都适用,不只是滑块验证码。
还有个细节:缓存的静态层要在onSizeChanged时重建,并且在 View 从窗口分离时回收,不然就是内存泄漏。
6. 收口成一个可复用组件:状态机、配置项与测试
前面五节讲的是"怎么把它做对",这一节讲"怎么让它能被反复用"。一个只能在一个页面里工作的实现,和一个能被五个业务线接入的组件,差别不在功能,在结构。
6.1 用状态机管住所有交互分支
滑块验证码的交互分支其实不少:初始态、按下态、拖动中、校验中、成功、失败、锁定、图片加载中、加载失败。如果这些状态用一堆布尔变量来表示(isDragging、isVerifying、isSuccess、isLocked……),很快就会陷入"哪些标志位该一起改"的泥潭。
我改成用一个枚举加一个when处理:
enum class CaptureState { IDLE, LOADING, READY, DRAGGING, VERIFYING, SUCCESS, FAILED, LOCKED } private fun transitionTo(next: CaptureState) { if (next == state) return val prev = state state = next when (next) { CaptureState.LOADING -> { resetInternal(); loadImages() } CaptureState.READY -> { handleEnabled = true; invalidate() } CaptureState.DRAGGING -> { handleEnabled = true; startTrackSampling() } CaptureState.VERIFYING -> { handleEnabled = false; stopTrackSampling() } CaptureState.SUCCESS -> { listener?.onSuccess() } CaptureState.FAILED -> { listener?.onFailed(attemptCount); scheduleReset() } CaptureState.LOCKED -> { listener?.onLocked() } else -> Unit } }状态机的好处是所有副作用(启动采样、禁用拖动、通知回调)都收敛在一个地方,加新状态的时候不会漏改。另外它让"非法跳转"变得可见:比如LOCKED状态下不该再响应触摸,就在onTouchEvent开头直接if (state != READY) return false。
6.2 配置项该怎么设计才不过度
回到开头说的11个配置项。我筛选的标准是:这个选项改了之后,会不会影响组件的核心行为逻辑。会影响的一律不给配,只保留视觉相关的。
最终保留的:
| 配置项 | 类型 | 说明 |
|---|---|---|
trackColor | color | 滑轨底色 |
trackProgressColor | color | 拖过部分的颜色 |
handleIcon | drawable | 手柄图标 |
handleSize | dimension | 手柄边长 |
panelHeight | dimension | 面板高度 |
holeCornerRadius | dimension | 拼图块圆角 |
maskAlpha | float | 遮罩不透明度 |
textHint/textSuccess/textFailed | string | 三段提示文案 |
maxAttempts | int | 最大尝试次数 |
色彩类、尺寸类、文案类,都是"改错了顶多难看"的选项。而像容差、采样间隔、动画时长这些"改错了会出安全问题或体验事故"的,全部锁在内部常量里,用注释写清楚为什么是这个值。
6.3 单元测试的切分方式
自定义 View 的测试不好写,但有两块是可以纯粹用 JVM 单测覆盖的,值得分开。
第一块是几何计算。calcBackgroundRect、坐标归一化、容差判定这些方法都是纯函数,输入输出都是数字,完全可以抽到一个独立的CaptureGeometry类里,脱离 View 单测。我最开始把这些算法写在 View 里,测的时候必须Robolectric起一个 Activity,跑一次要十几秒;抽出来之后纯 JVM 测试,毫秒级。
第二块是状态机流转。同样抽成一个CaptureStateMachine,输入事件(按下、移动、松手、请求返回、超时),输出状态。这样"连续失败三次后是否进入 LOCKED"这种逻辑可以精确断言,不用手动点。
至于绘制本身,就别指望单测了,靠截图对比或者人工过一遍更实际。我把测试重心放在"坐标算得对不对"和"状态切得对不对"上,这两块出问题的概率远高于绘制。
7. 几句掏心窝的实操心得
最后说点散的经验。
别把容差调得太紧。我第一版为了追求"安全",容差设成 2dp,结果上线第二天通过率只有六成,客服被投诉淹了。人的手指精度没那么高,尤其是在小屏幕上,稍微抖一下就是三四个像素的偏差。安全的问题交给服务端的风控去解决,别在交互层制造摩擦。
图片预加载比什么都管用。用户点开验证的时候如果还要等两秒加载图片,体验直接崩。我的做法是页面进入时就在后台预取一张图存在内存里,真正弹验证的时候直接用,同时再异步预取下一张备用。
手柄的按压反馈一定要有。哪怕只是缩放 0.95 加上一点点透明度变化,也能让用户确认"我点中了"。没有反馈的手柄,用户会反复点,越点越怀疑。
锁定后的提示文案别写得太技术。"验证过于频繁,请稍后再试"比"超出尝试次数限制"友好一百倍。
服务端的时间校验要注意时钟漂移。会话有效期我一开始设的是 120 秒,结果有用户反馈"刚打开就说超时",查下来是设备时间被手动改过。现在客户端上送的不再是绝对时间,而是duration_ms这种相对时长,服务端自己算,稳得多。
这个组件后续还能往两个方向扩展。一个是拼图形状的变化——现在都是圆角矩形,改成不规则形状能让脚本的匹配成本更高;另一个是行为数据的丰富度,除了轨迹,还可以采集陀螺仪的微小抖动、触摸压力(如果设备支持),这些信号组合起来,人机判断的准确率会明显提升。
这些东西没有一条写在需求文档里,但它们决定了一个验证码是"能用"还是"好用"。做这类跟用户直接较劲的组件,技术上难度不算高,真正花时间的是那些说不清道不明的细节——手感、容错、失败时的从容。把这些磨平了,才算真的做完。