🔍

Swift Concurrencyを使ったGitHubリポジトリ検索のiOSアプリケーションの実装

2023/01/06に公開約20,200字

背景

  • 色々な所で題材とされるGitHubAPIを使った、リポジトリを検索するアプリケーションを実装してみました。
  • 一番のモチベーションとしては、iOSDC2022でkoherさんの発表を見てSwift Concurrencyを使ったAPIの通信処理部分を書きたくなったからです。
  • (突っ込み所もあるかと思いますが、その際は遠慮なくマサカリをぶん投げてもらえると助かります…!)

GitHub

参考

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に適合したものを呼ぶため、以下の通り実装しています。
  • requestGitHubAPIRequestProtocolなどこの辺りのメインの考え方は以下の書籍を参考にしています。
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)
            ...
  • またURLSessionProtocolStubURLSessionは下記の通り定義しています。
  • 下記で使用されていたクラスを少し変えて使用しています。
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はトップ画面です。

image

  • ~Screenという名称は以下を参考につけています。

SwiftUI コーディングガイドのすすめ
View の命名規則です。View の命名は通常 ~View とサフィックスに View を用いると思います。UIKit では ViewController が最上位の画面となるのが一目でわかるのですが、SwiftUI の場合、全ての View が View サフィックスで命名されていると、それが画面中のコンポーネントなのか最上位の画面なのかが見分けにくくなり、デバッグや改修がしづらいと感じていました。そこで、最上位の画面となる View は ~Screen とサフィックスに Screen を用いることにしています。

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のビューです。

image

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 { ... }
    
}
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

image

  • 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

@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の実行がメインスレッドとなるため、searchRepositoriesnonisolatedで切り出しています…がこの辺りの理解が浅く不要な処理かもしれません)
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

@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の検索処理を、下記の段階に分けてテストしたいです。
検索前 検索中 検索後
Screen Shot 2023-01-06 at 22.25.41 Screen Shot 2023-01-06 at 22.25.22 Screen Shot 2023-01-06 at 22.25.34
// 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

ログインするとコメントできます