一、背景

日常开发过程中,常常需求在各类型控件上加载显现图画orGIF,例如:UIImageView、UIBtton、NSImageView、NSButton等等。
这时候有个网络图画库就会便当太多太多,很多人这时候会说,关于这块其实网络上有很多,比方 Kingfisher、 YYWebImage等等。

0 0. 结构由来,

  • 本来之前呢只是想完成一个怎么播映GIF,所以乎就出现第一版对任意控件完成播映GIF功用,这边只需求支撑 AsAnimatable 即可快速达到支撑播映GIF功用;
  • 后边Boss居然又说需求对GIF图支撑注入滤镜功用,所以乎又修改底层,对播映的GIF图完成滤镜功用,所以之前写的滤镜库 Harbeth 行将闪亮登场;
  • 然后Boss又说,主页banner需求图画和GIF混合显现,索性就又来简略封装显现网络图画,然后根据 AssetType 来区分是属于网图还是GIF图,以达到混合现实网络图画和网络GIF图以及本地图画和本地GIF混合播映功用;
  • 起初也只是简略的去下载资源Data用于显现图画,这时候boss又要搞事情了,图画显现有点慢,所以乎又开端写网络下载模块 DataDownloader 和磁盘缓存模块 Cached ,关于已下载的图画存储于磁盘缓存便利后续再次显现,同样的网络链接地址一起下载时不会重复下载,下载完成后一致分发呼应,关于下载部分资源进行断点续载功用;
  • 渐渐越写越发现这玩意不便是一个图画库嘛,so 它就这么的孕育而生了!!!

补白:作为参阅目标,当然这里面会有一些 Kingfisher 的影子,so 再次感谢猫神!!也学到不少新东西,Thanks!

先贴地址:github.com/yangKJ/Imag…

轻量化滤镜图像和GIF框架分享到你~

完成计划

这边首要便是分为以下几大模块,网络下载模块、资源缓存模块、GIF播映模块、控件展示模块 以及 装备模块等;

这边关于资源缓存模块,已独立封装成库 Lemons 来运用,支撑磁盘和内存存储,一起也会对磁盘数据进行时间过期和达到最大缓存空间的自动清理。

怎么播映GIF

关于这块,中心其实便是运用CADisplayLink不断刷新和更新GIF帧图,然后对不同的控件去设置显现图画资源;

首要便是针对不同目标设置显现内容:

extension AsAnimatable {
    /// Setting up what is currently showing.
    @inline(__always) func setContentImage(_ image: C7Image?, other: ImageX.Others?) {
        switch self {
        case var container_ as ImageContainer:
            container_.image = image
        #if canImport(AppKit) && !targetEnvironment(macCatalyst)
        case var container_ as NSButtonContainer:
            guard let other = other else {
                return
            }
            switch Others.NSButtonKey(rawValue: other.key) {
            case .none:
                break
            case .image:
                container_.image = image
            case .alternateImage:
                container_.alternateImage = image
            }
        #endif
        #if canImport(UIKit) && !os(watchOS)
        case var container_ as UIButtonContainer:
            guard let other = other else {
                return
            }
            switch Others.UIButtonKey(rawValue: other.key) {
            case .none:
                break
            case .image:
                if let state = other.value as? UIControl.State {
                    container_.setImage(image, for: state)
                    let (_, backImage) = container_.cacheImages[state.rawValue] ?? (nil, nil)
                    container_.cacheImages[state.rawValue] = (image, backImage)
                }
            case .backgroundImage:
                if let state = other.value as? UIControl.State {
                    container_.setBackgroundImage(image, for: state)
                    let (image_, _) = container_.cacheImages[state.rawValue] ?? (nil, nil)
                    container_.cacheImages[state.rawValue] = (image_, image)
                }
            }
        case var container_ as UIImageViewContainer:
            guard let other = other else {
                return
            }
            switch Others.UIImageViewKey(rawValue: other.key) {
            case .none:
                break
            case .image:
                container_.image = image
            case .highlightedImage:
                container_.highlightedImage = image
            }
        #endif
        #if canImport(WatchKit)
        case var container_ as WKInterfaceImageContainer:
            container_.image = image
            container_.setImage(image)
        #endif
        default:
            #if !os(macOS)
            //self.layer.setNeedsDisplay()
            self.layer.contents = image?.cgImage
            #endif
        }
    }
}

现在已对常用控件完成,

  • UIImageView:imagehighlightedImage
  • NSImageVIew:image
  • UIButton:imagebackgroundImage
  • NSButton:imagealternateImage
  • WKInterfaceImage:image

关于UIView没有上述属性显现,so 这边对layer.contents设置也是同样能达到该作用。

怎么下载网络资源

关于网络图画显现,不行获取的便是关于资源的下载。

最开端的简略版,

let task = URLSession.shared.dataTask(with: url) { (data, _, error) in
    switch (data, error) {
    case (.none, let error):
        failed?(error)
    case (let data?, _):
        DispatchQueue.main.async {
            self.displayImage(data: data, filters: filters, options: options)
        }
        let zipData = options.cacheDataZip.compressed(data: data)
        let model = CacheModel(data: zipData)
        storager.storeCached(model, forKey: key, options: options.cacheOption)
    }
}
task.resume()

鉴于boss说的显现有点慢,能优化不。所以开端就对网络下载模块开端优化,网络数据同享和断点续下功用就孕育而生,后续再来弥补分片下载功用,进一步提高网络下载速率。

网络同享

  • 关于网络同享,这边其实便是选用一个单例来设计,然后对需求下载的资源和回调呼应进行存储,以链接地址md5作为key来办理查找,当数据下载回来之后,别离分发给回调呼应即可,一起删去缓存的下载器和回调呼应目标;
struct Networking {
    typealias DownloadResultBlock = ((Result<DataResult, Error>) -> Void)
    typealias DownloadProgressBlock = ((_ currentProgress: CGFloat) -> Void)
    static let shared = Networking()
    private init() { }
    @ImageX.Locked var downloaders = [String: DataDownloader]()
    @ImageX.Locked var cacheCallBlocks = [(key: String, block: (download: DownloadResultBlock, progress: DownloadProgressBlock?))]()
    /// Add network download data task.
    /// - Parameters:
    ///   - url: The link url.
    ///   - progressBlock: Network data task download progress.
    ///   - downloadBlock: Download callback response.
    ///   - retry: Network max retry count and retry interval.
    ///   - timeoutInterval: The timeout interval for the request. Defaults to 20.0
    ///   - interval: Network resource data download progress response interval.
    /// - Returns: The data task.
    @discardableResult func addDownloadURL(_ url: URL,
                                           progressBlock: DownloadProgressBlock? = nil,
                                           downloadBlock: @escaping DownloadResultBlock,
                                           retry: ImageX.DelayRetry = DelayRetry.max3s,
                                           timeoutInterval: TimeInterval = 20,
                                           interval: TimeInterval = 0.02) -> URLSessionDataTask {
        let key = Lemons.CryptoType.md5.encryptedString(with: url.absoluteString)
        self.cacheCallBlocks.append((key, (downloadBlock, progressBlock)))
        if let downloader = self.downloaders[key] {
            return downloader.task
        }
        var request = URLRequest(url: url, timeoutInterval: timeoutInterval)
        request.httpShouldUsePipelining = true
        request.cachePolicy = .reloadIgnoringLocalCacheData
        if #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) {
            request.allowsConstrainedNetworkAccess = false
        }
        let downloader = DataDownloader(request: request, named: key, retry: retry, interval: interval) {
            for call in cacheCallBlocks where key == call.key {
                switch $0 {
                case .downloading(let currentProgress):
                    let type = AssetType(data: $1)
                    let rest = DataResult(key: key, url: url, data: $1!, response: $2, type: type, downloadStatus: .downloading)
                    call.block.progress?(currentProgress)
                    call.block.download(.success(rest))
                case .complete:
                    let type = AssetType(data: $1)
                    let rest = DataResult(key: key, url: url, data: $1!, response: $2, type: type, downloadStatus: .complete)
                    call.block.progress?(1.0)
                    call.block.download(.success(rest))
                case .failed(let error):
                    call.block.download(.failure(error))
                case .finished(let error):
                    call.block.download(.failure(error))
                }
            }
            switch $0 {
            case .complete, .finished:
                self.removeDownloadURL(with: key)
            case .failed, .downloading:
                break
            }
        }
        self.downloaders[key] = downloader
        return downloader.task
    }
    /// Remove the download data task.
    /// - Parameter url: The link url.
    func removeDownloadURL(with url: URL) {
        let key = Lemons.CryptoType.md5.encryptedString(with: url.absoluteString)
        removeDownloadURL(with: key)
    }
    /// No other callbacks waiting, we can clear the task now.
    func removeDownloadURL(with key: String) {
        self.downloaders[key]?.cancelTask()
        self.downloaders.removeValue(forKey: key)
        self.cacheCallBlocks.removeAll { $0.key == key }
    }
}

断点续下

  • 关于断点续下功用,这边是选用文件 Files 来实时写入存储已下载的资源,下载再下载到同样数据时间,即先取出上次现已下载数据,然后从该位置再次下载未下载完好的数据资源即可。
final class DataDownloader: NSObject {
    enum Disposition {
        case downloading(CGFloat)
        case complete
        case failed(Error)
        case finished(Error)
    }
    private(set) var task: URLSessionDataTask!
    private(set) var session: URLSession?
    private(set) var retry: DelayRetry
    private(set) var request: URLRequest
    private(set) var outputStream: OutputStream?
    private(set) var lastDate: Date!
    /// Downloaded raw data of current task.
    private(set) var mutableData: Data!
    /// The downloaded part.
    private(set) var offset: Int64 = 0
    /// Write to the resource file object.
    private(set) var files: Files!
    /// Network resource data download progress response interval.
    private(set) var interval: TimeInterval
    typealias DownloadBlock = ((_ state: Disposition, _ data: Data?, _ response: URLResponse?) -> Void)
    let completionHandler: DownloadBlock
    init(request: URLRequest, named: String, retry: DelayRetry, interval: TimeInterval, completionHandler: @escaping DownloadBlock) {
        self.retry = retry
        self.completionHandler = completionHandler
        self.request = request
        self.interval = interval
        super.init()
        do {
            self.files = try Files.init(named: named)
        } catch {
            self.result(data: nil, response: nil, state: .finished(error))
            return
        }
        self.setupDataTask()
    }
    deinit {
        session?.invalidateAndCancel()
    }
    func setupDataTask() {
        self.reset()
        self.session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
        self.task = session?.dataTask(with: request)
        self.task.resume()
        self.retry.increaseRetryCount()
    }
    func cancelTask() {
        self.session?.invalidateAndCancel()
        if self.task.state != .canceling {
            self.task.cancel()
        }
    }
}
extension DataDownloader {
    private func reset() {
        self.mutableData = Data()
        self.lastDate = Date()
        self.session?.invalidateAndCancel()
        self.offset = self.files.fileCurrentBytes()
        if self.offset > 0 {
            if let data = self.files.readData() {
                self.mutableData.append(data)
                let requestRange = String(format: "bytes=%llu-", self.offset)
                self.request.addValue(requestRange, forHTTPHeaderField: "Range")
            } else {
                self.offset = 0
                try? self.files.removeFileItem()
            }
        }
    }
    private func result(data: Data?, response: URLResponse?, state: Disposition) {
        switch state {
        case .downloading, .complete:
            if let data = data, data.isEmpty == false {
                self.completionHandler(state, data, response)
            } else {
                self.completionHandler(.failed(invalidDataError()), data, response)
            }
        case .finished:
            self.completionHandler(state, data, response)
        case .failed:
            self.retry.retry(task: task) { [weak self] state_ in
                switch state_ {
                case .retring:
                    self?.setupDataTask()
                case .stop:
                    self?.completionHandler(state, data, response)
                }
            }
        }
    }
    private func didReceiveData(data: Data, dataTask: URLSessionDataTask) {
        self.mutableData.append(data)
        if canDownloading() {
            let receiveBytes = dataTask.countOfBytesReceived + offset
            let allBytes = dataTask.countOfBytesExpectedToReceive + offset
            let currentProgress = min(max(0, CGFloat(receiveBytes) / CGFloat(allBytes)), 1)
            result(data: mutableData, response: dataTask.response, state: .downloading(currentProgress))
        }
    }
    private func canDownloading() -> Bool {
        let currentDate = Date()
        let time = currentDate.timeIntervalSince(lastDate)
        if time >= self.interval {
            lastDate = currentDate
            return true
        }
        return false
    }
    private func hasSuccessCode(_ response: HTTPURLResponse) -> Bool {
        switch response.statusCode {
        case 200 ..< 300:
            return true
        default:
            return false
        }
    }
    static let domain = "com.condy.ImageX.downloading"
    private func statusCodeError(_ statusCode: Int) -> NSError {
        let userInfo = [
            NSLocalizedDescriptionKey: HTTPURLResponse.localizedString(forStatusCode: statusCode)
        ]
        return NSError(domain: DataDownloader.domain, code: statusCode, userInfo: userInfo)
    }
    private func invalidHTTPURLResponseError() -> NSError {
        let userInfo = [
            NSLocalizedDescriptionKey: "Did receive response is not HTTPURLResponse."
        ]
        return NSError(domain: DataDownloader.domain, code: 2002, userInfo: userInfo)
    }
    private func invalidDataError() -> NSError {
        let userInfo = [
            NSLocalizedDescriptionKey: "The downloaded data is empty."
        ]
        return NSError(domain: DataDownloader.domain, code: 3003, userInfo: userInfo)
    }
}
extension DataDownloader: URLSessionDataDelegate {
    public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
        guard let response = dataTask.response as? HTTPURLResponse else {
            result(data: nil, response: response, state: .failed(invalidHTTPURLResponseError()))
            completionHandler(.cancel)
            return
        }
        guard hasSuccessCode(response) else {
            result(data: nil, response: response, state: .failed(statusCodeError(response.statusCode)))
            completionHandler(.cancel)
            return
        }
        self.outputStream = OutputStream(url: URL(fileURLWithPath: files.path), append: true)
        self.outputStream?.open()
        if offset == 0 {
            var totalBytes = response.expectedContentLength
            let data = Data(bytes: &totalBytes, count: MemoryLayout.size(ofValue: totalBytes))
            do {
                try URL(fileURLWithPath: files.path).mt.setExtendedAttribute(data: data, forName: Files.totalBytesKey)
            } catch {
                result(data: nil, response: response, state: .failed(error))
                completionHandler(.cancel)
                return
            }
        }
        completionHandler(.allow)
    }
    public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
        self.didReceiveData(data: data, dataTask: dataTask)
        self.outputStream?.write(Array(data), maxLength: data.count)
    }
    public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        self.session?.invalidateAndCancel()
        self.outputStream?.close()
        guard let response = task.response as? HTTPURLResponse else {
            result(data: nil, response: task.response, state: .failed(invalidHTTPURLResponseError()))
            return
        }
        if let error = error {
            let state: Disposition = self.retry.exceededRetriedCount() ? .finished(error) : .failed(error)
            result(data: nil, response: response, state: state)
        } else if hasSuccessCode(response) {
            result(data: mutableData, response: response, state: .complete)
            try? files.removeFileItem()
        } else {
            let error = statusCodeError(response.statusCode)
            let state: Disposition = self.retry.exceededRetriedCount() ? .finished(error) : .failed(error)
            result(data: nil, response: response, state: state)
        }
    }
}
  • 当然这边也关于网络下载失利,做了下载重试 DelayRetry 操作;

怎么运用

  • 运用流程根本能够参阅猫神所著Kingfisher,同样该库也选用这种形式,这样也便利我们运用习气;
  • 这边鉴于后续参数的增加,因而选用 AnimatedOptions 来传递其他参数,便利扩展和操作;

根本运用

let url = URL(string: "")!
imageView.mt.setImage(with: url)

设置不同参数运用

var options = AnimatedOptions(moduleName: "Component Name") // 组件化需模块名
options.loop = .count(3) // 循环播映3次
options.placeholder = .image(R.image("IMG_0020")!) // 占位图
options.contentMode = .scaleAspectBottomRight // 填充形式
options.bufferCount = 20 // 缓存20帧
options.cacheOption = .disk // 选用磁盘缓存
options.cacheCrypto = .sha1 // 加密
options.cacheDataZip = .gzip // 压缩数据
options.retry = .max3s // 网络失利重试
options.setPreparationBlock(block: { [weak self] _ in
    // do something..
})
options.setAnimatedBlock(block: { _ in
    // play is complete and then do something..
})
options.setNetworkProgress(block: { _ in
    // download progress..
})
options.setNetworkFailed(block: { _ in
    // download failed.
})
let links = [``GIF URL``, ``Image URL``, ``GIF Named``, ``Image Named``]
let named = links.randomElement() ?? ""
// Setup filters.
let filters: [C7FilterProtocol] = [
    C7SoulOut(soul: 0.75),
    C7Storyboard(ranks: 2),
]
imageView.mt.setImage(with: named, filters: filters, options: options)
  • 快速让控件播映GIF和增加滤镜
class AnimatedView: UIView, AsAnimatable {
  ...
}
let filters: [C7FilterProtocol] = [
    C7WhiteBalance(temperature: 5555),
    C7Storyboard(ranks: 3)
]
let data = R.gifData("pikachu")
var options = AnimatedOptions()
options.loop = .forever
options.placeholder = .view(placeholder)
animatedView.play(data: data, filters: filters, options: options)
  • AnimatedOptions参数介绍
