UIKit 3 / 1.1. REST



Если можешь не спать — не спи. Сон для слабых.

Изран

В этом уроке


О чём это приложение?

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

В данном приложении мы получим часть карт популярной карточной игры Hearthstone и выведем их в таблице.


Скелет приложения

  1. Создайте новое приложение под iOS
  2. Перетащите иконки из assets в качестве иконок приложения, а шрифт ttf в группу Resources
  3. Создайте также группы Model, View, Controlle
  4. Переместите ViewController в папку с Controller
  5. На полотне встройте контроллер в контроллер навигации
  6. И для единственного контроллера сделайте заголовком HSCards
  7. А также добавьте TableView (с нулевыми отступами от границ)
  8. И создайте ему аутлет tableView
  9. И не забудьте связать её делегата и источник данных с контроллером
  10. Не создавайте в этот раз ячейки прототипов

Вы можете открыть редактор помошник не только через верхнее меню, но и нажав с зажатым ALT по нужному файлу.


Прокси для вида

Мы хотим изменить отображение нашего навигационного бара во всех случаях. Для этого есть очень удобный способ - метод appearance() класса UINavigationBar - он возвращает так навязываемый прокси-объект, который никогда не инстанцируется, но обращение к которому меняет все остальные объекты класса.

Давайте извлечём этот объект в методе application(_:didFinishLaunchingWithOptions:) класса ApplicationDelegate:

let navigationBarProxy = UINavigationBar.appearance()

//Это раскрасит навигационный бар во всех контроллерах
navigationBarProxy.barTintColor = #colorLiteral(red: 0.9178599119, green: 0.6678383946, blue: 0.1624581814, alpha: 1)

//А это сделает элементы навигации белыми
navigationBarProxy.tintColor = #colorLiteral(red: 1.0, green: 1.0, blue: 1.0, alpha: 1.0)

Мы всегда ранее пользовались готовыми шрифтами, но в этот раз мы добавим свой собственный шрифт в приложение. Мы уже добавили в папку с ресурсами наш кастомный шрифт Belwe-Bold. Теперь добавим его в приложение:

  1. В Info.plist добавьте строчку Fonts provided by application, что создаст новый массив.
  2. Теперь укажите в качестве значения Item 0 Belwe-Bold.ttf. Учтите, что нужно указывать точное название файла. При этом добавить можно сколько угодно шрифтов
  3. Теперь соберите проект, чтобы шрифты гарантированно добавились в цель.

Вернёмся в тот же метод, где мы задавали вид навигации:

//Пытаемся получить наш новый шрифт
if let barFont = UIFont(name: "Belwe-Bold", size: 24.0) {
    //Если это вышло, то задаём атрибуты для заголовка навигации
    navigationBarProxy.titleTextAttributes = [
        .foregroundColor: #colorLiteral(red: 1.0, green: 1.0, blue: 1.0, alpha: 1.0), //через цвет
        .font: barFont //и шрифт
    ]
}

И последним штрихом мы сделаем так, чтобы наш статус-бар был белым:

UIApplication.shared.statusBarStyle = .lightContent

Что же такое UIApplication? Это специальный класс, объект которого создаётся всего один раз для каждого приложения и позволяет приложениям взаимодействовать.

Но этого не достаточно - нужно также прописать в файле Info.plist, что статус-бар не должен адаптироваться к контроллерам вида:

View controller-based status bar appearance

Со значением NO.

Можно задать для каждого контроллера свой стиль статус-бара, переписав свойство preferredStatusBarStyle. Либо его можно можно вообще скрыть, задав свойство prefersStatusBarHidden.


Модель для сетевого взаимодействия

Раньше мы сохраняли данные в plist, теперь же мы будем получать их по сети. Данные в REST-сервисах чаще всего передаются в виде строк в формате JSON (JavaScript Object Notation). Swift позволяет кодировать объекты в этот формат и декодировать так же просто, как и в случае со списками свойств. Сам по себе данный формат выглядит примерно так;

[
    {
        "cardId": "EX1_572",
        "name": "Ysera",
        "cardSet": "Classic",
        "type": "Minion",
        "faction": "Neutral",
        "rarity": "Legendary",
        "cost": 9,
        "attack": 4,
        "health": 12,
        "text": "At the end of your turn, add a Dream Card to your hand.",
        "flavor": "Ysera rules the Emerald Dream.  Which is some kind of green-mirror-version of the real world, or something?",
        "artist": "Gabor Szikszai",
        "collectible": true,
        "elite": true,
        "race": "Dragon",
        "img": "http://wow.zamimg.com/images/hearthstone/cards/enus/original/EX1_572.png",
        "imgGold": "http://wow.zamimg.com/images/hearthstone/cards/enus/animated/EX1_572_premium.gif",
        "locale": "enUS"
    }
]

