12
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

CleanArchitectureを参考にしてサンプルアプリ作ってみた

Last updated at Posted at 2022-04-26

記事修正

PresenterがUseCaseから直接呼ばれていたので、OutPut経由で通知されるように依存関係を修正しました。(2022/12/22)

投稿の経緯

私が参加しているコミュニティにてCleanArchitectureの輪読会をしました。ただ読むだけでは学びにならないので、サンプルアプリを元にアウトプットしていきます。

参加しているコミュニティはこちら👇

CleanArchitectureについて

image.png

CleanArchitectureを要約するとドメイン駆動開発(DDD)やユースケース駆動開発(UCDD)を意識して、ビジネスロジックをUIやFrameworkから引き離し、それぞれの層毎に役割と責任を分離したしたArchitectureです。

CleanArchitectureはSOLID原則を突き詰めたArchitectureだと思っています。

SOLID原則とは

  • SRP:単一責任の原則
  • OCP:オープン・クローズドの原則
  • LSP:リスコフの置換原則
  • ISP:インターフェイス分離の原則
  • DIP:依存関係逆転の原則

これらの原則の頭文字をとってSOLID原則と呼ばれています。

CleanArchitectureのメリット

  • UIやフレームワークに依存しない
  • ビジネスロジックが明確になる
  • 各レイヤーが抽象にしか依存いないので役割分担して開発できる
  • テストの導入が容易になる

CleanArchitectureのデメリット

  • コード量が多い
  • ファイルが多い
  • プロトタイプや小規模開発には向かない
  • 導入難易度が高い

各レイヤの役割について

Presentation layer

UIの表示やユーザーからのイベントを処理します。ビジネスロジックは処理しない。

View

画面の表示とユーザーからのアクションをControllerに通知します。

ViewModel

Presenterから受けとったデータやステータスによってViewの表示を切り替えます。

Controller

 Viewからイベントを受け取ってイベントに応じたUseCaseを実行します。また、UseCaseで使うModel型に値を変換する役割も担当します。

Presenter

UseCaseから受け取ったでデータをViewModelへ渡します。Viewがどうなっているかは知りません。

Domain layer

 アプリ固有のビジネスロジックはこのレイヤーが担当します。

Model

Domain層で扱うModel。Viewから受け取ったデータや、Data層のEntityをPresentation層で使うときに必要なModel。

UseCase

ビジネスロジックを処理する。UIやフレームワークなどの外部の変更の影響を受けることはない。

Translator

UseCaseで取得したEntityをPresentation layerで使用するModelへ変換する役割。Viewで使用するために最適化したModelを作成する。

Repository

データの保存や取得に必要なDataStoreへデータ処理のリクエストをおこなう。Domain層として書いていますが、実際はDomain層とData層のパイプ役です。(アダプタ的な役割)

Data layer

データベースやAPI通信などデータに関するのロジックはこのレイヤが担当します。

DataStore

実際のデータの保存や取得する処理を記述する。FirebaseやAPIへのリクエストを投げたりする。

Entity

DataStoreで扱うことができるデータの静的なモデル。Entity自身を直接操作することはなく、valueobjectとして使う。EntityはPresentation層では使用されない。

サンプルアプリ

image.png

上記で説明した役割とこちらの図を意識してサンプルプロジェクトを開発しました。全ての機能を紹介していると大変なのでユーザー登録機能のみ紹介します。

コードはGitHubにPushしています。気になる方はご覧ください。
https://github.com/ken-sasaki2/CleanArchitectureTraining
QR_460754.png

実装

Data layer

ユーザー情報の登録先はFirebaseのFirestoreにしています。

UserRequestToFirestore.swift
import Firebase
import FirebaseAuth

final class UserRequestToFirestore {
    private let reference = Firestore.firestore().collection("users")
    private var uid: String {
        Auth.auth().currentUser?.uid ?? ""
    }
    
    func saveUser(inputEntity: UserAddInputEntity) async throws -> Void {
        return try await withCheckedThrowingContinuation { continuation in
            reference.document(uid).collection("user").addDocument(data: [
                "uid" : uid,
                "name" : inputEntity.name,
                "gender" : inputEntity.gender,
                "createdAt" : inputEntity.createdAt
            ]) { error in
                if let error = error {
                    print("Error writing document:", error)
                    continuation.resume(throwing: error)
                } else {
                    print("Success write document.")
                    continuation.resume(returning: ())
                }
            }
        }
    }
}

