CRStudio

CRStudio 查看完整档案

西安编辑  |  填写毕业院校  |  填写所在公司/组织填写个人主网站
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 该用户太懒什么也没留下

个人动态

CRStudio 回答了问题 · 10月20日

android studio 离线打包后一直停留在启动页

我也遇到这个问题了,帮你顶一下

关注 2 回答 1

CRStudio 发布了文章 · 10月18日

Android开发drawable目录不同分辨率对icon,push,splash的尺寸要求

icon尺寸
  • ldpi: 36*36
  • mdpi: 48*48
  • hdpi: 72*72
  • xhdpi: 96*96
  • xxhdpi: 144*144
  • xxxhdpi: 192*192
push尺寸:
  • ldpi: 48*48
  • mdpi: 64*64
  • hdpi: 96*96
  • xhdpi: 128*128
  • xxhdpi: 192*192
push_small尺寸
  • ldpi: 18*18
  • mdpi: 24*24
  • hdpi: 36*36
  • xhdpi: 48*48
  • xxhdpi: 72*72
  • xxxhdp: 96*96
splash尺寸
横屏 高×宽
  • ldpi 200*320
  • mdpi 320*480
  • hdpi 480*800
  • xhdpi 720*1280
  • xxhdpi 960*1600
  • xxxhdpi 1280*1920
竖屏 高×宽
  • ldpi 320*200
  • mdpi 480*320
  • hdpi 800*480
  • xhdpi 1280*720
  • xxhdpi 1600*960
  • xxxhdpi 1920*1280
参考资料:
查看原文

赞 0 收藏 0 评论 0

CRStudio 发布了文章 · 10月15日

PhpStorm在使用Yii框架开发的时候程序崩溃假死的解决办法

当你的 PhpStorm 在开发Yii框架相关程序的时候,我们有的人会启用一个插件 Github:Yii2support,但是当Phpstorm开启这个插件的时候,每当我们的代码写道 ::class的时候,Phpstorm都会假死。

其实这个插件的作者已经解决了这个办法,但是这个版本的插件还是测试版,大家可以去这里下载:https://github.com/nvlad/yii2...,或者直接下载 yii2support-0.10.57.24.jar.zip , 当前2020-10-15日的最新版本还是 0.10.57.23,作者说了这个是测试版。

怎么从本地安装插件,自己百度或者摸索去,很简单,我就不多说了。使用后,假死的情况的确没有了。终于解决了一年多了的问题,都差点儿有心理阴影了。

查看原文

赞 2 收藏 1 评论 0

CRStudio 赞了回答 · 9月24日

解决微信小程序点击 navigator ,页面不跳转

终于知道为什么了。是因为我在app.json里,把 navigator 对应的 url 配置在"tabBar"的"list"里面了,所以也没才不跳转。

关注 2 回答 2

CRStudio 赞了回答 · 8月21日

解决Network 面板能不能屏蔽浏览器插件的请求?

时隔两年,自己找到了答案

filter -scheme:chrome-extension

关注 5 回答 4

CRStudio 赞了文章 · 7月12日

Hilo开发H5小游戏踩坑笔记

第一次开发小游戏,用的是Hilo框架。由于项目开发时间比较紧张,对游戏和CANVAS都没有了解过。代码虽然写的很烂,但是还是记录下踩过的坑吧!本文为碎碎念模式,并不深入,写错的地方希望多多指点。

一、CANVAS横屏适配处理

游戏是微信内的一款横屏游戏。如果强制横屏,提示用户去控制横竖屏开关并不友好。

解决方案,游戏场景做成如下图紫色部分结构,游戏宽高和手机屏幕调换。如果手机为竖屏,那么将游戏旋转90°即可。

注:所述【横屏】为用户打开了允许横屏的开关并横屏,真正的横屏。

竖屏

代码如下所示:

let width = document.documentElement.clientWidth;
let height =  document.documentElement.clientHeight;
let box =  document.getElementsByTagName('canvas');
let style = '';
// 竖屏
if (width < height) {
    style += `width:${height}px;`;
    style += `height:${width}px;`;
    style += '-webkit-transform: rotate(90deg); transform: rotate(90deg);';
    // 注意旋转中点的处理
    style += `-webkit-transform-origin: ${width / 2}px ${width / 2}px;`;
    style += `transform-origin: ${width / 2}px ${width / 2}px;`;
}

if (box.length) {
    box[0].style.cssText = style;
}

当用户开启了横屏开关,如果用户横屏,那就将游戏场景旋转0°即可,也就是恢复最初的样子。如下:

横屏

// 横屏
if (width > height) {
    style += `width:${width}px;`; // 注意旋转后的宽高切换
    style += `height:${height}px;`;
    style += '-webkit-transform: rotate(0); transform: rotate(0);';
    style += '-webkit-transform-origin: 0 0;';
    style += 'transform-origin: 0 0;';
}

if (box.length) {
    box[0].style.cssText = style;
}

