如何用canvas拍出 jDer's工作照

jdf2e發表於2020-09-14

背景

在京東,就職滿五年的老員工被稱作“大佬”,如果滿了十年,那就要被稱之為“超級大佬”了。

從 2016 年 5 月 19 日開始,每一年的這一天都被定為京東集團的“519 老員工日”。正所謂:五年礪銀,十年鍛金!在京東成長 10 年的員工,放在行業裡的任何一家公司,都能夠像金子般發光!

在這 5 年或 10 年無數個奮鬥的日夜裡,大家是以怎樣的姿勢在工作呢?下面由我揭曉這些姿勢是怎樣修煉而成的吧~

玩法

首先我們用一張 gif 圖來回顧一下效果

image

玩法基本的步驟如下

image

ok,拍完照就可以分享到朋友圈了。

技術選型

可以看到這裡用到了大量的圖片,通過對圖片的拖拽縮放等操作,擺放人物及配件,最終合成相應的圖片。那麼這一過程是怎麼實現的呢?

首先我們採用 NUTUI 來搭建整個專案,其腳手架可以很好地處理圖片優化打包等。底部操作選單模組使用了 NUTUI 中的 Tab 元件,提升了開發效率。在主介面的部分選用了基於 canvas 的 creatjs 庫,以及一個輕量級的觸屏裝置手勢庫 hammer.js 來開發。

image

NUTUI

NUTUI 是一套京東風格的移動端元件庫,開發和服務於移動 Web 介面的企業級前中後臺產品。50+ 高質量元件,40+ 京東移動端專案正在使用,支援按需載入,支援服務端渲染(Vue SSR)...

快掃碼體驗起來吧

image

Hammer.js

Hammer 是一個開原始碼庫,可以識別由觸控,滑鼠和 pointerEvents 做出的手勢。它沒有任何依賴性,並且很小,壓縮後只有 7.34 kB。
它支援常見的單點和多點觸控手勢,並且可以新增自定義手勢

image

Create.js

CreateJS 是基於 HTML5 開發的一套模組化的庫和工具。基於這些庫,可以非常快捷地開發出基於 HTML5 的遊戲、動畫和互動應用。

CreateJS 包含如下幾部分

image

在本專案中主要是運用了 EaseJs,並結合 Tween.js 做了一些小動畫。

瞭解完所用到的技術後,我們來看看具體的實現過程:

實現方案

這個專案主要包含了三大核心:載入圖片、繪製姿勢、手勢操作,下面我們分別來討論一下。

1. 載入圖片

由於這個專案 99%的模組是由圖片構成,因此預載入圖片這一功能必不可少。圖片那麼多,要一個個手動列出來去載入嗎?當然不用!現在是機械化時代了,能交給工具的就不動手。

const fs = require("fs");
const path = require("path");
let components = [];
const files = fs.readdirSync(path.resolve(__dirname, "../img/"));
files.forEach(function (item) {
  components.push(`'@/asset/img/${item}'`);
});
let data = `let imgList = [${[...components]}]
module.exports = imgList;`;
fs.writeFile(path.resolve(__dirname, "./imgList.js"), data, (error) => {
  console.log(error);
});

依託於 nodejs 對檔案的讀寫來完成自動生成圖片列表檔案,載入時對這個列表下的圖片依次 load 即可。

2. 繪製姿勢

EaselJS 在 Createjs 中承擔 ‘畫’ 的能力,這裡用到了畫圖片和畫文字的 API。EaselJS 一般的繪製步驟是:建立舞臺 -> 建立物件 -> 設定物件屬性 -> 新增物件到舞臺 -> 更新舞臺呈現下一幀

this.stage = new createjs.Stage(this.canvas); // 建立舞臺
let bgImg = new createjs.Bitmap(imgSrc); // 建立物件
this.stage.addChild(bgImg); // 新增物件到舞臺

