图片验证码控件

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/zgcqflqinhao/article/details/79278065

现在的应用各种验证方式五花八门,从最开始的数字验证,到后来的数字图片变成动态图,再到图片验证,还有 12306 那令人发指的:


无论方式怎么变化,都是为了保证有人使用机器恶意注册、登陆等,加大服务器的负担和消费。最近是简单实现了一下图片验证码,在优化过一次后决定记录一下它的实现原理。
最后的效果是这样:


1 思路

首先,我们需要对图片进行处理,从中挖取一个用于验证的拼图块,等待验证的图片也就是原图挖取拼图块后的样子,然后我们可以拖动进度条来改变拼图块的位置,最后松手的时候判断拼图块的位置是否在原图被挖取的位置附近就行了。

2 挖取拼图块

这种情况我们必然要用自定义 View 的,首先我们加载一个图片,将图片挖掉一个拼图块,再在底部画一个进度条:

public class ImageCheckCodeView extends View {
    private Paint mPaint;   //画笔,画圆角矩形的进度条,画进度游标
    private Bitmap mBitmap; //进行图片验证的原图
    private Bitmap waitCheckBitmap;   //等待验证的图片,是原图挖取掉一个拼图块后的图片
    private Puzzle puzzle;  //进行验证的拼图块
    private RectF roundRectF;   //进度条的圆角矩形
    private float progress = 0; //当前手机的滑动进度
    private int seekBarHeight;  //进度条的高度
    private boolean startImageCheck = false;   //开始图片验证的标志位
    private Rect puzzleSrc; //拼图块绘制区域
    private Rect puzzleDst; //拼图块显示区域

    public void setBitmap(Bitmap bitmap) {
        this.mBitmap = bitmap;
        if (getMeasuredWidth() == 0 || getMeasuredHeight() == 0) {
            getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
                @Override
                public void onGlobalLayout() {
                    initBitmap();
                }
            });
        } else {
            initBitmap();
        }
    }

    public ImageCheckCodeView(Context context) {
        this(context, null);
    }

    public ImageCheckCodeView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public ImageCheckCodeView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        //初始化画笔
        mPaint = new Paint();
        //创建进度条的圆角矩形的显示区域
        roundRectF = new RectF();
        //创建拼图块对象
        puzzle = new Puzzle();
        //拼图块的绘制区域和显示区域
        puzzleSrc = new Rect();
        puzzleDst = new Rect();
    }


    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int width;
        int height;
        if (widthMode == MeasureSpec.EXACTLY) {
            width = widthSize;
        } else {
            //如果没有指定宽,默认宽度为屏幕宽
            width = getContext().getResources().getDisplayMetrics().widthPixels;
        }
        if (heightMode == MeasureSpec.EXACTLY) {
            height = heightSize;
        } else {
            //如果没有指定高,默认高度为屏幕高的 1/3
            height = getContext().getResources().getDisplayMetrics().heightPixels / 3;
        }
        setMeasuredDimension(width, height);

        initBitmap();
    }

    /**
     * Description:初始化图片,获取拼图块和挖取拼图块后的等待验证的图片
     * Date:2018/2/7
     */
    private void initBitmap() {
        if (mBitmap == null) {
            mBitmap = BitmapFactory.decodeResource(getContext().getResources(), R.drawable.a);
        }
        //设置进度条的高度为控件高度的 1/10
        seekBarHeight = getMeasuredHeight() / 10;
        //设置进度条现在在控件底部,距离底部的距离也为控件高度的 1/10
        roundRectF.set(0, getMeasuredHeight() - seekBarHeight * 2, getMeasuredWidth(), getMeasuredHeight() - seekBarHeight);

        //将控件宽高传递给 puzzle 对象
        puzzle.setContainerWidth(getMeasuredWidth());
        puzzle.setContainerHeight(getMeasuredHeight());
        //将原图传递给 puzzle 对象,puzzle 内部会将图片处理成拼图块的样子
        puzzle.setBitmap(mBitmap);

        //根据原图和 puzzle 对象获取挖取拼图块后的图片,即等待验证的图片
        waitCheckBitmap = BitmapUtil.getWaitCheckBitmap(mBitmap, puzzle, getMeasuredWidth(), getMeasuredHeight());
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.drawBitmap(waitCheckBitmap, 0, 0, null);
        //画灰色,50% 透明度的圆角矩形的指示条
        mPaint.setColor(Color.argb(128, 128, 128, 128));
        canvas.drawRoundRect(roundRectF, 45, 45, mPaint);
    }
}


