16
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

RxSwiftとApolloでつくるGraphQL API Client

Posted at

はじめに

今までRxSwiftをあまり積極的に使用していなかったのですが、
最近業務で使う機会がありましたので、
勉強のために作ったデモアプリの内容をまとめていこうと思います。

開発環境

Tool Version Description
Xcode 11.3.1 (11C505) IDE
apollo 2.26.0 Apollo GraphQLのコマンドラインツール
https://www.npmjs.com/package/apollo

ライブラリの導入は、Swift Package Managerで行いました。

Library Version Description
apollo-ios 0.24.0 GraphQLクライアント
RxSwift 5.1.0 リアクティブプログラミング
RxApolloClient 1.3.0 Apolloクライアント用のRxSwift extension
RxViewController 1.0.0 UIViewControllerのRxSwiftラッパー
RxNuke 0.11.0 Nuke(非同期で画像を取得するライブラリ)用のRxSwift extension
Unio 0.8.0 InputからOutputまでの流れを単一方向にするフレームワーク
Action 4.0.1 RxSwiftで実行されるアクションを抽象化するライブラリ
(エラーが発生してもストリームを維持できるようにする用途で使用)

つくったもの

GitHub GraphQL APIでGitHubのリポジトリを検索するデモアプリを作りました。

ありがちなサンプルですが、インクリメンタルサーチをして
一番下(付近)までスクロールしたら追加リクエストをするようにしています🙉

search.gif

実装前の準備

1. apolloの導入

apolloは、npmでインストールします
$ npm install apollo

グローバルインストールの場合は-gをつけてください
$ npm install -g apollo

npmのグローバルインストールとローカルインストールについては、以下を参考にさせていただきましたm(_ _)m
https://qiita.com/kijitoraneko/items/175ef29d45d155b3f405

2. GitHubのPersonal access tokenを取得する

GitHub GraphQL APIを使用するためにGitHubのPersonal access tokenを取得する必要があります。
Settings > Developer settings > Personal access tokensから情報を入力することでトークンを作成することができます。

1.png

2.png

3.png

4.png

トークンのスコープを選択するチェックボックスが表示されますが、
今回は、「repo」の全部と「user」の中の「read:user」・「user:email」にチェックをつけてみました。

アクセストークンが発行されるので、メモをとっておきましょう📝

3.GitHubAPI v4のSchemaを取得する

トークンを使用して、GitHub APIのSchemaを取得します。
$ apollo schema:download --endpoint="https://api.github.com/graphql" --header "Authorization: Bearer <トークン>"

schema.jsonというファイルが生成されます。

4. graphqlファイルにqueryを定義し、Swiftコードを生成する

https://developer.github.com/v4/query/#connections のsearchを参考に
取得するデータ項目をgraphqlファイルに定義します。

query.graphql
query GitHubRepos($query: String!, $type: SearchType!, $first: Int!, $after: String) {
    search(query: $query, type: $type, first: $first, after: $after) {
        repositoryCount
        nodes {
            ... on Repository {
                name
                nameWithOwner
                homepageUrl
                description
                owner {
                    avatarUrl
                }
                stargazers {
                    totalCount
                }
                primaryLanguage {
                    name
                }
            }
        }
        pageInfo {
            startCursor
            endCursor
            hasPreviousPage
            hasNextPage
        }
    }
}

作成したgraphqlファイルを指定して、Swiftのコードを生成します。
$ apollo codegen:generate --queries=query.graphql --localSchemaFile=schema.json --target=swift GitHubGraphQLAPI.swift

プロジェクトの構成

今回は、プロジェクトのグループ構成を以下のようにしました。

RxInfiniteScrollDemo
 ├ Application/
   ├ ...
 ├ Extensions/
   ├ ...
 ├ Models/
   ├ Generated/
      ├ (apollo codegenで生成したファイル)
   └ GitHubRequest.swift (GitHubAPIのリクエスト用)
 ├ Resources/
   ├ ...
 ├ Utility/
   ├ GraphQLClient.swift
   └ GraphQLRequest.swift
 ├ ViewModels/
   ├ GitHubSearch
      ├ GitHubSearchViewModel.swift
   ├ ...
 ├ Views/
   ├ GitHubSearch
      ├ GitHubSearchViewController.swift
   ├ ...

実装(Utility)

GraphQLのリクエストをするための情報を渡すためのprotocolを定義しました

Utility/GraphQLRequest.swift
import Apollo
import Foundation

