Данная статья является обновлением статьи Получение удаленных данных в iOS, написанной в ноябре 2015 с использованием Objective-C и потому морально устарешней. Сейчас же будет приведен код, переписанный на Swift 3 и iOS 10 (последней версией является Swift 4.1 и iOS 11, но мой компьютер их уже не поддерживает).
Данный url из примера можно прочитать таким образом: http запрос с методом GET отправляется домену google.com, в корневую директорию /, с двумя параметрами q со значением Hello и safe со значением off.
Браузер преобразует строку url в заголовок и тело запроса. Для http-запроса тело пустое, а заголовок представлен следующим образом
Сначала создается запрос (request), потом устанавливается соединение (connection), посылается запрос и приходит ответ (response).
Все UI операции (связанные с пользовательским интерфейсом) выполняются в главном потоке. Нельзя просто взять и остановить этот поток, пока выполняется какая-то ресурсоемкая операция. Поэтому одним из решений этой проблемы было создание делегатов. Таким образом, операции становятся асинхронными, а главный поток выполняется без остановок. Когда же нужная операция будет выполнена, то будет вызван соответствующий метод делегата. Второе решение проблемы — создание нового потока выполнения.
Как и в оригинальной книге, мы используем делегат, чтобы было операции были разделены между методами более наглядно. Хотя через блоки код получается более компактным.
Мы используем NSURLSessionDownloadDelegate и реализуем его метод URLSession:downloadTask:didFinishDownloadingToURL:. То есть по сути скачиваем данные с шуткой во временное хранилище, и, когда загрузка завершена, вызываем метод делегата для обработки.
Загрузка данных во временное хранилище осуществляется не в главном потоке, но чтобы использовать эти данные для изменения UI мы перейдем в главный поток.
Так как в силу реализации кода, замыкание которое мы передаем в метод загрузки данных с url, переживет сам метод, то для Swift 3 необходимо явно обозначить его @escaping, а self сделать unowned, чтобы не происходило захвата и удержания ссылки self в этом замыкании. Но это уже нюансы реализации самого языка Swift, а не техонологии получения данных по API.
В некоторых случаях происходят редиректы. Например, если у нас имеется некоторый короткий url, то когда мы вводим его в поисковую строку браузера, браузер сначала идет на сервер, где этот короткий url расшифровывается и отправляется к нам, а затем уже по этому полному url мы переходим на целевой сервер. При необходимости мы можем контролировать эти редиректы с помощью NSURLSessionTaskDelegate, но по умолчанию NSURLSession сама справляется со всеми деталями.
Сериализация — это процесс перевода данных из одного вида хранения в другой, без потери содержания. Например, хранятся данные в двоичном виде, чтобы занимать меньше места, а при пересылке по сети их преобразуют в универсальный JSON (JavaScript Object Notation) формат, который уже мы расшифровываем и переводим в объекты нашей среды программирования.
Пример JSON:
Фигурные скобки обозначают словарь (dictionary), а объекты внутри словаря представлены парами ключ-значение.
В нашем случае API представлен адресом, откуда мы будет получать случайные шутки и форматов JSON ответа, который нам нужно разобрать в удобные для манипулирования структуры
Пример icndb API:
Весь проект, как и прошлый раз, реализован в коде, без использования 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, то и добавляем следующий код:
Мы, как и по умолчанию, запрещает произвольные загрузки: ключ NSAllowsArbitraryLoads в false. Но добавляем в виде исключения наш домен с шутками и все поддомены: значения ключа NSExceptionDomains.
Теперь в AppDelegate.swift переписываем application(_:didFinishLaunchingWithOptions:) следующим образом:
Создаем файл HTTPCommunication.swift. И пишем в нем следующий код.
Теперь распишем код данных функций.
Копируем код retrieveURL(_ url:, completionHandler:)
Копируем код func urlSession(_ session:, downloadTask:, didFinishDownloadingTo:)
Создаем файл MainViewController.swift и копируем следующий код, который создает необходимый интерфейс:
Разобрались с интерфейсом, теперь можно заполнять функционал.
Вот код retrieveRandomJokes()
Теперь запускаем приложение и получаем следующий результат.
Пока мы ждем получения шутки с сайта.
Наконец, шутка загружена и отображена.
В следующей статьи мы посмотрим на переписанную на swift вторую часть приложения, которая позволяет получать новые шутки, не перезапуская программу, а также голосовать за шутки.
Краткая теория
Формат 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.")
}
}
}
Теперь запускаем приложение и получаем следующий результат.
Пока мы ждем получения шутки с сайта.
Наконец, шутка загружена и отображена.
В следующей статьи мы посмотрим на переписанную на swift вторую часть приложения, которая позволяет получать новые шутки, не перезапуская программу, а также голосовать за шутки.