問(wèn)題來(lái)源:
在進(jìn)行Android camera相關(guān)的開發(fā)時(shí),對(duì)于圖片數(shù)據(jù)不論是緩存在本地磁盤還是上傳到后端,都需要先對(duì)圖片進(jìn)行壓縮處理。但是JPG(JPEG)圖片在壓縮后原圖的EXIF信息也會(huì)丟失。那如果想保留exif數(shù)據(jù)該怎么處理?
關(guān)鍵詞描述
EXIF:可交換圖像文件格式(英語(yǔ):Exchangeable image file format,官方簡(jiǎn)稱Exif),是專門為數(shù)碼相機(jī)的照片設(shè)定的,可以附加于JPEG、TIFF、RIFF等文件之中,可以記錄數(shù)碼照片的屬性信息和拍攝數(shù)據(jù)。比如記錄以下信息:
| 項(xiàng)目 | 資訊(舉例) |
|---|---|
| 制造廠商 | Canon |
| 相機(jī)型號(hào) | Canon EOS-1Ds Mark III |
| 影像方向 | 正常(upper-left) |
| 影像解析度X | 300 |
| 影像解析度Y | 300 |
| 解析度單位 | dpi |
| 軟件 | Adobe Photoshop CS Macintosh |
| 最后異動(dòng)時(shí)間 | 2005:10:06 12:53:19 |
| YCbCrPositioning | 2 |
| 曝光時(shí)間 | 0.00800 (1/125) sec |
| 光圈值 | F22 |
| 拍攝模式 | 光圈優(yōu)先 |
| ISO感光值 | 100 |
| Exif資訊版本 | 30,32,32,31 |
| 影像拍攝時(shí)間 | 2005:09:25 15:00:18 |
| 影像存入時(shí)間 | 2005:09:25 15:00:18 |
| 曝光補(bǔ)償(EV+-) | 0 |
| 測(cè)光模式 | 點(diǎn)測(cè)光(Spot) |
| 閃光燈 | 關(guān)閉 |
| 鏡頭實(shí)體焦長(zhǎng) | 12 mm |
| Flashpix版本 | 30,31,30,30 |
| 影像色域空間 | sRGB |
| 影像尺寸X | 5616 pixel |
| 影像尺寸Y | 3744 pixel |
現(xiàn)已有方案
利用Google提供的 android.support.media.ExifInterface 對(duì)圖片的exif進(jìn)行讀寫設(shè)置
This is a class for reading and writing Exif tags in a JPEG file or a RAW image file.
Supported formats are: JPEG, DNG, CR2, NEF, NRW, ARW, RW2, ORF, PEF, SRW and RAF.
Attribute mutation is supported for JPEG image files.
但是這個(gè)封裝類只提供了 getXXX() 和 setAttributes(String tag, String value) 這種操作單個(gè)屬性的方法,如果想將原圖片文件中的所有exif信息完整復(fù)制到另一個(gè)圖片中會(huì)非常繁瑣。因此有人通過(guò)反射,對(duì)所有屬性名進(jìn)行遍歷,從而實(shí)現(xiàn)了批量操作。也算是一種解決方案,具體如下:
public static void saveExif(String oldFilePath, String newFilePath) throws Exception {
ExifInterface oldExif = new ExifInterface(oldFilePath);
ExifInterface newExif = new ExifInterface(newFilePath);
Class<ExifInterface> cls = ExifInterface.class;
Field[] fields = cls.getFields();
for (int i = 0; i < fields.length; i++) {
String fieldName = fields[i].getName();
if (!TextUtils.isEmpty(fieldName) && fieldName.startsWith("TAG")) {
String fieldValue = fields[i].get(cls).toString();
String attribute = oldExif.getAttribute(fieldValue);
if (attribute != null) {
newExif.setAttribute(fieldValue, attribute);
}
}
}
//將內(nèi)存中的修改寫入磁盤(IO操作)
newExif.saveAttributes();
}
但是以上方案弊端也很明顯,就是需要對(duì)文件進(jìn)行多次IO操作。為什么這么說(shuō)?
首先觀察上面方法中的兩個(gè)參數(shù)都是文件路徑,意思就是我們?cè)谂耐暾胀ㄟ^(guò) onPictureTaken(byte[] data, Camera camera) 回調(diào)方法拿到圖片的 byte[] data 數(shù)據(jù)后的workflow是這樣的:
- 將data緩存到磁盤,路徑為
oldFilePath;(IO) - 將data轉(zhuǎn)換成
bitmap進(jìn)行壓縮、旋轉(zhuǎn)、剪切等操作; - 將處理后的
bitmap緩存到磁盤,路徑為newFilePath;(IO) - 調(diào)用上面的
saveExif(oldFilePath, newFilePath)方法; (IO)
能否只在內(nèi)存中操作?發(fā)現(xiàn)有 ExifInterface (String filename) 和 ExifInterface (InputStream inputStream) 兩種構(gòu)造方法, 所以我嘗試進(jìn)行如下改造:
public static void saveExif(byte[] srcData, byte[] destData) throws Exception {
ExifInterface oldExif = new ExifInterface(new ByteArrayInputStream(srcData));
ExifInterface newExif = new ExifInterface(new ByteArrayInputStream(destData));
...
newExif.saveAttributes();
}
然鵝并沒有什么卵用, 直接拋異常,后研究源碼發(fā)現(xiàn) saveAttributes() 的流程是這樣的:
- 校驗(yàn)構(gòu)造方法中傳入的
fileName是否為空,若為空則拋異常;假設(shè)我們new ExifInterface (“/a/b/picture.jpg”),即fileName為/a/b/picture.jpg,; - 把
/a/b/picture.jpg重命名為/a/b/picture.jpg.tmp; - 新建
/a/b/picture.jpg文件; - 將
/a/b/picture.jpg.tmp文件中的數(shù)據(jù)加上修改后的exif 存入到新建的/a/b/picture.jpg文件中; - 刪除
/a/b/picture.jpg.tmp;
由此可見, saveAttributes() 必然是IO操作,而且對(duì)于EXIF的修改只能使用第一種構(gòu)造方式,即必須傳入文件路徑. 否則必然拋出異常。所以進(jìn)一步改造如下:
public static void saveExif(byte[] srcData, String destFilePath) throws Exception {
ExifInterface oldExif = new ExifInterface(new ByteArrayInputStream(srcData));
ExifInterface newExif = new ExifInterface(destFilePath);
...
newExif.saveAttributes();
}
結(jié)果可行,而且少了一次IO (第一步); 但是我覺得還不夠優(yōu)雅。。。
我的解決方案
我的目標(biāo)是將所有有關(guān)圖片的操作都放到內(nèi)存中完成,最后只緩存一份圖片數(shù)據(jù)。
思路很簡(jiǎn)單,不管是圖片還是其他文件,其本質(zhì)都是格式化的數(shù)據(jù),都有其專用的數(shù)據(jù)結(jié)構(gòu)。那么就去研究下JPG的數(shù)據(jù)結(jié)構(gòu)好了,只要找到 exif 數(shù)據(jù)塊的起始索引,然后從源文件byte[]中復(fù)制插入到目標(biāo)文件byte[]對(duì)應(yīng)位置中不就ok了。

