Взаимодействие с сервером через API в iOS на Swift 3. Часть 1

  • Tutorial
Данная статья является обновлением статьи Получение удаленных данных в 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, как показано ниже. Для этого открываем Info.plist как source code, то и добавляем следующий код:

<key>NSAppTransportSecurity</key>
	<dict>
		<key>NSAllowsArbitraryLoads</key>
		<false/>
		<key>NSExceptionDomains</key>
		<dict>
			<key>api.icndb.com</key>
			<dict>
				<key>NSExceptionAllowsInsecureHTTPLoads</key>
				<true/>
				<key>NSIncludesSubdomains</key>
				<true/>
			</dict>
		</dict>
	</dict>

Мы, как и по умолчанию, запрещает произвольные загрузки: ключ NSAllowsArbitraryLoads в false. Но добавляем в виде исключения наш домен с шутками и все поддомены: значения ключа NSExceptionDomains.

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

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
    self.window = UIWindow(frame: UIScreen.main.bounds)

    // Объект MainViewController встраиваем в NavigationController, 
    // который понадобиться во второй части. 
    let navC: UINavigationController = UINavigationController(rootViewController: MainViewController()) 
    self.window?.rootViewController = navC

    self.window?.backgroundColor = UIColor.white 
    self.window?.makeKeyAndVisible()
    return true
}


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

import UIKit

// Наследуем от NSObject, чтобы подчиняться (conform) NSObjectProtocol,
// потому что URLSessionDownloadDelegate наследует от этого протокола, 
// а раз мы ему подчиняемся, то должны и родительскому протоколу.
class HTTPCommunication: NSObject {  
    // Свойство completionHandler в классе - это замыкание, которое будет
    // содержать код обработки полученных с сайта данных и вывода их
    // в интерфейсе нашего приложения.
    var completionHandler: ((Data) -> Void)!   
    
    // retrieveURL(_: completionHandler:) осуществляет загрузку данных
    // с url во временное хранилище
    func retrieveURL(_ url: URL, completionHandler: @escaping ((Data) -> Void)) {
    }
}

// Мы создаем расширение класса, которое наследует от NSObject
// и подчиняется(conforms) протоколу URLSessionDownloadDelegate, 
// чтобы использовать возможности данного протокола для обработки 
// загруженных данных.
extension HTTPCommunication: URLSessionDownloadDelegate { 

    // Данный метод вызывается после успешной загрузки данных
    // с сайта во временное хранилище для их последующей обработки.
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { 
    }
}


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

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

// С замыканием мы будем работать вне этой функции, 
// поэтому мы обозначаем ее @escaping.
func retrieveURL(_ url: URL, completionHandler: @escaping ((Data) -> Void)) {  
    self.completionHandler = completionHandler
    let request: URLRequest = URLRequest(url: url)
    let session: URLSession = URLSession(configuration: .default, delegate: self, delegateQueue: nil) 
    let task: URLSessionDownloadTask = session.downloadTask(with: request)
    // Так как задача всегда создается в остановленном состоянии, 
    // мы запускаем ее.
    task.resume() 
}


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

func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
    do {
        // Мы получаем данные на основе сохраненных во временное
        // хранилище данных. Поскольку данная операция может вызвать
        // исключение, мы используем try, а саму операцию заключаем 
        // в блок do {} catch {}
        let data: Data = try Data(contentsOf: location)
        // Далее мы выполняем completionHandler с полученными данными.
        // А так как загрузка происходила асинхронно в фоновой очереди, 
        // то для возможности изменения интерфейса, которой работает в 
        // главной очереди, нам нужно выполнить замыкание в главной очереди.
        DispatchQueue.main.async(execute: {  
            self.completionHandler(data)
        })
    } catch {
        print("Can't get data from location.")
    }
}


Создаем файл MainViewController.swift и копируем следующий код, который создает необходимый интерфейс:

import UIKit

class MainViewController: UIViewController {

    lazy var jokeLabel: UILabel = { 
        let label: UILabel = UILabel(frame: CGRect.zero) 
        label.lineBreakMode = .byWordWrapping		
        label.textAlignment = .center				
        label.numberOfLines = 0				
        label.font = UIFont.systemFont(ofSize: 16) 		
        label.sizeToFit() 
        self.view.addSubview(label)
        return label
    }()	

    // Идентификатор шутки понадобится для второй части статьи.
    var jokeID: Int = 0

    // ActivityView индикатор будет вращаться, пока не будет 
    // получена шутка, затем он исчезнет.
    lazy var activityView: UIActivityIndicatorView = {
        let activityView: UIActivityIndicatorView = UIActivityIndicatorView(activityIndicatorStyle: .gray)
        activityView.hidesWhenStopped = true
        activityView.startAnimating() 
        view.addSubview(activityView) 
        return activityView
    }()		

    lazy var stackView: UIStackView = {
        let mainStackView: UIStackView = UIStackView(arrangedSubviews: [self.jokeLabel])
        // Расстояние между элементами понадобиться во второй части
        mainStackView.spacing = 50   
        mainStackView.axis = .vertical
        mainStackView.distribution = .fillEqually
        self.view.addSubview(mainStackView)    
        return mainStackView
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

	self.title = "Chuck Norris Jokes"

        // В данном методе настраивается stackView и activityView, 
        // что вызывает инициализацию их ленивых переменных. 
        // В свою очередь инициализация stackView вызывает 
        // инициализацию ленивой переменной label.
        self.configConstraints()    // (E.2)

        // Данный метод содержит весь функционал по работе
        // с интернетом и получению шутки.
        self.retrieveRandomJokes()    // (E.3)
    }	

    func retrieveRandomJokes() {
    }
}

extension MainViewController {
  
    func configConstraints() {
        // Задаем перевод autoresizingMask в ограничения(constraints) 
        // как false, чтобы не создавать конфликт с нашими собственными 
        // ограничениями 
        self.stackView.translatesAutoresizingMaskIntoConstraints = false 
        NSLayoutConstraint.activate([   
            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   
        // Активируем массив ограничений (constraints) для activityView, 
        // чтобы он показывался на месте label: центр по X и Y равен 
        // центру label по X и Y.
        NSLayoutConstraint.activate([   
            self.activityView.centerXAnchor.constraint(equalTo: self.jokeLabel.centerXAnchor),
            self.activityView.centerYAnchor.constraint(equalTo: self.jokeLabel.centerYAnchor)
            ])
    }
}


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

Вот код retrieveRandomJokes()

func retrieveRandomJokes() {
    let http: HTTPCommunication = HTTPCommunication()
    // Посколько мы жестко кодируем url в код, то и сразу force unwrap его
    // Если url невалидный, то наше приложение уже бесполезно
    let url: URL = URL(string: "http://api.icndb.com/jokes/random")!    

    http.retrieveURL(url) {
        // Чтобы избежать захвата self в замыкании, делаем weak self
        [weak self] (data) -> Void in
        
        // Получаем и распечатываем строковое представление json
        // данных, чтобы знать, в какой формат их переводить. Если 
        // не можем получить нормальный json из загруженных данных,
        // то дальше уже не идем.
        guard let json = String(data: data, encoding: String.Encoding.utf8) else { return }
        // Пример распечатки: JSON:  { "type": "success", "value": 
        // { "id": 391, "joke": "TNT was originally developed by Chuck 
        // Norris to cure indigestion.", "categories": [] } }
        print("JSON: ", json)							
            
        do {
            let jsonObjectAny: Any = try JSONSerialization.jsonObject(with: data, options: [])

            // Проверяем, что мы можем переводить данные из Any
            // в нужный нам формат, иначе дальше не идем.
            guard 
                let jsonObject = jsonObjectAny as? [String: Any],
                let value = jsonObject["value"] as? [String: Any],
                let id = value["id"] as? Int,
                let joke = value["joke"] as? String else {
                    return
            }
                
            // Когда данные получены и расшифрованы, 
            // мы останавливаем наш индикатор и он исчезает.
            self.activityView.stopAnimating()
            self.jokeID = id 
            self.jokeLabel.text = joke
        } catch {
            print("Can't serialize data.")
        }
    }
}


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

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

image

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

image

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

Ну. И что?
Реклама
Комментарии 18
    +1
    Интрига в заголовке заставила прочитать статью.
      +1

      "Удалённый ресурс" оказался всего-лишь remote, а не removed или unlinked.


      Но статья получилась хорошей

        0
        Изменил заголовок статьи во избежание путаницы. Спасибо.
          0
          Спасибо и voidptr0. Странно, что мой ответ на его комментарий появился в этом месте.
          0
          1. Уже есть Swift 4 с Decodable, JSONSerialization не нужен
          2. Использовать URLDownloadTask для API — не лучшая практика, захламлять систему одноразовыми файлами не стоит. DataTask для API был бы более логичен.
          3. С форматированием кода (отступы) совсем беда.
          4. NSAppTransportSecurity.NSAllowsArbitraryLoads — PLEASE NO! Используйте TLS для ВСЕГО, в самом крайнем случае разрешайте доступ без шифрования к ограниченному числу доменов…
          5. Используйте weak вместо unowned, иначе легко словите Nil Unwrapping.
          6. Force Unwrap (xxx!) — зло в любом виде, особенно (ОСОБЕННО) в декодировании данных из сторонних источников.
            0
            а можно подробнее про 6 пункт? как быть системе если данные не пришли?
              0
              Я не про систему, а про особенность Swift. Swift спроектирован как null-safe язык (привет Java-программистам с их NPE), и любые объекты, которые могут быть «null» объявляются как Type? (синтаксический сахар к Optional<Type>). Обращаться к таким объектам можно определёнными способами, и один из них — «force unwrap»:
              let value: Type? = someFunc()
              print(value) // выведет Optional(value) или nil
              print(value!) // force unwrap - выведет value
              

              Если в момент «force unwrap» значение переменной (или результата метода) окажется null (nil) — приложение завершит работу с Fatal Error.
              Вывод — ВСЕГДА используйте optional unwrap и конструкции вида "if let x = optional { ... }" / "guard let x = optional else { ... ; return }"
                0
                На мой взгляд вывод неверен.
                Может быть так что мы хотели бы иметь URL для какого-то объекта и инициализировать его из статичной строки. Если мы будем использовать guard let или if let, то не получим URL и можем потратить какое-то время на поиск ошибки, которая могла быть в опечатке строки для это URL. Но если бы мы использовали let url = URL(string: "https://habr.com")!, ошибку нашли бы на много быстрее, что было бы равносильно assert.
                  0
                  Если Вы хотите получить именно ошибку, то сделайте к примеру функцию, которая будет возвращать URL в случае успеха, либо выкидывать ошибку, которую Вы будете отлавливать и обрабатывать. Если Вы будете использовать:
                  let url = URL(string: "https://habr.com")!
                  в приложении, и строка будет неправильной, оно просто упадет, за что пользователи явно не скажут вам спасибо, а менеджер настучит по голове.
                    0
                    Есть исключения из любого правила. Статические URL к серверам API можно делать force-unwrap, и использовать ".appendingPathComponent" или «URL.init(_, baseURL:)». Вполне нормальная практика. Но все данные, которые могут прийти извне, надо валидировать optional-binding'ом или другими способами (Decodable в самый раз). Force unwrap — это крэш приложения. Он не уходит в do...catch и вообще никак не обрабатывается. Это зло, которое надо использовать ОЧЕНЬ осторожно.
                0
                Спасибо DjPhoeniX за развернутый комментарий.
                1) На swift 4 все равно не могу написать, потому что мой компьютер не тянет. Об этом я писал. Но практика хорошая.
                2) Вопрос к авторам книги, так как они используют URLDownloadTask исключительно в образовательных целях. Но и упоминают, что DataTask удобнее и короче.
                3) Попытаюсь в следующих статьях это исправить.
                4) Вот этого не знал. Буду изучать и использовать.
                5) Так же не знал о различиях weak и unowned.
                6) Не доглядел. В большем части кода, все-таки я использую безопасный optional binding.
                Недочеты со временем исправлю.
                  0
                  Старый Mac или хакинтош без поддержки последней версии? И то и другое советую обновить. В любом случае в AppStore будут проблемы с не-последним SDK, да и в целом использовать Last Release более правильно.
                0
                По-моему какая-то жесть… Лучше почитайте эту статью или посмотрите как в Alamofire. И в этом коде:
                self.window = UIWindow(frame: UIScreen.main.bounds) 

                не задаете размер окна, а создаете новое, непонятно зачем…
                  0
                  Alamofire мне тоже нравится. Но я старался сохранить дух оригинальной книги.
                  В self.window… не вижу никакого жесткача.
                  0
                  Добрый день. Можете пояснить ситуацию, почему:
                  class RestRequest: NSMutableURLRequest {
                  }

                  let request = RestRequest() // падает
                  let request = NSMutableURLRequest() // работает

                  На ObjectiveC как-то без сюрпризов с конструктором
                    0
                    Подозреваю, что вы закрыли видимость для публичного конструктора родительского класса. Т.е. вам скорее всего надо добавить конструктор вида RestRequest() в ваш класс, вызывающий родительский конструктор
                    0
                    Изменения, внесенные в статью, по мере учета комментариев:
                    1) NSAppTransportSecurity позволяет загружать теперь только с домена, внесенного в исключения, а не со всех http сайтов, как это было ранее.
                    2) Убраны комментарии очевидных вещей. Оставлены только комментарии, поясняющие некоторые решения, например, необходимые в будущем. Сами комментарии включены в код, а не вынесены следом, как было ранее.
                    3) Код инициализации lazy var перенесен в них самих, а не в отдельные функции, как было ранее.
                    4) Сделал нормальное форматирование кода.
                    5) Почти все unwrapping сделал через guard. Оставил forse unwrapping только для свойства-замыкания и для обработки url.
                    6) Несмотря на кривость решения через download task и delegate, сохранил его, как памятник старины, чтобы знать, с чего все начиналось, и понять, чем современные решения лучше. Более современное решение будет показано в новой статье.
                      0
                      App Transport Security можно оставить полностью включенным, если URL заменить на HTTPS. На домене в статье он есть.

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

                    Самое читаемое