HTML5 Canvas拍照应用开发的那些坑

原文:http://tgideas.qq.com/webplat/info/news_version3/804/808/811/m579/201409/278736.shtml

一、项目简介

1.1、项目背景:
这是一个在移动终端创新应用的项目,用户在浏览器端(微信/手Q)即可完成与金秀贤的合影,希望通过这样一种趣味体验,引发用户的分享与转发的热潮。 6f3bcfcc72a4a9c16aa330143a9e401d

1.2、系统要求:
ios6-ios7、android3.0-android4.3、android4.4+(非webview内)

1.3、体验地址:
1407828487_48

二、初步技术方案确定

在项目前期首先启动了技术预演,确定初步技术方案(*非最终解决方案):

2.1、获取用户照片数据
2.1.1、首先放弃了主动获取用户摄像头的getUserMedia,因为移动终端支持率太低;
2.1.2、确定使用Input控件获取照片文件、使用FileReader读取照片数据,android3.0+、ios6.0+都可以支持。

2.2、编辑合成照片
2.2.1、放弃使用canvas编辑(即将图像数据读取到canvas内进行处理)照片,考虑到开发成本成高;
2.2.2、选用dom编辑(img标签),然后使用html2canvas,方便保存数据。

2.3、保存并上传照片
确定使用canvas的toDataURL接口,提交base64数据到服务器。

三、碰到的那些坑儿

按照既定的技术方案开始执行,开始碰到一个个问题,有些问题可以绕过,有些问题只能推倒重来。

3.1、照片方向反了(如下图所示)
1407834889_89
问题描述:
手持设备不同方向所拍摄的照片方向不同,照片的方向都会不同,但相册中会自动纠正,这一问题在ios和android中都存在。
问题解决:
3.1.1、将图片数据转换成二进制数据,方便获取图片的exif信息;(这里我引入了 Binary Ajax
3.1.2、获取图片的exif信息;(这里我使用了 Javascript EXIF Reader
3.1.3、通过图片exif信息,获取图片拍摄时所持设备方向orientation。
关键代码:

// 读取图片数据
var fr = new FileReader();
fr.readAsDataURL(file); 

fr.onload = function(fe){ 
	var result = this.result;
	var img = new Image();
 var exif;
	img.onload = function() {
		var orientation = exif ? exif.Orientation : 1;
		// 判断拍照设备持有方向调整照片角度
		switch(orientation) {
			case 3: 
				imgRotation = 180; 
				break;
			case 6: 
				imgRotation = 90; 
				break;
			case 8: 
				imgRotation = 270; 
			break;
		}
	};

	// 转换二进制数据
	var base64 = result.replace(/^.*?,/,'');
	var binary = atob(base64); 
	var binaryData = new BinaryFile(binary);

	// 获取exif信息
	exif = EXIF.readFromBinaryFile(binaryData);

	img.src = result;
};

3.2、html2canvas问题重重
问题背景:
为什么要用html2canvas呢,因为我们需要将用户合成照片后的base64数据提交服务器,所以我们需要通过转换为canvas获取照片数据。
问题详情:
3.2.1、图片使用css3 transform旋转了图片方向,但最终html2canvas渲染结果却未保存旋转信息;
3.2.2、html2canvas的渲染起点为网页右上角,而且不能更改设置;
3.2.3、ios大图被压扁了。
问题解决:
但最终因为碰到太多无法绕过的问题,不得不放弃html2canvas的方案,全部转为canvas开发。

3.3、ios大图被压扁了
问题详情: 当照片超过2M时,ios会出现压扁的情况(如下图所示)
1407913047_70
问题解决:
获取图片实际比例,重置图片的比例。(stack overflow讨论帖) 需要注意的是,ratio的获取是通过检测像素alpha,需要过滤png图片,这在stack overflow的讨论上没有人提出。
关键代码:
var getRatio = function(img) {
	if(/png$/i.test(img.src)) {
		return 1;
	}
	var iw = img.naturalWidth, ih = img.naturalHeight;
	var canvas = document.createElement('canvas');
	canvas.width = 1;
	canvas.height = ih;
	var ctx = canvas.getContext('2d');
	ctx.drawImage(img, 0, 0);
	var data = ctx.getImageData(0, 0, 1, ih).data;
	var sy = 0;
	var ey = ih;
	var py = ih;
	while (py > sy) {
		var alpha = data[(py - 1) * 4 + 3];
		if (alpha === 0) {
			ey = py;
		} else {
			sy = py;
		}
		py = (ey + sy) >> 1;
	}
	var ratio = (py / ih);
	return (ratio===0)?1:ratio;
}

3.4、照片太模糊啦,我想提高精度!
问题描述:
1407915648_24
如上图所示,为了减少本地内存消耗,项目最初采用尺寸是320×270。在项目上线后,在确保内存占用不过高的情况下,开始尝试开发高清方案,测试地址如下:
1407916586_85
在主流设备上测试,性能并无太大问题,但当网络切换为3g时,测试图片合并上传时间8-12s,是原来时间的3倍左右,于是测试了一下3g网络的上传速度:

 

下载速度
上传速度
联通3g
220kb/s
80kb/s
电信3g
180kb/s
60kb/s
移动3g
100kb/s
13kb/s
移动2g
15kb/s
12kb/s

平常会留意用户的下载速度,但对上传速度没太在意,640×540图片的base64数据大小为120kb左右,加上延时,3g环境下平均上传时间是5s左右。于是,上传速度成为了高清方案的瓶颈。

解决方案:
3.4.1、在微信和手Q环境中检测用户环境如果为wifi,则启用高清方案,但由于在这个网站推广的渠道很多,环境复杂,并不能完全解决问题,所以放弃了该解决方式;
3.4.2、在上传前对base64数据进行文本压缩,目前正在尝试lz77压缩,未上线。

3.5、canvas toDataURL bug
问题描述:
已测试,至少在手机QQ浏览器中,canvas对象使用toDataURL方法获取不到任何数据。
问题解决:
使用JPEGEncoder将图片像素数据转换为base64数据。
关键代码:

_public.toDataURL = function(callback){
	var self = this;
	// 去除编辑状态的元素
	self.unSelect();

	// 已测手机QQ浏览器canvas.toDataURL有问题,使用jeegEncoder
	window.setTimeout(function(){
		var encoder = new JPEGEncoder();
		var data = encoder.encode(self.canvas.getContext('2d').getImageData(0,0,self.stage.width,self.stage.height), 90);
		callback.call(self, data);
	}, 1000/self.config.fps)
}

3.6、当getElementOffset遇上transform
问题代码:
Quark.getElementOffset = function(elem)
{
	var left = elem.offsetLeft, top = elem.offsetTop;
	while((elem = elem.offsetParent) && elem != document.body && elem != document)
	{
		left += elem.offsetLeft;
		top += elem.offsetTop;
	}
	return {left:left, top:top};
};
问题描述:
当目标元素的上级元素中有使用transform:translate(x,y)时,用如上的方法都会导致offset计算错误,这一bug在常用canvas框架EaseJSQuarkJS,DOM类库Zepto中都存在。我项目中使用的是QuarkJS,碰到具体问题是舞台事件坐标不正确,由于是框架中的bug,足足花了半天时间才追查到。
问题解决:
offsetLeft或offsetTop需要减去translate的差值。

四、项目总结

4.1、最终技术方案
4.1.1、获取用户照片数据 使用Input控件获取照片文件、使用FileReader读取照片数据,android3.0+、ios6.0+都可以支持。
4.1.2、编辑合成照片
4.1.2.1、使用canvas编辑图片,使用canvas框架为QuarkJS;
4.1.2.2、使用binaryajax和exif获取照片信息,用于解决ios bug和照片方向调整;
4.1.3、保存并上传照片
4.1.3.1、使用JPEGEncoder转换为base64数据;
4.1.3.2、使用lz77进行数据压缩
4.2、心得

这个项目进行得并不顺利,经历过1次推翻整体方案重写、1次框架bug纠错、多次系统和浏览器的bug修复,由于线上并没有此类相对成熟的应用,找不到可参考案例,吐槽之余,也总结出一些心得:

4.2.1、对于创新类的应用,前期技术预演很关键,不能只是探索可行性;
4.2.2、选择一个成熟的框架很关键,QuarkJS虽然本身架构不错并且很轻量,但使用它的过程中还是碰到过不少bug或不完善之处,并且文档不详细;
4.2.3、需要善于利用现有技术。这个项目中使用了不少第三方框架来解决特定问题,如果没有这些,项目周期将会相当长。
4.2.4、H5从图像到音频到视频,还有太多领域值得探索,有很大可挖掘的价值,想想都有点小兴奋呢!

4.3、图片编辑类整体代码

/**
 * @author Brucewan
 * @version 1.0
 * @date 2014-07-11
 * @description 图片编辑器
 * @extends tg.Base
 * @name tg.ImageEditor
 * @requires zepto.js
 * @requires base.js
 * @class
*/
tg.add('tg.ImageEditor:tg.Base', function() {

	/**
	 * public 作用域
	 * @alias tg.ImageEditor#
	 * @ignore
	 */
	var _public = this;

	var _private = {};

	/**
	 * public static作用域
	 * @alias tg.ImageEditor.
	 * @ignore
	 */
	var _static = this.constructor;
	

	_public.constructor = function(config) {
		this.config = Zepto.extend(true, {}, _static.config, config); // 参数接收
		this.init();
	}

	// 插件默认配置
	_static.config = {
		width: 320,
		height: 320,
		fps: 60
	};


	/***
	 * 初始化
	 * @description 参数处理
	 */
	_public.init = function(){
		var self = this;
		var config = self.config;

		// 自定义事件绑定
		self.effect && self.on(self.effect);
		config.event && self.on(config.event);

		if(self.trigger('beforeinit') === false) {
			return;
		}

		// 创建canvas
		var canvas = Quark.createDOM('canvas', {
			width: config.width, 
			height: config.height, 
			style: {backgroundColor:"#fff"}
		});	
		canvas = $(canvas).appendTo(config.container)[0];



		var context = new Quark.CanvasContext({canvas:canvas});
		self.stage = new Quark.Stage({width:config.width, height:config.height, context:context}); 
		self.canvas = canvas;
		self.context = context;

		// register stage events
		var em = this.em = new Quark.EventManager();
		em.registerStage(self.stage, ['touchstart', 'touchmove', 'touchend'], true, true);
		self.stage.stageX = config.stageX !== window.undefined ? config.stageX : self.stage.stageX;
		self.stage.stageY = config.stageY !== window.undefined ? config.stageY : self.stage.stageY;

		var timer = new Quark.Timer(1000/config.fps);
		timer.addListener(self.stage);
		timer.addListener(Quark.Tween);
		timer.start();

		var bg = new Q.Graphics({width:config.width, height:config.height});
		bg.beginFill("#fff").drawRect(0, 0, config.width, config.height).endFill().cache();
		self.stage.addChild(bg)

		_private.attach.call(self);
	};



	_private.attach = function(){
		var self = this;
		var config = self.config;

		config.trigger.on('change', function(e){
			self.trigger('beforechange');

			// 只上传一个文件
			var file = this.files[0]; 


			// 限制上传图片文件
			if(file.type && !/image\/\w+/.test(file.type)){ 
				alert('请选择图片文件!'); 
				return false; 
			} 

			var fr = new FileReader();
			fr.readAsDataURL(file); 

			

			fr.onload = function(fe){ 
				var result = this.result;
				var img = new Image();
 		var exif;
				img.onload = function() {
					self.addImage({img: img, exif: exif});
					self.trigger('change');
				};
	 // 转换二进制数据
	 var base64 = result.replace(/^.*?,/,'');
	 var binary = atob(base64);
	 var binaryData = new BinaryFile(binary);

	 // get EXIF data
	 exif = EXIF.readFromBinaryFile(binaryData);

				img.src = result;

			};

			
			
		});


		self.stage.addEventListener('touchstart', function(e){
			if(self.imgs) {
				for(var i = 0; i < self.imgs.length; i++) {
					self.imgs[i].disable();
				}
			}
			if(e.eventTarget && e.eventTarget.parent.enEditable) {
				e.eventTarget.parent.enEditable();
				self.activeTarget = e.eventTarget.parent;
			}
		});
		self.stage.addEventListener('touchmove', function(e){
			var touches = e.rawEvent.touches || e.rawEvent.changedTouches;
			if(e.eventTarget && (e.eventTarget.parent == self.activeTarget) && touches[1]) {
				var dis = Math.sqrt(Math.pow(touches[1].pageX - touches[0].pageX, 2) + Math.pow(touches[1].pageY - touches[0].pageY, 2) );
				if(self.activeTarget.mcScale.touchDis) {
					var scale = dis / self.activeTarget.mcScale.touchDis -1;
					if( self.activeTarget.getCurrentWidth() < 100 && scale < 0) {
						scale = 0;
					}

					self.activeTarget.scaleX += scale;
					self.activeTarget.scaleY += scale;
				} 
				self.activeTarget.mcScale.touchDis = dis;
			}
		});
		self.stage.addEventListener('touchend', function(){
			if(self.activeTarget && self.activeTarget.mcScale) {
				delete self.activeTarget.mcScale.touchDis;
			}
		});


	};

	_public.addImage = function(info){
		var self = this;
		var config = self.config;
		var img = info.img;
		var exif = info.exif;
		var imgContainer;
		var mcScale;
		var mcClose;
		var imgWidth = img.width;
		var imgHeight = img.height;
		var imgRotation = 0;
		var imgRegX = 0;
		var imgRegY = 0;
		var imgX = 0;
		var imgY = 0;
		var posX = info.pos ? info.pos[0] : 0;
		var posY = info.pos ? info.pos[1] : 0;
		var imgScale = 1;
		var orientation = exif ? exif.Orientation : 1;
		var getRatio = function(img) {
			if(/png$/i.test(img.src)) {
				return 1;
			}
			var iw = img.naturalWidth, ih = img.naturalHeight;
			var canvas = document.createElement('canvas');
			canvas.width = 1;
			canvas.height = ih;
			var ctx = canvas.getContext('2d');
			ctx.drawImage(img, 0, 0);
			var data = ctx.getImageData(0, 0, 1, ih).data;
			var sy = 0;
			var ey = ih;
			var py = ih;
			while (py > sy) {
				var alpha = data[(py - 1) * 4 + 3];
				if (alpha === 0) {
					ey = py;
				} else {
					sy = py;
				}
				py = (ey + sy) >> 1;
			}
			var ratio = (py / ih);
			return (ratio===0)?1:ratio;
		}
		var ratio = getRatio(img);


		// window.setTimeout(function(){
		// 	alert(imgContainer.width);
		// 	alert(img);
		// }, 5000)




		if(typeof img == 'string') {
			var url = img;
			img = new Image();
			img.src = url;
		}


		// 判断拍照设备持有方向调整照片角度
		switch(orientation) {
			case 3: 
				imgRotation = 180; 
				imgRegX = imgWidth;
				imgRegY = imgHeight * ratio;
				// imgRegY -= imgWidth * (1-ratio);
				break;
			case 6: 

				imgRotation = 90; 
				imgWidth = img.height;
				imgHeight = img.width;
				imgRegY = imgWidth * ratio ;
				// imgRegY -= imgWidth * (1-ratio);
				break;
			case 8: 
				imgRotation = 270; 
				imgWidth = img.height;
				imgHeight = img.width;
				imgRegX = imgHeight * ratio;

				if(/iphone|ipod|ipad/i.test(navigator.userAgent)) {
					alert('苹果系统下暂不支持你以这么萌!萌!达!姿势拍照!');
					return;
				}

			break;


		}
		imgWidth *= ratio;
		imgHeight *= ratio;


		if(imgWidth > self.stage.width) {
			imgScale = self.stage.width / imgWidth;
		}

		imgWidth = imgWidth * imgScale;
		imgHeight = imgHeight * imgScale;

		imgContainer = new Quark.DisplayObjectContainer({width: imgWidth, height: imgHeight});
		imgContainer.x = posX;
		imgContainer.y = posY;


		img = new Quark.Bitmap({image:img, regX:imgRegX, regY:imgRegY});
		img.rotation = imgRotation;
		img.x = imgX;
		img.y = 0;
		img.scaleX = imgScale * ratio;
		img.scaleY = imgScale;





		if(config.iconScale && !info.disScale) {
			var iconScaleImg = new Image();
			iconScaleImg.onload = function(){
				var rect = config.iconScale.rect;
				mcScale = new Quark.MovieClip({image:iconScaleImg});
				mcScale.addFrame([{rect: rect}]);
				mcScale.x = imgWidth - rect[2];
				mcScale.y = 0;
				mcScale.alpha = 0.5;
				mcScale.visible = false;
				mcScale.addEventListener('touchstart', function(e){
					mcScale.scaleable = true;
					mcScale.startX = e.eventX;
					mcScale.startY = e.eventY;
					mcScale.alpha = 0.8;
					var curW = imgContainer.getCurrentWidth();
					var scaleMove = function(e){
						if(mcScale.scaleable) {
							// 缩放
							var disX = e.eventX - mcScale.startX;
							var scaleX = (curW+disX)/imgContainer.width;

							if( imgContainer.getCurrentWidth() < 100 && imgContainer.scaleX > scaleX) {
								return;
							}

							imgContainer.scaleX = scaleX;
							imgContainer.scaleY = scaleX;

							// 旋转
							var disOriX = e.eventX - imgContainer.x;
							var disOriY = e.eventY- imgContainer.y;
							var rotate = Math.atan2(disOriY,disOriX) * 360 / (2 * Math.PI);
							imgContainer.rotation = parseInt(rotate/1)*1;
						}
					};
					var scaleEnd = function(e) {
						mcScale.scaleable = false;
						mcScale.alpha = 0.5;
						self.stage.removeEventListener('touchmove', scaleMove);
						self.stage.removeEventListener('touchend', scaleEnd);
					}
					self.stage.addEventListener('touchmove', scaleMove);
					self.stage.addEventListener('touchend', scaleEnd);
				});
				imgContainer.mcScale = mcScale;
				imgContainer.addChild(mcScale);
			};
			iconScaleImg.src = config.iconScale.url;
		}

		var border = new Q.Graphics({width:imgWidth+10, height:imgHeight+10, x:-5, y:-5});
		border.lineStyle(5, "#aaa").beginFill("#fff").drawRect(5, 5, imgWidth, imgHeight).endFill().cache();
		border.alpha = 0.5;
		border.visible = false;
		imgContainer.addChild(border);

		if(config.iconClose) {
			var iconCloseImg = new Image();
			iconCloseImg.onload = function(){
				var rect = config.iconClose.rect;
				mcClose = new Quark.MovieClip({image:iconCloseImg});
				mcClose.addFrame([{rect: rect}]);
				mcClose.x = 0;
				mcClose.y = 0;
				mcClose.alpha = 0.5;
				mcClose.visible = false;
				mcClose.addEventListener('touchstart', function(e){
					mcClose.alpha = 0.8;
				});	
				mcClose.addEventListener('touchend', function(e){
					self.stage.removeChild(imgContainer);
				});									
				self.stage.addEventListener('touchend', function(e){
					mcClose.alpha = 0.5;
				});
				imgContainer.addChild(mcClose);
			};
			iconCloseImg.src = config.iconClose.url;
		}


		if(!info.disMove && !info.disable) {
			img.addEventListener('touchstart', function(e){
				var fnMove;
				var fnEnd;
				// 拖动
				img.curW = imgContainer.getCurrentWidth();
				img.curH = imgContainer.getCurrentHeight();
				img.moveabled = true;
				img.startX = e.eventX;
				img.startY = e.eventY;

				fnMove = function(e){
					// 是否双指按下
					var isScale = e.rawEvent && e.rawEvent.touches[1];

					if(img.moveabled && !isScale) {
						var disX = e.eventX - img.startX;
						var disY = e.eventY - img.startY;
						var setX = imgContainer.x + disX;
						var setY = imgContainer.y + disY;

						var diffX = 0, diffY = 0;

						if(setX < -img.curW/2 + 5 && disX < 0) {
							setX = -img.curW/2;
						}
						if(setY < -img.curH/2 + 5 && disY < 0) {
							setY = -img.curH/2;
						}
						if(setX > -img.curW/2 + self.stage.width - 5 && disX > 0) {
							setX = self.stage.width - img.curW/2;
						}
						if(setY > self.stage.height - 5 && disY > 0) {
							setY = self.stage.height;
						}

						imgContainer.x = setX;
						imgContainer.y = setY;
						img.startX = e.eventX;
						img.startY = e.eventY;
					}	
				};

				fnEnd = function(){
					img.moveabled = false;
					self.stage.addEventListener('touchmove');
					self.stage.addEventListener('touchend');	
				}
				self.stage.addEventListener('touchmove', fnMove);
				self.stage.addEventListener('touchend', fnEnd);


			});
		}


		imgContainer.enEditable = function(){
			if(info.disable) {
				return;
			}
			border.visible = true;
			if(mcScale) {
				mcScale.visible = true;
			}
			if(mcClose) {
				mcClose.visible = true;
			}
		}
		imgContainer.disable = function(){
			border.visible = false;
			if(mcScale) {
				mcScale.visible = false;
			}		
			if(mcClose) {
				mcClose.visible = false;
			}
		}


		img.update = function(){
			if(imgContainer && imgContainer.scaleX) {
				if(mcScale && mcScale.scaleX) {
					mcScale.scaleX = 1/imgContainer.scaleX;
					mcScale.scaleY = 1/imgContainer.scaleY;
					mcScale.x = border.getCurrentWidth() - 10 - mcScale.getCurrentWidth();
				}
				if(mcClose && mcClose.scaleX) {
					mcClose.scaleX = 1/imgContainer.scaleX;
					mcClose.scaleY = 1/imgContainer.scaleY;	
					mcClose.x = 0;
				}
			}

		}


		// imgContainer.rotation = 10;

		imgContainer.addChild(img);


		self.stage.update = function(){
			// console.log(0)
			// img.rotation ++;
		}





		imgContainer.update = function(){
			// this.rotation ++;
		}


		self.stage.addChild(imgContainer);

		if(self.imgs) {
			self.imgs.push(imgContainer);
		} else {
			self.imgs = [imgContainer];
		}
		



		// self.imgContainer.addEventListener('touchend', function(){
		// 	alert('sss')
		// });

		return imgContainer;


	};

	_public.clear = function(){
		if(this.imgs) {
			for(var i = 0; i < this.imgs.length; i++) {
				this.stage.removeChild(this.imgs[i]);
			}
		}
	};

	_public.unSelect = function(){
		var imgs = this.imgs;
		if(imgs) {
			for(var i = 0; i < imgs.length; i++) {
				imgs[i].disable();
			}
		}
	};

_public.toDataURL = function(callback){
	var self = this;
	// 去除编辑状态的元素
	self.unSelect();

	// 已测手机QQ浏览器canvas.toDataURL有问题,使用jeegEncoder
	window.setTimeout(function(){
		var encoder = new JPEGEncoder();
		var data = encoder.encode(self.canvas.getContext('2d').getImageData(0,0,self.stage.width,self.stage.height), 90);
		callback.call(self, data);
	}, 1000/self.config.fps)
}




});

关注我

我的微信公众号:前端开发博客,在后台回复以下关键字可以获取资源。

  • 回复「小抄」,领取Vue、JavaScript 和 WebComponent 小抄 PDF
  • 回复「Vue脑图」获取 Vue 相关脑图
  • 回复「思维图」获取 JavaScript 相关思维图
  • 回复「简历」获取简历制作建议
  • 回复「简历模板」获取精选的简历模板
  • 回复「加群」进入500人前端精英群
  • 回复「电子书」下载我整理的大量前端资源,含面试、Vue实战项目、CSS和JavaScript电子书等。
  • 回复「知识点」下载高清JavaScript知识点图谱

每日分享有用的前端开发知识,加我微信:caibaojian89 交流