 Apple Lover Developer & Artist

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

 Apple/iOS Dev Challenges

[Challenge] 🛠️ iOS 앱 설계 퓨전 레시피 15부 - State Restoration

singularis7 2023. 4. 12. 14:56
반응형

Overview

State Restoration을 구현하여 사용자가 애플리케이션이 중단되었음을 인식하지 않도록 구현한다.

배경

아이폰을 사용하다보면 다양한 원인으로 인해 현재 사용하던 앱이 중단될 수 있다. 예를 들어, 특정 앱을 사용하던 중 전화나 메시지가 와서 이를 수신할 수 있다. 또한 동시에 여러 작업을 수행하기 위해 멀티태스킹 기능을 사용하는 경우도 있을 것이다.

사용자가 다른 작업을 처리한 뒤에 본래 처리하던 작업을 마무리하고자 돌아올 수 있다. 이때 iOS 운영체제의 자원 관리 정책으로 인해 앱이 종료될 수 있다. 이 경우 사용자는 작업의 맥락을 놓치고 다시 처음부터 시작해야 하는 문제가 발생될 수 있다. 결코 좋은 경험이 아니다.

State Restoration 기능을 구현하여 앱이 일시 중단된 상태에서 운영체제에 의해 종료되었을지라도 사용자 입장에서 앱이 재실행되었음을 인식하지 않고 기존에 처리하던 작업 맥락을 가져갈 수 있도록 만들어 주는 것이 목표이다.

Technology

State Restoration API

iOS13부터 State Restoration 과정은 UIWindowSceneDelegate에 의해 처리되며 NSUserActivity 클래스를 사용하여 달성할 수 있다. Apple Developer Document - UIKit: Restoring Your App’s State를 통해 자세한 변화를 확인해볼 수 있다. 이번 프로젝트에서는 Scene API를 사용하여 구현한다.

NSUserActivity API

NSUserActivity는 State Restoration, Hand off, Spotlight Search Indexing 및 SiriKit을 포함하여 Apple 플랫폼에서 다양한 기능을 가능하게 하는 경량 객체이다. 중요한 순간에 NSUserActivity 인스턴스를 만들어주어 특정 작업의 수행을 위한 문맥을 이 객체의 정보로 담아줄 수 있다. Apple Developer Document - Foundation: NSUserActivity를 참조하여 자세한 내용을 확인할 수 있다.

구현 기술 개요

OrderApp에 State Restoration 기능을 추가하기 위해 앞서 소개한 기술을 활용해 볼 것이다. 예를 들어, 현재 Order 주문서 정보를 갖고 있는 Restaurant Controller를 통해 NSUserActivity 인스턴스와 각종 View Controller 들을 다시 생성하는데 필요한 정보를 관리한다.

동작 방식을 소개한다. 사용자가 앱을 사용하는 동안, NSUserActivity의 userInfo라는 dict 타입의 프로퍼티를 통해 주요 정보를 추적할 수 있다. 현재 앱의 Scene이 iOS의 백그라운드로 전환되면 iOS는 다음의 Scene이 연결될 때 NSUserActivity 인스턴스를 사용하도록 요청한다. 백그라운드로 전환된 Scene이 다시 연결되면, 사용자의 작업 맥락을 지속할 수 있도록 앱의 상태를 재구축하는 데 사용할 수 있는 동일한 NSUserActivity 인스턴스가 제공된다.

Implement

1. Order 정보를 NSUserActivity와 연동한다

지금까지의 구현에서 모든 주문서 정보는 Restaurant Controller를 통해 관리되고 있다. NSUserActivity가 앱마다 제공되는 상태 정보를 유지할 수 있는 공간인 만큼, Order 앱이 중단 및 종료되더라도 주문서 정보가 유지될 수 있도록 NSUserActivity를 활용해 구현해 볼 것이다.