Firestoreと通信をおこなうclassです。withCheckedThrowingContinuationを使ってasync/awaitで非同期処理を書いています。引数で受け取ったEntityを使ってユーザーデータを登録します。

UserAddInputEntity.swift
import Foundation

struct UserAddInputEntity {
    var name: String
    var gender: Int
    var createdAt: TimeInterval
    
    init(inputData: UserAddInputData) {
        self.name = inputData.name
        self.gender = inputData.gender
        self.createdAt = inputData.createdAt
    }
}

先述したようにEntityは静的なデータです。直接操作することはありません。

UserDataStore.swift
import Foundation

protocol UserDataStoreInterface {
    func saveUser(inputData: UserAddInputData) async throws -> Void
}

final class UserDataStore: UserDataStoreInterface {
    private let userRequest = UserRequestToFirestore()
    
    func saveUser(inputData: UserAddInputData) async throws -> Void {
        do {
            let inputEntity = UserAddInputEntity(inputData: inputData)
            try await userRequest.saveUser(inputEntity: inputEntity)
            return
        } catch {
            throw error
        }
    }
}

このclassはUserRequestToFirestoreの処理を呼んで結果を受け取ります。Domain層から渡ってきたModelをEntityに変換しています。

Data層のコードは以上です。

Domain layer

UserRepository.swift
import Foundation

protocol UserRepositoryInterface {
    func saveUser(inputData: UserAddInputData) async throws -> Void
}

final class UserRepository: UserRepositoryInterface {
    private let userDataStore: UserDataStoreInterface
    
    init(userDataStore: UserDataStoreInterface) {
        self.userDataStore = userDataStore
    }
    
    func saveUser(inputData: UserAddInputData) async throws -> Void {
        do {
            try await userDataStore.saveUser(inputData: inputData)
            return
        } catch {
            throw error
        }
    }
}

RepositoryはDomain層とData層を繋ぐパイプ役のような役割です。

UserAddInputData.swift
import Foundation

struct UserAddInputData {
    var name: String
    var gender: Int
    var createdAt: TimeInterval
    
    init(name: String, gender: Int, createdAt: TimeInterval) {
        self.name = name
        self.gender = gender
        self.createdAt = createdAt
    }
}

ユーザーの入力情報を格納するModelです。

UserAddUseCase.swift
import Foundation

protocol UserAddUseCaseInput {
    func saveUser(inputData: UserAddInputData) async throws -> Void
}

protocol UserAddUseCaseOutput {
    func invalidUserName()
    func invalidGender()
    func successSaveUser()
    func failSaveUser()
}

final class UserAddUseCase: UserAddUseCaseInput {
    private let userRepository: UserRepositoryInterface
    private let output: UserAddUseCaseOutput
    
    init(userRepository: UserRepositoryInterface, output: UserAddUseCaseOutput) {
        self.userRepository = userRepository
        self.output = output
    }
    
    func saveUser(inputData: UserAddInputData) async throws -> Void {
        do {
            let isValidUserName = isValidUserName(name: inputData.name)
            let isValidGender = isValidGender(gender: inputData.gender)
            
            if !isValidUserName {
                output.invalidUserName()
                return
            }
            
            if !isValidGender {
                output.invalidGender()
                return
            }
            
            try await userRepository.saveUser(inputData: inputData)
            userRepository.setIsUserDataSaved(isSaved: true)
            output.successSaveUser()
        } catch {
            output.failSaveUser()
        }
    }
    
    func isValidUserName(name: String) -> Bool {
        let isMin = name.count >= 2
        let isMax = name.count <= 10
        
        if isMin && isMax {
            return true
        } else {
            return false
        }
    }
    
    func isValidGender(gender: Int) -> Bool {
        switch gender {
        case 1:
            return true
        case 2:
            return true
        case 3:
            return true
        default:
            return false
        }
    }
}

アプリ固有のビジネスロジックを管理します。isValidUserNameisValidGenderがユーザーの入力情報をバリデートしています。その他の役割としてはPresentation層のControllerからユーザーの入力情報を受け取り保存処理を実行し、結果をPresentation層のPresenterへ通知します。

Domain層のコードは以上です。

Presentation layer

UserProfileController.swift
import Foundation

