16 марта 2023

➡️🍏 Сетевые запросы и REST API в iOS и Swift: протокольно-ориентированное программирование. Часть 2

iOS-developer, ИТ-переводчица, пишу статьи и гайды.
В заключительной части обсудим, как избежать повторения кода, решить проблему загрязненного интерфейса и как абстрагировать ресурсы API с помощью протоколов, дженериков и расширений.
➡️🍏 Сетевые запросы и REST API в iOS и Swift: протокольно-ориентированное программирование. Часть 2
Данная статья является переводом. Автор: Matteo Manferdini. Ссылка на оригинальную статью.

Глава 4: Протокольно-ориентированная архитектура сетевого слоя

Стандартные архитектурные подходы к сетевому уровню приложения для iOS нарушают общие принципы проектирования и создают код, полный повторений, который необходимо постоянно менять, чтобы освободить место для новых сетевых запросов и типов данных. Следуя протокольно-ориентированному подходу, мы можем избежать всех этих проблем.

Ресурсы API должны быть типами моделей

Показанный выше подход сетевого менеджера — не единственный, который можно найти в интернете, хотя он довольно стандартный.

Для решения проблем, показанных выше, вы можете вынести в отдельную структуру ресурсов все параметры, которые вызывают длинные условия или повторение кода.

Затем эту структуру ресурсов можно передать универсальному методу, который выполняет сетевой запрос.

Код обычно выглядит следующим образом:

        struct Resource<T> {
    let url: URL
    // Other properties and methods
}

class NetworkManager {
    func load<T>(resource: Resource<T>, withCompletion completion: @escaping (T?) -> Void) {
        let task = URLSession.shared.dataTask(with: resource.url) { [weak self] (data, _ , _) -> Void in
            guard let data = data else {
                DispatchQueue.main.async { completion(nil) }
                return
            }
            // Use the Resource struct to parse data
        }
        task.resume()
    }
}
    

Это, безусловно, шаг в правильном направлении, но полностью проблему не решает. Несмотря на то что произошло явное улучшение, остается еще одна проблема: структура ресурса перегружена кучей свойств и методов для представления всех возможных параметров и декодирования данных различными способами.

Имейте в виду: проблема не в количестве свойств или методов. Проблема во взаимоисключении.

Например, метод, который декодирует двоичные данные в изображения, нельзя использовать, если представленный ресурс возвращает данные JSON, и наоборот. Вызывающая сторона должна знать это, потому что тип раскрывает интерфейс, который должен быть использован конкретным, но еще не определенным способом.

Эта проблема называется загрязнением интерфейса (interface pollution), которая возникает, когда тип использует методы, которые ему не нужны. Загрязнение интерфейса — это следствие нарушения другого принципа SOLID, принципа разделения интерфейса .

Решение здесь состоит в том, чтобы разделить ресурсы на несколько типов, которые затем могут совместно использовать стандартный интерфейс и функциональные возможности посредством программно-ориентированного программирования.

Абстрагирование ресурсов API с помощью протоколов, дженериков и расширений

Начнем с ресурсов, предоставляемых REST API. Все удаленные ресурсы, независимо от их типа, имеют общий стандартный интерфейс. Ресурс имеет:

  1. URL-адрес, заканчивающийся путем, указывающим данные, которые мы извлекаем (например, запрос)
  2. опциональные параметры для фильтрации или сортировки данных в ответе;
  3. связанный тип модели, в который необходимо преобразовать данные.

Мы можем указать все эти требования, используя протокол. Затем, с расширением протокола, мы можем предоставить общую реализацию.

        protocol APIResource {
    associatedtype ModelType: Decodable
    var methodPath: String { get }
    var filter: String? { get }
}

extension APIResource {
    var url: URL {
        var components = URLComponents(string: "https://api.stackexchange.com/2.2")!
        components.path = methodPath
        components.queryItems = [
        URLQueryItem(name: "site", value: "stackoverflow"),
        URLQueryItem(name: "order", value: "desc"),
        URLQueryItem(name: "sort", value: "votes"),
        URLQueryItem(name: "tagged", value: "swiftui"),
        URLQueryItem(name: "pagesize", value: "10")
        ]
        if let filter = filter {
            components.queryItems?.append(URLQueryItem(name: "filter", value: filter))
        }
        return components.url!
    }
}
    

Вычисляемое url-свойство собирает полный URL-адрес ресурса, используя базовый URL-адрес API, параметр methodPath и различные параметры запроса.