NSUserActivity의 dict 타입의 userInfo 프로퍼티를 통해 Order 정보를 관리해볼 것이다. 필자의 경우 NSUserActivity에 order로 네이밍한 연산 프로퍼티 getter와 setter를 생성하여 주문서 정보의 관리 과정을 추상화해 보았다.

extension NSUserActivity {

  var order: Order? {
      get {
          guard let jsonData = userInfo?[Keys.order.rawValue] as? Data else {
              return nil
          }

          return try? JSONDecoder().decode(Order.self, from: jsonData)
      }
      set {
          if let newValue = newValue, let jsonData = try? JSONEncoder().encode(newValue) {
              userInfo?[Keys.order.rawValue] = jsonData
          } else {
              userInfo?[Keys.order.rawValue] = nil
          }
      }
  }

}

Order 주문서 정보를 저장하기 위해 JSON을 활용하였다. JSON은 네트워크 통신에서도 자주 사용되는 데이터의 표시방식 중 하나이다. userInfo dict에 담을 수 있는 자료형이 Array, Data, Date, Dictionary, NSNumber, Set, String 혹은 URL 등이 해당되기에 Order 커스텀 타입을 담아주기 위하여 Codable 프로토콜을 활용하여 JSON 형식의 데이터로 변환시켜 준 것이다.

enum Keys: String {       
    case order
    case controllerIdentifier
    case menuCategory
    case menuItem
}

userInfo dict의 키 값을 열거형을 활용해 관리하고 있다. 코딩 편의상 String 리터럴 표기의 사용을 줄여 오타 등의 원인으로 생산성이 떨어지는 문제를 예방하기 위함이다. String 프로토콜을 채택하여 각 케이스의 네이밍이 raw value를 통해 String 타입으로 반환될 수 있도록 구현해 두었다.

NSUserActivity는 앱 별로 구분하여 상태 정보를 구분한다. 이를 위해 프로젝트의 번들 이름을 표현할 때 사용했던 DNS 형식의 String 값을 식별자로 사용한다. Restaurant Controller에서 NSUserActivity를 사용하기 위해 다음과 같은 코드를 구현해 주었다.

class RestaurantController {
  // ...
  let userActivity = NSUserActivity(activityType: "com.tistory.singularis7.OrderApp.order")

  var order = Order() {
      didSet {
          // ...
          userActivity.order = order
      }
  }
  // ...
}

현재 앱을 기준으로 userActivity 공간을 유지하는데 앞서 구현한 extension을 통해 order 정보를 facade 하도록 관리할 수 있게 되었기 때문에 위와 같은 코드를 작성할 수 있게 되었다. 이제 restaurant controller의 order 값이 변경되면 기존에 구현된 값과 더불어 userActivity를 통해 상태 정보도 persistent 된다.

2. Scene Delegate를 통해 State Restoration 절차를 등록한다.

iOS13 이후로 아이패드에서 여러 개의 Scene 인스턴스를 생성할 수 있게 되었다. 화면에 보이는 UI 인스턴스의 생명주기 이벤트는 기존의 App Delegate에서 Scene Delegate로 이관되어 제어할 수 있도록 변경되었다.

Scene Delegate에서 State Restoration 절차를 제어할 수 있도록 두 가지 위임 메서드를 활용해보고자 한다.

우선 stateRestorationActivity(for:)이다. 이 메서드는 Scene이 Background로 진입할 때, UIKit이 호출한다. 역할은 Scene이 재연결될 때 시스템으로 부터 다시 반환받을 NSUserActivity를 넘겨주는 것이다.

다음으로 scene(_:restoreInteractionStateWith:)이다. 이 메서드는 Scene이 재연결되고 스토리보드와 view들이 로딩된 후이자 화면에 보이기 전에 호출된다. 여기서 restoreInteractionStateWith 파라미터는 stateRestorationActivityFor 메서드를 통해 시스템에 넘겨준 userActivity를 의미한다.

주문서 정보의 복원

NSUserActivity 인스턴스를 UIKit 시스템을 통해 저장하고 불러오기 위해 다음과 같은 코드를 구현하였다.

