Prefetching images size without downloading them [entirely] in Swift
source link: https://www.tuicool.com/articles/hit/zIN7fiE
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
The following article was also published on Medium.
Working with custom layouts and remote images can be tricky; it can easily become a chicken-eggs problem where you need the size of your images to reserve the correct space for your layout but you have to download all of them to make it right. It’s a bit mess and even if you use tables / collections you can not do a good prefetching.
Adjusting layout incrementally while you are downloading produce the well know web-like effect where every element become a crazy clown until the end of the download.
Your final result is a poor UX / UI experience and many disappointed users. So, if you are not enough lucky to deal with your backend colleague (or possibly kill your designer) you may be in trouble.
But don’t worry, I’m here to say compromise is possible .
You know, every image is a stream of binary data, well-structured where the size of the canvas, along with some other interesting infos, is made explicit at the very beginning of the file itself: so if you start downloading only a bunch of data just to get these info you can stop the download immediately and obtain the size of your image . Typically this operation require only few bytes (even if your downloaded block maybe larger around 50kb or less) of download per image, regardless of the real file size.
Obviously the structure of these data is strictly related to the format of the image itself. So the first thing we need is to know how to read what is called the header of a file; we’ll do it for the most common web image formats (PNG, JPEG, GIF and BMP) but you can expand it to support even more file types
Each of these formats start with an unique signature which can tell us the kind of format used to encode the data, followed at certain point by another chunk of data with the size of the image canvas. Let’s start exploring it!
PNG
A PNG file consists of a PNG signature followed by a series of chunks ( complete specs are here ). The first 8 bytes of a PNG file always contain the a fixed signature which just indicates that the remainder of the file contains a single PNG image. PNG file is grouped in chunk of data; each chunk of data is composed by 4 parts:
- Length (4 bytes) which defines the length of the chunk
- Type (4 bytes) which defines type of chunk
- Data (variable) which contains the chunk’s data
- CRC (4 bytes) redundancy code to validate the correctness of the data above
The chunk we are interested in is called IHDR
and — as to specs — must be always appear first just after the signature. It contains the following ordered data:
An example of PNG file in an HEX editor. The red section is the IHDR chunk with the width and height field highlighted.
Clearly we are interested to width and height attributes only: they are 4-byte integers (zero is an invalid value). The maximum for each is 231 − 1 in order to accommodate languages that have difficulty with unsigned 4-byte values.
So in order to get the size of a PNG file we just need of 33 bytes regardless the total size of the image.
GIF
GIF is a bitmap image format; it starts with a fixed length header (GIF87a or GIF89a where 7a and 9a identify the version) immediately followed by a fixed length Logical Screen Descriptor giving the size and other characteristics of the logical display.
An example of GIF file with the initial width/height fields just after the GIF signature.
With 10 bytes we are ready to catch the size of a gif even before downloading it. If you are interested in GIF format the original RFC is a great start
JPEG
A JPEG image file can be of two different formats: if we read FF D8 FF E0
signature it’s a JPEG File Interchange Format (the most common), while with FF D8 FF E1
it’s an Exchangeable Image File Format (which is a bit more complicate to parse).
0xFF
byte followed by a byte indicating what kind of marker is it). The frame dimension are located in a segment called
SOF[n]
(which mean Start Of Frame n —
where n means something reserved to JPEG decoder ); there is not a particular order for these segments so we need iterate over our data searching for one of these patterns:
FFC0, FFC1 or FFC2.
The values are big endian, so we may have to reverse the bytes on our system; once we find this frame, we can decode it to find the image height and width.
JPEG File is a bit more complex; we need to find the SFOn frame which is placed without a fixed order. So we should iterate over the date until we found it.
Our Project
Now that we know some dirty secrets of our image formats we can go further by writing a program which act as a generic pre-fetcher. The scope is to provide a GCD based class where you are able to enqueue your request and receive the result in a callback function.
Additionally our class may maintain an internal cache to immediately return already performed url requests ( NSCache
is good enough for our demo but in a real scenario we should keep this data on disk; nothing complex but it’s outside our scope right now).
We can use it to perform fetching in a widely range of cases, like pre-fetching for UITableView or UICollectionView.
Conceptually our project has 3 different entities: a ImageFetcher (which just expose a function to enqueue our request by passing at least url and a callback; internally it also manage the queue) a FetcherOperation (subclass of Operation
, which is responsible of the async data download via URLSessionTask
) and an ImageParser (which evaluate partial data and eventually return format and size of the image).
ImageFetcher
As we said the fetcher is essentially a class which manage a queue of operations, keep a cache and manage an URLSession
to download efficiently our data. This class must be an NSObject
in order to be conform to URLSessionDataDelegate
which is used by the URLSession
instance to report each new portion of data.
public class ImageSizeFetcher: NSObject, URLSessionDataDelegate { /// Callback type alias public typealias Callback = ((Error?, ImageSizeFetcherParser?) -> (Void)) /// URL Session used to download data private var session: URLSession! /// Queue of active operations private var queue = OperationQueue() /// Built-in in memory cache private var cache = NSCache<NSURL,ImageSizeFetcherParser>() /// Request timeout public var timeout: TimeInterval /// Initialize a new fetcher with in memory cache. /// /// - Parameters: /// - configuration: url session configuration /// - timeout: timeout for request, by default is 5 seconds. public init(configuration: URLSessionConfiguration = .ephemeral, timeout: TimeInterval = 5) { self.timeout = timeout super.init() self.session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil) } /// Request for image info at given url. /// /// - Parameters: /// - url: url of the image you want to analyze. /// - force: true to skip cache and force download. /// - callback: completion callback called to give out the result. public func sizeFor(atURL url: URL, force: Bool = false, _ callback: @escaping Callback) { guard force == false, let entry = cache.object(forKey: (url as NSURL)) else { // we don't have a cached result or we want to force download let request = URLRequest(url: url, cachePolicy: .useProtocolCachePolicy, timeoutInterval: self.timeout) let op = ImageSizeFetcherOp(self.session.dataTask(with: request), callback: callback) queue.addOperation(op) return } // return result from cache 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
ImageFetcherOperation is just a subclass of Operation
and its used to encapsulate the logic behind the data download and lookup.
An operation receive data from URLSession
instance from parent class (the fetcher) and attempt to call the ImageParser until a valid result is returned (or it fails with an error). Once a result is provided the operation is immediately cancelled (avoiding the download of further data of the image) and the result is returned to the callback.
internal class ImageSizeFetcherOp: Operation { /// Callback to call at the end of the operation let callback: ImageSizeFetcher.Callback? /// Request data task let request: URLSessionDataTask /// Partial data private(set) var receivedData = Data() /// URL of the operation var url: URL? { return self.request.currentRequest?.url } /// Initialize a new operation for a given url. /// /// - Parameters: /// - request: request to perform. /// - callback: callback to call at the end of the operation. 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) // not enough data collected for anything guard data.count >= 2 else { return } // attempt to parse received data, if enough we can stop download do { if let result = try ImageSizeFetcherParser(sourceURL: self.url!, data) { self.callback?(nil,result) self.cancel() } // nothing received, continue accumulating data } catch let err { // parse has failed self.callback?(err,nil) self.cancel() } } func onEndWithError(_ error: Error?) { // download has failed, return to callback with the description of the error self.callback?(ImageParserErrors.network(error),nil) self.cancel() } }
ImageParser
The parser is the core of the the project; it takes a bunch of data and attempt to parse it in one of the supported formats.
First of all it checks the file signature at the very beginning of the stream; if no known signature has been found, parent operation will be cancelled and an unsupportedFormat
error is therefore returned.
When a known signature has been found the second check is performed on data length: only when enough data for the format is collected parser can go further and attempt to retrive the size of the frame (until then it returns nil
and operation continues to accumulate image data from server).
If enough data is available the last step is to parse the stream and search for frame size; obliviously it’s strictly related to the file format as we seen above. Code is pretty straightforward and fast: except for JPEG (where we need to make a small iteration) all the remaining formats had fixed length fields.
The code below illustrate this class:
/// Parser is the main core class which parse collected partial data and attempts /// to get the image format along with the size of the frame. public class ImageSizeFetcherParser { /// Supported image formats public enum Format { case jpeg, png, gif, bmp /// Minimum amount of data (in bytes) required to parse successfully the frame size. /// When `nil` it means the format has a variable data length and therefore /// a parsing operation is always required. 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 } } /// Attempt to recognize a known signature from collected partial data. /// /// - Parameter data: partial data from server. /// - Throws: throw an exception if file is not supported. internal init(fromData data: Data) throws { // Evaluate the format of the image 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 } } } /// Recognized image format public let format: Format /// Recognized image size public let size: CGSize /// Source image url public let sourceURL: URL /// Data downloaded to parse header informations. public private(set) var downloadedData: Int /// Initialize a new parser from partial data from server. /// /// - Parameter data: partial data from server. /// - Throws: throw an exception if file format is not supported by the parser. internal init?(sourceURL: URL, _ data: Data) throws { let imageFormat = try ImageSizeFetcherParser.Format(fromData: data) // attempt to parse signature // if found attempt to parse the frame size guard let size = try ImageSizeFetcherParser.imageSize(format: imageFormat, data: data) else { return nil // not enough data to format } // found! self.format = imageFormat self.size = size self.sourceURL = sourceURL self.downloadedData = data.count } /// Parse collected data from a specified file format and attempt to get the size of the image frame. /// /// - Parameters: /// - format: format of the data. /// - data: collected data. /// - Returns: size of the image, `nil` if cannot be evaluated with collected data. /// - Throws: throw an exception if parser fail or data is corrupted. private static func imageSize(format: Format, data: Data) throws -> CGSize? { if let minLen = format.minimumSample, data.count <= minLen { return nil // not enough data collected to evaluate png size } 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 // check for valid JPEG image // http://www.fastgraph.com/help/jpeg_header_format.html guard data[i] == 0xFF && data[i+1] == 0xD8 && data[i+2] == 0xFF && data[i+3] == 0xE0 else { throw ImageParserErrors.unsupportedFormat // Not a valid SOI header } i += 4 // Check for valid JPEG header (null terminated 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 // Not a valid JFIF string } // Retrieve the block length of the first block since the // first block will not contain the size of file var block_length: UInt16 = UInt16(data[i]) * 256 + UInt16(data[i+1]) repeat { i += Int(block_length) //I ncrease the file index to get to the next block if i >= data.count { // Check to protect against segmentation faults return nil } if data[i] != 0xFF { //Check that we are truly at the start of another block return nil } if data[i+1] >= 0xC0 && data[i+1] <= 0xC3 { // if marker type is SOF0, SOF1, SOF2 // "Start of frame" marker which contains the file size 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 { // Skip the block marker i+=2; block_length = UInt16(data[i]) * 256 + UInt16(data[i+1]); // Go to the next block } } while (i < data.count) return nil } } }
Now you are able to call image parser just doing:
let imageURL: URL = ... fetcher.sizeFor(atURL: $0.url) { (err, result) in // error check... print("Image size is \(NSStringFromCGSize(result.size))") }
Conclusion
How much you can save? Due the nature of JPG format the interesting segment has not a fixed position so data download maybe variable; moreover you are not in control of the downloaded packet data length most of the time you are receiving more data than you really need (but even so you do not need to fetch much of the image to find the size).
However, in most cases the the downloaded data is below 50 KB.
Where we can use this class? Probably the best place to use this fetcher is inside the UICollectionView/UITableView pre-fetching methods introduced in iOS 10: with this function you are able to fetch the size of the image prior showing it to user.
The project, along with a CocoaPods/SPM structure is available on GitHub page . Fell free to use it for your needs and tell me if you like it. Any PR is welcomed!
Bonus Track
If you are interested in learning more about file formats Synalyze It! Pro is a great software which also highlights the grammar of lots of popular file formats!
ImageSizeFetcher is a project and an article I wrote about how to get the size of the popular image formats by fetching as little as needed avoiding consuming both bandwith and time; in pure #Swift . https://t.co/MZhcjLDnf2 #swiftlang #iosdev #macdev #indiedev pic.twitter.com/XjwychTUoJ
— daniele margutti (@danielemargutti) September 9, 2018
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK