ReactableKit

๐Ÿš€ ReactableKit

๐Ÿ“Œ Introduction

ReactableKit์€ Combine ๊ธฐ๋ฐ˜์œผ๋กœ ๋งŒ๋“ค์–ด์ง„, SwiftUI ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ์œ„ํ•œ ๊ฐ€๋ณ์ง€๋งŒ ๊ฐ•๋ ฅํ•œ ์ƒํƒœ ๊ด€๋ฆฌ ํ”„๋ ˆ์ž„์›Œํฌ์ž…๋‹ˆ๋‹ค. ์ด ํ”„๋ ˆ์ž„์›Œํฌ๋Š” ReactorKit ์•„ํ‚คํ…์ฒ˜๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ ํ•˜์—ฌ, ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง๊ณผ ์ƒํƒœ ๋ณ€ํ™˜์„ ํšจ์œจ์ ์œผ๋กœ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋Š” ๊ตฌ์กฐ์ ์ธ ๋ฐฉ์‹์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ“‹ Requirements

โšก Usage

๐Ÿ“ฆ Installation

Swift Package Manager (SPM)

ReactableKit์€ Swift Package Manager๋ฅผ ํ†ตํ•ด ์‰ฝ๊ฒŒ ์„ค์น˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. Xcode์—์„œ ํ”„๋กœ์ ํŠธ๋ฅผ ์—ด๊ณ , ๋ฉ”๋‰ด์—์„œ File > Add Packages...๋ฅผ ์„ ํƒํ•œ ํ›„, ์•„๋ž˜ URL์„ ์ž…๋ ฅํ•˜์„ธ์š”:

https://github.com/topwiz/ReactableKit.git

๋˜๋Š” Package.swift ํŒŒ์ผ์— ์•„๋ž˜์™€ ๊ฐ™์ด ์ง์ ‘ ์ถ”๊ฐ€ํ•  ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค:

dependencies: [
    .package(url: "https://github.com/topwiz/ReactableKit.git", from: `version`)
]

โšก ๊ธฐ๋ณธ ์‚ฌ์šฉ๋ฒ•

1๏ธโƒฃ Reactable์˜ ํ•ต์‹ฌ ๊ตฌ์กฐ

Reactable์„ ์‚ฌ์šฉํ•˜๋ ค๋ฉด Reactable ํ”„๋กœํ† ์ฝœ์„ ์ค€์ˆ˜ํ•˜๋Š” ํด๋ž˜์Šค๋ฅผ ์ƒ์„ฑํ•˜์„ธ์š”. Action, Mutation, State์„ ์ •์˜ํ•˜๊ณ , mutate(action:)์™€ reduce(state:mutation:)๋ฅผ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค.

final class CounterReactable: Reactable {
    
    enum Action {
        case increase
        case decrease
    }
    
    struct State {
        var count: Int = 0
    }
    
    enum Mutation {
        case setCount(Int)
    }
    
    var initialState = State()
    
    func mutate(action: Action) -> AnyPublisher<Mutation, Never> {
        switch action {
        case .increase:
            return .just(.setCount(self.currentState.count + 1))
            
        case .decrease:
            return .run { send in 
                await send(.setCount(self.currentState.count - 1))
            }
        }
    }
    
    func reduce(state: inout State, mutate: Mutation) {
        switch mutate {
        case let .setCount(value):
            state.count = value
        }
    }
}

2๏ธโƒฃ transformAction์„ ํ†ตํ•œ ์•ก์…˜ ๋ณ€ํ™˜

transformAction์€ ์ž๋™์œผ๋กœ ์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜ ์•ก์…˜ ํŠธ๋ฆฌ๊ฑฐ๋ฅผ ํ™œ์„ฑํ™”ํ•ฉ๋‹ˆ๋‹ค. ์ด๋Š” ํƒ€์ด๋จธ, ๊ฐ์ข… ์•ก์…˜์„ Reactable Action์œผ๋กœ ๋ณ€ํ™˜ํ•˜๋Š”๋ฐ ์œ ์šฉํ•ฉ๋‹ˆ๋‹ค.

final class CounterReactable: Reactable {
    
    enum Action {
        case increase
        case autoIncrease
    }
    
