H5拍照應用開發經歷的那些坑兒

1、項目簡介

1.一、項目背景:
這是一個在移動終端創新應用的項目,用戶在瀏覽器端(微信/手Q)便可完成與金秀賢的合影,但願經過這樣一種趣味體驗,引起用戶的分享與轉發的熱潮。javascript

1.二、系統要求:
ios6-ios七、android3.0-android4.三、android4.4+(非webview內)css

1.三、體驗地址:

html

2、初步技術方案肯定

在項目前期首先啓動了技術預演,肯定初步技術方案(*非最終解決方案):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

3、碰到的那些坑兒

按照既定的技術方案開始執行,開始碰到一個個問題,有些問題能夠繞過,有些問題只能推倒重來。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框架EaseJSQuarkJS,DOM類庫Zepto中都存在。我項目中使用的是QuarkJS,碰到具體問題是舞臺事件座標不正確,因爲是框架中的bug,足足花了半天時間才追查到。
問題解決:
offsetLeft或offsetTop須要減去translate的差值。

4、項目總結

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)
}
 
 
 
 
});
相關文章
相關標籤/搜索