其中 Puzzle 对象如下,在父容器测量到宽高之后传递该对象中,根据父容器的宽高来决定拼图块的宽高,宽我是取的父容器的 1/5,高是父容器的 1/4,然后再随机生成一下拼图块的位置,x 和 y 是拼图块左上角的横纵坐标:

public class Puzzle {
    private int containerWidth; //容器宽度
    private int containerHeight;    //容器高度
    private int x;  //拼图块左上角横坐标
    private int y;  //拼图块左上角纵坐标
    private int width;  //拼图块的宽
    private int height; //拼图块的高
    private Bitmap bitmap;  //原图

    public Puzzle() {
    }

    public void setContainerWidth(int containerWidth) {
        this.containerWidth = containerWidth;
        x = new Random().nextInt(containerWidth
                - containerWidth / 5    //减去拼图块宽度,保证拼图块全部显示
        );
        width = containerWidth / 5;
    }

    public void setContainerHeight(int containerHeight) {
        this.containerHeight = containerHeight;
        y = new Random().nextInt(containerHeight
                - containerHeight / 5   //减去拼图块高度,保证拼图块全部显示
        );
        height = containerHeight / 4;
    }

    public int getX() {
        return x;
    }

    public int getY() {
        return y;
    }

    public int getWidth() {
        return width;
    }

    public int getHeight() {
        return height;
    }

    public Bitmap getBitmap() {
        return bitmap;
    }

    public void setBitmap(Bitmap bitmap) {
        this.bitmap = BitmapUtil.getPuzzleBitmap(bitmap, this, containerWidth, containerHeight);
    }
}


BitmapUtil 中有两个处理图片的方法,这两个方法内部实现差不多,主要区别是 serXfermode() 来决定显示交集部分还是非交集部分,拼图块的绘制主要涉及到贝塞尔曲线的计算,不懂的朋友可以搜一下贝塞尔曲线。

public class BitmapUtil {
    /**
     * Description:获取拼图块
     * Date:2018/2/7
     */
    public static Bitmap getPuzzleBitmap(Bitmap bitmap, Puzzle puzzle, int width, int height) {
        //创建一个拼图块大小的图片
        Bitmap mBitmap = Bitmap.createBitmap(puzzle.getWidth(), puzzle.getHeight(), Bitmap.Config.ARGB_8888);

        Canvas mCanvas = new Canvas(mBitmap);
        Paint mPaint = new Paint();
        mPaint.setAntiAlias(true);

        //画拼图块的路径
        Path mPath = new Path();
        mPath.moveTo(0, puzzle.getHeight() / 4);
        mPath.lineTo(puzzle.getWidth() / 3, puzzle.getHeight() / 4);
        mPath.cubicTo(puzzle.getWidth() / 6, 0
                , puzzle.getWidth() - puzzle.getWidth() / 6, 0
                , puzzle.getWidth() - puzzle.getWidth() / 3, puzzle.getHeight() / 4);
        mPath.lineTo(puzzle.getWidth(), puzzle.getHeight() / 4);
        mPath.lineTo(puzzle.getWidth(), puzzle.getHeight());
        mPath.lineTo(0, puzzle.getHeight());
        mPath.lineTo(0, puzzle.getHeight() - puzzle.getHeight() / 4);
        mPath.cubicTo(puzzle.getWidth() / 3, puzzle.getHeight() - puzzle.getHeight() / 8
                , puzzle.getWidth() / 3, puzzle.getHeight() / 4 + puzzle.getHeight() / 8
                , 0, puzzle.getHeight() / 2);
        mPath.lineTo(0, puzzle.getHeight() / 4);
        mCanvas.drawPath(mPath, mPaint);

        //画拼图块的图片
        mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
        //拼图块显示的位置根据图片与控件的比例决定
        Rect src = new Rect((int) ((double) bitmap.getWidth() / (double) width * puzzle.getX())
                , (int) ((double) bitmap.getHeight() / (double) height * puzzle.getY())
                , (int) ((double) bitmap.getWidth() / (double) width * (puzzle.getX() + puzzle.getWidth()))
                , (int) ((double) bitmap.getHeight() / (double) height * (puzzle.getY() + puzzle.getHeight())));
        Rect dst = new Rect(0, 0, puzzle.getWidth(), puzzle.getHeight());
        mCanvas.drawBitmap(bitmap, src, dst, mPaint);

        //在拼图块外围画一个轮廓,显眼一点
        mPaint.setColor(Color.RED);
        mPaint.setStrokeWidth(5f);
        mPaint.setStyle(Paint.Style.STROKE);
        mPaint.setXfermode(null);
        mCanvas.drawPath(mPath, mPaint);

        return mBitmap;
    }

