最新更新时间:2019年10月31日15:33:32
《猛戳-查看我的博客地图-总有你意想不到的惊喜》
本文内容:在移动端实现图片拍摄、压缩、预览、裁剪、上传的五大功能,看起来是一套很复杂的业务逻辑组合,实际上每个模块可以单独开发,细分并拆分业务模块是常见复杂业务形态开发的基本方案。
概述
在移动端做开发永远越不过的两个障碍或技术瓶颈,兼容性和性能。
- 兼容性,某些HTML元素的默认样式在不同浏览器下显示效果不一;CSS样式的兼容性,移动端常见的是同一样式在不同OS和不同机型下显示效果不一;原生事件交互的兼容性,比如拍照和键盘输入场景下,Android和iOS系统表现的形式不一;
- 性能,主要表现在硬件设备系统内存容量的受限,比如视频播放、图片连续拍摄都是高功耗的应用场景,处理不当容易造成内存泄漏导致浏览器crash。
本文中的技术方案,瓶颈在于连续拍摄照片有数量限制,实测过程中iPhone X等高性能手机连续拍摄几十张照片的时候,容易导致浏览器crash,这个问题经过长期探索和研究,手动实现了实时垃圾回收,以及图片压缩比例调整和压缩时机控制,性能有所提高和改善,从二十张左右的数量提升到了四十张左右的数量,但部分机型无限连续拍摄图片出现崩溃的场景终究没有解决方案,究其本质原因,受限于手机系统、可用内存容量等硬件。
本方案,对于大部分机型连续拍摄照片,并实施压缩预览裁剪上传功能,无数量限制。最终向服务器上传的是base64数据格式的图片数据。
技术方案的实现
- DOM布局如下:
import React from 'react'
//引入Cropper图片裁剪组件
import Cropper from 'react-cropper';
import 'cropperjs/dist/cropper.css';
let styles = .contianer {
.cropModal{
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
background: #000000;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 3;
.crop{
}
.btn{
display: flex;
flex-direction: row;
justify-content: space-between;
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 285px;
height: 60px;
.cropperBtn{
width: 60px;
height: 60px;
line-height: 30px;
color: #FFFFFF;
font-size: 14px;
text-align: center;
img{
width: 23px;
height: 22px;
vertical-align: top;
position: relative;
top: 50%;
transform: translateY(-50%);
}
}
}
.cropTips{
position: absolute;
top: 22px;
font-size: 11px;
line-height: 15px;
color: #B8B8B8;
padding: 0 26px;
letter-spacing: 0.5px;
}
}
}
export default class TakePhoto extends React.Component {
constructor(props) {
super(props);
this.state = {
displayLoading: false,
cropperData:'',
showCropModal: false
};
this.fReader = new FileReader();
this.closureTime = 0;
}
render() {
return <div id='testPage' className={styles.contianer}>
{/*图片裁剪组件*/}
{
this.state.showCropModal ? <div className={styles.cropModal} id='cropModal'>
<Cropper
className={styles.crop}
ref='cropper'
src={this.state.cropperData}
style={{maxHeight: '78%', width: '100%'}}
//0-默认-没有任何限制 1-限制裁剪框不超过canvas画布边缘 2-如果是长图-限制图片不超过cropper的最高可视区域-同时裁剪框不超过canvas画布边缘
viewMode={2}
dragMode='none'
minCanvasWidth={285}
//隐藏棋盘背景色
background={false}
//裁剪框内部的横竖虚线可见
guides={true}
//裁剪框内部的十字线可见
center={false}
//可旋转原图
rotatable={true}
//可缩放原图
scalable={true}
//crop={(e)=>{this.crop(e)}}
/>
<div className={styles.btn}>
<div className={styles.cropperBtn} onClick={this.cancelCrop}>取消</div>
<div className={styles.cropperBtn} onClick={this.confirmCrop}>确认</div>
<div className={styles.cropperBtn} onClick={this.rotateCrop}>旋转</div>
</div>
</div> : null
}
{this.state.displayLoading ? <Loading></Loading> : null}
<input
type="file"
onChange={(e)=>{this.onChange(e)}}
className={styles.getImg}
title={this.state.title}
id="fileinput"
ref='onChange'
accept="image/*"
// capture="camera"
/>
</div>
}
}
- input元素onChange事件调起相机和相册的功能代码如下:
/**
* input onChange事件
* @param e
* @return
*/
onChange(e){
//此处是崩溃点 相机调用的频率越高,崩溃越快
let _this = this;
//弹出加载动画
this.openLoading()
let file = e.currentTarget.files[0];//object-Blob //96K 的文件转换成 base64 是 130KB
//用户取消操作
if(file == undefined){
return
}
this.fReader = new FileReader();
let tempTimer = setTimeout(function(){
_this.fReader.readAsDataURL(file);
_this.fReader.onload=function(e) {
this.zip(this.result);//压缩逻辑
}
file = null;
tempTimer = null;
},500)
}
/**
* 显示loading组件
* @param
* @return
*/
openLoading(){
this.setState({
displayLoading: true
})
}
- 图片压缩
/**
* 图片压缩
* @param base64
* @return
*/
zip(base64){
let img = new Image();
let canvas = document.createElement("canvas");
let ctx = canvas.getContext("2d");
let compressionRatio = 0.5
//获取用户拍摄图片的旋转角度
let orientation = this.getOrientation(this.base64ToArrayBuffer(base64));//1 0° 3 180° 6 90° 8 -90°
img.src = base64
img.onload = function () {
let width = img.width, height = img.height;
//图片旋转到 正向
if(orientation == 3){
canvas.width = width;
canvas.height = height;
ctx.rotate(Math.PI)
ctx.drawImage(img, -width, -height, width, height)
}else if(orientation == 6){
canvas.width = height;
canvas.height = width;
ctx.rotate(Math.PI / 2)
ctx.drawImage(img, 0, -height, width, height)
}else if(orientation == 8){
canvas.width = height;
canvas.height = width;
ctx.rotate(-Math.PI / 2)
ctx.drawImage(img, -width, 0, width, height)
}else{
//不旋转原图
canvas.width = width;
canvas.height = height;
ctx.drawImage(img, 0, 0, width, height);
}
//第一次粗压缩
// let base64 = canvas.toDataURL('image/jpeg', compressionRatio);//0.1-表示将原图10M变成1M 10-表示将原图1M变成10M
//100保证图片容量 0.05保证不失真
//console.log('第一次粗压缩',base64.length/1024,'kb,压缩率',compressionRatio);
//第二次细压缩
// while(base64.length/1024 > 500 && compressionRatio > 0.01){
//console.log('while')
// compressionRatio -= 0.01;
// base64 = canvas.toDataURL('image/jpeg', compressionRatio);//0.1-表示将原图10M变成1M 10-表示将原图1M变成10M
//console.log('第二次细压缩',base64.length/1024,'kb,压缩率',compressionRatio)
// }
this.setCropperDate(canvas.toDataURL('image/jpeg', compressionRatio));
};
}
/**
* 拍照第一次压缩后为cropper组件赋值
* @param imgDataBase64 图片的base64
* @return
*/
setCropperDate = (imgDataBase64) => {
let _this = this;
this.state.cropperData = imgDataBase64;
//定时器的作用,上面的imgDataBase64赋值,属于大数据赋值操作,消耗资源过大,加上定时器等待大数据赋值成功内存释放之后再渲染UI,不会出现白屏
let tempTimer = setTimeout(function(){
_this.setState({
displayLoading: false,
showCropModal: true
})
clearTimeout(tempTimer)
},300)
}
- 获取图片的旋转角度
/**
* base64转ArrayBuffer对象
* @param base64
* @return buffer
*/
base64ToArrayBuffer(base64) {
base64 = base64.replace(/^data\:([^\;]+)\;base64,/gmi, '');
var binary = atob(base64);
var len = binary.length;
var buffer = new ArrayBuffer(len);
var view = new Uint8Array(buffer);
for (var i = 0; i < len; i++) {
view[i] = binary.charCodeAt(i);
}
return buffer;
}
/**
* 获取jpg图片的exif的角度
* @param
* @return
*/
getOrientation(arrayBuffer) {
var dataView = new DataView(arrayBuffer);
var length = dataView.byteLength;
var orientation;
var exifIDCode;
var tiffOffset;
var firstIFDOffset;
var littleEndian;
var endianness;
var app1Start;
var ifdStart;
var offset;
var i;
// Only handle JPEG image (start by 0xFFD8)
if (dataView.getUint8(0) === 0xFF && dataView.getUint8(1) === 0xD8) {
offset = 2;
while (offset < length) {
if (dataView.getUint8(offset) === 0xFF && dataView.getUint8(offset + 1) === 0xE1)
app1Start = offset;
break;
}
offset++;
}
}
if (app1Start) {
exifIDCode = app1Start + 4;
tiffOffset = app1Start + 10;
if (getStringFromCharCode(dataView, exifIDCode, 4) === 'Exif') {
endianness = dataView.getUint16(tiffOffset);
littleEndian = endianness === 0x4949;
if (littleEndian || endianness === 0x4D4D /* bigEndian */) {
if (dataView.getUint16(tiffOffset + 2, littleEndian) === 0x002A) {
firstIFDOffset = dataView.getUint32(tiffOffset + 4, littleEndian);
if (firstIFDOffset >= 0x00000008) {
ifdStart = tiffOffset + firstIFDOffset;
}
}
}
}
}
if (ifdStart) {
length = dataView.getUint16(ifdStart, littleEndian);
for (i = 0; i < length; i++) {
offset = ifdStart + i * 12 + 2;
if (dataView.getUint16(offset, littleEndian) === 0x0112 /* Orientation */) {
// 8 is the offset of the current tag's value
offset += 8;
// Get the original orientation value
orientation = dataView.getUint16(offset, littleEndian);
// Override the orientation with its default value for Safari (#120)
if (true) {
dataView.setUint16(offset, 1, littleEndian);
}
break;
}
}
}
return orientation;
}
/**
* Unicode码转字符串 ArrayBuffer对象 Unicode码转字符串
* @param
* @return
*/
getStringFromCharCode(dataView, start, length) {
var str = '';
var i;
for (i = start, length += start; i < length; i++) {
str += String.fromCharCode(dataView.getUint8(i));
}
return str;
}
- Cropper组件的取消、裁剪、旋转的三个方法:
/**
* 无线逆时针旋转图片
* @param
* @return
*/
rotateCrop(){
this.refs.cropper.rotate(-90);
}
/**
* 在裁剪组件中确认裁剪
* @param
* @return
*/
confirmCrop(){
let _this = this;
//节流
if(Date.now() - this.closureTime < 2000){
return
}
this.closureTime = Date.now()
document.getElementById('cropModal').style.visibility = 'hidden';
this.setState({
displayLoading: true,
})
let tempTimer = setTimeout(function(){
//获取裁剪后的图片base64 向服务器传递500KB以内的图片
let compressionRatio = 0.5;
let cropperData = _this.refs.cropper.getCroppedCanvas().toDataURL("image/jpeg", compressionRatio)
while(cropperData.length/1024 > 500 && compressionRatio > 0.1){
compressionRatio -= 0.1;
cropperData = _this.refs.cropper.getCroppedCanvas().toDataURL("image/jpeg", compressionRatio)
}
_this.state.cropperData = null;
_this.refs.cropper.clear();//去除裁剪框
//_this.refs.cropper.destroy();//需要修改npm包
_this.upload(cropperData);//向服务器提交base64图片数据
cropperData = null;
//必须先拿到cropper数据 关闭裁剪框 显示加载框
_this.setState({showCropModal: false})
clearTimeout(tempTimer)
},300)
}
/**
* 在裁剪组件中取消裁剪
* @param
* @return
*/
cancelCrop(){
this.state.cropperData = null;
this.refs.cropper.clear()
this.setState({
showCropModal: false
})
}
参考资料
- 无
感谢阅读,欢迎评论^-^