移動端Web上傳圖片實踐


前段時間項目上有一個拍照的需求,對於客戶端當然是個小問題,但是PM要求該功能需要在網頁版的頁面上同樣要實現跟客戶端一樣的體驗!看到這個需求有點蒙,首先還不確定網頁如何調用系統相機,選本地照片的話弄個<input type="file">應該就ok,其次手機拍一張照片都是幾兆幾兆的,如果不壓縮一下圖片,在這蛋疼的網絡環境下,基本是沒辦法傳到服務器的,網頁上的環境也就那樣,怎么做圖片壓縮呢?

1、上傳方式

一般都是采用FormData提交

傳統的<form enctype=”multipart/form-data” method=”post” action=”” target=”upload-form”> 配合 <iframe style=”display:none” name=”upload-form”></iframe>放到今天已經無法忍受了,好消息最新XHR2中支持把文件放在Formdata對象中異步提交,只考慮移動端,就可以舍棄iframe之類的兼容方案了。核心代碼這樣:

var xhr = new XMLHttpRequest();
var formData = new FormData();
formData.append('file', input.files[0]);
xhr.open('POST', form.action);
xhr.send(formData);

而且XHR2中還可以通過process事件來監聽進度,實現類似進度條的功能,代碼這樣:

xhr.onprogress = updateProgress;
xhr.upload.onprogress = updateProgress;
function updateProgress(event) {
    if (event.lengthComputable) {
        var percentComplete = event.loaded / event.total;
    ......
  }
}

用FormData發送的請求頭中你的Content-Type 會變成這樣 multipart/form-data; boundary=----WebKitFormBoundaryyqVkWF3PcCpAzZp9,如果上傳時要附帶參數也可以直接append到formData里。

 

另外一種就是讀取圖片數據轉成Base64編碼或者二進制流提交,配合FormData使用提交

思路就是用JS把圖片讀到canvas中,然后用canvas.toDataURL()接口輸出畫布的base64編碼,再把base64編碼轉成Blob塞到Formdata里傳到后端。

這里貼一下twitter和webuploader的圖片上傳邏輯

send: function() {
                var owner = this.owner,
                    opts = this.options,
                    xhr = this._initAjax(),
                    blob = owner._blob,
                    server = opts.server,
                    formData, binary, fr;
    
                if ( opts.sendAsBinary ) {
                    server += (/\?/.test( server ) ? '&' : '?') +
                            $.param( owner._formData );
    
                    binary = blob.getSource();
                } else {
                    formData = new FormData();
                    $.each( owner._formData, function( k, v ) {
                        formData.append( k, v );
                    });
    
                    formData.append( opts.fileVal, blob.getSource(),
                            opts.filename || owner._formData.name || '' );
                }
    
                if ( opts.withCredentials && 'withCredentials' in xhr ) {
                    xhr.open( opts.method, server, true );
                    xhr.withCredentials = true;
                } else {
                    xhr.open( opts.method, server );
                }
    
                this._setRequestHeader( xhr, opts.headers );
    
                if ( binary ) {
                    // 強制設置成 content-type 為文件流。
                    xhr.overrideMimeType &&
                            xhr.overrideMimeType('application/octet-stream');
    
                    // android直接發送blob會導致服務端接收到的是空文件。
                    // bug詳情。
                    // https://code.google.com/p/android/issues/detail?id=39882
                    // 所以先用fileReader讀取出來再通過arraybuffer的方式發送。
                    if ( Base.os.android ) {
                        fr = new FileReader();
    
                        fr.onload = function() {
                            xhr.send( this.result );
                            fr = fr.onload = null;
                        };
    
                        fr.readAsArrayBuffer( binary );
                    } else {
                        xhr.send( binary );
                    }
                } else {
                    xhr.send( formData );
                }
            }
// 壓縮前的代碼
...
convertCanvasToBlob:function(e){var t,i,s,n,r,a,o,c;for(n="image/jpeg",t=e.toDataURL(n),i=window.atob(t.split(",")[1]),r=new window.ArrayBuffer(i.length),a=new window.Uint8Array(r),s=0;s<i.length;s++)a[s]=i.charCodeAt(s);return o=window.WebKitBlobBuilder||window.MozBlobBuilder,o?(c=new o,c.append(r),c.getBlob(n)):new window.Blob([r],{type:n})}
...
function convertCanvasToBlob(canvas) {
    var format = "image/jpeg";
    var base64 = canvas.toDataURL(format);
    var code = window.atob(base64.split(",")[1]);
    var aBuffer = new window.ArrayBuffer(code.length);
    var uBuffer = new window.Uint8Array(aBuffer);
    for(var i = 0; i < code.length; i++){
        uBuffer[i] = code.charCodeAt(i);
    }
    var Builder = window.WebKitBlobBuilder || window.MozBlobBuilder;
    if(Builder){
        var builder = new Builder;
        builder.append(buffer);
        return builder.getBlob(format);
    } else {
        return new window.Blob([ buffer ], {type: format});
    }
}
這是它觸屏版上傳前的圖片壓縮邏輯之一,就是在前端把base64轉成二級制數據,這個數據體積相比base64小很多,還可以塞到formdata中提交,不過不支持android 2及以下,ios 5.1及以下版本的瀏覽器。
我猜你的業務可能也是想實現類似這樣的圖片上傳功能,分析twitter的源碼可能會對你有一些幫助

2、讀取圖片

//綁定input change事件
$("#photo").unbind("change").on("change",function(){
    var file = this.files[0];
    if(file){
        //驗證圖片文件類型
        if(file.type && !/image/i.test(file.type)){
            return false;
        }
        var reader = new FileReader();
        reader.onload = function(e){
            //readAsDataURL后執行onload,進入圖片壓縮邏輯
            //e.target.result得到的就是圖片文件的base64 string
            render(e.target.result);  
        };
        //以dataurl的形式讀取圖片文件
        reader.readAsDataURL(file);
    }
});

 

3、前端圖片壓縮

圖片上傳的主體工作算是完成了,不過現在手機隨便拍張照片就是一兩兆,wifi環境下不說,移動網絡通過這方案上傳照片就有點坑了。手機客戶端中一般會先壓縮圖片再上傳,Web中如何實現壓縮后上傳呢?
可以把圖片讀到canvas中,然后用canvas.toDataURL()接口輸出畫布的base64編碼,再把base64編碼轉成Blob塞到Formdata里傳到后端。這樣即可以壓縮圖片減少流量,又可以在前端就修正圖片旋轉的問題。當然這里面處理兼容的的坑很多,我們只說思路。

//定義照片的最大高度
var MAX_HEIGHT = 480;
var render = function(src){
    var image = new Image();
    image.onload = function(){
        var cvs = document.getElementById("cvs");
        var w = image.width;
        var h = image.height;
        //計算壓縮后的圖片長和寬
        if(h>MAX_HEIGHT){
            w *= MAX_HEIGHT/h;
            h = MAX_HEIGHT;
        }
        var ctx = cvs.getContext("2d");
        cvs.width = w;
        cvs.height = h;
        //將圖片繪制到Canvas上,從原點0,0繪制到w,h
        ctx.drawImage(image,0,0,w,h);

        //進入圖片上傳邏輯
        sendImg();
    };
    image.src = src;
};

4、上傳圖片

var sendImg = function(){
    var cvs = document.getElementById("cvs");
    //調用Canvas的toDataURL接口,得到的是照片文件的base64編碼string
    var data = cvs.toDataURL("image/jpeg");
    //base64 string過短顯然就不是正常的圖片數據了,過濾の。
    if(data.length<48){
        console.log("image data error.");
        return;
    }
    //圖片的base64 string格式是data:/image/jpeg;base64,xxxxxxxxxxx
    //是以data:/image/jpeg;base64,開頭的,我們在服務端寫入圖片數據的時候不需要這個頭!
    //所以在這里只拿頭后面的string
    //當然這一步可以在服務端做,但讓閑着蛋疼的客戶端幫着做一點吧~~~(稍微減輕一點服務器壓力)
    data = data.split(",")[1];
    $.post("./api/uploadimg",{
        fileName:"xxx.jpeg",
        fileData:data
    },function(data){
        if(data.status==200){
            // some code here.
            console.log("commit image success.");
        }else{
            console.log("commit image failed.");
        }
    },"json");
};

看完上面的代碼,是不是覺得也沒那么難?真的是這樣嗎?code旅途艱辛,顯然沒那么容易就讓你好過。

測試后發現,在pc上以及大部分android和iphone4s+上是正常的,但是極小部分android和iphone4s以下的機型上得到的照片居然是不完整的!比如只有上半部分,下半部分是黑的,或者照片是旋轉的!開始以為是服務端圖片存儲的時候出了問題,不過后面排除了服務端的問題,看來上面代碼是有兼容性問題的。

具體排除問題的過程很復雜糾結,就不細說了。貼幾個帖子:

1.HTML5 Canvas drawImage ratio bug iOS

2.iOS HTML5 canvas drawImage vertical scaling bug, even for small images?

3.Drawing on canvas after megapix rendering is reversed

主要是低版本的ios safari上面對於大尺寸的照片(超過設備的物理像素)處理的bug,導致的現象就是上半部分是照片下半部分是黑的,我們需要一個工具將一張大圖切成若干個小於屏幕尺寸的小圖,分別對小圖進行處理然后再合並成一張圖片。原理很簡單,但實現起來就沒那么簡單了,還是已經有相關的開源工具來完成這個工作。

