[iOS 13주차] Connecting between SceneDelegate's LifeCycle and ViewModel

DoyleHWorks·2025년 1월 14일
0

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의 역할 분리가 모호해졌다.

문제 접근 1

의존성 주입에 의해 코드가 복잡해지는 것을 해결하기 위해 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의 역할 분리가 모호해진 것은 해결하지 못하였다.

문제 접근 2

willEnterForegroundNotificationdidEnterBackgroundNotification 을 이용하여, SceneDelegate의 생명주기 메서드를 건들지 않고 StopwatchViewModel 안에서만 생명주기를 감지할 수 있도록 하려고 하였다.

하지만 그러한 신호들이 방출되어 ViewModel로 가는 것을 명시적으로 SceneDelegate에 표현하지 못할 것이란 점이 마음에 걸렸다. 이러한 점을 전역 클래스(싱글톤)와 RxSwift를 이용하여 해결하기로 하였다.

문제 접근 3 및 문제 해결

중계자 역할을 할 수 있는 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()
    }
}
profile
Reciprocity lies in knowing enough

0개의 댓글