// MARK: State Restoration Handling Code
extension SceneDelegate {  
  func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {
      return RestaurantController.shared.userActivity
  }

  func scene(_ scene: UIScene, restoreInteractionStateWith stateRestorationActivity: NSUserActivity) {
      if let restoredOrder = stateRestorationActivity.order {
          RestaurantController.shared.restore(order: restoredOrder)
      }
  }
}

restore 과정을 명시적으로 코딩하고자 restaurant controller에 restore 메서드를 구현해 줬다. 이 메서드가 하는 행위는 간단하다. 파라미터로 넘겨준 order를 restaurant controller의 싱글턴 인스턴스의 order에 담아주는 역할이다. 이를 통해 시스템에 persist 된 NSUserActivity에 담긴 order와 동기화시킬 수 있게 되었다.

이 시점에서 앱이 종료되어도 Order 정보는 유지된다. State Restoration 기능이 잘 동작하는지 확인할 수 있다. 앱을 실행하던 중 아이폰의 홈화면으로 나간 후 Xcode를 통해 앱을 중단시키고 다시 앱 아이콘을 터치해 실행시키면 된다. 주문서 화면에 들어갔을 때 이전에 담아둔 주문서 명단이 없어지지 않았다면 state restoration 기능이 정상적으로 동작한 것이다.

Note!

앱의 디버깅 과정에서 확인할 점이 있다. 초기에 홈화면으로 나가지 않는다면 상태 복원 메서드도 호출되지 않는다는 점이다. 이는 앱의 상태가 적절히 복원되지 않는 현상으로 이어진다. 또한 생각해야 할 점이 있다. 앱에서 크래쉬가 발생하면 상태복원은 동작하지 않는다. 앱이 재실행되었을 때 나쁜 상태로 복원되어 다시 크래쉬가 생기지 않도록 하기 위함이다.

3. StoryBoard를 갱신한다

Order 앱에서 주문서 명단을 관리하던 중 긴 시간 동안 다른 작업을 처리하고 다시 Order 앱으로 돌아왔다. 기존의 구현에서는 주문서에 한정한 앱의 상태 복원 과정을 구현했기에 앱이 처음 실행될 때의 화면 상태로 되돌아가게 된다. 사용자의 입장에서 원래 처리하던 작업 맥락을 찾기 위해 화면 계층을 탐색하는 일은 결코 반가운 일은 아닐 것이다. 앱의 workflow를 생각하며 사용자가 작업하던 화면의 맥락도 보존해 보도록 구현해 보자!

사용자 인터페이스의 복원

프로젝트에서 앱에서 발생할 대부분의 워크 플로우는 StoryBoard를 통해 구현했다. View Controller 오브젝트를 통해 화면 단위를 구성하고 Segue를 통해 화면 전환을 구현했다. 만약 상태 복원 기능을 통해 앱의 인터페이스 상태를 복원하고자 한다면 스토리보드뿐만 아니라 프로그래밍 적으로 화면 계층을 구성할 수 있어야 한다.

문뜩, ViewController 객체의 인스턴스를 생성해 주는 방식으로 구현 가능하지 않는가?라는 생각이 들 수 있다. 문제는 View Controller가 스토리보드 파일에 아카이브 된 사용자 인터페이스 정보를 불러와서 생성되기에 생성자의 파라미터로 NSCoder를 받는다는 점이다.

방법이 아예 없지는 않다. 이제까지는 스토리보드 파일에서 Xcode의 인터페이스 빌더가 제공하는 스튜디오 편집기 환경에서만 작업했기에 경험하기 어려웠을 수 있다. UIKit은 스토리보드를 프로그래밍적으로 핸들링할 수 있는 수단을 만들어두었기에 쉽게 해결할 수 있다. 객체의 이름은 UIStoryboard이다.

