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

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

yimajo
September 20, 2020

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

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)