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 를 사용하기 위해서는 프로젝트 패키지를 추가해야합니다.
이번 예제에서는 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"
}
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
}
}
}
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)
}
}
}
}
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())
)
}
}
}