1. 程式人生 > >圖片驗證碼控制元件

圖片驗證碼控制元件

現在的應用各種驗證方式五花八門,從最開始的數字驗證,到後來的數字圖片變成動態圖,再到圖片驗證,還有 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 傳送門

圖片驗證碼控制元件