Upgrade to Pro — share decks privately, control downloads, hide ads and more …

iOSアプリ開発のためのThe Composable Architectureがすごく良いので...

Avatar for yimajo yimajo
September 20, 2020

iOSアプリ開発のためのThe Composable Architectureがすごく良いので紹介したい

Avatar for yimajo

yimajo

September 20, 2020
Tweet

More Decks by yimajo

Other Decks in Programming

Transcript

  1. About The Composable Architecture • A library for building applications

    • SwiftUI, UIKit • iOS, MacOS, tvOS, and WatchOS
  2. About The Composable Architecture • A library for building applications

    • SwiftUI, UIKit • iOS, MacOS, tvOS, and WatchOS • Point-Free hosted by Mr. Brandon and Mr. Stephen. • https://github.com/pointfreeco/swift-composable-architecture
  3. TCA

  4. ΍ΕΔ͜ͱ • - ͱ + ΛλοϓͰ͖Δ • ਺ࣈͷද͕ࣔ - ͱ

    + ʹΑΓมߋ͞ΕΔ ಛ௃ • Effect ͕ͳ͍ɻEnvironment͸Χϥ CaseStudies/CounterDemo
  5. CaseStudies/CounterDemo State Action Reducer View Store // Action enum CounterAction:

    Equatable { case decrementButtonTapped case incrementButtonTapped }
  6. CaseStudies/CounterDemo State Action Reducer View Store // Action enum CounterAction:

    Equatable { case decrementButtonTapped case incrementButtonTapped } // State struct CounterState: Equatable { var count = 0 }
  7. CaseStudies/CounterDemo State Action Reducer View Store // Action enum CounterAction:

    Equatable { case decrementButtonTapped case incrementButtonTapped } // Reducer let counterReducer = Reducer { state, action, _ in switch action { case .decrementButtonTapped: state.count -= 1 return .none case .incrementButtonTapped: state.count += 1 return .none } } // State struct CounterState: Equatable { var count = 0 }
  8. CaseStudies/CounterDemo State Action Reducer View Store // Action enum CounterAction:

    Equatable { case decrementButtonTapped case incrementButtonTapped } // Reducer let counterReducer = Reducer { state, action, _ in switch action { case .decrementButtonTapped: state.count -= 1 return .none case .incrementButtonTapped: state.count += 1 return .none } } // State struct CounterState: Equatable { var count = 0 } struct CounterView: View { let store: Store<CounterState, CounterAction> var body: some View { WithViewStore(self.store) { viewStore in HStack { Button("−") { viewStore.send(.decrementButtonTapped) } Text(“\(viewStore.count)”) Button("+") { viewStore.send(.incrementButtonTapped) } } }
  9. ΍ΕΔ͜ͱ • 2ߦͷ- ͱ + ΛλοϓͰ͖ΔView͕2ͭ ಛ௃ • Effect ͕ͳ͍ɻEnvironment͸Χϥ

    • Χ΢ϯλʔ༻ͷStoreͱViewΛͻͱͭͷ ෦඼ͱͯ͠2ߦར༻͍ͯ͠Δ CaseStudies/TwoCounterDemo
  10. CaseStudies/TwoCounterDemo State Action Reducer View Store // Action enum TwoCountersAction

    { case counter1(CounterAction) case counter2(CounterAction) }
  11. CaseStudies/TwoCounterDemo State Action Reducer View Store // Action enum TwoCountersAction

    { case counter1(CounterAction) case counter2(CounterAction) } // State struct TwoCountersState: Equatable { var counter1 = CounterState() var counter2 = CounterState() }
  12. CaseStudies/TwoCounterDemo State Action Reducer View Store // Action enum TwoCountersAction

    { case counter1(CounterAction) case counter2(CounterAction) } // Reducer let twoCountersReducer = Reducer .combine( counterReducer.pullback( state: \TwoCountersState.counter1, action: /TwoCountersAction.counter1, environment: { _ in CounterEnvironment() } ), counterReducer.pullback( state: \TwoCountersState.counter2, action: /TwoCountersAction.counter2, environment: { _ in CounterEnvironment() } ) ) // State struct TwoCountersState: Equatable { var counter1 = CounterState() var counter2 = CounterState() }
  13. struct TwoCountersView: View { let store: Store<TwoCountersState, TwoCountersAction> var body:

    some View { Form { Section(header: Text(template: readMe, .caption)) { HStack { Text("Counter 1") CounterView( store: self.store.scope(state: { $0.counter1 }, action: TwoCountersAction.counter1) ) .buttonStyle(BorderlessButtonStyle()) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) } HStack { Text("Counter 2") CounterView( store: self.store.scope(state: { $0.counter2 }, action: TwoCountersAction.counter2) ) .buttonStyle(BorderlessButtonStyle()) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) } } } .navigationBarTitle("Two counter demo") } }
  14. ΍ΕΔ͜ͱ • - ͱ + ΛλοϓͰ͖Δ • - λοϓ͢Δͱ1ඵޙʹ+͞ΕΔ •

    Number factϘλϯΛλοϓͰWeb APIݺͼग़͠ ಛ௃ • Effect ͕2ͭ͋Γɺ- Ϙλϯλοϓͱ NumberFact ϘλϯʹΑΓಈ࡞͢Δɻ CaseStudies/EffectBasics
  15. CaseStudies/EffectBasics State Action Reducer View Store Environment // Action enum

    EffectsBasicsAction: Equatable { case decrementButtonTapped case incrementButtonTapped case numberFactButtonTapped case numberFactResponse ( Result<String, NumbersApiError> ) } Effect
  16. CaseStudies/EffectBasics State Action Reducer View Store // State struct EffectsBasicsState:

    Equatable { var count = 0 // ௨৴தΛࣔ͢ϑϥά var isNumberFactRequestInFlight = false // WebAPIͷϨεϙϯε var numberFact: String? } Environment // Action enum EffectsBasicsAction: Equatable { case decrementButtonTapped case incrementButtonTapped case numberFactButtonTapped case numberFactResponse ( Result<String, NumbersApiError> ) } Effect
  17. CaseStudies/EffectBasics State Action Reducer View Store // State struct EffectsBasicsState:

    Equatable { var count = 0 // ௨৴தΛࣔ͢ϑϥά var isNumberFactRequestInFlight = false // WebAPIͷϨεϙϯε var numberFact: String? } Environment // Action enum EffectsBasicsAction: Equatable { case decrementButtonTapped case incrementButtonTapped case numberFactButtonTapped case numberFactResponse ( Result<String, NumbersApiError> ) } Effect // Reducer let effectsBasicsReducer = Reducer { state, action, environment in switch action { case .decrementButtonTapped: ɾɾɾলུ return Effect(value: EffectsBasicsAction.incrementButtonTapped) .delay(for: 1, scheduler: environment.mainQueue) .eraseToEffect() case .incrementButtonTapped: ɾɾɾলུ return .none case .numberFactButtonTapped: state.isNumberFactRequestInFlight = true state.numberFact = nil return environment.numberFact(state.count) .receive(on: environment.mainQueue) .catchToEffect() .map(EffectsBasicsAction.numberFactResponse) case let .numberFactResponse(.success(response)): state.isNumberFactRequestInFlight = false state.numberFact = response return .none case .numberFactResponse(.failure): ɾɾɾলུ return .none } }
  18. CaseStudies/EffectBasics State Action Reducer View Store // State struct EffectsBasicsState:

    Equatable { var count = 0 // ௨৴தΛࣔ͢ϑϥά var isNumberFactRequestInFlight = false // WebAPIͷϨεϙϯε var numberFact: String? } Environment // Action enum EffectsBasicsAction: Equatable { case decrementButtonTapped case incrementButtonTapped case numberFactButtonTapped case numberFactResponse ( Result<String, NumbersApiError> ) } Effect // Reducer let effectsBasicsReducer = Reducer { state, action, environment in switch action { case .decrementButtonTapped: ɾɾɾলུ return Effect(value: EffectsBasicsAction.incrementButtonTapped) .delay(for: 1, scheduler: environment.mainQueue) .eraseToEffect() case .incrementButtonTapped: ɾɾɾলུ return .none case .numberFactButtonTapped: state.isNumberFactRequestInFlight = true state.numberFact = nil return environment.numberFact(state.count) .receive(on: environment.mainQueue) .catchToEffect() .map(EffectsBasicsAction.numberFactResponse) case let .numberFactResponse(.success(response)): state.isNumberFactRequestInFlight = false state.numberFact = response return .none case .numberFactResponse(.failure): ɾɾɾলུ return .none } }
  19. CaseStudies/EffectBasics State Action Reducer View Store // State struct EffectsBasicsState:

    Equatable { var count = 0 // ௨৴தΛࣔ͢ϑϥά var isNumberFactRequestInFlight = false // WebAPIͷϨεϙϯε var numberFact: String? } Environment // Action enum EffectsBasicsAction: Equatable { case decrementButtonTapped case incrementButtonTapped case numberFactButtonTapped case numberFactResponse ( Result<String, NumbersApiError> ) } Effect // Reducer let effectsBasicsReducer = Reducer { state, action, environment in switch action { case .decrementButtonTapped: ɾɾɾলུ return Effect(value: EffectsBasicsAction.incrementButtonTapped) .delay(for: 1, scheduler: environment.mainQueue) .eraseToEffect() case .incrementButtonTapped: ɾɾɾলུ return .none case .numberFactButtonTapped: state.isNumberFactRequestInFlight = true state.numberFact = nil return environment.numberFact(state.count) .receive(on: environment.mainQueue) .catchToEffect() .map(EffectsBasicsAction.numberFactResponse) case let .numberFactResponse(.success(response)): state.isNumberFactRequestInFlight = false state.numberFact = response return .none case .numberFactResponse(.failure): ɾɾɾলུ return .none } }
  20. public struct WithViewStore<State, Action, Content>: View where Content: View {

    private let content: (ViewStore<State, Action>) -> Content private var prefix: String? @ObservedObject private var viewStore: ViewStore<State, Action> public init( _ store: Store<State, Action>, removeDuplicates isDuplicate: @escaping (State, State) -> Bool, @ViewBuilder content: @escaping (ViewStore<State, Action>) -> Content ) { self.content = content self.viewStore = ViewStore(store, removeDuplicates: isDuplicate) } public var body: some View { … লུ return self.content(self.viewStore) }
  21. public struct WithViewStore<State, Action, Content>: View where Content: View {

    private let content: (ViewStore<State, Action>) -> Content private var prefix: String? @ObservedObject private var viewStore: ViewStore<State, Action> public init( _ store: Store<State, Action>, removeDuplicates isDuplicate: @escaping (State, State) -> Bool, @ViewBuilder content: @escaping (ViewStore<State, Action>) -> Content ) { self.content = content self.viewStore = ViewStore(store, removeDuplicates: isDuplicate) } public var body: some View { … লུ return self.content(self.viewStore) }
  22. public struct WithViewStore<State, Action, Content>: View where Content: View {

    private let content: (ViewStore<State, Action>) -> Content private var prefix: String? @ObservedObject private var viewStore: ViewStore<State, Action> public init( _ store: Store<State, Action>, removeDuplicates isDuplicate: @escaping (State, State) -> Bool, @ViewBuilder content: @escaping (ViewStore<State, Action>) -> Content ) { self.content = content self.viewStore = ViewStore(store, removeDuplicates: isDuplicate) } public var body: some View { … লུ return self.content(self.viewStore) }
  23. @dynamicMemberLookup public final class ViewStore<State, Action>: ObservableObject { … লུ

    /// The current state. public private(set) var state: State { willSet { self.objectWillChange.send() } } … লུ public func send(_ action: Action) { self._send(action) }
  24. Effect<User, Error>.result { let fileUrl = URL( fileURLWithPath: NSSearchPathForDirectoriesInDomains( .documentDirectory,

    .userDomainMask, true )[0] ) .appendingPathComponent("user.json") let result = Result<User, Error> { let data = try Data(contentsOf: fileUrl) return try JSONDecoder().decode(User.self, from: $0) } return result } ௥ه
  25. func testNumberFact() { let store = TestStore( initialState: EffectsBasicsState(), reducer:

    effectsBasicsReducer, environment: EffectsBasicsEnvironment( mainQueue: self.scheduler.eraseToAnyScheduler(), numberFact: { n in Effect(value: "\(n) is a good number") } ) ) store.assert( .send(.incrementButtonTapped) { $0.count = 1 }, .send(.numberFactButtonTapped) { $0.isNumberFactRequestInFlight = true }, .do { self.scheduler.advance(by: 1) }, .receive(.numberFactResponse(.success("1 is a good number"))) { $0.isNumberFactRequestInFlight = false $0.numberFact = "1 is a good number" } ) }
  26. func testNumberFact() { let store = TestStore( initialState: EffectsBasicsState(), reducer:

    effectsBasicsReducer, environment: EffectsBasicsEnvironment( mainQueue: self.scheduler.eraseToAnyScheduler(), numberFact: { n in Effect(value: "\(n) is a good number") } ) ) store.assert( .send(.incrementButtonTapped) { $0.count = 1 }, .send(.numberFactButtonTapped) { $0.isNumberFactRequestInFlight = true }, .do { self.scheduler.advance(by: 1) }, .receive(.numberFactResponse(.success("1 is a good number"))) { $0.isNumberFactRequestInFlight = false $0.numberFact = "1 is a good number" } ) }
  27. func testNumberFact() { let store = TestStore( initialState: EffectsBasicsState(), reducer:

    effectsBasicsReducer, environment: EffectsBasicsEnvironment( mainQueue: self.scheduler.eraseToAnyScheduler(), numberFact: { n in Effect(value: "\(n) is a good number") } ) ) store.assert( .send(.incrementButtonTapped) { $0.count = 1 }, .send(.numberFactButtonTapped) { $0.isNumberFactRequestInFlight = true }, .do { self.scheduler.advance(by: 1) }, .receive(.numberFactResponse(.success("1 is a good number"))) { $0.isNumberFactRequestInFlight = false $0.numberFact = "1 is a good number" } ) }
  28. func testNumberFact() { let store = TestStore( initialState: EffectsBasicsState(), reducer:

    effectsBasicsReducer, environment: EffectsBasicsEnvironment( mainQueue: self.scheduler.eraseToAnyScheduler(), numberFact: { n in Effect(value: "\(n) is a good number") } ) ) store.assert( .send(.incrementButtonTapped) { $0.count = 1 }, .send(.numberFactButtonTapped) { $0.isNumberFactRequestInFlight = true }, .do { self.scheduler.advance(by: 1) }, .receive(.numberFactResponse(.success("1 is a good number"))) { $0.isNumberFactRequestInFlight = false $0.numberFact = "1 is a good number" } ) } $0.count Λ 1 count ͸ 1 -> ࣗ෼͕ظ଴͢ΔState ࣮ࡍʹมߋ͞ΕͨState ͱൺֱ
  29. func testNumberFact() { let store = TestStore( initialState: EffectsBasicsState(), reducer:

    effectsBasicsReducer, environment: EffectsBasicsEnvironment( mainQueue: self.scheduler.eraseToAnyScheduler(), numberFact: { n in Effect(value: "\(n) is a good number") } ) ) store.assert( .send(.incrementButtonTapped) { $0.count = 1 }, .send(.numberFactButtonTapped) { $0.isNumberFactRequestInFlight = true }, .do { self.scheduler.advance(by: 1) }, .receive(.numberFactResponse(.success("1 is a good number"))) { $0.isNumberFactRequestInFlight = false $0.numberFact = "1 is a good number" } ) } $0.count Λ 1 count ͸ 1 -> ࣗ෼͕ظ଴͢ΔState ࣮ࡍʹมߋ͞ΕͨState ͱൺֱ
  30. func testNumberFact() { let store = TestStore( initialState: EffectsBasicsState(), reducer:

    effectsBasicsReducer, environment: EffectsBasicsEnvironment( mainQueue: self.scheduler.eraseToAnyScheduler(), numberFact: { n in Effect(value: "\(n) is a good number") } ) ) store.assert( .send(.incrementButtonTapped) { $0.count = 1 }, .send(.numberFactButtonTapped) { $0.isNumberFactRequestInFlight = true }, .do { self.scheduler.advance(by: 1) }, .receive(.numberFactResponse(.success("1 is a good number"))) { $0.isNumberFactRequestInFlight = false $0.numberFact = "1 is a good number" } ) } $0.count Λ 1 count ͸ 1 -> ࣗ෼͕ظ଴͢ΔState ࣮ࡍʹมߋ͞ΕͨState ͱൺֱ
  31. func testNumberFact() { let store = TestStore( initialState: EffectsBasicsState(), reducer:

    effectsBasicsReducer, environment: EffectsBasicsEnvironment( mainQueue: self.scheduler.eraseToAnyScheduler(), numberFact: { n in Effect(value: "\(n) is a good number") } ) ) store.assert( .send(.incrementButtonTapped) { $0.count = 1 }, .send(.numberFactButtonTapped) { $0.isNumberFactRequestInFlight = true }, .do { self.scheduler.advance(by: 1) }, .receive(.numberFactResponse(.success("1 is a good number"))) { $0.isNumberFactRequestInFlight = false $0.numberFact = "1 is a good number" } ) } $0.count Λ 1 count ͸ 1 -> ࣗ෼͕ظ଴͢ΔState ࣮ࡍʹมߋ͞ΕͨState ͱൺֱ ෭࡞༻ͷ݁Ռɺ"DUJPO͕ड͚औΔظ଴஋
  32. func testNumberFact() { let store = TestStore( initialState: EffectsBasicsState(), reducer:

    effectsBasicsReducer, environment: EffectsBasicsEnvironment( mainQueue: self.scheduler.eraseToAnyScheduler(), numberFact: { n in Effect(value: "\(n) is a good number") } ) ) store.assert( .send(.incrementButtonTapped) { $0.count = 1 }, .send(.numberFactButtonTapped) { $0.isNumberFactRequestInFlight = true }, .do { self.scheduler.advance(by: 1) }, .receive(.numberFactResponse(.success("1 is a good number"))) { $0.isNumberFactRequestInFlight = false $0.numberFact = "1 is a good number" } ) } $0.count Λ 1 count ͸ 1 -> ࣗ෼͕ظ଴͢ΔState ࣮ࡍʹมߋ͞ΕͨState ͱൺֱ ෭࡞༻ͷ݁Ռɺ"DUJPO͕ड͚औΔظ଴஋ -> ࣗ෼͕ظ଴͢ΔState
  33. • ࠓ৓ળۣʢ͍·͡ΐ͏ Α͠ͷΓʣ • ΞΧ΢ϯτ͸େ఍ y.imajo ͱ͔ yimajo ը૾Ҿ༻ݩ: ͓Ͱ͔͚ମݧܕϝσΟΞSPOT

    ʰ஑ାʹര஀ͨ͠α΢φͷָԂʮ͔Δ·Δʯ͕ྑ͗ͯ͢ҰੜՈʹؼΕͳ͍͔΋͠Εͳ͍ ʱ https://travel.spot-app.jp/karumaru_yoppy/
  34. • ࠓ৓ળۣʢ͍·͡ΐ͏ Α͠ͷΓʣ • ΞΧ΢ϯτ͸େ఍ y.imajo ͱ͔ yimajo • גࣜձࣾΩϡϦΦγςΟιϑτ

    ΢ΣΞΛҰਓͰ΍͍ͬͯ·͢ ը૾Ҿ༻ݩ: ͓Ͱ͔͚ମݧܕϝσΟΞSPOT ʰ஑ାʹര஀ͨ͠α΢φͷָԂʮ͔Δ·Δʯ͕ྑ͗ͯ͢ҰੜՈʹؼΕͳ͍͔΋͠Εͳ͍ ʱ https://travel.spot-app.jp/karumaru_yoppy/
  35. • ࠓ৓ળۣʢ͍·͡ΐ͏ Α͠ͷΓʣ • ΞΧ΢ϯτ͸େ఍ y.imajo ͱ͔ yimajo • גࣜձࣾΩϡϦΦγςΟιϑτ

    ΢ΣΞΛҰਓͰ΍͍ͬͯ·͢ • ͓͢͢Ίͷα΢φ͸஑ାͷʮ͔Δ ·Δʯ ը૾Ҿ༻ݩ: ͓Ͱ͔͚ମݧܕϝσΟΞSPOT ʰ஑ାʹര஀ͨ͠α΢φͷָԂʮ͔Δ·Δʯ͕ྑ͗ͯ͢ҰੜՈʹؼΕͳ͍͔΋͠Εͳ͍ ʱ https://travel.spot-app.jp/karumaru_yoppy/
  36. pixiv / BOOTHͰిࢠॻ੶Λൢച͍ͯ͠·͢ https://swift.booth.pm/ • async/await ݚڀಡຊ (iOSDC 2018) •

    RxSwift ݚڀಡຊ1~4 (iOSDC 2019) • VIPER ݚڀಡຊ • SwiftUI ΨΠυϒοΫ • Combine ΨΠυϒοΫ (iOSDC 2020)