MENU

【Swift Concurrency】モダンな技術でAPI通信をモジュール化をしよう!

目次

導入

Swiftアプリケーション開発において、非同期APIリクエスト処理をクリーンでモジュール化された形で実装することは、アプリのスケーラビリティとメンテナンス性を高める上で非常に重要です。この記事では、TCA (The Composable Architecture) とSwift Concurrencyを組み合わせて、GitHub APIからデータを取得し、その結果をTCAのストアに反映する一連の流れを実現する方法について解説します。

実装手順

  1. TCAとSwift Concurrencyの概要
  2. APIクライアントの実装
  3. GitHubクライアントの依存関係注入
  4. レスポンス型の定義
  5. リクエストとレスポンスの実装
  6. リデューサーでのAPIリクエスト処理
  7. ビューでのデータ表示
  8. レスポンスヘッダーの処理

1. TCAとSwift Concurrencyの概要

TCA は、モジュール化された状態管理を提供し、アプリケーション全体の一貫性とテスト可能性を向上させるためのフレームワークです。一方、Swift Concurrency は、非同期プログラミングを簡潔かつエラー耐性のある形で実装するための最新のSwift機能です。

これらの技術を組み合わせることで、APIリクエスト処理をよりクリーンに、そして簡単に拡張可能な形で実装できます。

2. APIクライアントの実装

APIリクエストを処理するためのApiClientを実装します。このクライアントは、非同期にAPIリクエストを送信し、結果をデコードして返す役割を持っています。

final class ApiClient: Sendable {
    private let session: Session

    init(session: Session) {
        self.session = session
    }

    func send<T: BaseRequest>(request: T) async throws -> (T.Response, HTTPURLResponse) {
        do {
            let response = try await session.response(for: request)
            guard let httpResponse = response.urlResponse as? HTTPURLResponse else {
                throw ApiError.invalidResponse
            }
            return (response.object as! T.Response, httpResponse)
        } catch let originalError as SessionTaskError {
            switch originalError  {
            case let .connectionError(error), let .responseError(error), let .requestError(error):
                throw ApiError.unknown(error as NSError)
            case let .responseError(error as ApiError):
                throw error
            }
        }
    }

    static let liveValue = ApiClient(session: Session.shared)
}

3. GitHubクライアントの依存関係注入

次に、GitHub APIに特化したクライアントを実装し、TCAのDependencyKeyを使用して依存関係を注入します。

import Dependencies

struct GithubClient: DependencyKey {
    var searchRepos: @Sendable (String, Int) async throws -> (SearchReposResponse, HTTPURLResponse)
    var searchFavorites: @Sendable () async throws -> (SearchFavoritesResponse, HTTPURLResponse)

    static let liveValue: GithubClient = .live()

    static func live(apiClient: ApiClient = .liveValue) -> Self {
        .init(
            searchRepos: { query, page in
                try await apiClient.send(request: SearchReposRequest(query: query, page: page))
            },
            searchFavorites: { 
                try await apiClient.send(request: SearchFavoritesRequest())
            }
        )
    }
}

4. レスポンス型の定義

APIのレスポンスをデコードするための構造体を定義します。これにより、APIから取得したデータを簡単に扱うことができます。

import Foundation

// SearchReposResponse.swift

struct SearchReposResponse: Decodable, Equatable {
    let totalCount: Int
    let items: [Item]

    enum CodingKeys: String, CodingKey {
        case totalCount = "total_count"
        case items
    }

    struct Item: Decodable, Identifiable, Equatable {
        let id: Int
        let name: String
        let fullName: String
        let owner: Owner
        let description: String?
        let stargazersCount: Int

        enum CodingKeys: String, CodingKey {
            case id
            case name
            case fullName = "full_name"
            case owner
            case description
            case stargazersCount = "stargazers_count"
        }
    }

    struct Owner: Decodable, Equatable {
        let login: String
        let avatarUrl: URL

        enum CodingKeys: String, CodingKey {
            case login
            case avatarUrl = "avatar_url"
        }
    }
}

// SearchFavoritesResponse.swift

struct SearchFavoritesResponse: Decodable, Equatable {
    let items: [SearchFavoritesResponseItem]

    struct SearchFavoritesResponseItem: Decodable, Identifiable, Equatable {
        let id: Int
        let name: String
        let fullName: String
        let owner: Owner
        let description: String?
        let stargazersCount: Int

        enum CodingKeys: String, CodingKey {
            case id
            case name
            case fullName = "full_name"
            case owner
            case description
            case stargazersCount = "stargazers_count"
        }
        
        struct Owner: Decodable, Equatable {
            let login: String
            let avatarUrl: URL

            enum CodingKeys: String, CodingKey {
                case login
                case avatarUrl = "avatar_url"
            }
        }
    }
}

5. リクエストとレスポンスの実装

GitHub APIリクエストを実装するために、SearchReposRequestSearchFavoritesRequestを作成します。

import APIKit

struct SearchReposRequest: GithubRequest {
    typealias Response = SearchReposResponse
    let query: String
    let page: Int

    var path: String { "/search/repositories" }
    var method: HTTPMethod { .get }
    var parameters: [String: Any]? {
        [
            "q": query,
            "page": page
        ]
    }
}

struct SearchFavoritesRequest: GithubRequest {
    typealias Response = SearchFavoritesResponse

    var path: String { "/user/starred" }
    var method: HTTPMethod { .get }
}

6. リデューサーでのAPIリクエスト処理

GithubClientを使用してリデューサーでAPIリクエストを処理し、取得したデータとレスポンスヘッダーをTCAのステートに反映させます。

import ComposableArchitecture

struct SearchReposState: Equatable {
    var repos: [Repository] = []
    var isLoading: Bool = false
    var headers: [AnyHashable: Any] = [:]
}

enum SearchReposAction: Equatable {
    case search(String)
    case searchResponse(Result<([Repository], [AnyHashable: Any]), Error>)
}

struct SearchReposReducer: Reducer {
    @Dependency(\.githubClient) var githubClient

    func reduce(into state: inout SearchReposState, action: SearchReposAction) -> Effect<SearchReposAction> {
        switch action {
        case .search(let query):
            state.isLoading = true
            return .run { send in
                await send(.searchResponse(Result {
                    let (response, headers) = try await githubClient.searchRepos(query, 1)
                    return (response.items.map(Repository.init), headers.allHeaderFields)
                }))
            }

        case let .searchResponse(.success((repos, headers))):
            state.repos = repos
            state.headers = headers
            state.isLoading = false
            return .none

        case .searchResponse(.failure):
            state.isLoading = false
            return .none
        }
    }
}

7. ビューでのデータ表示

取得したデータをビューに表示し、ユーザーに提供します。また、必要に応じてレスポンスヘッダーも表示できます。

import SwiftUI
import ComposableArchitecture

struct SearchReposView: View {
    let store: Store<SearchReposState, SearchReposAction>

    var body: some View {
        WithViewStore(self.store) { viewStore in
            VStack {
                TextField("Search...", text: .constant(""))
                    .onSubmit {
                        viewStore.send(.search("your-query"))
                    }
                
                if viewStore.isLoading {
                    ProgressView("Loading...")
                } else {
                    List(viewStore.repos) { repo in
                        Text(repo.name)
                    }

                    ForEach(viewStore.headers.keys.sorted(), id: \.self) { key in
                        if let value = viewStore.headers[key] as? String {
                            Text("\(key): \(value)")
                        }
                    }
                }
            }
        }
    }
}

まとめ

このサンプルコードでは、TCAとSwift Concurrencyを活用して、GitHub APIからデータを非同期で取得し、その結果をTCAのストアに反映する一連の流れを実現しました。依存関係の注入、非同期関数の呼び出し、APIクライアントの実装、リクエストのデコード、そしてTCAのエフェクトを組み合わせることで、クリーンでモジュール化されたAPIリクエスト処理が可能になっています。また、レスポンスヘッダーの処理を追加することで、さらに柔軟な対応ができるようになります。

このアプローチを使用することで、他のAPIエンドポイントにも簡単に拡張可能なシステムを構築できます。ぜひ、自身のプロジェクトにもこの手法を取り入れてみてください。

さいごに、年収4桁万円を達成中のiOSエンジニアが皆さんをお導きいたします!
ぜひメンターを受けてみてください〜
メンターはこちら
貴方もなれます!!

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

Rio@iOSエンジニアのアバター Rio@iOSエンジニア 経営者兼モバイルアプリエンジニア

都内のモバイルアプリ開発会社経営者。
モバイルアプリの新規の請負開発及び保守運用を引き受ける。
Denso→Honda→現在
#RxSwift #MVVM #Firebase #Python3

コメント

コメントする

目次