20

Kingfisher源码分析

 3 years ago
source link: http://yyny.me/ios/Kingfisher源码分析/
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.

一:普通姿势

// 普通姿势1
func originMethod1() {
    guard let url = URL(string: imageURL),
          let data = try? Data(contentsOf: url) else { return }
    imageView.image = UIImage(data: data)
}

// 普通姿势2
func originMethod2() {
    DispatchQueue.global().async {
        guard let url = URL(string: self.imageURL),
              let data = try? Data(contentsOf: url) else { return }
        DispatchQueue.main.async {
            self.imageView.image = UIImage(data: data)
        }
    }
}

上面是两种普通设置网络图片的方法,弊端也是很明显的, originMethod1 会阻塞主线程,并且它们都没办法中途取消任务。

二:Kingfisher

以 Kingfisher v5.6.0 版本代码为示例

1.简单使用

let url = URL(string: imageURL)
imageView.kf.setImage(with: url)

2. kf 定义

先看 kf 的定义,返回一个包含自己的 KingfisherWrapper 对象,可以调用 Setting Image 一系列函数。

extension ImageView: KingfisherCompatible { }

extension KingfisherCompatible {
    /// Gets a namespace holder for Kingfisher compatible types.
    public var kf: KingfisherWrapper<Self> {
        get { return KingfisherWrapper(self) }
        set { }
    }
}

public struct KingfisherWrapper<Base> {
    public let base: Base
    public init(_ base: Base) {
        self.base = base
    }
}

3.1 setImage(with: resource…)

@discardableResult
public func setImage(
    with resource: Resource?,
    placeholder: Placeholder? = nil,
    options: KingfisherOptionsInfo? = nil,
    progressBlock: DownloadProgressBlock? = nil,
    completionHandler: ((Result<RetrieveImageResult, KingfisherError>) -> Void)? = nil) -> DownloadTask?
{
    return setImage(
        with: resource.map { .network($0) },
        placeholder: placeholder,
        options: options,
        progressBlock: progressBlock,
        completionHandler: completionHandler)
}

@discardableResult:表示取消不使用返回值的警告

可以看到 setImage(with: url) 内部调用了另外一个相似函数,只不过 source 参数类型从 Resource? 变成 Source? ,我们来看下这两者的区别:

public protocol Resource {
    var cacheKey: String { get }
    var downloadURL: URL { get }
}

public enum Source {
    public enum Identifier {
        public typealias Value = UInt
        static var current: Value = 0
        static func next() -> Value {
            current += 1
            return current
        }
    }

    case network(Resource)
    case provider(ImageDataProvider)

    public var cacheKey: String {
        switch self {
        case .network(let resource): return resource.cacheKey
        case .provider(let provider): return provider.cacheKey
        }
    }
  
    public var url: URL? {
        switch self {
        case .network(let resource): return resource.downloadURL
        case .provider(_): return nil
        }
    }
}

Resource 是协议,Source 是枚举,Source 有两种类型:.network(Resource) 和 .provider(ImageDataProvider)

imageView.kf.setImage(with: url) 可以直接传入 url,是因为 URL 实现了 Resource 协议

extension URL: Resource {
    public var cacheKey: String { return absoluteString }
    public var downloadURL: URL { return self }
}

3.2 setImage(with: source…)

