1.一、項目背景:
這是一個在移動終端創新應用的項目,用戶在瀏覽器端(微信/手Q)便可完成與金秀賢的合影,但願經過這樣一種趣味體驗,引起用戶的分享與轉發的熱潮。javascript
1.二、系統要求:
ios6-ios七、android3.0-android4.三、android4.4+(非webview內)css
1.三、體驗地址:html
在項目前期首先啓動了技術預演,肯定初步技術方案(*非最終解決方案):html5
2.一、獲取用戶照片數據
2.1.一、首先放棄了主動獲取用戶攝像頭的getUserMedia,由於移動終端支持率過低;
2.1.二、肯定使用Input控件獲取照片文件、使用FileReader讀取照片數據,android3.0+、ios6.0+均可以支持。java
2.二、編輯合成照片
2.2.一、放棄使用canvas編輯(即將圖像數據讀取到canvas內進行處理)照片,考慮到開發成本成高;
2.2.二、選用dom編輯(img標籤),而後使用html2canvas,方便保存數據。android
2.三、保存並上傳照片
肯定使用canvas的toDataURL接口,提交base64數據到服務器。ios
按照既定的技術方案開始執行,開始碰到一個個問題,有些問題能夠繞過,有些問題只能推倒重來。css3
3.一、照片方向反了(以下圖所示)
問題描述:
手持設備不一樣方向所拍攝的照片方向不一樣,照片的方向都會不一樣,但相冊中會自動糾正,這一問題在ios和android中都存在。
問題解決:
3.1.一、將圖片數據轉換成二進制數據,方便獲取圖片的exif信息;(這裏我引入了 Binary Ajax)
3.1.二、獲取圖片的exif信息;(這裏我使用了 Javascript EXIF Reader)
3.1.三、經過圖片exif信息,獲取圖片拍攝時所持設備方向orientation。
關鍵代碼:git
// 讀取圖片數據 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.二、html2canvas問題重重
問題背景:
爲何要用html2canvas呢,由於咱們須要將用戶合成照片後的base64數據提交服務器,因此咱們須要經過轉換爲canvas獲取照片數據。
問題詳情:
3.2.一、圖片使用css3 transform旋轉了圖片方向,但最終html2canvas渲染結果卻未保存旋轉信息;
3.2.二、html2canvas的渲染起點爲網頁右上角,並且不能更改設置;
3.2.三、ios大圖被壓扁了。
問題解決:
但最終由於碰到太多沒法繞過的問題,不得不放棄html2canvas的方案,所有轉爲canvas開發。github
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.四、照片太模糊啦,我想提升精度!
問題描述:
如上圖所示,爲了減小本地內存消耗,項目最初採用尺寸是320×270。在項目上線後,在確保內存佔用不太高的狀況下,開始嘗試開發高清方案,測試地址以下:
在主流設備上測試,性能並沒有太大問題,但當網絡切換爲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.一、在微信和手Q環境中檢測用戶環境若是爲wifi,則啓用高清方案,但因爲在這個網站推廣的渠道不少,環境複雜,並不能徹底解決問題,因此放棄了該解決方式;
3.4.二、在上傳前對base64數據進行文本壓縮,目前正在嘗試lz77壓縮,未上線。
3.五、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.六、當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.一、最終技術方案
4.1.一、獲取用戶照片數據
使用Input控件獲取照片文件、使用FileReader讀取照片數據,android3.0+、ios6.0+均可以支持。
4.1.二、編輯合成照片
4.1.2.一、使用canvas編輯圖片,使用canvas框架爲QuarkJS;
4.1.2.二、使用binaryajax和exif獲取照片信息,用於解決ios bug和照片方向調整;
4.1.三、保存並上傳照片
4.1.3.一、使用JPEGEncoder轉換爲base64數據;
4.1.3.二、使用lz77進行數據壓縮
4.二、心得
這個項目進行得並不順利,經歷過1次推翻總體方案重寫、1次框架bug糾錯、屢次系統和瀏覽器的bug修復,因爲線上並無此類相對成熟的應用,找不到可參考案例,吐槽之餘,也總結出一些心得:
4.2.一、對於創新類的應用,前期技術預演很關鍵,不能只是探索可行性;
4.2.二、選擇一個成熟的框架很關鍵,QuarkJS雖然自己架構不錯而且很輕量,但使用它的過程當中仍是碰到過很多bug或不完善之處,而且文檔不詳細;
4.2.三、須要善於利用現有技術。這個項目中使用了很多第三方框架來解決特定問題,若是沒有這些,項目週期將會至關長。
4.2.四、H5從圖像到音頻到視頻,還有太多領域值得探索,有很大可挖掘的價值,想一想都有點小興奮呢!
4.三、圖片編輯類總體代碼
/** * @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) } });