获取网络图片的大小

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,968评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,601评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,220评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,416评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,425评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,144评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,432评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,088评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,586评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,028评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,137评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,783评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,343评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,333评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,559评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,595评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,901评论 2 345

推荐阅读更多精彩内容