 Apple Lover Developer & Artist

영속적인 디자인에 현대의 공감을 채워넣는 공방입니다

 Apple/iOS Dev Challenges

[Challenge] 🛠️ iOS 앱 설계 퓨전 레시피 12부 - Order

singularis7 2023. 3. 29. 17:12
반응형

Overview

음식점 서버에 주문서를 전송한다. Timer와 Combine API를 활용하여 Progressive Bar 인터페이스를 갱신해 본다. 페이지 전환 방식으로써 모달을 사용해 본다.

Order Confirmation

Order 앱은 음식점 서버에 주문을 넣을 수 있다. Order 주문서에는 음식 메뉴 아이템으로 구성되어 있는다. 음식점 서버는 음식 메뉴 아이템에 따라 준비시간이 다를 수 있다. 서버의 역할은 사용자의 주문이 들어올 때마다 음식이 준비될 때까지 필요한 시간을 계산하여 사용자에게 알려주는 것이다.

이전에 준비해 둔 스토리보드에는 Order Confirmation이라는 화면 페이지가 있었다. 이 화면의 역할은 음식점 서버가 알려준 음식 준비 시간과 주문을 넣은 시간을 기준으로 현재 시각에 따라 음식의 준비 진행률을 보여준다.

Technique

네트워크 통신을 할 수 있어야 한다. 음식의 주문과정에서 음식메뉴 아이템을 음식점 서버에 전달할 수 있어야 한다. 이전 포스팅에서 구현한 네트워크 모델링에서 Restaurant Controller 객체를 통해 주문 API가 연동되어 있다. 이를 활용하여 주문서 제출 기능을 구현할 것이다.

화면 전환 기법으로써 Modal을 사용할 수 있어야 한다. 사전에서 찾아보면 모달은 mode의 형용사 표현으로 특정 작업을 위한 방식으로 해석할 수 있다. 애플의 HIG에 따르면 일종의 디자인 기법으로 소개된다. 화면에 콘텐츠를 구분되고 집중할 수 있는 모드로 보여준다. 이때 부모 뷰와의 상호 작용을 제한하고 명시적인 dismiss 액션을 요구한다. Order Confirmation 화면을 모달 기법을 사용해 전환하는 이유는 주문하기 전에 주문서 명단을 조작하던 맥락을 가져가며 사용자에게 음식 준비 시간이라는 중요 정보를 전달하기 위함이다. 자세한 내용은 HIG: Modality를 살펴보면 좋다. 이를 활용해 Order 화면에서 Order confirmation 화면으로 넘어가는 화면 전환 기능을 구현할 것이다.

실시간으로 UI를 갱신하는 기능을 구현하기 위해 Timer를 사용할 수 있어야 한다. Timer는 OS 스레드와 run loop 위에서 동작하여 특정 시간 단위로 이벤트를 발생시켜 준다. 자세한 내용은 Apple Developer: Foundation-Timer를 살펴보면 좋다. 특히, 이번 구현에서는 Combine API를 접목시킬 것이다. 비동기 이벤트를 다루는 객체에 대해 통합된 인터페이스를 사용하여 코드 생산성을 개선시키기 위함이다. 콤바인에 대한 기본기는 WWDC2019: Introducing to Combine를 살펴보기를 권장한다.

구현

Order Scene

img

Order에서 Order Confirmation 화면으로 전환할 때는 어떤 작업을 수행해야 할까? 우선 서버에 주문서 명단을 전달해야 한다. 주문이 완료되고 음식점으로부터 음식의 준비 대기 시간을 반환받아야 한다. 다음으로 음식점 서버로 부터 받아온 대기시간 정보를 갖고 Order Confirmation을 띄워줘야 한다. 이때 화면 전환 기법은 위에서 언급한 사유로 Modal을 사용한다. Order Confirmartion 화면에서는 Timer를 활용해 Progressive Bar 사용자 인터페이스를 갱신해주어야 한다.

스토리보드 세그웨이

이번 프로젝트에서 화면 전환 코드는 스토리보드에서 관리되고 있다. 지금도 마찬가지로 스토리보드에서 작업한다. 방법은 Order 화면과 Order Confirmation 화면 간에 세그웨이를 연결해 주는 것이다. 화면 생성 패턴은 데이터바인딩 포스팅에서 집중적으로 다루었으니 참조 바란다.

화면의 전환 과정에서 음식점 서버와의 네트워크 통신 과정이 이루어져야 한다. 화면 전환 과정을 프로그래밍적으로 구현하기 위해 ViewController 간에 세그웨이를 연결해 준다. 세그웨이의 종류는 Present Modally로 설정해 준다. 세그웨이를 프로그래밍적으로 식별하기 위해 식별자를 부여해줘야 한다. 필자의 경우 confirmOrder로 설정해 주었다. 이제 프로그래밍적으로 세그웨이를 트리거할 수 있는 준비가 되었다.

