Pro Yearly is on sale from $80 to $50! »

Introduction to the Swift Composable Architecture

Introduction to the Swift Composable Architecture

91afa794270c0668bdc3462d7da3506e?s=128

Gyula Voros

August 11, 2020
Tweet

Transcript

  1. Introduction to the Composable Architecture

  2. whoami Gyula Voros Co-founder of Twitter: @gyula_voros

  3. Agenda 01 Architecture with SwiftUI 02 Introduction to the Composable

    Architecture 03 Small surprise
  4. Architecture with SwiftUI

  5. Different SwiftUI property wrappers @State, @StateObject, @ObservedObject, @EnvironmentObject What is

    missing? A reasonable architecture with ViewModels Counter App
  6. struct CounterView_State: View { @State private var counter = 0

    var body: some View { VStack { Button("Increment") { counter += 1 } Text("\(counter)") Button("Decrement") { counter -= 1 } } .navigationTitle("@State") } } State
  7. StateObject class CounterStateObject: ObservableObject { @Published var counter = 0

    "// more state over time }
  8. StateObject struct EvenOddView: View { @StateObject var state: CounterStateObject var

    body: some View { Text(state.counter % 2 "== 0 ? "even" : "odd") } } struct CounterView_StateObject: View { @StateObject var state = CounterStateObject() var body: some View { VStack { VStack { Button("Increment") { state.counter += 1 } Text("\(state.counter)") Button("Decrement") { state.counter -= 1 } } EvenOddView(state: state) } .navigationTitle("@StateObject") } }
  9. StateObject struct EvenOddView: View { @StateObject var state: CounterStateObject var

    body: some View { Text(state.counter % 2 "== 0 ? "even" : "odd") } } struct CounterView_StateObject: View { @StateObject var state = CounterStateObject() var body: some View { VStack { VStack { Button("Increment") { state.counter += 1 } Text("\(state.counter)") Button("Decrement") { state.counter -= 1 } } EvenOddView(state: state) } .navigationTitle("@StateObject") } }
  10. StateObject struct EvenOddView: View { @StateObject var state: CounterStateObject var

    body: some View { Text(state.counter % 2 "== 0 ? "even" : "odd") } } struct CounterView_StateObject: View { @StateObject var state = CounterStateObject() var body: some View { VStack { VStack { Button("Increment") { state.counter += 1 } Text("\(state.counter)") Button("Decrement") { state.counter -= 1 } } EvenOddView(state: state) } .navigationTitle("@StateObject") } }
  11. ObservedObject let counterState = CounterStateObject() "// ""... NavigationLink( "Counter with

    @ObservedObject", destination: CounterView_ObservedObject(state: counterState) ) "// ""... struct CounterView_ObservedObject: View { @ObservedObject var state: CounterStateObject var body: some View { VStack { Button("Increment") { state.counter += 1 } Text("\(state.counter)") Button("Decrement") { state.counter -= 1 } } .navigationTitle("@ObservedObject") } }
  12. ObservedObject let counterState = CounterStateObject() "// ""... NavigationLink( "Counter with

    @ObservedObject", destination: CounterView_ObservedObject(state: counterState) ) "// ""... struct CounterView_ObservedObject: View { @ObservedObject var state: CounterStateObject var body: some View { VStack { Button("Increment") { state.counter += 1 } Text("\(state.counter)") Button("Decrement") { state.counter -= 1 } } .navigationTitle("@ObservedObject") } }
  13. EnvironmentObject struct EvenOddView_EnvironmentObject: View { @EnvironmentObject var state: CounterStateObject var

    body: some View { Text(state.counter % 2 "== 0 ? "even" : "odd") } } struct CounterView_EnvironmentObject: View { @EnvironmentObject var state: CounterStateObject var body: some View { VStack { VStack { Button("Increment") { state.counter += 1 } Text("\(state.counter)") Button("Decrement") { state.counter -= 1 } } EvenOddView_EnvironmentObject() } .navigationTitle("@StateObject") } }
  14. EnvironmentObject struct EvenOddView_EnvironmentObject: View { @EnvironmentObject var state: CounterStateObject var

    body: some View { Text(state.counter % 2 "== 0 ? "even" : "odd") } } struct CounterView_EnvironmentObject: View { @EnvironmentObject var state: CounterStateObject var body: some View { VStack { VStack { Button("Increment") { state.counter += 1 } Text("\(state.counter)") Button("Decrement") { state.counter -= 1 } } EvenOddView_EnvironmentObject() } .navigationTitle("@StateObject") } }
  15. EnvironmentObject let counterState = CounterStateObject() "// ""... NavigationLink( "Counter with

    @EnvironmentObject", destination: CounterView_EnvironmentObject() .environmentObject(counterState) )
  16. EnvironmentObject let counterState = CounterStateObject() "// ""... NavigationLink( "Counter with

    @EnvironmentObject", destination: CounterView_EnvironmentObject() "//.environmentObject(counterState) ) ""--- Fatal error: No ObservableObject of type CounterStateObject found. A View.environmentObject(_:) for CounterStateObject may be missing as an ancestor of this view.: file SwiftUI, line 0
  17. What is missing? struct CounterView_EnvironmentObject: View { @EnvironmentObject var state:

    CounterStateObject var body: some View { VStack { VStack { Button("Increment") { state.counter += 1 } Text("\(state.counter)") Button("Decrement") { if state.counter > 0 { state.counter -= 1 } } } EvenOddView_EnvironmentObject() } .navigationTitle("@EnvironmentObject") } } Business logic in view?
  18. What is missing? struct SomeView: View { private var apiCancellable:

    AnyCancellable? var body: some View { Button("Make network call") { self.apiCancellable = URLSession .shared .dataTaskPublisher(for: URL(string: "https:"//my.api/")!) .sink( receiveCompletion: { _ in }, receiveValue: { (data, response) in } ) } } } Business logic in view? Self is immutable
  19. What is missing? Business logic in view? Self is immutable

    Dependencies? Testing?
  20. ViewModel class CounterViewModel: ObservableObject { @Published var counter = 0

    func increment() { counter += 1 } func decrement() { guard counter > 0 else { return } counter -= 1 } }
  21. ViewModel struct CounterView_ViewModel: View { @ObservedObject var viewModel: CounterViewModel var

    body: some View { VStack { Button("Increment") { viewModel.increment() } Text("\(viewModel.counter)") Button("Decrement") { viewModel.decrement() } } .navigationTitle("ViewModel") } }
  22. The Composable Architecture

  23. Brandon Williams & Stephen Celis Implementation of an unidirectional data

    flow Focus on: State management, Composition, Side effects, Testing, Ergonomics https://www.pointfree.co/ https://github.com/pointfreeco/swift-composable-architecture Composable Architecture
  24. State: type that describes your domain model Action: type that

    represents the events in your app Environment: type that holds any dependencies Reducer: function that describes how to evolve state Store: runtime Building blocks
  25. Domain struct CounterState: Equatable { var counter = 0 }

    enum CounterAction { case increment case decrement } typealias CounterEnvironment = Void
  26. Behavior & runtime let counterReducer = Reducer<CounterState, CounterAction, CounterEnvironment> {

    state, action, _ in switch action { case .increment: state.counter += 1 case .decrement: state.counter -= 1 } return .none } let counterStore = Store( initialState: CounterState(), reducer: counterReducer, environment: CounterEnvironment() )
  27. Testing func test_SimpleCounter() throws { let testStore = TestStore( initialState:

    CounterState(), reducer: counterReducer, environment: CounterEnvironment() ) testStore.assert( .send(.increment) { $0.counter = 1 }, .send(.increment) { $0.counter = 2 }, .send(.decrement) { $0.counter = 1 } "// .receive(action) { } "// .do { } "// .environment { environment in } ) }
  28. Testing func test_SimpleCounter() throws { let testStore = TestStore( initialState:

    CounterState(), reducer: counterReducer, environment: CounterEnvironment() ) testStore.assert( .send(.increment) { $0.counter = 1 }, .send(.increment) { $0.counter = 3 }, .send(.decrement) { $0.counter = 1 } ) }
  29. Simple Counter struct SimpleCounter: View { let store: Store<CounterState, CounterAction>

    var body: some View { WithViewStore(store) { viewStore in VStack { Button("Increment") { viewStore.send(.increment) } Text("\(viewStore.counter)") Button("Decrement") { viewStore.send(.decrement) } } .navigationTitle("Simple Counter") } } }
  30. Simple Counter struct SimpleCounter: View { let store: Store<CounterState, CounterAction>

    var body: some View { WithViewStore(store) { viewStore in VStack { Button("Increment") { viewStore.send(.increment) } Text("\(viewStore.counter)") Button("Decrement") { viewStore.send(.decrement) } } .navigationTitle("Simple Counter") } } } class Store<State, Action> class ViewStore<State, Action">: ObservableObject
  31. Advanced Counter Multiple counters Sum of all counters Reset with

    timeout
  32. Advanced Counter struct CountersState: Equatable { var counters: IdentifiedArrayOf<CounterState> =

    [] var sum = 0 var isResetInFlight = false } enum CountersAction: Equatable { case counter(id: UUID, action: CounterAction) case addCounter case reset case scheduleReset case cancelScheduledReset case alertDismissed } struct CountersEnvironment { var uuid: () "-> UUID = UUID.init var mainQueue = DispatchQueue.main.eraseToAnyScheduler() }
  33. Advanced Counter struct CounterState: Equatable, Identifiable { var counter =

    0 let id: UUID }
  34. Advanced Counter let countersReducer = Reducer<CountersState, CountersAction, CountersEnvironment> { state,

    action, environment in struct ResetCancellationId: Hashable {} switch action { case .addCounter: state.counters.insert(CounterState(id: environment.uuid()), at: 0) case .scheduleReset: state.isResetInFlight = true return Effect(value: .reset) .delay(for: .milliseconds(3000), scheduler: environment.mainQueue) .eraseToEffect() .cancellable(id: ResetCancellationId(), cancelInFlight: true) case .reset: state.sum = 0 state.counters = [] state.isResetInFlight = false
  35. Advanced Counter let countersReducer = Reducer<CountersState, CountersAction, CountersEnvironment> { state,

    action, environment in struct ResetCancellationId: Hashable {} case .cancelScheduledReset: state.isResetInFlight = false return .cancel(id: ResetCancellationId()) case let .counter(_, action): switch action { case .increment: state.sum += 1 case .decrement: state.sum -= 1 } case .alertDismissed: break } return .none }
  36. Advanced Counter let countersAppReducer = Reducer.combine( countersReducer, counterReducer.forEach( state: \.counters,

    action: /CountersAction.counter, environment: { _ in } ) ) let countersStore = Store( initialState: CountersState(), reducer: countersAppReducer, environment: CountersEnvironment() )
  37. Advanced Counter struct CounterItemView: View { let store: Store<CounterState, CounterAction>

    var body: some View { WithViewStore(store) { viewStore in HStack { Button("Decrement") { viewStore.send(.decrement) } Text("\(viewStore.counter)") Button("Increment") { viewStore.send(.increment) } } } } }
  38. Advanced Counter struct AdvancedCounter: View { let store: Store<CountersState, CountersAction>

    var body: some View { WithViewStore(store) { viewStore in VStack { "// header List { ForEachStore( self.store.scope( state: \.counters, action: CountersAction.counter(id:action:) ), content: CounterItemView.init(store:) ) } "// action sheet }}}}
  39. Advanced Counter struct AdvancedCounter: View { let store: Store<CountersState, CountersAction>

    var body: some View { WithViewStore(store) { viewStore in VStack { "// body } .navigationBarItems( trailing: HStack(spacing: 20) { Button("Reset") { viewStore.send(.scheduleReset) } .disabled(viewStore.isResetInFlight) Button("Add Counter") { viewStore.send(.addCounter) } } )
  40. Advanced tests let testScheduler = DispatchQueue.testScheduler func test_AdvancedCounter() throws {

    let testStore = TestStore( initialState: CountersState(), reducer: countersAppReducer, environment: CountersEnvironment( uuid: UUID.incrementing, mainQueue: testScheduler.eraseToAnyScheduler() ) ) }
  41. Advanced tests testStore.assert( .send(.addCounter) { $0.counters.insert( CounterState(id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!), at:

    0 ) }, .send(.addCounter) { $0.counters.insert( CounterState(id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!), at: 0 ) } )
  42. Advanced tests testStore.assert( "// ""... .send(.counter( id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, action:

    .increment )) { $0.counters = [ CounterState(counter: 1, id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!), CounterState(counter: 0, id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!) ] $0.sum = 1 } "// ""... )
  43. Advanced tests testStore.assert( "// ""... .send(.scheduleReset) { $0.isResetInFlight = true

    }, .do { self.testScheduler.advance(by: 3) }, .receive(.reset) { $0.counters = [] $0.sum = 0 $0.isResetInFlight = false } )
  44. Advanced tests testStore.assert( "// ""... .send(.scheduleReset) { $0.isResetInFlight = true

    }, .do { self.testScheduler.advance(by: 2.5) }, .receive(.reset) { $0.counters = [] $0.sum = 0 $0.isResetInFlight = false } )
  45. Advanced tests testStore.assert( "// ""... .send(.scheduleReset) { $0.isResetInFlight = true

    }, .do { self.testScheduler.advance(by: 3) } "// .receive(.reset) { "// $0.counters = [] "// $0.sum = 0 "// $0.isResetInFlight = false "// } )
  46. Sum up Compelling story for state management ✅ Composition ✅

    Controlled side effects ✅ Testing ✅ Ergonomics ✅
  47. Surprise

  48. None
  49. Search example class ComposeSearchActivity : AppCompatActivity() { override fun onCreate(savedInstanceState:

    Bundle?) { super.onCreate(savedInstanceState) setContent { SearchView(store = SearchApp.store) } } }
  50. Search example @Composable fun SearchView(store: Store<SearchState, SearchAction>) { var state

    by state { store.currentState } launchInComposition { store.states.collect { state = it } } Column { val queryState = state { TextFieldValue("") } TextField( value = queryState.value, onValueChange = { queryState.value = it store.send(SearchAction.SearchQueryChanged(it.text)) } ) AdapterList(data = state.locations) { location "-> Text(text = location.title) } } }
  51. Questions?