코디네이터패턴을 동시성으로 리팩토링

rbw·2023년 3월 21일
0

TIL

목록 보기
76/98

Using swift concurrency with coordinator pattern

https://medium.com/swiftblade/using-swift-concurrency-with-coordinator-pattern-de290b95f09b

위 글을 보고 번역/정리한 글! 자세한 내용은 위 링크 참JO !


코디네이터 패턴을 사용하는 프로토콜의 예제 코드.

얘를 스위프트의 동시성을 사용하여 리팩토링 한다고 하네여~

protocol ParentCoordinator: AnyObject {
  var children: [AnyObject] { get set }
  func start()
}

protocol ChildCoordinator: AnyObject {
  var teardown: ((Self) -> Void)? { get set }
  func start()
}

extension ChildCoordinator {
  func stop() {
    teardown?(self)
  }
}

위 코드에서의 문제는 두 가지가 있습니다.

  • stop() 메소드 호출을 잊어버릴 가능성
  • 끝난다면 children을 해제해줘야하는 필요성

The new Coordinator

protocol Coordiantor: AnyObject {
    associatedtype Output
    func start() async throws -> Output
}

start() 함수에 async, throws 키워드를 포함하여, 예전 ChildCoordinatorteardown 클로저를 삭제할 수 있습니다.

teardown() 클로저는 비동기였기 때문에 스위프트의 동시성을 사용하여 좀 더 간결하고 읽기 쉬운 코드로 리팩토링 되었습니다.

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
  static let shared = UIApplication.shared.delegate as! AppDelegate

  var window: UIWindow?
  private(set) var coordinator: AppCoordinator!

  func application(_ application: UIApplication,
                   didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

    let window = UIWindow(frame: UIScreen.main.bounds) // 1
    self.window = window
    self.coordinator = AppCoordinator(window: window)
    self.coordinator.start() // 2

    return true
  }
}
  1. UIWindow를 생성하고, AppCoordinator로 넘김
  2. start() 메소드를 호출하여 뷰컨트롤러를 설정하도록 합니다.

AppCoordinator

이 친구는 3가지 메인 흐름이 있습니다.

  • LoginCoordinator - 로그인 페이지를 다룸
  • HomeCoordinator - 홈페이지와 다른 컨텐츠를 표현함
  • PurchaseCoordinator - 알람의 종류를 나타냄, 얘는 다른 뷰컨을 보여주지 않음.
@MainActor final class AppCoordinator {

  private let window: UIWindow

  init(window: UIWindow) {
    self.window = window

    // 아래 더미를 만들지 않으면 async로 실행하는 start()가 있어서
    // 앱이 크래쉬가 난다네용
    let dummyViewController = UIViewController()
    window.rootViewController = dummyViewController
    window.makeKeyAndVisible()
  }

  func start() {
    // 1
    if UserDefaults.standard.isLoggedIn {
      showHome()
    }
    else {
      showLogin()
    }
  }

  private func showHome() {
    Task {
      let coordinator = HomeCoordinator(window: window)
      try? await coordinator.start()
      UserDefaults.standard.isLoggedIn = false
      start()
    }
  }

  private func showLogin() {
    Task {
      let coordinator = LoginCoordinator(window: window)
      let result = try? await coordinator.start()

      // 2
      print("Login result: \(String(describing: result))")

      // 3
      UserDefaults.standard.isLoggedIn = true

      // 4
      start()
    }
  }

}
  1. 로그인이 되어 있다면 HomeCoordinator, 아니면 LoginCoordianator를 띄워준다
  2. await를 통과하면, 로그인 코디네이터의 결과가 준비되었음을 의미합니다. 그리고 실제 앱이였다면 user, session 객체를 사용하겠지만 여기서는 그냥 문자열을 사용했다
  3. 로그인 상태를 바꿔줌
  4. 다시 start를 호출하여 showHome()이 호출될 수 있게 만든다.

LoginCoordinator

이는 새로운 Coordinator 프로토콜을 채택합니다.

// 코드가 길어 필요한 부분만 남겼습니다. 자세한건 본문 참조
@MainActor final class LoginCoordinator: Coordinator {
  private lazy var navigationController = UINavigationController()
  private var continuation: CheckedContinuation<String, Error>?

  func start() async throws -> String {
    // 1. 
    let viewController = LoginLandingViewController()
    viewController.delegate = self
    navigationController.setViewControllers([viewController], animated: false)
    window.rootViewController = navigationController

    // 2.
    return try await withCheckedThrowingContinuation { self.continuation = $0 }
  }
}

extension LoginCoordinator: LoginLandingViewControllerDelegate {
  func loginLandingViewControllerDidSelectLogin(_ viewController: LoginLandingViewController) {
    let viewController = LoginViewController()
    viewController.delegate = self
    navigationController.pushViewController(viewController, animated: true)
  }
}

extension LoginCoordinator: LoginViewControllerDelegate {
  func loginViewControllerDidFinishLogin(_ viewController: LoginViewController, result: String) {
    // 3.
    continuation?.resume(returning: result)
  }

  func loginViewControllerDidCancel(_ viewController: LoginViewController) {
    navigationController.popToRootViewController(animated: true)
  }
}
  1. 뷰 컨트롤러를 설정함
  2. 로그인 프로세스가 나중에 끝나므로, 나중에 완료할 수 있도록 우리는 continuation 인스턴스를 만들어 보유합니다
  3. 여기서, 비동기 함수 완료