横屏没想象那么顺利,我们的游戏是在微信场景。当用户开启横屏开关并横屏后,微信内置浏览器头也会占一大部分区域。这样我们的游戏场景旋转后明显是显示不全的。

解决方案就是利用Hilo的api resize下舞台

// 解决微信横屏浏览器头部 导致高度变化的问题
this.stage.resize(height, width, true);   

最后有几个注意点:

1、注意旋转过程中的宽高切换

2、注意单位适配问题

3、注意微信浏览器头,就因为这个头的变化。整个游戏都需要处理,所以还是尽量不要自己处理。。。

参考文章

二、点击事件失效

如下图所示,游戏结束场景的2个按钮。

左侧旋转90°后变成右侧横屏,但是竖屏下的横屏(也就是旋转90°得来的)【再来一次】按钮点击事件会失效,但是点击红色区域(没有按钮,大致绘制并不精准)部分这个时间会被触发。

而用户横屏(开启了横屏开关,自然横屏)【再来一次】按钮点击事件不会失效。

绘制时坐标以游戏场景左上角为(0,0),而旋转90°后坐标以游戏场景左下角为(0,0)。因为旋转90°后游戏场景左下角变成了视觉上的左上角。因此竖屏下的横屏点击红色区域生效就是因为,Hilo的点击事件是绑定在元素绘制时坐标区域上(猜测,没有看源码)。旋转后,按钮的点击事件生效区间就变成了根据绘制的x、y也就是红色区域。

那么如何解决这个问题,如上图所示,旋转后的x、y如图中蓝色字所示。可以算出

// 再玩一次按钮
const start = this.gameOverScene.getChildById('start'); 
// 再玩一次按钮 新的x = 游戏画布宽度 - 绘制的y - 按钮的高度
const startNewX = this.width - start.y - start.height ;
// 再玩一次按钮 新的y = 绘制的x
const startNewY = start.x;
// 监听舞台点击事件
this.stage.on(Hilo.event.POINTER_START, (e) => {
    // 利用新的x、y 和按钮自身的高度和宽度 判断是否点击在按钮区域
    if ((e.stageX > startNewX && e.stageX < startNewX + start.height) &&
        (e.stageY > startNewY && e.stageY < startNewY + start.width)) {
        // 在玩一次逻辑处理
    }
)};

【再玩一次】按钮点击事件解决了,但是事情没有那么简单。

给另一个【分享】按钮加上事件,what?无论横屏还是竖屏点击事件都不生效。至少【再玩一次】按钮事件还是生效的,只是不准罢了。

原因,观察上述图。【分享】按钮在初始化的过程中是在游戏画布右侧,也就是手机屏幕外部。经过测试发现,发现绘制时在手机屏幕外的区域点击事件都不会生效。解决方法如【再玩一次】,无论横屏还是竖屏都计算坐标判断。

三、音乐播放兼容

Hilo的HTMLAudio声音播放模块,官方文档表示【使用限制:iOS平台需用户事件触发才能播放,很多Android浏览器仅能同时播放一个音频。】但是目前使用来看,浏览器测试OK,绝大部分手机都不能正常播放。解决方案,采用DOM的audio,但是同样iOS平台需用户事件触发才能播放。因此最终的解决方案就是进入游戏之前或者某个合适的环节获取所有的音乐,先播放再暂停。用户不会感知,可以完美解决。如下:

// html
<audio id="audio"  data-original="xxx.mp3" preload="auto"></audio>
// dom为获取
const dom = document.getElementById('audio');
dom.play();
dom.pause();

四、部分机型游戏场景显示不全

游戏中可能有某些元素是经常复用的,因此会单独切出来。如下图左侧

如上图右侧所示效果,最开始的实现方式如下。在初始化的时候就将公用元素Y轴截断展示,这个效果看似OK,但是在测试阶段发现某些iPhone手机不能显示这2个元素。

new Hilo.Bitmap({
    // 绘制的图片
    image: 'imgurl', 
    // 测试坐标,非精准坐标
    rect: [0, 100, 50, 300],
    y: 0,
});
new Hilo.Bitmap({
    // 绘制的图片
    image: 'imgurl', 
    // 测试坐标,非精准坐标
    rect:[0, 150, 50, 300],
    y: 0,
});

查看Hilo源码绘制图片是用CanvasRenderingContext2D.drawImage方法。

CanvasRenderingContext2D.drawImage() 是浏览器原生提供的在 canvas 上绘制图片的方法。

其有以下三种参数形式(详细用法说明及演示可见 MDN):

  1.   ctx.drawImage(image, dx, dy);
      ctx.drawImage(image, dx, dy, dWidth, dHeight);
      ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

| 参数 | 意义 |
| --------------- | ---------------------------- |
| sx, sy | 源图像的选择区域的偏移量 |
| sWidth, sHeight | 源图像的选择区域的宽高 |
| dx, dy | 目标canvas的选择区域的偏移量 |
| dWidth, dHeight | 目标canvas的选择区域的宽高 |