    struct State {
        var count: Int = 0
    }
    
    enum Mutation {
        case setCount(Int)
    }
    
    var initialState = State()
    
    func mutate(action: Action) -> AnyPublisher<Mutation, Never> {
        switch action {
        case .increase:
            return .just(.setCount(self.currentState.count + 1))
        case .autoIncrease:
            return .just(.setCount(self.currentState.count + 2))
        }
    }
    
    func reduce(state: inout State, mutation: Mutation) {
        switch mutation {
        case let .setCount(value):
            state.count = value
        }
    }
    
    func transformAction() -> AnyPublisher<Action, Never> {
        return .merge([
            Timer.publish(every: 5, on: .main, in: .common)
                .autoconnect()
                .map { _ in Action.autoIncrease }
                .eraseToAnyPublisher()
        ])
    }
}

โš ๏ธ Store์„ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค๋ฉด Reactable init์— ์ˆ˜๋™์œผ๋กœ initialize()์„ ๋ถˆ๋Ÿฌ์ค˜์•ผ ํ•ฉ๋‹ˆ๋‹ค.

3๏ธโƒฃ SwiftUI์™€์˜ Reactable ํ†ตํ•ฉ

Store๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ƒํƒœ ๋ณ€ํ™”๋ฅผ ๊ฐ์ง€ํ•˜๊ณ  SwiftUI ๋ทฐ ๋‚ด์—์„œ Action์„ ๋””์ŠคํŒจ์น˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

struct CounterView: View {
    @StateObject var store = Store { 
        CounterReactable()
    }
    
    var body: some View {
        VStack {
            Text("\(self.store.state.count)")
                .font(.largeTitle)
                .padding()
            
            Button("Increase") {
                store.action(.increase)
            }
        }
    }
}

4๏ธโƒฃ updateOn: SwiftUI ์—…๋ฐ์ดํŠธ ์ตœ์ ํ™”

updateOn์€ SwiftUI ์ƒํƒœ ๊ด€์ฐฐ์ž๋กœ, ๋ทฐ๊ฐ€ ํ•„์š”ํ•œ ๊ฒฝ์šฐ์—๋งŒ ์—…๋ฐ์ดํŠธ๋˜๋„๋ก ๋ณด์žฅํ•ฉ๋‹ˆ๋‹ค.

struct CounterView: View {
    @ObservedObject var store = Store { 
        CounterReactable() 
    }

    var body: some View {
        VStack(spacing: 20) {
            // โœ… `count`๊ฐ€ ๋ณ€๊ฒฝ๋  ๋•Œ๋งŒ UI ์—…๋ฐ์ดํŠธ. count1์ด ๋ณ€๊ฒฝ๋  ๋•Œ๋Š” ์—…๋ฐ์ดํŠธ๋˜์ง€ ์•Š์Œ.
            
            self.store.updateOn(\.count) { value in
                Text("\(value)")
                    .font(.headline)
            }

           // ํ•ญ์ƒ ์—…๋ฐ์ดํŠธ๋จ
           
            Text("\(self.store.state.count1)")
                .font(.headline)

           // Binding<Value> ์˜ˆ์ œ
           
            self.store.updateOn(\.isOn1) { value in
                Toggle(isOn: value) {
                    Text("Toggle 1")
                }
            }

            // Action์ด ํฌํ•จ๋œ Binding<Value> ์˜ˆ์ œ 
            
            self.store.updateOn(\.isOn1) { value in
                Toggle(isOn: value) {
                    Text("Toggle 1 updateOn with action")
                }
            } action: { newValue in
                .isOnChanged
            }
            
            // ForEach List ์˜ˆ์ œ
            
            ForEach(self.store.state.list) { item in
                self.store.updateOn(\.list, for: item.id) { value in
                    Text("\(value.index)")
                        .font(.headline)
                }
            }
            
            // ForEach List ๋‹ค์ค‘ ๋ทฐ ์˜ˆ์ œ
            
            ForEach(self.store.state.list) { item in
                HStack {
                    self.store.updateOn(\.list, for: item.id, property: \.index) { value in
                        Text("\(value)")
                            .font(.headline)
                    }
                    
                    self.store.updateOn(\.list, for: item.id, property: \.toggle) { value in
                        Toggle(isOn: value) {
                            Text("Toggle 2 updateOn")
                        }
                    }
                }
            }
        }
    }
}

