✳️ Overview
📌 시연 영상 미리 보기
📌 기능
- 검색을 통해 깃허브에 존재하는 repository를 조회할 수 있도록 함
- 셀을 누르면 해당 repo의 ID가 alert로 등장
- 검색어가 바뀌고 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)
}
- VC에서는 VM에 존재하는 클로저를 alert를 띄울 수 있도록 초기화시켜준다.
- VC의 didSelectRowAt메서드를 통해 입력을 받으면, VM의 didSelectRow라는 커스텀 메서드가 실행되도록 한다.
- 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
}
- VC에서 VM의 didUpdateRepo 클로저를 초기화해줘서, VM이 클로저를 호출하면 VC의 테이블뷰가 리로드될 수 있도록 한다.
- VC에서는 updateSearchResults를 통해 SearchController의 Query가 바뀌는 것을 감지한다. Query가 바뀔 때마다 VM의 didChangeQuery 가 실행된다.
- didChangeQuery는 startSearchWithQuery를 실행한다.
- 현재 진행중인 서버통신을 취소
- 다시 search를 진행
- finishSearching으로 검색 결과를 data에 반영
- 프로퍼티 옵저버가 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)
}
}
- 처음에 minimumDelay를 받아서 생성하도록 되어있음.
- throttle 메서드는 block 클로저를 인자로 받고, block 안에 있는 코드들이 delay를 가지고 실행됨.
- throttle이 실행되면
- 현재 진행중인 workItem을 취소함
- workItem을 재정의함. 이때 previousRun의 값을 초기화해줌.
- deltaDelay를 선언하는데, previousRun과 현재의 시간 차이가 아까 생성자에서 지정해준 minimumDelay보다 크면 0을 반환하여 workItem이 즉시 실행되도록, 아니라면 delay를 반환하여 delay만큼 기다린 후에 workItem이 실행되도록 하고 있다.
- 결과적으로 delay만큼 기다리지 않은 상태에서 throttle 메서드가 실행되면 , 다시금 delay만큼 시간이 흐른 이후에 workItem이 실행되는 구조이다.
참고
'iOS' 카테고리의 다른 글
[Swift] Coordinator Pattern 기본!! With RayWanderlich Tutorial! (0) | 2022.05.19 |
---|---|
[Swift] Alamofire를 Moya처럼 사용해보자! By Router Pattern (2편 - Services, Routers 구현) (4) | 2022.05.19 |
[Swift] Alamofire를 Moya처럼 사용해보자! By Router Pattern (1편 - Foundation Setting) (3) | 2022.05.18 |
[RxSwift] RxSwift 기본 (1편 - Observable, Observer, Subject) (1) | 2022.04.16 |
[Swift 문법] Optional type Closure와 @escaping (2) | 2022.03.20 |