31

Prefetching images size without downloading them [entirely] in Swift

 5 years ago
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:

uaeYbyz.png!web

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.

AJzuUjR.png!web

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).

We’re dealing with the first one; a JPEG file consists of a sequence of segments, each beginning with a marker (which is marked with 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.

nMrIZvA.png!web

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK