H5拍照應用開發經歷的那些坑兒
一、專案簡介
1.1、專案背景:
這是一個在移動終端創新應用的專案,使用者在瀏覽器端(微信/手Q)即可完成與金秀賢的合影,希望通過這樣一種趣味體驗,引發使用者的分享與轉發的熱潮。
1.2、系統要求:
ios6-ios7、android3.0-android4.3、android4.4+(非webview內)
1.3、體驗地址:
二、初步技術方案確定
在專案前期首先啟動了技術預演,確定初步技術方案(*非最終解決方案):
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、照片方向反了(如下圖所示)
問題描述:
手持裝置不同方向所拍攝的照片方向不同,照片的方向都會不同,但相簿中會自動糾正,這一問題在ios和android中都存在。
問題解決:
3.1.1、將圖片資料轉換成二進位制資料,方便獲取圖片的exif資訊;(這裡我引入了
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會出現壓扁的情況(如下圖所示)
問題解決:
獲取圖片實際比例,重置圖片的比例。(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、照片太模糊啦,我想提高精度!
問題描述:
如上圖所示,為了減少本地記憶體消耗,專案最初採用尺寸是320x270。在專案上線後,在確保記憶體佔用不過高的情況下,開始嘗試開發高清方案,測試地址如下:
在主流裝置上測試,效能並無太大問題,但當網路切換為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 |
平常會留意使用者的下載速度,但對上傳速度沒太在意,640x540圖片的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框架EaseJS、QuarkJS,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;
}// 建立canvasvar 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 eventsvar 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)
}
});