Android滑块验证码自研实战:坐标、轨迹与安全加固

发布时间:2026/11/4 7:44:05
Android滑块验证码自研实战:坐标、轨迹与安全加固

1. 滑块验证码真正在防什么,以及“5行代码”这个说法该怎么理解

这两年做 Android 端登录、注册、找回密码这类页面,只要稍微有点规模,安全或者风控那边总会提出一个要求:给入口加一道滑块验证码。原因不复杂,单靠图形字符验证码已经被打得很惨了,而滑块验证码在用户体验和防护成本之间找到了一个还不错的平衡点。它的核心诉求不是“挡住所有人”,而是把机器批量请求的成本抬到一个不划算的位置,同时保证正常用户一次就能过去。如果你正在做 Android 端的登录页、活动页、短信发送入口,或者单纯想搞清楚这一类交互控件的实现原理,下面这些内容应该都能直接拿去用。

先泼一盆冷水。标题里说的“5行代码”,我理解它指的是业务接入层的那几行——控件初始化、设置回调、拿到结果、拿着 token 去请求接口,确实差不多五行。但如果你真以为整个滑块验证码只有五行,那大概率会在联调阶段被现实教育:坐标错位、拼图块糊成一片、用户手指一抬就白屏、安全同学一句“这个能直接抓包拿到目标位置”就把方案打回来。所以这篇我想按真实落地的顺序来写,先讲清楚它的原理和边界,再一行一行把控件写出来,最后把踩过的坑列成表。

1.1 一道滑块背后的三层校验

很多人对滑块验证码的理解停留在“拖动距离够不够”。实际上一个能上线用的方案,至少有三层:

第一层是交互层。用户按住滑块,横向拖动,松手后判断拼图块的横向位置和缺口的横向位置偏差是否在容差范围内。这一层只在客户端做,作用是给出即时反馈,让用户知道“我拖得对不对”。多数场景容差给到 5 到 10 个物理像素就够,给太大容易被暴力枚举,给太小会让手指粗糙的用户反复失败。

第二层是行为层。人在拖动的时候,速度曲线是“先加速、中途波动、临近终点减速”的,中间还会有几毫秒的停顿和纵向抖动。而脚本通常是匀速直线,或者用贝塞尔曲线做得很平滑但没有微抖动。这一层采集的是拖动过程中的采样点,包含相对时间、X 偏移、Y 偏移,最终形成一条轨迹,交给后端做特征比对。这一层才是真正拉开人和机器差距的地方。

第三层是凭证层。校验通过之后,后端下发一个一次性的、带过期时间的 token,业务接口(比如发送短信、登录)必须带着这个 token 才能放行。这样即使有人把验证码控件的逻辑逆向了,也没法凭空造出一个有效凭证,因为他造不出后端签发的签名。

这三层里,第一层决定体验,第二层决定防护强度,第三层决定最终能不能守住业务。只做第一层,等于在门口挂了一块“请勿入内”的牌子;三层都做,才算是装了一道门。

1.2 5行代码指的是接入层,不是全部

我把业务侧真正需要写的东西整理一下,大概长这样:

sliderCaptcha.onResult = { result -> when (result) { is SliderResult.Success -> loginViewModel.submit(result.token) is SliderResult.Failed -> toast("验证失败,请重试") } } sliderCaptcha.load()

确实是五行左右。但这段代码背后,你需要一个能画的 View、一套能采到轨迹的触摸逻辑、一个图片下发接口、一个校验接口,还有容错和重试策略。这些是控件作者要做的事,接入方可以完全不关心——这才是“5行代码”这个说法真正的价值:把复杂度收敛在控件内部,对外只暴露一个结果回调。

顺带说一句,如果你的 App 只有一个登录页需要滑块,接入方和控件作者是同一个人,那“5行”就没什么意义,老老实实把原理搞懂更重要。


2. 方案选型:自绘控件、现成库,还是换一种形态

动手之前我对比过几条路,这里把结论摆出来,省得你重复走一遍。

2.1 三条路线的对比

路线开发成本视觉可控性轨迹数据掌控体积影响适用场景
自绘自定义 View中,2 到 3 人天可出可用版本完全可控完全自己说了算几乎为零想统一 UI 规范、要自定义轨迹采集、包体积敏感
引入第三方验证 SDK低,半天接入受限于对方提供的样式通常拿不到原始轨迹视 SDK 而定,几 MB 到十几 MB项目排期紧、没有风控自研能力、愿意接受外部依赖
WebView 承载 H5 验证页低由 H5 决定需要 JS 桥接小已有成熟的 Web 端方案、多端复用