Fixes iOS6 Safari's image file rendering issue for large size image (over mega-pixel), which causes unexpected subsampling when drawing it in canvas.

剩下一個圖片旋轉的問題,其實每張圖片拍攝后EXIF里面都帶有旋轉Orientation字段來標注圖片的旋轉信息的,也就是說其實圖片本身就是倒着的,但是圖片展示的時候通過讀取Orientation來修正圖片展示,使圖片能按照拍攝的角度展示,所以我們在寫入圖片數據的時候需要按照圖片本身的Orientation來寫入數據,這樣我們就需要拿到圖片本身的EXIF信息。

JavaScript library for reading EXIF image metadata

4、實際測試一下iOS沒問題,Android 4 有些機型不行,貌似修改過file的Blob數據發到服務端的數據字節就會為0 這是安卓的bug https://code.google.com/p/android/issues/detail?id=39882 。 網上有人給出的解決方案是用FileReader把文件讀出來,然后把整個二進制文件當請求發到服務端,這種方式要附帶參數的話只能放url里了。

var reader = new FileReader();
reader.onload = function() {
    $.ajax({
                type: 'POST',
                url: server,
                data: this.result,
                contentType: false,
                processData: false,
                beforeSend: function (xhr) {
                    xhr.overrideMimeType('application/octet-stream');
            },
            }).done(function (res) {
                ......
            }).fail(function () {
                ......
            }).always(function () {
                ......
            });
};
reader.readAsArrayBuffer(file);

ok,問題終於全部排除完畢啦。那么經過優化后的完整代碼就是:

//綁定input change事件
$("#photo").unbind("change").on("change",function(){
    var file = this.files[0];
    if(file){
        //驗證圖片文件類型
        if(file.type && !/image/i.test(file.type)){
            return false;
        }
        var reader = new FileReader();
        reader.onload = function(e){
            //readAsDataURL后執行onload,進入圖片壓縮邏輯
            //e.target.result得到的就是圖片文件的base64 string
            render(file,e.target.result);  
        };
        //以dataurl的形式讀取圖片文件
        reader.readAsDataURL(file);
    }
});

//定義照片的最大高度
var MAX_HEIGHT = 480;
var render = function(file,src){
    EXIF.getData(file,function(){
        //獲取照片本身的Orientation
        var orientation = EXIF.getTag(this, "Orientation");
        var image = new Image();
        image.onload = function(){
            var cvs = document.getElementById("cvs");
            var w = image.width;
            var h = image.height;
            //計算壓縮后的圖片長和寬
            if(h>MAX_HEIGHT){
                w *= MAX_HEIGHT/h;
                h = MAX_HEIGHT;
            }
            //使用MegaPixImage封裝照片數據
            var mpImg = new MegaPixImage(file);
            //按照Orientation來寫入圖片數據,回調函數是上傳圖片到服務器
            mpImg.render(cvs, {maxWidth:w,maxHeight:h,orientation:orientation}, sendImg);
        };
        image.src = src;
    });
};

//上傳圖片到服務器
var sendImg = function(){
    var cvs = document.getElementById("cvs");
    //調用Canvas的toDataURL接口,得到的是照片文件的base64編碼string
    var data = cvs.toDataURL("image/jpeg");
    //base64 string過短顯然就不是正常的圖片數據了,過濾の。
    if(data.length<48){
        console.log("data error.");
        return;
    }
    //圖片的base64 string格式是data:/image/jpeg;base64,xxxxxxxxxxx
    //是以data:/image/jpeg;base64,開頭的,我們在服務端寫入圖片數據的時候不需要這個頭!
    //所以在這里只拿頭后面的string
    //當然這一步可以在服務端做,但讓閑着蛋疼的客戶端幫着做一點吧~~~(稍微減輕一點服務器壓力)
    data = data.split(",")[1];
    $.post("./api/uploadimg",{
        fileName:"xxx.jpeg",
        fileData:data
    },function(data){
        if(data.status==200){
            // some code here.
            console.log("commit image success.");
        }else{
            console.log("commit image failed.");
        }
    },"json");
};
View Code

實測一下,稍低端的的安卓上有點卡,畢竟處理一張圖片的運算量可不小,目測目前用前端壓縮上傳方案的不多,至少微博觸屏版 (http://m.weibo.cn/) 就是把原始圖片直接上傳的,這種方式是否適合直接使用或者還有哪些可以優化的地方有待驗證。QQ空間觸屏版圖片上傳是直接把圖片base64編碼發給服務端處理。

 

參考:

http://www.cnblogs.com/Zjmainstay/archive/2012/08/09/jquery_upload_image.html
http://www.cnblogs.com/Zjmainstay/archive/2012/08/11/jQuery_upload_multiple_images.html


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM