在react的移動端項目中實現手機拍攝圖片、壓縮、預覽、裁剪、上傳的實現方案

最新更新時間: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
	})
}

參考資料

感謝閱讀,歡迎評論^-^

打賞我吧^-^

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章