注:

  1. 在 Chrome 和 Firefox 下,最终的选择区域超出源图像的部分不会被绘制。
  2. 在 IE 和 Edge 下,最终的选择区域超出源图像的部分会用图像的边界像素来填充。
  3. Safari 7.1 额外要求 sx + sWidthsy + sHeight 不超过源图像的宽高,否则 drawImage() 函数不会绘制任何图像。(未在更高版本的 Safari 上测试)
  4. 在支持 canvas 的老版本的 Firefox 上,有着和 IE 等浏览器类似的对 sx, sy, sWidth, sHeight 的限制,在新版本中,这些限制已经被移除。

而我们的元素在部分机型上不能显示就是因为触发了第3点坑。修复代码如下,通过完整的绘制图片,然后通过元素的坐标来达到目标样式。

new Hilo.Bitmap({
    // 绘制的图片
    image: 'imgurl', 
    // 测试坐标,非精准坐标
    rect: [0, 0, 50, 300],
    y: -100,
});
new Hilo.Bitmap({
    // 绘制的图片
    image: 'imgurl', 
    // 测试坐标,非精准坐标
    rect:[0, 0, 50, 300],
    y: -150,
});

参考文章

五、碰撞检测,撞击坐标不准确

用Hilo最开始开心的一点也是碰撞检测不需要自己写,hitTestObject检测object参数指定的对象是否与其相交。因此撞击区域可以书写撞击坐标。

// 给如下图中的一个多边形实行撞击坐标
new Hilo.Bitmap({
    // 绘制的图片
    image: 'imgurl', 
    // 测试坐标,非精准坐标
    rect: [0, 0, 50, 300],
    y: -100,
    boundsArea: [
        // 测试坐标,非精准坐标,图中红点的坐标,从左到右
        {x: 0, y: 0},
        {x: 0, y: 100},
        {x: 100, y: 100},
        {x: 100, y: 200},
        {x: 200, y: 200},
        {x: 200, y: 100},
        {x: 300, y: 100},
        {x: 300, y: 0},
    ]
});

理想中应该是如下黄色区域构成的碰撞检测区域。

而实际却是如下黄色区域,空气墙???用户反馈为什么没撞到就死翘翘了。(看了Hilo碰撞检测这部分的实现源码,没太看懂多边形的处理。。)

解决办法,类似雪碧图使用,恩...主要是懒得切图

new Hilo.Bitmap({
    // 绘制的图片
    image: 'imgurl', 
    // 测试坐标,非精准坐标
    rect: [0, 0, 100, 100],
    y: 0,
    boundsArea: [
        // 测试坐标,非精准坐标,图中红点的坐标,从左到右
        {x: 0, y: 0},
        {x: 0, y: 100},
        {x: 100, y: 100},
        {x: 100, y: 0},
    ]
});
new Hilo.Bitmap({
    // 绘制的图片
    image: 'imgurl', 
    // 测试坐标,非精准坐标
    rect: [100, 0, 100, 200],
    y: 0,
    boundsArea: [
        // 测试坐标,非精准坐标,图中红点的坐标,从左到右
        {x: 0, y: 0},
        {x: 0, y: 200},
        {x: 100, y: 200},
        {x: 100, y: 0},
    ]
});
new Hilo.Bitmap({
    // 绘制的图片
    image: 'imgurl', 
    // 测试坐标,非精准坐标
    rect: [200, 0, 100, 100],
    y: 0,
    boundsArea: [
        // 测试坐标,非精准坐标,图中红点的坐标,从左到右
        {x: 0, y: 0},
        {x: 0, y: 100},
        {x: 100, y: 100},
        {x: 100, y: 0},
    ]
});

其实就是将1张图裁剪成了3张图,裁剪出来的区域撞击坐标都中规中矩。过于不规则的图形只能尽量写的粗糙一些。

这么轻松就解决了吗???当然NO!!!回顾下第四点,部分机型游戏场景显示不全。上面裁剪的3张图里,最后一张图又触发了safari的bug。o(╥﹏╥)o,所以还是乖乖切图,或者雪碧图留一点安全区域吧!

而且后来我才知道雪碧图对于CANVAS来说更耗性能,还不如多切点图呢~

查看原文

赞 2 收藏 0 评论 0

CRStudio 收藏了文章 · 5月29日

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

背景

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

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

查看原文

CRStudio 赞了文章 · 5月29日

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

背景

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

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

查看原文

赞 10 收藏 5 评论 0

CRStudio 赞了文章 · 5月13日

CSS中的间距,前端开发中各种设置间距的优点缺点及实例

默认文件1588564373403.png

来源:https://ishadeed.com,作者:Ahmad Shadeed
翻译:公众号《前端外文精选》

如果两个或多个元素很接近,那么用户就会认为它们以某种方式属于彼此。当对多个设计元素进行分组时,用户可以根据它们之间的空间大小来决定它们之间的关系。没有间距,用户将很难浏览页面并知道哪些内容相关而哪些内容无关。