@discardableResult
public func setImage(
    with source: Source?,
    placeholder: Placeholder? = nil,
    options: KingfisherOptionsInfo? = nil,
    progressBlock: DownloadProgressBlock? = nil,
    completionHandler: ((Result<RetrieveImageResult, KingfisherError>) -> Void)? = nil) -> DownloadTask?
{
    var mutatingSelf = self

    // 如果 source 为 nil,设置 imageView 的 image 为 placeholder,然后 return
    guard let source = source else {
        mutatingSelf.placeholder = placeholder
        mutatingSelf.taskIdentifier = nil
        completionHandler?(.failure(KingfisherError.imageSettingError(reason: .emptySource)))
        return nil
    }

    // KingfisherParsedOptionsInfo: 图片下载的解析选项
    // 具体参数含义参考 KingfisherOptionsInfo.swift 文件里 KingfisherOptionsInfoItem 定义
    var options = KingfisherParsedOptionsInfo(KingfisherManager.shared.defaultOptions + (options ?? .empty))
    let noImageOrPlaceholderSet = base.image == nil && self.placeholder == nil

    if !options.keepCurrentImageWhileLoading || noImageOrPlaceholderSet {
        // imageView 当前没有 image/placeholder 时,优先设置 placeholder
        mutatingSelf.placeholder = placeholder
    }

    // 图片下载指示器,默认为 nil
    let maybeIndicator = indicator
    maybeIndicator?.startAnimatingView()

    // 设置 taskIdentifier,从1开始,1,2,3...
    let issuedIdentifier = Source.Identifier.next()
    mutatingSelf.taskIdentifier = issuedIdentifier

    // 预加载所有动画图像数据
    if base.shouldPreloadAllAnimation() {
        options.preloadAllAnimationData = true
    }

    // 下载进度 block
    if let block = progressBlock {
        options.onDataReceived = (options.onDataReceived ?? []) + [ImageLoadingProgressSideEffect(block)]
    }

    if let provider = ImageProgressiveProvider(options, refresh: { image in
        self.base.image = image
    }) {
        options.onDataReceived = (options.onDataReceived ?? []) + [provider]
    }

    options.onDataReceived?.forEach {
        $0.onShouldApply = { issuedIdentifier == self.taskIdentifier }
    }

    // 获取图片
    let task = KingfisherManager.shared.retrieveImage(
        with: source,
        options: options,
        completionHandler: { result in
            // 处理结果
            CallbackQueue.mainCurrentOrAsync.execute {
                maybeIndicator?.stopAnimatingView()
                // 判断 Identifier 是否相等			   
                // 用来避免 UITableviewCell,UICollectionViewCell 重用时数据显示错误
                guard issuedIdentifier == self.taskIdentifier else {
                    let reason: KingfisherError.ImageSettingErrorReason
                    do {
                        let value = try result.get()
                        reason = .notCurrentSourceTask(result: value, error: nil, source: source)
                    } catch {
                        reason = .notCurrentSourceTask(result: nil, error: error, source: source)
                    }
                    // Identifier 不相等,返回错误原因
                    let error = KingfisherError.imageSettingError(reason: reason)
                    completionHandler?(.failure(error))
                    return
                }

                // 置空 imageTask,taskIdentifier
                mutatingSelf.imageTask = nil
                mutatingSelf.taskIdentifier = nil

                switch result {
                case .success(let value):
                    // 判断 直接/过渡动画 设置 image
                    guard self.needsTransition(options: options, cacheType: value.cacheType) else {
                        mutatingSelf.placeholder = nil
                        self.base.image = value.image
                        completionHandler?(result)
                        return
                    }

                    self.makeTransition(image: value.image, transition: options.transition) {
                        completionHandler?(result)
                    }

                case .failure:
                    // 失败情况下,设置 imageView.image = options.onFailureImage
                    if let image = options.onFailureImage {
                        self.base.image = image
                    }
                    completionHandler?(result)
                }
            }
        }
    )

    // 设置 imageTask,用于做 cancelDownloadTask() 操作
    mutatingSelf.imageTask = task
    return task
}

3.3 KingfisherManager.shared.retrieveImage(with: source…)

func retrieveImage(
        with source: Source,
        options: KingfisherParsedOptionsInfo,
        completionHandler: ((Result<RetrieveImageResult, KingfisherError>) -> Void)?) -> DownloadTask?
    {
        // 忽略缓存,直接下载并缓存图片
        if options.forceRefresh {
            return loadAndCacheImage(
                source: source,
                options: options,
                completionHandler: completionHandler)?.value
            
        } else {
            // 从缓存中获取图片
            let loadedFromCache = retrieveImageFromCache(
                source: source,
                options: options,
                completionHandler: completionHandler)
            // 缓存中获取到图片,直接返回
            if loadedFromCache {
                return nil
            }
            
            if options.onlyFromCache {
                let error = KingfisherError.cacheError(reason: .imageNotExisting(key: source.cacheKey))
                completionHandler?(.failure(error))
                return nil
            }
            // 下载并缓存图片
            return loadAndCacheImage(
                source: source,
                options: options,
                completionHandler: completionHandler)?.value
        }
    }

3.4.1 从缓存中获取图片: retrieveImageFromCache(source: Source…)

func retrieveImageFromCache(
        source: Source,
        options: KingfisherParsedOptionsInfo,
        completionHandler: ((Result<RetrieveImageResult, KingfisherError>) -> Void)?) -> Bool
    {
        // 1. Check whether the image was already in target cache. If so, just get it.
      	// 1.检查内存缓存,如果有缓存,就返回图片
        let targetCache = options.targetCache ?? cache
        let key = source.cacheKey
        let targetImageCached = targetCache.imageCachedType(
            forKey: key, processorIdentifier: options.processor.identifier)
        
        let validCache = targetImageCached.cached &&
            (options.fromMemoryCacheOrRefresh == false || targetImageCached == .memory)
        if validCache {
            targetCache.retrieveImage(forKey: key, options: options) { result in
                guard let completionHandler = completionHandler else { return }
                options.callbackQueue.execute {
                    result.match(
                        onSuccess: { cacheResult in
                            let value: Result<RetrieveImageResult, KingfisherError>
                            if let image = cacheResult.image {
                                value = result.map {
                                    RetrieveImageResult(image: image, cacheType: $0.cacheType, source: source)
                                }
                            } else {
                                value = .failure(KingfisherError.cacheError(reason: .imageNotExisting(key: key)))
                            }
                            completionHandler(value)
                        },
                        onFailure: { _ in
                            completionHandler(.failure(KingfisherError.cacheError(reason: .imageNotExisting(key: key))))
                        }
                    )
                }
            }
            return true
        }

        // 2. Check whether the original image exists. If so, get it, process it, save to storage and return.
      	// 2.检查磁盘缓存,如果有缓存,返回图片,并存储到内存缓存 
        let originalCache = options.originalCache ?? targetCache
        // No need to store the same file in the same cache again.
        if originalCache === targetCache && options.processor == DefaultImageProcessor.default {
            return false
        }

        // Check whether the unprocessed image existing or not.
        let originalImageCached = originalCache.imageCachedType(
            forKey: key, processorIdentifier: DefaultImageProcessor.default.identifier).cached
        if originalImageCached {
            // Now we are ready to get found the original image from cache. We need the unprocessed image, so remove
            // any processor from options first.
            var optionsWithoutProcessor = options
            optionsWithoutProcessor.processor = DefaultImageProcessor.default
            originalCache.retrieveImage(forKey: key, options: optionsWithoutProcessor) { result in

                result.match(
                    onSuccess: { cacheResult in
                        guard let image = cacheResult.image else {
                            return
                        }

                        let processor = options.processor
                        (options.processingQueue ?? self.processingQueue).execute {
                            let item = ImageProcessItem.image(image)
                            guard let processedImage = processor.process(item: item, options: options) else {
                                let error = KingfisherError.processorError(
                                    reason: .processingFailed(processor: processor, item: item))
                                options.callbackQueue.execute { completionHandler?(.failure(error)) }
                                return
                            }

                            var cacheOptions = options
                            cacheOptions.callbackQueue = .untouch
                            targetCache.store(
                                processedImage,
                                forKey: key,
                                options: cacheOptions,
                                toDisk: !options.cacheMemoryOnly)
                            {
                                _ in
                                if options.waitForCache {
                                    let value = RetrieveImageResult(image: processedImage, cacheType: .none, source: source)
                                    options.callbackQueue.execute { completionHandler?(.success(value)) }
                                }
                            }

                            if !options.waitForCache {
                                let value = RetrieveImageResult(image: processedImage, cacheType: .none, source: source)
                                options.callbackQueue.execute { completionHandler?(.success(value)) }
                            }
                        }
                    },
                    onFailure: { _ in
                        // This should not happen actually, since we already confirmed `originalImageCached` is `true`.
                        // Just in case...
                        options.callbackQueue.execute {
                            completionHandler?(.failure(KingfisherError.cacheError(reason: .imageNotExisting(key: key))))
                        }
                    }
                )
            }
            return true
        }

        return false
    }
}

3.5.1 下载并缓存图片: loadAndCacheImage(source…)