Обратите внимание, что в приведенном выше коде большинство параметров жестко запрограммированы, поскольку они всегда одинаковы. Единственным исключением является необязательное свойство filter, которое нам понадобится в одних запросах, но не понадобится в других. Вы можете быстро преобразовать любой другой параметр в требование для протокола ApiResource, если вам нужен более детальный контроль над ним.

Благодаря этому протоколу, теперь легко создавать конкретные структуры для вопросов, ответов, пользователей или любого другого типа, предлагаемого API Stack Overflow.

В нашем небольшом примере приложения нам нужен только ресурс для вопросов.

        struct QuestionsResource: APIResource {
    typealias ModelType = Question
    var id: Int?
    
    var methodPath: String {
        guard let id = id else {
            return "/questions"
        }
        return "/questions/\(id)"
    }
    
    var filter: String? {
        id != nil ? "!9_bDDxJY5" : nil
    }
}
    

Эта структура содержит всю логику, связанную с удаленным ресурсом:

  1. Если указан идентификатор, мы запрашиваем данные одного запроса. В противном случае нам нужен список.
  2. Когда мы запрашиваем данные для одного запроса, мы включаем фильтр, который заставляет удаленный API возвращать дополнительные данные. В нашем случае это тело запроса.

Обратите также внимание на то, что и расширение протокола APIResource, и расширение QuestionsResource не содержат асинхронного кода, что значительно упрощает модульное тестирование.

Создание универсальных классов для выполнения вызовов API и других сетевых запросов.

Теперь, когда у нас есть представление ресурсов, предлагаемых API, нам действительно нужно создать несколько сетевых запросов.

Как мы видели, не все наши сетевые запросы отправляются в REST API. Медиафайлы обычно хранятся на CDN. Это означает, что нам нужно, чтобы наш сетевой код был универсальным и не был привязан к протоколу APIResource, который мы создали выше.

Итак, мы начинаем с анализа требований с точки зрения вызывающей стороны. Общий сетевой запрос требует:

  1. метод преобразования полученных данных в тип модели;
  2. способ запуска асинхронной передачи данных;
  3. обратный вызов для передачи обработанных данных обратно вызывающей стороне.

Мы снова объявляем эти требования, используя протокол:

        protocol NetworkRequest: AnyObject {
    associatedtype ModelType
    func decode(_ data: Data) -> ModelType?
    func execute(withCompletion completion: @escaping (ModelType?) -> Void)
}
    

Благодаря этим требованиям, мы можем абстрагировать код, который использует URLSession для выполнения сетевой передачи. Мы снова помещаем этот код в расширение протокола:

        extension NetworkRequest {
    fileprivate func load(_ url: URL, withCompletion completion: @escaping (ModelType?) -> Void) {
        let task = URLSession.shared.dataTask(with: url) { [weak self] (data, _ , _) -> Void in
            guard let data = data, let value = self?.decode(data) else {
                DispatchQueue.main.async { completion(nil) }
                return
            }
            DispatchQueue.main.async { completion(value) }
        }
        task.resume()
    }
}
    

Не забудьте добавить слабую ссылку на себя в список захвата обработчика завершения любого асинхронного метода, например, dataTask(with:completionHandler)или вы можете вызвать утечку памяти или неожиданные ошибки.

Как и ресурсы API, наши конкретные классы сетевых запросов будут основаны на протоколе NetworkRequest, предоставляя недостающие части, определенные требованиями протокола.

Самый простой тип сетевого запроса — это запрос для изображений, для которого нам нужен только URL:

        class ImageRequest {
    let url: URL
    
    init(url: URL) {
        self.url = url
    }
}

extension ImageRequest: NetworkRequest {
    func decode(_ data: Data) -> UIImage? {
        return UIImage(data: data)
    }
    
    func execute(withCompletion completion: @escaping (UIImage?) -> Void) {
        load(url, withCompletion: completion)
    }
}

    

Создать значение UIImage из полученного Data несложно. И поскольку нам не нужна какая-то конкретная конфигурация, метод execute(withCompletion:) из ImageRequest может просто вызвать метод load(_:withCompletion:) из NetworkRequest.

Вы снова видите, что этот подход позволяет нам создавать столько типов запросов, сколько нам нужно. Все, что нам нужно сделать, это добавить новые классы. Нет необходимости изменять существующий код, соблюдая принцип открытости-закрытости.

Теперь мы можем следовать тому же процессу и создать класс для запросов API.

        class APIRequest<Resource: APIResource> {
    let resource: Resource
    
    init(resource: Resource) {
        self.resource = resource
    }
}

extension APIRequest: NetworkRequest {
    func decode(_ data: Data) -> [Resource.ModelType]? {
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .secondsSince1970
        let wrapper = try? decoder.decode(Wrapper<Resource.ModelType>.self, from: data)
        return wrapper?.items
    }
    
    func execute(withCompletion completion: @escaping ([Resource.ModelType]?) -> Void) {
        load(resource.url, withCompletion: completion)
    }
}
    

Класс APIRequest использует универсальный тип Resource. Единственное требование состоит в том, чтобы ресурсы соответствовали APIResource. Соответствие протоколу NetworkRequest также не было таким сложным. Поскольку API возвращает данные JSON, все, что нам нужно сделать, это расшифровать полученное Data с помощью класса JSONDecoder.

➡️🍏 Сетевые запросы и REST API в iOS и Swift: протокольно-ориентированное программирование. Часть 2

Теперь у нас есть расширяемая протокольно-ориентированная архитектура, которую мы можем расширять по своему усмотрению. Мы можем добавлять новые ресурсы API по мере необходимости или новые типы сетевых запросов для отправки данных или загрузки других типов медиафайлов.

Глава 5: Выполнение сетевых запросов в реальном приложении SwiftUI

После создания полностью рабочего сетевого уровня получение данных в приложении SwiftUI становится простым. Все, что вам нужно сделать, это собрать вместе различные части, которые скроют все детали реализации от ваших представлений и моделей данных SwiftUI.

Выполнение сетевых запросов внутри моделей представления.

Наконец-то мы подошли к последней фазе этой длинной статьи. Здесь мы будем получать данные из API Stack Exchange и отображать их на экране.

Мы собираемся выполнять сетевые запросы в моделях представления/данных, следуя паттерну MVVM. Хотя это также требует подробного объяснения, этот вопрос выходит за рамки данной статьи. Вы можете обратиться к моей другой статье, чтобы углубиться в паттерн.

Начнем с первого экрана нашего приложения.

Прежде всего, нам нужен объект модели данных, который извлекает самые популярные вопросы из Stack Overflow.

        class QuestionsDataModel: ObservableObject {
    @Published private(set) var questions: [Question] = []
    @Published private(set) var isLoading = false
    
    private var request: APIRequest<QuestionsResource>?
    
    func fetchTopQuestions() {
        guard !isLoading else { return }
        isLoading = true
        let resource = QuestionsResource()
        let request = APIRequest(resource: resource)
        self.request = request
        request.execute { [weak self] questions in
            self?.questions = questions ?? []
            self?.isLoading = false
        }
    }
}
    

Обратите внимание, что на этом уровне все, что нам нужно сделать, это:

• создать переменную QuestionsResource;

• передать ее новому экземпляру APIRequest;

• выполнить сетевой запрос;

• и сохранять возвращенные вопросы в свойстве @Published для обновления пользовательского интерфейса.

То, как работает наша сетевая инфраструктура, полностью скрыто от нашей модели представления. Нет нужды беспокоиться о сеансах, URL-адресах или декодировании JSON.

Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека мобильного разработчика»

Запуск сетевых запросов в представлениях SwiftUI и заполнение пользовательского интерфейса

Теперь мы можем позаботиться о пользовательском интерфейсе. Давайте начнем с некоторых расширений для форматирования данных в наших представлениях.

        extension Int {
    var thousandsFormatting: String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .decimal
        let number = self > 1000
        ? NSNumber(value: Float(self) / 1000)
        : NSNumber(value: self)
        return formatter.string(from: number)!
    }
}

extension Date {	
    var formatted: String {
        let formatter = DateFormatter()
        formatter.dateStyle = .medium
        return formatter.string(from: self)
    }
}

extension Color {
    static var teal: Color {
        Color(UIColor.systemTeal)
    }
}
    

Нам также нужны некоторые данные для заполнения наших предварительных просмотров Xcode. Мы можем получить некоторые данные JSON непосредственно из API Stack Exchange и сохранить их в файле с именем Questions.json. Затем мы загружаем его в специальную структуру с помощью файла JSONdecoder.

        struct TestData {
    static var Questions: [Question] = {
        let url = Bundle.main.url(forResource: "Questions", withExtension: "json")!
        let data = try! Data(contentsOf: url)
        let wrapper = try! JSONDecoder().decode(Wrapper<Question>.self, from: data)
        return wrapper.items
    }()
    
    static let user = User(name: "Lumir Sacharov", reputation: 2345, profileImageURL: nil)
}
    

Поскольку оба экрана в нашем приложении имеют общие элементы дизайна, мы можем создать повторно используемое представление.

        struct Details: View {
    let question: Question
    
    private var tags: String {
        question.tags[0] + question.tags.dropFirst().reduce("") { $0 + ", " + $1 }
    }
    
    var body: some View {
        VStack(alignment: .leading, spacing: 8.0) {
            Text(question.title)
            .font(.headline)
            Text(tags)
            .font(.footnote)
            .bold()
            .foregroundColor(.accentColor)
            Text(question.date.formatted)
            .font(.caption)
            .foregroundColor(.secondary)
            ZStack(alignment: Alignment(horizontal: .leading, vertical: .center)) {
                Label("\(question.score.thousandsFormatting)", systemImage: "arrowtriangle.up.circle")
                Label("\(question.answerCount.thousandsFormatting)", systemImage: "ellipses.bubble")
                .padding(.leading, 108.0)
                Label("\(question.answerCount.thousandsFormatting)", systemImage: "eye")
                .padding(.leading, 204.0)
            }
            .foregroundColor(.teal)
        }
        .padding(.top, 24.0)
        .padding(.bottom, 16.0)
    }
}

struct TopQuestionsView_Previews: PreviewProvider {
    static var previews: some View {
        Details(question: TestData.Questions[0])
        .previewLayout(.sizeThatFits)
    }
}
    
➡️🍏 Сетевые запросы и REST API в iOS и Swift: протокольно-ориентированное программирование. Часть 2

И, наконец, мы можем собрать представление на весь экран.

        struct TopQuestionsView: View {
    @StateObject private var dataModel = QuestionsDataModel()
    
    var body: some View {
        List(dataModel.questions) { question in
            NavigationLink(destination: QuestionView(question: question)) {
                Details(question: question)
            }
        }
        .navigationTitle("Top Questions")
        .onAppear {
            dataModel.fetchTopQuestions()
        }
    }
}
    

QuestionView в приведенном выше коде еще не существует, поэтому ваш код не будет скомпилирован. Если вы хотите запустить приложение на симуляторе и увидеть, как экран заполняется вопросами, замените QuestionView на EmptyView или TextView.

➡️🍏 Сетевые запросы и REST API в iOS и Swift: протокольно-ориентированное программирование. Часть 2

Последовательность асинхронных сетевых запросов

Представление, показывающее подробности вопроса, работает в основном таким же образом, но с существенным отличием. Ему необходимо последовательно выполнить два сетевых запроса: один для тела вопроса и один для изображения профиля владельца вопроса.

Это одна из причин, по которой некоторым разработчикам нравится использовать внешние библиотеки или фреймворк FRP, например, Combine. Чтобы упорядочить два сетевых запроса, вы должны вложить второй в замыкание завершения первого.

Это может легко привести к проблемам с обратным вызовом.

Но вам не нужна какая-то сложная структура, чтобы решить эту проблему. Вместо этого поместите каждый сетевой запрос в отдельный метод. Это также поможет вам сохранить хорошую организацию кода.

        class QuestionDataModel: ObservableObject {
    @Published var question: Question
    @Published var isLoading = false
    
    private var questionRequest: APIRequest<QuestionsResource>?
    private var imageRequest: ImageRequest?
    
    init(question: Question) {
        self.question = question
    }
    
    func loadQuestion() {
        guard !isLoading else { return }
        isLoading = true
        let resource = QuestionsResource(id: question.id)
        let request = APIRequest(resource: resource)
        self.questionRequest = request
        request.execute { [weak self] questions in
            guard let question = questions?.first else { return }
            self?.question = question
            self?.loadOwnerAvatar()
        }
    }
}

private extension QuestionDataModel {
    func loadOwnerAvatar() {
        guard let url = question.owner?.profileImageURL else { return }
        let imageRequest = ImageRequest(url: url)
        self.imageRequest = imageRequest
        imageRequest.execute { [weak self] image in
            self?.question.owner?.profileImage = image
            self?.isLoading = false
        }
    }
}
    

Опять же, оба метода только создают запрос и выполняют его. Детали реализации не просачиваются в наши модели представления.

Мы почти закончили. Все, что нам осталось, это построить экран для одного запроса.

Прежде всего, давайте создадим представление для отображения данных владельца, включая его изображение профиля.

        struct Owner: View {
    let user: User
    
    private var image: Image {
        guard let profileImage = user.profileImage else {
            return Image(systemName: "questionmark.circle")
        }
        return Image(uiImage: profileImage)
    }
    
    var body: some View {
        HStack(spacing: 16.0) {
            image
            .resizable()
            .frame(width: 48.0, height: 48.0)
            .cornerRadius(8.0)
            .foregroundColor(.secondary)
            VStack(alignment: .leading, spacing: 4.0) {
                Text(user.name ?? "")
                .font(.headline)
                Text(user.reputation?.thousandsFormatting ?? "")
                .font(.caption)
                .foregroundColor(.secondary)
            }
        }
        .padding(.vertical, 8.0)
    }
}

struct QuestionView_Previews: PreviewProvider {
    static var previews: some View {
        Owner(user: TestData.user)
        .previewLayout(.sizeThatFits)
    }
}
    
➡️🍏 Сетевые запросы и REST API в iOS и Swift: протокольно-ориентированное программирование. Часть 2

Затем мы собираем полное представление, используя представления Owner и Details (создавали в предыдущем разделе).

        struct QuestionView: View {
    @StateObject private var dataModel: QuestionDataModel
    
    init(question: Question) {
        let dataModel = QuestionDataModel(question: question)
        _dataModel = StateObject(wrappedValue: dataModel)
    }
    
    var body: some View {
        ScrollView(.vertical) {
            LazyVStack(alignment: .leading) {
                Details(question: dataModel.question)
                if dataModel.isLoading {
                    ProgressView()
                    .frame(maxWidth: .infinity, alignment: .center)
                } else {
                    if let body = dataModel.question.body {
                        Text(body)
                    }
                    if let owner = dataModel.question.owner {
                        Owner(user: owner)
                        .frame(maxWidth: .infinity, alignment: .trailing)
                    }
                }
            }
            .padding(.horizontal, 20.0)
        }
        .navigationTitle("Detail")
        .onAppear {
            dataModel.loadQuestion()
        }
    }
}
    

Пока данные загружаются, мы показываем ProgressView и скрываем остальную часть пользовательского интерфейса. Это простое решение, но вы можете усложнить его настолько, насколько захотите.

Важно то, что мы берем информацию о прогрессе из модели данных. Представлению не нужно ничего знать о выполняемых сетевых запросах или асинхронном коде.

➡️🍏 Сетевые запросы и REST API в iOS и Swift: протокольно-ориентированное программирование. Часть 2

Если у вас быстрое подключение к Интернету, пользовательский интерфейс может появиться сразу. Чтобы увидеть, как сетевое приложение работает с медленными соединениями, вы можете использовать network link conditioner, чтобы замедлить ваши сетевые запросы.

Вывод

В этой статье я показал вам не только как отправлять сетевые запросы к удаленному REST API, но и как структурировать сетевой уровень в ваших приложениях. Хотя пример приложения, которое мы создали — простой, вы могли заметить, что оно уже сопряжено с большой сложностью. Правильное проектирование сетевого кода — это инвестиция, которая принесет хорошие дивиденды в будущем, поскольку добавление новых вызовов API в приложение становится простым, с более высокой долей повторного использования кода.

Важные концепции, которые следует помнить:

  • REST API основан на URL-адресах и протоколе HTTP.

Архитектура REST для веб-служб использует URL-адреса для указания ресурсов и параметров и методы HTTP для идентификации действий. В ответах используются коды состояния HTTP для выражения результатов и тело ответа для возврата запрошенных данных, часто в формате JSON.

  • Вам не нужна сетевая библиотека, такая как AlamoFire или AFNetworking.

Внешние библиотеки добавляют в ваше приложение зависимости и ограничения. Сторонние библиотеки могут изменяться или ломаться без предварительного уведомления, и вам придется адаптировать свою архитектуру к выбору решения, сделанного кем-то другим.

  • Вы выполняете сетевые запросы в iOS, используя систему загрузки URL.

Есть три типа, которые вам нужны для выполнения сетевых запросов. Класс URLSession обрабатывает сеансы HTTP, структура URLRequest представляет один запрос в сеансе, а класс URLSessionTask— это тип, который выполняет асинхронную передачу данных.

  • Монолитные сетевые менеджеры создают проблемы в вашем коде.

Многие разработчики помещают весь сетевой код в один класс менеджера. Это нарушает здравые принципы разработки программного обеспечения и создает код, который трудно изменить, легко сломать и трудно протестировать.

  • Протокольно-ориентированное программирование — лучший инструмент для построения сетевого уровня ваших приложений.

Используя комбинацию протоколов, расширений и конкретных типов, вы можете создать гибкую иерархию, которую легко расширить с помощью новых ресурсов и сетевых запросов.

Источники

МЕРОПРИЯТИЯ

Комментарии

ВАКАНСИИ

Добавить вакансию
Go-разработчик
по итогам собеседования
Java Team Lead
Москва, по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