在本文中,我将介绍有关CSS中的间距,实现此间距的不同方法以及何时使用 padding 或 margin 所需的所有知识。

间距类型

CSS中的间距有两种类型,一种在元素外部,另一种在元素内部。对于本文,我将其称为outerinner。假设我们有一个元素,它内部的间距是inner,外部的间距是outer

在CSS中,间距可以如下:

.element {
  padding: 1rem;
  margin-bottom: 1rem;
}

我使用 padding 来填充内部间距,使用 margin 来填充外部间距。很简单,不是吗?但是,当处理具有许多细节和子元素的组件时,这会变得越来越复杂。

margin 外部间距

它用于增加元素之间的间距。例如,在上一个示例中,我添加了 margin-bottom:1rem 在两个堆叠的元素之间添加垂直间距。

由于可以沿四个不同的方向(top、right、 bottom、left)添加margin,因此在深入研究示例和用例之前,一定要阐明一些基本概念,这一点很重要。

margin 折叠

简而言之,当两个垂直元素具有margin,并且其中一个元素的margin大于另一个元素时,发生边距折叠。在这种情况下,将使用更大的margin,而另一个将被忽略。

在上面的模型中,一个元素有 margin-bottom,另一个元素有 margin-top,边距较大的元素获胜。

为避免此类问题,建议按照本文使用单向边距。此外,CSS Tricks还在页边距底部和页边距顶部之间进行了投票。61%的开发者更喜欢 margin-bottom 而不是 margin-top

请在下面查看如何解决此问题:

.element:not(:last-child) {
  margin-bottom: 1rem;
}

使用 :not CSS选择器,您可以轻松地删除最后一个子元素的边距,以避免不必要的间距。

另一个与边距折叠相关的例子是子节点和父节点。让我们假设如下:

<div class="parent">
  <div class="child">I'm the child element</div>
</div>
.parent {
  margin: 50px auto 0 auto;
  width: 400px;
  height: 120px;
}

.child {
  margin: 50px 0;
}

请注意,子元素固定在其父元素的顶部。那是因为它的边距折叠了。根据W3C,以下是针对该问题的一些解决方案:

  • 在父元素上添加 border
  • 将子元素显示更改为 inline-block

一个更直接的解决方案是将 padding-top 添加到父元素。

负margin

它可以与四个方向一起使用以留出余量,在某些用例中非常有用。让我们假设以下内容:

父节点具有 padding:1rem,这导致子节点从顶部、左侧和右侧偏移。但是,子元素应该紧贴其父元素的边缘。负margin可以助你一臂之力。

.parent {
  padding: 1rem
}

.child {
  margin-left: -1rem;
  margin-right: -1rem;
  margin-top: -1rem;
}

如果您想更多地挖负margin,建议阅读这篇文章。

padding 内部间距

如前所述,padding在元素内部增加了一个内间距。它的目标可以根据使用的情况而变化。

例如,它可以用于增加链接之间的间距,这将导致链接的可点击区域更大。

必须提出的是,垂直方向的padding对于那些具有 display:inline 的元素不适用,比如 <span><a>。如果添加了内边距,它不会影响元素,内边距将覆盖其他内联元素。

这只是一个友好的提醒,应该更改内联元素的 display 属性。

.element span {
  display: inline-block;
  padding-top: 1rem;
  padding-bottom: 1rem;
}

CSS Grid 间隙

在CSS网格中,可以使用 grid-gap 属性轻松在列和行之间添加间距。这是行和列间距的简写。

.element {
  display: grid;
  grid-template-columns: 1fr 1fr;
  grid-gap: 16px; /* 为行和列都增加了16px的间隙。 */
}

gap属性可以使用如下:

.element {
  display: grid;
  grid-template-columns: 1fr 1fr;
  grid-row-gap: 24px;
  grid-column-gap: 16px;
}

CSS Flexbox 间隙

gap 是一个提议的属性,将用于CSS Grid和flexbox,撰写本文时,它仅在Firefox中受支持。

.element {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
}

CSS 定位

它可能不是直接的元素间距方式,但在一些设计案例中却起到了一定的作用。例如,一个绝对定位的元素需要从其父元素的左边缘和上边缘定位 16px

考虑以下示例,带有图标的卡片,其图标应与其父对象的左上边缘隔开。在这种情况下,将使用以下CSS:

.category {
  position: absolute;
  left: 16px;
  top: 16px;
}

用例和实际示例

在这一节中,你将回顾一下在日常工作中,你在处理CSS项目时,会遇到的不同用例。

header 组件

在这种情况下,标题具有logo,导航和用户个人资料。你能猜出CSS中的间距应该如何设置吗?好吧,让我为你添加一个骨架模型。

<header class="c-header">
  <h1 class="c-logo"><a href="#">Logo</a></h1>
  <div class="c-header__nav">
    <nav class="c-nav">
      <ul>
        <li><a href="#">...</a></li>
      </ul>
    </nav>
    <a href="#" class="c-user">
      <span>Ahmad</span>
      <img class="c-avatar" data-original="shadeed.jpg" alt="">
    </a>
  </div>
</header>

Header的左侧和右侧都有padding,这样做的目的是防止内容物紧贴在边缘上。

.c-header {
  padding-left: 16px;
  padding-right: 16px;
}

对于导航,每个链接在垂直和水平侧均应具有足够的填充,因此其可单击区域可以很大,这将增强可访问性。

.c-nav a {
  display: block;
  padding: 16px 8px;
}

对于每个项目之间的间距,您可以使用 margin 或将 <li>display 更改为 inline-block。内联块元素在它的兄弟元素之间添加了一点空间,因为它将元素视为字符。

.c-nav li {
  /* 这将创建你在骨架中看到的间距 */
  display: inline-block;
}

最后,头像(avatar)和用户名的左侧有一个空白。

.c-user img,
.c-user span {
  margin-left: 10px;
}

请注意,如果你要构建多语言网站,建议使用如下所示的CSS逻辑属性。

.c-user img,
.c-user span {
  margin-inline-start: 1rem;
}

请注意,分隔符周围的间距现在相等,原因是导航项没有特定的宽度,而是具有padding。结果,导航项目的宽度基于其内容。以下是解决方案:

  • 设置导航项目的最小宽度
  • 增加水平padding
  • 在分隔符的左侧添加一个额外的margin

最简单,更好的解决方案是第三个解决方案,即添加 margin-left

.c-user {
  margin-left: 8px;
}

网格系统中的间距:Flexbox

网格是间隔最常用的情况之一。考虑以下示例:

间距应在列和行之间。考虑以下HTML标记:

<div class="wrapper">
  <div class="grid grid--4">
    <div class="grid__item">
      <article class="card"><!-- Card content --></article>
    </div>
    <div class="grid__item">
      <article class="card"><!-- Card content --></article>
    </div>
    <!-- And so on.. -->
  </div>
</div>

通常,我更喜欢将组件封装起来,并避免给它们增加边距。由于这个原因,我有 grid__item元素,我的card组件将位于其中。

.grid--4 {
  display: flex;
  flex-wrap: wrap;
}

.grid__item {
  flex-basis: 25%;
  margin-bottom: 16px;
}

使用上述CSS,每行将有四张卡片。这是在它们之间添加空格的一种可能的解决方案:

.grid__item {
  flex-basis: calc(25% - 10px);
  margin-left: 10px;
  margin-bottom: 16px;
}

通过使用CSS calc() 函数,可以从 flex-basis 中扣除边距。如你所见,这个方案并不是那么简单。我比较喜欢的是下面这个办法。

  • 向网格项目添加 padding-left
  • 在网格父节点上增加一个负值 margin-left,其 padding-left 值相同。

几年前,我从CSS Wizardy那里学到了上述解决方案(我忘记了文章标题,如果您知道,请告诉我)。

.grid--4 {
  display: flex;
  flex-wrap: wrap;
  margin-left: -10px;
}

.grid__item {
  flex-basis: 25%;
  padding-left: 10px;
  margin-bottom: 16px;
}

我之所以用了负 margin-left,是因为第一张卡有 padding-left,而实际上不需要。所以,它将把 .wrapper 元素推到左边,取消那个不需要的空间。

另一个类似的概念是在两边都添加填充,然后边距为负。这是Facebook故事的一个示例:

.wrapper {
  margin-left: -4px;
  margin-right: -4px;
}

.story {
  padding-left: 4px;
  padding-right: 4px;
}

网格系统中的间距:CSS Grid

现在,到了激动人心的部分!使用CSS Grid,你可以很容易地使用 grid-gap 添加间距。此外,你不需要关心网格项的宽度或底部空白,CSS Grid 为你做者一切!

.grid--4 {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr;
  grid-gap: 1rem;
}

就是这样!难道不是那么容易和直接吗?

按需定制

我真正喜欢CSS Grid 的地方是 grid-gap 只在需要的时候才会被应用。考虑下面的模型。

没有CSS网格,就不可能拥有这种灵活性。首先,请参见以下内容:

.card:not(:last-child) {
  margin-bottom: 16px;
}

@media (min-width: 700px) {
  .card:not(:last-child) {
    margin-bottom: 0;
    margin-left: 1rem;
  }
}

不舒服吧?这个如何?

.card-wrapper {
  display: grid;
  grid-template-columns: 1fr;
  grid-gap: 1rem;
}

@media (min-width: 700px) {
  .card-wrapper {
    grid-template-columns: 1fr 1fr;
  }
}

完成了!容易得多。

处理底部margin

假设以下组件堆叠在一起,每个组件都有底边距。

注意最后一个元素有一个空白,这是不正确的,因为边距只能在元素之间。

可以使用以下解决方案之一解决此问题:

解决方案1-CSS :not 选择器

.element:not(:last-child) {
  margin-bottom: 16px;
}

解决方案2:相邻兄弟组合器

.element + .element {
  margin-top: 16px;
}

虽然解决方案1具有吸引力,但它具有以下缺点:

  • 它会导致CSS的特异性问题。在使用 :not 选择器之前不可能覆盖它。
  • 万一设计中有不止一列,它将无法正常工作。参见下图。

关于解决方案2,它没有CSS特异性问题。但是,它只能处理一个列栈。

更好的解决方案是通过向父元素添加负边距来取消不需要的间距。

.wrapper {
  margin-bottom: -16px;
}

它用一个等于底部间距的值将元素推到底部。注意不要超过边距值,因为它会与同级元素重叠。

Card组件

Oh,如果我想把所有细节的Card组件间距都写进去的话,最后可能会出现书本上的内容。我就突出一个大概的模式,看看间距应该如何应用。

你能想到此卡片在哪里使用间距吗?参见下图。

<article class="card">
  <a href="#">
    <div class="card__thumb"><img data-original="food.jpg" alt=""></div>
    <div class="card__content">
      <h3 class="card__title">Cinnamon Rolls</h3>
      <p class="card__author">Chef Ahmad</p>
      <div class="card__rating"><span>4.9</span></div>
      <div class="card__meta"><!-- --></div>
    </div>
  </a>
</article>
.card__content {
  padding: 10px;
}

上面的 padding 将向其中的所有子元素添加一个偏移量。然后,我将添加所有边距。

.card__title,
.card__author,
.card__rating {
  margin-bottom: 10px;
}

对于评分和 .car__meta 元素之间的分隔线,我将添加它作为边框。

.card__meta {
  padding-top: 10px;
  border-top: 1px solid #e9e9e9;
}

糟糕!由于对父元素 .card__content 进行了填充,因此边框没有粘在边缘上。

是的,你猜对了!负边距是解决办法。

.card__meta {
  padding-top: 10px;
  border-top: 1px solid #e9e9e9;
  margin: 0 -10px;
}

糟糕,再次!出了点问题。内容粘在边缘!

为了解决这个问题,内容应该从左右两边加垫(呵呵,看来加垫是个新词)。

.card__meta {
  padding: 10px 10px 0 10px;
  border-top: 1px solid #e9e9e9;
  margin: 0 -10px;
}

文章内容

我相信这是一个非常非常普遍的用例。由于文章内容来自CMS(内容管理系统),或者是由Markdown文件自动生成的,因此无法为元素添加类。

考虑下面的示例,其中包含标题,段落和图像。

<div class="wrapper">
  <h1>Spacing Elements in CSS</h1>
  <p><!-- content --></p>
  <h2>Types of Spacing</h2>
  <img data-original="spacing-1.png" alt="">
  <p><!-- content --></p> 
  <p><!-- content --></p> 
  <h2>Use Cases</h2>
  <p><!-- content --></p> 
  <h3>Card Component</h3> 
  <img data-original="use-case-card-2.png" alt="">
</div>

为了使它们看起来不错,间距应保持一致并谨慎使用。我从type-scale.com借了一些样式。

h1, h2, h3, h4, h5 {
  margin: 2.75rem 0 1.05rem;
}

h1 {
  margin-top: 0;
}

img {
  margin-bottom: 0.5rem;
}

如果一个 <p> 后面有一个标题,例如“Types of Spacing”,那么 <p>margin-bottom 将被忽略。你猜到了,那是因为页边距折叠。

Just In Case Margin

我喜欢把这个叫做 "Just in case" margin,因为这就是字面意思。考虑一下下面的模型图。

当元素靠近的时候,它们看起来并不好看。我是用flexbox搭建的。这项技术称为“对齐移位包装”,我从CSS Tricks中学到了它的名称。

.element {
  display: flex;
  flex-wrap: wrap;
}

当视口尺寸较小时,它们的确以新行结尾。见下文:

需要解决的是中间设计状态,即两件物品仍然相邻,但两件物品之间的间距为零的设计状态。在这种情况下,我倾向于向元素添加一个 margin-right,这样可以防止它们相互接触,从而加快 flex-wrap 的工作速度。

CSS 书写模式

根据MDN:

writing-mode CSS属性设置了文本行是水平还是垂直排列,以及块的前进方向。

你是否曾经考虑过将边距与具有不同 writing-mode 的元素一起使用时应如何表现?考虑以下示例。

.wrapper {
  /* 使标题和食谱在同一行 */
  display: flex;
}

.title {
  writing-mode: vertical-lr;
  margin-right: 16px;
}

标题被旋转了90度,在它和图像之间应该有一个空白区。结果表明,基于 writing-mode 的页边距工作得非常好。

我认为这些用例就足够了。让我们继续一些有趣的概念!

组件封装