CreateJs 提供了兩種渲染模式,一種是用 setTimeout,一種是用 requestAnimationFrame,預設是 setTimeout,幀數是 20,這裡我們選用 requestAnimationFrame 模式,因為要對頁面元素進行大量的操作,選此種方式會更加流暢。

createjs.Ticker.timingMode = createjs.Ticker.RAF; // RAF為requestAnimationFrame縮寫

createjs 其他基本設定

easeljs 事件預設是不支援 touch 裝置的,需要手動開啟

createjs.Touch.enable(this.stage);

實時重新整理舞臺

createjs.Ticker.addEventListener("tick", this.stage.update(event));

hammer.js 配置

由於 hammer.js 預設是不開啟 rotate 事件的,因此需要在選項中使用 recognizers 來設定一個識別器

let bodyHandle = new Hammer.Manager(this.canvas, {
  recognizers: [[Hammer.Rotate], [Hammer.Pan]],
});
let bodyRotate = new Hammer.Rotate();
bodyHandle.add(bodyRotate);

準備工作完成,下面正式開始

繪製場景

為了保持文明的形象,就不支援站在桌子上辦公了。因此場景分為背景和桌子兩部分,通過設定桌子的層級在人物的上層來進行約束。

首先繪製背景

let Bg = new Image();
Bg.src = require("../asset/img/scene" + n + ".png");
Bg.onload = () => {
  let bgimg = new createjs.Bitmap(Bg);
  this.stage.addChild(bgimg);
};

注意,如果不是首次繪製,需要將之前的內容清空

this.stage.removeAllChildren();

同理繪製桌子,需要注意的是,桌子繪製完以後,需要設定其層級

...
this.stage.addChild(deskImg);
this.stage.setChildIndex(deskImg, 1);

繪製角色

繪製角色與場景不同,這裡需要用到 Container。
Container 是一個容器,可以包含 Text、Bitmap、Shape、Sprite 等其他的 EaselJS 元素。例如,你可以將手臂、腿部、軀幹和頭部聚在一起,把它們轉換為一組,同時還可以將各個部分相對彼此移動。在這裡我們將角色及其表情放在一個 Container 中方便統一管理,統一移動縮放旋轉等。

繪製角色前,我們先確定繪製的位置:預設位置在畫布的最中間

let pos = {
  x: this.canvasW / 2,
  y: this.canvasH / 2,
};

如果已經選擇過角色,需要更換時,需要保持之前角色的位置

pos = {
  x: joy.x,
  y: joy.y,
};

下面是具體繪製步驟:

var joy = new Image();
joy.src = require("../asset/img/joy" + n + ".png");
// 載入角色圖片
joy.onload = () => {
  var joyImg = new createjs.Bitmap(joy); // 建立影像
  joyImg.name = "joy"; // 角色命名
  joyImg.regX = joy.width / 2; // 移動x方向到中心點位置
  joyImg.regY = joy.height / 2; // 移動y方向到中心點位置
  joyImg.x = pos.x; // 設定初始位置
  joyImg.y = pos.y; // 設定初始位置
  let container = new createjs.Container(); // 建立容器
  container.name = "joyContainer"; // 容器命名
  container.addChild(joyImg); // 容器新增角色
  this.stage.addChild(container); // 新增容器到舞臺
};

繪製表情

在上面繪製角色時,建立了一個 name 為 joyContainer 的容器,我們將表情也繪製進去

var face = new createjs.Bitmap(imgBg);
...
joyContainer.addChild(face);

這樣當我們想移動這個角色時,通過移動容器,來保證整體性。否則會出現腦袋跟不上身體移動的情況。。。

刪除元素

從新增角色開始,就會記錄下當前的操作物件 activeItem,當觸發刪除按鈕時,只要找到 activeItem,並將其相關內容刪除即可。

const ele = this.stage.getChildByName(this.activeItem.name);
this.stage.removeChild(ele);

3. 手勢操作