REST-сервисы унифицируют передачу данных по протоколу HTTP(s) между сервером и клиентом.

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

Создайте класс Card в новом одноимённом файле.

Наша модель карты будет достаточно простой:

class Card: Codable {

    //Имя карты
    let name: String
    //Цена в мане
    let manaCost: Int
    //Текст карты
    let text: String
    //Путь до картинки
    let imagePath: String

    init(name: String, manaCost: Int, text: String, imagePath: String) {
        self.name = name
        self.manaCost = manaCost
        self.text = text
        self.imagePath = imagePath
    }
}

Мы сразу же объявили класс соответствующим протоколу Codable. Сверху же однако виден пример результата... Что будет с теми ключами, которых нет в классе? Ничего - они просто будут отброшены. Но допустим нам нужно сделать так, чтобы img из ответа сервера превращался на нашей стороне в свойство imagePath. Что же делать?

Для этого протокол Codable позволяет определить вложенное перечисление enum CodingKeys: String, CodingKey, каждый кейс которого будет иметь название поля типа в Swift, а значением - название поля в ответе сервера. Проблема состоит в том, что мы не можем переодпрелеить только какие-то ключи - придётся переопределять все:

enum CodingKeys: String, CodingKey {
    case name = "name"
    case manaCost = "cost"
    case text = "text"
    case imagePath = "img"
}

Отдельное создание ячейки

В этот раз создадим ячейку отдельно от остального вида. Для этого в папке View создайте класс CardTableViewCell, унаследовав его от UITableViewCell, при этом галочка Also create xib должна быть установлена, что создаст отдельное представление для ячейки таблицы - причём иметь оно будет то же название, что и класс. Только расширение будет .xib.

  1. Установите в инспекторе размеров высоту ячейки в 100 точек
  2. Добавьте на полотно
    1. ImageView (аутлет cardImageView)
    2. Label с именем Name (аутлет nameLabel)
  3. Label с именем Text (аутлет descriptionLabel)
  4. Установите для Name шрифт Headline (мы узнаем совсем скоро, что это нам даст
  5. А для Text тонкий (thin) шрифт размером 11. Ему же задайте число линий в 0 (то есть бесконечность).
  6. Теперь объедините обе метки в Stack View:
    1. Alignment и Distribution установите в Fill
    2. Spacing в 5 точек
  7. Этот стэк вместе с картинкой положите в ещё один стэк вью:
    1. Alignment - Center
    2. Distribution - Fill
    3. Spacing - 10
  8. Наложите на картинку ограничения:
    1. Content Mode - Aspect Fit
    2. Ширина и высота по 60 точек
  9. Для самого верхнего стэка в иерархии задайте отступы
    1. В 2 точки от маржинов верхней и нижней грани Content View
    2. И 6 точек от боковых граней

Вы получите примерно следующее:

Если Вы запутаетесь в иерархии видов, то вместо подробной иерархии можно получить краткую, нажав правой кнопкой манипулятора вместе с SHIFT по виду:

Теперь перейдите в раскадровку и в инспекторе размеров табличного представления задайте:

  1. Row Height - 100
  2. Estimate - 100

REST

Выделяют несколько типов запросов и соответсвующих путей для получения ресурсов:

Метод URL Действие Маршрут Описание
GET /cards index cards.index Получение всех карт
GET /cards/create create cards.create Запрос на создание карты (например, получение веб-формы)
POST /cards store cards.store Запрос на сохрание карты
GET /cards/{card} show cards.show Получение отдельной карты, причём карта специфицируемая как аргумент пути в виде `{card}`, то есть вместо этой надписи будет находиться уникальный идентификатор карты
GET /cards/{card}/edit edit cards.edit Запрос на изменение карты
PUT/PATCH /cards/{card} update cards.update Запрос на сохранение изменений в карте
DELETE /cards/{card} delete cards.delete Удаление карты

В этом задании мы будем использовать только получение одной текущей карты. Добавьте ключ API в качестве свойства константы в контроллер:

let kAPIKey = "sJoVbrkZRPmshSXRb5TUbAyYEpMZp1I6ntujsnJvZskUFsFRkW"

А также предопределённый массив скрытых карт:

private let cardNames = [
    "Ysera",
    "Ancient Watcher",
    "Argent Protector",
    "Cruel Taskmaster",
    "Defias Ringleader",
    "Dire Wolf Alpha",
    "Doomsayer",
    "Eviscerate"
]

Теперь создадим свойство для хранения карт и навесим на него обсервер:

var cards = [Card]() {
    didSet {
        DispatchQueue.main.async {
            self.tableView.reloadData()
        }
    }
}

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

Во viewDidLoad будем заполнять этот массив для каждого названия карты:

for name in cardNames {
    //Создаётся строка запроса, причём она кодируется в строку, пригодную для запросов
    let urlString = "https://omgvamp-hearthstone-v1.p.mashape.com/cards/\(name)".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
    //Из неё создаётся объект запроса
    var request = URLRequest(url: URL(string: urlString!)!)
    //Можно было создать и обычную ссылку, но объект запроса позволяет изменить его тип - мы явно укажем, что это GET
    request.httpMethod = "GET"

    //А также добавим заголовок со значением ключа API
    request.addValue(kAPIKey, forHTTPHeaderField: "X-Mashape-Key")
    //Заголовки, это дополнительные сведения передеаваемые с запросом. Назване заголовка придумали авторы.

    //Для выполнения запрос необходимо создать задачу в URL-сессии. Для большинства простых задач можно воспользоваться готовым объектом класса:
    URLSession.shared.dataTask(with: request) { (data, respone, netError) in
    //В результате выполнения задачи придут
    // data - данные, если запрос был успешен
    // netError - если запрос был неудачен
    // response - заголовок ответа сервера в любом случае
    // Учтите, что это замыкание выпонляется асинхронно

        //Если данные не пришли, то гарантированно произошла ошибка, а мы её выведем
        guard let cardsData = data else {
            print(netError)
            return
        }

        do {
            //Пытаемся извлечь карту
            //Заметьте, что метод декодера для JSON поход на `plist`
            let card = try JSONDecoder().decode([Card].self, from: cardsData).first!
            //Мы декодировали массив и извлекали из него первый объект, а не просто объект, так как в этом странном API ответ оборачивается в массив
            self.cards.append(card)
            //Добавление карты в массив приводит к срабатыванию наблюдателя
        } catch {
            //Если не вышло декодировать, то выводим ошибку
            print(error)
        }

    }.resume()
    //Созданную задачу ешё необходимо запустить
}

Заметьте, как легко мы получили с сервера объект! В будущем мы научимся не только получать объекты с сервера, но и отправлять их на него.


Динамическая высота ячеек

Мы не создавали ячеек прототипов, а значит и не регистрировали наш класс ячейки в табличном представлении. Однако есть и программный способ сделать это (код добавляется во viewDidLoad):

tableView.register(CardTableViewCell.self, forCellReuseIdentifier: "Cell")

Данный метод регистрирует класс для ячейки с указанным идентификатором.

Теперь так же зарегистрируем представление для ячейки:

tableView.register(UINib(nibName: "CardTableViewCell", bundle: nil), forCellReuseIdentifier: "Cell")

И напоследок зададим динамическую высоту ячеек таблицы:

tableView.rowHeight = UITableViewAutomaticDimension

Это позволяет ячейкам иметь разную высоту, а табличному представлению самому высчитывать эти размеры.

В расширении класса реализуйте соответствие протоколам делегата и источника данных табличного представления. Причём число строк сделайте равным количеству карт:

extension ViewController: UITableViewDataSource, UITableViewDelegate {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return cards.count
    }
}