HomeCoordinator

이는 메인 콘텐츠가 있는 부분입니다. 여기는 데모 버전이므로, 구매 버튼, 로그아웃 버튼만 구현해있습니다.

위에 로그인 코디네이터랑 거의 동일한 모습 ~

// 코드의 필요한 부분만 냅뒀슴다
@MainActor final class HomeCoordinator: Coordinator {
  private lazy var navigationController = UINavigationController()
  private var continuation: CheckedContinuation<Void, Error>?

  func start() async throws {
    let viewController = HomeViewController()
    viewController.delegate = self
    navigationController.setViewControllers([viewController], animated: false)
    window.rootViewController = navigationController

    try await withCheckedThrowingContinuation { self.continuation = $0 }
  }
}

extension HomeCoordinator: HomeViewControllerDelegate {
  func homeViewControllerDidLogOut(_ viewController: HomeViewController) {
    continuation?.resume(returning: ())
  }

  func homeViewControllerPurchase(_ viewController: HomeViewController) {
    Task {
      guard let viewController = window.rootViewController else { return }
      let coordinator = PurchaseCoordinator(rootViewController: viewController)
      let result = try? await coordinator.start()

      // Use the result. Here we just print it.
      print("Purchase result: \(String(describing: result))")
    }
  }
}

PurchaseCoordinator

이 코디네이터는 UIAlerControllers들을 관리하고, 앱의 구매 흐름을 모방합니다. 이 코디네이터는 앞에서 본 코디네이터들과의 차이가 있는데, window.rootViewController를 사용하지 않는 부분입니다. Alert이라 당연한거라고도 볼 수 있을거 같습니다.

@MainActor final class PurchaseCoordinator: Coordinator {

  enum PurchaseResult {
    case cancelled
    case success
  }

  let rootViewController: UIViewController
  private var continuation: CheckedContinuation<PurchaseResult, Error>?

  init(rootViewController: UIViewController) {
    print("\(type(of: self)) \(#function)")
    self.rootViewController = rootViewController
  }

  func start() async throws -> PurchaseResult {
    rootViewController.present(purchaseAlertController(), animated: true)
    return try await withCheckedThrowingContinuation { self.continuation = $0 }
  }

  // MARK: - Alert Controllers
  func purchaseAlertController() -> UIAlertController {
    let alert = UIAlertController(title: "Purchase flow",
                                  message: "Do you want to purchase?",
                                  preferredStyle: .alert)
    // Cancel button
    alert.addAction(UIAlertAction(title: "Cancel",
                                  style: .cancel,
                                  handler: { _ in
      self.rootViewController.present(self.purchaseResultAlertController(.cancelled), animated: true)
    }))

    // OK button
    alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in
      Task {
        await self.purchase()
        self.rootViewController.present(self.purchaseResultAlertController(.success), animated: true)
      }
    }))
    return alert
  }

  func purchase() async {
    let alert = UIAlertController(title: "Purchasing ...",
                                  message: "",
                                  preferredStyle: .alert)
    self.rootViewController.present(alert, animated: true)
    try? await Task.sleep(nanoseconds:1_000_000_000) // wait 1 second
    await alert.dismissAnimatedAsync() // 1
  }

  func purchaseResultAlertController(_ result: PurchaseResult) -> UIAlertController {
    let title = result == .success ? "Purchase Success" : "Purchase Cancelled"
    let alert = UIAlertController(title: title,
                                  message: "",
                                  preferredStyle: .alert)
    alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in
      self.continuation?.resume(returning: result)
    }))
    return alert
  }
}
  1. 비동기의 뷰컨트롤러를 디스미스합니다. 이에 관한 코드는 다음과 같습니다.
extension UIViewController {
  func dismissAnimatedAsync() async {
    await withCheckedContinuation { continuation in
      dismiss(animated: true) {
        continuation.resume()
      }
    }
  }
}

장점

  • 선형 코드 : 출력이 비동기 throws 함수를 통해 선형 코드로 반환됩니다. 더 이상 델리게이트 콜백을 거칠 필요가 없슴니다.
  • 가시적 출력 : start()자체가 값을 반환하므로 출력이 눈에 잘 띄고 가독성이 높아집니다. 더 이상 코디네이터 커스텀 변수 안에 출력을 숨기거나 새로운 델리게이트 프로토콜을 추가할 필요가 없습니다.
  • 자식 관리가 필요 x : 자식 코디네이터 배열을 붙잡고 있다가 완료되면 정리할 필요가 없습니다. Swift의 협력 스레드 풀이 코디네이터의 수명 주기를 관리해줍니다. 이는 또한 부모 코디네이터가 필요하지 않다는 의미도 됩니다.

단점

App Freeze, Continuation

코디네이터에서 수행하는 작업이 여전히 델리게이트 및 클로저 콜백을 사용하면, Continuation을 사용하여 비동기 작업 완료를 코디네이터와 연결해야 하는데 이는 여전히 수동입니다.

이어서 resume() 호출을 잊어버리면 작업이 영원히 대기하고 앱이 정지됩니다. 이를 주의해야합니다.

Some Learing curve

만약 스위프트의 동시성을 사용해보지 않았다면 조금 러닝커브가 있을 수 있습니다.

profile
hi there 👋

0개의 댓글