移动端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,导致的现象就是上半部分是照片下半部分是黑的,我们需要一个工具将一张大图切成若干个小于屏幕尺寸的小图,分别对小图进行处理然后再合并成一张图片。原理很简单,但实现起来就没那么简单了,还是已经有相关的开源工具来完成这个工作。
剩下一个图片旋转的问题,其实每张图片拍摄后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"); };
实测一下,稍低端的的安卓上有点卡,毕竟处理一张图片的运算量可不小,目测目前用前端压缩上传方案的不多,至少微博触屏版 (http://m.weibo.cn/) 就是把原始图片直接上传的,这种方式是否适合直接使用或者还有哪些可以优化的地方有待验证。QQ空间触屏版图片上传是直接把图片base64编码发给服务端处理。
参考: