
По крайней мере в последнее десятилетие, количество приложений, которым требуется доступ в интернет, неимоверно возросло. Причем для большинства проектов требуется только выполнение 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 имеет недостатки и в качестве примера стоит привести спагетти код. В любом случае иметь под рукой такой немаловажный инструмент вовсе не будет лишним.
ссылка на оригинал статьи https://habr.com/ru/post/676092/
Добавить комментарий