用 canvas 實現 Web 手勢解鎖

最近參加 360 暑假的前端星計劃,有一個在線做業,截止日期是 3 月 30 號,讓手動實現一個 H5 手勢解鎖,具體的效果就像原生手機的九宮格解鎖那樣。javascript

實現的最終效果就像下面這張圖這樣:css

基本要求是這樣的:將密碼保存到 localStorage 裏,開始的時候會從本地讀取密碼,若是沒有就讓用戶設置密碼,密碼最少爲五位數,少於五位要提示錯誤。須要對第一次輸入的密碼進行驗證,兩次同樣才能保持,而後是驗證密碼,可以對用戶輸入的密碼進行驗證。html

H5 手勢解鎖

掃碼在線查看:前端

或者點擊查看手機版java

項目 GitHub 地址,H5HandLockandroid

首先,我要說明一下,對於這個項目,我是參考別人的,H5lockgit

我以爲一個比較合理的解法應該是利用 canvas 來實現,不知道有沒有大神用 css 來實現。若是純用 css 的話,能夠將連線先設置 display: none,當手指劃過的時候,顯示出來。光設置這些應該就很是麻煩吧。github

以前瞭解過 canvas,但沒有真正的寫過,下面就來介紹我這幾天學習 canvas 並實現 H5 手勢解鎖的過程。web

準備及佈局設置

我這裏用了一個比較常規的作法:chrome

(function(w){
  var handLock = function(option){}

  handLock.prototype = {
    init : function(){},
    ...
  }

  w.handLock = handLock;
})(window)

// 使用
new handLock({
  el: document.getElementById('id'),
  ...
}).init();

常規方法,比較易懂和操做,弊端就是,能夠被隨意的修改。

傳入的參數中要包含一個 dom 對象,會在這個 dom 對象內建立一個 canvas。固然還有一些其餘的 dom 參數,好比 message,info 等。

關於 css 的話,懶得去新建文件了,就直接內聯了。

canvas

1. 學習 canvas 並搞定畫圓

MDN 上面有個簡易的教程,大體瀏覽了一下,感受還行。Canvas教程

先建立一個 canvas,而後設置其大小,並經過 getContext 方法得到繪畫的上下文:

var canvas = document.createElement('canvas');
canvas.width = canvas.height = width;
this.el.appendChild(canvas);

this.ctx = canvas.getContext('2d');

而後呢,先畫 n*n 個圓出來:

createCircles: function(){
  var ctx = this.ctx,
    drawCircle = this.drawCircle,
    n = this.n;
  this.r = ctx.canvas.width / (2 + 4 * n) // 這裏是參考的,感受這種畫圓的方式挺合理的,方方圓圓
  r = this.r;
  this.circles = []; // 用來存儲圓心的位置
  for(var i = 0; i < n; i++){
    for(var j = 0; j < n; j++){
      var p = {
        x: j * 4 * r + 3 * r,
        y: i * 4 * r + 3 * r,
        id: i * 3 + j
      }
      this.circles.push(p);
    }
  }
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); // 爲了防止重複畫
  this.circles.forEach(function(v){
    drawCircle(ctx, v.x, v.y); // 畫每一個圓
  })
},

drawCircle: function(ctx, x, y){ // 畫圓函數
  ctx.strokeStyle = '#FFFFFF';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.arc(x, y, this.r, 0, Math.PI * 2, true);
  ctx.closePath();
  ctx.stroke();
}

畫圓函數,須要注意:如何肯定圓的半徑和每一個圓的圓心座標(這個我是參考的),若是以圓心爲中點,每一個圓上下左右各擴展一個半徑的距離,同時爲了防止四邊太擠,四周在填充一個半徑的距離。那麼獲得的半徑就是 width / ( 4 * n + 2),對應也能夠算出每一個圓所在的圓心座標,也有一套公式,GET

2. 畫線

畫線須要藉助 touch event 來完成,也就是,當咱們 touchstart 的時候,傳入開始時的相對座標,做爲線的一端,當咱們 touchmove 的時候,得到座標,做爲線的另外一端,當咱們 touchend 的時候,開始畫線。

這只是一個測試畫線功能,具體的後面再進行修改。

有兩個函數,得到當前 touch 的相對座標:

getTouchPos: function(e){ // 得到觸摸點的相對位置
  var rect = e.target.getBoundingClientRect();
  var p = { // 相對座標
    x: e.touches[0].clientX - rect.left,
    y: e.touches[0].clientY - rect.top
  };
  return p;
}

畫線:

drawLine: function(p1, p2){ // 畫線
  this.ctx.beginPath();
  this.ctx.lineWidth = 3;
  this.ctx.moveTo(p1.x, p2.y);
  this.ctx.lineTo(p.x, p.y);
  this.ctx.stroke();
  this.ctx.closePath();
},

而後就是監聽 canvas 的 touchstarttouchmove、和 touchend 事件了。

3. 畫折線

所謂的畫折線,就是,將已經觸摸到的點連起來,能夠把它看做是畫折線。

首先,要用兩個數組,一個數組用於已經 touch 過的點,另外一個數組用於存儲未 touch 的點,而後在 move 監聽時候,對 touch 的相對位置進行判斷,若是觸到點,就把該點從未 touch 移到 touch 中,而後,畫折線,思路也很簡單。

drawLine: function(p){ // 畫折線
  this.ctx.beginPath();
  this.ctx.lineWidth = 3;
  this.ctx.moveTo(this.touchCircles[0].x, this.touchCircles[0].y);
  for (var i = 1 ; i < this.touchCircles.length ; i++) {
    this.ctx.lineTo(this.touchCircles[i].x, this.touchCircles[i].y);
  }
  this.ctx.lineTo(p.x, p.y);
  this.ctx.stroke();
  this.ctx.closePath();
},
judgePos: function(p){ // 判斷 觸點 是否在 circle 內
  for(var i = 0; i < this.restCircles.length; i++){
    temp = this.restCircles[i];
    if(Math.abs(p.x - temp.x) < r && Math.abs(p.y - temp.y) < r){
      this.touchCircles.push(temp);
      this.restCircles.splice(i, 1);
      this.touchFlag = true;
      break;
    }
  }
}

4. 標記已畫

前面已經說了,咱們把已經 touch 的點(圓)放到數組中,這個時候須要將這些已經 touch 的點給標記一下,在圓心處畫一個小實心圓:

drawPoints: function(){
  for (var i = 0 ; i < this.touchCircles.length ; i++) {
    this.ctx.fillStyle = '#FFFFFF';
    this.ctx.beginPath();
    this.ctx.arc(this.touchCircles[i].x, this.touchCircles[i].y, this.r / 2, 0, Math.PI * 2, true);
    this.ctx.closePath();
    this.ctx.fill();
  }
}

同時添加一個 reset 函數,當 touchend 的時候調用,400ms 調用 reset 重置 canvas。

到如今爲止,一個 H5 手勢解鎖的簡易版已經基本完成。

password

爲了要實現記住和重置密碼的功能,把 password 保存在 localStorage 中,但首先要添加必要的 html 和樣式。

1. 添加 message 和 單選框

爲了儘量的使界面簡潔(越醜越好),直接在 body 後面添加了:

<div id="select">
  <div class="message">請輸入手勢密碼</div>
  <div class="radio">
    <label><input type="radio" name="pass">設置手勢密碼</label>
    <label><input type="radio" name="pass">驗證手勢密碼</label>
  </div>
</div>

將添加到 dom 已 option 的形式傳給 handLock:

var el = document.getElementById('handlock'),
  info = el.getElementsByClassName('info')[0],
  select = document.getElementById('select'),
  message = select.getElementsByClassName('message')[0],
  radio = select.getElementsByClassName('radio')[0],
  setPass = radio.children[0].children[0],
  checkPass = radio.children[1].children[0];
