По крайней мере в последнее десятилетие, количество приложений, которым требуется доступ в интернет, неимоверно возросло. Причем для большинства проектов требуется только выполнение REST запросов и загрузка изображений, с созданием preview. В связи с чем, необходим своего рода менеджер сетевых запросов для загрузки данных из сети. Далее будет представлен NetworkManager, с помощью которого выполняются REST запросы, а также загружаются файлы, в том числе и изображения.
Задача
Необходимо разработать менеджер сетевых запросов для выполнения REST запросов, загрузки и кэширования и создания preview изображений. При этом необходимо, чтобы была возможность обрабатывать появление/отсутствие соединения и конфигурировать кэширование. Решение должно использовать новую систему concurrency, представленную в iOS 13.
REST запросы
Ниже представлен базовый класс, в котором расположена логика по обработке ответа сервера (URLResponse), от которого будут наследоваться конкретные загрузчики (модули) и который зависит только от Foundation framework.
import Foundation
public class NetworkManager {
// MARK: - Properties
let session: URLSession
let decoder: JSONDecoder
// MARK: - Lifecycle
public init(session: URLSession, decoder: JSONDecoder) {
self.session = session
self.decoder = decoder
}
// MARK: - Methods
func handle<T>(response: URLResponse, content: T) throws -> T {
guard let response = response as? HTTPURLResponse else {
throw "Unknown response received"
}
guard let httpStatusCode = HttpStatusCode(rawValue: response.statusCode) else {
throw "Unknown http status code"
}
if httpStatusCode.isSuccessStatusCode {
return content
} else if let content = content as? Data {
throw try decoder.decode(Request.Error.self, from: content)
} else {
throw Request.Error(code: response.statusCode)
}
}
}
Не стоит удивляться наличию перечисления HTTPStatusCode, поскольку это достаточно стандартный набор констант, возвращаемых серверами в URLResponse.
import Foundation
public enum HttpStatusCode: Int {
case unknown = -1
// Informational response
case `continue` = 100
case switchingProtocols = 101
case processing = 102
case earlyHints = 103
// Success
case ok = 200
case created = 201
case accepted = 202
case nonAuthoritativeInformation = 203
case noContent = 204
case resetContent = 205
case partialContent = 206
case multiStatus = 207
case alreadyReported = 208
case used = 226
// Redirection
case multipleChoices = 300
case movedPermanently = 301
case found = 302
case seeOther = 303
case notModified = 304
case useProxy = 305
case switchProxy = 306
case temporaryRedirect = 307
case permanentRedirect = 308
// Client errors
case badRequest = 400
case unauthorized = 401
case paymentRequired = 402
case forbidden = 403
case notFound = 404
case methodNotAllowed = 405
case notAcceptable = 406
case proxyAuthenticationRequired = 407
case requestTimeout = 408
case conflict = 409
case gone = 410
case lengthRequired = 411
case preconditionFailed = 412
case payloadTooLarge = 413
case urlTooLong = 414
case unsupportedMediaType = 415
case rangeNotSatisfiable = 416
case expectationFailed = 417
case teapot = 418
case misdirectedRequest = 421
case unprocessableEntity = 422
case locked = 423
case failedDependency = 424
case tooEarly = 425
case upgradeRequired = 426
case preconditionRequired = 428
case tooManyRequests = 429
case requestHeaderFieldsTooLarge = 431
case unavailableForLegalReasons = 451
// Server errors
case internalServerError = 500
case notImplemented = 501
case badGateway = 502
case serviceUnavailable = 503
case gatewayTimeout = 504
case httpVersionNotSupported = 505
case variantAlsoNegotiates = 506
case insufficientStorage = 507
case loopDetected = 508
case notExtended = 510
case networkAuthenticationRequired = 511
public var isSuccessStatusCode: Bool {
switch self.rawValue {
case 200..<300:
return true
default:
return false
}
}
}
DataLoader
Наиболее часто используется. Поскольку с его помощью выполняются REST запросы, то предпочтительно, чтобы данный модуль еще и выполнял сериализацию/десериализацию запросов.
import Foundation
public final class DataLoader: NetworkManager {
@discardableResult
public func dataRequest<T: Decodable>(url: UrlCreatable,
method: HTTPMethod,
headers: [HTTPHeader] = [],
parameters: Request.Parameters? = nil) async throws -> T {
let data = try await dataRequest(
url: url,
method: method,
headers: headers,
parameters: parameters
)
return try decoder.decode(T.self, from: data)
}
@discardableResult
public func dataRequest(url: UrlCreatable,
method: HTTPMethod,
headers: [HTTPHeader] = [],
parameters: Request.Parameters? = nil) async throws -> Data {
let url = try url.create()
let request = try Request(
url: url,
method: method,
headers: headers,
parameters: parameters
)
let (data, response) = try await session.data(for: request.urlRequest)
return try handle(response: response, content: data)
}
}
UrlCreatable — это интерфейс для создания URL, котрый реализуют типы String и URL.
import Foundation
public protocol UrlCreatable {
func create() throws -> URL
}
extension String: UrlCreatable {
public func create() throws -> URL {
if let url = URL(string: self) {
return url
} else {
throw "Cannot create url"
}
}
}
extension URL: UrlCreatable {
public func create() throws -> URL {
return self
}
}
С HTTPMethod и HTTPHeader тоже все понятно — это также стандартный набор констант, используемых в запросе, отправляемом на сервер (URLRequest).
import Foundation
public struct HTTPHeader: Hashable {
public let name: String
public let value: String
public init(name: String, value: String) {
self.name = name
self.value = value
}
}
extension HTTPHeader: CustomStringConvertible {
public var description: String {
"\(name): \(value)"
}
}
public extension HTTPHeader {
static func accept(_ value: String) -> HTTPHeader {
HTTPHeader(name: "Accept", value: value)
}
static func acceptCharset(_ value: String) -> HTTPHeader {
HTTPHeader(name: "Accept-Charset", value: value)
}
static func acceptLanguage(_ value: String) -> HTTPHeader {
HTTPHeader(name: "Accept-Language", value: value)
}
static func acceptEncoding(_ value: String) -> HTTPHeader {
HTTPHeader(name: "Accept-Encoding", value: value)
}
static func authorization(username: String, password: String) -> HTTPHeader {
let credential = Data("\(username):\(password)".utf8).base64EncodedString()
return authorization("Basic \(credential)")
}
static func authorization(bearerToken: String) -> HTTPHeader {
authorization("Bearer \(bearerToken)")
}
static func authorization(_ value: String) -> HTTPHeader {
HTTPHeader(name: "Authorization", value: value)
}
static func contentDisposition(_ value: String) -> HTTPHeader {
HTTPHeader(name: "Content-Disposition", value: value)
}
static func contentType(_ value: String) -> HTTPHeader {
HTTPHeader(name: "Content-Type", value: value)
}
static func contentLength(_ value: String) -> HTTPHeader {
HTTPHeader(name: "Content-Length", value: value)
}
static func userAgent(_ value: String) -> HTTPHeader {
HTTPHeader(name: "User-Agent", value: value)
}
}
public extension HTTPHeader {
static func qualityEncoded(_ encodings: [String]) -> String {
return encodings.enumerated().map { index, encoding in
let quality = 1.0 - (Double(index) * 0.1)
return "\(encoding);q=\(quality)"
}
.joined(separator: ", ")
}
static let defaultAcceptEncoding: HTTPHeader = {
let encodings = ["br", "gzip", "deflate"]
let value = qualityEncoded(encodings)
return .acceptEncoding(value)
}()
static let defaultAcceptLanguage: HTTPHeader = {
let encodings = Array(Locale.preferredLanguages.prefix(6))
let value = qualityEncoded(encodings)
return .acceptLanguage(value)
}()
static let defaultUserAgent: HTTPHeader = {
let info = Bundle.main.infoDictionary
let executable = (info?["CFBundleExecutable"] as? String) ??
(ProcessInfo.processInfo.arguments.first?.split(separator: "/").last.map(String.init)) ??
"Unknown"
let bundle = info?["CFBundleIdentifier"] as? String ?? "Unknown"
let appVersion = info?["CFBundleShortVersionString"] as? String ?? "Unknown"
let appBuild = info?["CFBundleVersion"] as? String ?? "Unknown"
let osNameVersion: String = {
let version = ProcessInfo.processInfo.operatingSystemVersion
let versionString = "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)"
let osName: String = {
#if os(iOS)
#if targetEnvironment(macCatalyst)
return "macOS(Catalyst)"
#else
return "iOS"
#endif
#elseif os(watchOS)
return "watchOS"
#elseif os(tvOS)
return "tvOS"
#elseif os(macOS)
return "macOS"
#elseif os(Linux)
return "Linux"
#elseif os(Windows)
return "Windows"
#else
return "Unknown"
#endif
}()
return "\(osName) \(versionString)"
}()
let userAgent = "\(executable)/\(appVersion) (\(bundle); build:\(appBuild); \(osNameVersion))"
return .userAgent(userAgent)
}()
}
import Foundation
public struct HTTPMethod: RawRepresentable, Equatable, Hashable {
public let rawValue: String
public init(rawValue: String) {
self.rawValue = rawValue
}
}
public extension HTTPMethod {
static let connect = HTTPMethod(rawValue: "CONNECT")
static let delete = HTTPMethod(rawValue: "DELETE")
static let get = HTTPMethod(rawValue: "GET")
static let head = HTTPMethod(rawValue: "HEAD")
static let options = HTTPMethod(rawValue: "OPTIONS")
static let patch = HTTPMethod(rawValue: "PATCH")
static let post = HTTPMethod(rawValue: "POST")
static let put = HTTPMethod(rawValue: "PUT")
static let trace = HTTPMethod(rawValue: "TRACE")
}
А вот создание самого запроса программировать уже несколько сложнее, так как нужно сериализовать параметры либо в query строку, либо в httpBody.
import AceExtensions
import Foundation
public final class Request {
// MARK: - Properties
private(set) var urlRequest: URLRequest
// MARK: - Lifecycle
init(url: URL,
method: HTTPMethod,
headers: [HTTPHeader],
parameters: Parameters?) throws {
urlRequest = URLRequest(url: url)
urlRequest.httpMethod = method.rawValue
urlRequest.allHTTPHeaderFields = headers.reduce(into: [:]) { $0[$1.name] = $1.value }
if let parameters = parameters {
try encode(parameters: parameters)
}
}
// MARK: - Methods
private func encode(parameters: Parameters) throws {
switch parameters.configuration.destination {
case .query(let arrayEncoding, let boolEncoding):
try appendQuery(parameters: parameters, arrayEncoding: arrayEncoding, boolEncoding: boolEncoding)
case .httpBody:
try addHttpBody(parameters: parameters)
}
}
private func appendQuery(parameters: Parameters,
arrayEncoding: Parameters.Configuration.ArrayEncoding,
boolEncoding: Parameters.Configuration.BoolEncoding) throws {
guard let dictionary = parameters.object as? [String: Any] else {
throw "Query parameters is not [String: Any] dictionary"
}
guard let url = urlRequest.url,
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
else {
throw "Cannot create url components from url request"
}
for (name, value) in dictionary {
let items = createQueryItems(
name: name,
value: value,
arrayEncoding: arrayEncoding,
boolEncoding: boolEncoding
)
var queryItems = components.queryItems ?? []
queryItems.append(contentsOf: items)
components.queryItems = queryItems
}
if let url = components.url {
urlRequest.url = url
}
if parameters.configuration.updateHeaders {
var headers = urlRequest.allHTTPHeaderFields ?? [:]
headers["Content-Type"] = "application/x-www-form-urlencoded; charset=utf-8"
urlRequest.allHTTPHeaderFields = headers
}
}
private func addHttpBody(parameters: Parameters) throws {
if let data = parameters.object as? Data {
urlRequest.httpBody = data
} else if JSONSerialization.isValidJSONObject(parameters.object) {
let data = try JSONSerialization.data(withJSONObject: parameters.object, options: .fragmentsAllowed)
urlRequest.httpBody = data
}
if parameters.configuration.updateHeaders {
var headers = urlRequest.allHTTPHeaderFields ?? [:]
headers["Content-Type"] = "application/json; charset=utf-8"
urlRequest.allHTTPHeaderFields = headers
}
}
private func createQueryItems(name: String,
value: Any,
arrayEncoding: Parameters.Configuration.ArrayEncoding,
boolEncoding: Parameters.Configuration.BoolEncoding) -> [URLQueryItem] {
switch value {
case let boolean as Bool:
let value = encodeQuery(boolean: boolean, encoding: boolEncoding)
let queryItem = URLQueryItem(name: name, value: value)
return [queryItem]
case let number as NSNumber:
let queryItem = URLQueryItem(name: name, value: number.stringValue)
return [queryItem]
case let array as [Any]:
let name = encodeQuery(array: name, encoding: arrayEncoding)
let queryItems = array.flatMap { value in
createQueryItems(name: name, value: value, arrayEncoding: arrayEncoding, boolEncoding: boolEncoding)
}
return queryItems
case let dictionary as [String: Any]:
let queryItems: [[URLQueryItem]] = dictionary.map { key, value in
let name = name + "[\(key)]"
return createQueryItems(name: name, value: value, arrayEncoding: arrayEncoding, boolEncoding: boolEncoding)
}
return queryItems.flatMap { $0 }
default:
let queryItem = URLQueryItem(name: name, value: "\(value)")
return [queryItem]
}
}
private func encodeQuery(array name: String, encoding: Parameters.Configuration.ArrayEncoding) -> String {
switch encoding {
case .brackets:
return name + "[]"
case .noBrackets:
return name
}
}
func encodeQuery(boolean value: Bool, encoding: Parameters.Configuration.BoolEncoding) -> String {
switch encoding {
case .numeric:
return (value as NSNumber).stringValue
case .literal:
return String(value)
}
}
}
Может показаться, что работа с параметрами запроса несколько запутана, но на деле это набор ключ: значение, сериализованные по определенным правилам, заданным в структуре Parameters.
import Foundation
public extension Request {
struct Parameters {
// MARK: - Types
public struct Configuration {
public enum Destination {
case query(arrayEncoding: ArrayEncoding, boolEncoding: BoolEncoding)
case httpBody
}
public enum ArrayEncoding {
case brackets
case noBrackets
}
public enum BoolEncoding {
case numeric
case literal
}
public var destination: Destination
public var updateHeaders: Bool
public static let `defaultQuery` = Configuration(
destination: .query(arrayEncoding: .brackets, boolEncoding: .literal),
updateHeaders: true
)
public static let `defaultJson` = Configuration(
destination: .httpBody,
updateHeaders: true
)
}
// MARK: - Properties
public let object: Any
public let configuration: Configuration
// MARK: - Lifecycle
public init(object: [String: Any], configuration: Configuration = .defaultQuery) {
self.object = object
self.configuration = configuration
}
public init<T: Encodable>(object: T, configuration: Configuration = .defaultJson) throws {
self.object = try JSONEncoder().encode(object)
self.configuration = configuration
}
public init(object: Data, configuration: Configuration) throws {
self.object = object
self.configuration = configuration
}
}
}
// MARK: - ExpressibleByDictionaryLiteral
extension Request.Parameters: ExpressibleByDictionaryLiteral {
public init(dictionaryLiteral elements: (String, Any)...) {
let dictionary = Dictionary(uniqueKeysWithValues: elements)
let object = (dictionary as [String: Any?]).compactMapValues { $0 }
self.init(object: object, configuration: .defaultQuery)
}
}
На этом ядро NetworkManager готово. В очередной раз обращаю внимание, что ядро не зависит от UI фреймворков, а значит легче поддерживается.
FileLoader
Имея такое ядро невероятно просто добавить новый модуль — FileLoader для загрузки файлов.
import Foundation
public final class FileLoader: NetworkManager {
public init(session: URLSession) {
super.init(session: session, decoder: JSONDecoder())
}
@discardableResult
public func downloadRequest(url: UrlCreatable,
method: HTTPMethod,
headers: [HTTPHeader] = [],
parameters: Request.Parameters? = nil) async throws -> URL {
let url = try url.create()
let request = try Request(
url: url,
method: method,
headers: headers,
parameters: parameters
)
let (fileUrl, response) = try await session.download(for: request.urlRequest)
return try handle(response: response, content: fileUrl)
}
}
ImageLoader
С загрузкой изображений все несколько сложнее, поскольку необходимо добавить логику кэширования и создания preview. Но, очень важно, чтобы эта логика выполнялась в потоке, параллельном main, иначе возможны тормоза в работе UI. По этой причине так называемый ImageLoader является actor, поскольку будет использоваться Task данного actor, а не MainActor. Также, поскольку уже идет работа с изображениями, добавлена зависимость от UI фреймворка — SwiftUI.
import SwiftUI
public actor ImageLoader {
// MARK: - Properties
private let fileLoader: FileLoader
private let storage = ImagesStorage()
// MARK: - Lifecycle
public init(session: URLSession) {
fileLoader = FileLoader(session: session)
}
// MARK: - Methods
public func loadImage(at url: URL) async -> UIImage? {
if let image = storage.loadObject(for: url) {
return image
} else {
let image: UIImage? = try? await downloadImage(url: url)
storage.save(object: image, for: url)
return image
}
}
public func createThumb(of image: UIImage?, size: CGSize) async -> UIImage? {
guard let image = image else {
return nil
}
let ratio = image.size.width / image.size.height
let width: CGFloat
let height: CGFloat
if image.size.width > image.size.height {
width = size.width
height = width / ratio
} else {
height = size.height
width = ratio * height
}
let size = CGSize(width: width, height: height)
return await image.byPreparingThumbnail(ofSize: size)
}
public func fileUrl(forImageAt url: URL) async -> URL? {
return storage.fileUrl(for: url)
}
@discardableResult
public func downloadImage(url: UrlCreatable,
method: HTTPMethod = .get,
headers: [HTTPHeader] = [],
parameters: Request.Parameters? = nil) async throws -> Image? {
let uiImage: UIImage? = try await downloadImage(
url: url,
method: method,
headers: headers,
parameters: parameters
)
if let uiImage = uiImage {
return Image(uiImage: uiImage)
} else {
return nil
}
}
@discardableResult
public func downloadImage(url: UrlCreatable,
method: HTTPMethod = .get,
headers: [HTTPHeader] = [],
parameters: Request.Parameters? = nil) async throws -> UIImage? {
let fileUrl = try await fileLoader.downloadRequest(
url: url,
method: method,
headers: headers,
parameters: parameters
)
return UIImage(contentsOfFile: fileUrl.path)
}
}
Если с созданием preview все понятно — достаточно вызвать метод byPreparingThumbnail(ofSize:), то с логикой кэширования нужно разобраться. Ведь использовать NSCache<KeyType, ObjectType> ошибочно, так как изображение и так будет сохранено в ОЗУ, при наполнении модели. А вот для того, чтобы изображения были кэшированы в постоянную память, требуется некоторый ImagesStorage объект, сохраняющий в постоянную память и загружающий из неё.
import Foundation
public protocol NetworkCache {
associatedtype Item
func save(object: Item?, for requestUrl: URL)
func loadObject(for requestUrl: URL) -> Item?
func hasCache(for requestUrl: URL) -> Bool
}
import UIKit
public final class ImagesStorage: CachedStorage, NetworkCache {
// MARK: - Methods
public func save(object: UIImage?, for requestUrl: URL) {
let data = object?.pngData()
let url = fileUrl(for: requestUrl)
save(data: data, at: url)
}
public func loadObject(for requestUrl: URL) -> UIImage? {
let url = fileUrl(for: requestUrl)
if let data = loadData(at: url) {
return UIImage(data: data)
} else {
return nil
}
}
public func hasCache(for requestUrl: URL) -> Bool {
let url = fileUrl(for: requestUrl)
return hasCache(at: url)
}
public func fileUrl(for requestUrl: URL) -> URL {
let url = cachesUrl()
let className = String(describing: Self.self)
let hash = (requestUrl.absoluteString as NSString).hash
return url
.appendingPathComponent(className)
.appendingPathComponent(String(hash))
.appendingPathComponent(requestUrl.lastPathComponent)
}
}
import Foundation
public class CachedStorage {
// MARK: - Properties
private let fileManager: FileManager = .default
// MARK: - Methods
public func save(data: Data?, at url: URL) {
let directoryUrl = url.deletingLastPathComponent()
if !directoryExistsAtPath(directoryUrl.path) {
createCacheDirectory(at: directoryUrl)
}
fileManager.createFile(atPath: url.path, contents: data, attributes: nil)
}
public func loadData(at url: URL) -> Data? {
return try? Data(contentsOf: url)
}
public func hasCache(at url: URL) -> Bool {
return fileManager.fileExists(atPath: url.path)
}
private func directoryExistsAtPath(_ path: String) -> Bool {
var isDirectory = ObjCBool(true)
let exists = fileManager.fileExists(atPath: path, isDirectory: &isDirectory)
return exists && isDirectory.boolValue
}
private func createCacheDirectory(at url: URL) {
do {
try fileManager.createDirectory(at: url, withIntermediateDirectories: true)
} catch {
assertionFailure("Cache directory not created: \(error.localizedDescription)")
}
}
public func cachesUrl() -> URL {
let urls = fileManager.urls(for: .cachesDirectory, in: .userDomainMask)
if let url = urls.first {
return url
} else {
return fileManager.temporaryDirectory
}
}
}
Внимательного читателя может удивить использование hash свойства NSString вместо стандартного md5 hash. В этом месте разработчик позволил себе сделать паузу и выпить чашечку хорошего кофе, после чего запрограммировал наиболее простое решение.
Этот объект унаследован от CachedStorage, который сохраняет файлы в постоянную память и загружает из неё и реализует NetworkCache интерфейс.
Пример использования
Сначала необходимо сконфигурировать URLSession.
import Foundation
extension URLSession {
static let `default`: URLSession = {
let configuration = URLSessionConfiguration.default
configuration.waitsForConnectivity = true
configuration.timeoutIntervalForResource = configuration.timeoutIntervalForRequest
return URLSession(configuration: configuration)
}()
static let background: URLSession = {
let configuration = URLSessionConfiguration.background(withIdentifier: "background-session")
return URLSession(configuration: configuration)
}()
}
Далее объявить несколько вспомогательных методов, которые удовлетворяют требованиям проекта.
extension DataLoader {
convenience init(session: URLSession = .default) {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
self.init(session: session, decoder: decoder)
}
private func baseUrl() throws -> URL {
guard let host = Bundle.main.object(forInfoDictionaryKey: "Host") as? String else {
throw "Host variable is not secified"
}
guard let url = URL(string: host) else {
throw "Cannot create url"
}
return url.appendingPathComponent("swagger")
}
private var accessToken: String {
let secureSettings = SecureSettings()
return secureSettings.accessToken ?? ""
}
@discardableResult
public func dataRequest(path: String,
method: HTTPMethod,
headers: [HTTPHeader] = [],
parameters: Request.Parameters? = nil) async throws -> Data {
let url = try baseUrl().appendingPathComponent(path)
return try await dataRequest(
url: url,
method: method,
headers: headers,
parameters: parameters
)
}
@discardableResult
public func dataRequest<T: Decodable>(path: String,
method: HTTPMethod,
headers: [HTTPHeader] = [],
parameters: Request.Parameters? = nil) async throws -> T {
let url = try baseUrl().appendingPathComponent(path)
return try await dataRequest(
url: url,
method: method,
headers: headers,
parameters: parameters
)
}
}
И, наконец, объявить REST методы.
extension DataLoader {
func login(userName: String, password: String) async throws -> LoginResponse {
return try await dataRequest(
path: "auth/login",
method: .get,
parameters: ["userName": username, "password": password]
)
}
}
Несмотря на кажущуюся простоту решения, в результате на руках мощный инструмент для загрузки как данных, так и изображений.
class TestViewController: UIViewController {
func loadPreviews(of news: [NewsViewModel]) async {
await withTaskGroup(of: Void.self) { group in
for news in news {
group.addTask { [weak self] in
await self?.loadImage(of: news)
}
}
}
}
private func loadImage(of news: NewsViewModel) async {
guard let url = news.underlyingModel.preview else {
return
}
if let image = await imageLoader.loadImage(at: url) {
news.preview = Image(uiImage: image)
}
}
}
Заключение
Вполне возможно, что Apple представят своего конкурента Alamofire. Тем более, что Alamofire имеет недостатки и в качестве примера стоит привести спагетти код. В любом случае иметь под рукой такой немаловажный инструмент вовсе не будет лишним.
Комментарии (2)
thomasR
11.07.2022 20:50Нативная реализация запросов с использованием возможностей языка - это конечно сильно.
Но как всегда в сфере программирования ищутся пути побыстрее.
В своей практике использую связку Alamofire + ObjectMapper + PromiseKit - и асинхронность и удобная де/сеарилизация и общая организация вызовов.
ws233
Цитата по ссылке:
"Пример NSURLCache в очередной раз показывает нам, насколько важно знать возможности системы, с которой работаешь.
Многие разработчики изобретают свой велосипед для организации кеширования, так как не знают о возможностях NSURLCache, инициализация которого занимает всего 2 строчки кода и делает работу в 100 раз эффективнее. Еще большее число вообще не задумывается о преимуществах сетевого кеширования и не использует его, нагружая свой сервер огромным количеством ненужных запросов."
https://habr.com/ru/post/179349/