Получение удаленные данных в iOS. Swift 3 версия

в 18:08, , рубрики: api, iOS, swift, swift 3, разработка под iOS

Данная статья является обновлением статьи Получение удаленных данных в iOS, написанной в ноябре 2015 с использованием Objective-C и потому морально устарешней. Сейчас же будет приведен код, переписанный на Swift 3 и iOS 10 (последней версией является Swift 4.1 и iOS 11, но мой компьютер их уже не поддерживает).

Краткая теория

Формат url

http://www.google.com/?q=Hello&safe=off

  • http — протокол, который определяет, по какому стандарту делается запрос. Еще варианты: https, ftp, file
  • www.google.com — имя домена
  • / — директория, где находятся необходимые нам ресурсы.
  • После вопросительного знака (?) идут параметры q=Hello&safe=off. Они состоят из пар ключ-значение.
  • При запросе также указывается метод, который говорит, как сервер должен обрабатывать этот запрос. По умолчанию, это метод GET.

Данный url из примера можно прочитать таким образом: http запрос с методом GET отправляется домену google.com, в корневую директорию /, с двумя параметрами q со значением Hello и safe со значением off.

http заголовок

Браузер преобразует строку url в заголовок и тело запроса. Для http-запроса тело пустое, а заголовок представлен следующим образом

GET /?q=Hello&safe=off HTTP/1.1
Host: google.com
Content-Length: 133
// здесь пустая строка
// и здесь пустая строка

Cхема запроса на сервер

Сначала создается запрос (request), потом устанавливается соединение (connection), посылается запрос и приходит ответ (response).

Делегаты сессии

Все UI операции (связанные с пользовательским интерфейсом) выполняются в главном потоке. Нельзя просто взять и остановить этот поток, пока выполняется какая-то ресурсоемкая операция. Поэтому одним из решений этой проблемы было создание делегатов. Таким образом, операции становятся асинхронными, а главный поток выполняется без остановок. Когда же нужная операция будет выполнена, то будет вызван соответствующий метод делегата. Второе решение проблемы — создание нового потока выполнения.

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

Описание видов делегатов сессии

Мы используем NSURLSessionDownloadDelegate и реализуем его метод URLSession:downloadTask:didFinishDownloadingToURL:. То есть по сути скачиваем данные с шуткой во временное хранилище, и, когда загрузка завершена, вызываем метод делегата для обработки.

Переход в главный поток

Загрузка данных во временное хранилище осуществляется не в главном потоке, но чтобы использовать эти данные для изменения UI мы перейдем в главный поток.

«Убегающее» замыкание (@escaping)

Так как в силу реализации кода, замыкание которое мы передаем в метод загрузки данных с url, переживет сам метод, то для Swift 3 необходимо явно обозначить его @escaping, а self сделать unowned, чтобы не происходило захвата и удержания ссылки self в этом замыкании. Но это уже нюансы реализации самого языка Swift, а не техонологии получения данных по API.

Переадресация (редиректы)

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

Схема сериализации

Сериализация — это процесс перевода данных из одного вида хранения в другой, без потери содержания. Например, хранятся данные в двоичном виде, чтобы занимать меньше места, а при пересылке по сети их преобразуют в универсальный JSON (JavaScript Object Notation) формат, который уже мы расшифровываем и переводим в объекты нашей среды программирования.

Пример JSON:

{
"name": "Martin Conte Mac Donell",
"age": 29,
"username": "fz"
}

Фигурные скобки обозначают словарь (dictionary), а объекты внутри словаря представлены парами ключ-значение.

API (Application Programming Interface)

В нашем случае API представлен адресом, откуда мы будет получать случайные шутки и форматов JSON ответа, который нам нужно разобрать в удобные для манипулирования структуры

http://api.icndb.com/jokes/random

Пример icndb API:

{
"type": "success",
"value": 
  {
   "id": 201,
   "joke": "Chuck Norris was what Willis was talkin’ about"
   }
}

А теперь практика

Весь проект, как и прошлый раз, реализован в коде, без использования storyboard. Весь код написан в 3х файлах: AppDelegate.swift, MainViewController.swift и HTTPCommunication.swift. AppDelegate.swift содержит общую настройку приложения. HTTPCommunication.swift осуществляет настройку соединения (запрос, сессия) и получение данных. В MainViewController.swift эти данные сериализуются для вывода, а также содержится код пользовательского интерфейса.