new handLock({
  el: el,
  info: info,
  message: message,
  setPass: setPass,
  checkPass: checkPass,
  n: 3
}).init();

2. info 信息顯示

關於 info 信息顯示,本身寫了一個懸浮窗,而後默認爲 display: none,而後寫了一個 showInfo 函數用來顯示提示信息,直接調用:

showInfo: function(message, timer){ // 專門用來顯示 info
  var info = this.dom.info;
  info.innerHTML = message;
  info.style.display = 'block';
  setTimeout(function(){
    info.style.display = '';
  }, 1000)
}

關於 info 的樣式,在 html 中呢。

3. 關於密碼

先不考慮從 localStorage 讀取到狀況,新加一個 lsPass 對象,專門用於存儲密碼,因爲密碼狀況比較多,好比設置密碼,二次確認密碼,驗證密碼,爲了方便管理,暫時設置了密碼的三種模式,分別是:

model:1 驗證密碼模式

model:2 設置密碼模式

model:3 設置密碼二次驗證

具體看下面這個圖:

這三種 model ,只要處理好它們之間如何跳轉就 ok 了,即狀態的改變。

因此就有了 initPass:

initPass: function(){ // 將密碼初始化
  this.lsPass = w.localStorage.getItem('HandLockPass') ? {
    model: 1,
    pass: w.localStorage.getItem('HandLockPass').split('-')
  } : { model: 2 };
  this.updateMessage();
},

updateMessage: function(){ // 根據當前模式,更新 dom
  if(this.lsPass.model == 2){
    this.dom.setPass.checked = true;
    this.dom.message.innerHTML = '請設置手勢密碼';
  }else if(this.lsPass.model == 1){
    this.dom.checkPass.checked = true;
    this.dom.message.innerHTML = '請驗證手勢密碼';
  }else if(this.lsPass.model = 3){
    this.dom.setPass.checked = true;
    this.dom.message.innerHTML = '請再次輸入密碼';
  }
},

有必要再來介紹一下 lsPass 的格式:

this.lsPass = {
  model:1, // 表示當前的模式
  pass: [0, 1, 2, 4, 5] // 表示當前的密碼,可能不存在
}

由於以前已經有了一個基本的實現框架,如今只須要在 touchend 以後,寫一個函數,功能就是先對當前的 model 進行判斷,實現對應的功能,這裏要用到 touchCircles 數組,表示密碼的順序:

checkPass: function(){
  var succ, model = this.lsPass.model; //succ 之後會用到
  if(model == 2){ // 設置密碼
    if(this.touchCircles.length < 5){ // 驗證密碼長度
      succ = false;
      this.showInfo('密碼長度至少爲 5!', 1000);
    }else{
      succ = true;
      this.lsPass.temp = []; // 將密碼放到臨時區存儲
      for(var i = 0; i < this.touchCircles.length; i++){
        this.lsPass.temp.push(this.touchCircles[i].id);
      }
      this.lsPass.model = 3;
      this.showInfo('請再次輸入密碼', 1000);
      this.updateMessage();
    }
  }else if(model == 3){// 確認密碼
    var flag = true;
    // 先要驗證密碼是否正確
    if(this.touchCircles.length == this.lsPass.temp.length){
      var tc = this.touchCircles, lt = this.lsPass.temp;
      for(var i = 0; i < tc.length; i++){
        if(tc[i].id != lt[i]){
          flag = false;
        }
      }
    }else{
      flag = false;
    }
    if(!flag){
      succ = false;
      this.showInfo('兩次密碼不一致,請從新輸入', 1000);
      this.lsPass.model = 2; // 因爲密碼不正確,從新回到 model 2
      this.updateMessage();
    }else{
      succ = true; // 密碼正確,localStorage 存儲,並設置狀態爲 model 1
      w.localStorage.setItem('HandLockPass', this.lsPass.temp.join('-')); // 存儲字符串
      this.lsPass.model = 1; 
      this.lsPass.pass = this.lsPass.temp;
      this.updateMessage();
    }
    delete this.lsPass.temp; // 很重要,必定要刪掉,bug
  }else if(model == 1){ // 驗證密碼
    var tc = this.touchCircles, lp = this.lsPass.pass, flag = true;
    if(tc.length == lp.length){
      for(var i = 0; i < tc.length; i++){
        if(tc[i].id != lp[i]){
          flag = false;
        }
      }
    }else{
      flag = false;
    }
    if(!flag){
      succ = false;
      this.showInfo('很遺憾,密碼錯誤', 1000);
    }else{
      succ = true;
      this.showInfo('恭喜你,驗證經過', 1000);
    }
  }
},

密碼的設置要參考前面那張圖,要時刻警戒狀態的改變。

4. 手動重置密碼

思路也很簡單,就是添加點擊事件,點擊以後,改變 model 便可,點擊事件以下:

this.dom.setPass.addEventListener('click', function(e){
  self.lsPass.model = 2; // 改變 model 爲設置密碼
  self.updateMessage(); // 更新 message
  self.showInfo('請設置密碼', 1000);
})
this.dom.checkPass.addEventListener('click', function(e){
  if(self.lsPass.pass){
    self.lsPass.model = 1;
    self.updateMessage();
    self.showInfo('請驗證密碼', 1000)
  }else{
    self.showInfo('請先設置密碼', 1000);
    self.updateMessage();
  }
})

ps:這裏面還有幾個小的 bug,由於 model 只有 3 個,因此設置的時候,當點擊重置密碼的時候,沒有設置密碼成功,又切成驗證密碼狀態,此時沒法提高沿用舊密碼,緣由是 model 只有三個

5. 添加 touchend 顏色變化

實現這個基本上就大功告成了,這個功能最主要的是給用戶一個提醒,若用戶劃出的密碼符合規範,顯示綠色,若不符合規範或錯誤,顯示紅色警告。

由於以前已經設置了一個 succ 變量,專門用於重繪。

drawEndCircles: function(color){ // end 時重繪已經 touch 的圓
  for(var i = 0; i < this.touchCircles.length; i++){
    this.drawCircle(this.touchCircles[i].x, this.touchCircles[i].y, color);
  }
},

// 調用
if(succ){
  this.drawEndCircles('#2CFF26'); // 綠色
}else{
  this.drawEndCircles('red'); // 紅色
}

那麼,一個能夠演示的版本就生成了,儘管還存在一些 bug,隨後會來解決。(詳情分支 password)

一些 bugs

有些 bugs 在作的時候就發現了,一些 bug 後來用手機測試的時候才發現,好比,我用 chrome 的時候,沒有察覺這個 bug,當我用 android 手機 chrome 瀏覽器測試的時候,發現當我 touchmove 向下的時候,會觸發瀏覽器的下拉刷新,解決辦法:加了一個 preventDefault,沒想到竟然成功了。

this.canvas.addEventListener('touchmove', function(e){
  e.preventDefault ? e.preventDefault() : null;
  var p = self.getTouchPos(e);
  if(self.touchFlag){
    self.update(p);
  }else{
    self.judgePos(p);
  }
}, false)

關於 showInfo

因爲showInfo 中有 setTimeout 函數,能夠看到函數裏的演出爲 1s,致使若是咱們操做的速度比較快,在 1s 內連續 show 了不少個 info,後面的 info 會被第一個 info 的 setTimeout 弄亂,顯示的時間小於 1s,或更短。好比,當重複點擊設置手勢密碼和驗證手勢密碼,會產生這個 bug。

解決辦法有兩個,一個是增長一個專門用於顯示的數組,每次從數組中取值而後顯示。另外一種解題思路和防抖動的思路很像,就是當有一個新的 show 到來時,把以前的那個 setTimeout 清除掉。

這裏採用第二種思路:

showInfo: function(message, timer){ // 專門用來顯示 info
  clearTimeout(this.showInfo.timer);
  var info = this.dom.info;
  info.innerHTML = message;
  info.style.display = 'block';
  this.showInfo.timer = setTimeout(function(){
    info.style.display = '';
  }, timer || 1000)
},

解決小尾巴

所謂的小尾巴,以下:

解決辦法也很簡單,在 touchend 的時候,先進行 clearRect 就 ok 了。

關於優化

性能優化一直都是一個大問題,不要覺得前端不須要考慮內存,就能夠隨便寫代碼。

以前在設計本身網頁的時候,用到了滾動,鼠標滑輪輕輕一碰,滾動函數就執行了幾十多則幾百次,以前也考慮過解決辦法。

優化 canvas 部分

對於 touchmove 函數,原理都是同樣的,手指一劃,就執行了 n 屢次,這個問題後面在解決,先來看另外一個問題。

touchmove 是一個高頻函數,看到這裏,若是你並無仔細看個人代碼,那你對我採用的 canvas 畫圖方式可能不太瞭解,下面這個是 touchmove 函數幹了哪些事:

  1. 先判斷,若是當前處於未選中一個密碼狀態,則繼續監視當前的位置,直到選中第一個密碼,進入第二步;

  2. 進入 update 函數,update 函數主要幹四件事,重繪圓(密碼)、判斷當前位置、重繪點、重繪線;

第二步是一個很揪心的動做,爲何每次都要重繪圓,點和線呢?

上面這個圖能夠很好的說明問題,由於在設置或驗證密碼的過程當中,咱們須要用一條線來鏈接觸點到當前的最後一個密碼,而且當 touchmove 的時候,能看到它們在變化。這個功能很棒,能夠勾勒出 touchmove 的軌跡。

可是,這就必需要時刻刷新 canvas,性能大大地下降,刷新的那但是整個 canvas。

由於 canvas 只有一個,既要畫背景圓(密碼),又要畫已選密碼的點,和折線。這其中好多步驟,自始至終只須要一次就行了,好比背景圓,只需在啓動的時候畫一次,已選密碼,只要當 touchCircles 新加元素的時候纔會用一次,還不用重繪,只要畫就能夠了。折線分紅兩部分,一部分是已選密碼之間的連線,還有就是最後一個密碼點到當前觸點之間的連線。

若是有兩個 canvas 就行了,一個存儲靜態的,一個專門用於重繪

爲何不能夠有呢!

個人解決思路是,如今有兩個 canvas,一個在底層,做爲描繪靜態的圓、點和折線,另外一個在上層,一方面監聽 touchmove 事件,另外一方面不停地重繪最後一個密碼點的圓心到當前觸點之間的線。若是這樣能夠的話,touchmove 函數執行一次的效率大大提升。

插入第二個 canvas:

var canvas2 = canvas.cloneNode(canvas, true);
canvas2.style.position = 'absolute';//讓上層 canvas 覆蓋底層 canvas
canvas2.style.top = '0';
canvas2.style.left = '0';
this.el.appendChild(canvas2);
this.ctx2 = canvas2.getContext('2d');

要改換對第二個 ctx2 進行 touch 監聽,並設置一個 this.reDraw 參數,表示有新的密碼添加進來,須要對點和折線添加新內容, update 函數要改爲這樣:

update: function(p){ // 更新 touchmove
  this.judgePos(p); // 每次都要判斷
  this.drawLine2TouchPos(p); // 新加函數,用於繪最後一個密碼點點圓心到觸點之間的線
  if(this.reDraw){ // 有新的密碼加進來
    this.reDraw = false;
    this.drawPoints(); // 添加新點
    this.drawLine();// 添加新線
  }
},
drawLine2TouchPos: function(p){
  var len = this.touchCircles.length;
  if(len >= 1){
    this.ctx2.clearRect(0, 0, this.width, this.width); // 先清空
    this.ctx2.beginPath();
    this.ctx2.lineWidth = 3;
    this.ctx2.moveTo(this.touchCircles[len - 1].x, this.touchCircles[len - 1].y);
    this.ctx2.lineTo(p.x, p.y);
    this.ctx2.stroke();
    this.ctx2.closePath();
  }
},

相應的 drawPoints 和 drawLine 函數也要對應修改,由原理畫全部的,到如今只須要畫新加的。

效果怎麼樣:

move 函數執行屢次,而其餘函數只有當新密碼加進來的時候才執行一次。

加入節流函數

以前也已經說過了,這個 touchmove 函數執行的次數比較多,儘管咱們已經用兩個 canvas 對重繪作了很大的優化,但 touchmove 仍是有點大開銷。

這個時候我想到了防抖動和節流,首先防抖動確定是不行的,萬一我一直處於 touch 狀態,重繪會延遲死的,這個時候節流會好一些。防抖和節流

先寫一個節流函數:

throttle: function(func, delay, mustRun){
  var timer, startTime = new Date(), self = this;
  return function(){
    var curTime = new Date(), args = arguments;
    clearTimeout(timer);
    if(curTime - startTime >= mustRun){
      startTime = curTime;
      func.apply(self, args);
    }else{
      timer = setTimeout(function(){
        func.apply(self, args);
      }, delay)
    }
  }
}

節流函數的意思:在延遲爲 delay 的時間內,若是函數再次觸發,則從新計時,這個功能和防抖動是同樣的,第三個參數 mustRun 是一個時間間隔,表示在時間間隔大於 mustRun 後的一個函數能夠當即直接執行。

而後對 touchmove 的回調函數進行改造:

var t = this.throttle(function(e){
  e.preventDefault ? e.preventDefault() : null;
  e.stopPropagation ? e.stopPropagation() : null;
  var p = this.getTouchPos(e);
  if(this.touchFlag){
    this.update(p);
  }else{
    this.judgePos(p);
  }
}, 16, 16)
this.canvas2.addEventListener('touchmove', t, false)

關於 delay 和 mustRun 的時間間隔問題,web 性能裏有一個 16ms 的概念,就是說若是要達到每秒 60 幀,間隔爲 1000/60 大約爲 16 ms。若是間隔大於 16ms 則 fps 會比 60 低。

鑑於此,咱們這裏將 delay 和 mustRun 都設爲 16,在極端的狀況下,也就是最壞的狀況下,或許須要 15 + 15 = 30ms 纔會執行一次,這個時候要設置兩個 8 才合理,不過考慮到手指活動是一個連續的過程,怎麼可能會每 15 秒執行一次,通過在線測試,發現設置成 16 效果還不錯。

性能真的能優化嗎,咱們來看兩個圖片,do 和 wantdo 表示真實執行和放到節流函數中排隊準備執行。

當 touchmove 速度通常或很快的時候:

當 touchmove 速度很慢的時候:

能夠看出來,滑動過程當中,速度通常和快速,平均優化了一半,慢速效果也優化了 20 到 30% 之間,平時手勢鎖解鎖時候,確定速度很快。可見,節流的優化仍是很明顯的。

關鍵是,優化以後的流程性,沒有受到任何影響。

這個節流函數最終仍是出現了一個 bug:因爲是延遲執行的,致使 e.preventDefault 失效,在手機瀏覽器向下滑會出現刷新的狀況,這也算事件延遲的一個危害吧。

解決辦法:在節流函數提早取消默認事件:

throttle: function(func, delay, mustRun){
  var timer, startTime = new Date(), self = this;
  return function(e){
    if(e){
      e.preventDefault ? e.preventDefault() : null; //提早取消默認事件,不要等到 setTimeout
      e.stopPropagation ? e.stopPropagation() : null;
    }
    ...
  }
}

總結

大概花了三天左右的時間,將這個 H5 的手勢解鎖給完成,本身仍是比較滿意的,雖然可能達不到評委老師的承認,不過本身在作的過程當中,學習到了不少新知識。

參考

H5lock
Canvas教程
js獲取單選框裏面的值
前端高性能滾動 scroll 及頁面渲染優化

歡迎來個人博客交流。

相關文章
相關標籤/搜索