大型设计系统包含许多组件。向其直接添加边距是否合乎逻辑?

考虑以下示例。

<button class="button">Save Changes</button>
<button class="button button-outline">Discard</button>

按钮之间的间距应在哪里添加?是否应将其添加到左侧或右侧按钮?也许你可以如下使用相邻同级选择器:

.button + .button {
  margin-left: 1rem;
}

这是不好的。如果只有一个按钮的情况怎么办?或者,当它垂直堆叠时在移动设备上将如何工作?很多很多的复杂性。

使用抽象组件

解决上述问题的一种方法是使用抽象的组件,其目标是托管其他组件,就像Max Stoiber所说的那样,这是将管理边距的责任移到了父元素上,让我们以这种思维方式重新思考以前的用例。

<div class="list">
  <div class="list__item">
    <button class="button">Save Changes</button>
  </div>
  <div class="list__item">
    <button class="button button-outline">Discard</button>
  </div>
</div>

注意,我添加了一个包装器,并且每个按钮现在都包装在其自己的元素中。

.list {
  display: flex;
  align-items: center;
  margin-left: -1rem; /* 取消第一个元素的左空白 */
}

.list__item {
  margin-left: 1rem;
}

就是这样!而且,将这些概念应用到任何JavaScript框架中都相当容易。例如:

<List>
  <Button>Save Changes</Button>
  <Button outline>Discard</Button>
</List>

你使用的JavaScript工具应该将每个项包装在自己的元素中。

间隔组件

是的,你没看错。我在这篇文章中讨论了避免margin的概念,并使用间隔组件来代替它们。

让我们假设一个区域需要从左到右24px的空白,并记住这些限制:

  • margin不能直接用于组件,因为它是一个已经构建的设计系统。
  • 它应该是灵活的。间距可能在X页上,但不在Y页上。

我在检查Facebook的新设计CSS时首先注意到了这一点。

那是一个 <div>,内联样式宽度:16px,它唯一的作用是在左边缘和包装器之间增加一个空白空间。

引述这本React游戏手册中的内容。

但在现实世界中,我们确实需要组件之外的间距来合成页面和场景,这就是margin渗入组件代码的地方:用于组件的间距组合。

我同意。对于大型设计系统,不断向组件添加margin是不可伸缩的。这将最终导致一个令人毛骨悚然的代码。

间隔组件的挑战

现在你了解了间隔组件的概念,让我们深入研究使用它们时遇到的一些挑战。这是我想到的一些问题:

  • 间隔组件如何在父级内部取其宽度或高度?在水平布局和垂直布局中,它将如何工作?
  • 我们是否应该根据其父项的显示类型(Flex,Grid)对它们进行样式设置

让我们一一解决上述问题。

调整间隔组件的大小

可以创建一个接受不同变化和设置的间隔。我不是JavaScript开发人员,但我认为他们将其称为Props。考虑来自styled-system.com的以下内容:

我们在一个header和一个 section之间有一个隔板。

<Header />
    <Spacer mb={4} />
<Section />

虽然这个有点不一样,一个间隔器在logo和导航之间建立一个自动间隔。

<Flex>
  <Logo />
  <Spacer m="auto" />
  <Link>Beep</Link>
  <Link>Boop</Link>
</Flex>

你可能会认为,通过添加 justify-content:space-between,使用CSS做到这一点相当容易。

如果设计上需要改一下怎么办?那么,如果是这样的话,样式就应该改了。

见下文,你看到那里的灵活性了吗?

<Flex>
  <Logo />
  <Link>Beep</Link>
  <Link>Boop</Link>
  <Spacer m="auto" />
  <Link>Boop</Link>
</Flex>

那么,如果是这样的话,就应该改变样式。你看出来有什么灵活性了吗?对于尺寸调整部分,可以根据其母体的尺寸调整间隔的尺寸。

对于上面的内容,也许你可以做一个叫 grow 的prop,可以计算成 flex-grow:1 在CSS中。

<Flex>
  <Spacer grow="1" />
</Flex>

使用伪元素

我考虑过的另一个想法是使用伪元素创建间隔符。

.element:after {
    content: "";
    display: block;
    height: 32px;
}

也许我们可以选择通过一个伪元素而不是一个单独的元素来添加间隔器?例如:

<Header spacer="below" type="pseudo" length="32">
  <Logo />
  <Link>Home</Link>
  <Link>About</Link>
  <Link>Contact</Link>
</Header>

直到今天,我还没有在项目中使用间隔组件,但是我期待可以使用它们的用例。

CSS数学函数:Min(),Max(),Clamp()

有可能有动态的边距吗?例如,根据视口宽度设置具有最小值和最大值的空白。答案是肯定的!我们可以。最近,Firefox 75支持CSS数学函数,这意味着根据CanIUse在所有主流浏览器中都支持CSS数学函数。

让我们回想一下Grid用例,以了解如何在其中使用动态间距。

.wrapper {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-gap: min(2vmax, 32px);
}