    /**
     * Description:获取等待验证的图片
     * Date:2018/2/7
     */
    public static Bitmap getWaitCheckBitmap(Bitmap bitmap, Puzzle puzzle, int width, int height) {
        //创建一个与控件宽高相等的图片
        Bitmap mBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);

        Canvas mCanvas = new Canvas(mBitmap);
        Paint mPaint = new Paint();
        mPaint.setAntiAlias(true);
        Path mPath = new Path();

        //挖取掉拼图块
        mPath.moveTo(puzzle.getX(), puzzle.getY() + puzzle.getHeight() / 4);
        mPath.lineTo(puzzle.getX() + puzzle.getWidth() / 3, puzzle.getY() + puzzle.getHeight() / 4);
        mPath.cubicTo(puzzle.getX() + puzzle.getWidth() / 6, puzzle.getY()
                , puzzle.getX() + puzzle.getWidth() - puzzle.getWidth() / 6, puzzle.getY()
                , puzzle.getX() + puzzle.getWidth() - puzzle.getWidth() / 3, puzzle.getY() + puzzle.getHeight() / 4);
        mPath.lineTo(puzzle.getX() + puzzle.getWidth(), puzzle.getY() + puzzle.getHeight() / 4);
        mPath.lineTo(puzzle.getX() + puzzle.getWidth(), puzzle.getY() + puzzle.getHeight());
        mPath.lineTo(puzzle.getX(), puzzle.getY() + puzzle.getHeight());
        mPath.lineTo(puzzle.getX(), puzzle.getY() + puzzle.getHeight() - puzzle.getHeight() / 4);
        mPath.cubicTo(puzzle.getX() + puzzle.getWidth() / 3, puzzle.getY() + puzzle.getHeight() - puzzle.getHeight() / 8
                , puzzle.getX() + puzzle.getWidth() / 3, puzzle.getY() + puzzle.getHeight() / 4 + puzzle.getHeight() / 8
                , puzzle.getX(), puzzle.getY() + puzzle.getHeight() / 2);
        mPath.lineTo(puzzle.getX(), puzzle.getY() + puzzle.getHeight() / 4);
        mCanvas.drawPath(mPath, mPaint);

        //图片显示在控件范围内
        mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_OUT));
        Rect src = new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight());
        Rect dst = new Rect(0, 0, width, height);
        mCanvas.drawBitmap(bitmap, src, dst, mPaint);
        return mBitmap;
    }
}


目前的效果如下:


成功获取到了挖取拼图块后的等待验证的图片,还有一个进度条,拼图块暂时还没画出来,不着急,一般是滑动的时候才会出现拼图块,接下来我们就来处理滑动。


