獲取網路圖片的大小

CepheusSun發表於2019-03-04

獲取網路圖片的大小

根據網路圖片來自定義佈局是一件很蛋疼的事情,如果需要根據圖片的大小來決定頁面控制元件的佈局,或者說在一個 TableView 上面有多張大小不一的圖片,我們要根據圖片的大小的決定 Cell 的高度。玩過 Tumblr 的人可能都知道,不像微信微博之類的 App,Tumblr 在圖片佈局的時候是完全按照圖片的大小來的(本來想截個圖的,找了半天,全是不能放出來的內容?)。在研究了 TMTumblrSDK 之後發現,著名圖片視訊類部落格 App Tumblr 有一套自己的解決方案,我們先來看看通過 TMTumblrSDK 我們拿到的原始資料是什麼樣的:

{
	"photoset_layout" = 1221121;   
}
複製程式碼
"original_size" = {
	height = 1278;
	url = "https://66.media.tumblr.com/this_is_iamge_url.jpg";
	width = 960;
};
 "alt_sizes" = ({
    height = 1278;
    url = "https://66.media.tumblr.com/this_is_iamge_url_1280.jpg";
	width = 960;
	},
    {
    height = 852;
    url = "https://66.media.tumblr.com/this_is_iamge_url_640.jpg";
    width = 640;
    },
... 
複製程式碼

每一篇 photoSet 的博文都帶有以上的欄位。不用試了, URL 都處理過?。

不難理解 Tumblr 在 Sever 端返回圖片 URL 的時候,就直接給出了圖片的大小,已經相應縮圖及其大小。另外 photoset_layout 這個欄位表示一共 7 行每行分別是 1,2,2,1,1,2,1 張圖片。

真好! 這完全符合輕客戶端的設計,客戶端只需要拿到資料,然後佈局就可以了。不需要再對原始資料做其他的計算。

如果世界都是這樣運轉的,那就完美的,可惜。記得很久以前接到過一個專案,之前是用 Cordova 寫的,需要全部改成 native 實現,我們知道前端的佈局是彈性的,而 iOS 中的佈局是居於 Frame 的。在做到某個詳情頁面的時候,我拿到了幾個圖片的 URL,可惡的是他們的高度還很不一樣....

終於要開始正文了...

都知道,圖片實際上都是結構完好的二進位制的資料流,圖片檔案的頭部儲存了這個圖片的相關資訊。從中我們可以讀取到尺寸、大小、格式等相關資訊。因此,如果只下載圖片的頭部資訊,就可以知道這個圖片的大小。而相對於下載整張圖片這個操作只需要很少的位元組。

很明顯,這些資料的結構是跟圖片格式相關的,我們要做的首先就是讀取圖片的頭部資訊。

這些格式的檔案的開始都是相對應的簽名資訊,這個簽名資訊告訴我們這個檔案編碼的格式,在這段簽名資訊之後就是我們需要的圖片大小資訊了。

PNG

WIKI 上可以看到 PNG 影象格式檔案由一個 8 位元組的 PNG 檔案標識域和 3 個以上的後續資料塊組成。PNG 檔案的前 8 個位元組總是包含了一個固定的簽名,它是用來標識這個檔案的其餘部分是一個 PNG 的影象。

PNG定義了兩種型別的資料塊:一種是PNG檔案必須包含、讀寫軟體也都必須要支援的關鍵塊(critical chunk);另一種叫做輔助塊(ancillary chunks),PNG允許軟體忽略它不認識的附加塊。這種基於資料塊的設計,允許PNG格式在擴充套件時仍能保持與舊版本相容

關鍵資料塊中有4個標準資料塊:

  • 檔案頭資料塊IHDR(header chunk):包含有影象基本資訊,作為第一個資料塊出現並只出現一次。
  • 調色盤資料塊PLTE(palette chunk):必須放在影象資料塊之前。
  • 影象資料塊IDAT(image data chunk):儲存實際影象資料。PNG資料允許包含多個連續的影象資料塊。
  • 影象結束資料IEND(image trailer chunk):放在檔案尾部,表示PNG資料流結束。

我們需要關心的是 IHDR ,也就是檔案頭資料塊

png

我們只關心 WIDTH 以及 HEIGHT 兩個資訊,因此,要獲得 PNG 檔案的寬高資訊,只需要 33 位元組。

GIF

GIF 是一種點陣圖圖形檔案格式。他以固定長度的頭開始,緊接著是固定長度的邏輯螢幕描述符用來標記圖片的邏輯顯示大小及其他特徵。

gif

只需要 10 個位元組我們就能夠獲取到 GIF 圖片的大小了

JPEG

JPEG 格式的檔案有兩種不同的格式:

  • 檔案交換格式(以 FF D8 FF E0 開始)
  • 可交換影象檔案格式 (以 FF D8 FF E1 開始)

由於第一種是最為通用的圖片格式,這篇文章只會處理這種型別的圖片格式。JPEG 格式的檔案由一系列資料段組成,每格段都是由 0xFF 開頭的。他之後的一個位元組用來顯示這個資料段的型別。frame 資訊的資料段位於一個叫做 SOF[n] 的區段中,因為這些資料段沒有特定的順序,要找到 SOF[n] 我們必須要跳過它前面的標記, 所以我們需要根據前面的資料段的長度來跳過這些資料段。知道我們找到了跟 frame 相關的標記(FFC0、FFC1、FFC2)。

jpg

程式碼實現

既然我們已經知道了影象格式的一些內部機制,我們就可以寫一個類來預載入圖片的大小。此外我們還需要在這個類中維護一個 NSCache 來快取已經預載入 frame 的 url。在實際情況中我們應該將這個東西儲存在磁碟中。

做這個需求我們需要至少三個類:

  • ImageFetcher: 實際使用的類。管理操作佇列,快取,管理 URLSession
  • FetcherOperation:通過 URLSessionTask 來執行一步下載的任務
  • ImageParser:分析部分資料,並返回圖片的格式和大小資訊。

ImageFetcher

如上文所說,這個類是用來管理操作佇列,操作快取、管理 URLSession 的。

public class ImageSizeFetcher: NSObject, URLSessionDataDelegate {
	
	/// Callback type alias
	public typealias Callback = ((Error?, ImageSizeFetcherParser?) -> (Void))
	
	/// 用來下載資料的 URLSession
	private var session: URLSession!
	
	/// Queue of active operations
	private var queue = OperationQueue()
	
	/// 內建的快取
	private var cache = NSCache<NSURL,ImageSizeFetcherParser>()
	
	/// 請求超時的時間
	public var timeout: TimeInterval
	
	/// 初始化方法
	public init(configuration: URLSessionConfiguration = .ephemeral, timeout: TimeInterval = 5) {
		self.timeout = timeout
		super.init()
		self.session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
	}
	
	/// 請求圖片資訊的方法
	///
	/// - Parameters:
	///   - url: 圖片的 URL
	///   - force: 強制從網路獲取(不實用快取的大小)
	///   - callback: 回撥
	public func sizeFor(atURL url: URL, force: Bool = false, _ callback: @escaping Callback) {
		guard force == false, let entry = cache.object(forKey: (url as NSURL)) else {
            // 不需要快取,或者需要直接獲取
			let request = URLRequest(url: url, cachePolicy: .useProtocolCachePolicy, timeoutInterval: self.timeout)
			let op = ImageSizeFetcherOp(self.session.dataTask(with: request), callback: callback)
			queue.addOperation(op)
			return
		}
		// 回撥快取的資料
		callback(nil,entry)
	}
	
	//MARK: - Helper Methods
	
	private func operation(forTask task: URLSessionTask?) -> ImageSizeFetcherOp? {
		return (self.queue.operations as! [ImageSizeFetcherOp]).first(where: { $0.url == task?.currentRequest?.url })
	}
	
	//MARK: - URLSessionDataDelegate
	
	public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
		operation(forTask: dataTask)?.onReceiveData(data)
	}
	
	public func urlSession(_ session: URLSession, task dataTask: URLSessionTask, didCompleteWithError error: Error?) {
		operation(forTask: dataTask)?.onEndWithError(error)
	}
	
}
複製程式碼