1๏ธโƒฃ ํ”„๋กœํผํ‹ฐ ๋ž˜ํผ

๐ŸŽจ @ViewState

@ViewState๋Š” ๊ฐ’์ด ๋ณ€๊ฒฝ๋  ๋•Œ ์ž๋™ UI ์—…๋ฐ์ดํŠธ๋ฅผ ๋ณด์žฅํ•ฉ๋‹ˆ๋‹ค. @ViewState๊ฐ€ ์—†๋Š” ํ”„๋กœํผํ‹ฐ๋Š” SwiftUI ์—…๋ฐ์ดํŠธ๋ฅผ ํŠธ๋ฆฌ๊ฑฐํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

struct State {
    @ViewState var count: Int = 1
    /// ignoreEquality = true ์ธ ๊ฒฝ์šฐ๋Š” ๊ฐ™์€ ๊ฐ’์ด set์ด ๋˜๋ฉด SwiftUI View๊ฐ€ ์—…๋ฐ์ดํŠธ ๋ฉ๋‹ˆ๋‹ค.
    @ViewState(ignoreEquality: true) var forceUpdate: Bool = false
    /// animation: ์• ๋‹ˆ๋ฉ”์ด์…˜์„ ์„ค์ •ํ•œ ๊ฒฝ์šฐ๋Š” ๊ฐ’์ด ๋ณ€๊ฒฝ๋  ๋•Œ ์• ๋‹ˆ๋ฉ”์ด์…˜์„ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค.
    @ViewState(animation: .default) var forceUpdate: Bool = false
}

๐Ÿ”„ @Shared

@Shared๋Š” ๋ถ€๋ชจ์™€ ์ž์‹ ์ปดํฌ๋„ŒํŠธ ๊ฐ„์˜ ์ƒํƒœ ๊ณต์œ ๋ฅผ ๊ฐ€๋Šฅํ•˜๊ฒŒ ํ•ฉ๋‹ˆ๋‹ค.

/// `file`, `UserDefailt` ์ €์žฅ์†Œ๋Š” Codable๋ฅผ ์ค€์ˆ˜ ํ•ด์•ผํ•ฉ๋‹ˆ๋‹ค.
struct SharedState: Codable, Equatable {
    var username: String = ""
    var age: Int = 0
    var isPremium: Bool = false
}

struct State {
    @Shared(.file()) var sharedState = SharedState()
    @Shared(.file(path: "Test/")) var sharedState = SharedState() // ์„œ๋ธŒ ํด๋” ๊ฒฝ๋กœ
    @Shared var sharedState = SharedState()
    @Shared(key: "custom_key") var sharedState = SharedState() // ์ปค์Šคํ…€ ํ‚ค
    @ViewState var displayInfo: String = ""
}

โš ๏ธ @Shared๋Š” ๊ฐ’์ด ๋ณ€๊ฒฝ๋˜์–ด๋„ UI๋ฅผ ์ž๋™์œผ๋กœ ์—…๋ฐ์ดํŠธํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

๐Ÿ“ฆ ์ƒํƒœ ์ถ”์ ์„ ์œ„ํ•œ @Emit ์‚ฌ์šฉ

@Emit์€ ๊ฐ’์ด ๋™์ผํ•˜๊ฒŒ ์„ค์ •๋˜๋”๋ผ๋„ ์—…๋ฐ์ดํŠธ๋ฅผ ํŠธ๋ฆฌ๊ฑฐํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ“Œ ์ƒํƒœ์—์„œ @Emit ์‚ฌ์šฉํ•˜๊ธฐ

struct MyState {
    @Emit var title: String = "Hello"
}

๐Ÿ“Œ emit(_:) ๊ตฌ๋…ํ•˜๊ธฐ

reactable.emit(\.$title)
    .sink { newValue in
        print("Title updated:", newValue)
    }
    .store(in: &cancellables)

๐Ÿ“Œ SwiftUI์—์„œ @Emit ์‚ฌ์šฉํ•˜๊ธฐ

