Всем привет!

Сегодня мы создадим учебное приложение для iOS с использованием бэкенда на GraphQL при помощи библиотеки Apollo — оно имеет несложный двухэкранный интерфейс, который не будет сильно отвлекать нас от работы с сетью. Приложение будет отображать прошедшие и грядущие запуски разных кораблей SpaceX. Приложение под названием Rocket Launch будет предоставлять информацию о миссиях, включая даты и время. Для создания Rocket Launch мы будем использовать UIKit без Сторибордов. В этом материале я дам код целиком с пояснениями, но оставляю ссылку на репозиторий со своим проектом.

На главном экране будет отображаться список ракет в виде таблицы. При выборе ракеты будет открыт список прошедших и грядущих запусков этой ракеты.

На экране списка запусков для каждого запуска будет отображаться дата и тип миссии.

Коротко о GraphQL, Apollo и нашем API SpaceX

GraphQL — это язык запросов для работы с API, который позволяет клиентскому приложению запрашивать только нужные данные и получать их в формате JSON. GraphQL позволяет приложению точно специфицировать, какие данные нужны, и сервер возвращает только запрошенные данные. Это снижает объем передаваемых данных по сравнению с REST API и улучшает производительность приложения. Рекомендую ознакомиться с документацией на сайте GraphQL.

Apollo — это библиотека для работы с GraphQL в клиентских приложениях. Она предоставляет удобные инструменты для запроса и мутации данных на сервере, а также для управления состоянием приложения. Библиотека Apollo интегрируется в том числе с iOS, именно её мы и будем использовать. Подробная информация также доступна на сайте библиотеки.

SpaceXAPI — хороший тренировочный API. Там мы получим URL, по которому будем осуществлять запросы, нужную нам GraphQL-схему, а также поработаем в разделе Explorer. В нём мы сможем создавать необходимые нам операции, сражу же выполнять их и смотреть, что приходит в ответе.

Создание проекта

Для начала создадим проект нового приложения в Xcode. Я назвал его rocket-launch-programmatically, так как собираюсь создавать его в коде без использования Storyboard. Выбираем интерфейс Storyboard и выбираем язык Swift.

Установка Apollo через Package Manager

Теперь установим Apollo. Это можно сделать разными способами, например через CocoaPods или Package Manager. Я выберу второй вариант. В поиске вводим apollo-ios и устанавливаем фреймворк. Нам достаточно установить первый Package Product в списке — Apollo.

Установка CLI

CLI (Command Line Interface) поможет нам генерировать Swift-код на основе схемы GraphQL.

Для этого нам нужно открыть контекстное меню на нашем проекте, сделав правый клик в левом верхнем углу Xcode и выбрать Install CLI, после этого нажимаем Run и даём разрешение на изменение файлов.

Конфигурация GraphQL-модуля

Зайдём в Terminal и выполним команду для перехода в папку нашего проекта. В моём случае он лежит прямо на Десктопе.

cd Desktop/rocket-launches-programmatically

Теперь проинициализируем файл конфигурации. Этот файл определяет необходимый набор параметров для дальнейшей генерации кода.

В команду init нам нужно передать значение для аргумента schema-name (это пространство имён для будущих сгенерированных файлов) и значение для аргумента module-type — swiftPackageManager. Это значит, что все будущие файлы будут связаны с основным проектом через SPM.

./apollo-ios-cli init --schema-name SpaceXAPI --module-type swiftPackageManager

После выполнения команды будет создан файл конфигурации.

Загрузка файла схемы

Теперь нам нужно загрузить файл схемы, где описаны все возможные запросы, типы и структуры данных. Для этого нам нужно открыть вкладку с нашим SpaceXAPI. На странице в левом меню выбираем раздет Schema, затем переходим в SDL. Вот прямая ссылка на этот раздел. Нам нужно загрузить файл Raw в правой верхней части страницы.

NB Любопытный момент, что файл скачивается в расширении .graphql. Оно подходит только для query, мутаций и фрагментов. Для того, чтобы использовать файл в качестве схемы, нужно изменить расширение на .graphqls (s в конце как раз означает schema). Само имя файла для удобства переименуем в schema, то есть в итоге мы получаем файл schema.graphqls.

Для удобства создадим в нашем проекте папку GraphQL и перенесем нашу схему туда.

Теперь внесём папку в наш проект, сделав правый клик в Navigator area и добавив в проект папку GraphQL.

Добавление первой GraphQL-операции

На первом экране нашего приложения будет отображаться список ракет компании SpaceX. Мы снова отправимся на сайт SpaceX API и перейдём в раздел Explorer. Там мы создадим query, которая и будет возвращать список ракет.

  • В левой навигационной панели нам нужно найти позицию rockets (не путать с Rocket, нам нужен массив). Нажав на неё, мы получаем новую query в редакторе.

  • Теперь найдём в навигаторе и добавим поля, которые мы хотим получить в запросе. Точно так же выберем поля id, name, heigh (отметим только метры) и массу (отметим только килограммы).

  • Прямо в редакторе кода переименуем операцию Rockets в RocketsQuery.

  • Если мы нажмём на тестовый запуск, то в инспекторе справа увидим данные о разных ракетах.

Создание файла для этой операции в Xcode

Теперь прямо в папке GraphQL в Xcode создадим файл rocketsQuery с расширением .graphql. При выборе типа файла берем темплейт Empty. Прямо в него копируем нашу только что созданную query.

Генерация Swift-кода для этой операции

Вот здесь нам и понадобится упомянутый выше CLI. Нам нужно снова перейти в Terminal и выполнить команду generate. Это необходимо делать каждый раз при изменениях существующих операций и при добавлении новых. Вот её код для Терминала:

./apollo-ios-cli generate

После этого в папке SpaceXAPI появится файл RocketsQuery.graphql с расширением .swift.

Добавление локального пакета SpaceXAPI

Мы сгенерировали graphql-модуль, и теперь нужно добавить в наш проект локальный пакет SpaceXAPI. Для этого нам нужно открыть Package Manager и нажать кнопку Add Local… в нижней части интерфейса.

Там нам нужно выбрать папку SpaceXAPI и нажать Add Package.

Также добавим SpaceXAPI в раздел Frameworks нашего основного таргета.

После этого можно импортировать пакет в любой файл через import SpaceXAPI

Создание клиента для работы с сетью

Создадим простой клиент под названием NetworkService. Это будет swift-файл с синглтоном. У него всего одно свойство Apollo, и к нему мы будем обращаться всегда, когда захотим выполнить какую-то GraphQL-операцию.

Свойство Apollo нам нужно проинициализировать с каким-либо end point, куда будут отправляться запросы. Мы возьмём URL из документации SpaceXAPI.

Вот код для нашего NetworkService:

import Apollo
import Foundation
final class NetworkService {
	static let shared = NetworkService()

	private(set) var apollo = ApolloClient(url: URL(string: "<https://spacex-production.up.railway.app/>)")!)

	private init() { }

}

Приступим к вёрстке RocketsViewController

Чтобы сразу отключить Сториборд, допишем функцию scene в SceneDelegate и сделаем нашим рутовым контроллером RocketsViewController, который мы вскоре создадим. Разумеется, можно сделать это через AppDelegate, а также опционально удалить файл Main и всё, что связано со Сторибордом.

import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else { return }
    let window = UIWindow(windowScene: windowScene)
    let rocketsVC = RocketsViewController()
    let navController = UINavigationController(rootViewController: rocketsVC)
    window.rootViewController = navController
    self.window = window
    window.makeKeyAndVisible()
}
// Остальные функции SceneDelegate
}

Я не буду вдаваться в детали работы с UITableView. Скорее всего, вы с ним хорошо знакомы. Поэтому не буду давать исчерпывающих объяснений по созданию таблицы и ячеек, а просто покажу код с комментариями.

Для контроллера RocketsViewController я создал отдельный одноимённый файл.

В TableView будет прокидываться ячейка RocketCell, для которой я создал отдельный файл.

Для читаемости кода я использую кложурную инициализацию, а также функции setupViews() для иерархии вью и setupConstraints() для настройки лейаута при помощи библиотеки SnapKit — я загрузил его через SPM.

Создаём вью контроллер RocketsViewController

Собственно, давайте перейдём к тому, ради чего мы тут собрались.

Создадим новый файл Swift под названием RocketsViewController. Нам нужно не забыть импортировать локальный пакет SpaceXAPI, которые мы создали ранее. Кроме того, я импортирую библиотеку SnapKit для работы с констрейнтами.

