最新更新時間: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
})
}
參考資料
- 無
感謝閱讀,歡迎評論^-^