ZStack { }
.emit(\.$title, from: self.store) { value in
    print("Title updated:", value)
}

2๏ธโƒฃ ObservableEvent (๋ถ€๋ชจ ์ž์‹๊ฐ„ ํ†ต์‹ )

ObservableEvent๋Š” ์ž์‹ ์ปดํฌ๋„ŒํŠธ์™€ ๋ถ€๋ชจ ์ปดํฌ๋„ŒํŠธ๊ฐ„ ์•ก์…˜์„ ์ „์†กํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์ค๋‹ˆ๋‹ค.

โš ๏ธ Action์ด ๋๋‚œ์‹œ์ ์— ์ž๋…€์˜ State๋ฅผ ๋ถ€๋ชจ์—๊ฒŒ ์ „๋‹ฌํ•˜์ง€๋งŒ, ํƒ€์ด๋ฐ ์ด์Šˆ๋กœ ์ตœ์‹  state๊ฐ€ ์•„๋‹์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์–ด๋–ค ์ž๋…€ Reactable์ด ๋ณด๋ƒˆ๋Š”์ง€ ํŒ๋‹จํ•˜๋Š” ์šฉ๋„๋กœ ์‚ฌ์šฉํ•ด์ฃผ์„ธ์š”.

// ์ž์‹ Reactable
class ChildReactable: Reactable, ObservableEvent { 
    enum Action {
        case notifyParent(Int)
    }
}

// ๋ถ€๋ชจ Reactable
func transformAction() -> AnyPublisher<Action, Never> {
     // ๊ธ€๋กœ๋ฒŒํ•˜๊ฒŒ ๋ชจ๋“  ChildReactable์˜ ์•ก์…˜๊ณผ ๋ณ€๊ฒฝ๋œ ์ƒํƒœ๋ฅผ ๊ด€์ฐฐ
    let childEvent = ChildReactable.observe()
        .filter { result in // result ์—๋Š” ๋ฐœ์ƒํ•œ ์•ก์…˜๊ณผ ์•ก์…˜์ด ๋๋‚œ ์‹œ์ ์˜ Child State๊ฐ€ ํฌํ•จ๋ฉ๋‹ˆ๋‹ค.
            if case .notifyParent = result.action { return true }
            return false
        }
        .map(Action.parentAction)
        .eraseToAnyPublisher()
        
    // ํŠน์ • reactable ์•ก์…˜์„ ๊ด€์ฐฐ
    let localChildEvent = self.currentState.childReactable.observe()
        .filter { result in // result ์—๋Š” ๋ฐœ์ƒํ•œ ์•ก์…˜๊ณผ ์•ก์…˜์ด ๋๋‚œ ์‹œ์ ์˜ Child State๊ฐ€ ํฌํ•จ๋ฉ๋‹ˆ๋‹ค.
            if case .notifyParent = result.action { return true }
            return false
        }
        .map(Action.parentAction)
        .eraseToAnyPublisher()
        
    return .merge([
        childEvent,
    ])
}

3๏ธโƒฃ ReactableView ํ”„๋กœํ† ์ฝœ

UIKit ๋ทฐ์—์„œ @MainActor๋ฅผ ๋”ฐ๋ฅด๋Š” ReactableView ํ”„๋กœํ† ์ฝœ์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

final class UIKitView: UIView {
    var cancellables: Set<AnyCancellable> = []
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        self.reactable = .init()
    }
}

extension UIKitView: ReactableView { 
    // self.reactable์ด ์„ค์ •๋˜๋ฉด ํ˜ธ์ถœ๋ฉ๋‹ˆ๋‹ค.
    func bind(reactable: UIKitReactable) { 

    }
}

4๏ธโƒฃ DependencyInjectable & Factory ํŒจํ„ด ์‚ฌ์šฉ๋ฒ•

์˜์กด์„ฑ ์ฃผ์ž… ์‹œ์Šคํ…œ๊ณผ ํŒฉํ† ๋ฆฌ ํŒจํ„ด์„ ๊ฒฐํ•ฉํ•˜์—ฌ, real, preview, test ํ™˜๊ฒฝ์—์„œ ๊ฐ์ฒด ์ƒ์„ฑ ๋ฐ ์˜์กด์„ฑ ๊ด€๋ฆฌ๋ฅผ ๊ฐ„์†Œํ™”ํ•ฉ๋‹ˆ๋‹ค.

