はじめに
今まで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のリポジトリを検索するデモアプリを作りました。
ありがちなサンプルですが、インクリメンタルサーチをして
一番下(付近)までスクロールしたら追加リクエストをするようにしています🙉
実装前の準備
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から情報を入力することでトークンを作成することができます。
トークンのスコープを選択するチェックボックスが表示されますが、
今回は、「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 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を定義しました
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でレスポンスを返却できるようにしています。
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を実装しました。
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便利👏👏
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
)
}
}
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もとりあえず使ってみたという感じで
まだまだ深く理解できていないところがあるので
随時アップデートしていければと思います😔