[iOS] TCA 패턴을 사용해서 메모앱 만들기

LeeEunJae·2023년 4월 29일
2

💁‍♂️ 결과물

🤔 TCA ?

The Composable Architecture(TCA)는 일관되고 이해할 수 있는 방식으로 어플리케이션을 만들기 위해 탄생한 라이브러리입니다.
iOS 개발에서 통상적으로 MVVM 패턴을 사용해왔고, 현재 많은 기업에서도 MVVM 패턴으로 프로젝트를 진행하고 있습니다.
하지만, SwiftUI를 사용하면서 MVVM 아키텍쳐 사용을 지양하는 의견들이 나오기 시작했고, 그에 대한 대안으로 TCA가 등장했습니다.

TCA는 다양한 목적과 복잡도를 가진 어플리케이션을 만들기 위해 다음과 같은 핵심 도구들을 제공합니다.

  • State(상태) : 기능의 로직을 수행하고 UI를 렌더링하는데 필요한 데이터
  • Action : 사용자 작업, 알림, 이벤트 소스 등 기능에서 발생할 수 있는 모든 작업
  • Reducer : 액션이 주어졌을 때 앱의 현재 상태를 다음 상태로 변경시키는 방법을 구현하는 💁‍♂️. 리듀서는 API 요청과 같이 실행해야 하는 모든 효과를 반환하는 역할도 담당하며, 이는 Effect 값을 반환하여 수행할 수 있습니다.
  • Store: 실제로 기능을 구동하는 런타임. 모든 사용자 액션을 스토어로 전송하여 스토어가 감속기와 효과를 실행할 수 있도록 하고, 스토어에서 상태 변화를 관찰하여 UI를 업데이트 할 수 있습니다.

TCA 깃허브 : https://github.com/pointfreeco/swift-composable-architecture

TCA 를 사용하기 위해서는 프로젝트 패키지를 추가해야합니다.

📌 Memo

이번 예제에서는 Realm 을 사용해서 Memo 데이터를 저장할 것 입니다.

import Foundation
import RealmSwift
import SwiftUI

class Memo: Object, ObjectKeyIdentifiable {
    @Persisted(primaryKey: true) var id: ObjectId
    @Persisted var text: String = ""
    @Persisted var date: Date = Date.now
    @Persisted var color: String = "blue"
}

📌 MemoFeature(ReducerProtocol)

ReducerProtocol을 따르는 MemoFeature 구조체입니다.
이 구조체에서는 메모앱에서 사용되는 모든 상태와 액션, 그리고 해당 상태와 액션을 연결해주기 위한 reduce function을 설명합니다.

//
//  MemoReducer.swift
//  MemoApp_TCA
//
//  Created by 이은재 on 2023/04/29.
//

import Foundation
import ComposableArchitecture
import RealmSwift

// Feature 자체가 ReducerProtocol을 준수하며, 내부에서 실제 reduce function을 통해 논리, 동작을 처리한다
struct MemoFeature : ReducerProtocol {
    private(set) var localRealm: Realm?
    
    init() {
        openRealm()
    }
    
    // 도메인(어떤걸 만들 때 거기에 대한 데이터) + 상태
    struct MemoState: Equatable {
        var memos: [Memo] = []
        var selectedMemo: Memo? = nil
    }
    
    // 도메인 + 액션 (액션을 통해 상태를 변경함)
    enum MemoAction: Equatable {
        case findAllMemo
        case findMemo(_ id: ObjectId)
        case addMemo(_ memo: Memo)
        case deleteMemo(_ id: ObjectId)
        case updateMemo(id: ObjectId, text: String, color: String)
    }
    
    mutating func openRealm() {
        do {
            let config = Realm.Configuration(schemaVersion: 1)
            Realm.Configuration.defaultConfiguration = config
            self.localRealm = try Realm()
        } catch {
            print("Error opening Realm : \(error)")
        }
    }
    
    func findAllMemo() -> [Memo] {
        if let localRealm = localRealm {
            let allMemo = localRealm.objects(Memo.self).sorted(byKeyPath: "date")
            var result = [Memo]()
            allMemo.forEach { memo in
                result.append(memo)
            }
            return result
        } else {
            return []
        }
    }
    
    func findMemo(_ id: ObjectId) -> Memo? {
        if let localRealm = localRealm {
            let memoToFind = localRealm.objects(Memo.self).filter(NSPredicate(format: "id == %@", id))
            guard !memoToFind.isEmpty else { return nil }
            return memoToFind.first
            
        } else {
            return nil
        }
    }
    
    func addMemo(memo: Memo) {
        if let localRealm = localRealm {
            do {
                try localRealm.write {
                    localRealm.add(memo)
                    print("Added new memo to Realm : \(memo)")
                }
            } catch {
                print("Error adding memo to Realm : \(error)")
            }
        }
    }
    
    func deleteMemo(_ id: ObjectId) {
        if let localRealm = localRealm {
            do {
                let memoToDelete = localRealm.objects(Memo.self).filter(NSPredicate(format: "id == %@", id))
                guard !memoToDelete.isEmpty else { return }
                
                try localRealm.write {
                    localRealm.delete(memoToDelete)
                    print("Deleted memo with id : \(id)")
                }
                
            } catch {
                print("Error deleting memo \(id) from Realm: \(error)")
            }
        }
    }
    
    func updateMemo(id: ObjectId, text: String, color: String) {
        if let localRealm = localRealm {
            do {
                if let memoToUpdate = localRealm.objects(Memo.self).filter(NSPredicate(format: "id == %@", id)).first {
                    try localRealm.write {
                        memoToUpdate.date = Date.now
                        memoToUpdate.text = text
                        memoToUpdate.color = color
                    }
                    print("updated memo with id : \(id)")
                }
            } catch {
                print("Error updating memo \(id) from Realm: \(error)")
            }
        }
    }
    
    // 리듀서 : 액션과 상태를 연결시켜주는 역할
    func reduce(into state: inout MemoState, action: MemoAction) -> EffectTask<MemoAction> {
        switch action {
        case .findAllMemo:
            state.memos = findAllMemo()
            return .none
        case .findMemo(let id):
            state.selectedMemo = findMemo(id)
            return .none
        case .addMemo(let memo):
            addMemo(memo: memo)
            state.memos = findAllMemo()
            return .none
        case .deleteMemo(let id):
            deleteMemo(id)
            state.memos = findAllMemo()
            return .none
        case .updateMemo(let id, let text, let color):
            updateMemo(id: id, text: text, color: color)
            state.memos = findAllMemo()
            return .none
        }
    }
}

📌 ContentView

viewStore.send(MemoAction) 을 통해서 액션을 전달하고, 액션에 따른 상태를 변경시킴

import SwiftUI
import ComposableArchitecture

struct ContentView: View {
	// store: Feature(Reducer Protocol을 준수하는)의 스토어임, 상태, 액션을 가지고 있음 - 커맨드 센터 역할
    let store: StoreOf<MemoFeature>
    var body: some View {
        WithViewStore(self.store) { viewStore in
            NavigationView {
                List {
                    ForEach(viewStore.memos, id: \.id) { memo in
                        if !memo.isInvalidated && !memo.isFrozen {
                            NavigationLink {
                                MemoEditorView(
                                    mode: .update,
                                    memo: memo,
                                    updateMemo: { id, text, color in
                                        viewStore.send(.updateMemo(id: id, text: text, color: color))
                                    }
                                )
                            } label: {
                                MemoItem(memo)
                            }
                        }
                    }
                    .onDelete { indexSet in
                        indexSet.forEach { index in
                            let memoToDelete = viewStore.memos[index]
                            viewStore.send(.deleteMemo(memoToDelete.id))
                        }
                    }
                }
                .listStyle(PlainListStyle())
                .refreshable {
                    viewStore.send(.findAllMemo)
                }
                .navigationTitle("메모")
                .toolbar {
                    NavigationLink {
                        MemoEditorView(
                            addMemo: { memo in
                                viewStore.send(.addMemo(memo))
                            }
                        )
                    } label: {
                        Image(systemName: "plus")
                            .font(.system(size: 25))
                            .bold()
                            .foregroundColor(Color("title_color"))
                    }

                }
            }.tint(.white)
                .onAppear {
                    viewStore.send(.findAllMemo)
                }
        }
    }
}

📌 MemoApp_TCAApp(root_view)

contentView에서 정의한 store를 rootView에 추가해줌으로써 TCA 패턴을 성공적으로 사용할 수 있게된다.

//
//  MemoApp_TCAApp.swift
//  MemoApp_TCA
//
//  Created by 이은재 on 2023/04/29.
//

import SwiftUI
import ComposableArchitecture

@main
struct MemoApp_TCAApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView(
                store: Store(initialState: MemoFeature.MemoState(), reducer: MemoFeature())
            )
        }
    }
}

앱 전체 코드

https://github.com/EJLee1209/MemoApp_TCA

profile
매일 조금씩이라도 성장하자

0개의 댓글