UIStoryboard가 제공하는 핵심 기능 중 하나는 바로 스토리보드 파일에 정의된 View Controller 객체를 생성시켜 주는 역할이다. 이 기능을 사용하려면 스토리보드 파일의 ViewController 오브젝트에 식별자를 붙여줘야 한다. 스토리보드 파일을 열어 우측에 Identity Inspector를 열면 Storyboard ID를 입력할 수 있는 텍스트 필드가 보이는데 위와 같은 용도로 사용되는 친구이다.

img

UI 상태 복원이 필요한 화면에 대해서 Storyboard ID를 지정해 줬다. 대상은 음식 메뉴의 종류를 보여주는 Menu Table View Controller와 특정 음식 메뉴의 세부 정보를 보여주는 Menu Item Detail View Controller이다. 이들을 각각 menumenuItemDetail로 식별자를 설정해 주었다.

Xcode 우측 패널에 눈에 띄는 텍스트 필드가 있다. 이는 iOS12 이전에 상태 복원 과정에서 사용되어 온 식별자이다. 이번에는 iOS13 이후 Scene 기반의 API를 사용하기에 이 정보는 무시해도 좋다.

여기까지 오면 스토리보드 상의 오브젝트를 프로그래밍적으로 생성하여 핸들링할 수 있는 준비가 되었다.

4. View Controller의 상태를 보존한다

앱의 사용자 인터페이스 상태를 정리할 필요가 있다. 상태 복원 과정에서 어떤 화면을 보여줄지 그리고 어떤 값들이 필요한지 생각해봐야 하기 때문이다. 필자가 개발 중인 Order 앱의 경우 다음과 같은 상태를 도출해 볼 수 있다.

img

각 화면에 대응되는 ViewController 클래스와 생성 시 요구되는 파라미터 그리고 스토리보드 클래스를 통해 해당 ViewController의 인스턴스를 생성하는 데 사용할 식별자로 구분되어 표가 도출되었다.

사용자 인터페이스의 상태를 모델링하기 앞서 참조해 볼 애플 아티클 Maintaining State in Your Apps을 소개한다. 골자는 앱의 상태를 캡처하고 추적하기 위해 enumeration을 사용하라는 것이다. 왜냐면 enum을 통해 유한한 상태를 정의할 수 있고 각 상태와 연관된 값들을 묶을(bundle) 수 있기 때문이다. 특히나 상태를 여러 변수에 퍼트려서 관리하는 것은 버그를 유발하고 코드를 이해하기 어렵도록 만들기 때문에 주의해야 한다.

위 표에 언급된 UI 상태를 enum을 활용해 다음과 같이 구현해 보았다.

enum StateRestorationController {
  case categories
  case menu(category: String)
  case menuItemDetail(MenuItem)
  case order
}

각 ViewController는 case로 정의되었으며 생성 시 외부 값이 필요한 경우 연관값을 갖도록 구현했다. 각 ViewController의 상태는 NSUSerActivity의 userInfo 키값을 통해 관리되기에 상태를 String 등의 식별자를 갖도록 구현할 필요가 있다. 다음과 같이 구현해 보았다.

enum StateRestorationController {
  enum Identifier: String {
    case categories, menu, menuItemDetail, order
  }
}

nested type으로 선언하여 Identifier의 네임 스페이스를 한정시켜 주었다. 다른 식별자 개념과 구분하기 위함이다. Identifier 열거형 타입의 개별 case가 String 타입의 rawvalue를 갖도록 설정해 주었다.

NSUserActivity에서 ViewController의 식별자로 사용하기 위해 연산 프로퍼티를 추가해야 한다. StateRestorationController를 통해 현재 UI 상태를 의미하는 case 인스턴스가 생성되면 이를 지칭하는 identifier도 반환할 수 있어야 하기 때문이다. 다음과 같은 연산 프로퍼티의 구현을 추가해 주었다.

var identifier: Identifier {
  switch self {
  case .categories:
      return .categories
  case .menu:
      return .menu
  case .menuItemDetail:
      return .menuItemDetail
  case .order:
      return .order
  }
}