1. DependencyInjectable

DependencyInjectable ํ”„๋กœํ† ์ฝœ์€ ํƒ€์ž…์ด ์„œ๋กœ ๋‹ค๋ฅธ ํ™˜๊ฒฝ์— ๋Œ€ํ•œ ์˜์กด์„ฑ์„ ์ •์˜ํ•  ์ˆ˜ ์žˆ๋„๋ก ํ•ฉ๋‹ˆ๋‹ค.

public protocol DependencyInjectable {
    associatedtype DependencyType
    static var real: DependencyType { get }
    static var preview: DependencyType { get } // optional
    static var test: DependencyType { get } // optional
}

Example:

protocol ServiceProtocol {
    func test() -> String
}

struct Service: ServiceProtocol {
     func test() -> String { "real" }
    
    struct Mock: ServiceProtocol {
        public init() {}
        public func test() -> String { "mock" }
    }
    
    struct TestMock: ServiceProtocol {
        public init() {}
        public func test() -> String { "test" }
    }
}

extension Service: DependencyInjectable {
    static var real: ServiceProtocol { Service() }
    static var preview: ServiceProtocol { Service.Mock() }
    static var test: ServiceProtocol { Service.TestMock() }
}

extension GlobalDependencyKey {
    var service: ServiceProtocol {
        self[Service.self]
    }
}

// usage
@Dependency(\.service) var service

2. Factory

@MainActor๊ฐ€ ํ•„์š”ํ•œ Factory๋Š” ViewFactory๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

final class TestObject: Factory {
    struct Payload {
        var text: String
    }

    let payload: Payload
    
    init(payload: Payload) {
        self.payload = payload
    }
    
    func print1() {
        print(self.payload.text)
    }
}

extension TestObject: DependencyInjectable {
    typealias DependencyType = TestObject.Factory
    static var real: TestObject.Factory { .init() }
}

extension GlobalDependencyKey {
    var testObjectFactory: TestObject.Factory {
        self[TestObject.self]
    }
}

// usage
@Dependency(\.testObjectFactory) var testObjectFactory

3. AnyFactory

AnyFactory๋Š” ๊ฐ์ฒด ์ƒ์„ฑ ๊ณผ์ •์„ ์ถ”์ƒํ™”ํ•˜๋Š” ์ œ๋„ค๋ฆญ ๋ž˜ํผ์ž…๋‹ˆ๋‹ค. Factory๋ฅผ ์ด์šฉํ•˜์—ฌ ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•œ ํ›„, ๋ณ€ํ™˜ ํด๋กœ์ €๋ฅผ ํ†ตํ•ด ์›ํ•˜๋Š” ์ถœ๋ ฅ ํƒ€์ž…์œผ๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค.

// Define the protocol for testing
protocol FactoryTestProtocol {
    func test() -> String
}

// Real factory implementation
struct FactoryTest: FactoryTestProtocol, Factory {
    struct Payload { }
    let payload: Payload
    
    init(payload: Payload) {
        self.payload = payload
    }
    
    func test() -> String { "real \(payload)" }
}

// Mock factory implementation
struct FactoryTestMock: FactoryTestProtocol, Factory {
    let payload: FactoryTest.Payload
    
    init(payload: FactoryTest.Payload) {
        self.payload = payload
    }
    
    func test() -> String { "mock \(payload)" }
}

// Conform FactoryTest to DependencyInjectable using AnyFactory
extension FactoryTest: DependencyInjectable {
    typealias DependencyType = AnyFactory<FactoryTestProtocol, Payload>
    
    static var real: DependencyType {
        DependencyType(factory: FactoryTest.Factory())
    }
    static var test: DependencyType {
        DependencyType(factory: FactoryTestMock.Factory())
    }
}

extension GlobalDependencyKey {
    var factoryTestFactory: FactoryTest.DependencyType {
        self[FactoryTest.self]
    }
}

๐Ÿ—๏ธ Roadmap

๐Ÿ”— References

๐Ÿ“œ License

ReactableKit is available under the MIT license.