🔍
Swift Concurrencyを使ったGitHubリポジトリ検索のiOSアプリケーションの実装
背景
- 色々な所で題材とされるGitHubAPIを使った、リポジトリを検索するアプリケーションを実装してみました。
- 一番のモチベーションとしては、iOSDC2022でkoherさんの発表を見てSwift Concurrencyを使ったAPIの通信処理部分を書きたくなったからです。
- (突っ込み所もあるかと思いますが、その際は遠慮なくマサカリをぶん投げてもらえると助かります…!)
GitHub
参考
- Swift Concurrency
- API通信処理
GitHubAPI部分の実装
GitHubAPIServiceProtocol
- GitHubAPIを利用するクラスとして
GitHubAPIService
を作成します。 - UnitTestのため
Stub
を作成するので、GitHubAPIServiceProtocol
を定義して適合させます。 - また任意の場所から呼び出すため、データ競合しないようにactorとして作成し
shared
のようにシングルトンとして呼び出します
protocol GitHubAPIServiceProtocol {
static var shared: Self { get }
func searchRepositories(keyword: String) async throws -> [Repository]
}
// 実際にAPIを呼び出すクラス
final actor GitHubAPIService: GitHubAPIServiceProtocol {
...
}
// Stub
final class StubGitHubAPIService: GitHubAPIServiceProtocol {
...
}
// 使用例
try await GitHubAPIService.shared.searchRepositories(keyword: keyword)
GitHubAPIService
- リポジトリ検索の実装は以下の通りです。
func searchRepositories(keyword: String) async throws -> [Repository] {
let response = try await request(with: GitHubAPIRequest.SearchRepositories(keyword: keyword))
let repositories = response.items
return repositories
}
- また
request
に関しては、任意のGitHubAPIRequestProtocol
に適合したものを呼ぶため、以下の通り実装しています。 -
request
・GitHubAPIRequestProtocol
などこの辺りのメインの考え方は以下の書籍を参考にしています。
extension GitHubAPIService {
func request<Request>(with request: Request) async throws ->
Request.Response where Request: GitHubAPIRequestProtocol {
let (data, response): (Data, URLResponse)
do {
let urlRequest = request.buildURLRequest()
(data, response) = try await urlSession.data(for: urlRequest)
} catch {
throw GitHubAPIServiceError.connectionError(error)
}
guard let statusCode = (response as? HTTPURLResponse)?.statusCode,
(200..<300).contains(statusCode) else {
// エラーレスポンス
let gitHubAPIError: GitHubAPIError
do {
gitHubAPIError = try JSONDecoder().decode(GitHubAPIError.self, from: data)
} catch {
throw GitHubAPIServiceError.responseParseError(error)
}
throw GitHubAPIServiceError.apiError(gitHubAPIError)
}
// 成功レスポンス
do {
let response = try JSONDecoder().decode(Request.Response.self, from: data)
return response
} catch {
throw GitHubAPIServiceError.responseParseError(error)
}
}
}
GitHubAPIRequestProtocol
- 下記の通り
URLRequest
を作成するためのGitHubAPIRequestProtocol
を定義します。 - extensionでProtocolのデフォルト実装を行い、その他のパラメータは呼び出したいAPI毎に定義します。
let urlRequest = request.buildURLRequest()
protocol GitHubAPIRequestProtocol {
associatedtype Response: Decodable
var baseURL: URL { get }
var path: String { get } // baesURLからの相対パス
var method: HTTPMethod { get }
var queryItems: [URLQueryItem] { get }
var header: [String: String] { get }
var body: Data? { get } // HTTP bodyに設定するパラメータ
}
extension GitHubAPIRequestProtocol {
var baseURL: URL { ... }
func buildURLRequest() -> URLRequest { ... }
}
-
GitHubAPIRequest.xxx
と呼び出し時にわかりやすくするため、enumのextensionとしてAPI毎にGitHubAPIRequestProtocol
に適合したstructを作成します。
enum GitHubAPIRequest {
// 実体はextensionで定義
}
extension GitHubAPIRequest {
public struct SearchRepositories: GitHubAPIRequestProtocol {
public typealias Response = SearchResponse<Repository>
public let keyword: String
...
}
}
GitHubAPI実装部分のユニットテスト
モデルクラスのデコードのテスト
-
GitHubService
で使用するモデルクラスのユニットテストです。 - JSON文字列からモデルクラスのデコードを行い、各パラメータが期待のものかどうかをテストしています。
extension Repository {
static var sampleJSON: String {
#"""
{
"id": 130902948,
...
"subscribers_count": 261
}
"""#
}
}
final class RepositoryTests: XCTestCase {
func testDecode() throws {
let jsonDecoder = JSONDecoder()
guard let data = Repository.sampleJSON.data(using: .utf8) else {
XCTFail("jsonデータの取得に失敗しました")
return
}
let repository = try jsonDecoder.decode(Repository.self, from: data)
let user = repository.owner
XCTAssertEqual(repository.id, 130902948)
XCTAssertEqual(repository.name, "swift")
XCTAssertEqual(repository.fullName, "tensorflow/swift")
...
}
}
GitHubAPIServiceのテスト
-
GitHubAPIService
のイニシャライザに定義したURLSessionProtocol
を渡してDIをします。
final actor GitHubAPIService: GitHubAPIServiceProtocol {
private(set) var urlSession: URLSessionProtocol
init(urlSession: URLSessionProtocol = URLSession.shared) {
self.urlSession = urlSession
}
}
- これにより下記の
urlSession.data
の部分で、テスト内で定義した結果が返るようにしています。
extension GitHubAPIService {
func request<Request>(with request: Request) async throws ->
Request.Response where Request: GitHubAPIRequestProtocol {
let (data, response): (Data, URLResponse)
do {
let urlRequest = request.buildURLRequest()
(data, response) = try await urlSession.data(for: urlRequest)
...
- また
URLSessionProtocol
・StubURLSession
は下記の通り定義しています。 - 下記で使用されていたクラスを少し変えて使用しています。
protocol URLSessionProtocol {
func data(for request: URLRequest) async throws -> (Data, URLResponse)
}
extension URLSession: URLSessionProtocol { }
/// GitHubAPIServiceのテストで使用するURLSessionのStub
class StubURLSession: URLSessionProtocol {
private let stubbedData: Data?
private let stubbedResponse: URLResponse?
private let stubbedError: Error?
init(data: Data? = nil, response: URLResponse? = nil, error: Error? = nil) {
self.stubbedData = data
self.stubbedResponse = response
self.stubbedError = error
}
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
if let stubbedError {
throw stubbedError
}
guard let stubbedData,
let stubbedResponse else {
fatalError("(date, response)かerrorのどちらかのパラメータを設定してください")
}
return (stubbedData, stubbedResponse)
}
}
- 実際には以下の通り使用します。
let urlSessionStub = StubURLSession(
data: data,
response: HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)
)
sut = GitHubAPIService(urlSession: urlSessionStub)
- 以上を使って、以下の通り成功したときにリポジトリが得られているか、また各エラーが期待通り発生するかのユニットテストを実装しています。
import XCTest
@testable import iOSEngineerCodeCheck
final class GitHubAPIServiceTests: XCTestCase {
// MARK: - Properties
private var sut: GitHubAPIService!
// MARK: - Setup/TearDown
override func setUpWithError() throws {
try super.setUpWithError()
}
override func tearDownWithError() throws {
sut = nil
try super.tearDownWithError()
}
// MARK: - リポジトリの検索
// MARK: 成功
func testSearchRepositotiesSuccess() async throws {
// given
guard let data = SearchResponse<Repository>.sampleJSON.data(using: .utf8),
let url = URL(string: "https://api.github.com/search/repositories?q=Swift")
else {
XCTFail("Stubデータの作成に失敗しました")
return
}
let urlSessionStub = StubURLSession(
data: data,
response: HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)
)
sut = GitHubAPIService(urlSession: urlSessionStub)
// when/then
do {
let repositories = try await sut.searchRepositories(keyword: "Swift")
XCTAssertFalse(repositories.isEmpty)
} catch {
XCTFail("unexpected error: \(error.localizedDescription)")
}
}
// MARK: 通信エラー
func testSearchRepositotiesFailByConnectionError() async throws {
// given
let urlSessionStub = StubURLSession(
error: URLError(.cannotConnectToHost)
)
sut = GitHubAPIService(urlSession: urlSessionStub)
// when
var errorIsExpected = false
do {
_ = try await sut.searchRepositories(keyword: "Swift")
} catch {
// then
if let serviceError = error as? GitHubAPIServiceError {
switch serviceError {
case .connectionError:
errorIsExpected = true
default:
XCTFail("unexpected error: \(error.localizedDescription)")
}
} else {
XCTFail("unexpected error: \(error.localizedDescription)")
}
}
XCTAssert(errorIsExpected, "expected error is .connectionError")
}
...
Viewの実装
RepositorySearchScreen
-
RepositorySearchScreen
はトップ画面です。
-
~Screen
という名称は以下を参考につけています。
SwiftUI コーディングガイドのすすめ
View の命名規則です。View の命名は通常 ~View とサフィックスに View を用いると思います。UIKit では ViewController が最上位の画面となるのが一目でわかるのですが、SwiftUI の場合、全ての View が View サフィックスで命名されていると、それが画面中のコンポーネントなのか最上位の画面なのかが見分けにくくなり、デバッグや改修がしづらいと感じていました。そこで、最上位の画面となる View は ~Screen とサフィックスに Screen を用いることにしています。
- 下記の通り
@MainActor
のViewModel
を作成し、検索実行時にTask
内で検索処理を実行しています。
struct RepositorySearchScreen: View {
@StateObject private var viewModel: SearchResultViewModel<GitHubAPIService> = .init()
@State private var keyword = ""
internal let inspection = Inspection<Self>()
var body: some View {
NavigationView {
SearchResultView(viewModel: viewModel)
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $keyword,
placement: .automatic,
prompt: "検索") {
}.onSubmit(of: .search) {
Task {
await viewModel.searchButtonPressed(withKeyword: keyword)
}
}
}
.onReceive(inspection.notice) { self.inspection.visit(self, $0) }
}
}
RepositoryCell
- 検索結果のListのCellのビューです。
-
body
の中をスッキリさせたいので、細かいビューを切り出して定義するようにしています。
struct RepositoryCell: View {
let repository: Repository
var body: some View {
VStack(alignment: .leading) {
repositoryNameLabel()
descriptionLabel()
userLabel()
HStack(spacing: 18) {
starsLabel()
languageLabel()
}
.padding(.top, 2)
}
}
// MARK: - ViewBuilder
@ViewBuilder
private func userLabel() -> some View { ... }
@ViewBuilder
private func repositoryNameLabel() -> some View { ... }
}
- また細かいUIはUIテストではなく、Previewを細かく定義して確認するようにしました。
struct RepositoryCell_Previews: PreviewProvider {
static var previews: some View {
Group {
RepositoryCell(repository: Repository.sampleData[0])
.previewDisplayName("通常")
RepositoryCell(repository: Repository.sampleDataWithLongWord)
.previewDisplayName("長い語句を含む場合")
RepositoryCell(repository: Repository.sampleDataWithoutSomeInfo)
.previewDisplayName("空の情報がある場合")
}
.padding()
.previewLayout(.fixed(width: 400, height: 400))
}
}
AboutView
-
GridView
を使ってスタッツの部分を表示しています。 - 各要素にalignmentを設定して表示することができます。
Grid(verticalSpacing: 8) {
starsGridRow(starsCount: repository.starsCount)
forksGridRow(forksCount: repository.forksCount)
issuesGridRow(issuesCount: repository.openIssuesCount)
websiteGridRow(websiteURL: repository.websiteURL)
}
@ViewBuilder
private func starsGridRow(starsCount: Int) -> some View {
GridRow {
Image(systemName: "star")
.foregroundColor(.secondary)
.gridColumnAlignment(.center)
Text("\(IntegerFormatStyle<Int>().notation(.compactName).format(starsCount))")
.bold()
.gridColumnAlignment(.trailing)
Text("stars")
.foregroundColor(.secondary)
.gridColumnAlignment(.leading)
}
}
- 数値の表示部分の
IntegerFormatStyle
に関しては下記を参考にしています。
- またURLの部分だけTextの数が違うのですが、gridCellColumns(_:)を使うと任意のカラム数として揃えることができます。
@ViewBuilder
private func websiteGridRow(websiteURL: URL?) -> some View {
if let websiteURL {
GridRow {
Image(systemName: "link")
.foregroundColor(.secondary)
Button {
UIApplication.shared.open(websiteURL)
} label: {
Text("\(websiteURL.absoluteString)")
.lineLimit(1)
.bold()
}
.gridCellColumns(3)
}
}
}
ViewModelの実装
SearchResultViewModel
- ViewModelは
MainActor
で保護します。- プロパティの変更がUIの変更となるため、必ずメインスレッドでプロパティが更新されるようにしています。
- Swift Concurrency時代のiOSアプリの作り方
- また
ViewModel
のユニットテストのため、型パラメータインジェクションでGitHubAPIService
のDIを行います。
@MainActor
final class SearchResultViewModel<GitHubAPIService>: ObservableObject
where GitHubAPIService: GitHubAPIServiceProtocol {
@Published private(set) var repositories: Stateful<[Repository]>
private(set) var task: Task<(), Never>?
init(repositories: Stateful<[Repository]> = .idle) {
self.repositories = repositories
}
}
-
Stateful
の実装は以下の通りです。 - 状態とデータを1つのプロパティで扱えるのが便利です。
enum Stateful<Value> {
case idle // まだデータを取得しにいっていない
case loading // 読み込み中
case failed(Error) // 読み込み失敗、遭遇したエラーを保持
case loaded(Value) // 読み込み完了、読み込まれたデータを保持
}
- また非同期処理のキャンセルのため、下記プロパティを持たせています。
private(set) var task: Task<(), Never>?
- 全体の通信部分の実装は以下の通りです。
- (
MainActor
なのでTaskの実行がメインスレッドとなるため、searchRepositories
をnonisolated
で切り出しています…がこの辺りの理解が浅く不要な処理かもしれません)
extension SearchResultViewModel {
func cancelSearching() {
task?.cancel()
task = nil
}
func searchButtonPressed(withKeyword keyword: String) async {
if keyword.isEmpty {
return
}
repositories = .loading
task = Task {
do {
let repositories = try await searchRepositories(keyword: keyword)
self.repositories = .loaded(repositories)
} catch {
if Task.isCancelled {
repositories = .idle
} else {
repositories = .failed(error)
}
}
}
}
// サブスレッドで実行させるためnoisolatedを使用する
nonisolated private func searchRepositories(keyword: String) async throws -> [Repository] {
#if DEBUG
// try await Task.sleep(nanoseconds: 3 * NSEC_PER_SEC) // n秒待つ。検索キャンセル処理の動作確認用。
#endif
return try await GitHubAPIService.shared.searchRepositories(keyword: keyword)
}
}
ViewModelのユニットテスト
SearchResultViewModel
型パラメータインジェクションによるDI
- 実装の所で触れた通り、型パラメータインジェクションを使って
GitHubAPIService
のDIを行います。
@MainActor
final class RepositoryViewModelTests: XCTestCase {
// MARK: - Properties
private var sut: SearchResultViewModel<StubGitHubAPIService>!
// MARK: - Setup/TearDown
override func setUpWithError() throws {
try super.setUpWithError()
sut = SearchResultViewModel<StubGitHubAPIService>()
}
override func tearDownWithError() throws {
sut = nil
try super.tearDownWithError()
}
テスト用のStubGitHubAPIService
- UnitTestで非同期処理を各ステップ毎に実行するため、下記の通り実装します。
-
CheckedContinuation
により、検索実行前後の状態をテストすることが可能となります。
/// ViewModelの非同期処理を含めて確認するためのGitHubAPIServiceのStub
final class StubGitHubAPIService: GitHubAPIServiceProtocol {
static let shared: StubGitHubAPIService = .init()
var searchContinuation: CheckedContinuation<[Repository], Error>?
private init() {}
func searchRepositories(keyword: String) async throws -> [Repository] {
try await withCheckedThrowingContinuation { continuation in
searchContinuation = continuation
}
}
}
非同期処理のテスト
- ViewModelの検索処理を、下記の段階に分けてテストしたいです。
検索前 | 検索中 | 検索後 |
---|---|---|
![]() |
![]() |
![]() |
- そこで先程の
StubGitHubAPIService
を使うと、非同期処理のタイミングをコントロールしてテストすることができます。
// MARK: 成功
func testSearchRepositoriesButtonPressedSuccess() async {
// given
// when
async let search: Void = sut.searchButtonPressed(withKeyword: "swift")
while StubGitHubAPIService.shared.searchContinuation == nil {
await Task.yield()
}
// then
// 検索中の状態になっているかの確認
switch sut.repositories {
case .loading:
break
default:
XCTFail("unexpected repositories: \(sut.repositories)")
}
// GitHubAPIの実行
StubGitHubAPIService.shared.searchContinuation?
.resume(returning: Repository.sampleData)
StubGitHubAPIService.shared.searchContinuation = nil
await search
_ = await sut.task?.result // searchButtonPressed()内のTask内の処理を待つ
// 検索後の状態の確認
switch sut.repositories {
case let .loaded(repositories):
XCTAssertFalse(repositories.isEmpty)
default:
XCTFail("unexpected repositories: \(sut.repositories)")
}
}
Discussion