final class UserProfileController {
    private let userAddUseCase: UserAddUseCaseInterface
    
    init(userAddUseCase: UserAddUseCaseInterface) {
        self.userAddUseCase = userAddUseCase
    }
    
    func createUser(uid: String, name: String, gender: Int) {
        let inputData = UserAddInputData(
            name: name,
            gender: gender,
            createdAt: Date().timeIntervalSince1970
        )
        
        Task {
            try await userAddUseCase.saveUser(inputData: inputData)
        }
    }
}

Controllerの役割はユーザーの入力情報をModelに変換し、UseCaseへ通知することです。Viewでおこなわれたユーザーの操作は全てControllerへ通知され処理が始まります。

UserAddPresenter.swift
import Foundation

final class UserAddPresenter: UserAddUseCaseOutput {
    var userProfileVM: UserProfileViewModel
    
    init(userProfileVM: UserProfileViewModel) {
        self.userProfileVM = userProfileVM
    }
    
    func invalidUserName() {
        userProfileVM.isShowUserNameAlert = true
    }
    
    func invalidGender() {
        userProfileVM.isShowGenderAlert = true
    }
    
    func successSaveUser() {
        userProfileVM.isShowSuccessSaveUserAlert = true
    }
    
    func failSaveUser() {
        userProfileVM.isShowFailSaveUserAlert = true
    }
}

UseCaseから通知されたユーザー情報の登録結果でViewModelを操作してViewの見た目を変化させます。先述した通りPresenterはViewがどうなっているかは知りません。

UserProfileViewModel.swift
import Foundation

final class UserProfileViewModel: ObservableObject {
    @Published var isShowUserNameAlert = false
    @Published var isShowGenderAlert = false
    @Published var isShowSuccessSaveUserAlert = false
    @Published var isShowFailSaveUserAlert = false
}

Viewに表示するデータやステータスを持っており、Presenterから受けとったデータによってViewの表示を操作します。

UserProfileView.swift
import SwiftUI

struct UserProfileView: View {
    @ObservedObject var userProfileVM: UserProfileViewModel
    @State private var name = ""
    @State private var genderSelection = 0
    let userProfileController: UserProfileController
    private let genders = ["未選択", "男", "女", "選ばない"]

    var body: some View {
        GeometryReader { geometry in
            VStack {
                Spacer()
                Text("プロフィール登録")
                    .font(.system(size: 28, weight: .semibold, design: .default))
                VStack(spacing: 0) {
                    Text("必須")
                        .foregroundColor(.gray)
                        .font(.system(size: 12, weight: .regular, design: .default))
                        .frame(width: geometry.size.width / 1.3, alignment: .leading)
                    TextField("名前", text: $name)
                        .textFieldStyle(RoundedBorderTextFieldStyle())
                        .padding(.vertical, 5)
                        .padding(.horizontal, 35)
                        .keyboardType(.default)
                }
                .padding(.top, 20)
                VStack(spacing: 0) {
                    Text("必須")
                        .foregroundColor(.gray)
                        .font(.system(size: 12, weight: .regular, design: .default))
                        .frame(width: geometry.size.width / 1.3, alignment: .leading)
                    Picker(selection: $genderSelection, label: Text("性別")) {
                        ForEach(0..<genders.count) { index in
                            Text(genders[index])
                        }
                    }
                    .pickerStyle(SegmentedPickerStyle())
                    .frame(width: geometry.size.width / 1.2)
                }
                .padding(.top, 15)
                .padding(.bottom, 30)
                RegistrationButtonView {
                    createUser(name: name, gender: genderSelection)
                }
                .alert("登録失敗", isPresented: $userProfileVM.isShowUserNameAlert) {
                    Button("OK") {
                        name = ""
                    }
                } message: {
                    Text("2文字以上10文字以下で登録してください")
                }
                .alert("登録失敗", isPresented: $userProfileVM.isShowGenderAlert) {
                    Button("OK") {}
                } message: {
                    Text("性別を選択してください")
                }
                .alert("登録失敗", isPresented: $userProfileVM.isShowFailSaveUserAlert) {
                    Button("OK") {}
                } message: {
                    Text("登録に失敗しました。通信状態が良好な環境で再度お試しください。")
                }
                .alert("登録完了", isPresented: $userProfileVM.isShowSuccessSaveUserAlert) {
                    Button("OK") {}
                } message: {
                    Text("プロフィールを登録しました")
                }
                Spacer()
            }
        }
    }
}