下面是 min(2vmax,32px) 的意思:使用一个等于 2vmax 的间隙,但不能超过 32px

拥有这样的灵活性确实令人惊讶,并且为我们提供了构建更多动态和灵活布局的许多可能性。


文章首发《前端外文精选》微信公众号

subscribe2.png

继续阅读其他高赞文章


查看原文

赞 24 收藏 18 评论 4

CRStudio 收藏了文章 · 5月13日

什么,秒杀系统也有这么多种!

前言

本文结构很简单:

5张图送你5种秒杀系统,再加点骚操作,再顺带些点心里话🤷‍♀️。

一个简单的秒杀系统

实现原理: 通过redis原子操作减库存

图一

优点缺点
简单好用考验redis服务能力
是否公平
公平
先到先得

我们称这类秒杀系统为:

简单秒杀系统

如果刚开始QPS并不高,redis完全抗的下来的情况,完全可以依赖这个「简单秒杀系统」。

一个够用的秒杀系统

实现原理: 服务内存限流算法 + redis原子操作减库存

图二

优点缺点
简单好用-
是否公平
不是很公平
相对的先到先得

我们称这类秒杀系统为:

够用秒杀系统

性能再好点的秒杀系统

实现原理: 服务本地内存原子操作减库存

服务本地内存的库存怎么来的?

活动开始前分配好每台机器的库存,推送到机器上。

图三

优点缺点
高性能不支持动态伸缩容(活动进行期间),因为库存是活动开始前分配好的
释放redis压力-
是否公平
不是很公平
不是绝对的先到先得

我们称这类秒杀系统为:

预备库存秒杀系统

支持动态伸缩容的秒杀系统

实现原理: 服务本地协程Coroutine定时redis原子操作减部分库存到本地内存 + 服务本地内存原子操作减库存

图四

优点缺点
高性能支持动态伸缩容(活动进行期间)
释放redis压力-
具备通用性-
是否公平
不是很公平,但是好了点
几乎先到先得

我们称这类秒杀系统为:

实时预备库存秒杀系统

公平的秒杀系统

实现原理: 服务本地Goroutine定时同步是否售罄到本地内存 + 队列 + 排队成功轮训(或主动Push)结果

图五

优点缺点
高性能开发成本高(需主动通知或轮训排队结果)
真公平-
具备通用性-
是否公平
很公平
绝对的先到先得

我们称这类秒杀系统为:

公平排队秒杀系统

骚操作

上面的秒杀系统还不够完美吗?

答案:是的。

还有什么优化的空间?

答案:静态化获取秒杀活动信息的接口。

静态化是什么意思?

答案:比如获取秒杀活动信息是通过接口 https://seckill.skrshop.tech/v1/acticity/get 获取的。现在呢,我们需要通过https://static-api.skrshop.tech/seckill/v1/acticity/get 这个接口获取。有什么区别呢?看下面:

服务名接口数据存储位置
秒杀服务https://seckill.skrshop.tech/...秒杀服务内存或redis等
接口静态化服务https://static-api.skrshop.te...CDN、本地文件

以前是这样

变成了这样

结果:可以通过接口https://static-api.skrshop.tech/seckill/v1/acticity/get就获取到了秒杀活动信息,流量都分摊到了cdn,秒杀服务自身没了这部分的负载。

小声点说:“秒杀结果我也敢推CDN😏😏😏。”
备注:
之后我们会分享`如何用Golang设计一个好用的「接口静态化服务」`。

总结

上面我们得到了如下几类秒杀系统

秒杀系统
简单秒杀系统
够用秒杀系统
预备库存秒杀系统
实时预备库存秒杀系统
公平排队秒杀系统

我想说的是里面没有最好的方案,也没有最坏的方案,只有适合你的。

先到先得来说,一定要看你们的产品对外宣传,切勿上来就追逐绝对的先到先得。其实你看所有的方案,相对而言都是“先到先得”,比如,活动开始一个小时了你再来抢,那相对于准时的用户自然抢不过,对吧。

又如预备库存秒杀系统,虽然不支持动态伸缩容。但是如果你的环境满足如下任意条件,就完全够用了。

  • 秒杀场景结束时间之快,通常几秒就结束了,真实活动可能会发生如下情况:

    • 服务压力大还没挂:根本就来不及动态伸缩容
    • 服务压力大已经挂了:可以先暂停活动,服务起来&扩容结束,用剩余库存重新推送
  • 运维自身不具备动态伸缩容的能力

所以:

合适好用就行,切勿过度设计。

最后

这次算是把老本都吐露出来了,真是慌得一匹。


SkrShop历史分享:https://github.com/skr-shop/m...

3911642037-d2bb08d8702e7c91_articlex.jpg

查看原文

认证与成就

  • 获得 24 次点赞
  • 获得 9 枚徽章 获得 1 枚金徽章, 获得 1 枚银徽章, 获得 7 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2014-09-22
个人主页被 553 人浏览