概要
Swift5.5から新しく登場したasync/await
による非同期処理の学習でGitHub API
を叩いてリポジトリを検索する機能を実装したので記事にします。
開発環境
Swift 5.5
Xcode 13.1
サンプルプロジェクト
GitHubにPushしました。気になる方はご覧ください。
https://github.com/ken-sasaki-222/AsyncAwaitTrainingInUIKit
レスポンスをもとに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
だけ取得します。
import Foundation
struct SearchResponseModel: Decodable {
var items: [Item]
}
struct Item: Decodable {
var full_name: String
var description: String?
}
実装
処理順序
依存関係逆転の原則(DIP)
を利用して今回は開発しています。
DataStore
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
import Foundation
protocol SearchRepositoryInterface {
func searchGitHubRepository(searchText: String) async throws -> [Item]
}
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
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
で受け取ってそれぞれ処理します。
検索結果画面
おわりに
今回はasync/await
を使ってGitHub APIを叩きました。
これまで@escaping
で書いてたときのクロージャラッシュに比べると、安全性が向上し可読性が高くなった印象です。また同期的に書ける点も嬉しいですね。
ご覧いただきありがとうございました。
こうしたほうがいいや、ここはちょっと違うなど気になる箇所があった場合、ご教示いただけると幸いです。
おまけ
データに依存するので完全なテストではないですが、DataStoreの動作確認レベルでUnitTest
を書きました。
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でご依頼お待ちしております!