이제 UI의 상태의 모델링 그리고 Storyboard와의 연동을 위한 identifier까지 구현하였다. 다음은 order를 NSUserActivity에 저장한 것과 유사한 패턴을 사용해 UI 상태도 NSUserActivity의 userInfo dict에 담아주면 된다.

order와 마찬가지로 NSUserActivity에서 정보를 불러오고 저장해 줄 Fascade 인터페이스를 만들어줄 것이다. 구현 방식은 연산 프로퍼티의 getter와 setter를 설정해 주는 것이다.

3가지 종류의 프로퍼티를 구현할 것이다. 사용자가 작업하던 화면을 식별할 controllerIdentifier 화면 전환 시 필요한 값을 담은 menuCategorymenuItem이 해당된다. 코드는 다음과 같다.

extension NSUserActivity {

    var order: Order? {
        // ...
    }

    var controllerIdentifier: StateRestorationController.Identifier? {
        get {
            if let controllerIdentifierString = userInfo?[Keys.controllerIdentifier.rawValue] as? String {
                return .init(rawValue: controllerIdentifierString)
            } else {
                return nil
            }
        }
        set {
            userInfo?[Keys.controllerIdentifier.rawValue] = newValue?.rawValue
        }
    }

    var menuCategory: String? {
        get {
            return userInfo?[Keys.menuCategory.rawValue] as? String
        }
        set {
            userInfo?[Keys.menuCategory.rawValue] = newValue
        }
    }

    var menuItem: MenuItem? {
        get {
            guard let jsonData = userInfo?[Keys.menuItem.rawValue] as? Data else {
                return nil
            }
            return try? JSONDecoder().decode(MenuItem.self, from: jsonData)
        }
        set {
            if let newValue = newValue, let jsonData = try? JSONEncoder().encode(newValue) {
                userInfo?[Keys.menuItem.rawValue] = jsonData
            } else {
                userInfo?[Keys.menuItem.rawValue] = nil
            }
        }
    }

    enum Keys: String {
        case order
        case controllerIdentifier
        case menuCategory
        case menuItem
    }

}

UI 상태 저장하기

이제 UI 상태가 변경될 때마다 NSUserActivity와 상태를 동기화해 주면 된다. 아이디어는 화면이 전환될 때마다 NSUserActivity를 갱신해 주는 것이다. 좀 더 구체적으로 말하자면 화면이 전환될 때 View 계층이 바뀌기 때문에 View 라이프사이클 이벤트를 받을 수 있다. 이들 중 viewdidload 호출 시점에서 userActivity를 갱신해 줄 것인데, 세부적인 논리들은 restaurant controller에 updateUserActivity 메서드로 추상화시켜둘 것이다. 각 view controller에선 이 메서드를 활용해 상태를 갱신해주기만 하면 된다.

func updateUserActivity(with controller: StateRestorationController) {
    switch controller {
    case .menu(let category):
        userActivity.menuCategory = category
    case .menuItemDetail(let menuItem):
        userActivity.menuItem = menuItem
    case .order, .categories:
        break
    }

    userActivity.controllerIdentifier = controller.identifier
}

위 메서드의 사용 사례를 소개한다. 앱에서 특정 카테고리를 선택했을 때 다음과 같이 UI 상태를 갱신해줄 수 있다. 다른 View controller에서도 동일한 방법으로 사용할 수 있다.

final class OrderTableViewController: UITableViewController {
  // ...
  override func viewDidLoad() {
      super.viewDidLoad()
      configureUI()
      restaurantController.updateUserActivity(with: .order)
  }
  // ...
}

UI 상태 복원하기

이제 모델링 된 UI상태가 NSUserActivity에 저장되어 있다. NSUserActivity는 위에서 소개한 Scene API를 통해 앱의 실행 상태에 따라 시스템에 저장되거나 불러오도록 동작할 것이다.

