获取网络图片的大小

根据网络图片来自定义布局是一件很蛋疼的事情,如果需要根据图片的大小来决定页面控件的布局,或者说在一个 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,可恶的是他们的高度还很不一样....

都知道,图片实际上都是结构完好的二进制的数据流,图片文件的头部存储了这个图片的相关信息。从中我们可以读取到尺寸、大小、格式等相关信息。因此,如果只下载图片的头部信息,就可以知道这个图片的大小。而相对于下载整张图片这个操作只需要很少的字节。

很明显,这些数据的结构是跟图片格式相关的,我们要做的首先就是读取图片的头部信息。

这些格式的文件的开始都是相对应的签名信息,这个签名信息告诉我们这个文件编码的格式,在这段签名信息之后就是我们需要的图片大小信息了。

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

只需要 10 个字节我们就能够获取到 GIF 图片的大小了

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:分析部分数据,并返回图片的格式和大小信息。

如上文所说,这个类是用来管理操作队列,操作缓存、管理 URLSession 的。

public class ImageSizeFetcher: NSObject, URLSessionDataDelegate { public typealias Callback = ((Error?, ImageSizeFetcherParser?) -> (Void)) private var session: URLSession! 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) } 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) } private func operation(forTask task: URLSessionTask?) -> ImageSizeFetcherOp? { return (self.queue.operations as! [ImageSizeFetcherOp]).first(where: { $0.url == task?.currentRequest?.url }) } 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) } }
复制代码

这个类是 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 } override func start() { guard !self.isCancelled else { return } self.request.resume() } override func cancel() { self.request.cancel() super.cancel() } 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() } }
复制代码

它是这个组件的核心,他拿到 Data ,然后用支持的格式解析数据。

首先在流开始的时候检查文件的签名,如果没有找到,返回不支持的格式异常。

确认签名之后,检查数据的长度,只有拿到足够长度的数据之后,解析起才会进一步检索 frame。

如果有足够的数据,开始检索 frame。这个过程是非常快的。因为除了 JPEG 以外,所有的格式都只需要拿到固定的长度。

因为 JPEG 的格式问题,他需要在内部进行一下遍历。

public class ImageSizeFetcherParser { public enum Format { case jpeg, png, gif, bmp var minimumSample: Int? { switch self { case .jpeg: return nil case .png: return 25 case .gif: return 11 case .bmp:return 29 } } 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) 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 guard data[i] == 0xFF && data[i+1] == 0xD8 && data[i+2] == 0xFF && data[i+3] == 0xE0 else { throw ImageParserErrors.unsupportedFormat } i += 4 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 { 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 的方案。毕竟轻客户端才是王道啊😂

首页 - Wiki
Copyright © 2011-2024 iteam. Current version is 2.125.0. UTC+08:00, 2024-05-07 01:34
浙ICP备14020137号-1 $访客地图$