はじめに
アーキテクチャやテストを自分で実装しながら学ぶためのサンプル(あえてアーキテクチャやテストを無視して実装したもの)が欲しかったため、GitHubのREST APIを使ってユーザーを検索するサンプルを用意しました。
※アーキテクチャやテストが考慮された、PEAKS(ピークス)|iOSアプリ設計パターン入門のサンプルコード(GitHub - peaks-cc/iOS_architecture_samplecode)を参考にしています。そのため、アーキテクチャやテストが考慮されたものについては、上記書籍と書籍のサンプルコードをご確認ください。
開発環境
実装
Main.storyboard
UISearchBarとUITableViewを配置。
※下の画像の通り、UITableViewはdelegateとdataSourceを設定してください。
ViewController.swift
import UIKit class ViewController: UIViewController { @IBOutlet weak var searchBar: UISearchBar! @IBOutlet weak var tableView: UITableView! private(set) var users: [User] = [] override func viewDidLoad() { super.viewDidLoad() tableView.register(UINib(nibName: "TableViewCell", bundle: nil), forCellReuseIdentifier: "TableViewCell") } // ユーザー情報を取得 func fetchUser(query: String, completion: @escaping (Result<[User]>) -> ()) { let request = SearchUsersRequest(query: query) Session().send(request) { result in switch result { case .success(let response): completion(.success(response.items)) case .failure(let error): completion(.failure(error)) } } } } extension ViewController: UISearchBarDelegate { // 編集開始時の処理 func searchBarShouldBeginEditing(_ searchBar: UISearchBar) -> Bool { // キャンセルボタンを表示 searchBar.showsCancelButton = true return true } // キャンセルボタン選択時の処理 func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { // キャンセルボタンを非表示に変更 searchBar.showsCancelButton = false // キーボードを下げる searchBar.resignFirstResponder() } // 検索実行時の処理 func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { guard let query = searchBar.text else { return } guard !query.isEmpty else { return } fetchUser(query: query) { [weak self] result in switch result { case .success(let users): self?.users = users DispatchQueue.main.async { self?.tableView.reloadData() } case .failure(let error): // TODO: Error Handling () } } } } extension ViewController: UITableViewDataSource { // セルの個数 func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return users.count } // セルを作成 func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "TableViewCell", for: indexPath) as! TableViewCell cell.configure(user: users[indexPath.row]) return cell } }
TableViewCell.xib
UIImageViewとUILabelを配置しています。
TableViewCell.swift
import UIKit class TableViewCell: UITableViewCell { @IBOutlet weak var icon: UIImageView! @IBOutlet weak var name: UILabel! private var task: URLSessionTask? // セルの初期化処理 override func prepareForReuse() { super.prepareForReuse() task?.cancel() task = nil imageView?.image = nil } func configure(user: User) { // 画像URLからユーザー画像を取得して表示 task = { let url = user.avatarURL let task = URLSession.shared.dataTask(with: url) { data, response, error in guard let imageData = data else { return } DispatchQueue.global().async { [weak self] in guard let image = UIImage(data: imageData) else { return } DispatchQueue.main.async { self?.icon?.image = image self?.setNeedsLayout() } } } task.resume() return task }() name.text = user.login } }
Session.swift
import Foundation final class Session { private let additionalHeaderFields: () -> [String: String]? private let session: URLSession init(additionalHeaderFields: @escaping () -> [String: String]? = { nil }, session: URLSession = .shared) { self.additionalHeaderFields = additionalHeaderFields self.session = session } @discardableResult func send<T: Request>(_ request: T, completion: @escaping (Result<T.Response>) -> ()) -> URLSessionTask? { let url = request.baseURL.appendingPathComponent(request.path) guard var componets = URLComponents(url: url, resolvingAgainstBaseURL: true) else { completion(.failure(SessionError.failedToCreateComponents(url))) return nil } componets.queryItems = request.queryParameters?.compactMap(URLQueryItem.init) guard var urlRequest = componets.url.map({ URLRequest(url: $0) }) else { completion(.failure(SessionError.failedToCreateURL(componets))) return nil } urlRequest.httpMethod = request.method.rawValue let headerFields: [String: String] if let additionalHeaderFields = additionalHeaderFields() { headerFields = request.headerFields.merging(additionalHeaderFields, uniquingKeysWith: +) } else { headerFields = request.headerFields } urlRequest.allHTTPHeaderFields = headerFields let task = session.dataTask(with: urlRequest) { data, response, error in if let error = error { completion(.failure(error)) return } guard let response = response as? HTTPURLResponse else { completion(.failure(SessionError.noResponse)) return } guard let data = data else { completion(.failure(SessionError.noData(response))) return } guard 200..<300 ~= response.statusCode else { let message = try? JSONDecoder().decode(SessionError.Message.self, from: data) completion(.failure(SessionError.unacceptableStatusCode(response.statusCode, message))) return } do { let object = try JSONDecoder().decode(T.Response.self, from: data) completion(.success(object)) } catch { completion(.failure(error)) } } task.resume() return task } } enum SessionError: Error { case noData(HTTPURLResponse) case noResponse case unacceptableStatusCode(Int, Message?) case failedToCreateComponents(URL) case failedToCreateURL(URLComponents) } extension SessionError { struct Message: Decodable { let documentationURL: URL let message: String private enum CodingKeys: String, CodingKey { case documentationURL = "documentation_url" case message } } }
Request.swift
import Foundation protocol Request { associatedtype Response: Decodable var baseURL: URL { get } var method: HttpMethod { get } var path: String { get } var headerFields: [String: String] { get } var queryParameters: [String: String]? { get } } extension Request { var baseURL: URL { return URL(string: "https://api.github.com")! } var headerFields: [String: String] { return ["Accept": "application/json"] } var queryParameters: [String: String]? { return nil } } enum HttpMethod: String { case get = "GET" case post = "POST" }
SearchUsersRequest.swift
import Foundation struct SearchUsersRequest: Request { typealias Response = ItemsResponse<User> let method: HttpMethod = .get let path = "/search/users" var queryParameters: [String: String]? { let params: [String: String] = ["q": query] return params } let query: String init(query: String) { self.query = query } }
ItemsResponse.swift
import Foundation struct ItemsResponse<Item: Decodable>: Decodable { let items: [Item] init(items: [Item]) { self.items = items } }
Result.swift
import Foundation enum Result<T> { case success(T) case failure(Error) }
User.swift
import Foundation struct User: Codable { let login: String let avatarURL: URL private enum CodingKeys: String, CodingKey { case login case avatarURL = "avatar_url" } init(login: String, avatarURL: URL) { self.login = login self.avatarURL = avatarURL } }
おわりに
サンプルを使って、今後以下の順番でアーキテクチャやテストの理解を深めたいと思います。
※記事にまとめながら気づいたのですが、リストのUIはiOS14を意識してUITableViewではなくUICollectionViewで実装すればよかったですね・・・。
UICollectionViewでUITableViewのUIを表現する実装は別途試してみたいと思います。