如上圖所示,每一個(gè)JPEG文件的內(nèi)容都開始于一個(gè)二進(jìn)制的值 '0xFFD8', 并結(jié)束與二進(jìn)制值'0xFFD9'. 在JPEG的數(shù)據(jù) 中有好幾種類似于二進(jìn)制 0xFFXX 的數(shù)據(jù), 它們都統(tǒng)稱作 "標(biāo)記", 并且它們代表了一段JPEG的 信息數(shù)據(jù).
0xFFD8 的意思是 SOI圖像起始(Start of image) ,是Jpeg文件的魔數(shù)(Magic Number)。每種格式的文件都有固定的Magic Number,比如.class 字節(jié)碼文件的Magic Number是 “0xCAFEBABE”;基于安全性考慮,Unix like 系統(tǒng)的應(yīng)用程序都是基于Magic Number 來(lái)區(qū)分不同的文件格式,而不是采用用戶可隨意更改的文件擴(kuò)展名。
0xFFD9 則表示 EOI圖像結(jié)束 (End of image).
這兩個(gè)特殊的標(biāo)記的后面都不跟隨數(shù)據(jù), 而其他的標(biāo)記在后面則會(huì)附帶數(shù)據(jù). 標(biāo)記的基本格式如下.
0xFF+標(biāo)記號(hào)(1個(gè)字節(jié))+數(shù)據(jù)大小描述符(2個(gè)字節(jié))+數(shù)據(jù)內(nèi)容(n個(gè)字節(jié))
而對(duì)于EXIF數(shù)據(jù),使用的是APP1標(biāo)記,前兩個(gè)字節(jié)固定為 0xFFE1,后面緊跟著兩個(gè)字節(jié)記錄的是exif數(shù)據(jù)內(nèi)容的 length + 2,假設(shè)這兩個(gè)字節(jié)的值是 24,那么exif數(shù)據(jù)內(nèi)容的長(zhǎng)度就是22字節(jié).
了解了JPG的數(shù)據(jù)格式后,剩下的就是動(dòng)手操作數(shù)組了,找到EXIF在數(shù)組中的起始索引,把它摳出來(lái)插入到新數(shù)組中去!

/**
* 將原圖片中的EXIF復(fù)制到目標(biāo)圖片中
* 僅限JPEG
* @param srcData
* @param destData
* @return
*/
public static byte[] cloneExif(byte[] srcData, byte[] destData) {
if (srcData == null || srcData.length == 0 || destData == null || destData.length == 0) return null;
ImageHeaderParser srcImageHeaderParser = new ImageHeaderParser(srcData);
byte[] srcExifBlock = srcImageHeaderParser.getExifBlock();
if (srcExifBlock == null || srcExifBlock.length <= 4) return null;
LOG.d(TAG, "pictureData src: %1$s KB; dest: %2$s KB", srcData.length / 1024, destData.length / 1024);
LOG.d(TAG, "srcExif: %s B", srcExifBlock.length);
ImageHeaderParser destImageHeaderParser = new ImageHeaderParser(destData);
byte[] destExifBlock = destImageHeaderParser.getExifBlock();
if (destExifBlock != null && destExifBlock.length > 0) {
LOG.d(TAG, "destExif: %s B", destExifBlock.length);
//目標(biāo)圖片中已有exif信息, 需要先刪除
int exifStartIndex = destImageHeaderParser.getExifStartIndex();
//構(gòu)建新數(shù)組
byte[] newDestData = new byte[srcExifBlock.length + destData.length - destExifBlock.length];
//copy 1st block
System.arraycopy(destData, 0, newDestData, 0, exifStartIndex);
//copy 2rd block (exif)
System.arraycopy(srcExifBlock, 0, newDestData, exifStartIndex, srcExifBlock.length);
//copy 3th block
int srcPos = exifStartIndex + destExifBlock.length;
int destPos = exifStartIndex + srcExifBlock.length;
System.arraycopy(destData, srcPos, newDestData, destPos, destData.length - srcPos);
LOG.d(TAG, "output image Data with exif: %s KB", newDestData.length / 1024);
return newDestData;
} else {
LOG.d(TAG, "destExif: %s B", 0);
//目標(biāo)圖片中沒有exif信息
byte[] newDestData = new byte[srcExifBlock.length + destData.length];
//copy 1st block (前兩個(gè)字節(jié))
System.arraycopy(destData, 0, newDestData, 0, 2);
//copy 2rd block (exif)
System.arraycopy(srcExifBlock, 0, newDestData, 2, srcExifBlock.length);
//copy 3th block
int srcPos = 2;
int destPos = 2 + srcExifBlock.length;
System.arraycopy(destData, srcPos, newDestData, destPos, destData.length - srcPos);
LOG.d(TAG, "output image Data with exif: %s KB", newDestData.length / 1024);
return newDestData;
}
}
如此,拿到圖片的 byte[] srcData 數(shù)據(jù)后,整個(gè)workflow就簡(jiǎn)化成:
- 將srcData轉(zhuǎn)換成
bitmap進(jìn)行壓縮、旋轉(zhuǎn)、剪切等操作,后再轉(zhuǎn)成 byte[] destData; - 調(diào)用上面的
cloneExif(srcData, destData)方法,將原圖的exif復(fù)制到壓縮處理后的圖片中; - 將壓縮處理后的含有exif的圖片data 緩存到磁盤;(IO)
只進(jìn)行一次IO操作~