鴻蒙開源第三方組件—SwipeCaptcha_ohos2.0滑動拼圖驗證組件
前言
基于安卓平臺的滑動拼圖驗證組件SwipeCaptcha(https://github.com/mcxtzhang/SwipeCaptcha ),實現了鴻蒙化遷移和重構,代碼已經開源到(https://gitee.com/isrc_ohos/swipe-captcha_ohos ),目前已經獲得了很多人的Star和Fork ,歡迎各位下載使用并提出寶貴意見!
背景
在頁面登錄或者注冊的時候,為了確保不是機器人操作,會讓用戶手動驗證。驗證方式分為滑動拼圖驗證和滑動驗證兩種。
- 滑動拼圖驗證:有圖片作為背景,通過圖塊拼接實現安全驗證;
- 滑動驗證:無圖片背景,只拖動滑塊便可實現安全驗證;
本文的SwipeCaptcha_ohos2.0組件屬于滑動拼圖驗證,操作簡單,安全性強,可被應用于各種網站的登錄、注冊、找回密碼或投票等場景中。
我們之前已經實現了滑動拼圖驗證組件SwipeCaptcha_ohos,相關文章在:https://harmonyos.51cto.com/posts/3402 。本次SwipeCaptcha_ohos2.0是基于之前移植的項目進行了相關功能的優化,具體優化內容將在下文中詳細介紹。
組件效果展示
SwipeCaptcha_ohos2.0的主要功能和之前的SwipeCaptcha_ohos基本一致,組件在使用時,有兩個較為重要的元素:滑塊和原圖。二者被放置于同一水平線上,用戶拖動滑塊至原圖處使二者重合,誤差小于提前設定的驗證閾值,即可驗證成功。每次調用組件,滑塊和原圖的位置都會發生隨機變化。
SwipeCaptcha_ohos2.0相較于之前的版本,大幅提升了組件功能的完整性以及使用體驗,下面將依次從組件驗證失敗和驗證成功兩個狀態,展示SwipeCaptcha_ohos2.0與之前版本的效果對比。
1.驗證失敗
通過圖1(a)和圖1(b)的對比可以看出,新版本移除了舊版本中“當前進度值預覽”的不必要功能以及下方的狀態欄,取而代之的功能如下:
驗證滑塊由正方形小塊升級為“拼圖塊”樣式;
待驗證背景圖塊增加了陰影遮罩效果;
驗證失敗后增加了滑塊閃爍效果以及“驗證失敗,請重新驗證!”的彈窗提醒。

(a)舊版本組件驗證失敗效果

(b)新版本組件驗證失敗效果
圖1 新舊版本驗證失敗效果對比
2.驗證成功
通過圖2(a)和圖2(b)的對比可以看出,新版本移除了舊版本中“當前進度值預覽”的不必要功能以及下方的狀態欄,取而代之的功能如下:
- 點擊“重新生成驗證碼”按鈕后,滑塊和原圖的位置都會發生隨機變化;
- 驗證成功后增加了反光條劃過的動畫效果以及“驗證成功!”的彈窗提醒。

(a)舊版本組件驗證成功效果

(b)新版本組件驗證成功效果
圖2 新舊版本驗證成功效果對比
除了上述直觀的功能優化外,SwipeCaptcha_ohos2.0還實現了以下功能:
- 滑塊大小和容錯閾值的用戶自定義
滑塊大小自定義是指用戶可以通過代碼自定義滑塊的寬高;容錯閾值自定義是指用戶可以通過代碼自定義匹配時的容錯率,即相差多少視作匹配成功。
- 拼圖背景在指定范圍內的自適應填充。
原組件的圖片不能在指定組件寬高的前提下自動填充圖片,如果強行適配寬高會出現拼圖塊內容錯位的情況;經過改進后,驗證圖片已經能夠適配布局中規定的組件寬高。
Sample解析
通過上文相信大家已經了解SwipeCaptcha_ohos2.0組件的使用效果,下面將具體講解SwipeCaptcha_ohos2.0組件的使用方法,共分為5個步驟:
步驟1. 導入SwipeCaptchaView類并聲明類對象。
步驟2. 在xml文件中添加SwipeCaptchaView控件。
步驟3. 綁定SwipeCaptchaView控件。
步驟4. 設置回調處理函數。
步驟5. 設置Button控件監聽事件,重新生成驗證區域
(1)導入SwipeCaptchaView類并聲明類對象
在MainAbilitySlice.java文件中,通過import關鍵字導入SwipeCaptchaView類。
- //導入SwipeCaptchaView類
- import com.huawei.swipecaptchaview.lib.SwipeCaptchaView;
- public class MainAbilitySlice extends AbilitySlice {
- //聲明SwipeCaptchaView類對象
- SwipeCaptchaView swipeCaptchaView;
- ......
- }
(2)在xml文件中添加SwipeCaptchaView控件
在xml文件中添加SwipeCaptchaView控件,用于顯示滑動驗證的背景圖和動態效果。設置控件高和寬、滑塊的高和寬以及驗證閾值等屬性。
- <com.huawei.swipecaptchaview.lib.SwipeCaptchaView
- xmlns:captcha="http://schemas.huawei.com/res/ohos-auto" //聲明一個用于傳輸自定義參數的命名空間
- ohos:id="$+id:swipeCaptchaView" //規定控件id
- ohos:height="220vp" //控件的高
- ohos:width="330vp" //控件的寬
- captcha:captchaHeight="30vp" //拼圖滑塊高
- captcha:captchaWidth="30vp" //拼圖滑塊寬
- captcha:matchDeviation="9"/> //驗證失敗的閾值
(3)綁定SwipeCaptchaView控件
在MainAbilitySlice.java的onStart()方法中,使用findComponentById()方法將xml文件中SwipeCaptchaView控件與SwipeCaptchaView類對象綁定;再調用setImageId()方法設置組件的背景圖片。
- //根據id找到相應的控件
- swipeCaptchaView = (SwipeCaptchaView) findComponentById(ResourceTable.Id_swipeCaptchaView);
- ...
- button = (Button) findComponentById(ResourceTable.Id_btn_change);
- //設置背景圖片
- swipeCaptchaView.setImageId(ResourceTable.Media_pic01);
(4)設置回調處理函數
設置SwipeCaptchaView組件的回調處理函數,來提示用戶滑動驗證結果。以提示用戶驗證成功為例:首先重寫matchSuccess()方法,設置驗證成功后的提示信息,然后實例化一個ToastDialog提示框對象,使用setText()方法設置顯示文字為“驗證成功!”;setAlignment()方法設置提示框的布局位置在整體布局的中央;show()方法用于顯示提示框。
設置驗證失敗的情況和驗證成功同理,只需重寫matchFailed()方法將文字信息設置為“驗證失敗!”即可。
- //每次滑動結束后會根據判定結果回調
- swipeCaptchaView.setOnCaptchaMatchCallback(new SwipeCaptchaView.OnCaptchaMatchCallback() {
- @Override
- public void matchSuccess(SwipeCaptchaView swipeCaptchaView) {
- new ToastDialog(getContext())
- .setText(" 驗證成功!")
- .setAlignment(LayoutAlignment.CENTER)
- .show();
- }
- });
(5)設置Button控件監聽事件,重新生成驗證區域
綁定button對象和xml文件中“重新生成驗證碼”Button控件;為button設置監聽事件,每次點擊按鈕,都會調用createCaptcha()方法隨機生成滑塊和原圖的位置。
- button = (Button) findComponentById(ResourceTable.Id_btn_change);//綁定Button
- button.setClickedListener(new Component.ClickedListener() {//設置監聽
- @Override
- public void onClick(Component component) {
- swipeCaptchaView.createCaptcha();//隨機生成滑塊和原圖的位置
- ...
- }
- });
Library解析
本部分將要重點介紹的類是圖3中框出的2個類,分別是DrawHelperUtils、和SwipeCaptchaView。它們向開發者提供設置SwipeCaptcha_ohos2.0組件相關屬性的具體執行方法,其中DrawHelperUtils是工具類,SwipeCaptchaView是具體實現滑塊滑動效果的類,本節將分別講解這兩個類的內部邏輯實現。

圖3 Library目錄結構
1、DrawHelperUtils類
Swipeptcha_ohos2.0升級實現的拼圖滑塊的原理是在方塊的左、右兩條豎邊中點處分別繪制一個凸半圓或凹半圓(隨機),可參考圖4。DrawHelperUtils類的drawPartCircle()方法具體用于繪制拼圖滑塊兩條豎邊上的半圓,先來解釋一下該方法涉及變量和參數的含義:
- 起點坐標:開始繪制半圓的起點坐標,在圖中由A表示,規定為方塊豎邊的前1/3處,由入參傳入。
- 終點坐標:開始繪制半圓的起點坐標,在圖中由C表示,規定為方塊豎邊的后1/3處,由入參傳入。
- 中點坐標:半圓直徑的中點坐標,在圖中由B表示,由起點A和終點C的X、Y坐標計算得到。
- r1:半圓半徑 = AB長度 = AC長度/2 = 1/6方塊豎邊長度。
- gap1:由r1乘以貝塞爾曲線(cubicTo()方法)系數c得到,用于確定控制點D和F的坐標,控制點作用是控制半圓繪制的軌跡。
- flag:半圓的旋轉系數,用來控制凹、凸半圓的繪制。當為1時,A、B、C坐標與變量相加,繪制向外的凸半圓;當為-1時,其坐標與變量相減,得到向內的凹半圓。

圖4-1 凸半圓繪制原理圖

圖4-2 凹半圓繪制原理圖
以豎直繪制一個凸半圓為例,根據A、B、C點計算得到上述變量后,調用兩次貝塞爾曲線cubicTo(x1,y1,x2,y2,x3,y3)分別繪制前1/2和后1/2半圓,此方法中需要使用到兩個控制點,共有6個參數,分別表示控制點1(x1,y1)、控制點2(x2,y2)和繪制終點(x3,y3)。
如圖4-1,繪制前1/2半圓時以起點A右側平行gap1flag1距離處作為第一個控制點D、中點B右側平行r1距離的半圓頂點第二個控制點E、中點B作為繪制終點;繪制后1/2半圓同理,以E點作為第一個控制點,終點C右側平行gap1flag1距離處作為第二個控制點F、終點C作為繪制終點。
其他繪制方向同理,若為從下向上繪制,則將flag設為-1;若繪制凹半圓,則在計算坐標時橫坐標反方向計算即可可參考圖4-2。
- public static void drawPartCircle(Point start, Point end, Path path, boolean outer) {
- float c = 0.551915024494f;
- Point middle = new Point(start.getPointX() + (end.getPointX() - start.getPointX()) / 2,start.getPointY() + (end.getPointY() - start.getPointY()) / 2);//根據起點坐標A和終點坐標C算出中點B坐標
- //半徑
- float r1 = (float) Math.sqrt(Math.pow((middle.getPointX() - start.getPointX()), 2) + Math.pow((middle.getPointY() - start.getPointY()), 2));
- float gap1 = r1 * c;//距離gap
- if (start.getPointX() == end.getPointX()) {//繪制豎直方向
- boolean topToBottom = end.getPointY() - start.getPointY() > 0;
- int flag;//旋轉系數
- if (topToBottom) { //若從上到下繪制
- flag = 1;//旋轉系數設為1
- } else { flag = -1; }//若從下到上繪制,設為-1
- if (outer) {//若為凸半圓,相加
- path.cubicTo(start.getPointX() + gap1 * flag, start.getPointY(),middle.getPointX() + r1 * flag, middle.getPointY() - gap1 * flag,middle.getPointX() + r1 * flag, middle.getPointY());
- path.cubicTo(middle.getPointX() + r1 * flag, middle.getPointY() + gap1 * flag,end.getPointX() + gap1 * flag, end.getPointY(), end.getPointX(), end.getPointY());
- }... }//若為凹半圓,則相減
- }
2、SwipteCaptchaView類
SwipeCaptchaView是具體實現滑塊滑動效果的類,下文將從初始化滑動條并設置滑動條監聽、初始化驗證區域背景、設置驗證后的動畫效果、生成滑動驗證區域四個方面具體講解實現邏輯。接下來將按類型講解類中各方法間的調用邏輯,可參考圖4。

圖4 各類間的函數調用關系示意圖
(1)初始化滑動條并設置滑動條監聽
在SwipteCaptchaView類的構造函數中,調用init()方法進行初始化。其中,獲取xml文件中控件參數即寬、高和系統屏幕寬度;通過switch-case判斷來獲取滑塊的寬、高和滑動誤差值;
- mHeight = getHeight();//獲取控件高和款
- mWidth = getWidth();//獲取系統屏幕寬度
- if (mWidth == 0) {//mWidth=0為設置了match_parent的情況
- mWidth = DisplayManager.getInstance().getDefaultDisplay(context).get().getAttributes().width;
- }
- for (int i = 0; i < attrSet.getLength(); i++) {
- Optional<Attr> attr = attrSet.getAttr(i);
- if (attr.isPresent()) {
- switch (attr.get().getName()) {
- case "captchaHeight"://獲取滑塊高度
- mCaptchaHeight = attr.get().getDimensionValue();
- break;
- case "captchaWidth"://獲取滑塊寬度
- ...
- case "matchDeviation"://獲取滑動誤差值
- ...
- }
- }
- }
實例化Image類得到驗證區域圖片對象,并為其設置圖片縮放模式以及位圖格式等屬性;實例化Slider類得到拖動條對象,為其設置寬、高、進度值、進度顏色等屬性,以及監聽事件;
- mImage = new Image(context);//表示驗證區域圖片
- ...
- mImage.setScaleMode(Image.ScaleMode.CLIP_CENTER);
- mImage.setPixelMap(ResourceTable.Media_no_resource);
- ...
- mSlider = new Slider(mLayout.getContext());//實例化Slider類表示拖動條
- mSlider.setWidth(mWidth); //設置寬、高
- mSlider.setHeight(SLIDER_HEIGHT);
- mSlider.setMarginTop(mHeight - SLIDER_HEIGHT);
- mSlider.setMinValue(0); //進度最小、最大值、當前進度值、進度顏色
- mSlider.setMaxValue(10000);
- mSlider.setProgressValue(0)
- mSlider.setProgressColor(Color.BLACK);
- setSlideListener(); //設置拖動條的監聽事件
- ...
在拖動條監聽事件setSlideListener()方法中,重寫onTouchEnd()方法,判斷滑動結束后滑塊位置的誤差值是否小于規定誤差值。若小于則驗證成功,取消滑塊的陰影并設置回調;否則驗證失敗,直接設置回調;
- @Override
- public void onTouchEnd(Slider slider) {
- if (onCaptchaMatchCallback != null) {
- if (Math.abs(mSlider.getProgress() * (mWidth - mCaptchaWidth) / 10000 - mCaptchaX) < mMatchDeviation) {//滑動結束后滑塊位置誤差值小于規定誤差值驗證成功
- mCaptchaPaint.setMaskFilter(null); //取消滑塊的陰影
- slider.setEnabled(false);
- onCaptchaMatchCallback.matchSuccess(SwipeCaptchaView.this);//設置驗證成功后的回調
- mSuccessAnim.start();//播放驗證成功動畫
- } else {//滑動誤差值大于規定誤差值驗證失敗
- slider.setProgressValue(0);
- onCaptchaMatchCallback.matchFailed(SwipeCaptchaView.this);//設置驗證失敗后的回調
- mFailAnim.start();//播放驗證失敗動畫
- }
- }
- }
(2)初始化滑動驗證區域
在通過Image類對象調用setPixelMap()方法設置完驗證圖片后,由initCaptcha()方法完成驗證區域的初始化。
實例化兩個Paint類分別得到畫筆對象和滑塊目標區域對象,為其設置畫筆抗鋸齒和陰影、滑塊樣式和顏色等屬性;再分別調用 createMatchAnim()和createCaptcha()方法設置驗證后的動畫效果和生成滑動驗證區域。
- private void initCaptcha() {
- mRandom = new Random(System.nanoTime());
- //設置畫筆
- mCaptchaPaint = new Paint();//畫筆對象
- mCaptchaPaint.setAntiAlias(true); //抗鋸齒
- mCaptchaPaint.setDither(true); //使位圖進行有利的抖動的位掩碼標志
- mCaptchaPaint.setStyle(Paint.Style.FILL_STYLE);
- mCaptchaPaint.setMaskFilter(new MaskFilter(10, MaskFilter.Blur.SOLID));//陰影
- //滑塊目標區域
- mMaskPaint = new Paint();//滑塊目標區域對象
- mMaskPaint.setAntiAlias(true);
- mMaskPaint.setDither(true);
- mMaskPaint.setStyle(Paint.Style.FILL_STYLE); //填充樣式
- mMaskPaint.setColor(new Color(Color.argb(188, 0, 0, 0))); //填充顏色
- mMaskPaint.setMaskFilter(new MaskFilter(20, MaskFilter.Blur.INNER)); //陰影
- mCaptchaPath = new Path();
- createMatchAnim();//設置驗證后的動畫效果
- createCaptcha();//生成驗證碼區域
- }
(3)設置驗證后的動畫效果
由createMatchAnim()方法實現,能夠設置驗證成功或失敗后的動畫效果。
- 驗證成功
通過AnimatorValue類對象設置動畫間隔時間為500毫秒;并為其設置當值更新時的監聽事件,重寫onUpdate()方法,設置成功動畫中拼圖的偏移量;
- //成功動畫
- int width = AttrHelper.vp2px(60, mLayout.getContext());
- mSuccessAnim = new AnimatorValue();
- mSuccessAnim.setDuration(500);//間隔時間為500毫秒
- mSuccessAnim.setValueUpdateListener(new AnimatorValue.ValueUpdateListener() {
- @Override//設置監聽
- public void onUpdate(AnimatorValue animatorValue, float v) {
- mSuccessAnimOffset = (int) (v * (mWidth + width));//拼圖偏移量
- invalidate();
- }
- });
通過Paint類和Path類對象分別調用相關函數來完成陰影效果和動畫路徑的繪制。
- mSuccessPaint = new Paint();
- mSuccessPaint.setShader(new LinearShader(//設置陰影
- new Point[]{new Point(0, 0), new Point(width * 3 / 2, mHeight)},
- new float[]{0, 0.5f},
- new Color[]{new Color(0x00FFFFFF), new Color(0x66FFFFFF)},
- Shader.TileMode.MIRROR_TILEMODE), Paint.ShaderType.LINEAR_SHADER);
- mSuccessPath = new Path();//繪制動畫路徑
- mSuccessPath.moveTo(0, 0);
- mSuccessPath.rLineTo(width, 0);
- mSuccessPath.rLineTo(width / 2, mHeight - SLIDER_HEIGHT);
- mSuccessPath.rLineTo(-width, 0);
- mSuccessPath.close();//關閉
- 驗證失敗
與設置驗證成功的前半部分流程相似,不同之處是將動畫間隔設為200毫秒、還要設置畫圈次數為2次;在值更新時的監聽事件中,需要判斷當更新值小于0.5f時,將isDrawMask置為false即不繪制滑塊,反之為true則繪制。
- //設置驗證失敗動畫
- mFailAnim = new AnimatorValue();//實例化驗證失敗的動畫對象
- mFailAnim.setDuration(200);//設置間隔時間為200毫秒
- mFailAnim.setLoopedCount(2);//設置畫圈次數為2次
- mFailAnim.setValueUpdateListener(new AnimatorValue.ValueUpdateListener() {
- @Override
- public void onUpdate(AnimatorValue animatorValue, float v) {
- if (v < 0.5f) {
- isDrawMask = false;//不繪制滑塊
- } else { isDrawMask = true; }//繪制滑塊
- invalidate();
- }});
- }
(4)生成滑動驗證區域
由createCaptcha()方法實現。先調用createCaptchaPath()方法繪制拼圖塊的輪廓路徑。其中通過Random類的nextInt()方法隨機生成驗證區域坐標,使滑塊和原圖位置隨機變化;再使用工具類DrawHelperUtils的DrawPartCircle()方法繪制拼圖塊左上角、右上角、右下角和左下角的圖形。
- private void createCaptchaPath() {//繪制拼圖塊輪廓路徑path
- int gap = mCaptchaWidth / 3; //拼圖缺口的位置,設置在中間 1/3 處
- mCaptchaX = mRandom.nextInt(mWidth - (mCaptchaWidth * 3) - gap) + (mCaptchaWidth * 2); //隨機生成驗證區域左上角的坐標
- mCaptchaY = mRandom.nextInt(mHeight - SLIDER_HEIGHT - mCaptchaHeight - gap);
- mCaptchaPath.reset();
- mCaptchaPath.lineTo(0, 0);
- //開始繪制圖形
- mCaptchaPath.moveTo(mCaptchaX, mCaptchaY); //左上角
- mCaptchaPath.lineTo(mCaptchaX + gap, mCaptchaY);
- drawPartCircle(new Point(mCaptchaX + gap, mCaptchaY),new Point(mCaptchaX + gap * 2, mCaptchaY),
- mCaptchaPath, mRandom.nextBoolean());
- ...//右上角、右下角和左下角同理
- mCaptchaPath.close();//繪制完成后及時關閉
- }
接著生成滑動驗證區域,前面介紹過,SwipeCaptcha_ohos2.0版升級實現了驗證區域背景圖片自適應填充的效果。其實現原理是先獲取位圖;根據圖片的寬高和控件實際的寬高分別計算出水平方向和豎直方向上的縮放比例,兩者中較大的是圖片真實的縮放比例,這是由于上文介紹的Image控件將圖片縮放模式設為了CLIP_CENTER,該模式會將圖片的短邊縮放至合適的大小并對長邊進行裁剪,因此較小的縮放比例代表被裁剪的邊,較大的即在填充進控件時的真實縮放比例;接著繪制滑塊目標區域的陰影,其不隨拖動條的移動而更新;最后繪制滑塊區域,根據拖動條的數值計算畫布偏移量,調用drawPath()方法繪制邊框,獲取圖片 PixelMapHolder,根據路徑裁剪并將畫布縮放至跟圖片縮放程度一致,根據比例計算出垂直方向上由于 CLIP_CENTER 裁剪掉的圖片的高度以及水平方向上被裁掉的寬度,即可繪制內容。
- public void createCaptcha() {//生成驗證區域
- if (mImage.getPixelMap() != null) {
- createCaptchaPath();//繪制拼圖塊輪廓路徑Path
- ...}
- PixelMap mCaptchaPixelMap = mImage.getPixelMap();//getPixelMap(mLayout.getContext(),ResourceTable.Media_pic01);
- //根據圖片的原寬度和控件寬度算出縮放比例
- int originWidth = mCaptchaPixelMap.getImageInfo().size.width;
- int originHeight = mCaptchaPixelMap.getImageInfo().size.height;
- float ratioWidth = (float) mWidth / originWidth;
- float ratioHeight = (float) (mHeight - SLIDER_HEIGHT) / originHeight;
- float ratio = Math.max(ratioWidth, ratioHeight);//更大的ratio
- mImage.addDrawTask((component, canvas) -> { //滑塊目標區域陰影的繪制
- canvas.drawPath(mCaptchaPath, mMaskPaint);
- });
- mLayout.addDrawTask((component, canvas) -> {//滑塊區域的繪制
- if (isDrawMask) {
- canvas.translate(mSlider.getProgress() * (mWidth - mCaptchaWidth) / 10000 - mCaptchaX, 0); //根據拖動條的數值計算畫布的偏移量
- canvas.drawPath(mCaptchaPath, mCaptchaPaint);//繪制邊框
- PixelMapHolder mCaptchaPixelMapHolder = new PixelMapHolder(mCaptchaPixelMap); //獲取圖片的 PixelMapHolder
- canvas.clipPath(mCaptchaPath, Canvas.ClipOp.INTERSECT);//根據路徑裁剪
- canvas.scale(ratio, ratio);//畫布縮放至跟圖片縮放程度一致
- if(ratio == ratioWidth) {
- float heightErr = (originHeight * ratio - (mHeight - SLIDER_HEIGHT)) / 2;//根據比例計算出垂直方向上由于 CLIP_CENTER 裁剪掉的圖片的高度
- canvas.drawPixelMapHolder(mCaptchaPixelMapHolder, 0, - heightErr / ratio, mCaptchaPaint);//繪制內容
- }
- else {
- float widthErr = (originWidth * ratio - mWidth) / 2;//根據比例計算出水平方向上由于 CLIP_CENTER 裁剪掉的圖片的寬度
- canvas.drawPixelMapHolder(mCaptchaPixelMapHolder, - widthErr / ratio, 0, mCaptchaPaint);//繪制內容
- }
- }});
- }