Создаем пустой проект. Для простоты пишем приложение только для iPhone. Удаляем ViewController.swift, Main.storyboard и в Info.plist также удаляем ссылку на storyboard, а именно строку Main storyboard file base name — String — Main.

По умолчанию App Transport Security в iOS блокирует загрузки из интернета по обычному http (не https), поэтому в Info.plist добавляем строку App Transport Security Settings и для этих настроек создаем ключ Allow Arbitrary Loads, которые выставляем в YES. Если открыть Info.plist как source code, то наш добавляемый код выглядит так:

<key>NSAppTransportSecurity</key>
	<dict>
		<key>NSAllowsArbitraryLoads</key>
		<true/>
	</dict>

Теперь в AppDelegate.swift переписываем application(_:didFinishLaunchingWithOptions:) следующим образом:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
        self.window = UIWindow(frame: UIScreen.main.bounds)    // (A.1)
        let navC: UINavigationController = UINavigationController(rootViewController: MainViewController())    // (A.2)
        self.window?.rootViewController = navC

        self.window?.backgroundColor = UIColor.white    // (A.3)
        self.window?.makeKeyAndVisible()    // (A.4)
        return true
    }

(A.1) Мы задаем размер окна равный размеру экрана UScreen.main.bound.
(A.2) Создаем MainViewController и сразу embed его в NavigationController, который понадобиться во второй части. NavigationController делает rootViewController окна.
(A.3) Цвет фона белый
(A.4) И не забывает сделать окно ключевым и видимым.

Создаем файл HTTPCommunication.swift. И пишем в нем следующий код.

import Foundation

class HTTPCommunication: NSObject {    // (B.1)
    var completionHandler: ((Data) -> Void)!    // (B.2)

    func retrieveURL(_ url: URL, completionHandler: @escaping ((Data) -> Void)) {    // (B.3)
    }
}

extension HTTPCommunication: URLSessionDownloadDelegate {    // (B.4)
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {    // (B.5)
    }
}

(B.1) Наследуем от NSObject, чтобы conforms NSObjectProtocol, потому что URLSessionDownloadDelegate наследует от этого протокола, а раз мы ему подчиняемся(conforms), то должны и родительскому протоколу.
(B.2) Свойство completionHandler в классе — это замыкание, которое будет содержать код обработки полученных с сайта данных и вывода их в интерфейсе нашего приложения.
(B.3) retrieveURL(_: completionHandler:) осуществляет загрузку данных с url во временное хранилище
(B.4) Мы создаем расширение класса, которое наследует от NSObject и подчиняется(conforms) протоколу URLSessionDownloadDelegate, чтобы использовать возможности данного протокола для обработки загруженных данных.
(B.5) urlSession(_:downloadTask: didFinishDownloadingTo) вызывается после успешной загрузки данных с сайта во временное хранилище для их последующей обработки.

Теперь распишем код данных функций.

Копируем код retrieveURL(_ url:, completionHandler:)

func retrieveURL(_ url: URL, completionHandler: @escaping ((Data) -> Void)) {    // (C.1)
        self.completionHandler = completionHandler    // (C.2)
        let request: URLRequest = URLRequest(url: url)    // (C.3)
        let conf: URLSessionConfiguration = URLSessionConfiguration.default    // (C.4)
        let session: URLSession = URLSession(configuration: conf, delegate: self, delegateQueue: nil)  
  // (C.5)
        let task: URLSessionDownloadTask = session.downloadTask(with: request)    // (C.6)
        task.resume()    // (C.7)
    }

(C.1) С замыканием мы будем работать вне этой функции, поэтому мы обозначаем ее @escaping.
(C.2) Мы сохраняем переданное замыкание в свойство completionHandler.
(C.3) Инициализируем запрос переданным url.
(C.4) Создаем конфигурацию сессии по умолчанию.
(C.5) Создаем сессию.
(C.6) Создаем задачу загрузки.
(C.7) Так как задача всегда создается в остановленном состоянии, мы запускаем ее.

Копируем код func urlSession(_ session:, downloadTask:, didFinishDownloadingTo:)

func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
        do {
            let data: Data = try Data(contentsOf: location)	    // (D.1)
            DispatchQueue.main.async(execute: {    // (D.2)
                self.completionHandler(data)
            })
        } catch {
            print("Can't get data from location.")
        }
    }

