503 lines
20 KiB
Swift
503 lines
20 KiB
Swift
//
|
|
// ImageDownloader.swift
|
|
// Kingfisher
|
|
//
|
|
// Created by Wei Wang on 15/4/6.
|
|
//
|
|
// Copyright (c) 2019 Wei Wang <onevcat@gmail.com>
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
// THE SOFTWARE.
|
|
|
|
#if os(macOS)
|
|
import AppKit
|
|
#else
|
|
import UIKit
|
|
#endif
|
|
|
|
typealias DownloadResult = Result<ImageLoadingResult, KingfisherError>
|
|
|
|
/// Represents a success result of an image downloading progress.
|
|
public struct ImageLoadingResult {
|
|
|
|
/// The downloaded image.
|
|
public let image: KFCrossPlatformImage
|
|
|
|
/// Original URL of the image request.
|
|
public let url: URL?
|
|
|
|
/// The raw data received from downloader.
|
|
public let originalData: Data
|
|
|
|
/// Creates an `ImageDownloadResult`
|
|
///
|
|
/// - parameter image: Image of the download result
|
|
/// - parameter url: URL from where the image was downloaded from
|
|
/// - parameter originalData: The image's binary data
|
|
public init(image: KFCrossPlatformImage, url: URL? = nil, originalData: Data) {
|
|
self.image = image
|
|
self.url = url
|
|
self.originalData = originalData
|
|
}
|
|
}
|
|
|
|
/// Represents a task of an image downloading process.
|
|
public struct DownloadTask {
|
|
|
|
/// The `SessionDataTask` object bounded to this download task. Multiple `DownloadTask`s could refer
|
|
/// to a same `sessionTask`. This is an optimization in Kingfisher to prevent multiple downloading task
|
|
/// for the same URL resource at the same time.
|
|
///
|
|
/// When you `cancel` a `DownloadTask`, this `SessionDataTask` and its cancel token will be pass through.
|
|
/// You can use them to identify the cancelled task.
|
|
public let sessionTask: SessionDataTask
|
|
|
|
/// The cancel token which is used to cancel the task. This is only for identify the task when it is cancelled.
|
|
/// To cancel a `DownloadTask`, use `cancel` instead.
|
|
public let cancelToken: SessionDataTask.CancelToken
|
|
|
|
/// Cancel this task if it is running. It will do nothing if this task is not running.
|
|
///
|
|
/// - Note:
|
|
/// In Kingfisher, there is an optimization to prevent starting another download task if the target URL is being
|
|
/// downloading. However, even when internally no new session task created, a `DownloadTask` will be still created
|
|
/// and returned when you call related methods, but it will share the session downloading task with a previous task.
|
|
/// In this case, if multiple `DownloadTask`s share a single session download task, cancelling a `DownloadTask`
|
|
/// does not affect other `DownloadTask`s.
|
|
///
|
|
/// If you need to cancel all `DownloadTask`s of a url, use `ImageDownloader.cancel(url:)`. If you need to cancel
|
|
/// all downloading tasks of an `ImageDownloader`, use `ImageDownloader.cancelAll()`.
|
|
public func cancel() {
|
|
sessionTask.cancel(token: cancelToken)
|
|
}
|
|
}
|
|
|
|
extension DownloadTask {
|
|
enum WrappedTask {
|
|
case download(DownloadTask)
|
|
case dataProviding
|
|
|
|
func cancel() {
|
|
switch self {
|
|
case .download(let task): task.cancel()
|
|
case .dataProviding: break
|
|
}
|
|
}
|
|
|
|
var value: DownloadTask? {
|
|
switch self {
|
|
case .download(let task): return task
|
|
case .dataProviding: return nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Represents a downloading manager for requesting the image with a URL from server.
|
|
open class ImageDownloader {
|
|
|
|
// MARK: Singleton
|
|
/// The default downloader.
|
|
public static let `default` = ImageDownloader(name: "default")
|
|
|
|
// MARK: Public Properties
|
|
/// The duration before the downloading is timeout. Default is 15 seconds.
|
|
open var downloadTimeout: TimeInterval = 15.0
|
|
|
|
/// A set of trusted hosts when receiving server trust challenges. A challenge with host name contained in this
|
|
/// set will be ignored. You can use this set to specify the self-signed site. It only will be used if you don't
|
|
/// specify the `authenticationChallengeResponder`.
|
|
///
|
|
/// If `authenticationChallengeResponder` is set, this property will be ignored and the implementation of
|
|
/// `authenticationChallengeResponder` will be used instead.
|
|
open var trustedHosts: Set<String>?
|
|
|
|
/// Use this to set supply a configuration for the downloader. By default,
|
|
/// NSURLSessionConfiguration.ephemeralSessionConfiguration() will be used.
|
|
///
|
|
/// You could change the configuration before a downloading task starts.
|
|
/// A configuration without persistent storage for caches is requested for downloader working correctly.
|
|
open var sessionConfiguration = URLSessionConfiguration.ephemeral {
|
|
didSet {
|
|
session.invalidateAndCancel()
|
|
session = URLSession(configuration: sessionConfiguration, delegate: sessionDelegate, delegateQueue: nil)
|
|
}
|
|
}
|
|
open var sessionDelegate: SessionDelegate {
|
|
didSet {
|
|
session.invalidateAndCancel()
|
|
session = URLSession(configuration: sessionConfiguration, delegate: sessionDelegate, delegateQueue: nil)
|
|
setupSessionHandler()
|
|
}
|
|
}
|
|
|
|
/// Whether the download requests should use pipeline or not. Default is false.
|
|
open var requestsUsePipelining = false
|
|
|
|
/// Delegate of this `ImageDownloader` object. See `ImageDownloaderDelegate` protocol for more.
|
|
open weak var delegate: ImageDownloaderDelegate?
|
|
|
|
/// A responder for authentication challenge.
|
|
/// Downloader will forward the received authentication challenge for the downloading session to this responder.
|
|
open weak var authenticationChallengeResponder: AuthenticationChallengeResponsible?
|
|
|
|
private let name: String
|
|
private var session: URLSession
|
|
|
|
// MARK: Initializers
|
|
|
|
/// Creates a downloader with name.
|
|
///
|
|
/// - Parameter name: The name for the downloader. It should not be empty.
|
|
public init(name: String) {
|
|
if name.isEmpty {
|
|
fatalError("[Kingfisher] You should specify a name for the downloader. "
|
|
+ "A downloader with empty name is not permitted.")
|
|
}
|
|
|
|
self.name = name
|
|
|
|
sessionDelegate = SessionDelegate()
|
|
session = URLSession(
|
|
configuration: sessionConfiguration,
|
|
delegate: sessionDelegate,
|
|
delegateQueue: nil)
|
|
|
|
authenticationChallengeResponder = self
|
|
setupSessionHandler()
|
|
}
|
|
|
|
deinit { session.invalidateAndCancel() }
|
|
|
|
private func setupSessionHandler() {
|
|
sessionDelegate.onReceiveSessionChallenge.delegate(on: self) { (self, invoke) in
|
|
self.authenticationChallengeResponder?.downloader(self, didReceive: invoke.1, completionHandler: invoke.2)
|
|
}
|
|
sessionDelegate.onReceiveSessionTaskChallenge.delegate(on: self) { (self, invoke) in
|
|
self.authenticationChallengeResponder?.downloader(
|
|
self, task: invoke.1, didReceive: invoke.2, completionHandler: invoke.3)
|
|
}
|
|
sessionDelegate.onValidStatusCode.delegate(on: self) { (self, code) in
|
|
return (self.delegate ?? self).isValidStatusCode(code, for: self)
|
|
}
|
|
sessionDelegate.onResponseReceived.delegate(on: self) { (self, invoke) in
|
|
(self.delegate ?? self).imageDownloader(self, didReceive: invoke.0, completionHandler: invoke.1)
|
|
}
|
|
sessionDelegate.onDownloadingFinished.delegate(on: self) { (self, value) in
|
|
let (url, result) = value
|
|
do {
|
|
let value = try result.get()
|
|
self.delegate?.imageDownloader(self, didFinishDownloadingImageForURL: url, with: value, error: nil)
|
|
} catch {
|
|
self.delegate?.imageDownloader(self, didFinishDownloadingImageForURL: url, with: nil, error: error)
|
|
}
|
|
}
|
|
sessionDelegate.onDidDownloadData.delegate(on: self) { (self, task) in
|
|
return (self.delegate ?? self).imageDownloader(self, didDownload: task.mutableData, with: task)
|
|
}
|
|
}
|
|
|
|
// Wraps `completionHandler` to `onCompleted` respectively.
|
|
private func createCompletionCallBack(_ completionHandler: ((DownloadResult) -> Void)?) -> Delegate<DownloadResult, Void>? {
|
|
return completionHandler.map { block -> Delegate<DownloadResult, Void> in
|
|
|
|
let delegate = Delegate<Result<ImageLoadingResult, KingfisherError>, Void>()
|
|
delegate.delegate(on: self) { (self, callback) in
|
|
block(callback)
|
|
}
|
|
return delegate
|
|
}
|
|
}
|
|
|
|
private func createTaskCallback(
|
|
_ completionHandler: ((DownloadResult) -> Void)?,
|
|
options: KingfisherParsedOptionsInfo
|
|
) -> SessionDataTask.TaskCallback
|
|
{
|
|
return SessionDataTask.TaskCallback(
|
|
onCompleted: createCompletionCallBack(completionHandler),
|
|
options: options
|
|
)
|
|
}
|
|
|
|
private func createDownloadContext(
|
|
with url: URL,
|
|
options: KingfisherParsedOptionsInfo,
|
|
done: @escaping ((Result<DownloadingContext, KingfisherError>) -> Void)
|
|
)
|
|
{
|
|
func checkRequestAndDone(r: URLRequest) {
|
|
|
|
// There is a possibility that request modifier changed the url to `nil` or empty.
|
|
// In this case, throw an error.
|
|
guard let url = r.url, !url.absoluteString.isEmpty else {
|
|
done(.failure(KingfisherError.requestError(reason: .invalidURL(request: r))))
|
|
return
|
|
}
|
|
|
|
done(.success(DownloadingContext(url: url, request: r, options: options)))
|
|
}
|
|
|
|
// Creates default request.
|
|
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: downloadTimeout)
|
|
request.httpShouldUsePipelining = requestsUsePipelining
|
|
if #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) , options.lowDataModeSource != nil {
|
|
request.allowsConstrainedNetworkAccess = false
|
|
}
|
|
|
|
if let requestModifier = options.requestModifier {
|
|
// Modifies request before sending.
|
|
requestModifier.modified(for: request) { result in
|
|
guard let finalRequest = result else {
|
|
done(.failure(KingfisherError.requestError(reason: .emptyRequest)))
|
|
return
|
|
}
|
|
checkRequestAndDone(r: finalRequest)
|
|
}
|
|
} else {
|
|
checkRequestAndDone(r: request)
|
|
}
|
|
}
|
|
|
|
private func addDownloadTask(
|
|
context: DownloadingContext,
|
|
callback: SessionDataTask.TaskCallback
|
|
) -> DownloadTask
|
|
{
|
|
// Ready to start download. Add it to session task manager (`sessionHandler`)
|
|
let downloadTask: DownloadTask
|
|
if let existingTask = sessionDelegate.task(for: context.url) {
|
|
downloadTask = sessionDelegate.append(existingTask, callback: callback)
|
|
} else {
|
|
let sessionDataTask = session.dataTask(with: context.request)
|
|
sessionDataTask.priority = context.options.downloadPriority
|
|
downloadTask = sessionDelegate.add(sessionDataTask, url: context.url, callback: callback)
|
|
}
|
|
return downloadTask
|
|
}
|
|
|
|
|
|
private func reportWillDownloadImage(url: URL, request: URLRequest) {
|
|
delegate?.imageDownloader(self, willDownloadImageForURL: url, with: request)
|
|
}
|
|
|
|
private func reportDidDownloadImageData(result: Result<(Data, URLResponse?), KingfisherError>, url: URL) {
|
|
var response: URLResponse?
|
|
var err: Error?
|
|
do {
|
|
response = try result.get().1
|
|
} catch {
|
|
err = error
|
|
}
|
|
self.delegate?.imageDownloader(
|
|
self,
|
|
didFinishDownloadingImageForURL: url,
|
|
with: response,
|
|
error: err
|
|
)
|
|
}
|
|
|
|
private func reportDidProcessImage(
|
|
result: Result<KFCrossPlatformImage, KingfisherError>, url: URL, response: URLResponse?
|
|
)
|
|
{
|
|
if let image = try? result.get() {
|
|
self.delegate?.imageDownloader(self, didDownload: image, for: url, with: response)
|
|
}
|
|
|
|
}
|
|
|
|
private func startDownloadTask(
|
|
context: DownloadingContext,
|
|
callback: SessionDataTask.TaskCallback
|
|
) -> DownloadTask
|
|
{
|
|
|
|
let downloadTask = addDownloadTask(context: context, callback: callback)
|
|
|
|
let sessionTask = downloadTask.sessionTask
|
|
guard !sessionTask.started else {
|
|
return downloadTask
|
|
}
|
|
|
|
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.
|
|
self.reportDidDownloadImageData(result: result, url: context.url)
|
|
|
|
switch result {
|
|
// Download finished. Now process the data to an image.
|
|
case .success(let (data, response)):
|
|
let processor = ImageDataProcessor(
|
|
data: data, callbacks: callbacks, processingQueue: context.options.processingQueue
|
|
)
|
|
processor.onImageProcessed.delegate(on: self) { (self, done) 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) = done
|
|
|
|
self.reportDidProcessImage(result: result, url: context.url, response: response)
|
|
|
|
let imageResult = result.map { ImageLoadingResult(image: $0, url: context.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)) }
|
|
}
|
|
}
|
|
}
|
|
|
|
reportWillDownloadImage(url: context.url, request: context.request)
|
|
sessionTask.resume()
|
|
return downloadTask
|
|
}
|
|
|
|
// MARK: Downloading Task
|
|
/// Downloads an image with a URL and option. Invoked internally by Kingfisher. Subclasses must invoke super.
|
|
///
|
|
/// - Parameters:
|
|
/// - url: Target URL.
|
|
/// - options: The options could control download behavior. See `KingfisherOptionsInfo`.
|
|
/// - completionHandler: Called when the download progress finishes. This block will be called in the queue
|
|
/// defined in `.callbackQueue` in `options` parameter.
|
|
/// - Returns: A downloading task. You could call `cancel` on it to stop the download task.
|
|
@discardableResult
|
|
open func downloadImage(
|
|
with url: URL,
|
|
options: KingfisherParsedOptionsInfo,
|
|
completionHandler: ((Result<ImageLoadingResult, KingfisherError>) -> Void)? = nil) -> DownloadTask?
|
|
{
|
|
var downloadTask: DownloadTask?
|
|
createDownloadContext(with: url, options: options) { result in
|
|
switch result {
|
|
case .success(let context):
|
|
// `downloadTask` will be set if the downloading started immediately. This is the case when no request
|
|
// modifier or a sync modifier (`ImageDownloadRequestModifier`) is used. Otherwise, when an
|
|
// `AsyncImageDownloadRequestModifier` is used the returned `downloadTask` of this method will be `nil`
|
|
// and the actual "delayed" task is given in `AsyncImageDownloadRequestModifier.onDownloadTaskStarted`
|
|
// callback.
|
|
downloadTask = self.startDownloadTask(
|
|
context: context,
|
|
callback: self.createTaskCallback(completionHandler, options: options)
|
|
)
|
|
if let modifier = options.requestModifier {
|
|
modifier.onDownloadTaskStarted?(downloadTask)
|
|
}
|
|
case .failure(let error):
|
|
options.callbackQueue.execute {
|
|
completionHandler?(.failure(error))
|
|
}
|
|
}
|
|
}
|
|
|
|
return downloadTask
|
|
}
|
|
|
|
/// Downloads an image with a URL and option.
|
|
///
|
|
/// - Parameters:
|
|
/// - url: Target URL.
|
|
/// - options: The options could control download behavior. See `KingfisherOptionsInfo`.
|
|
/// - progressBlock: Called when the download progress updated. This block will be always be called in main queue.
|
|
/// - completionHandler: Called when the download progress finishes. This block will be called in the queue
|
|
/// defined in `.callbackQueue` in `options` parameter.
|
|
/// - Returns: A downloading task. You could call `cancel` on it to stop the download task.
|
|
@discardableResult
|
|
open func downloadImage(
|
|
with url: URL,
|
|
options: KingfisherOptionsInfo? = nil,
|
|
progressBlock: DownloadProgressBlock? = nil,
|
|
completionHandler: ((Result<ImageLoadingResult, KingfisherError>) -> Void)? = nil) -> DownloadTask?
|
|
{
|
|
var info = KingfisherParsedOptionsInfo(options)
|
|
if let block = progressBlock {
|
|
info.onDataReceived = (info.onDataReceived ?? []) + [ImageLoadingProgressSideEffect(block)]
|
|
}
|
|
return downloadImage(
|
|
with: url,
|
|
options: info,
|
|
completionHandler: completionHandler)
|
|
}
|
|
|
|
/// Downloads an image with a URL and option.
|
|
///
|
|
/// - Parameters:
|
|
/// - url: Target URL.
|
|
/// - options: The options could control download behavior. See `KingfisherOptionsInfo`.
|
|
/// - completionHandler: Called when the download progress finishes. This block will be called in the queue
|
|
/// defined in `.callbackQueue` in `options` parameter.
|
|
/// - Returns: A downloading task. You could call `cancel` on it to stop the download task.
|
|
@discardableResult
|
|
open func downloadImage(
|
|
with url: URL,
|
|
options: KingfisherOptionsInfo? = nil,
|
|
completionHandler: ((Result<ImageLoadingResult, KingfisherError>) -> Void)? = nil) -> DownloadTask?
|
|
{
|
|
downloadImage(
|
|
with: url,
|
|
options: KingfisherParsedOptionsInfo(options),
|
|
completionHandler: completionHandler
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: Cancelling Task
|
|
extension ImageDownloader {
|
|
|
|
/// Cancel all downloading tasks for this `ImageDownloader`. It will trigger the completion handlers
|
|
/// for all not-yet-finished downloading tasks.
|
|
///
|
|
/// If you need to only cancel a certain task, call `cancel()` on the `DownloadTask`
|
|
/// returned by the downloading methods. If you need to cancel all `DownloadTask`s of a certain url,
|
|
/// use `ImageDownloader.cancel(url:)`.
|
|
public func cancelAll() {
|
|
sessionDelegate.cancelAll()
|
|
}
|
|
|
|
/// Cancel all downloading tasks for a given URL. It will trigger the completion handlers for
|
|
/// all not-yet-finished downloading tasks for the URL.
|
|
///
|
|
/// - Parameter url: The URL which you want to cancel downloading.
|
|
public func cancel(url: URL) {
|
|
sessionDelegate.cancel(url: url)
|
|
}
|
|
}
|
|
|
|
// Use the default implementation from extension of `AuthenticationChallengeResponsible`.
|
|
extension ImageDownloader: AuthenticationChallengeResponsible {}
|
|
|
|
// Use the default implementation from extension of `ImageDownloaderDelegate`.
|
|
extension ImageDownloader: ImageDownloaderDelegate {}
|
|
|
|
extension ImageDownloader {
|
|
struct DownloadingContext {
|
|
let url: URL
|
|
let request: URLRequest
|
|
let options: KingfisherParsedOptionsInfo
|
|
}
|
|
}
|