@discardableResult
func loadAndCacheImage(
    source: Source,
    options: KingfisherParsedOptionsInfo,
    completionHandler: ((Result<RetrieveImageResult, KingfisherError>) -> Void)?) -> DownloadTask.WrappedTask?
{
    // 添加图片到缓存
    func cacheImage(_ result: Result<ImageLoadingResult, KingfisherError>)
    {
        switch result {
        case .success(let value):
            // 默认缓存是 ImageCache.default = ImageCache(name: "default")
            let targetCache = options.targetCache ?? self.cache
            targetCache.store(
                value.image,
                original: value.originalData,
                forKey: source.cacheKey,
                options: options,
                toDisk: !options.cacheMemoryOnly)
            {
                _ in
                // 缓存成功后再执行 completionHandler 回调
                if options.waitForCache {
                    let result = RetrieveImageResult(image: value.image, cacheType: .none, source: source)
                    completionHandler?(.success(result))
                }
            }

            let needToCacheOriginalImage = options.cacheOriginalImage &&
                options.processor != DefaultImageProcessor.default
            // 缓存图片的原始数据 Data
            if needToCacheOriginalImage {
                let originalCache = options.originalCache ?? targetCache
                originalCache.storeToDisk(
                    value.originalData,
                    forKey: source.cacheKey,
                    processorIdentifier: DefaultImageProcessor.default.identifier,
                    expiration: options.diskCacheExpiration)
            }

            // 直接执行 completionHandler 回调
            if !options.waitForCache {
                let result = RetrieveImageResult(image: value.image, cacheType: .none, source: source)
                completionHandler?(.success(result))
            }
        case .failure(let error):
            completionHandler?(.failure(error))
        }
    }

    switch source {
    case .network(let resource):
        let downloader = options.downloader ?? self.downloader

        // 创建 DownloadTask,下载图片
        guard let task = downloader.downloadImage(
            with: resource.downloadURL,
            options: options,
            completionHandler: cacheImage) else {
            return nil
        }
        return .download(task)

    case .provider(let provider):
        provideImage(provider: provider, options: options, completionHandler: cacheImage)
        return .dataProviding
    }
}

3.5.1.1 创建 DownloadTask: downloadImage(with url…)

@discardableResult
func downloadImage(
    with url: URL,
    options: KingfisherParsedOptionsInfo,
    completionHandler: ((Result<ImageLoadingResult, KingfisherError>) -> Void)? = nil) -> DownloadTask?
{
    // 创建 request,timeoutInterval 默认为 15s
    var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: downloadTimeout)
    request.httpShouldUsePipelining = requestsUsePipelining

    // 修改 request
    if let requestModifier = options.requestModifier {
        guard let r = requestModifier.modified(for: request) else {
            options.callbackQueue.execute {
                completionHandler?(.failure(KingfisherError.requestError(reason: .emptyRequest)))
            }
            return nil
        }
        request = r
    }

    // 判断 url 是否为 nil / empty
    guard let url = request.url, !url.absoluteString.isEmpty else {
        options.callbackQueue.execute {
            completionHandler?(.failure(KingfisherError.requestError(reason: .invalidURL(request: request))))
        }
        return nil
    }

    // Wraps `completionHandler` to `onCompleted` respectively.
    // 将 completionHandler 包装成 onCompleted
    let onCompleted = completionHandler.map {
        block -> Delegate<Result<ImageLoadingResult, KingfisherError>, Void> in
        let delegate = Delegate<Result<ImageLoadingResult, KingfisherError>, Void>()
        delegate.delegate(on: self) { (_, callback) in
            block(callback)
        }
        return delegate
    }

    // SessionDataTask.TaskCallback is a wrapper for `onCompleted` and `options` (for processor info)
    // SessionDataTask.TaskCallback 是 onCompleted 和 options 的包装(用于处理下载完成后的图片 Data)
    let callback = SessionDataTask.TaskCallback(
        onCompleted: onCompleted,
        options: options
    )

    // Ready to start download. Add it to session task manager (`sessionHandler`)

    // 如果 sessionDelegate 里有相同 url 的 task 时,就不创建新的 SessionDataTask,仅把 callback 添加到对应 SessionDataTask 的 callbacksStore 里即可,请求结束后,顺序依次处理 callbacks,避免重复请求,浪费用户流量
    let downloadTask: DownloadTask
    if let existingTask = sessionDelegate.task(for: url) {
        downloadTask = sessionDelegate.append(existingTask, url: url, callback: callback)
    } else {
        // 创建新的 URLSessionDataTask
        let sessionDataTask = session.dataTask(with: request)
        sessionDataTask.priority = options.downloadPriority
        downloadTask = sessionDelegate.add(sessionDataTask, url: url, callback: callback)
    }

    let sessionTask = downloadTask.sessionTask

  	// 当下载完成后执行 sessionTask 的 onTaskDone 回调
    if !sessionTask.started {
        sessionTask.onTaskDone.delegate(on: self) { (self, done) in
            // Underlying downloading finishes.
            // result: Result<(Data, URLResponse?)>, callbacks: [TaskCallback]
            let (result, callbacks) = done

            // Before processing the downloaded data.
            do {
                let value = try result.get()
                self.delegate?.imageDownloader(
                    self,
                    didFinishDownloadingImageForURL: url,
                    with: value.1,
                    error: nil
                )
            } catch {
                self.delegate?.imageDownloader(
                    self,
                    didFinishDownloadingImageForURL: url,
                    with: nil,
                    error: error
                )
            }

            switch result {
            // Download finished. Now process the data to an image.
            // 下载完成,将数据转化成图片
            case .success(let (data, response)):
                let processor = ImageDataProcessor(
                    data: data, callbacks: callbacks, processingQueue: options.processingQueue)
                processor.onImageProcessed.delegate(on: self) { (self, result) in
                    // `onImageProcessed` will be called for `callbacks.count` times, with each
                    // `SessionDataTask.TaskCallback` as the input parameter.
                    // result: Result<Image>, callback: SessionDataTask.TaskCallback
                    let (result, callback) = result

                    if let image = try? result.get() {
                        self.delegate?.imageDownloader(self, didDownload: image, for: url, with: response)
                    }

                    let imageResult = result.map { ImageLoadingResult(image: $0, url: url, originalData: data) }
                    let queue = callback.options.callbackQueue
                    queue.execute {
                        callback.onCompleted?.call(imageResult)
                    }
                }
                processor.process()

            case .failure(let error):
                callbacks.forEach { callback in
                    let queue = callback.options.callbackQueue
                    queue.execute { callback.onCompleted?.call(.failure(error)) }
                }
            }
        }
        delegate?.imageDownloader(self, willDownloadImageForURL: url, with: request)

        // 开始下载
        sessionTask.resume()
    }
    return downloadTask
}

下载这步看着有点绕,我们来梳理一下:

  1. ImageDownloader 是下载管理器,这个类中包含 session、sessionDelegate 等
  2. SessionDelegate 是 URLSession 的 delegate,这个类中包含 tasks: [URL: SessionDataTask] ,以及实现 URLSessionDataDelegate 相关协议
  3. SessionDataTask 是 URLSessionDataTask 的封装,里面包含了 task、callbacksStore,callbacksStore 是 task 对应的多个回调

2ueiMr.jpg!mobile

当 sessionTask.resume() 开始下载时,SessionDelegate 就开始接受数据,接受完数据后,会调用 urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) 协议,然后再调用 onCompleted(task: URLSessionTask, result: Result<(Data, URLResponse?), KingfisherError>) 方法,找到对应的 sessionTask,执行 sessionTask 的 onTaskDone 回调,传入 result 和 callbacks。

func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
    guard let sessionTask = self.task(for: task) else { return }

    ...
    onCompleted(task: task, result: result)
}

private func onCompleted(task: URLSessionTask, result: Result<(Data, URLResponse?), KingfisherError>) {
    guard let sessionTask = self.task(for: task) else {
        return
    }
    remove(task)
    sessionTask.onTaskDone.call((result, sessionTask.callbacks))
}

3.5.1.2 Data 转换为 Image: process(item: ImageProcessItem…)

可以看到是通过 Image(data: data, scale: options.scale) 方法生成对应图片

通过 Data 前面的 bytes 判断图片对应的格式

public func process(item: ImageProcessItem, options: KingfisherParsedOptionsInfo) -> Image? {
    switch item {
    case .image(let image):
        return image.kf.scaled(to: options.scaleFactor)
    case .data(let data):
        return KingfisherWrapper.image(data: data, options: options.imageCreatingOptions)
    }
}

public static func image(data: Data, options: ImageCreatingOptions) -> Image? {
    var image: Image?
    switch data.kf.imageFormat {
    case .JPEG:
        image = Image(data: data, scale: options.scale)
    case .PNG:
        image = Image(data: data, scale: options.scale)
    case .GIF:
        image = KingfisherWrapper.animatedImage(data: data, options: options)
    case .unknown:
        image = Image(data: data, scale: options.scale)
    }
    return image
}

public var imageFormat: ImageFormat {
    guard base.count > 8 else { return .unknown }

    var buffer = [UInt8](repeating: 0, count: 8)
    base.copyBytes(to: &buffer, count: 8)

    if buffer == ImageFormat.HeaderData.PNG {
        return .PNG

    } else if buffer[0] == ImageFormat.HeaderData.JPEG_SOI[0],
        buffer[1] == ImageFormat.HeaderData.JPEG_SOI[1],
        buffer[2] == ImageFormat.HeaderData.JPEG_IF[0]
    {
        return .JPEG

    } else if buffer[0] == ImageFormat.HeaderData.GIF[0],
        buffer[1] == ImageFormat.HeaderData.GIF[1],
        buffer[2] == ImageFormat.HeaderData.GIF[2]
    {
        return .GIF
    }

    return .unknown
}

3.5.2 缓存图片 store(_ image: Image…)

open func store(_ image: Image,
                    original: Data? = nil,
                    forKey key: String,
                    options: KingfisherParsedOptionsInfo,
                    toDisk: Bool = true,
                    completionHandler: ((CacheStoreResult) -> Void)? = nil)
{
    // 处理器的标示符,默认为 ""
    let identifier = options.processor.identifier
    let callbackQueue = options.callbackQueue
    // 计算缓存 key,默认为 URL 的 absoluteString
    let computedKey = key.computedKey(with: identifier)
    // Memory storage should not throw.
    // 存储图片到内存缓存(NSCache)
    memoryStorage.storeNoThrow(value: image, forKey: computedKey, expiration: options.memoryCacheExpiration)

    guard toDisk else {
        if let completionHandler = completionHandler {
            let result = CacheStoreResult(memoryCacheResult: .success(()), diskCacheResult: .success(()))
            callbackQueue.execute { completionHandler(result) }
        }
        return
    }

    // 存储图片到磁盘(File)
    ioQueue.async {
        let serializer = options.cacheSerializer
        if let data = serializer.data(with: image, original: original) {
            self.syncStoreToDisk(
                data,
                forKey: key,
                processorIdentifier: identifier,
                callbackQueue: callbackQueue,
                expiration: options.diskCacheExpiration,
                completionHandler: completionHandler)
        } else {
            guard let completionHandler = completionHandler else { return }

            let diskError = KingfisherError.cacheError(
                reason: .cannotSerializeImage(image: image, original: original, serializer: serializer))
            let result = CacheStoreResult(
                memoryCacheResult: .success(()),
                diskCacheResult: .failure(diskError))
            callbackQueue.execute { completionHandler(result) }
        }
    }
}

3.6 完整流程

Y7rEZvn.png!mobile

三:补充

1. 缓存策略

  1. 内存缓存:

    MemoryStorage.Backend 类中有一个 cleanTimer 定时器,默认时间是 120s 去清理一次过期图片,图片的默认过期时间是 300s。如果从缓存中取到了图片,就重置该图片的开始时间,默认过期时间依然是 300s。

  2. 磁盘缓存:

    图片的默认过期时间是 7天,如果从磁盘中取到了图片,就重置该图片的开始时间,默认过期时间依然是 7天。

2. 其它细节

  1. ImageCache 类中添加了一系列的监听通知,比如 App 收到内存警告时,所以当 App 收到内存警告时,不需要再次处理图片缓存。

    notifications = [
                (UIApplication.didReceiveMemoryWarningNotification, #selector(clearMemoryCache)),
                (UIApplication.willTerminateNotification, #selector(cleanExpiredDiskCache)),
                (UIApplication.didEnterBackgroundNotification, #selector(backgroundCleanExpiredDiskCache))
    ]
    
    notifications.forEach {
                NotificationCenter.default.addObserver(self, selector: $0.1, name: $0.0, object: nil)
    }


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK