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

avatar
UX @京东

背景

在京东,就职满五年的老员工被称作“大佬”,如果满了十年,那就要被称之为“超级大佬”了。

从 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,项目中间不断试错,不断去解决问题,学习新知识,收获良多。在以后的工作中,还要注重基础知识的广度,不断积累,也许学习的时候并不清楚应用场景,但是终有一天会发现,每个知识都有其存在的理由。