第三方 SDK 的优势是把“图片下发、轨迹判定、token 签发”整套都打包好了,你只需要接一个回调,风险模型也经过大量线上数据打磨。但它的问题也明显:包体积上去了,视觉风格和自己的 App 未必一致,轨迹数据你也拿不到,没法跟自己已有的风控系统做特征融合。

WebView 那条路在多端统一的项目里很常见,好处是 Web 和 App 复用同一套后端接口,坏处是首屏加载会有一个明显的白屏时间,弱网下体验相当差,而且拖动手势和原生页面滚动容易打架。

2.2 我为什么最后还是自己写控件

说几个决定性的理由。第一是坐标安全。不少现成方案会把缺口的目标 X 直接下发到客户端,虽然做了简单加密或者混淆,但只要有人在运行时把内存里的值 dump 出来,这个方案就废了。自研的话我可以把目标 X 完全留在服务端,客户端只上报“我把拼图拖到了哪个 X”,比对在后端做,客户端拿不到答案就没有作弊入口。

第二是轨迹数据。已有的风控系统里通常会有一套行为特征模型,我希望滑块的轨迹能直接喂进去,包括按下到抬起的毫秒数、每一步的 delta、纵向抖动幅度。第三方 SDK 一般只给一个“通过/不通过”,拿不到原始数据。

第三是视觉一致性。我们的设计体系里有明确的圆角、阴影、主色规范,第三方 SDK 的皮肤定制能力有限,最后做出来总有点“贴上去”的感觉。

当然,如果你只是想在内部后台的登录页上加一道门槛,用现成方案完全没问题,别为了自研而自研。


3. 把坐标体系理顺,后面 80% 的坑都能绕过去

我见过太多同学在这一步栽跟头:拼图块总是偏一点,或者在不同的屏幕上偏移量还不一样。根因几乎都是坐标系混用。

3.1 三个坐标系不要混用

一个滑块验证控件里同时存在三套坐标:

第一套是原图坐标,也就是服务端下发的图片自身的像素坐标,比如 600×300 的位图。缺口的目标位置、拼图块的裁剪区域,都在这套坐标里描述。

第二套是控件坐标,也就是 View 内部的绘制坐标,单位是 px,原点在 View 左上角,范围是0..width × 0..height。因为图片是按控件尺寸缩放绘制的,所以需要一个scale = viewWidth / bitmapWidth的映射。

第三套是触摸坐标,MotionEvent给出的x、y同样是相对 View 左上角的 px,但它还带有rawX、rawY这样的屏幕绝对坐标。拖动逻辑只用event.x,千万别混进rawX,否则页面一滚动位置就全乱了。

我的做法是:控件内部只存控件坐标,服务端下发的图片只负责绘制,所有比较、上报、动画统一用控件 px。上报给服务端的值再换算回原图比例:

// 上报比例而不是绝对像素,避免不同分辨率下容差失真 val reportRatio = pieceX / (width - pieceWidth).toFloat()

用比例而不是像素上报,好处是后端只需要一个 0 到 1 的浮点容差阈值,而不用关心用户的手机宽度。

3.2 触摸命中判定与事件消费

自定义 View 想收到ACTION_MOVE和ACTION_UP,有两个前置条件:onTouchEvent必须在ACTION_DOWN时返回true,或者 View 设置了clickable = true。我见过有人onTouchEvent里只处理 DOWN 就返回了 false,结果 MOVE 一次都收不到,还以为是自己写的拖动逻辑有问题。

override fun onTouchEvent(event: MotionEvent): Boolean { when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { if (!hitTestSlider(event.x, event.y)) return false dragging = true downX = event.x startPieceX = pieceX track.clear() record(event) performClickSafely() return true // 关键:返回 true 才能继续收 MOVE } MotionEvent.ACTION_MOVE -> { if (!dragging) return false record(event) updatePieceX(event.x - downX + startPieceX) return true } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { if (!dragging) return false dragging = false onRelease() return true } } return super.onTouchEvent(event) }

hitTestSlider我建议做成“滑块热区比视觉区域上下各扩 12dp”,因为手指按压点未必精准落在滑块上,尤其是单手操作的时候。实测下来这个扩展能显著降低“点了没反应”的投诉率。

还有一点容易忽略:父容器的拦截。如果滑块控件放在ScrollView或者ViewPager2里,用户横向拖动时可能被父容器抢走事件。处理方式是在控件内部调用parent.requestDisallowInterceptTouchEvent(true),在ACTION_UP或ACTION_CANCEL时再放开。


4. 从零实现:背景、缺口、拼图块怎么画出来

4.1 图片下发与二次采样

服务端下发的底图尺寸建议控制在 600×300 到 720×360 之间,格式用 WebP 或者 JPEG 质量 80。更大的图在移动端没必要,反而增加解码开销。

客户端加载的时候一定要做二次采样。控件宽度往往只有 300dp 左右,一张 720 宽的图直接BitmapFactory.decodeStream会申请好几 MB 的堆内存,放在列表里滚几下就 OOM 了。

fun decodeSampled(stream: InputStream, reqWidth: Int, reqHeight: Int): Bitmap { val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } val bytes = stream.readBytes() BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts) opts.inSampleSize = calculateInSampleSize(opts, reqWidth, reqHeight) opts.inJustDecodeBounds = false opts.inPreferredConfig = Bitmap.Config.RGB_565 // 底图不需要 alpha,省一半内存 return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts) }

注意inPreferredConfig用RGB_565只适用于底图。拼图块因为要用 Path 裁剪出透明边缘,必须是ARGB_8888,否则边缘会发黑。

4.2 缺口轮廓的绘制

缺口形状如果只是矩形,视觉上有种“没做完”的感觉。我一般用一个矩形加两段贝塞尔曲线做出不规则的凸起,既有辨识度,脚本也不容易靠边缘检测逆推出精确位置。

private fun buildPiecePath(w: Float, h: Float): Path = Path().apply { moveTo(0f, 0f) lineTo(w * 0.42f, 0f) cubicTo(w * 0.42f, -h * 0.20f, w * 0.70f, -h * 0.20f, w * 0.70f, 0f) lineTo(w, 0f) lineTo(w, h * 0.55f) cubicTo(w * 1.16f, h * 0.55f, w * 1.16f, h * 0.86f, w, h * 0.86f) lineTo(w, h) lineTo(0f, h) close() }

这里有两个实操要点。一是cubicTo的控制点会超出0..w的范围,所以绘制的时候canvas必须做一次clipRect,否则拼图块的凸起部分会画到 View 边界外面去,在父容器里留一条脏边。二是我把凸起的幅度控制在宽高的一小段比例内,太夸张的形状在小屏手机上会让用户看不出这是拼图。

缺口本身在底图上怎么表现?常见做法有两种:一种是直接挖空(用PorterDuff.Mode.CLEAR),另一种是叠加一层半透明黑。我倾向后者的变体——在缺口区域盖一层 alpha 约 0.35 的黑色遮罩,并且加一条 1px 的浅色描边。理由很简单:如果底图刚好在那个位置是浅色,挖空之后用户根本看不出缺口在哪。

4.3 onDraw 里的绘制顺序与离屏缓冲

onDraw的执行顺序直接影响性能和观感,我固定成这个次序:

  1. 绘制缩放后的底图;
  2. 绘制缺口遮罩(Path + 半透明画刷);
  3. save()后平移画布到pieceX,再用clipPath裁出拼图区域,绘制拼图块位图;
  4. restore();
  5. 绘制滑块轨道和圆形滑块。

第 3 步有两种实现方式。用PorterDuffXfermode做SRC_IN可以,但它会触发离屏缓冲(saveLayer),在低端机上滑动会明显掉帧。我改用clipPath之后再drawBitmap,硬件加速下走的是剪切路径,开销小很多。代价是边缘抗锯齿略差,可以通过给 Paint 开isAntiAlias加一层描边来补偿。

另外一个细节:onDraw里不要 new 对象。RectF、Paint、Path全部作为字段提前创建好,每次只改值。这个习惯在调试时不明显,但一个页面里如果有多个自定义控件同时刷新,GC 抖动会非常直观。


5. 拖动交互与轨迹采集的实操细节

5.1 拖动逻辑与回弹动画

拖动过程中pieceX的更新很直接,但要限制在0..(width - pieceWidth)之间,越界了做一次阻尼效果。阻尼我用的是一条简单公式:

private fun clampWithResistance(raw: Float): Float { val max = (width - pieceWidth).toFloat() return when { raw < 0f -> -sqrt(-raw) * 6f raw > max -> max + sqrt(raw - max) * 6f else -> raw } }

这样拖到底还能再往外带一点点,手感上会“软”一些,比硬邦邦地卡住舒服。

松手后的回弹用ValueAnimator从当前位置回到 0,时长 260ms,插值器用DecelerateInterpolator。这里有个体验细节:失败回弹时先停顿 120ms 再动画回位,如果立刻弹回去,用户会以为自己没按到,会疯狂重复拖动。

5.2 轨迹数据结构与采样频率

轨迹点我定义成这样一个结构:

data class TrackPoint( val t: Long, // 相对按下时刻的毫秒数 val x: Float, // 控件坐标,保留一位小数足够 val y: Float )

采样不能每个 MOVE 事件都记。MotionEvent在 60Hz 屏幕上大约 16ms 一次,在 120Hz 上能到 8ms,全部记录会让上报体积翻倍,而且高频抖动对判定没有帮助。我的策略是按时间窗口采样,最小间隔 12ms,同时强制记录终点,一条正常的拖动轨迹大概 30 到 60 个点,序列化之后 2KB 以内,完全可以随请求一起提交。

有一点务必注意:上报的t必须是相对时间,且不能由客户端单独决定可信度。所有时间戳在服务端只作为特征参考,不能作为判定“是否超时”的唯一依据,否则改了手机系统时间就能绕过。

5.3 本地判定与服务端判定怎么分工

这里我要说一个非常容易做错的点。

如果服务端把缺口的目标 X 下发给客户端,客户端本地判断“偏差 < 8px 就通过”,那这个方案基本等于没有,因为攻击者只需要 hook 一下拿到 targetX,直接调用通过逻辑就行。

正确的分工是:

  • 客户端只做视觉呈现和交互,松手后把{token, trackList, finalRatio, durationMs}上报;
  • 服务端持有targetRatio,比对偏差、检查轨迹特征、检查 token 有效期和一次性,通过后签发业务 token;
  • 客户端拿到服务端返回的成功/失败,再决定动画方向。

那客户端怎么知道拼图块该画在哪儿?不知道。它只知道拼图块现在被拖到了哪个位置,具体对不对,等后端说话。这样实现的好处是,即使有人在客户端把整个控件逆向了,他也拿不到答案。


6. 安全加固:几件让脚本不那么好写的事

6.1 图片与坐标不下发明文

服务端生成校验图的时候,我建议的流程是:从图库里挑一张底图,在服务端随机取一个 Y 比例和 X 比例,在那里裁剪出拼图块,返回三个东西——底图(不带缺口)、拼图块(带透明边的 PNG)、一个随机 token。目标 X 比例只存在 Redis 或者缓存里,key 就是 token,有效期 2 分钟。

这里还有个小优化:拼图块返回之前做一次随机的轻微旋转或者透明度渐变,让它在视觉上像是“浮起来”的,同时增加图像识别的难度。不过旋转角度别超过 3 度,超过了用户自己都对不齐。

6.2 一次性 token 与有效期

token 必须满足三个条件:随机性足够(用安全随机数生成,长度 32 位以上)、一次性(校验成功或失败都立即失效)、有有效期(我一般给 120 秒)。这三个条件不满足任何一个,都可能被重放。

顺带说,校验失败也要消耗 token。有些实现失败后不删 token,攻击者可以用同一个 token 反复试不同的 X,理论上几百次就能试出来。这是个很隐蔽但很致命的漏洞。

6.3 风控联动:滑块不是万能的

滑块验证码的定位是“抬高批量请求成本”,不是“绝对防住”。真实的风控系统里,它通常和这些信号一起工作:设备指纹是否异常、同 IP 在时间窗口内的请求频次、账号历史行为、请求的 UA 与传感器特征是否匹配。

所以我个人的做法是,滑块的校验结果只作为一个加权因子,不作为唯一放行条件。同时对“短时间内多次校验失败”的会话做降级处理,比如直接要求走更强的验证方式,而不是让他在滑块上一直试。


7. 常见问题与排查实录

7.1 坐标与点击类问题

拼图块总是偏右或者偏左一段固定距离:八成是把event.rawX和event.x混用了,或者滑块在拖动时父布局有 padding 没减掉。排查方法很简单,在 DOWN 时打一条日志,把event.x和event.rawX都打出来,如果两者差值等于控件在屏幕上的位置,那就是用错了。

拖不动,或者只能拖一次:检查onTouchEvent的 DOWN 分支是否返回了 false。另外检查performClick()有没有被调用,虽然不调用不影响功能,但无障碍服务和无痕测试工具会报错。

松开后没有回弹:多半是ValueAnimator被复用了但没cancel()上一次的动画,导致新旧动画互相覆盖。养成习惯,每次启动新动画前先cancel()。

7.2 内存与性能类问题

滑动时掉帧:按前面的建议把onDraw里的对象分配全部提到字段上,同时确认没有用PorterDuffXfermode配合saveLayer。

页面退出后 OOM:底图和拼图块这两个 Bitmap 需要在onDetachedFromWindow里主动recycle()并置空。虽然现代 Android 的 GC 能处理,但图片尺寸较大的时候,及时释放能显著降低峰值内存。

首次加载有白屏:如果用网络图片,务必在控件内部先画一个占位骨架(一个浅灰的圆角矩形加一条呼吸动画),不要让用户盯着空白。

7.3 适配与兼容类问题

深色模式下拼图块边缘发黑:拼图块位图的 config 必须是ARGB_8888,且绘制时Paint不能开filterBitmap = false,否则缩放会有硬边。

横屏或者平板下拼图变形:控件高度固定但宽度随屏幕拉伸时,缩放比例要用max而不是按宽度算,同时底图用centerCrop的方式裁切,避免拉伸。

RTL 语言环境下滑块跑到右边:如果产品要支持 RTL,滑块的起始位置需要用layoutDirection判断,或者干脆强制 LTR,因为滑动方向本身就是从右到左的语言习惯里也是从左往右拖。

7.4 问题速查表

现象高概率原因快速验证方式处理方式
拼图块偏移固定值坐标系混用打印event.x与event.rawX统一用控件坐标
拖一下就卡住DOWN 返回 false 或父容器拦截打日志看 MOVE 是否进来返回 true + 禁止父容器拦截
拼图块边缘有黑边位图 config 不是 ARGB_8888打印bitmap.config改用 ARGB_8888
滑动掉帧onDraw 内分配对象或用了离屏缓冲开 GPU 渲染分析看过度绘制复用对象,改用 clipPath
偶尔校验失败容差过小或上报比例为 px对比上报值和服务端值上报比例 + 容差 6 到 10px
退出页面后内存不降未回收 Bitmap用内存分析工具看引用链onDetachedFromWindow 里 recycle

8. 让它好看:视觉与反馈的打磨

8.1 视觉规范

滑块的视觉部分其实就这么几个元素:轨道、滑块、拼图块、缺口遮罩。但要把它们做“好看”,我的经验是三个统一。圆角统一,轨道、滑块、拼图块、遮罩的圆角半径都取同一个基础值,比如 8dp,不要一个 4 一个 12。阴影克制,滑块只用一层 2dp 高度的柔和阴影,阴影颜色用主色的深色版本加很低的不透明度,不要用纯黑,纯黑在浅色背景上会显得脏。留白均匀,轨道和控件上下边缘的间距等于滑块半径,视觉上最舒服。

配色上,轨道的未填充部分用一层很浅的灰(比背景深一档),填充部分用主色,滑块用白色加一点点描边。这样用户一眼就能看出“已经走了多远”。

8.2 动效与状态反馈

成功的反馈我是这样做的:拼图块落到缺口位置后,先把遮罩的 alpha 从 0.35 动画到 0 并保持 180ms,然后整个控件淡出并向业务回调。失败的话,滑块颜色短暂变红,同时做一次水平抖动,抖动幅度控制在 6dp 以内,太快太猛都会显得廉价。

拖动过程中,滑块的尺寸可以随拖动进度从 1.0 缩到 0.92 再回弹,这种“按下有反馈”的细节对质感提升非常明显,代码也就两行scaleX。

还有一个我特别喜欢的小设计:拖动时让拼图块带着一点轻微的跟随阴影,位移越大阴影越深。视觉上会让人觉得这个块是“浮在图上”的,而不是贴上去的色块。

8.3 无障碍与测试

contentDescription要写上“拖动滑块完成验证”,并且在成功和失败时通过announceForAccessibility播报状态,视障用户才知道发生了什么。另外performClick()一定要在 DOWN 里调用,不然无障碍服务点了没反应。

测试方面,我一般会写两组。一组是纯逻辑的单测:给固定的targetRatio和一组人工构造的轨迹,验证判定函数是否正确,包括匀速直线(应该拒绝)、带抖动的缓入缓出(应该通过)、以及时间过短(应该拒绝)。另一组是手工回归清单,重点覆盖不同屏幕尺寸、深色浅色主题、横竖屏切换、弱网图片加载失败、以及连续快速失败三次后的降级逻辑。

我在实际项目里踩得最深的一个坑是弱网。图片请求超时了但控件没有超时回调,用户就一直看到占位骨架,以为页面卡死,最后是给图片加载加了一个 8 秒超时并把错误态做成“点击重试”的文案才解决。这类问题的共同点是:功能逻辑写对了,但边界状态没人管。所以后来我写这个控件的习惯是,先把所有失败态列一遍,再写正经逻辑,反而更快。