ImageFetcherOperation

這個類是 Operation 的子類,他用來執行資料下載的邏輯。

一個 Operation 從 URLSession 中獲取資料。當接收到資料的時候馬上呼叫 ImageParser,當獲取到有效的結果的時候,取消下載任務,並將結果回撥回去。

internal class ImageSizeFetcherOp: Operation {
	
	let callback: ImageSizeFetcher.Callback?
	
	let request: URLSessionDataTask
	
	private(set) var receivedData = Data()
	
	var url: URL? {
		return self.request.currentRequest?.url
	}
	
	init(_ request: URLSessionDataTask, callback: ImageSizeFetcher.Callback?) {
		self.request = request
		self.callback = callback
	}
	
	///MARK: - Operation Override Methods
	override func start() {
		guard !self.isCancelled else { return }
		self.request.resume()
	}
	
	override func cancel() {
		self.request.cancel()
		super.cancel()
	}
	
	//MARK: - Internal Helper Methods
	func onReceiveData(_ data: Data) {
		guard !self.isCancelled else { return }
		self.receivedData.append(data)
		
		// 資料太少
		guard data.count >= 2 else { return }
		
		// 嘗試解析資料,如果得到了足夠的資訊,取消任務
		do {
			if let result = try ImageSizeFetcherParser(sourceURL: self.url!, data) {
				self.callback?(nil,result)
				self.cancel()
			}
		} catch let err {
			self.callback?(err,nil)
			self.cancel()
		}
	}
	
	func onEndWithError(_ error: Error?) {
		self.callback?(ImageParserErrors.network(error),nil)
		self.cancel()
	}
	
}
複製程式碼

ImageParser

它是這個元件的核心,他拿到 Data ,然後用支援的格式解析資料。

首先在流開始的時候檢查檔案的簽名,如果沒有找到,返回不支援的格式異常。

確認簽名之後,檢查資料的長度,只有拿到足夠長度的資料之後,解析起才會進一步檢索 frame。

如果有足夠的資料,開始檢索 frame。這個過程是非常快的。因為除了 JPEG 以外,所有的格式都只需要拿到固定的長度。

因為 JPEG 的格式問題,他需要在內部進行一下遍歷。

public class ImageSizeFetcherParser {
	
	/// 支援的圖片型別
	public enum Format {
		case jpeg, png, gif, bmp
		
        // 需要下載的最小的位元組數。當獲取到了該長度的位元組之後,就回停止下載操作
        // 為 nil 表示這個檔案格式需要下載的長度不固定。
		var minimumSample: Int? {
			switch self {
			case .jpeg: return nil // will be checked by the parser (variable data is required)
			case .png: 	return 25
			case .gif: 	return 11
			case .bmp:	return 29
			}
		}
		
		/// 用來識別檔案格式
		///
		/// - Throws: 如果沒有支援的格式,就跑出異常
		internal init(fromData data: Data) throws {
			var length = UInt16(0)
			(data as NSData).getBytes(&length, range: NSRange(location: 0, length: 2))
			switch CFSwapInt16(length) {
			case 0xFFD8:	self = .jpeg
			case 0x8950:	self = .png
			case 0x4749:	self = .gif
			case 0x424D: 	self = .bmp
			default:		throw ImageParserErrors.unsupportedFormat
			}
		}
	}
	
	public let format: Format
	
	public let size: CGSize
	
	public let sourceURL: URL
	
	public private(set) var downloadedData: Int
	
	internal init?(sourceURL: URL, _ data: Data) throws {
		let imageFormat = try ImageSizeFetcherParser.Format(fromData: data) // 獲取圖片格式
		// 如果成功的獲取到了圖片格式,就去獲取 frame
		guard let size = try ImageSizeFetcherParser.imageSize(format: imageFormat, data: data) else {
			return nil
		}
		// 找到了圖片的大小
		self.format = imageFormat
		self.size = size
		self.sourceURL = sourceURL
		self.downloadedData = data.count
	}
	
	// 獲取圖片的大小
	private static func imageSize(format: Format, data: Data) throws -> CGSize? {
		if let minLen = format.minimumSample, data.count <= minLen {
			return nil 
		}
		
		switch format {
		case .bmp:
			var length: UInt16 = 0
			(data as NSData).getBytes(&length, range: NSRange(location: 14, length: 4))
			
			var w: UInt32 = 0; var h: UInt32 = 0;
			(data as NSData).getBytes(&w, range: (length == 12 ? NSMakeRange(18, 4) : NSMakeRange(18, 2)))
			(data as NSData).getBytes(&h, range: (length == 12 ? NSMakeRange(18, 4) : NSMakeRange(18, 2)))
			
			return CGSize(width: Int(w), height: Int(h))
			
		case .png:
			var w: UInt32 = 0; var h: UInt32 = 0;
			(data as NSData).getBytes(&w, range: NSRange(location: 16, length: 4))
			(data as NSData).getBytes(&h, range: NSRange(location: 20, length: 4))
			
			return CGSize(width: Int(CFSwapInt32(w)), height: Int(CFSwapInt32(h)))
			
		case .gif:
			var w: UInt16 = 0; var h: UInt16 = 0
			(data as NSData).getBytes(&w, range: NSRange(location: 6, length: 2))
			(data as NSData).getBytes(&h, range: NSRange(location: 8, length: 2))
			
			return CGSize(width: Int(w), height: Int(h))
			
		case .jpeg:
			var i: Int = 0
			// 檢查 JPEG 是不是 檔案交換型別(SOI)
			guard data[i] == 0xFF && data[i+1] == 0xD8 && data[i+2] == 0xFF && data[i+3] == 0xE0 else {
				throw ImageParserErrors.unsupportedFormat //不是 SOI
			}
			i += 4
			
            // 確定是 JFIF 型別
			guard data[i+2].char == "J" &&
				data[i+3].char == "F" &&
				data[i+4].char == "I" &&
				data[i+5].char == "F" &&
				data[i+6] == 0x00 else {
					throw ImageParserErrors.unsupportedFormat
			}
			
			var block_length: UInt16 = UInt16(data[i]) * 256 + UInt16(data[i+1])
			repeat {
				i += Int(block_length) 
				if i >= data.count { 
					return nil
				}
				if data[i] != 0xFF { 
					return nil
				}
				if data[i+1] >= 0xC0 && data[i+1] <= 0xC3 {  // 找到了 C0 C1 C2 C3
					var w: UInt16 = 0; var h: UInt16 = 0;
					(data as NSData).getBytes(&h, range: NSMakeRange(i + 5, 2))
					(data as NSData).getBytes(&w, range: NSMakeRange(i + 7, 2))
					
					let size = CGSize(width: Int(CFSwapInt16(w)), height: Int(CFSwapInt16(h)) );
					return size
				} else {
					i+=2;
					block_length = UInt16(data[i]) * 256 + UInt16(data[i+1]);  
				}
			} while (i < data.count)
			return nil
		}
	}
	
}
複製程式碼

現在只需要這樣就能獲取到圖片的大小了:

let imageURL: URL = ...
fetcher.sizeFor(atURL: $0.url) { (err, result) in
  print("Image size is \(NSStringFromCGSize(result.size))")
}
複製程式碼

總結

還是強烈建議使用 Tumblr 的方案。畢竟輕客戶端才是王道啊?

相關文章