如何用Canvas拍出 JDer's工做照

背景

在京東,就任滿五年的老員工被稱做「大佬」,若是滿了十年,那就要被稱之爲「超級大佬」了。javascript

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

在這 5 年或 10 年無數個奮鬥的日夜裏,你們是以怎樣的姿式在工做呢?下面由我揭曉這些姿式是怎樣修煉而成的吧~java

玩法

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

image

玩法基本的步驟以下webpack

image

ok,拍完照就能夠分享到朋友圈了。ios

技術選型

能夠看到這裏用到了大量的圖片,經過對圖片的拖拽縮放等操做,擺放人物及配件,最終合成相應的圖片。那麼這一過程是怎麼實現的呢?web

首先咱們採用 NUTUI 來搭建整個項目,其腳手架能夠很好地處理圖片優化打包等。底部操做菜單模塊使用了 NUTUI 中的 Tab 組件,提高了開發效率。在主界面的部分選用了基於 canvas 的 creatjs 庫,以及一個輕量級的觸屏設備手勢庫 hammer.js 來開發。canvas

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,項目中間不斷試錯,不斷去解決問題,學習新知識,收穫良多。在之後的工做中,還要注重基礎知識的廣度,不斷積累,也許學習的時候並不清楚應用場景,可是終有一天會發現,每一個知識都有其存在的理由。

相關文章
相關標籤/搜索