hammer.js 是用於檢測觸控手勢的 JavaScript 庫,支援最常見的單點和多點觸控手勢,並且可以完全擴充套件以新增自定義手勢。NUTUI中將會整合此功能並在下個版本中正式釋出。

bodyHandle.on("rotate", (e) => {
  let ctrEle = this.activeItem;
  ctrEle.scaleX = ctrEle.scaleY = e.scale * this.nowScale;
  ctrEle.rotation = this.BorderBox.rotation = e.rotation + this.nowRotate;
});

通過監聽 rotate 事件,可以得到當次操作的縮放及旋轉的資料,我們再將其與之前的狀態相結合,就能達到各種手勢操作的效果了。

好了,一切準備就緒,開始你的表演吧~

首先,選擇一個辦公場景,然後來個角色扮演,站著有點累?沒關係,換個姿勢坐下來吧,當然你想站著凳子上也沒關係。。表情是不是有點古板?那就吐吐舌頭吧。電腦水杯安排上,最後再來個口號“在京東胖個 20 斤”。。

image

玩過癮了嗎?好了,收收心我們們繼續聊如何實現的吧。

生成圖片

當你點選“完成時”,我們會進入分享頁,分享頁的底圖是三種顏色隨機選擇。這裡我們需要建立一個臨時的 canvas 來繪製分享圖片,將分享的背景,定製好的姿勢場景圖(通過 canvas.toDataURL 方法轉成圖片),還有二維碼,以及暱稱,依次繪製到這個臨時的 canvas 中,最後匯出圖片後賦值給分享圖片的 url。

let tmpStage = new createjs.Stage(tmpCanvas);
tmpStage.addChild(bg, share, code, text);

由於分享圖片與分享頁展示元素不完全一樣,因此展示給使用者看到的是分享頁,而分享圖片設定了透明度為 0,只能儲存不能被看到。

然而,事情沒有這麼簡單,一大波 bug 正在馬不停蹄的狂奔襲來。。

遇到的問題

路由 底部導航去除

前面介紹過,這個專案是由載入頁和主介面兩個頁面組成,中間是通過路由跳轉(history 模式)。但是在一些手機中,通過路由跳轉到另一個頁面時,底部會自動出現導航模組,這是我們所不希望看到的,本就捉襟見肘的空間裡,憑空多了這麼大一塊,這是不可容忍的存在。

image

因此在權衡之後,選擇了 replace 模式,但是這樣使用者在進入主介面以後,就不能回到載入頁了,魚與熊掌不可兼得。

image

ios 中輸入框不自動收回,有白塊

在載入完成後,有個暱稱的輸入框,在 ios 下輸入完成,鍵盤收起後頁面底部會有一大片空白,呈卡死狀。

image

但是當我們在頁面上隨意滑動一下,這個白塊就會消失。這是因為 ios 鍵盤彈出後,會把頁面整體頂上去,因此我們需要使用 scrollTo 函式,在 blur 鍵盤落下時滾動頁面,使頁面歸位。

blur() {
    window.scrollTo(0, 0);
}

由於系統更新後,白塊變成了透明狀態,這使得人更加琢磨不透,明明看不到任何東西,但是輸入框就是無法選中。別以為脫了馬甲就不認識你了,上面的解決方案依舊是有效的。

圖片跨域

本地開發完成,上傳程式碼到伺服器後,原本的世界靜好全都消失不見,取而代之的是刺眼的紅:

image

一番查閱後找到了如下這段話:
儘管可以在畫布中使用未經CORS批准的影像,但這樣做會汙染畫布。一旦畫布被汙染,就不能再從畫布中提取資料。例如,不能再使用canvas toBlob()、toDataURL()或getImageData()方法;這樣做將引發安全錯誤。這可以防止使用者在未經允許的情況下使用影像從遠端網站獲取資訊,從而公開私有資料。
這就解釋了上面報錯的由來,那麼如何解決呢?

var bg = new Image();
bg.crossOrigin = "Anonymous";

這就開啟了圖片載入過程中的 CORS 功能,從而繞過了報錯。

點選報錯

圖片可以載入了,可是當我想做拖拽等操作時,又又又報錯了。。。
image

createjs 提供了 hitArea 點選區域。可以設定另一個物件 objB 作為顯示物件 objA 的 hitArea,當點選到 objB 時就相當於點選到了 objA。 這個 objB 不需要新增到顯示物件列表,也不需要可見,但它會在互動事件的觸發中替代 objA。

var hitArea = new createjs.Shape();
hitArea.graphics.beginFill("#000").drawRect(0, 0, imgBg.width, imgBg.height); //這裡的大小為圖片大小,請自己調整
img.hitArea = hitArea;

給物件繫結一個點選區域,這樣拖拽是操作這個區域,而不是原本的影像,這樣就可以不報錯了

層級問題

在這個專案中的設定,角色在所有其他元素的底層,而元素切換選中時,也需要將當前選中元素置頂,這裡用到了 createjs 的 setChildIndex 方法

setChildIndex 方法允許你向上或向下移動顯示物件在顯示列表內的位置。顯示列表可以看作為一個陣列,它的索引位置是從第 0 開始的。假如建立了 3 個元素,那麼他們的位置就是第 0,1,2 層。第二層的物件在外面,第 0 層的在最裡面。

如果想把某一元素移到所有元素的上面,這時就要用到 getNumChildren 屬性,它的含義就是該容器內顯示物件的數目。最外層的層深就是第 numChildren-1 層。其他原本層級高於置頂元素的元素,相應層級會減少一級。

if (ele.name === "joy") {
  this.stage.setChildIndex(ele, 1);
} else {
  this.stage.setChildIndex(ele, this.stage.getNumChildren() - 2);
}

在我們選中或者新增一個元素時,觸發層級設定,因為要保證當前操作的元素層級在上。由於有置頂的元素,因此在設定層級時,如果是角色元素,那麼設定在第 2 層,僅僅高於場景背景層;如果是其他元素,則設定為次頂層。

ios 低版本 base64 onload 有問題

在測試階段發現,ios10 以下的手機,不能拖拽,真是個晴天霹靂!

在排查過程中發現了蹊蹺,不能拖拽竟然是因為選中框上面的刪除按鈕沒有載入到,這個按鈕有什麼特別之處呢,哦,原來是 webpack 配置中的 url-loader 自動將小圖片轉成了 base64 格式,順著這個思路,將這個功能去掉以後,問題得以解決,但並沒有深究。

接下來的結果更糟,分享圖片不翼而飛了,只剩下個背景框!

image

上面“生成圖片”部分就講過,圖片都是將 canvas 通過 toDataURL 匯出,匯出格式正是上面有問題的 base64 格式。

我們發現 base64 在 ios10 以下版本中,無法觸發 onload 事件,而是走了 onerror。那麼 base64 圖片還能轉成什麼格式呢?答案就在這裡:

dataURLToBlob(dataurl) {
    //dataurl: data:image/webp;base64,UklGRvAIAABXRUJQVlA4WAoAAAAQAAAAXwAAXwAAQUxQSC4CAAABkAXbtmlH+xmxn...
    var arr = dataurl.split(','); // ['data:image/webp;base64','UklGRvAIAABXRUJQVlA4WAoAAAAQAAAAXwAAXwAAQUxQSC4CAAABkAXbtmlH+xmxn...']
    var mime = arr[0].match(/:(.*?);/)[1]; // 分離出mime型別 ——> image/webp
    var bstr = atob(arr[1]); // atob() 方法用於解碼使用 base64 編碼的字串,轉換為字串中儲存的原始二進位制資料。
    var n = bstr.length;
    var u8arr = new Uint8Array(n); // Uint8Array表示一個8位無符號整型陣列,建立時內容被初始化為0。建立完後,可以以物件的方式或使用陣列下標索引的方式引用陣列中的元素。
    while (n--) {
        u8arr[n] = bstr.charCodeAt(n); // 依次儲存Unicode 編碼
    }
    return new Blob([u8arr], {type: mime});  // type:代表了將會被放入到blob中的陣列內容的MIME型別
}

我們先將 base64 圖片轉為 blob 格式

sharePhoto.src = window.URL.createObjectURL(this.dataURLToBlob(photo));

然後通過 URL.createObjectURL 方法生成 ObjectURL

window.URL.revokeObjectURL(sharePhoto);

由於 createObjectURL 返回的 url 一直儲存在記憶體中,直到 document 觸發了 unload 事件(例如:document close)。所以我們們養成好習慣,在使用完成以後要記得隨手釋放一下哦~

那麼 createObjectURL 到底是何方神聖呢?我們一起來學習下:

createObjectURL

定義:URL.createObjectURL()方法會根據傳入的引數建立一個指向該引數物件的 URL。這個 URL 的生命僅存在於它被建立的這個文件裡。新的物件 URL 指向執行的 File 物件或者是 Blob 物件。

createObjectURL 返回一段帶 hash 的 url,並且一直儲存在記憶體中,直到 document 觸發了 unload 事件(例如:document close)或者執行 revokeObjectURL 來釋放。

瀏覽器支援情況如下,移動端基本可以放心使用~
image

阻止長按事件

在即將上線時,由於內部 app 對長按儲存圖片支援不太充分,因此臨時決定在其中遮蔽此功能,這裡嘗試了三種方法:

  1. 加透明 div 蓋在最頂層
    由於長按儲存時間是在 img 標籤上觸發,因此 div 能阻擋住
  2. touchstart 時阻止 contextmenu
    究其本質,長按是觸發了 contextmenu 上下文選單,那麼我們只要阻止這個事件即可
document.oncontextmenu = (e) => {
  e.preventDefault();
};

在 web 瀏覽器中生效,但是在移動端無效

  1. 加樣式
* {
  -webkit-touch-callout: none; /* 系統預設選單被禁用*/
  -webkit-user-select: none; /* webkit瀏覽器*/
  -moz-user-select: none; /* 火狐*/
  -ms-user-select: none; /* IE10*/
  user-select: none; /* 使用者是否能夠選中文字*/
}

實踐證明這種方式不可行,我們依次來分析一下:
user-select 控制使用者能否選中文字,而我們這裡需要的是控制圖片。
-webkit-touch-callout:當你觸控並按住觸控目標時候,禁止或顯示系統預設選單。適用於:連結元素比如新視窗開啟,img 元素比如儲存影像等等
乍一看,這不就是我們所需要的嗎?
但是,-webkit-touch-callout 是一個 不規範的屬性(unsupported WebKit property),它沒有出現在 CSS 規範草案中。
看一下支援情況就明白了:
image

最終選擇了第一種方式,簡單直接,不用考慮相容性。

圖片優化

在解決了上面一系列的問題之後,要回到最初的分析:不管專案用了何種技術,最終呈現的本質都是圖片。所以圖片的大小不僅影響載入速度,同時也影響著渲染速度,為了提供更優的使用者體驗,選擇使用 NUTUI 中的圖片壓縮功能,它可以提供高壓縮比的圖片優化,並且可以自動轉化成 webp 格式。大家都知道,webp 格式的圖片比一般壓縮過的圖片還要小很多,依託於這麼強大的靠山,想不出色都難!

總結

不管你現在是大佬、超級大佬,還是剛剛加入京東的 fresh blood,519 老員工日就是屬於每一位 JDer 共同的節日!

在做專案的過程中,從零開始學習 createjs,專案中間不斷試錯,不斷去解決問題,學習新知識,收穫良多。在以後的工作中,還要注重基礎知識的廣度,不斷積累,也許學習的時候並不清楚應用場景,但是終有一天會發現,每個知識都有其存在的理由。

相關文章