13
4

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 1 year has passed since last update.

SwiftAdvent Calendar 2021

Day 4

[Swift]async/awaitを使ってGitHubAPIを叩く(UIKit版)

Last updated at Posted at 2021-12-01

概要

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

開発環境

Swift 5.5
Xcode 13.1

サンプルプロジェクト

GitHubにPushしました。気になる方はご覧ください。
https://github.com/ken-sasaki-222/AsyncAwaitTrainingInUIKit
QR_280408.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

依存関係逆転の原則(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

おわりに

今回は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 throwstry awaitが登場してますね。これまでのXCTestExpectationがなくても非同期テストを実行できます。

お知らせ

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

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

Twitter DMでご依頼お待ちしております!
Twitter_QR.png

13
4
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
13
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?