(D.1) Мы получаем данные на основе сохраненных во временное хранилище данных. Поскольку данная операция может вызвать исключение, мы используем try, а саму операцию заключаем в блок do {} catch {}
(D.2) Далее мы выполняем completionHandler с полученными данными. А так как загрузка происходила асинхронно в фоновой очереди, то для возможности изменения интерфейса, которой работает в главной очереди, нам нужно выполнить замыкание в главной очереди.

Создаем файл MainViewController.swift и копируем следующий код

import UIKit

class MainViewController: UIViewController {

    lazy var jokeLabel: UILabel = {    // (E.1)
        self.configLabel()
    }()	
    var jokeID: Int = 0
lazy var activityView: UIActivityIndicatorView = {
        self.configActivityView()
    }()		

lazy var stackView: UIStackView! = {
        self.configStackView()
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

	self.title = "Chuck Norris Jokes"

        self.configConstraints()    // (E.2)

        self.retrieveRandomJokes()    // (E.3)
    }	

    func retrieveRandomJokes() {
    }
}

extension MainViewController {    // (E.4)
    func configActivityView() -> UIActivityIndicatorView {
}

   func configLabel() -> UILabel {
}

func configStackView() -> UIStackView {
}

func configConstraints() {
}

}

(E.1) Создаем label, которая будет отображать шутку про Чака Норриса. Идентификатор (id) шутки понадобится для второй части статьи. ActivityView индикатор будет вращаться, пока не будет получена шутка, затем он исчезнет. StackView используется для визуального представления (layout).
(E.2) В viewDidLoad() вызываем configConstraints(), в которой настраивается stackView и activityView, что вызывает инициализацию их ленивых переменных. В свою очередь инициализация stackView вызывает инициализацию ленивой переменной label.
(E.3) Также в viewDidLoad() вызываем retrieveRandomJokes(), которая содержит весь функционал по работе с интернетом и получению шутки.
(E.4) Настройка label, activityView, stackView и constraints вынесены в расширение.

Теперь подробнее о каждой функции.

Копируем код configActivityView()

func configActivityView() -> UIActivityIndicatorView {
        let activityView: UIActivityIndicatorView = UIActivityIndicatorView(activityIndicatorStyle: .gray) 
   // (F.1)
        activityView.hidesWhenStopped = true    // (F.2)
        activityView.startAnimating()    // (F.3)
        view.addSubview(activityView)    // (F.4)
        return activityView
    }

(F.1) Создаем actitivyView серого цвета.
(F.2) Когда он перестанет вращаться, он исчезнет.
(F.3) Начинаем анимацию вращения.
(F.4) И добавляем в иерархию вьюх.

Копируем код configLabel()

func configLabel() -> UILabel {
        let label: UILabel = UILabel(frame: CGRect.zero)    // (G.1)
        label.lineBreakMode = .byWordWrapping		
        label.textAlignment = .center				
        label.numberOfLines = 0				
        label.font = UIFont.systemFont(ofSize: 16) 		
        label.sizeToFit()    // (G.2)
        self.view.addSubview(label)    // (G.3)
        return label
    }

(G.1) Создаем label с первоначальным нулевым фреймом; задаем переносы строк по словам; задаем выравнивание по центру; делаем label многострочной, для чего выставляем количество строк в 0; выставляем системный шрифт размера 16.
(G.2) Для того, чтобы label по размеру соответствовал внутреннему содержимому, вызываем sizeToFit().
(G.3) И добавляем в иерархию вьюх.

Копируем код configStackView()

func configStackView() -> UIStackView {
        let mainStackView: UIStackView = UIStackView(arrangedSubviews: [self.jokeLabel])    // (H.1)
        mainStackView.spacing = 50    // (H.2)
        mainStackView.axis = .vertical
        mainStackView.distribution = .fillEqually
        self.view.addSubview(mainStackView)    // (H.3)
        return mainStackView
    }

(H.1) Создаем stackView, содержающую label.
(H.2) Настраиваем расстояние между элементами 50, оно понадобиться во второй части; оси — вертикальные; распределение одинаковое.
(H.3) И добавляем в иерархию вьюх.

Копируем код configConstraints()

func configConstraints() {
        self.stackView.translatesAutoresizingMaskIntoConstraints = false    // (I.1)
        NSLayoutConstraint.activate([    // (I.2)
            self.stackView.centerYAnchor.constraint(equalTo: self.view.centerYAnchor),
            self.stackView.leadingAnchor.constraint(equalTo: self.view.layoutMarginsGuide.leadingAnchor),
            self.stackView.trailingAnchor.constraint(equalTo: self.view.layoutMarginsGuide.trailingAnchor)
            ])

        self.activityView.translatesAutoresizingMaskIntoConstraints = false    // (I.1)
        NSLayoutConstraint.activate([    // (I.3)
            self.activityView.centerXAnchor.constraint(equalTo: self.jokeLabel.centerXAnchor),
            self.activityView.centerYAnchor.constraint(equalTo: self.jokeLabel.centerYAnchor)
            ])
    }

(I.1) Задаем перевод autoresizingMask в ограничения(constraints) как false, чтобы не создавать конфликт с нашими собственными ограничениями(constraits).
(I.2) Активируем сразу массив ограничений (constraints) в количестве трех: центр по оси Y равен центру view по оси Y; leading равен leading margins и trailing равен trailing margins.
(I.3) Активируем массив ограничений (constraints) для activityView, чтобы он показывался на месте label: центр по X и Y равен центру label по X и Y.

Разобрались с интерфейсом, теперь можно заполнять функционал.

Вот код retrieveRandomJokes()

func retrieveRandomJokes() {
        let http: HTTPCommunication = HTTPCommunication()    // (J.1)
        guard let url = URL(string: "http://api.icndb.com/jokes/random")	else { return }    // (J.2)
           http.retrieveURL(url) {    // (J.3)
		 [unowned self] (data) -> Void in    // (J.4)
      let json: String = String(data: data, encoding: String.Encoding.utf8)!    // (J.5)
                print("JSON: ", json)							
                do {
                    let jsonObject: [String: Any] = try JSONSerialization.jsonObject(with: data, options: []) as! [String: Any]    // (J.6)				
                    if let value = jsonObject["value"] as? [String: Any], let id = value["id"] as? Int, let joke = value["joke"] as? String {    // (J.7)
		   self.activityView.stopAnimating()    // (J.8)
                        self.jokeID = id    // (J.9)
                        self.jokeLabel.text = joke
                    }
                } catch {
                    print("Can't serialize data.")
                }
            }
        }

(J.1) Создаем http — экземпляр класса HTTPCommunication, который выполняет все запросы.
(J.2) Через guard из строки адреса получаем url, с которым будем дальше работать, или возвращаемся из метода в противном случае.
(J.3) Мы вызываем метод retrieveURL() для объекта http и передаем ему в качестве параметров наш url и наше замыкание типа (data: Data) -> Void. Это замыкание и содержит код по обработке уже полученных “сырых” данных и извлечения из них шутки. Поскольку замыкание стоит последним параметром, то применяем синтаксический сахар и передаем замыкание сразу за скобками.
(J.4) Посколько замыкание @escaping, то есть “переживет” вызов функции, то оно будет удерживать self. Чтобы это избежать, ставим unowned self.
(J.5) Сначала мы переводим сырые данные в строковый формат с кодировкой utf8 и распечатываем их. Делаем это только для того, чтобы узнать формат пересланных данных. Формат следующий:
JSON: { "type": "success", "value": { "id": 391, "joke": "TNT was originally developed by Chuck Norris to cure indigestion.", "categories": [] } }
Как видим, это словарь с двумя ключами: type и “value”, нас интересует ключ “value”, который также является словарем с ключами “id”, “joke” и “categories”.
(J.6) Затем сериализуем JSON данные в словарь типа [String: Any]. Поскольку сериализация может вызвать исключение мы вызываем ее с try, а сам код заключаем в блок do {} catch {}
(J.7) Выполняем три последовательных опциональных связывания (optional binding) и получаем необходимые нам id и joke из словаря.
(J.8) Когда данные наверняка получены и расшифрованы, мы останавливаем наш индикатор и он исчезает.
(J.9) Присваиваем id и joke заданным нами ранее свойствам класса. И label появляется на месте нашего индикатора.

Теперь запускаем приложение и получаем следующий результат.

Пока мы ждем получения шутки с сайта.

image

Наконец, шутка загружена и отображена.

image

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

Автор: 2husher

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js