https://github.com/1aborInModeration/OneMoreMinute
해당 프로젝트에서 맡은 개발 사항은 스톱워치 화면과 그 기능이다. 처음에는 StopwatchViewController
, StopwatchViewModel
등 담당한 파일 안에서만 작업을 했으나, 앱이 백그라운드로 가거나 종료된 경우에도 스톱워치 기능이 잘 작동하도록 구현하려다보니 앱(Scene)의 생명주기와 함께 로직이 움직이도록 구현해야 했다. 이를 위한 상태 저장은 UserDefaults 데이터를 관리하여 구현하였다.
SceneDelegate에서 StopwatchViewModel의 인스턴스를 생성하고, 각 생명주기 메서드에 로직을 구현하였다.
SceneDelegate에서 MainTabBarViewController 인스턴스를 생성하고, MainTabBarViewController에서는 childVC로서 StopwatchViewController 인스턴스를 생성하여 관리하는데, 이 StopwatchViewController까지 SceneDelegate에서 생성한 StopwatchViewModel의 의존성을 주입해주었다.
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private let stopwatchViewModel = StopwatchViewModel()
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let scene = (scene as? UIWindowScene) else { return }
stopwatchViewModel.restoreLaps()
let mainTabBarController = MainTabBarController(stopwatchViewModel: stopwatchViewModel)
let window = UIWindow(windowScene: scene)
window.rootViewController = mainTabBarController
self.window = window
window.makeKeyAndVisible()
}
func sceneWillEnterForeground(_ scene: UIScene) {
stopwatchViewModel.restoreState()
if stopwatchViewModel.isRunningRelay.value {
stopwatchViewModel.startTimer()
}
}
func sceneDidEnterBackground(_ scene: UIScene) {
stopwatchViewModel.saveState()
stopwatchViewModel.saveLaps()
}
}
class MainTabBarController: UIViewController {
private let stopwatchViewModel: StopwatchViewModelProtocol
private let childVCList: [UIViewController]
init(stopwatchViewModel: StopwatchViewModelProtocol) {
self.stopwatchViewModel = stopwatchViewModel
// 각 탭에 해당하는 자식 뷰 컨트롤러 리스트
self.childVCList = [
AlarmViewController(),
WorldTimeViewController(),
StopwatchViewController(viewModel: stopwatchViewModel),
TimerViewController()
]
super.init(nibName: nil, bundle: nil)
}
}
위와 같은 코드로 구현하고자 한 기능은 잘 작동했지만, 의존성 주입에 의해 코드 구조가 복잡해졌고 ViewModel과 SceneDelegate의 역할 분리가 모호해졌다.
의존성 주입에 의해 코드가 복잡해지는 것을 해결하기 위해 ViewModelProvider
라는 전역 클래스를 구현하였다.
protocol ViewModelProviding {
var stopwatchViewModel: StopwatchViewModel { get }
}
final class ViewModelProvider: ViewModelProviding {
static let shared = ViewModelProvider()
lazy var stopwatchViewModel = StopwatchViewModel()
}
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private let viewModelProvider: ViewModelProviding
override init() {
self.viewModelProvider = ViewModelProvider.shared
super.init()
print("SceneDelegate initialized")
}
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let scene = (scene as? UIWindowScene) else { return }
viewModelProvider.stopwatchViewModel.restoreLaps()
let mainTabBarController = MainTabBarController()
let window = UIWindow(windowScene: scene)
window.rootViewController = mainTabBarController
self.window = window
window.makeKeyAndVisible()
}
func sceneWillEnterForeground(_ scene: UIScene) {
let stopwatchViewModel = ViewModelProvider.shared.stopwatchViewModel
stopwatchViewModel.restoreState()
if stopwatchViewModel.isRunningRelay.value {
stopwatchViewModel.startTimer()
}
}
func sceneDidEnterBackground(_ scene: UIScene) {
let stopwatchViewModel = ViewModelProvider.shared.stopwatchViewModel
stopwatchViewModel.saveState()
stopwatchViewModel.saveLaps()
}
}
위와 같은 코드로 의존성 주입 과정을 생략하고 StopwatchViewModel이 여러 계층을 뛰어넘는 방식으로 구성할 수 있었다.
하지만 여전히 SceneDelegate와 ViewModel의 역할 분리가 모호해진 것은 해결하지 못하였다.
willEnterForegroundNotification 와 didEnterBackgroundNotification 을 이용하여, SceneDelegate의 생명주기 메서드를 건들지 않고 StopwatchViewModel 안에서만 생명주기를 감지할 수 있도록 하려고 하였다.
하지만 그러한 신호들이 방출되어 ViewModel로 가는 것을 명시적으로 SceneDelegate에 표현하지 못할 것이란 점이 마음에 걸렸다. 이러한 점을 전역 클래스(싱글톤)와 RxSwift를 이용하여 해결하기로 하였다.
중계자 역할을 할 수 있는 SceneLifeCycleObserver
를 구현하여, SceneDelegate의 생명주기 메서드에 명시적으로 남기되, 구체적인 로직은 StopwatchViewModel 내부에 구현할 수 있도록 하였다.
import RxRelay
final class SceneLifeCycleObserver {
static let shared = SceneLifeCycleObserver()
private init() {}
let sceneWillEnterForegroundRelay = PublishRelay<Void>()
let sceneDidEnterBackgroundRelay = PublishRelay<Void>()
func sceneWillEnterForeground() {
sceneWillEnterForegroundRelay.accept(())
}
func sceneDidEnterBackground() {
sceneDidEnterBackgroundRelay.accept(())
}
}
final class StopwatchViewModel {
init() {
SceneLifeCycleObserver.shared.sceneWillEnterForegroundRelay
.subscribe(onNext: { [weak self] in
self?.handleSceneWillEnterForeground()
})
.disposed(by: disposeBag)
SceneLifeCycleObserver.shared.sceneDidEnterBackgroundRelay
.subscribe(onNext: { [weak self] in
self?.handleSceneDidEnterBackground()
})
.disposed(by: disposeBag)
restoreState()
if model.isRunning {
startTimer()
}
}
}
이에 따라 SceneDelegate의 구조를 간단하게 유지하면서도, 특정 생명주기 신호가 구독되고 있음을 명시적으로 표현하는게 가능해졌다:
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let scene = (scene as? UIWindowScene) else { return }
let mainTabBarController = MainTabBarController()
let window = UIWindow(windowScene: scene)
window.rootViewController = mainTabBarController
self.window = window
window.makeKeyAndVisible()
}
func sceneWillEnterForeground(_ scene: UIScene) {
SceneLifeCycleObserver.shared.sceneWillEnterForeground()
}
func sceneDidEnterBackground(_ scene: UIScene) {
SceneLifeCycleObserver.shared.sceneDidEnterBackground()
}
}