extension UserProfileView {
    private func createUser(name: String, gender: Int) {
        userProfileController.createUser(name: name, gender: gender)
    }
}

struct UserProfileView_Previews: PreviewProvider {
    static var previews: some View {
        UserProfileBuilder.shared.build()
    }
}

Viewの役割はとてもシンプルで、UIの描画です。

Presentation層のコードは以上です。

依存性の注入

UserProfileBuilder.swift
import Foundation

final class UserProfileBuilder {
    static let shared = UserProfileBuilder()

    private init() {}

    func build() -> UserProfileView {
        let viewModel = UserProfileViewModel()
        let view = UserProfileView(
            userProfileVM: viewModel,
            userProfileController: UserProfileController(
                userAddUseCase: UserAddUseCase(
                    userRepository: UserRepository(
                        userDataStore: UserDataStore(),
                    output: UserAddPresenter(
                        userProfileVM: viewModel
                    )
                )
            )
        )

        return view
    }
}

各classはInterface(抽象)に依存しており、外部から注入してあげる必要があるのでDI(Dependency Injection)します。
DIに関してはこちらの記事を参考にしてください。

テスト

UserAddUseCaseTests.swift
import XCTest

class UserAddUseCaseTests: XCTestCase {

    override func setUpWithError() throws {
        // Put setup code here. This method is called before the invocation of each test method in the class.
    }

    override func tearDownWithError() throws {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
    }

    func testIsValidUserName() throws {
        let dataStore = UserDataStore()
        let repository = UserRepository(userDataStore: dataStore)
        let viewModel = UserProfileViewModel()
        let output = UserAddPresenter(userProfileVM: viewModel)
        let useCase = UserAddUseCase(userRepository: repository, output: output)
        
        XCTContext.runActivity(named: "nameが2文字以上10文字以下の場合") { _ in
            let name = "xx"
            let result = useCase.isValidUserName(name: name)
            XCTAssert(result == true)
        }
        XCTContext.runActivity(named:"nameが2文字未満の場合") { _ in
            let name = "x"
            let result = useCase.isValidUserName(name: name)
            XCTAssert(result == false)
        }
        XCTContext.runActivity(named:"nameが10文字超過の場合") { _ in
            let name = "xxxxxxxxxxx"
            let result = useCase.isValidUserName(name: name)
            XCTAssert(result == false)
        }
    }
    
    func testIsValidGender() throws {
        let dataStore = UserDataStore()
        let repository = UserRepository(userDataStore: dataStore)
        let viewModel = UserProfileViewModel()
        let output = UserAddPresenter(userProfileVM: viewModel)
        let useCase = UserAddUseCase(userRepository: repository, output: output)

        XCTContext.runActivity(named: "genderが0の場合") { _ in
            let result = useCase.isValidGender(gender: 0)
            XCTAssert(result == false)
        }
        XCTContext.runActivity(named: "genderが1の場合") { _ in
            let result = useCase.isValidGender(gender: 1)
            XCTAssert(result == true)
        }
        XCTContext.runActivity(named: "genderが2の場合") { _ in
            let result = useCase.isValidGender(gender: 2)
            XCTAssert(result == true)
        }
        XCTContext.runActivity(named: "genderが3の場合") { _ in
            let result = useCase.isValidGender(gender: 3)
            XCTAssert(result == true)
        }
    }
}

CleanArchitectureで開発したのでもちろんテストも書いています。(量が多くなるのでほんの一部だけ紹介)
レイヤーや各classの依存関係が疎結合なことからテスト導入難易度も簡易でアーキテクチャの恩恵を受けと思います。

参考にした情報

おわりに

今回はCleanArchitectureに関して記事を書きました。
ファイルの多さや、コード量の多さなど初めは苦戦しましたが、慣れてくるにつれ、テストの容易性やビジネスロジックが明確になる点など、CleanArchitectureの恩恵を受けることもでき、いい学びになりました。

ただ各レイヤーの扱いや依存関係の認識がずれている箇所もあるかもしれないので、ご教示いただけると幸いです。

ご覧いただきありがとうございました!この記事が誰かの役に立てば幸いです。

お知らせ

現在副業でiOSアプリ開発を募集しています。
ご依頼はTwitter DMでお待ちしております。
QR_461181.png

12
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?