public struct AnimatedOptions {
    public static var `default` = AnimatedOptions()
    /// Desired number of loops. Default is ``forever``.
    public var loop: ImageX.Loop = .forever
    /// 假如遇见设置`original`以外其他形式显现无效`铺满屏幕`的情况,
    /// 请将承载控件``view.contentMode = .scaleAspectFit``
    /// Content mode used for resizing the frames. Default is ``original``.
    public var contentMode: ImageX.ContentMode = .original
    /// The number of frames to buffer. Default is 50. A high number will result in more memory usage and less CPU load, and vice versa.
    public var bufferCount: Int = 50
    /// Weather or not we should cache the URL response. Default is ``diskAndMemory``.
    public var cacheOption: Lemons.CachedOptions = .diskAndMemory
    /// Placeholder image. default gray picture.
    public var placeholder: ImageX.Placeholder = .none
    /// Network data cache naming encryption method, Default is ``md5``.
    public var cacheCrypto: Lemons.CryptoType = .md5
    /// Network data compression or decompression method, default ``gzip``.
    /// This operation is done in the subthread.
    public var cacheDataZip: ImageX.ZipType = .gzip
    /// Network max retry count and retry interval, default max retry count is ``3`` and retry ``3s`` interval mechanism.
    public var retry: ImageX.DelayRetry = .max3s
    /// Confirm the size to facilitate follow-up processing, Default display control size.
    public var confirmSize: CGSize = .zero
    /// Web images or GIFs link download priority.
    public var downloadPriority: Float = URLSessionTask.defaultPriority
    /// The timeout interval for the request. Defaults to 20.0
    public var timeoutInterval: TimeInterval = 20
    /// Network resource data download progress response interval.
    public var downloadInterval: TimeInterval = 0.02
    /// 做组件化操作时间,处理本地GIF或本地图片所处于另外模块然后读不出数据问题。
    /// Do the component operation to solve the problem that the local GIF or Image cannot read the data in another module.
    public let moduleName: String
    /// Instantiation of GIF configuration parameters.
    /// - Parameters:
    ///   - moduleName: Do the component operation to solve the problem that the local GIF cannot read the data in another module.
    public init(moduleName: String = "ImageX") {
        self.moduleName = moduleName
    }
    internal var preparation: ((_ res: ImageX.GIFResponse) -> Void)?
    /// Ready to play time callback.
    /// - Parameter block: Prepare to play the callback.
    public mutating func setPreparationBlock(block: @escaping ((_ res: ImageX.GIFResponse) -> Void)) {
        self.preparation = block
    }
    internal var animated: ((_ loopDuration: TimeInterval) -> Void)?
    /// GIF animation playback completed.
    /// - Parameter block: Complete the callback.
    public mutating func setAnimatedBlock(block: @escaping ((_ loopDuration: TimeInterval) -> Void)) {
        self.animated = block
    }
    internal var failed: ((_ error: Error) -> Void)?
    /// Network download task failure information.
    /// - Parameter block: Failed the callback.
    public mutating func setNetworkFailed(block: @escaping ((_ error: Error) -> Void)) {
        self.failed = block
    }
    internal var progressBlock: ((_ currentProgress: CGFloat) -> Void)?
    /// Network data task download progress.
    /// - Parameter block: Download the callback.
    public mutating func setNetworkProgress(block: @escaping ((_ currentProgress: CGFloat) -> Void)) {
        self.progressBlock = block
    }
    internal var displayed: Bool = false // 避免重复设置占位信息
    internal func setDisplayed(placeholder displayed: Bool) -> Self {
        var options = self
        options.displayed = displayed
        return options
    }
}

总结

本文只是对网络图画和GIF显现的轻量化处理计划,让网图显现更加快捷,便利开发和后续迭代修改。完成计划还有许多能够改进的地方;
欢迎我们来运用该结构,然后纠正修改亦或者我们有什么需求也可提出来,后续渐渐弥补完善;
也欢迎大神来协助运用优化此库,再次感谢!!!

本库运用的滤镜库 Harbeth 和磁盘缓存库 Lemons 也欢迎我们运用;


关于怎么运用和设计原理先简略介绍出来,关于后续功用和优化再渐渐介绍!

觉得有协助的铁子,就给我点个星支撑一哈,谢谢铁子们~
本文图画滤镜结构传送门 ImageX 地址。
有什么问题也能够直接联络我,邮箱 yangkj310@gmail.com