import UIKit
import SpaceXAPI
import SnapKit

Далее создаём класс нашей таблицы на вью контроллере RocketsViewController:

// Финальный класс RocketsViewController
final class RocketsViewController: UIViewController {
// Приватный массив rockets, содержащий данные о ракетах, сюда мы будем склдывать ракеты, получаенные через Apollo
private var rockets: [RocketsQuery.Data.Rocket] = []
// Приватная переменная для создания таблицы, назовём ячейку RocketCell — мы напишем её в отдельном файле
private lazy var tableView: UITableView = {
let tv = UITableView(frame: .zero, style: .plain)
tv.dataSource = self
tv.delegate = self
tv.register(RocketCell.self, forCellReuseIdentifier: "RocketCell")
return tv
}()
// Прокинем во вьюдидлоад функцию создания иерархии вью, настроек констреинтов и функцию получения данных о ракетах
override func viewDidLoad() {
super.viewDidLoad()
// Настройка таблицы
setupViews()
setupConstraints()
// Получение данных о ракетах, мы создадим ниже
fetchRockets()
}
// Функция для настройки иерархии вью
private func setupViews() {
view.addSubview(tableView)
}
// Функция для настройки констреинтов с использованием SnapKit
private func setupConstraints() {
tableView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
}

Далее сделаем расширение класса и настроим delegate и dataSource нашей таблицы.

extension RocketsViewController: UITableViewDelegate, UITableViewDataSource {
// Функция возвращающая количество секций в таблице (как и было бы по умолчанию, так что без этой функции можно обойтись)
func numberOfSections(in tableView: UITableView) -> Int {
    return 1
}
// Функция возвращающая количество ячеек в секции таблицы
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
// Значение соотвествует количеству элементов в массиве rockets
return rockets.count
}
// Функция возвращающая заголовок для секции
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
return "Rockets"
}
// Функция для конфигурации ячейки таблицы
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "RocketCell", for: indexPath) as! RocketCell
let rocket = rockets[indexPath.row]
cell.configureWith(rocket)
return cell

}
// Функция didSelectRowAt, вызываемая при нажатии на ячейку таблицы. Она пригодится нам позже, когда мы будем переходить на другой вью контроллер с информацией о запусках. Он будет называться LaunchesViewController. Пока что можно оставить её закомментированной. В реальной жизни мы бы писали её при создании нового вью-контроллера, но чтобы не путать вас, предлагаю создать её сразу.
//func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
//    let vc = LaunchesViewController()
//    vc.rocket = rockets[indexPath.row]
//    let navController = UINavigationController(rootViewController: vc)
//    navController.modalPresentationStyle = .pageSheet
//    self.present(navController, animated: true, completion: nil)
}
}

Теперь допишем ещё одно расширение класса для функции, которая будет приносить данные о ракетах:

private extension RocketsViewController {
    // Приватная функция для получения данных о ракетах
    func fetchRockets() {
		// Создаём запрос из нашего graphql-файла
        let query = RocketsQuery()
		// Обращаемся к нашему Network Service, Apollo возвращает стандартный свифтовый result, обрабатываем его в switch case
        NetworkService.shared.apollo.fetch(query: query) { [weak self] result in
            switch result {
            case .success(let value):
                // Получение данных о ракетах и обновление таблицы
                self?.rockets = value.data?.rockets?.compactMap { $0 } ?? []
                DispatchQueue.main.async {
                    self?.tableView.reloadData()
                }
            case .failure(let error):
                // Вывод ошибки в консоль
                debugPrint(error.localizedDescription)
            }
        }
    }
}

Создаём ячейку таблицы

В отдельном файле создадим ячейку RocketCell

import UIKit
import SpaceXAPI
import SnapKit
// Класс RocketCell, наследуемый от UITableViewCell
class RocketCell: UITableViewCell {
// Создаем лейблы для основного и второстепенного текста
private let nameLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 16, weight: .bold)
label.numberOfLines = 0
return label
}()
private let detailLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 14, weight: .regular)
label.numberOfLines = 0
return label
}()
// Инициализация ячейки и настройка вью
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupViews()
setupConstraints()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// Функция configureWith принимает экземпляр ракеты и настраивает ячейку в соответствии с полученными данными
func configureWith(_ rocket: RocketsQuery.Data.Rocket) {
// Настройка основного текста ячейки равной имени ракеты
nameLabel.text = rocket.name
// Настройка второстепенного текста ячейки, который отображает высоту и массу ракеты в метрах и кг соответственно
// Если данные отсутствуют, используется значение по умолчанию 0
detailLabel.text = "\\(rocket.height?.meters ?? 0) meters / \\(rocket.mass?.kg ?? 0) kg"
}
// Функция для настройки представлений
private func setupViews() {
contentView.addSubview(nameLabel)
contentView.addSubview(detailLabel)
}
// Функция для настройки ограничений с использованием SnapKit
private func setupConstraints() {
nameLabel.snp.makeConstraints { make in
make.top.left.equalToSuperview().offset(15)
make.right.lessThanOrEqualToSuperview().offset(-15)
}
detailLabel.snp.makeConstraints { make in
    make.top.equalTo(nameLabel.snp.bottom).offset(5)
    make.left.right.equalTo(nameLabel)
    make.bottom.lessThanOrEqualToSuperview().offset(-15)
}
}

}

Создаём новую GraphQL-операцию для нового контроллера LaunchesViewController

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

Давайте вернёмся на сайт нашего SpaceXAPI и создадим новую операцию, которая сможет приносить нам данные и совершённых и планируемых запусках отдельных ракет. Тут стоит обратить внимание на ещё одну удобную особенность GraphQL: мы можем выполнять две разных операции в рамках одной общей. Сейчас мы в этом убедимся — в нашем случае это две операции — одна для получения информации о прошлых запусках, вторая — о грядущих. Вот как будет выглядеть готовый код:

query LaunchesQuery($upcomingFind: LaunchFind, $pastFind: LaunchFind) {
  launchesUpcoming(find: $upcomingFind) {
    id
    mission_name
    launch_date_utc 
  }
  launchesPast(find: $pastFind) {
    id
    mission_name
    launch_date_utc
  }
}

В эксплорере на сайте API создадим новую вкладку, в навигаторе слева выберем launchesUpcoming, после чего в открывшемся меню выберем значения id, mission_name и launch_date_utc.

Теперь нажмём в навигаторе стрелку назад и добавим операцию для получения будущих запусков. Выберем поле launchesPast и положим туда те же значения d, mission_name и launch_date_utc.

Переименуем общую операцию (сейчас она называется LaunchesUpcoming) в LauchesQuery. Так как мы хотим получать запуски по конкретной ракете, нам нужно добавить два параметра для каждой из query. Это параметр find, в который можно передать id ракеты.

Теперь вернёмся в Xcode и создадим новый файл типа Empty в нашей папке GraphQL. Он будет называться launchesQuery.graphql. Добавим в этот файл наш GraphQL-код с описанием операции. Вот как это должно выглядеть:

Помните, что обязательно нужно сделать теперь? Правильно, необходимо сгенерировать swift-файл. Для этого возвращаемся в Terminal и снова вводим команду:

./apollo-ios-cli generate

Если в нашей папке сгенерировался новый файл с расширением .swift, всё в порядке.

Создаём новый вью контроллер LaunchesViewController

Новый вью-контроллер LaunchesViewController будет отображаться через present при нажатии на одну из ячеек таблицы (конкретная ракета) на нашем первом вью-контроллере. Именно для этого в конце того файла мы заранее написали функцию didSelectRowAt — func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) (сейчас она закомментирована).

Итак, создадим новый Swift-файл под названием LaunchesViewController, импортируем туда необходимые библиотеки.

import SpaceXAPI
import UIKit
import SnapKit

Теперь напишем код класса LaunchesViewController.

Добавим расширение класса для конфигурации таблицы. Как говорил ранее, не буду фокусироваться на особенностях создания UITableView.

extension LaunchesViewController: UITableViewDelegate, UITableViewDataSource {
// Функция возвращающая количество секций в таблице
func numberOfSections(in tableView: UITableView) -> Int {
    return 2
}
// Функция возвращающая количество ячеек в секции таблицы
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
switch section {
case 0: return launchesUpcoming.count
case 1: return launchesPast.count
default: return 0
}
}
// Функция для конфигурации ячейки таблицы
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "LaunchCell", for: indexPath) as! LaunchCell
let cellText: String
let cellSecondaryText: String
// Настройка ячеек в зависимости от секции, для выведения дат запусков используем данные наших DateFormatter
switch indexPath.section {
case 0:
    let launchUpcoming = launchesUpcoming[indexPath.row]
    cellText = launchUpcoming.mission_name ?? ""
    let launchDate = inDateFormatter.date(from: launchUpcoming.launch_date_utc ?? "") ?? .now
    cellSecondaryText = outDateFormatter.string(from: launchDate)

case 1:
    let launchPast = launchesPast[indexPath.row]
    cellText = launchPast.mission_name ?? ""
    let launchDate = inDateFormatter.date(from: launchPast.launch_date_utc ?? "") ?? .now
    cellSecondaryText = outDateFormatter.string(from: launchDate)

default:
    cellText = ""
    cellSecondaryText = ""
}

// Конфигурация ячейки
cell.configureWith(text: cellText, secondaryText: cellSecondaryText)
return cell

}
// Функция возвращающая заголовок для секции
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
switch section {
case 0: return "Upcoming launches"
case 1: return "Past launches"
default: return nil
}
}
}

Создадим ещё одно расширение класса для метода по получению запусков через Apollo.

private extension LaunchesViewController {
    func fetchLaunches() {
// Так как нужно передать в запрос id ракеты, сначала проинициализируем LaunchFind с параметром rocket.id
        let launchFind = LaunchFind(rocket_id: rocket.id ?? .none)
// Далее проинициализруем саму query и передадим туда launchFind для обоих параметров
        let query = LaunchesQuery(upcomingFind: .some(launchFind), pastFind: .some(launchFind))
// Затем через наш NetworkService выполним саму операцию, обработав result в блоке completion 
        NetworkService.shared.apollo.fetch(query: query) { [weak self] result in
            switch result {
            case .success(let value):
// В случае успеха положим данные в два массива launchesUpcoming и launchesPast, которые заранее создали в нашем классе
                self?.launchesUpcoming = value.data?.launchesUpcoming?.compactMap { $0 } ?? []
                self?.launchesPast = value.data?.launchesPast?.compactMap { $0 } ?? []
// Обновим данные таблицы
                DispatchQueue.main.async {
                    self?.tableView.reloadData()
                }
        case .failure(let error):
            // Вывод ошибки в консоль
            debugPrint(error.localizedDescription)
        }
    }
}

}

Наконец, создадим новый Swift-файл для ячейки запусков под названием LaunchCell.

import UIKit
import SnapKit
// Финальный класс LaunchCell, наследуемый от UITableViewCell
final class LaunchCell: UITableViewCell {
// Создаем лейблы для основного и второстепенного текста
private let mainTextLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 16, weight: .bold)
label.numberOfLines = 0
return label
}()
private let secondaryTextLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 14, weight: .regular)
label.numberOfLines = 0
return label
}()
// Инициализация ячейки и настройка представлений
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupViews()
setupConstraints()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// Функция configureWith принимает текст и второстепенный текст, и настраивает ячейку в соответствии с полученными данными
func configureWith(text: String, secondaryText: String) {
// Устанавливаем основной текст ячейки, равный переданному тексту
mainTextLabel.text = text
// Устанавливаем второстепенный текст ячейки, равный переданному второстепенному тексту
secondaryTextLabel.text = secondaryText
}
// Функция для настройки представлений
private func setupViews() {
contentView.addSubview(mainTextLabel)
contentView.addSubview(secondaryTextLabel)
}
// Функция для настройки ограничений с использованием SnapKit
private func setupConstraints() {
mainTextLabel.snp.makeConstraints { make in
make.top.left.equalToSuperview().offset(15)
make.right.lessThanOrEqualToSuperview().offset(-15)
}
secondaryTextLabel.snp.makeConstraints { make in
    make.top.equalTo(mainTextLabel.snp.bottom).offset(5)
    make.left.right.equalTo(mainTextLabel)
    make.bottom.lessThanOrEqualToSuperview().offset(-15)
}
}

}

После всех этих действий приложение должно корректно работать.

Комментарии (1)


  1. Zasalidol
    19.08.2023 12:27
    +1

    вооуув