Pull to refresh

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

Reading time9 min
Views24K
Данная статья является обновлением статьи Получение удаленных данных в 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 вторую часть приложения, которая позволяет получать новые шутки, не перезапуская программу, а также голосовать за шутки.
Tags:
Hubs:
+6
Comments18

Articles

Change theme settings