如何用canvas拍出 jDer's工作照
背景
在京东,就职满五年的老员工被称作“大佬”,如果满了十年,那就要被称之为“超级大佬”了。
从 2016 年 5 月 19 日开始,每一年的这一天都被定为京东集团的“519 老员工日”。正所谓:五年砺银,十年锻金!在京东成长 10 年的员工,放在行业里的任何一家公司,都能够像金子般发光!
在这 5 年或 10 年无数个奋斗的日夜里,大家是以怎样的姿势在工作呢?下面由我揭晓这些姿势是怎样修炼而成的吧~
玩法
首先我们用一张 gif 图来回顾一下效果
玩法基本的步骤如下
ok,拍完照就可以分享到朋友圈了。
技术选型
可以看到这里用到了大量的图片,通过对图片的拖拽缩放等操作,摆放人物及配件,最终合成相应的图片。那么这一过程是怎么实现的呢?
首先我们采用 NUTUI 来搭建整个项目,其脚手架可以很好地处理图片优化打包等。底部操作菜单模块使用了 NUTUI 中的 Tab 组件,提升了开发效率。在主界面的部分选用了基于 canvas 的 creatjs 库,以及一个轻量级的触屏设备手势库 hammer.js 来开发。
NUTUI
NUTUI 是一套京东风格的移动端组件库,开发和服务于移动 Web 界面的企业级前中后台产品。50+ 高质量组件,40+ 京东移动端项目正在使用,支持按需加载,支持服务端渲染(Vue SSR)...
快扫码体验起来吧
Hammer.js
Hammer 是一个开源代码库,可以识别由触摸,鼠标和 pointerEvents 做出的手势。它没有任何依赖性,并且很小,压缩后只有 7.34 kB。
它支持常见的单点和多点触摸手势,并且可以添加自定义手势
Create.js
CreateJS 是基于 HTML5 开发的一套模块化的库和工具。基于这些库,可以非常快捷地开发出基于 HTML5 的游戏、动画和交互应用。
CreateJS 包含如下几部分
在本项目中主要是运用了 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 斤”。。
玩过瘾了吗?好了,收收心咱们继续聊如何实现的吧。
生成图片
当你点击“完成时”,我们会进入分享页,分享页的底图是三种颜色随机选择。这里我们需要创建一个临时的 canvas 来绘制分享图片,将分享的背景,定制好的姿势场景图(通过 canvas.toDataURL 方法转成图片),还有二维码,以及昵称,依次绘制到这个临时的 canvas 中,最后导出图片后赋值给分享图片的 url。
let tmpStage = new createjs.Stage(tmpCanvas);
tmpStage.addChild(bg, share, code, text);
由于分享图片与分享页展示元素不完全一样,因此展示给用户看到的是分享页,而分享图片设置了透明度为 0,只能保存不能被看到。
然而,事情没有这么简单,一大波 bug 正在马不停蹄的狂奔袭来。。
遇到的问题
路由 底部导航去除
前面介绍过,这个项目是由加载页和主界面两个页面组成,中间是通过路由跳转(history 模式)。但是在一些手机中,通过路由跳转到另一个页面时,底部会自动出现导航模块,这是我们所不希望看到的,本就捉襟见肘的空间里,凭空多了这么大一块,这是不可容忍的存在。
因此在权衡之后,选择了 replace 模式,但是这样用户在进入主界面以后,就不能回到加载页了,鱼与熊掌不可兼得。
ios 中输入框不自动收回,有白块
在加载完成后,有个昵称的输入框,在 ios 下输入完成,键盘收起后页面底部会有一大片空白,呈卡死状。
但是当我们在页面上随意滑动一下,这个白块就会消失。这是因为 ios 键盘弹出后,会把页面整体顶上去,因此我们需要使用 scrollTo 函数,在 blur 键盘落下时滚动页面,使页面归位。
blur() {
window.scrollTo(0, 0);
}
由于系统更新后,白块变成了透明状态,这使得人更加琢磨不透,明明看不到任何东西,但是输入框就是无法选中。别以为脱了马甲就不认识你了,上面的解决方案依旧是有效的。
图片跨域
本地开发完成,上传代码到服务器后,原本的世界静好全都消失不见,取而代之的是刺眼的红:
一番查阅后找到了如下这段话:
尽管可以在画布中使用未经CORS批准的图像,但这样做会污染画布。一旦画布被污染,就不能再从画布中提取数据。例如,不能再使用canvas toBlob()、toDataURL()或getImageData()方法;这样做将引发安全错误。这可以防止用户在未经允许的情况下使用图像从远程网站获取信息,从而公开私有数据。
这就解释了上面报错的由来,那么如何解决呢?
var bg = new Image();
bg.crossOrigin = "Anonymous";
这就开启了图片加载过程中的 CORS 功能,从而绕过了报错。
点击报错
图片可以加载了,可是当我想做拖拽等操作时,又又又报错了。。。
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 格式,顺着这个思路,将这个功能去掉以后,问题得以解决,但并没有深究。
接下来的结果更糟,分享图片不翼而飞了,只剩下个背景框!
上面“生成图片”部分就讲过,图片都是将 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 来释放。
浏览器支持情况如下,移动端基本可以放心使用~
阻止长按事件
在即将上线时,由于内部 app 对长按保存图片支持不太充分,因此临时决定在其中屏蔽此功能,这里尝试了三种方法:
- 加透明 div 盖在最顶层
由于长按保存时间是在 img 标签上触发,因此 div 能阻挡住 - touchstart 时阻止 contextmenu
究其本质,长按是触发了 contextmenu 上下文菜单,那么我们只要阻止这个事件即可
document.oncontextmenu = (e) => {
e.preventDefault();
};
在 web 浏览器中生效,但是在移动端无效
- 加样式
* {
-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 规范草案中。
看一下支持情况就明白了:
最终选择了第一种方式,简单直接,不用考虑兼容性。
图片优化
在解决了上面一系列的问题之后,要回到最初的分析:不管项目用了何种技术,最终呈现的本质都是图片。所以图片的大小不仅影响加载速度,同时也影响着渲染速度,为了提供更优的用户体验,选择使用 NUTUI 中的图片压缩功能,它可以提供高压缩比的图片优化,并且可以自动转化成 webp 格式。大家都知道,webp 格式的图片比一般压缩过的图片还要小很多,依托于这么强大的靠山,想不出色都难!
总结
不管你现在是大佬、超级大佬,还是刚刚加入京东的 fresh blood,519 老员工日就是属于每一位 JDer 共同的节日!
在做项目的过程中,从零开始学习 createjs,项目中间不断试错,不断去解决问题,学习新知识,收获良多。在以后的工作中,还要注重基础知识的广度,不断积累,也许学习的时候并不清楚应用场景,但是终有一天会发现,每个知识都有其存在的理由。