Готовим iOS клиента для GraphQL

в 13:22, , рубрики: api, Apollo, graphql, graphql-client, iOS, swift, Блог компании Sports.ru, мобильная разработка, разработка мобильных приложений, разработка под iOS

image

Я уверен, что каждый из нас хоть когда-то испытывал проблемы с REST API. Вечные баталии с бэком за нужный формат API, несколько запросов на экран и прочее. Согласитесь, что это не редкость, а ежедневная рутина. А еще недавно Tribuna Digital запустила новый проект — Betting Insider. Изначально проект был реализован на iOS и Android, а позже началась разработка веб версии. Имеющееся API оказалось очень неудобным для веба. Это все привело к тому, что мы решили устроить эксперимент и попробовать GraphQL вместе с клиентом от Apollo. Если хотите познакомится с данной технологией в iOS поближе, то добро пожаловать под кат!

Немного о GraphQL

GraphQL — (Graph Query Language) это язык запросов и инстанс для обработки этих запросов. Если говорить проще, то это прослойка между нашими серверами и клиентом, которая отдает клиенту то, что ему нужно от сервера. Общение с данной прослойкой происходит непосредственно на языке GraphQL. Если хотите подробней почитать непосредственно про GraphQL Language и прочее или совсем ничего не знаете, то читаем тут и тут. Там все достаточно подробно и с картинками.

Что нам понадобится?

Для дальнейшей работы нам понадобится NodeJS, Node Package Manager, CocoaPods.
Если вы хотите подсветку graphql запросов, то установите вот этот плагин. Также необходимо скачать заранее приготовленный проект. И самым важным является получение вашего персонального ключа. Вот тут инструкция, а запрашиваем вот такой вот скоп:

  • user
  • public_repo
  • repo
  • repo_deployment
  • repo:status
  • repo:repo_hook
  • repo:org
  • repo:public_key
  • repo:gpg_key

Что будем делать?

Мы напишем небольшой клиент для github, в котором мы можем найти топ 20 репозиториев для любого ключевого слова. Чтобы прочувствовать Github API и GraphQL, то можете поиграться здесь.

Настройка проекта

Прежде чем начать писать код, установим кое-какие пакеты и настроим проект.
Библиотека от Apollo использует код, который генерирует их же инструмент apollo-codegen. Установим его с помощью npm:

npm install -g apollo-codegen

Далее переходим в папку с проектом и открываем Source. Скачаем схему вашего GraphQL сервера, а вместо <token> вставим ваш персональный ключ, который вы должны были сделать выше:

apollo-codegen download-schema https://api.github.com/graphql --output schema.json --header "Authorization: Bearer <token>"

Создадим папку GraphQL в папке Source.

Далее необходимо прописать скрипт для нашего кодогенератора:

APOLLO_FRAMEWORK_PATH="$(eval find $FRAMEWORK_SEARCH_PATHS -name "Apollo.framework" -maxdepth 1)"

if [ -z "$APOLLO_FRAMEWORK_PATH" ]; then
echo "error: Couldn't find Apollo.framework in FRAMEWORK_SEARCH_PATHS; make sure to add the framework to your project."
exit 1
fi

cd "${SRCROOT}/Source"
$APOLLO_FRAMEWORK_PATH/check-and-run-apollo-codegen.sh generate $(find . -name '*.graphql') --schema schema.json --output GraphQL/API.swift

Готовим iOS клиента для GraphQL - 2

Готовим iOS клиента для GraphQL - 3

При первом запуске сборки проекта данный скрипт создаст файл API.swift, который будет содержать необходимый код для работы с вашими запросами, и будет обновляться при каждой сборке. На данный момент Xcode выдаст ошибку на стадии сборки, так как нам кое-чего не хватает.

Запросы

Кодогенератору для создания API нужен файл, где будут прописаны все запросы на языке GraphQL. Создадим новый пустой файл в папка Source/GraphQL(в самом низу списка файлов) с названием github.graphql. Далее давайте напишем query в данный файл, который отдаст нам первые 20 репозиториев по поисковому запросу, а также сразу определим фрагмент для репозитория.

query SearchRepos($searchText: String!) {
    search(first: 20, query: $searchText, type: REPOSITORY) {
        nodes {
            ... on Repository {
                ...RepositoryDetail
            }
        }
    }
}
 
fragment RepositoryDetail on Repository {
    id
    nameWithOwner
    viewerHasStarred
    stargazers {
        totalCount
    }
}

Далее сбилдим проект, чтобы сгенерировался нужный нам код. Добавим получившийся API.swift файл в проект.

ВАЖНО: Если вы добавили новый код в *.graphql, то сначала билдим проект и только потом начинаем писать новый код!

Клиент

Необходимо создать клиент Apollo. Создадим его в AppDelegate.swift над объявление AppDelegate. Так как общение с github требует авторизации, то необходимо добавить header во все наши запросы. Получим вот такую штуку:

let apollo: ApolloClient = {
    let configuration = URLSessionConfiguration.default
    
    configuration.httpAdditionalHeaders = ["Authorization": "bearer <your token>"]
    
    let url = URL(string: "https://api.github.com/graphql")!
    
    return ApolloClient(networkTransport: HTTPNetworkTransport(url: url, configuration: configuration))
}()

Поисковый запрос

Теперь необходимо сделать так, чтобы при нажатии на кнопку Search мы отправляли запрос на сервер. Создадим файл ReposListViewModel.swift, а нем ReposListViewModel. Это будет класс, который будет отправлять наши запросы на сервер, а также управлять состоянием уже имеющихся запросов.

Для начала импортируем Apollo, а потом создадим переменную типа Cancellable и назовем её currentSearchCancellable:

import Apollo
 
class ReposListViewModel {
    private var currentSearchCancellable: Cancellable?
}

Далее создадим функцию, которая будет принимать текст для поиска, а в callback будем передавать полученный массив. Наконец-то переходим непосредственно к отправке запроса! Для начала нужно создать query. Наш кодогенератор сгенерировал нам query, который соответствует названию, которое мы ему дали в graphql файле: SearchReposQuery, а инициализировать будем его с помощью поискового текста. Далее для получения ответа мы вызываем у Apollo клиента функцию fetch, в которую передадим наш query, а также выберем главную очередь исполнения и соответствующую политику кэширования. В callback fetch отдает наш optional result и error. Мы пока не будем задумываться на счет обработки ошибки, а просто будем печатать ее в случае возникновения. Вытащим из полученного результата RepositoryDetail, который тоже сгенерировал нам кодогенератор и передадим их в callback функции поиска. Получится вот такая функция
:

func search(for text: String, completion: @escaping ([RepositoryDetail]) -> Void) {
        currentSearchCancellable?.cancel()
        let query = SearchReposQuery(searchText: text)
        currentSearchCancellable = apollo.fetch(query: query, cachePolicy: .returnCacheDataAndFetch, queue: .main, resultHandler: { (result, error) in
            if let result = result, let data = result.data {
                let repositoryDetails = (data.search.nodes ?? [SearchReposQuery.Data.Search.Node?]()).map{$0?.asRepository}.filter{$0 != nil}.map{($0?.fragments.repositoryDetail)!}
                completion(repositoryDetails)
            } else {
                print(error as Any)
                completion([RepositoryDetail]())
            }
        })
    }

Теперь создадим viewModel, как параметр ReposListViewController, а также массив для хранения RepositoryDetail:

let viewModel = ReposListViewModel()
var repositoryDetails = [RepositoryDetail]()

Далее меняем numberOfRowsInSection на:

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return repositoryDetails.count
    }

Также необходимо обновить ReposListCell:

class ReposListCell: UITableViewCell {
    
    @IBOutlet weak var repoName: UILabel!
    @IBOutlet weak var starCount: UILabel!
    @IBOutlet weak var starButton: UIButton!
        
    var repositoryDetail: RepositoryDetail! {
        didSet {
            repoName.text = repositoryDetail.nameWithOwner
            starCount.text = "(repositoryDetail.stargazers.totalCount)"
            
            if repositoryDetail.viewerHasStarred {
                starButton.setImage( imageLiteral(resourceName: "ic_full_star"), for: .normal)
            } else {
                starButton.setImage( imageLiteral(resourceName: "ic_empty_star"), for: .normal)
            }
        }
    }
}

А cellForRow примет данный вид:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "ReposListCell") as! ReposListCell
        cell.repositoryDetail = repositoryDetails[indexPath.row]
        return cell
    }

Осталось запросить у viewModel необходимые нам данные по клику:

 func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
        viewModel.search(for: searchBar.text ?? "") { [unowned self] repositoryDetails in
            self.repositoryDetails = repositoryDetails
            self.tableView.reloadData()
        }
    }

Готово! Компилим и ищем топ 20 репозиториев по вашему запросу!

Ставим звезды

Как вы уже поняли, звезда в верстке ячейки находится не просто так. Давайте добавим данный функционал в наше приложение. Для этого необходимо создать новый mutation запрос, но перед этим давайте получим ваш ID тут. Теперь можно вставить поисковый запрос с соответствующим clientMutationID и собрать проект:

mutation AddStar( $repositoryId: ID!) {
    addStar(input: {clientMutationId: “<your ID>”, starrableId: $repositoryId}) {
        starrable {
            ... on Repository {
               ...RepositoryDetail
            }
        }
    }
}

mutation RemoveStar($repositoryId: ID!) {
    removeStar(input: {clientMutationId: “<your ID>”, starrableId: $repositoryId}) {
        starrable {
            ... on Repository {
                ...RepositoryDetail
            }
        }
    }
}

Добавим выполнение данных запросов в ViewModel. Логика та же, что и с query, но вместо fetch, мы вызываем теперь perform:

func addStar(for repositoryID: String, completion: @escaping (RepositoryDetail?) -> Void ) {
        currentAddStarCancellable?.cancel()
        let mutation = AddStarMutation(repositoryId: repositoryID)
        currentAddStarCancellable = apollo.perform(mutation: mutation, queue: .main, resultHandler: { (result, error) in
            if let result = result, let data = result.data {
                let repositoryDetails = data.addStar?.starrable.asRepository?.fragments.repositoryDetail
                completion(repositoryDetails)
            } else {
                print(error as Any)
                completion(nil)
            }
        })
    }
    
    func removeStar(for repositoryID: String, completion: @escaping (RepositoryDetail?) -> Void ) {
        currentRemoveStarCancellable?.cancel()
        let mutation = RemoveStarMutation(repositoryId: repositoryID)
        currentAddStarCancellable = apollo.perform(mutation: mutation, queue: .main, resultHandler: { (result, error) in
            if let result = result, let data = result.data {
                let repositoryDetails = data.removeStar?.starrable.asRepository?.fragments.repositoryDetail
                completion(repositoryDetails)
            } else {
                print(error as Any)
                completion(nil)
            }
        })
    }

Теперь нужно дать знать ViewController, что мы нажали на кнопку. Сделаем его делегатом ячейки. Для этого создадим протокол и добавим его в файл ReposListCell.swift:

protocol ReposListCellDelegate: class {
    func starTapped(for cell: ReposListCell)
}

Добавим новый параметр класса ячейки и обработку нажатия на звезду:

weak var delegate: ReposListCellDelegate?
override func awakeFromNib() {
        super.awakeFromNib()
        
        starButton.addTarget(self, action: #selector(starTapped), for: .touchUpInside)
    }
    
    @objc func starTapped() {
        delegate?.starTapped(for: self)
    }

Теперь назначим контроллер делегатом в cellForRow:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "ReposListCell") as! ReposListCell
        cell.repositoryDetail = repositoryDetails[indexPath.row]
        cell.delegate = self
        return cell
    }

Добавим функцию, которая обновит данные в таблице:

func updateTableView(for newDetail: RepositoryDetail?) {
        if let repositoryDetail = newDetail {
            for (index, detail) in repositoryDetails.enumerated() {
                if detail.id == repositoryDetail.id {
                    self.repositoryDetails[index] = repositoryDetail
                    for visibleCell in tableView.visibleCells {
                        if (visibleCell as! ReposListCell).repositoryDetail.id == repositoryDetail.id {
                            (visibleCell as! ReposListCell).repositoryDetail = repositoryDetail
                        }
                    }
                }
            }
        }
    }

И осталось сделать расширение контроллера для ранее созданного контроллера:

extension ReposListViewController: ReposListCellDelegate {
    func starTapped(for cell: ReposListCell) {
        if cell.repositoryDetail.viewerHasStarred {
            viewModel.removeStar(for: cell.repositoryDetail.id) { [unowned self] repositoryDetail in
                self.updateTableView(for: repositoryDetail)
            }
        } else {
            viewModel.addStar(for: cell.repositoryDetail.id) { [unowned self] repositoryDetail in
                self.updateTableView(for: repositoryDetail)
            }
        }
    }
}

Готово! Можем запускать приложение, искать топ 20 репозиториев и ставить/убирать звездочки!

На этом еще не все. Интересно применение данной библиотеки в сумме с RxSwift, решение проблемы пагинации, а ещё Apollo поддерживает кэширование, но обо всем в следующий раз!

Если хотите решать подобные задачи вместе с нами — присоединяйтесь! Вопросы и резюме можно направлять на почту jobs@tribuna.digital.

Еще больше вакансий тут!

Автор: loringit

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js