• 如果您觉得本站非常有看点,那么赶紧使用Ctrl+D 收藏吧

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

开发技术 开发技术 2周前 (09-13) 18次浏览

背景

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

从 2016 年 5 月 19 日开始,每一年的这一天都被定为京东集团的“519 老员工日”。正所谓:五年砺银,十年锻金!在京东成长 10 年的员工,放在行业里的任何一家公司,都能够像金子般发光!

在这 5 年或 10 年无数个奋斗的日夜里,大家是以怎样的姿势在工作呢?下面由我揭晓这些姿势是怎样修炼而成的吧~

玩法

首先我们用一张 gif 图来回顾一下效果

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

玩法基本的步骤如下

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

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

技术选型

可以看到这里用到了大量的图片,通过对图片的拖拽缩放等操作,摆放人物及配件,最终合成相应的图片。那么这一过程是怎么实现的呢?

首先我们采用 NUTUI 来搭建整个项目,其脚手架可以很好地处理图片优化打包等。底部操作菜单模块使用了 NUTUI 中的 Tab 组件,提升了开发效率。在主界面的部分选用了基于 canvas 的 creatjs 库,以及一个轻量级的触屏设备手势库 hammer.js 来开发

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

NUTUI

NUTUI 是一套京东风格的移动端组件库,开发和服务于移动 Web 界面的企业级前中后台产品。50+ 高质量组件,40+ 京东移动端项目正在使用,支持按需加载,支持服务端渲染(Vue SSR)…

快扫码体验起来吧

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

Hammer.js

Hammer 是一个开源代码库,可以识别由触摸,鼠标和 pointerEvents 做出的手势。它没有任何依赖性,并且很小,压缩后只有 7.34 kB。
它支持常见的单点和多点触摸手势,并且可以添加自定义手势

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

Create.js

CreateJS 是基于 HTML5 开发的一套模块化的库和工具。基于这些库,可以非常快捷地开发出基于 HTML5 的游戏、动画和交互应用。

CreateJS 包含如下几部分

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

在本项目中主要是运用了 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拍出 jDer's工作照

玩过瘾了吗?好了,收收心咱们继续聊如何实现的吧。

生成图片

当你点击“完成时”,我们会进入分享页,分享页的底图是三种颜色随机选择。这里我们需要创建一个临时的 canvas 来绘制分享图片,将分享的背景,定制好的姿势场景图(通过 canvas.toDataURL 方法转成图片),还有二维码,以及昵称,依次绘制到这个临时的 canvas 中,最后导出图片后赋值给分享图片的 url。

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

由于分享图片与分享页展示元素不完全一样,因此展示给用户看到的是分享页,而分享图片设置了透明度为 0,只能保存不能被看到。

然而,事情没有这么简单,一大波 bug 正在马不停蹄的狂奔袭来。。

遇到的问题

路由 底部导航去除

前面介绍过,这个项目是由加载页和主界面两个页面组成,中间是通过路由跳转(history 模式)。但是在一些手机中,通过路由跳转到另一个页面时,底部会自动出现导航模块,这是我们所不希望看到的,本就捉襟见肘的空间里,凭空多了这么大一块,这是不可容忍的存在。

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

因此在权衡之后,选择了 replace 模式,但是这样用户在进入主界面以后,就不能回到加载页了,鱼与熊掌不可兼得。

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

ios 中输入框不自动收回,有白块

在加载完成后,有个昵称的输入框,在 ios 下输入完成,键盘收起后页面底部会有一大片空白,呈卡死状。

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

但是当我们在页面上随意滑动一下,这个白块就会消失。这是因为 ios 键盘弹出后,会把页面整体顶上去,因此我们需要使用 scrollTo 函数,在 blur 键盘落下时滚动页面,使页面归位。

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

由于系统更新后,白块变成了透明状态,这使得人更加琢磨不透,明明看不到任何东西,但是输入框就是无法选中。别以为脱了马甲就不认识你了,上面的解决方案依旧是有效的。

图片跨域

本地开发完成,上传代码到服务器后,原本的世界静好全都消失不见,取而代之的是刺眼的红:

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

一番查阅后找到了如下这段话:
尽管可以在画布中使用未经CORS批准的图像,但这样做会污染画布。一旦画布被污染,就不能再从画布中提取数据。例如,不能再使用canvas toBlob()、toDataURL()或getImageData()方法;这样做将引发安全错误。这可以防止用户在未经允许的情况下使用图像从远程网站获取信息,从而公开私有数据。
这就解释了上面报错的由来,那么如何解决呢?

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

这就开启了图片加载过程中的 CORS 功能,从而绕过了报错。

点击报错

图片可以加载了,可是当我想做拖拽等操作时,又又又报错了。。。
如何用canvas拍出 jDer's工作照

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拍出 jDer's工作照

上面“生成图片”部分就讲过,图片都是将 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 来释放。

浏览器支持情况如下,移动端基本可以放心使用~
如何用canvas拍出 jDer's工作照

阻止长按事件

在即将上线时,由于内部 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 规范草案中。
看一下支持情况就明白了:
如何用canvas拍出 jDer's工作照

最终选择了第一种方式,简单直接,不用考虑兼容性。

图片优化

在解决了上面一系列的问题之后,要回到最初的分析:不管项目用了何种技术,最终呈现的本质都是图片。所以图片的大小不仅影响加载速度,同时也影响着渲染速度,为了提供更优的用户体验,选择使用 NUTUI 中的图片压缩功能,它可以提供高压缩比的图片优化,并且可以自动转化成 webp 格式。大家都知道,webp 格式的图片比一般压缩过的图片还要小很多,依托于这么强大的靠山,想不出色都难!

总结

不管你现在是大佬、超级大佬,还是刚刚加入京东的 fresh blood,519 老员工日就是属于每一位 JDer 共同的节日!

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


喜欢 (0)