手势解锁:九宫格手势绘制、密码存储、验证逻辑

手势解锁,说白了就是那个你每天划几百次的九宫格锁屏。嗯,这功能看起来简单,但真要自己实现一遍,坑还挺多的。我记得第一次做这个功能时,以为就是画几条线的事,结果被触摸事件的分发、密码的加密存储、还有那个烦人的「最少连接4个点」的校验,折腾了好几天。

今天我们就来拆解一下,一个完整的手势解锁模块,到底该怎么设计。

核心流程:从触摸到验证

先看整体流程,我习惯用一张图来理清思路:

用户触摸 绘制路径 路径校验 ? 提示错误 解锁成功 加密存储 验证逻辑

整个流程其实就三步:触摸绘制 → 路径校验 → 存储/验证。但每一步都有细节,我们一个一个说。

九宫格绘制:自定义View的必修课

手势解锁的核心,是一个自定义View。我建议你直接继承 View,而不是 ImageView 或别的。为什么?因为我们需要完全控制绘制逻辑,包括触摸事件的处理。

1. 定义九宫格的点位

九宫格有9个点,3行3列。每个点需要记录:

  • 坐标位置(x, y)
  • 当前状态(未选中、选中、错误)
  • 对应的数字(1-9,用于生成密码序列)
class Point {
    float x, y;
    int index;      // 1-9
    State state;    // NORMAL, SELECTED, ERROR
    
    enum State { NORMAL, SELECTED, ERROR }
}

初始化时,根据View的宽高,计算出每个点的中心坐标。嗯,这里要注意:

我曾经踩过的坑: 直接硬编码坐标,导致不同屏幕尺寸下九宫格变形。一定要根据View的宽高动态计算,保持3x3的等距排列。

2. 绘制状态

绘制分三层:

  • 底圆:未选中时灰色空心圆,选中时蓝色实心圆
  • 连线:按用户触摸顺序,在选中的点之间画线
  • 箭头指示:在连线末端画一个小箭头,提示方向(这个看产品需求,不是必须的)

我个人习惯把绘制逻辑拆成三个方法:

@Override
protected void onDraw(Canvas canvas) {
    drawPoints(canvas);      // 绘制所有点
    drawLines(canvas);       // 绘制连线
    drawFinger(canvas);      // 绘制手指当前位置的指示线
}

这样代码清晰,后期改样式也方便。你想想看,如果所有绘制都塞在一个方法里,改起来多痛苦。

触摸事件处理:别让手指「溜走」

手势解锁的触摸逻辑,比普通点击复杂。因为用户可能从点1滑到点5,中间经过点2、点3。我们需要实时判断手指当前落在哪个点上。

核心逻辑:

  1. ACTION_DOWN:判断手指是否落在某个点范围内,如果是,记录为第一个选中点
  2. ACTION_MOVE:每次移动都检查当前坐标是否落在某个未选中的点内,如果是,加入选中列表
  3. ACTION_UP:结束绘制,生成密码序列,触发验证
关键点: 判断「点是否被选中」时,不要用精确的坐标匹配。给每个点一个「触摸半径」,比如30dp。只要手指落在半径内,就算选中。

这里有个细节:如果用户手指从点1滑到点3,中间经过了点2,但手指没有在点2上停留。要不要自动选中点2?

我的做法是:不自动选中。因为用户可能想画一个「1→3」的斜线,中间的点2只是路过。如果自动选中,反而会干扰用户意图。当然,有些产品要求「路过即选中」,那就需要做路径插值计算了。

密码存储:别存明文!

手势密码本质上是一个数字序列,比如「1-5-9-8-7」。但直接存这个序列?绝对不行。

我见过一些App,把手势密码用SharedPreferences明文存储。嗯,这跟把银行卡密码写在便利贴上贴手机背面,没什么区别。

正确的做法:

  • 使用 SHA-256 或更安全的哈希算法,对密码序列进行哈希
  • 存储时加盐(Salt),防止彩虹表攻击
  • 存储在 EncryptedSharedPreferencesKeyStore
fun saveGesturePassword(password: String) {
    val salt = generateSalt()  // 随机生成盐值
    val hash = hashPassword(password, salt)  // SHA-256 哈希
    // 存储 hash 和 salt
    encryptedPrefs.edit()
        .putString("gesture_hash", hash)
        .putString("gesture_salt", salt)
        .apply()
}
小提示: 手势密码通常只有3-9位数字,组合空间很小。即使加了哈希,暴力破解也不难。所以建议加上「失败次数限制」——连续5次错误,锁定30秒。这个我在项目里是必加的。

验证逻辑:不只是比对字符串

验证时,用户重新绘制一遍手势,生成密码序列,然后与存储的哈希值比对。

但这里有个问题:用户绘制的路径,可能因为手指滑动速度、角度不同,导致生成的序列顺序有细微差异吗?

不会。因为我们是按「选中点的顺序」生成序列的,只要用户依次划过相同的点,序列就是固定的。所以直接比对哈希值即可。

验证流程:

  1. 用户绘制完成,生成密码序列(如 "1-5-9-8-7")
  2. 从存储中取出 salt 和 hash
  3. 对用户输入的序列 + salt 进行哈希
  4. 比对两个哈希值是否相等
fun verifyGesture(password: String): Boolean {
    val storedHash = encryptedPrefs.getString("gesture_hash", "")
    val storedSalt = encryptedPrefs.getString("gesture_salt", "")
    val inputHash = hashPassword(password, storedSalt)
    return inputHash == storedHash
}

你看,逻辑其实很简单。但实际项目中,我还会加一个「最小连接数」的校验。比如至少连接4个点,否则提示「密码太短,请重新绘制」。

避坑指南:那些年我踩过的雷

1. 触摸事件被父容器拦截

如果手势解锁View放在ScrollView或ViewPager里,父容器可能会拦截触摸事件。解决办法:在View的onTouchEvent中调用 getParent().requestDisallowInterceptTouchEvent(true)

2. 密码序列的「回退」问题

用户画错了,想撤销上一个点。有些产品支持「手指滑回上一个点,取消选中」。这个实现起来比较麻烦,需要记录路径历史,并支持反向操作。我个人建议:如果产品没有明确要求,就别加这个功能,容易把逻辑搞复杂。

3. 屏幕旋转与Activity重建

手势解锁通常在全屏Activity中展示。如果用户旋转屏幕,Activity重建,绘制状态会丢失。解决办法:在onSaveInstanceState中保存选中的点列表,在onRestoreInstanceState中恢复。

性能优化:别让绘制卡顿

手势解锁的绘制频率很高——手指每移动一点,都要重绘。如果onDraw里做了耗时操作,界面就会卡顿。

我建议:

  • 不要在onDraw里创建对象(比如new Paint()、new Path()),全部在初始化时创建好
  • 使用 invalidate() 而不是 postInvalidate(),除非你在非UI线程
  • 如果点很多(比如4x4、5x5的网格),考虑使用 SurfaceView 代替普通View
总结一下: 手势解锁看似简单,但要做好,需要处理好触摸事件、绘制性能、密码安全三个维度。尤其是安全方面,千万别偷懒。我见过太多App因为手势密码存储不当,导致用户隐私泄露的案例了。

好了,关于九宫格手势解锁的核心实现,就聊到这里。代码示例都是基于我实际项目中的经验提炼的,你可以直接拿去用。如果有细节问题,欢迎随时交流。


公众号:蓝海资料掘金营,微信deep3321