본문 바로가기
Swift/TCA

[TCA] day 10 long living Effect (publisher Effect and async/await effect)

by 마라민초닭발로제 2024. 5. 5.

코드 전문

import ComposableArchitecture
import SwiftUI
import Combine

private let readMe = """
  This application demonstrates how to handle long-living effects, for example notifications from \
  Notification Center, and how to tie an effect's lifetime to the lifetime of the view.

  Run this application in the simulator, and take a few screenshots by going to \
  *Device › Screenshot* in the menu, and observe that the UI counts the number of times that \
  happens.

  Then, navigate to another screen and take screenshots there, and observe that this screen does \
  *not* count those screenshots. The notifications effect is automatically cancelled when leaving \
  the screen, and restarted when entering the screen.
  """

@Reducer
struct LongLivingEffects {
  @ObservableState
  struct State: Equatable {
    var screenshotCount = 0
  }

  enum Action {
    case task
    case taskWithPublisher
    case userDidTakeScreenshotNotification
    case userDidTakeScreenshotNotification2(String)
  }

  @Dependency(\.screenshots) var screenshots
  @Dependency(\.ss) var ss
  var subscriptions = Set<AnyCancellable>()

  var body: some Reducer<State, Action> {
    Reduce { state, action in
      switch action {
      case .task:
        // When the view appears, start the effect that emits when screenshots are taken.
        return .run { send in
          for await _ in await self.screenshots() {
            await send(.userDidTakeScreenshotNotification)
          }
        }
        
      case .taskWithPublisher:
        // When the view appears, start the effect that emits when screenshots are taken.
        return .publisher {
          ss.ssnotification()
            .map{str in Action.userDidTakeScreenshotNotification2(str)}
        }

      case .userDidTakeScreenshotNotification:
        state.screenshotCount += 1
        return .none
      case let .userDidTakeScreenshotNotification2(str) :
        print(str)
        return .none
      }
  
    }
  }
}


final class SSAdaptor: DependencyKey {
  static var liveValue: SSAdaptor = .init()
  
  func ssnotification() -> AnyPublisher<String, Never> {
    return NotificationCenter.default.publisher(for: UIApplication.userDidTakeScreenshotNotification)
      .map{cur in return cur.description}
      .eraseToAnyPublisher()
  }
}

extension DependencyValues {
  var screenshots: @Sendable () async -> AsyncStream<Void> {
    get { self[ScreenshotsKey.self] }
    set { self[ScreenshotsKey.self] = newValue }
  }
  var ss: SSAdaptor {
    get { self[SSAdaptor.self]}
    set { self[SSAdaptor.self] = newValue}
  }
}

private enum ScreenshotsKey: DependencyKey {
  static let liveValue: @Sendable () async -> AsyncStream<Void> = {
    await AsyncStream(
      NotificationCenter.default
        .notifications(named: UIApplication.userDidTakeScreenshotNotification)
        .map { _ in }
    )
  }
}

struct LongLivingEffectsView: View {
  @Bindable
  var store: StoreOf<LongLivingEffects>

  var body: some View {
    NavigationStack {
      Form {
        Section {
          AboutView(readMe: readMe)
        }
        Text("A screenshot of this screen has been taken \(store.screenshotCount) times.")
          .font(.headline)
        
        Section {
          NavigationLink {
            detailView
          } label: {
            Text("Navigate to another screen")
          }
        }
      }
    }
    .navigationTitle("Long-living effects")
    .task {
      // MARK:- 이 화면에서만 동작하게
       await store.send(.task).finish()
      
      // MARK:- 다른 화면에서도 동작하게
      store.send(.task)
      store.send(.taskWithPublisher)
    }
  }

  var detailView: some View {
    Text(
      """
      Take a screenshot of this screen a few times, and then go back to the previous screen to see \
      that those screenshots were not counted.
      """
    )
    .padding(.horizontal, 64)
    .navigationBarTitleDisplayMode(.inline)
  }
}

#Preview {
  NavigationStack {
    LongLivingEffectsView(
      store: Store(initialState: LongLivingEffects.State()) {
        LongLivingEffects()
      }
    )
  }
}

 

 

리드미 해석

 

  This application demonstrates how to handle long-living effects, for example notifications from  Notification Center, and how to tie an effect's lifetime to the lifetime of the view.

  Run this application in the simulator, and take a few screenshots by going to  *Device › Screenshot* in the menu, and observe that the UI counts the number of times that  happens.

  Then, navigate to another screen and take screenshots there, and observe that this screen does   *not* count those screenshots. The notifications effect is automatically cancelled when leaving   the screen, and restarted when entering the screen.

 

 

이 애플리케이션은 알림 센터의 알림과 같이 수명이 긴 효과를 처리하는 방법과 효과의 수명을 뷰의 수명에 연결하는 방법을 보여 줍니다.

시뮬레이터에서 이 애플리케이션을 실행하고 메뉴의 *장치 ' 스크린샷*으로 이동하여 스크린샷을 몇 장 찍고 UI가 횟수를 계산하는 것을 관찰합니다.

그런 다음 다른 화면으로 이동하여 스크린샷을 찍고 이 화면에서 해당 스크린샷이 '카운트되지' 않는지 확인합니다. 알림 효과는 화면을 나가면 자동으로 취소되고 화면에 들어가면 다시 시작됩니다.

 

 

 

동작화면

 

여러 View에서도 ss를  가능하게 하는 동작 화면 

 

 

 

TaskModifier

View에 붙은 TaskModifier는 뭘까? Depth를 타면 다음과 같이 적혀있습니다.

 

    /// Adds an asynchronous task to perform before this view appears.
    ///
    /// Use this modifier to perform an asynchronous task with a lifetime that
    /// matches that of the modified view. If the task doesn't finish
    /// before SwiftUI removes the view or the view changes identity, SwiftUI
    /// cancels the task.

 

view가 나타나기전 비동기 작업을 추가합니다. 이 modifier를 비동기 Task를 생명주기와 같이 수행하도록 하게 사용합니다. (생명주기는 modified 뷰와 매치됩니다.) 만약 task가 swiftUI 뷰를 제거하거나, 뷰의 identitiy를 바꾸기전에 종료가 되었다면, swiftUI는 task를 종료합니다. 

 

 

AsyncStream

AsyncStream을 통해서 NotificationCenter를 통한 screenshotNoti를 map으로 감싸 Action으로 처리하게 합니다. 이는 asyncStream이기에 @sendable () async 입니다. 이를 이용하기 위해서는 await을 활용해야 합니다. 

AsyncStream(
      NotificationCenter.default
        .notifications(named: UIApplication.userDidTakeScreenshotNotification)
        .map { _ in }
    )
  }

 

 

AsyncStream과 for await 

 

asyncStream을 받아서 for await으로 객체를 받습니다. 그리고 await으로 들어온 값들을 await send(Action)으로 방출시켜 적절한 reducer의 분기를 타게합니다. 

  case .task:
        // When the view appears, start the effect that emits when screenshots are taken.
        return .run { send in
          for await _ in await self.screenshots() {
            await send(.userDidTakeScreenshotNotification)
          }
        }

 

 .task {
      await store.send(.task).finish()
    }

 

 

 

Publisher로 리팩토링

우리에게 익숙한 Publisher로 리팩토링하면 다음과 같이 진행됩니다. 

final class SSAdaptor: DependencyKey {
  static var liveValue: SSAdaptor = .init()
  
  func ssnotification() -> AnyPublisher<String, Never> {
    return NotificationCenter.default.publisher(for: UIApplication.userDidTakeScreenshotNotification)
      .map{cur in return cur.description}
      .eraseToAnyPublisher()
  }
}
  case .taskWithPublisher:
    // When the view appears, start the effect that emits when screenshots are taken.
    return .publisher {
      ss.ssnotification()
        .map{_ in Action.userDidTakeScreenshotNotification}
    }

 

 

 

store.state.finish()

문서에는 state가 finish될 때 까지 기다린다고 써 있습니다. (/// Waits for the task to finish.) 그래서 await 을 쓰게 된다면, Task 구문 밑에 있는 함수가 실행되지 않습니다. 따라서 await을 제거하고 finish를 제거하면 store(.send(.taskWithPublisher)구문이 정상 동작하게 됩니다. 상황에 맞게 적절하게 쓰면 좋을 것 같습니다. 

 

  .task {
      await store.send(.task).finish()
      store.send(.taskWithPublisher) //❌ never be run before
    }
    
   .task {
      store.send(.task)
      store.send(.taskWithPublisher) //✅ will be run
    }

with await and finish
without await and finish

 

 

 

 

 

 

게시물은 TCA 라이브러리의 CaseStudies를 직접 작성해보고 어떻게 작동하는지에 대해서 공부하기 위해서 작성했습니다.