Submit Action

음식의 주문은 Order 화면에서 시작된다. 주문을 넣어주는 기능을 구현하기 위해 Navigation Bar 우측에 Submit Bar Button Item을 생성해 주었다. OrderTableViewController 객체에서는 Submit이 클릭되었을 때 수행할 작업을 구현해 준다.

@IBAction private func submitTapped(_ sender: UIBarButtonItem) {
  let orderTotal = restaurantController.totalAmount
  let formattedTotal = orderTotal.formatted(.currency(code: "usd"))

  let alert = UIAlertController(
      title: "Confirm Order",
      message: "You are about to submit your order with a total of \(formattedTotal)",
      preferredStyle: .actionSheet
  )
  alert.addAction(
      UIAlertAction(title: "Submit", style: .default) { _ in
          self.uploadOrder()
      }
  )
  alert.addAction(
      UIAlertAction(title: "Cancel", style: .cancel)
  )

  present(alert, animated: true)
}

Submit 버튼을 터치하면 알림 창이 뜬다. 알림 창에는 음식점에 주문을 넣을지 확인하는 내용이 담겨있다. 사용자가 승인하면 Submit UIAlertAction에 담긴 클로저가 실행된다. 이 클로저에는 네트워크 통신을 사용해 음식점 서버에 주문을 전달하는 코드가 담긴다.

private func uploadOrder() {
  let menuIds = restaurantController.order.menuItems.map { $0.id }
  Task {
      do {
          let minutesToPrepare = try await restaurantController.submitOrder(forMenuIDs: menuIds)
          minutesToPrepareOrder = minutesToPrepare
          performSegue(withIdentifier: "confirmOrder", sender: nil)
      } catch {
          displayError(error, title: "Order Submission Failed")
      }
  }
}

uploadOrder 메서드에 구현이 담겨있다. 내용은 restaurant controller의 submit api를 비동기 호출하여 서버로부터 시간을 받아온 후 스토리보드에 구현한 confirmOrder세그웨이를 트리거 시킨다. 네트워크 통신 과정에서 문제가 생기면 사용자에게 오류 내용을 담은 알림 창을 띄어준다.

confirmOrder 세그웨이를 트리거 시킬 때 전환될 화면을 의미하는 ViewController의 생성 책임은 개발자에게 있다. 객체를 생성해 주기 위해 스토리보드의 confirmOrder 세그웨이를 ViewController 스위프트 파일로 끌어와서 IBSegueAction을 만들어준다. 여기서 구현할 작업은 간단하다. OrderConfirmationViewController 인스턴스를 생성해서 반환하는 것뿐이다. 생성자에 음식점 서버가 반환한 준비 시간을 주입시켜 준다.

@IBSegueAction private func confirmOrder(_ coder: NSCoder) -> OrderConfirmationViewController? {
  return OrderConfirmationViewController(coder: coder, minutesToPrepare: minutesToPrepareOrder)
}

마지막으로 OrderConfirmation 작업이 완료되어 사용자가 Order 화면으로 돌아올 때 주문서에 담아둔 내역이 남아있지 않도록 처리해줘야 한다. OrderViewController 스위프트 파일에 unwind 액션을 제공해 주어 모달창이 종료된 후 다시 Order 화면으로 돌아올 때 주문서에 담긴 내역이 제거되도록 코딩해 주었다.

@IBAction private func unwindToOrderList(segue: UIStoryboardSegue) {
  if segue.identifier == "dismissConfirmation" {
      restaurantController.deleteAllOrder()
  }
}

Order confirmation Scene

img

화면에는 총 3가지의 사용자 인터페이스가 보인다. 첫 번째는 UIProgressive Bar이다. 음식의 준비 진행률을 보여주는 역할을 한다. 두 번째는 UILabel이다. 음식이 준비되기까지 소요되는 시간을 숫자를 포함한 문장으로 보여주는 역할을 한다. 세 번째는 Dismiss UIButton이다. Modal 창을 종료하고 이전 작업 콘텍스트로 돌아갈 수 있는 수단을 제공해 준다.

이 화면의 구현은 MVVM을 활용한 계층적 구조로 구현되어 있어서 ViewController는 View의 역할을 수행하고 ViewModel과 Model 객체에 주요 논리가 구현되었다. 예를 들어 UI 컴포넌트들은 OrderConfirmationViewController 스위프트 파일에 IBOutlet으로 연결되어 통제된다. ViewModel에서 Timer를 구동시켜 특정 시간 간격으로 비동기 이벤트를 발생시킨다. Timer 이벤트를 기준으로 음식의 준비 진행률 값을 계산하고 값이 변화할 때마다 ViewController에 objectWillChange 신호를 보낸다. ViewController가 이 신호를 받으면 화면을 갱신한다. 화면을 갱신하는 작업에는 새롭게 계산된 진행률 값을 ViewModel로부터 불러와 화면에 반영시키는 역할을 수행한다. MVVM 구조에 대한 자세한 설명은 책임의 밀당 포스팅을 참조 바란다. 이제 개요를 보았으니 구현을 해본다.

OrderConfirmation Model

struct OrderConfirmation {

    let minutesToPrepare: Int
    var timeRatio: Double = 0.0

    private let startingOrderDate = Date()

    var remainTimeRatio: Double {
        orderProgressedTime / timeIntervalToPrepare
    }

    private var timeIntervalToPrepare: TimeInterval {
        TimeInterval(minutesToPrepare)
    }

    private var orderProgressedTime: TimeInterval {
        abs(startingOrderDate.timeIntervalSinceNow)
    }

    mutating func updateTimeRatio() {
        timeRatio = remainTimeRatio
    }

}

시작 시각과 음식의 준비시간을 가지고 진행률을 계산해 주는 데이터와 로직이 담겨있다. 객체의 identity를 식별할 필요가 없어서 struct로 구현해 주었다. 즉, 값 타입을 갖음과 동일한 의미이다.

final class OrderConfirmationViewModel: ObservableObject {

  @Published private var orderConfirmation: OrderConfirmation

  private var scheduledTimerSubscriber: Cancellable?
  private let updateCycle: TimeInterval = 0.0001

  init(minutesToPrepare: Int) {
      self.orderConfirmation = OrderConfirmation(
          minutesToPrepare: minutesToPrepare)
      self.subscribeTimer()
  }

  var remainTimeRatio: Float {
      Float(orderConfirmation.remainTimeRatio)
  }

  var minutesToPrepare: Int {
      orderConfirmation.minutesToPrepare
  }

  private func subscribeTimer() {
      scheduledTimerSubscriber = Timer.publish(every: updateCycle, on: .main, in: .default)
        .autoconnect()
        .sink(receiveValue: { [weak self] _ in
            if let remainTimeRatio = self?.remainTimeRatio, remainTimeRatio > 1.0 {
                self?.unsubscribe()
            } else {
                self?.updateRemainTime()
            }
        })
  }

  private func updateRemainTime() {
      orderConfirmation.updateTimeRatio()
  }

  private func unsubscribe() {
      scheduledTimerSubscriber?.cancel()
  }

  deinit {
      unsubscribe()
  }

}

ViewModel 코드이다. 앞서 구현한 Order Confirmation 모델 값타입을 들고 있다. ViewModel이 생성될 때 콤바인 API를 활용해 구독 관계를 수립한다. 첫 번째 구독 관계는 Timer 객체를 통해 일정 시간 간격으로 비동기 이벤트를 발생시키기 위함이다. 두 번째는 ViewModel 자체가 콤바인 퍼블리셔 역할을 담당할 수 있도록 ObservableObject로 만들어 준 것이다. 하나씩 살펴보자!

Timer 객체를 활용해 비동기 이벤트를 발생시키는 이유는 일정 시간 간격으로 음식 준비 절차의 진행률을 계산하기 위함이다. 콤바인 API 통해 timer 객체의 퍼블리셔를 구독한다. 일정 시간마다 시각값과 함께 receiveValue 클로저가 호출된다. 음식의 준비과정이 완료되면 즉, 진행률이 100% 이상을 달성하면 구독 관계를 종료시켜 불필요한 연산을 없앤다. 그렇지 않으면 계속 진행률을 계산해 준다.

진행률 값은 앞서 구현한 Order Confirmation 객체에 담겨있다. 진행률 값의 계산은 이 객체 내부에서 이뤄지는데, 진행률의 값이 변하면 OrderConfirmation 인스턴스의 값 또한 변화한다. orderConfirmation 프로퍼티를 감싼 @Published Property Wrapper는 이 점을 활용해 ViewModel의 외부에 이벤트를 발생시킨다. 내부적으로 프로퍼티 옵서버를 통해 값의 변경을 감시하다가 이를 감지하면 ViewModel의 구독자에게 objectWillChange 이벤트를 쏴준다.

위와 같은 구현 과정에서 메모리 누수가 발생하지 않도록 테크닉이 적용되었다. 예를 들어 사용자가 OrderConfirmation 화면을 종료한 경우를 생각해 본다. ViewController가 메모리에서 제거되며 ARC 규칙에 의해 ViewModel 인스턴스도 메모리에서 제거될 것이다. 이때 구독 관계가 메모리에서 함께 제거되어 메모리가 누수되지 않도록 처리해줘야 한다. 필자는 ViewModel의 인스턴스가 deinit 되거나 더 이상 연산을 수행할 필요가 없을 때 구독 관계를 제거해 주는 것을 챙겼다. 정리하자면 구독이 성립하면 구독이 종료되는 시점을 명시적으로 알 수 있도록 코딩했다. 또한 어느 클로저와 마찬가지로 비동기 처리 중 self 인스턴스를 캡처리스트를 활용한 weak로 넘겨주어 강한 참조 사이클을 예방하는 것이 대표적인 예시이다.

OrderConfirmationViewController

데이터와 로직 그리고 콤바인 API를 활용한 구독 관계를 수립하기 위해 앞서 소개한 방식을 통해 준비해 두었다. 이제 View는 objectWillChange 이벤트를 받을 때마다 데이터를 어떻게 화면에 반영시킬지만 고민하면 된다.

이벤트를 받아오려면 구독 관계를 수립해야 한다 방법은 어렵지 않다. ViewModel의 퍼블리셔를 통해 subscription을 수립하여 ViewController가 들고 있으면 된다. 필자는 ViewController의 라이프사이클 중 사용자 인터페이스는 메모리에 올라왔으나 화면에 띄우기 전 상태를 의미하는 viewdidload에서 구독 관계를 설정해 주었다. 구현은 다음과 같다.

override func viewDidLoad() {
    super.viewDidLoad()
    configureSubscription()
    updateUI(with: viewModel.minutesToPrepare, progress: .zero)
}

configureSubscription 메서드에는 다음과 같이 구독 관계를 수립하는 코드가 담겨 있다.

private func configureSubscription() {
    let viewModelSubscribe = viewModel.objectWillChange
        .sink { [weak self] _ in
            self?.reloadUI()
        }

    let sceneSubscribe = NotificationCenter.default.publisher(for: UIScene.didActivateNotification)
        .sink { [weak self] _ in
            self?.reloadUI()
        }

    subscribes.append(contentsOf: [viewModelSubscribe, sceneSubscribe])
}

viewModel 뿐만 아니라 Scene 이벤트도 수신하는 모습을 보여주고 있다. View 라이프사이클 이벤트는 View 계층이 변경될 때마다 이벤트가 발생되는데 사용자가 아이폰에서 앱 간에 이동하거나 홈화면으로 나가는 등 앱의 전적인 사용자 인터페이스의 변동 사항을 감지할 수 없다. Scene 이벤트를 통해 이와 같은 이벤트를 핸들링할 수 있기에 구독 관계가 수립되었다. 앱 간의 전환이 발생해도 이벤트를 받아서 사용자 이벤트를 갱신할 수 있게 되었다.

View Controller는 View의 역할을 담당하기 때문에 display 로직이 꼭 위치해있어야 한다. 필자는 다음과 같은 두 메서드로 나누어 구현해 보았다.

private func reloadUI() {
  let remainRatio = viewModel.remainTimeRatio
  let minutesToPrepare = viewModel.minutesToPrepare
  updateUI(with: minutesToPrepare, progress: remainRatio)
}

private func updateUI(with remainTime: Int, progress: Float) {
  confirmationLabel?.text = "Thank you for your order! Your wait time is approximately \(remainTime) minutes."
  timeProgressiveView?.setProgress(progress, animated: true)
}

메모리 누수를 방지하기 위해 Order Confirmation 화면을 탈출하면 구독 관계를 정리해 주었다.

override func viewDidDisappear(_ animated: Bool) {
  subscribes.forEach { $0.cancel() }
}

마무리

음식점 서버에 주문서를 전송해 보았다. Timer와 Combine API를 활용하여 Progressive Bar 인터페이스를 갱신해 보았다. 페이지 전환 방식으로써 모달을 사용해 보았다. 메모리 관리를 중심으로 구독 관계를 코딩해 보았다.

여기까지 구현하면 앱이 그럴듯하게 동작한다. 앞으로 진행될 이야기는 UIKit의 현대적인 API를 활용하는 방향으로 코드를 리팩터링 하고 부가적인 사용자 편의 기능을 구현하는 것이다. 대표적으로 User Notification, state restoration 등을 생각해 볼 수 있다.

부가 기능 씹어먹기! 기대해도 좋다! 상당히 재미있다 😁😁😁 언제나 포스팅을 봐주셔서 고맙습니다.

반응형