[Swift]async/awaitを使ってGitHubAPIを叩く（UIKit版）

## 概要
Swift5.5から新しく登場した`async/await`による非同期処理の学習で`GitHub API`を叩いてリポジトリを検索する機能を実装したので記事にします。

## 開発環境
Swift 5.5
Xcode 13.1

## サンプルプロジェクト
GitHubにPushしました。気になる方はご覧ください。
https://github.com/ken-sasaki-222/AsyncAwaitTrainingInUIKit
![QR_280408.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643372/db75944b-f035-278d-b380-c0b299264fcb.png)

## レスポンスをもとにModelを作成
```
{
    "total_count": 223653,
    "incomplete_results": false,
    "items": [
      {
        "id": 44838949,
        "node_id": "MDEwOlJlcG9zaXRvcnk0NDgzODk0OQ==",
        "name": "swift",
        "full_name": "apple/swift",
        "private": false,
        "owner": {
          "login": "apple",
          "id": 10639145,
          "node_id": "MDEyOk9yZ2FuaXphdGlvbjEwNjM5MTQ1",
          "avatar_url": "https://avatars.githubusercontent.com/u/10639145?v=4",
          "gravatar_id": "",
          ...
      },
    "html_url": "https://github.com/apple/swift",
    "description": "The Swift Programming Language",
    ...
```

今回はサンプルプロジェクトなので`full_name`と、`description`だけ取得します。

```SearchResponseModel.swift 
import Foundation

struct SearchResponseModel: Decodable {
    var items: [Item]
}

struct Item: Decodable {
    var full_name: String
    var description: String?
}
```

## 実装

#### 処理順序
![DIP.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643372/5f12c1cb-818f-a5c2-b90d-a666eefa4330.png)

`依存関係逆転の原則（DIP）`を利用して今回は開発しています。

#### DataStore
```SearchRepositoryDataStore.swift
import Foundation

class SearchRepositoryDataStore {
    private let baseUrl = "https://api.github.com/"
    private let shared = URLSession.shared
    private let decoder = JSONDecoder()
    
    func searchRepositories(query: String) async throws -> SearchResponseModel {
        let queryString = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
        let urlString = baseUrl + "search/repositories?q=\(queryString ?? "")"
        guard let url = URL(string: urlString) else {
            throw NSError(domain: "error", code: -1, userInfo: nil)
        }
        let request = URLRequest(url: url)
        let (data, _) = try await shared.data(for: request)
        let response = try decoder.decode(SearchResponseModel.self, from: data)
        return response
    }
}
```

APIに通信するDataStoreです。検索結果をRepositoryに返します。
`async throws`と書くことでエラーが発生する可能性のある非同期関数を意味します。エラー処理は`throw`で返します。非同期処理を行う箇所で`try await`を書くことで同期的な書き方ができます。
これまでの`@escaping`での非同期処理に比べて、`onFailre()`などの意識しないといけない箇所が減ったので可読性だけでなく安全性も高くなっています。

#### Repository
```SearchRepositoryInterface.swift
import Foundation

protocol SearchRepositoryInterface {
    func searchGitHubRepository(searchText: String) async throws -> [Item]
}
```

```SearchRepository.swift
import Foundation

class SearchRepository: SearchRepositoryInterface {
    private let searchRepositoryDataStore = SearchRepositoryDataStore()
    
    func searchGitHubRepository(searchText: String) async throws -> [Item] {
        do {
            let response = try await searchRepositoryDataStore.searchRepositories(query: searchText)
            let items = response.items
            return items
        }
        catch(let error) {
            throw error
        }
    }
}
```

SearchRepositoryDataStoreへ検索ワードを渡して`do catch`で値を受け取ります。受け取った結果は`async throws`でViewControllerに返します。
ポイントは非同期関数を呼ぶ箇所で`await`している点です。これまではクロージャーで受け取っていた箇所が整理されて可読性が高まっています。

#### ViewController
```SearchViewController.swift
import UIKit

class SearchViewController: UIViewController {
    @IBOutlet weak var searchBar: UISearchBar!
    @IBOutlet weak var tableView: UITableView!
    private var searchRepository: SearchRepositoryInterface
    private var indicator = UIActivityIndicatorView()
    private var items: [Item] = []
    
    init(searchRepository: SearchRepositoryInterface) {
        self.searchRepository = searchRepository
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        self.searchRepository = RepositoryLocator.getSearchRepository()
        super.init(coder: coder)
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setUpSearchBar()
        setUpTableView()
        setUpIndicator()
    }
    
    private func setUpSearchBar() {
        searchBar.delegate = self
    }
    
    private func setUpTableView() {
        tableView.delegate = self
        tableView.dataSource = self
    }
    
    private func setUpIndicator() {
        indicator.center = view.center
        indicator.style = .large
        indicator.color = .lightGray
        view.addSubview(indicator)
    }
    
    private func refreshTableView() {
        self.items = []
        tableView.reloadData()
    }
    
    private func showSearchTextIsEmptyAlert() {
        let alert = UIAlertController(title: "確認", message: "リポジトリ名を入力してください", preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "やり直す", style: .default, handler: nil))
        present(alert, animated: true, completion: nil)
    }
    
    private func showSearchResponseErrorAlert() {
        let alert = UIAlertController(title: "エラー", message: "検索に失敗しました", preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "やり直す", style: .default, handler: nil))
        present(alert, animated: true, completion: nil)
    }
    
    @IBAction func tapRefreshButton(_ sender: UIBarButtonItem) {
        refreshTableView()
        searchBar.text = ""
    }
}

extension SearchViewController: UISearchBarDelegate {
    func searchBarShouldBeginEditing(_ searchBar: UISearchBar) -> Bool {
        searchBar.showsCancelButton = true
        return true
    }
    
    func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
        searchBar.showsCancelButton = false
        view.endEditing(true)
    }
    
    func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
        indicator.startAnimating()
        refreshTableView()
        
        if let searchText = searchBar.text {
            if searchText == "" {
                showSearchTextIsEmptyAlert()
                indicator.stopAnimating()
            } else {
                Task {
                    do {
                        var items = try await searchRepository.searchGitHubRepository(searchText: searchText)
                        print("items:", items as Any)
                        
                        if items.count == 0 {
                            let emptyItem = Item(full_name: "検索結果0件", description: "")
                            items.append(emptyItem)
                            self.items = items
                            tableView.reloadData()
                            indicator.stopAnimating()
                        } else {
                            self.items = items
                            tableView.reloadData()
                            indicator.stopAnimating()
                        }
                    }
                    catch(let error) {
                        print("error:", error.localizedDescription)
                        indicator.stopAnimating()
                        showSearchResponseErrorAlert()
                    }
                }
            }
        }
    }
}

extension SearchViewController: UITableViewDelegate, UITableViewDataSource {
    func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return items.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        let name = cell.viewWithTag(1) as? UILabel
        let description = cell.viewWithTag(2) as? UILabel
        name?.text = items[indexPath.row].full_name
        if description?.text != "" {
            description?.text = items[indexPath.row].description
        } else {
            description?.text = "not description text."
        }
        return cell
    }
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
    }
}
```
UISearchBarの検索ボタンタップでテキストが空文字でなければ検索を開始するシンプルな機能です。
ポイントは非同期処理を開始する箇所を`Task`で囲んでいる点です。同期処理から非同期処理を呼ぶ場合、`Task`で囲む必要があります。囲んだ後は同期的に書いて`try await`で非同期処理を呼べばOKです。
Repositoryの`async throws`から返ってきた結果を`do catch`で受け取ってそれぞれ処理します。

## 検索結果画面
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643372/7b03f07c-f1ba-4b6f-945a-4aac263a6fd0.png)

## おわりに
今回は`async/await`を使ってGitHub APIを叩きました。
これまで`@escaping`で書いてたときのクロージャラッシュに比べると、安全性が向上し可読性が高くなった印象です。また同期的に書ける点も嬉しいですね。

ご覧いただきありがとうございました。
こうしたほうがいいや、ここはちょっと違うなど気になる箇所があった場合、ご教示いただけると幸いです。

## おまけ
データに依存するので完全なテストではないですが、DataStoreの動作確認レベルで`UnitTest`を書きました。

```SearchRepositoryDataStoreTests.swift
import XCTest

class SearchRepositoryDataStoreTests: XCTestCase {

    override func setUpWithError() throws {
        // Put setup code here. This method is called before the invocation of each test method in the class.
    }

    override func tearDownWithError() throws {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
    }

    func test_searchRepositoryDataStore() async throws {
        let dataStore = SearchRepositoryDataStore()
        let searchText = "Swift"
        let response = try await dataStore.searchRepositories(query: searchText)
        let items = response.items
        print("Success search repository dataStore.")
        print("items:", items)
        XCTAssert(items.count > 0)
    }

    func testPerformanceExample() throws {
        // This is an example of a performance test case.
        self.measure {
            // Put the code you want to measure the time of here.
        }
    }

}
```
ここでも`async throws`と`try await`が登場してますね。これまでの`XCTestExpectation`がなくても非同期テストを実行できます。

## お知らせ
現在副業でiOSアプリ開発案件を募集しています。

↓活動リンクはこちら↓
https://linktr.ee/sasaki.ken

[Twitter DM](https://twitter.com/ken_sasaki2)でご依頼お待ちしております！
![Twitter_QR.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643372/fa387b02-71af-ffe2-59d8-360102a9b172.png)