본문 바로가기

iOS

[MVVM] Closure를 이용한 MVVM 구현(With SearchController, Throttle)

✳️ Overview

📌 시연 영상 미리 보기

📌 기능

  1. 검색을 통해 깃허브에 존재하는 repository를 조회할 수 있도록 함
  2. 셀을 누르면 해당 repo의 ID가 alert로 등장
  3. 검색어가 바뀌고 minimumDelay만큼의 시간이 지나야 검색 수행

📌 Closure를 통한 MVVM 구현

특정 라이브러리 없이 Closure를 통하여 MVVM을 구현한 샘플 프로젝트입니다.

📌 Throttle(Custom Class)를 이용한 검색 지연 기능

Throttle이라는 클래스를 통해서 SearchController의 검색어가 바뀌고 minimumdelay만큼의 시간을 기다려야 검색이 실행되도록 했습니다.

✳️ Flow : 앱 실행으로부터의 흐름

앱이 실행되는 플로우를 따라 코드를 분석해 보았습니다!

1️⃣ SceneDelegate

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    guard let scene = (scene as? UIWindowScene) else { return }
        window = UIWindow(windowScene: scene)
    let viewModel = ReposViewModel(networkingService: NetworkingApi())
    let viewController = ReposViewController(viewModel: viewModel)
    
    window?.rootViewController = UINavigationController(rootViewController: viewController)
    window?.makeKeyAndVisible()
}

위와 같이 NetworkingApi()를 의존성 주입한 viewModel을 생성한다.

  • 뷰모델의 생성자
// Dependencies
private let networkingService: NetworkingService

init(networkingService: NetworkingService) {
    self.networkingService = networkingService
}

그리고 이러한 뷰모델을 가지는 RepoViewController를 만들어준 다음에, rootViewController로 띄워주고 있다.

  • 뷰컨의 생성자
private let viewModel: ReposViewModel

init(viewModel: ReposViewModel) {
    self.viewModel = viewModel
    super.init(nibName: nil, bundle: nil)
}

2️⃣ ReposVC

생성자에서 self.viewModel = ReposeViewModel로 초기화해줬다.

    override func viewDidLoad() {
        super.viewDidLoad()
				// UI 구성 부분 생략        

        setupViewModel()
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        viewModel.ready()
    }

위와 같이 순서대로 setupViewModel()과 viewModel.ready()를 호출해주고 있다.

📌 SetupViewModel()

private func setupViewModel() {
    viewModel.isRefreshing = { loading in
				if loading == true {   로딩로티.play()
        }
    }
    viewModel.didUpdateRepos = { [weak self] repos in
        guard let strongSelf = self else { return }
        strongSelf.data = repos
        strongSelf.tableView.reloadData()
    }
    viewModel.didSelecteRepo = { [weak self] id in
        guard let strongSelf = self else { return }
        let alertController = UIAlertController(title: "\\(id)", message: nil, preferredStyle: .alert)
        alertController.addAction(UIAlertAction(title: "OK", style: .cancel, handler: nil))
        strongSelf.present(alertController, animated: true, completion: nil)
    }
}

VC에서는 가장 먼저 뷰모델에 존재하는 클로저들을 초기화해서, 뷰컨에 권한이 있는 작업들을 수행할 수 있도록 한다.

ReposViewModel에는 아래와 같이 선언되어 있다.

var isRefreshing: ((Bool) -> Void)?
var didUpdateRepos: (([RepoViewModel]) -> Void)?
var didSelecteRepo: ((Int) -> Void)?

📌 ready()

VC에서 VM의 ready() 메서드 호출

// VC에서 VM의 ready()호출
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        viewModel.ready()
    }

// VM의 메서드 정의
func ready() {
    isRefreshing?(true)
    networkingService.searchRepos(withQuery: "swift") { [weak self] repos in
        guard let strongSelf  = self else { return }
        strongSelf.finishSearching(with: repos)
    }
}

private func finishSearching(with repos: [Repo]) {
    isRefreshing?(false)
    self.repos = repos
}

위와 같이 ready 메서드를 통해 초기 검색어가 “swift”로 실행된 결과를 테이블뷰에 올려줍니다.

3️⃣ 기능

📌 테이블뷰 셀을 터치하면?

// VC입니다

private func setupViewModel() {
		viewModel.didSelecteRepo = { [weak self] id in
		    guard let strongSelf = self else { return }
		    let alertController = UIAlertController(title: "\\(id)", message: nil, preferredStyle: .alert)
		    alertController.addAction(UIAlertAction(title: "OK", style: .cancel, handler: nil))
		    strongSelf.present(alertController, animated: true, completion: nil)
}

extension ReposViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        viewModel.didSelectRow(at: indexPath)
    }
}

// 아래는 VM의 메서드 정의

func didSelectRow(at indexPath: IndexPath) {
    if repos.isEmpty { return }
    didSelecteRepo?(repos[indexPath.item].id)
}
  1. VC에서는 VM에 존재하는 클로저를 alert를 띄울 수 있도록 초기화시켜준다.
  2. VC의 didSelectRowAt메서드를 통해 입력을 받으면, VM의 didSelectRow라는 커스텀 메서드가 실행되도록 한다.
  3. VM의 didSelectRow 메서드는 1.에서 VC가 초기화한 didSelectRepo 클로저를 실행한다.

📌 새로운 검색어로 검색을 하면?

//VC
private func setupViewModel() {
    viewModel.didUpdateRepos = { [weak self] repos in
        guard let strongSelf = self else { return }
        strongSelf.data = repos
        strongSelf.tableView.reloadData()
    }
}

func updateSearchResults(for searchController: UISearchController) {
    viewModel.didChangeQuery(searchController.searchBar.text ?? "")
}

//VM
private(set) var repos: [Repo] = [Repo]() {
    didSet {
        didUpdateRepos?(repos.map { RepoViewModel(repo: $0) })
    }
}

private let throttle = Throttle(minimumDelay: 0.3)

func didChangeQuery(_ query: String) {
    guard query.count > 2,
        query != lastQuery else { return } // distinct until changed
    lastQuery = query
    
    throttle.throttle {
        self.startSearchWithQuery(query)
    }
}

private func startSearchWithQuery(_ query: String) {
    currentSearchNetworkTask?.cancel() // cancel previous pending request
    
    isRefreshing?(true)

    currentSearchNetworkTask = networkingService.searchRepos(withQuery: query) { [weak self] repos in
        guard let strongSelf  = self else { return }
        strongSelf.finishSearching(with: repos)
    }
}

private func finishSearching(with repos: [Repo]) {
    isRefreshing?(false)
    self.repos = repos
}
  1. VC에서 VM의 didUpdateRepo 클로저를 초기화해줘서, VM이 클로저를 호출하면 VC의 테이블뷰가 리로드될 수 있도록 한다.
  2. VC에서는 updateSearchResults를 통해 SearchController의 Query가 바뀌는 것을 감지한다. Query가 바뀔 때마다 VM의 didChangeQuery 가 실행된다.
  3. didChangeQuery는 startSearchWithQuery를 실행한다.
    1. 현재 진행중인 서버통신을 취소
    2. 다시 search를 진행
    3. finishSearching으로 검색 결과를 data에 반영
  4. 프로퍼티 옵저버가 didUpdateRepo 실행 → tableView.reloadData

여기서 Searching을 delay 하는 기능은 throttle.throttle()이 담당하고 있다.

throttle 클래스에 대해 알아보자!

✳️ Throttle

import Foundation

class Throttle {
    private var workItem: DispatchWorkItem = DispatchWorkItem(block: {})
    private var previousRun: Date = Date.distantPast
    private let queue: DispatchQueue
    private let delay: TimeInterval
    
    init(minimumDelay: TimeInterval, queue: DispatchQueue = DispatchQueue.main) {
        self.delay = minimumDelay
        self.queue = queue
    }
    
    func throttle(_ block: @escaping () -> Void) {
        workItem.cancel()
        
        workItem = DispatchWorkItem() {
            [weak self] in
            self?.previousRun = Date()
            block()
        }
        
        let deltaDelay = previousRun.timeIntervalSinceNow > delay ? 0 : delay
        queue.asyncAfter(deadline: .now() + Double(deltaDelay), execute: workItem)
    }
}
  1. 처음에 minimumDelay를 받아서 생성하도록 되어있음.
  2. throttle 메서드는 block 클로저를 인자로 받고, block 안에 있는 코드들이 delay를 가지고 실행됨.
  3. throttle이 실행되면
    1. 현재 진행중인 workItem을 취소함
    2. workItem을 재정의함. 이때 previousRun의 값을 초기화해줌.
    3. deltaDelay를 선언하는데, previousRun과 현재의 시간 차이가 아까 생성자에서 지정해준 minimumDelay보다 크면 0을 반환하여 workItem이 즉시 실행되도록, 아니라면 delay를 반환하여 delay만큼 기다린 후에 workItem이 실행되도록 하고 있다.
  4. 결과적으로 delay만큼 기다리지 않은 상태에서 throttle 메서드가 실행되면 , 다시금 delay만큼 시간이 흐른 이후에 workItem이 실행되는 구조이다.

 

참고

https://github.com/infoweb77/iOS-architecture-examples/tree/b1260283cfa172e3b3d68bfbd27c97287b537b51/MVVM-Closures