protocol GraphQLRequest {

    associatedtype Query: GraphQLQuery

    var query: Query { get }
    var url: URL { get }
    var httpAdditionalHeaders: [AnyHashable: Any]? { get }
}

extension GraphQLRequest {
    var httpAdditionalHeaders: [AnyHashable: Any]? {
        return [:]
    }
}

GraphQLRequestプロトコルに準拠したリクエストを実行する処理を実装しました。
RxApolloClientを使用して、Observableでレスポンスを返却できるようにしています。

Utility/GraphQLClient.swift
import Apollo
import Foundation
import RxSwift
import RxApolloClient

struct GraphQLClient {

    private static var apolloClient: ApolloClient?

    static func fetch<T: GraphQLRequest>(graphQLRequest: T,
                                         cachePolicy: CachePolicy = .returnCacheDataElseFetch,
                                         queue: DispatchQueue = DispatchQueue.main) -> Observable<T.Query.Data> {
        let configuration = URLSessionConfiguration.default
        configuration.httpAdditionalHeaders = graphQLRequest.httpAdditionalHeaders
        let session = URLSession(configuration: configuration)
        let httpNetworkTransport = HTTPNetworkTransport(url: graphQLRequest.url, session: session)
        
        self.apolloClient = ApolloClient(networkTransport: httpNetworkTransport)
        return self.apolloClient!
            .rx
            .fetch(query: graphQLRequest.query, cachePolicy: cachePolicy, queue: queue)
            .asObservable()
    }
}

実装(Model)

上記のGraphQLRequestプロトコルに準拠したGitHubRequest.swiftを実装しました。

Utility/GraphQLRequest.swift
import Apollo
import Foundation

struct GitHubRequest: GraphQLRequest {

    // apollo codegenで生成したGraphQLQuery
    typealias Query = GitHubReposQuery

    var query: GitHubReposQuery

    // GitHub GraphQL APIのURL
    var url: URL = "https://api.github.com/graphql".toURL()

    // アクセストークンをヘッダにセット
    var httpAdditionalHeaders: [AnyHashable: Any]? = ["Authorization": "Bearer <Set GitHub personal access token>"]
}

今回は、GitHub GraphQL APIしか使用していませんが、
複数のエンドポイントがある場合にGraphQLRequestに準拠することで
共通のリクエスト処理GraphQLClient.swiftを使用することができます!

実装(ViewModel - ViewController)

ViewControllerとViewModelは、RxSwiftでバインディングをするように実装していますが、
Unioを導入することでロジックの流れをシンプルにすることができました!
Unio便利👏👏

ViewModels/GitHubSearch/GitHubSearchViewModel.swift
import Action
import Foundation
import RxCocoa
import RxSwift
import Unio

protocol GitHubSearchViewModelType : AnyObject {
    var input: Unio.InputWrapper<GitHubSearchViewModel.Input> { get }
    var output: Unio.OutputWrapper<GitHubSearchViewModel.Output> { get }
}

final class GitHubSearchViewModel: UnioStream<GitHubSearchViewModel>, GitHubSearchViewModelType {

    init() {
        super.init(input: Input(), state: State(), extra: Extra())
    }
}

extension GitHubSearchViewModel {

    // インプット・アウトプットを定義

    typealias Extra = NoExtra
    
    struct Input: InputType {
        let initialFetchTrigger = PublishRelay<String>()
        let fetchTrigger = PublishRelay<Void>()
    }

    struct Output: OutputType {
        let searchText: BehaviorRelay<String>
        let nodes: BehaviorRelay<[GitHubRequest.Query.Data.Search.Node]>
        let fetchError: Observable<Error>
        let isLoading: BehaviorRelay<Bool>
    }

    struct State: StateType {
        let searchText = BehaviorRelay<String>(value: "")
        let nodes = BehaviorRelay<[GitHubRequest.Query.Data.Search.Node]>(value: [])
        var pageInfo = BehaviorRelay<GitHubRequest.Query.Data.Search.PageInfo>(value: .init(hasPreviousPage: false, hasNextPage: true))
        let isLoading = BehaviorRelay<Bool>(value: false)

        mutating func initState() {
            self.searchText.accept("")
            self.nodes.accept([])
            self.pageInfo.accept(.init(hasPreviousPage: false, hasNextPage: true))
            self.isLoading.accept(false)
        }
    }
}

extension GitHubSearchViewModel {
    
    /// DependencyのInput, State, Extraが変化したらOutputを生成する
    static func bind(from dependency: Dependency<GitHubSearchViewModel.Input, GitHubSearchViewModel.State, GitHubSearchViewModel.Extra>,
                     disposeBag: DisposeBag) -> GitHubSearchViewModel.Output {
        
        let input = dependency.inputObservables
        var state = dependency.state
        
        // GitHubRequestを受け取って、クロージャのGraphQLClient.fetchを実行し、
        // GitHubRequest.Query.DataをObservableで返す
        let fetchData = Action<GitHubRequest, GitHubRequest.Query.Data> { args in
            GraphQLClient.fetch(graphQLRequest: args)
        }
        
        fetchData.elements
            .bind(onNext: { data in
                if let optionalNodes = data.search.nodes {
                    let nodes = optionalNodes.compactMap { $0 }
                    state.nodes.accept(state.nodes.value + nodes)
                } else {
                    state.nodes.accept([])
                }
                state.pageInfo.accept(data.search.pageInfo)
            })
            .disposed(by: disposeBag)

        // 初回検索(画面表示時・検索ワード変更時)
        input.initialFetchTrigger
            .bind(onNext: { text in
                state.initState()
                state.searchText.accept(text)
                state.nodes.accept([])
                let query = GitHubReposQuery(query: text, type: .repository, first: 20, after: state.pageInfo.value.endCursor)
                let request = GitHubRequest(query: query)
                fetchData.execute(request)
            })
            .disposed(by: disposeBag)

        // 追加読み込み
        input.fetchTrigger
            .filter { _ in state.pageInfo.value.hasNextPage }
            .bind(onNext: { _ in
                let query = GitHubReposQuery(query: state.searchText.value, type: .repository, first: 20, after: state.pageInfo.value.endCursor)
                let request = GitHubRequest(query: query)
                fetchData.execute(request)
            })
            .disposed(by: disposeBag)

        // ローディング
        fetchData.executing
            .bind(to: state.isLoading)
            .disposed(by: disposeBag)
        
        return GitHubSearchViewModel.Output(
            searchText: state.searchText,
            nodes: state.nodes,
            fetchError: fetchData.errors.map { $0 as Error },
            isLoading: state.isLoading
        )
    }
}
Views/GitHubSearch/GitHubSearchViewController.swift
import RxCocoa
import RxSwift
import RxViewController
import UIKit

private enum GitHubSearchTableViewSection: Int, CaseIterable {
    case githubItems
    case loading

    static func numberOfSectionsWhenNotLoading() -> Int {
        return GitHubSearchTableViewSection.allCases.count - 1
    }
}

final class GitHubSearchViewController: UIViewController {

    // MARK: - IBOutlet

    @IBOutlet private weak var tableView: UITableView! {
        didSet {
            tableView.delegate = self
            tableView.dataSource = self
        }
    }

    // MARK: - Properties

    private let initWord = "Swift"

    var viewModel: GitHubSearchViewModel! = .init()

    private var disposeBag = DisposeBag()
    private var searchBar: UISearchBar?

    private lazy var searchController: UISearchController! = {
        return UISearchController(searchResultsController: nil)
    }()

    // MARK: - Life cycle

    override func viewDidLoad() {
        super.viewDidLoad()
        setup()
    }
}

// MARK: - Private methods
extension GitHubSearchViewController {

    private func setup() {
        registerNibs()
        setupNavigationItem(inputWord: initWord)
        bindInput()
        bindOutput()
    }

    private func registerNibs() {
        GitHubRepositoryCell.register(tableView: self.tableView)
        LoadingCell.register(tableView: self.tableView)
    }

    private func setupNavigationItem(inputWord: String) {
        self.searchBar = searchController.searchBar
        searchController.obscuresBackgroundDuringPresentation = false
        navigationItem.searchController = searchController
        navigationItem.hidesSearchBarWhenScrolling = false
        self.searchBar?.delegate = self
        self.searchBar?.showsCancelButton = false
        self.searchBar?.returnKeyType = .search
        self.searchBar?.text = inputWord
        navigationItem.title = inputWord
    }

    private func bindInput() {

        // 初期検索(画面表示時)
        self.rx.viewWillAppear
            .take(1) // 無限スクロールで取得した内容が破棄されると困るので、初回のみフェッチ
            .map { [weak self] _ in
                guard let `self` = self else { return "" }
                return self.initWord
        }
            .bind(to: self.viewModel.input.initialFetchTrigger)
            .disposed(by: self.disposeBag)

        // インクリメンタルサーチ
        self.searchBar?
            .rx
            .text
            .filter { [weak self] _ in
                guard let `self` = self else { return false }

                let willSearchText = self.searchBar?.text ?? ""
                if willSearchText.isEmpty { return true }
                let searchedText = self.viewModel.output.searchText.value

                // 同じテキストの場合は、無限スクロールで取得した内容が破棄されると困るので、検索しない
                if willSearchText == searchedText {
                    return false
                }
                return true
        }
            .debounce(.milliseconds(500), scheduler: MainScheduler.instance)
            .compactMap{ $0 }
            .bind(to: self.viewModel.input.initialFetchTrigger)
            .disposed(by: self.disposeBag)

        // 追加読み込み
        self.tableView
            .rx
            .didScroll
            .filter { [weak self] _ in
                guard let `self` = self else { return false }
                // スクロールビューの末尾に近づいて、且つロード中じゃなければ追加読み込みする
                return self.tableView.isNearBottomEdge() && !self.viewModel.output.isLoading.value
        }
            .bind(to: self.viewModel.input.fetchTrigger)
            .disposed(by: self.disposeBag)
    }

    private func bindOutput() {

        self.viewModel.output.searchText
            .bind(onNext: { [weak self] searchText in
                self?.searchBar?.text = searchText
                self?.navigationItem.title = searchText
            })
            .disposed(by: self.disposeBag)

        self.viewModel.output.nodes
            .bind(onNext: { [weak self] nodes in
                self?.tableView.reloadData()
            })
            .disposed(by: self.disposeBag)

        self.viewModel.output.fetchError
            .bind(onNext: { [weak self] error in
                print("error", error)
                // TODO: Error handling
            })
            .disposed(by: self.disposeBag)
    }
}

// MARK: - UISearchBarDelegate
extension GitHubSearchViewController: UISearchBarDelegate {

    func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
        searchBar.resignFirstResponder()
        navigationItem.title = searchBar.text
    }
}

// MARK: - UITableViewDataSource
extension GitHubSearchViewController: UITableViewDataSource {

    func numberOfSections(in tableView: UITableView) -> Int {
        // 読み込み中の場合は、LoadingCell用のセクション有り
        // 読み込み中ではない場合は、LoadingCell用のセクション無し
        let isLoading = self.viewModel.output.isLoading.value
        return isLoading ? GitHubSearchTableViewSection.allCases.count : GitHubSearchTableViewSection.numberOfSectionsWhenNotLoading()
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {

        guard let gitHubSearchSection = GitHubSearchTableViewSection(rawValue: section) else {
            assertionFailure("GitHubSearchTableViewSection error.")
            return 0
        }

        switch gitHubSearchSection {
        case .githubItems:
            let nodesCount = self.viewModel.output.nodes.value.count
            return nodesCount

        case .loading:
            return 1
        }
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

        guard let gitHubSearchSection = GitHubSearchTableViewSection(rawValue: indexPath.section) else {
            assertionFailure("GitHubSearchTableViewSection error.")
            return UITableViewCell()
        }

        switch gitHubSearchSection {
        case .githubItems:
            return self.gitHubRepositoryCell(tableView: tableView, cellForRowAt: indexPath)

        case .loading:
            return self.loadingCell(tableView: tableView, cellForRowAt: indexPath)
        }
    }

    private func gitHubRepositoryCell(tableView: UITableView, cellForRowAt indexPath: IndexPath) -> GitHubRepositoryCell {
        let cell: GitHubRepositoryCell = tableView.dequeueReusableCell(for: indexPath)

        let nodes = self.viewModel.output.nodes.value
        if let repository = nodes[indexPath.row].asRepository {
            cell.configure(githubRepository: repository)
        }
        return cell
    }

    private func loadingCell(tableView: UITableView, cellForRowAt indexPath: IndexPath) -> LoadingCell {
        let cell: LoadingCell = tableView.dequeueReusableCell(for: indexPath)

        cell.startAnimating()
        return cell
    }
}

// MARK: - UITableViewDelegate
extension GitHubSearchViewController: UITableViewDelegate {}

さいごに

ソースコード全量はGitHubに公開しています

RxSwiftもGraphQLもとりあえず使ってみたという感じで
まだまだ深く理解できていないところがあるので
随時アップデートしていければと思います😔

16
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?