获取网络图片的大小

6,873 阅读6分钟

根据网络图片来自定义布局是一件很蛋疼的事情,如果需要根据图片的大小来决定页面控件的布局,或者说在一个 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 的方案。毕竟轻客户端才是王道啊😂