3 处理滑动

在按下时开始滑动(要从左边小于控件宽度 1/10 的地方按下时才算有效),

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                if (event.getRawX() < getMeasuredWidth() / 10) {
                    startImageCheck = true;
                    progress = event.getRawX() < seekBarHeight / 2
                            ? seekBarHeight / 2
                            : event.getRawX() > getMeasuredWidth() - seekBarHeight / 2 ? getMeasuredWidth() - seekBarHeight / 2 : event.getRawX();
                }
                invalidate();
                return true;
            case MotionEvent.ACTION_MOVE:
                if (!startImageCheck) {
                    break;
                }
                progress = event.getRawX() < seekBarHeight / 2
                        ? seekBarHeight / 2
                        : event.getRawX() > getMeasuredWidth() - seekBarHeight / 2 ? getMeasuredWidth() - seekBarHeight / 2 : event.getRawX();
                invalidate();
                break;
            case MotionEvent.ACTION_UP:
                if (!startImageCheck) {
                    break;
                }
                progress = 0;
                startImageCheck = false;
                invalidate();
                break;
        }
        return false;
    }


在 onDraw() 增加以下代码:

        //开始验证后重绘触摸点和拼图块的位置
        if (startImageCheck) {
            puzzleSrc.set(0, 0, puzzle.getWidth(), puzzle.getHeight());
            puzzleDst.set((int) progress - puzzle.getWidth() / 2, puzzle.getY(), (int) progress + puzzle.getWidth() - puzzle.getWidth() / 2, puzzle.getY() + puzzle.getHeight());
            canvas.drawBitmap(puzzle.getBitmap(), puzzleSrc, puzzleDst, null);

            //画触摸点
            mPaint.setColor(Color.BLACK);
            canvas.drawCircle(progress, getMeasuredHeight() - seekBarHeight - seekBarHeight / 2, seekBarHeight / 2, mPaint);
        }

现在每次重绘时如果是开始验证的情况就会绘制拼图块和进度条中的点:



4 验证结果回调

最后我们需要提供一个外部接口来告知是否验证成功。

    public interface OnCheckResultCallback {
        void onSuccess();

        void onFailure();
    }


定义一个存储回调接口的变量和提供一个设置回调接口的方法:

    private OnCheckResultCallback onCheckResultCallback;

    public void setOnCheckResultCallback(OnCheckResultCallback onCheckResultCallback) {
        this.onCheckResultCallback = onCheckResultCallback;
    }


最后在手指抬起时,判断一下验证结果并回调结果(我这里的判断条件是抬起时手指的位置在拼图块中心点左右误差不超过拼图块宽度 1/20 即验证成功):

                if (onCheckResultCallback == null) {
                    break;
                }
                if (event.getRawX() > puzzle.getX() + puzzle.getWidth() / 2 - puzzle.getWidth() / 20
                        && event.getRawX() < puzzle.getX() + puzzle.getWidth() / 2 + puzzle.getWidth() / 20) {
                    //松手时触摸点在拼图块中心的横坐标左右偏差不超过拼图块宽度的 1/20则验证成功
                    onCheckResultCallback.onSuccess();
                } else {
                    onCheckResultCallback.onFailure();
                }

这样就达到文章开头的效果了。


5 总结

这个图片验证码控件刚开始我准备做的时候想得挺麻烦,但是最后实现了回过头一想也并不麻烦,主要涉及的就是一些计算,包括拼图块大小的计算,拼图块轮廓路径的计算,触摸时的计算等。这是优化后的版本,刚开始的计算看起来比较复杂,最后将拼图块封装成了一个对象,这样看起来比较容易理解,这也是我们平时写代码时应该的思路,当发现代码比较难理解时就要进行适当的封装。完整代码和用法请移步 Github 传送门,有问题还希望大家留言指出。

6 Github 传送门










猜你喜欢

转载自blog.csdn.net/zgcqflqinhao/article/details/79278065