앞서 구현한 모델이 동작하려면 NSUserActivity를 불러올 때, 데이터로부터 UI상태를 모델로 불러들이는 작업이 필요하다. 이를 위해 StateRestorationController에서 NSUserActivity를 참조해 상태 인스턴스를 찍어낼 수 있도록 구현해줘야 한다. 필자는 다음과 같은 생성자를 새롭게 구현해 주었다.

enum StateRestorationController {
    init?(userActivity: NSUserActivity) {
        guard let identifier = userActivity.controllerIdentifier else {
            return nil
        }

        switch identifier {
        case .categories:
            self = .categories
        case .menu:
            if let category = userActivity.menuCategory {
                self = .menu(category: category)
            } else {
                return nil
            }
        case .menuItemDetail:
            if let menuItem = userActivity.menuItem {
                self = .menuItemDetail(menuItem)
            } else {
                return nil
            }
        case .order:
            self = .order
        }
    }
}

이제 NSUserActivity에 담긴 UI 상태 데이터의 해석을 바탕으로 화면 계층을 적절히 설정해주기만 하면 된다. order 주문서 정보의 복원에도 활용했던 Scene Delegate의 Scene restoreInteractionStateWith 메서드를 사용하여 구현할 수 있다.

화면의 탐색 순서 등을 포함하여 앱의 워크플로우에 맞추어 View 계층을 설정해 주면 된다. 여기서 VIewController를 생성하기 위해 UIStoryboard API를 사용하고 앞서 적용한 Storyboard ID를 사용하게 된다. 퍼즐이 풀리는데 긴 시간이 걸린 것 같다! 필자는 다음과 같이 구현해 보았다.

func scene(_ scene: UIScene, restoreInteractionStateWith stateRestorationActivity: NSUserActivity) {
    // ...

  guard let restorationController = StateRestorationController(userActivity: stateRestorationActivity),
        let tabBarController = window?.rootViewController as? UITabBarController,
            tabBarController.viewControllers?.count == 2,
        let categoryTableViewController = (tabBarController.viewControllers?.first as? UINavigationController)?.topViewController as? CategoryTableViewController else {
      return
  }

  let storyboard = UIStoryboard(name: "Main", bundle: nil)

  switch restorationController {
  case .categories:
      break
  case .menu(let category):
      let viewController = storyboard.instantiateViewController(identifier: "menu") {
          return MenuTableViewController(coder: $0, category: category)
      }
      categoryTableViewController.navigationController?
          .pushViewController(viewController, animated: true)
  case .menuItemDetail(let menuItem):
      let menuViewController = storyboard.instantiateViewController(identifier: "menu") {
          return MenuTableViewController(coder: $0, category: menuItem.category)
      }
      let menuDetailViewController = storyboard.instantiateViewController(identifier: "menuItemDetail") {
          return MenuItemDetailViewController(coder: $0, menuItem: menuItem)
      }
      categoryTableViewController.navigationController?
          .pushViewController(menuViewController, animated: false)
      categoryTableViewController.navigationController?
          .pushViewController(menuDetailViewController, animated: false)
  case .order:
      tabBarController.selectedIndex = 1
  }

}

마무리

State Restoration을 구현하여 사용자가 애플리케이션이 중단되었음을 인식하지 않도록 구현해 보았다. 지난,15부에 걸쳐서 iOS와 UIKit의 현대적인 API를 활용하여 중요한 컨셉들을 구현해보았다. 네트워크 통신, 모델링, View 관리, 동시성 등의 중요한 개념들을 말이다.

이것으로 iOS 앱 설계 퓨전 레시피의 첫 번째 연재를 마치고자 한다. 다음의 연재는 주문서 앱을 2019년 이후 사용되는 현대적인 API를 좀 더 적극적으로 도입하여 현재의 앱을 리팩터링 해보는 과정을 진행해 보는 것이 목표이다.

장편 연재라기보다는 작은 단위의 포스팅으로 이어질 것 같은데... 모르겠다... 지금처럼 앞으로 해나가야지 뭐 😄😄

반응형