👻

SwiftでCleanArchitectureぽくしてみた

2024/06/24に公開

これがそれっぽいかわかりませんが...

クラスでなくて、構造体の責務を分けるのが今回やっていることです。最近は、DIとか勉強するのが好きでオブジェクト指向を探求していました。Swiftだと独特なので、DIできているかわからないですが💦

これが完成品です

やったこと

HTTP GETして、Viewにデータを表示するだけですが、ロジックとViewを切り分けるだけでなく、他のロジックを作ったら取り替えることをできるようにしたり、特定の構造体に依存した関係をもたないように工夫しています。

今回はこのようなフォルダ構成になっております

海外の人やネットの情報を参考に作ったのですが、今回はDataというレイヤーはありません。DBを使いませんし書く機会がないので、省きました。もしかしたら良くないかも?

フォルダ 役割
Presentation 画面を作るViewのコードと、Viewの状態を管理するViewModelのコードを配置する。
Domain モデルとなるEntities、ロジックが実装されているRepository、RepositoryをDIして、受け取るUseCaseを配置する。

REST APIに合わせて作成して、モデルとなるstruct 構造体。他の言語だったら、モデルクラスを作りますかね。

// モデルクラスみたいなもの

// Post型の定義

struct Post: Codable, Identifiable {

    let id: Int

    let title: String

    let body: String

}

struct Postを配列のデータ型にしたRepositoryを定義します。protocolを作成して、これ実装した struct にAPIと通信するロジックを実装します。

import Foundation
// PostsRepository プロトコル
// プロトコルは、メソッドやプロパティの宣言をまとめたもの
// これをクラスで例えると、抽象クラスのようなもの
protocol PostsRepository {
    func fetchPosts() async throws -> [Post]
}

// これは、クラスで例えると、実装した抽象クラスのようなもの
// PostsRepository の具体的な実装
// この実装は、APIからデータを取得する
struct PostsRepositoryImpl: PostsRepository {
    func fetchPosts() async throws -> [Post] {
        // guard let は、Optionalの値がnilでない場合に、値を取り出して処理を続けるための構文
        guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts") else {
        // URLError(.badURL) は、URLが不正な場合に発生するエラー
            throw URLError(.badURL)
        }
        // URLSession.shared.data(from: url) は、URLからデータを取得する非同期処理
        let (data, _) = try await URLSession.shared.data(from: url)
        // JSONDecoder().decode([Post].self, from: data) は、JSONデータをPostの配列に変換する処理
        return try JSONDecoder().decode([Post].self, from: data)
    }
}

UseCasesは、ロジック自体は持たず、PostsRepositoryをDIして、使っています。外部のインスタンス化された構造体を受け取る。他のロジックに取り替えたい時は、ここで別の機能を実装している構造体に変更する。execute() メソッドは、ViewModelで使うために使用。

import Foundation

// FetchPostsUseCase プロトコル
// このプロトコルは、FetchPostsUseCase の実装クラスに必要なメソッドをまとめたもの
protocol FetchPostsUseCase {
    func execute() async throws -> [Post]
}

// GetPostsUseCase の実装
// このクラスは、FetchPostsUseCase プロトコルを実装している
struct GetPostsUseCase: FetchPostsUseCase {
    // PostsRepository プロトコルを実装した PostsRepositoryImpl のインスタンスを保持する
    private let repository: PostsRepository
    // PostsRepositoryImpl のインスタンスを引数に取るイニシャライザ
    init(repository: PostsRepository) {
        self.repository = repository
    }
    // execute() メソッドは、非同期処理で投稿データを取得する
    // execute() メソッド自体は、ロジックを持たず、repositoryが、ロジックを持つ
    func execute() async throws -> [Post] {
        return try await repository.fetchPosts()
    }
}

Presentationは、画面であるViewと画面の状態を管理するViewModelを配置します。

ViewModelには、通信のエラーが出た時の処理と、SwiftUIだと、ObservableObjectで状態管理をするので、クラスに継承させています。C#とか、Kotlinみたいに、:をつけて、スーパークラスの継承やプロトコルの継承をします。構造体だと継承はできないので、準拠させることしかしない。

今回のコードだと、エラー処理は足りないと思いますが、こんな感じで書いてみました。

import Foundation

enum HTTPError: Error {
    case serverError
}

// ViewModel
class PostViewModel: ObservableObject {
    @Published var posts: [Post] = []// Post の配列を保持するプロパティを追加
    @Published var error: HTTPError? // エラー情報を保持するプロパティを追加
    // FetchPostsUseCase のインスタンスを保持するプロパティを追加
    private var useCase: FetchPostsUseCase
    // FetchPostsUseCase のインスタンスを引数に取るイニシャライザ
    init(useCase: FetchPostsUseCase) {
        // イニシャライザで FetchPostsUseCase のインスタンスを保持する
        self.useCase = useCase
        // fetchPosts() メソッドを呼び出す
        fetchPosts()
    }

    func fetchPosts() {
        Task {
            do {
                let posts = try await useCase.execute()
                DispatchQueue.main.async {
                    self.posts = posts
                }
            } catch {
                DispatchQueue.main.async {
                    // ここでエラーの種類に応じて適切なエラーを設定
                    if let httpError = error as? HTTPError {
                        self.error = httpError
                    } else {
                        self.error = .serverError
                    }
                }
            }
        }
    }
}

HTTP GETに成功するとこちらのコードに、APIから取得したデータを表示することができます。

import SwiftUI

struct ContentView: View {
    
    @ObservedObject var viewModel: PostViewModel
    
    init(useCase: GetPostsUseCase) {
        self.viewModel = PostViewModel(useCase: useCase)
    }
    
    var body: some View {
        List(viewModel.posts) { post in
            VStack(alignment: .leading) {
                Text(post.title)
                    .font(.headline)
                Text(post.body)
                    .font(.subheadline)
            }
        }
    }
}

ContentViewは引数を受け取っているので、渡してあげないとエラーが出てしまいます。インスタンス化しているエントリーポイントのファイルで、DIしてあげれば完成です。

import SwiftUI

@main
struct CleanArchitectureSwiftApp: App {
    // 外部の依存を注入
    let useCase = GetPostsUseCase(repository: PostsRepositoryImpl())
    var body: some Scene {
        WindowGroup {
            // イニシャライザで依存を注入
            // 他の言語だったら、コンストラクタ引数に渡すと表現しますね。
            ContentView(useCase: useCase)
        }
    }
}

感想

結構レイヤー分けるの大変ですね。でも実務だと、他の言語で仕事してるので、クラスで表現しますが、あるクラスへの依存を減らすように設計しないといけません。今回だと、構造体かな。クラスも使ってはいるけど。
本番用とモック注入用に分けると、DIすることができるようになる。まだまだ、 Swiftでは、CleanArchitectureで設計できているかわからないですが、オブジェクト思考を理解していれば、「何で分けるのかな」ぐらいは理解できるようになってきました。

Discussion