Последним штрихом будет реализация метода, дающего нам конкретную ячейку:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! CardTableViewCell
    let card = cards[indexPath.row]

    cell.nameLabel.text = card.name
    cell.descriptionLabel.text = card.text

    do {
        cell.cardImageView?.image = try UIImage(data: Data(contentsOf: URL(string: card.imagePath)!))!
    } catch {
        print(error)
    }

    return cell
}

Новинкой в данной реализации было то, что мы загрузили картинку по ссылке в набор байт, а затем уже отобразили её. Нам даже уже не нужно хранить картинки на устройстве!

По умолчанию данная программа не будет отображать картинки. Это связано с тем, что они расположены по адресу с протоколом http, а не https. Последний предполагает шифрование, а в первом данные передаются в открытом виде. По-умолчанию Apple запрещает Вам использовать доступ без шифрования. Вы можете добавить адреса и разрешить загрузку с определенных или любых доменов через Info.plist. Для этого нужно добавить словарь App Transport Security Settings, а в нём значение Allow Arbitrary Loads с величиной YES.

Учтите, что не стоит делать такого в настоящих приложениях, если на то нет крайней необходимости - любой участник сети сможет перехватить такие данные. Добавление SSL-сертификата в наши дни занимает считанные минуты и может быть выполнено бесплатно.

Запустите программу и Вы получите примерно следующее:


Динамический размер шрифта

Помните, как мы добавили стиль шрифта Headline в наше чудо инженерной мысли?

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

  1. Settings
  2. General
  3. Accessability
  4. Larger Text
  5. Поставьте галочку у Larger Accessability Sizes
  6. А также подвигайте ползунок
  7. Теперь запустите приложение и Вы увидите, как оно изменилось: