재사용하는 셀의 초기 UI상태를 직접 설정하는 prepareForReuse() 메서드를 사용하지 않으니 UI가 뒤죽박죽 되었습니다.
해결한 방안
버그 1) 체크버튼을 눌러도 애니메이션이 재생을 건너뛰고 완료표시만 생김
-> 데이터바인딩이 되어 있어서 데이터값이 변경되면 tableView.reloadData() 메서드가 즉시 실행되는 구조였습니다.
-> 이를 데이터값이 변경은 되어도 tableView.reloadData()메서드가 실행되지 않도록 수정하여 애니메이션이 정상적으로 실행되도록 수정하였습니다.
버그 2) 체크버튼을 누르면 다른 셀이 반응함
-> 커스텀셀을 구현했고, dequeueReusableCell을 사용했습니다. 이에 따라 테이블뷰가 스크롤되거나 새로운 셀이 추가되면 재사용큐에 저장된 셀을 사용하거나 이전에 만들어진 셀을 바탕으로 셀이 생성됩니다.
-> 그 결과 UI가 뒤죽박죽 된 상태로 되었고 이를 방지하기 위해 셀이 추가되거나 재사용될 때 초기 UI 상태를 지정하는 prepareForReuse()를 사용하였습니
결과
정상적으로 작동하게 되었습니다.
당연하게 테이블 뷰를 구성할 때 커스텀 셀을 만들었고 dequeueReusableCell을 이용하여 셀을 구성하였었습니다.
이번 버그를 고치기 위해 셀을 재사용한다는 점과 데이터와 UI가 바인딩된 상태에서 애니메이션구현까지 된다면 기획했던 과정이 잘 진행이 되는지 검토해야 한다는 점을 느꼈습니다. 그리고 tableView.reloadData()는 즉시 테이블 뷰를 새로고침해서 애니메이션이 부자연스럽게 작동한다는 것을 알았습니다.
예를 들어, 수열 A = {10, 20, 10, 30, 20, 50} 인 경우에 가장 긴 증가하는 부분 수열은 A = {10, 20, 10, 30, 20, 50} 이고, 길이는 4이다.
입력
첫째 줄에 수열 A의 크기 N (1 ≤ N ≤ 1,000,000)이 주어진다.
둘째 줄에는 수열 A를 이루고 있는 Ai가 주어진다. (-1,000,000,000 ≤ Ai ≤ 1,000,000,000)
출력
첫째 줄에 수열 A의 가장 긴 증가하는 부분 수열의 길이를 출력한다.
둘째 줄에는 정답이 될 수 있는 가장 긴 증가하는 부분 수열을 출력한다.
내가 푼 풀이
이제까지 가장 긴 증가하는 부분수열(LIS)를 구하는 방법은 두가지가 있었다.
첫번째는 DP방법으로 해당 인덱스의 원소까지의 부분수열 길이를 최신화 하는 방법이였다.
이는 시간복잡도가 $O(N^2)$가 걸리므로 주어진 문제의 입력은 최대 100만개의 원소이므로 시간초과가 일어났다.
두번째 방법으론 이분탐색이 있다.
이분탐색을 통해 이문제를 해결했지만 몇가지 주의해야할 점이 있었다.
이전에 이분탐색으로 구하면 최장증가 부분수열의 "길이"만 구할 수 있었다.
이분탐색을 통해 배열에서 원소가 위치하는 자리를 구하고 해당위치에 넣은결과 길이는 정답이지만, 부분수열이 옳지 않게 구하게 되었다.
예로 수열 [10, 20, 10, 30, 15, 50]이 주어졌을 때, 이분탐색을 이용하여 구한다면
[10, 15, 30, 50]배열이 구해지고 이는 최장증가 부분수열이 아니다.
이유는 15는 50이전의 원소로 30보다 나중에 나오는 원소이므로 옳지 않다.
부분수열은 구할 수 없지만 길이는 알고있다.
이 점을 이용하여 이분탐색을 통해 부분수열을 구할 때, 해당 원소의 인덱스를 저장한다.
괄호안 숫자는 해당원소가 주어진 수열의위치를 의미한다.
첫번째 원소 10
첫번째 원소는 비교대상이 없으므로 첫번째에 넣는다.
result = [10(1)]
idx = [1]
두번째 원소 20
두번쨰 원소는 이분탐색을 이용하여 10뒤에 오는 숫자임을 알 수 있다.
result = [10(1), 20(2)]
idx = [1, 2]
세번째 원소 10
세번째 원소는 이분탐색 결과 첫번째 오는 숫자와 같음을 알 수 있다.
result = [10(3), 20(2)]
idx = [1, 2, 1]
네번째 원소 30
네번째 원소는 20뒤에오는 숫자임을 알 수 있다.
result = [10(3), 20(2), 30(4)]
idx = [1, 2, 1, 3]
다섯번째 원소 15
다섯번째 원소는 이분탐색 결과 10과 20사이에 오는 숫자임을 알 수 있다.
이분탐색 결과 부분수열은 아래와 같다.
result = [10(3), 15(5), 30(4)]
idx = [1, 2, 1, 3, 2]
-> 이분탐색으로 만들어진 부분수열은 이미 정답과는 다른 부분수열이 만들어졌다. 해당원소의 위치를 배열안에 저장한다.
여섯번째 원소 50
여섯번째 원소는 이분탐색결과 마지막에 오는 숫자임을 알 수 있다.
result =[10(3), 15(5), 30(4), 50(6)]
idx = [1, 2, 1, 3, 2, 4]
-> 결과
result =[10(3), 15(5), 30(4), 50(6)]
idx = [1, 2, 1, 3, 2, 4]
이전의 이분탐색을 이용하면 result 배열을 얻고 길이는 정답이지만 부분 수열은 옳지않은 수열이 된다.
이분탐색을 통해 부분수열의 값이 변경될때 해당 원소가 위치하는 인덱스값을 저장함으로써 길이를 차감하며 부분수열을 구할 수 있게된다.
길이가 4이므로 역순으로 4 3 2 1의 원소를 뽑으면 [50, 30, 20 ,10]이고 이배열의 역순은 정답이 된다.
이분탐색을 이용하는 방법은 알았지만, 부분수열을 뽑아내는 과정은 쉽게 떠올리지 못했다.
코드로 구현하면 다음과 같다.
import Foundation
// 입력받기
let N = Int(readLine()!)!
var A = readLine()!.split(separator: " ").map{Int(String($0))!}
A.insert(0, at: 0)
let INF = 1000000001
// 이분탐색 결과배열과 인덱스 배열
var result = Array(repeating: INF, count: N+1)
var idxArr = [Int]()
result[1] = A[1]
// 이분탐색
func binarySearch(num: Int) {
var left = 1
var right = result.count-1
while left <= right {
let mid = (right + left) / 2
if result[mid] < num {
left = mid + 1
} else {
right = mid - 1
}
}
// 해당위치에 넣고, 인덱스배열에 저장
result[left] = num
idxArr.append(left)
}
for i in 1...N {
binarySearch(num: A[i])
}
var answer = [Int]()
var count = 0
// 부분수열의 길이 구하기
for i in 1...N {
if result[i] != INF {
count += 1
} else {
break
}
}
// 부분수열 구하기
for i in (0..<idxArr.count).reversed() {
if idxArr[i] == count {
count -= 1
answer.append(A[i+1])
}
}
// 출력
print(answer.count)
print(answer.map{String($0)}.reversed().joined(separator: " "))
// Note: We cannot have any UI frameworks(like UIKit or SwiftUI) imports here.
protocol MoviesListViewModelInput {
func didSearch(query: String)
func didSelect(at indexPath: IndexPath)
}
protocol MoviesListViewModelOutput {
var items: Observable<[MoviesListItemViewModel]> { get }
var error: Observable<String> { get }
}
protocol MoviesListViewModel: MoviesListViewModelInput, MoviesListViewModelOutput { }
struct MoviesListViewModelActions {
// Note: if you would need to edit movie inside Details screen and update this
// MoviesList screen with Updated movie then you would need this closure:
// showMovieDetails: (Movie, @escaping (_ updated: Movie) -> Void) -> Void
let showMovieDetails: (Movie) -> Void
}
final class DefaultMoviesListViewModel: MoviesListViewModel {
private let searchMoviesUseCase: SearchMoviesUseCase
private let actions: MoviesListViewModelActions?
private var movies: [Movie] = []
// MARK: - OUTPUT
let items: Observable<[MoviesListItemViewModel]> = Observable([])
let error: Observable<String> = Observable("")
init(searchMoviesUseCase: SearchMoviesUseCase,
actions: MoviesListViewModelActions) {
self.searchMoviesUseCase = searchMoviesUseCase
self.actions = actions
}
private func load(movieQuery: MovieQuery) {
searchMoviesUseCase.execute(movieQuery: movieQuery) { result in
switch result {
case .success(let moviesPage):
// Note: We must map here from Domain Entities into Item View Models. Separation of Domain and View
self.items.value += moviesPage.movies.map(MoviesListItemViewModel.init)
self.movies += moviesPage.movies
case .failure:
self.error.value = NSLocalizedString("Failed loading movies", comment: "")
}
}
}
}
// MARK: - INPUT. View event methods
extension MoviesListViewModel {
func didSearch(query: String) {
load(movieQuery: MovieQuery(query: query))
}
func didSelect(at indexPath: IndexPath) {
actions?.showMovieDetails(movies[indexPath.row])
}
}
// Note: This item view model is to display data and does not contain any domain model to prevent views accessing it
struct MoviesListItemViewModel: Equatable {
let title: String
}
extension MoviesListItemViewModel {
init(movie: Movie) {
self.title = movie.title ?? ""
}
}
MoviesListViewModelInput과 MoviesListViewModelOutput의 인터페이스를 사용하여, 임의의 ViewModel을 쉽게 만들어 MoviesListViewContoller를 테스트할 수 있게 만듭니다.
또한 MoviesSearchFlowCoordinator에게 언제 다른 뷰를 나타낼지 전달할 MoviesListViewModelActions클로저가 있습니다.
액션 클로저가 Coordinator에 호출될 때, 영화의 자세한 내용의 뷰를 나타냅니다.
우리는나중에필요하다면쉽게더많은액션들을추가하기위해구조체를사용하여액션을그룹화합니다.
Presentation Layer은 또한 MoviesListViewModel의 데이터가 바인딩된 MoviesListViewController를 포함합니다.
UI는 비즈니스 로직이나 애플리케이션 로직(Business Models and UseCases)에 접근할 수 없고, 오직 뷰모델만 가능합니다.
이것이 바로 관심사의 분리입니다.
우리는 비즈니스 모델을 UI로 직접적으로 전달할 수 없습니다.
이것은 우리가 비즈니스모델을 뷰모델 내부의 뷰모델을 매핑하고 뷰에 전달하는 이유입니다.
우리는또한영화검색을시작하기위해뷰에서뷰모델로검색이벤트호출을추가합니다.
import UIKit
final class MoviesListViewController: UIViewController, StoryboardInstantiable, UISearchBarDelegate {
private var viewModel: MoviesListViewModel!
final class func create(with viewModel: MoviesListViewModel) -> MoviesListViewController {
let vc = MoviesListViewController.instantiateViewController()
vc.viewModel = viewModel
return vc
}
override func viewDidLoad() {
super.viewDidLoad()
bind(to: viewModel)
}
private func bind(to viewModel: MoviesListViewModel) {
viewModel.items.observe(on: self) { [weak self] items in
self?.moviesTableViewController?.items = items
}
viewModel.error.observe(on: self) { [weak self] error in
self?.showError(error)
}
}
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
guard let searchText = searchBar.text, !searchText.isEmpty else { return }
viewModel.didSearch(query: searchText)
}
}
항목들을 관찰하고, 항목이 바뀌면 뷰를 다시 불러옵니다.
여기엔아래 MVVM섹션에서설명할 간단한 Observable을사용합니다.
또한 MoviesSearchFlowCoordinator내부의 MoviesListViewModel의작업에 showMovieDetails함수를할당하여영화의세부장면을표시합니다.
protocol MoviesSearchFlowCoordinatorDependencies {
func makeMoviesListViewController() -> UIViewController
func makeMoviesDetailsViewController(movie: Movie) -> UIViewController
}
final class MoviesSearchFlowCoordinator {
private weak var navigationController: UINavigationController?
private let dependencies: MoviesSearchFlowCoordinatorDependencies
init(navigationController: UINavigationController,
dependencies: MoviesSearchFlowCoordinatorDependencies) {
self.navigationController = navigationController
self.dependencies = dependencies
}
func start() {
// Note: here we keep strong reference with actions closures, this way this flow do not need to be strong referenced
let actions = MoviesListViewModelActions(showMovieDetails: showMovieDetails)
let vc = dependencies.makeMoviesListViewController(actions: actions)
navigationController?.pushViewController(vc, animated: false)
}
private func showMovieDetails(movie: Movie) {
let vc = dependencies.makeMoviesDetailsViewController(movie: movie)
navigationController?.pushViewController(vc, animated: true)
}
}
ViewController의 크기와 책임을 줄이기 위해 프레젠테이션 로직에 Flow Coordinator을 사용합니다.
필요한 동안 Flow가 할당 해제 되지 않기 위해 Flow(액션 클로저, 자체기능)에 대한 강한 참조를 하고 있습니다.
이러한 접근방식은 다른 뷰들을 수정 없이 같은 뷰모델을 사용하기 쉽게 해 줍니다.
iOS 13.0 이상 버전을 확인하고 UIKit 대신에 SwiftUI를 만들고 같은 뷰모델로 데이터 바인딩을 합니다. 그렇지 않다면 UIKit을 생성합니다.
예제프로젝트에서 MoviesQueriesSuggestionList에 대한 SwiftUI예제를 추가해 두었습니다.
Model-View-ViewModel 패턴은 UI와 Domain사이의 관심사를 완전한 분리됨을 제공합니다.
클린 아키텍처와 함께 사용하면 Presentation과 UI 계층의 관심사 분리에 도움을 줍니다.
다른 뷰 구현은 같은 뷰모델이 사용될 수 있습니다.
예를 들어, CarsAroundListView와 CarsAroundMapView는 둘 다 CarsAroundViewModel을 사용할 수 있습니다.
또한 하나의 UIKit 뷰와 다른 하나의 SwiftUI도 같은 뷰모델로 지정할 수 있습니다.
뷰모델 내부에 UIKit, WatchKit, SwiftUI를 임폴트하지 않는 것을 기억하는 것이 중요합니다.
이러한방식은필요하다면다른플랫폼에서쉽게재사용될수 있다는점입니다.
View와 ViewModel 간의 데이터바인딩은 예시로 클로저, 델리게이트 또는 관찰자(RxSwift 같은)로 이루어집니다.
Combine과 SwiftUI 또한 사용될 수 있지만 최소한의 iOS 13 버전이상을 사용해야 합니다.
View는 ViewModel과 직접적인 관계가 있으며, View내부에서 이벤트가 발생할 때마다 ViewModel에게 전달합니다
ViewModel에서는 View에 대한 직접참조는 없습니다.(데이터바인딩만 참조)
이 예시에서, 우리는 타사 의존성을 피하기 위해 클로저의 와 didSet의 간단한 조합을 사용합니다.
public final class Observable<Value> {
private var closure: ((Value) -> ())?
public var value: Value {
didSet { closure?(value) }
}
public init(_ value: Value) {
self.value = value
}
public func observe(_ closure: @escaping (Value) -> Void) {
self.closure = closure
closure(value)
}
}
이것은 여러 관찰자와 관찰자 제거가 포함된 전체 구현을 볼 수 있는 매우 간단한 버전의 Observable입니다.
ViewController의 데이터바인딩 예시:
final class ExampleViewController: UIViewController {
private var viewModel: MoviesListViewModel!
private func bind(to viewModel: ViewModel) {
self.viewModel = viewModel
viewModel.items.observe(on: self) { [weak self] items in
self?.tableViewController?.items = items
// Important: You cannot use viewModel inside this closure, it will cause retain cycle memory leak (viewModel.items.value not allowed)
// self?.tableViewController?.items = viewModel.items.value // This would be retain cycle. You can access viewModel only with self?.viewModel
}
// Or in one line
viewModel.items.observe(on: self) { [weak self] in self?.tableViewController?.items = $0 }
}
override func viewDidLoad() {
super.viewDidLoad()
bind(to: viewModel)
viewModel.viewDidLoad()
}
}
protocol ViewModelInput {
func viewDidLoad()
}
protocol ViewModelOutput {
var items: Observable<[ItemViewModel]> { get }
}
protocol ViewModel: ViewModelInput, ViewModelOutput {}
클로저 관찰에서 뷰모델 접근은 허용하지 않습니다. 이것은 메모리 릭 현상을 발생하기 때문입니다.
예를 들어, ItemsListViewModel과 ItemEditViewModel이 있습니다.
그다음 ItemEditViewModelDidEditItem메서드를 갖는 ItemEditViewModelDelegate 프로토콜을 만듭니다.
그리고 이것을 다음 프로토콜을 채택하도록 만듭니다. Extension ListItemsviewModel: ItemEditViewModelDelegate
// Step 1: Define delegate and add it to first ViewModel as weak property
protocol MoviesQueryListViewModelDelegate: class {
func moviesQueriesListDidSelect(movieQuery: MovieQuery)
}
...
final class DefaultMoviesQueryListViewModel: MoviesListViewModel {
private weak var delegate: MoviesQueryListViewModelDelegate?
func didSelect(item: MoviesQueryListViewItemModel) {
// Note: We have to map here from View Item Model to Domain Enity
delegate?.moviesQueriesListDidSelect(movieQuery: MovieQuery(query: item.query))
}
}
// Step 2: Make second ViewModel to conform to this delegate
extension MoviesListViewModel: MoviesQueryListViewModelDelegate {
func moviesQueriesListDidSelect(movieQuery: MovieQuery) {
update(movieQuery: movieQuery)
}
}
또 다른 통신방법은 FlowCoordinator로부터 주입되거나 할당받은 클로저를 사용하는 것입니다.
이 예제프로젝트에서는 MoviesListViewModel이 액션 클로저 showMovieQueriesSuggestions를 사용하여 MoviesQueriesSuggestionsView를 표시하는 방법을 볼 수 있습니다.
또한 매개변수 (_ didSelect: MovieQuery) -> Void를 전달하여 해당 View에서 재호출 할 수 있습니다.
통신은 MoviesSearchFlowCoordinator내부와연결되어있습니다.
// MoviesQueryList.swift
// Step 1: Define action closure to communicate to another ViewModel, e.g. here we notify MovieList when query is selected
typealias MoviesQueryListViewModelDidSelectAction = (MovieQuery) -> Void
// Step 2: Call action closure when needed
class MoviesQueryListViewModel {
init(didSelect: MoviesQueryListViewModelDidSelectAction? = nil) {
self.didSelect = didSelect
}
func didSelect(item: MoviesQueryListItemViewModel) {
didSelect?(MovieQuery(query: item.query))
}
}
// MoviesQueryList.swift
// Step 3: When presenting MoviesQueryListView we need to pass this action closure as paramter (_ didSelect: MovieQuery) -> Void
struct MoviesListViewModelActions {
let showMovieQueriesSuggestions: (@escaping (_ didSelect: MovieQuery) -> Void) -> Void
}
class MoviesListViewModel {
var actions: MoviesListViewModelActions?
func showQueriesSuggestions() {
actions?.showMovieQueriesSuggestions { self.update(movieQuery: $0) }
//or simpler actions?.showMovieQueriesSuggestions(update)
}
}
// FlowCoordinator.swift
// Step 4: Inside FlowCoordinator we connect communication of two viewModels, by injecting actions closures as self function
class MoviesSearchFlowCoordinator {
func start() {
let actions = MoviesListViewModelActions(showMovieQueriesSuggestions: self.showMovieQueriesSuggestions)
let vc = dependencies.makeMoviesListViewController(actions: actions)
present(vc)
}
private func showMovieQueriesSuggestions(didSelect: @escaping (MovieQuery) -> Void) {
let vc = dependencies.makeMoviesQueriesSuggestionsListViewController(didSelect: didSelect)
present(vc)
}
}
Layer Separation into frameworks (Modules)
이제 예제 앱의 각 계층(도메인, 프레젠테이션, UI, 데이터, 인프라네트워크)들은 별도의 프레임워크와 쉽게 분리할 수 있습니다.
그다음 CocoaPod을사용해이러한프레임워크를당신의기본앱에포함시킬수있습니다.
Dependency Injection Container (의존성 주입 컨테이너?)
의존성 주입은 한 객체가 다른 객체의 종속성을 제공하는 기술입니다.
앱 내 DIContainer은모든주입의중심단위입니다.
종속성 팩토리 프로토콜 사용
많은 보기 중 하나는 종속성 생성을 DIContainer에 위임하는 종속성 프로토콜을 선언하는 것입니다.
이를 수행하기 위해 MoviesSearchFlowCoordinatorDependencies프로토콜을 정의하고 MoviesSceneDIContainer가 이 프로토콜을 준수하도록 생성합니다. 그다음 이 주입이 필요한 MoviesSearchFlowCoordinator에 주입하여 MoviesListViewController를 생성하고 표시해야 합니다.
여기에그단계가있습니다.
// Define Dependencies protocol for class or struct that needs it
protocol MoviesSearchFlowCoordinatorDependencies {
func makeMoviesListViewController() -> MoviesListViewController
}
class MoviesSearchFlowCoordinator {
private let dependencies: MoviesSearchFlowCoordinatorDependencies
init(dependencies: MoviesSearchFlowCoordinatorDependencies) {
self.dependencies = dependencies
}
...
}
// Make the DIContainer to conform to this protocol
extension MoviesSceneDIContainer: MoviesSearchFlowCoordinatorDependencies {}
// And inject MoviesSceneDIContainer `self` into class that needs it
final class MoviesSceneDIContainer {
...
// MARK: - Flow Coordinators
func makeMoviesSearchFlowCoordinator(navigationController: UINavigationController) -> MoviesSearchFlowCoordinator {
return MoviesSearchFlowCoordinator(navigationController: navigationController,
dependencies: self)
}
}
클로저 사용
또 다른 옵션은 클로저를 사용하는 것입니다.
이를수행하기위해주입이필요로하는클래스내부에클로저정의가필요합니다. 그리고이클로저를주입합니다.
// Define makeMoviesListViewController closure that returns MoviesListViewController
class MoviesSearchFlowCoordinator {
private var makeMoviesListViewController: () -> MoviesListViewController
init(navigationController: UINavigationController,
makeMoviesListViewController: @escaping () -> MoviesListViewController) {
...
self.makeMoviesListViewController = makeMoviesListViewController
}
...
}
// And inject MoviesSceneDIContainer's `self`.makeMoviesListViewController function into class that needs it
final class MoviesSceneDIContainer {
...
// MARK: - Flow Coordinators
func makeMoviesSearchFlowCoordinator(navigationController: UINavigationController) -> MoviesSearchFlowCoordinator {
return MoviesSearchFlowCoordinator(navigationController: navigationController,
makeMoviesListViewController: self.makeMoviesListViewController)
}
// MARK: - Movies List
func makeMoviesListViewController() -> MoviesListViewController {
...
}
}
결론
모바일 개발에서 대부분 사용된 아키텍처 패턴들은 클린 아키텍처, MVVM, Redux입니다.
MVVM과 클린 아키텍처는 별도로 사용할 수 있습니다. 그러나 MVVM은 오직 Presentation Layer내부에서의 관심사 분리만 제공하지만, 클린아키텍처는 코드를 쉽게 테스트하고 재사용하고 이해하도록 모듈식 계층을 분할합니다.
유스케이스가 저장소를 호출하는 것 외에 다른 작업이 없더라도 유스케이스 생성을 건너뛰지 않는 것이 중요합니다.
이 방식은, 당신의 아키텍처가 새로운 개발자가 당신의 유스케이스들을 봤을 때 자체적으로 설명될 것입니다.
이것은 출발점으로 유용할지라도, 모든 면에서 적합하진 않습니다.
당신의 프로젝트에서 요구사항을 충족하는 아키텍처를 고르세요.
클린 아키텍처는 TDD에서 매우 잘 작동합니다(Test Driven Development)(테스트 중심 개발)
이 아키텍쳐는 프로젝트를 테스트하기 용이하게 만들어주고 계층들을 쉽게 교체할 수 있습니다.(UI나 데이터)
저는 프로젝트에서 에러 핸들링 과정과 함께 URLSession을 아래와 같이 사용했습니다.
// URLSession NetworkError
enum NetworkError: Error {
case invalidUrl
case transportError
case serverError(code: Int)
case missingData
case decodingError(error: Error)
}
// URLSessionConfiguration
private let session = URLSession(configuration: .default)
// 반환 타입 정의
typealias NetworkResult = (Result<WeatherDataModel, NetworkError>) -> ()
// URLSession GET Data
func dataFetch(nx: Int, ny: Int, convenience: Bool, completion: @escaping NetworkResult) {
// URL 확인
let urls = getUrl(convenience: convenience, nx: nx, ny: ny)
for url in urls {
guard let url = url.url else {
completion(.failure(.invalidUrl))
return
}
var request: URLRequest = URLRequest(url: url)
request.httpMethod = "GET"
let dataTask = session.dataTask(with: request) { data, response, error in
// 연결 확인
guard error == nil else {
completion(.failure(.transportError))
print(error?.localizedDescription)
return
}
// 서버 확인
let successRange = 200..<300
guard let statusCode = (response as? HTTPURLResponse)?.statusCode else { return }
if !successRange.contains(statusCode) {
completion(.failure(.serverError(code: statusCode)))
return
}
// 데이터 확인
guard let loadData = data else {
completion(.failure(.missingData))
return
}
// 디코딩
do {
let parsingData: WeatherDataModel = try
JSONDecoder().decode(WeatherDataModel.self, from: loadData)
completion(.success(parsingData))
} catch let error {
completion(.failure(.decodingError(error: error)))
}
}.resume()
}
}
최신기술?인 async await을 사용하지않고 escaping 클로저로 비동기 데이터 통신을 처리했습니다.
절차는 아래와 같습니다!
URL 생성 (URL생성에 문제가 생기면 오류리턴)
생성된 URL을 바탕으로 URLComponent를 만들고, URLRequest 생성
URLSession을 통해 데이터 통신 (클로저 안에서 진행되고, 리턴값은 escaping 선언에 따라 해당 함수가 종료되면 실행)
네트워크 연결, 서버연결, 데이터불러오기 확인절차를 모두 진행하고 실패하면 오류 리턴
위 사항까지 모두 정상적으로 진행되었다면 디코딩 진행(JSON 모델과 받을 데이터의 모델과 형식이 같은지 주의)
디코딩이 완료되면 해당 데이터를 리턴
이렇게 작성하고 데이터를 받는 부분에서 불러올땐 클로저를 통해 데이터를 받았습니다.
APIManager.shared.dataFetch(nx: location.x, ny: location.y, convenience: false) {[weak self] result in
guard let self = self else { return }
switch result {
case .success(let data):
self.convertDataFromCategory(response: data)
case .failure(.decodingError(error: let error)):
print("decodingError: \(error.localizedDescription)")
case .failure(.invalidUrl):
print("invalidURL")
case .failure(.missingData):
print("missingData")
case .failure(.serverError(code: let code)):
print("serverError \(code)")
case .failure(.transportError):
print("transportError")
}
}
이렇게 네트워크 통신작업을 처리했는데 이것을 RxSwift로 리팩토링 하고자 합니다.
RxSwift URLSession
RxSwift는 반응형 프로그래밍으로 이전에 작성했던 코드와는 다른 개념이지만, RxSwift는 Swift에서 사용하던 UI나 프로토콜등 많은 프레임워크가 Reactive 형식으로 확장되서 지원하고있습니다.
extension Reactive where Base: URLSession {
public func response(request: URLRequest) -> Observable<(response: HTTPURLResponse, data: Data)> {
return Observable.create { observer in
let d: Date?
// shouldLogRequest: RxCocoa확장 라이브러리를 사용할 때, 네트워크 요청 로깅을 제어하기 위해 사용하는 함수?
// 디버깅 목적으로 사용
// 정확한 역할은 모르지만,, 코드 흐름을 보고 유추하자면.. 라이브러리 네트워크 연결이 됬다면 true를 리턴하는듯 하다.
if URLSession.rx.shouldLogRequest(request) {
d = Date()
}
else {
d = nil
}
// URLSession Task
let task = self.base.dataTask(with: request) { data, response, error in
if URLSession.rx.shouldLogRequest(request) {
let interval = Date().timeIntervalSince(d ?? Date())
// HTTPMethod, URLRequest의 url문자열 출력 (올바른 url이 아니면 "<unknown url>"
print(convertURLRequestToCurlCommand(request))
#if os(Linux)
print(convertResponseToString(response, error.flatMap { $0 as NSError }, interval))
#else
// 네트워크 연결상태 확인 status code (200..<300)
print(convertResponseToString(response, error.map { $0 as NSError }, interval))
#endif
}
// 응답오류, 데이터 불러오기 오류
guard let response = response, let data = data else {
observer.on(.error(error ?? RxCocoaURLError.unknown))
return
}
// HTTP응답없음 오류 리턴
guard let httpResponse = response as? HTTPURLResponse else {
observer.on(.error(RxCocoaURLError.nonHTTPResponse(response: response)))
return
}
// 응답값과 데이터 리턴 후 종료
observer.on(.next((httpResponse, data)))
observer.on(.completed)
}
task.resume()
// Observable Dispose
return Disposables.create(with: task.cancel)
}
}
}
※ 코드 분석
URLRequest를 인자로 받아 Observable<(response, data)> 형식으로 리턴한다.
라이브러리와 네트워크 통신을 디버깅목적으로 확인하는URLSession.rx.shouldLogRequest(request)을 거친다.
서버 통신확인, 서버로부터 응답과 url응답 확인한다. 오류가 발생한다면 오류 이벤트를 방출한다.
정상적으로 통신이 됬다면 해당 http응답값과 data를 이벤트 방출하고 종료한다.
URLResponse: 네트워크요청에 대한 응답을 나타낸다.
HTTPResponse: HTTP프로토콜을 사용한 네트워크에 대한 응답을 나타낸다. (URLResponse의 서브클래스)
사용 예시
func getData(nx: Int, ny: Int, convenience: Bool) -> Observable<WeatherDataModel> {
// components의 리턴값은 Observable<URLComponents>
return components(nx: nx, ny: ny, convenience: convenience)
// URLComponents를 URLRequest로 변환
.map{ url in
return URLRequest(url: url.url!)
}
// 변환된 URLRequest를 통해 통신
// URLSession.shared.rx.response에는 이미 에러대응이 구현되어있음.
// 리턴값은 Observable<(response: HTTPURLResponse, data: Data)>
.flatMap { request -> Observable<(response: HTTPURLResponse, data: Data)> in
return URLSession.shared.rx.response(request: request)
}
// response, data를 인자로 받고 정한 데이터모델로 디코딩 (실패시 임의의 값 넣기)
.map{ _, data -> WeatherDataModel in
let decoder = JSONDecoder()
let decoded = try? decoder.decode(WeatherDataModel.self, from: data)
return decoded ?? WeatherDataModel(response: DataResponse(header: ResponseHeader(resultCode: "04"), body: nil))
}
}
RxURLSession은 에러대응이 구현되어 있지만, 디코딩 에러에 대한 대응은 따로 구현해줘야 합니다.
또한 리턴값이 Observable이지만 네트워크 통신같은 경우는 성공과 실패의 결과만 있어도 되므로 Traits중 하나인 Single을 리턴하도록 합니다.
Single
Observable보다 더 좁은 범위로 .success 와 .error만 방출한다.
데이터 로딩과 같은 1회성 프로세스에 적합하다.
Observable에서 asSingle로 변환할 수 있다.
typealias NetworkResult = Result<WeatherDataModel, NetworkError>
func example(nx: Int, ny: Int, page: Int) -> Single<NetworkResult> {
return Single<NetworkResult>.create {[weak self] single in
// URL 확인
guard let components = self?.component(nx: nx, ny: ny, page: page) else {
single(.failure(NetworkError.invalidUrl))
return Disposables.create()
}
let request = URLRequest(url: components.url!)
URLSession.shared.dataTask(with: request) { data, response, error in
// 에러 확인
if let error = error {
single(.failure(NetworkError.transportError))
return
}
let successRange = 200..<300
guard let statusCode = (response as? HTTPURLResponse)?.statusCode else { return }
// 접속 코드 확인
if !successRange.contains(successRange) {
single(.failure(NetworkError.serverError(code: statusCode)))
return
}
// 데이터 확인
guard let loaded = data else {
single(.failure(NetworkError.missingData))
return
}
// 디코딩 확인
do {
let decoded = try JSONDecoder().decode(WeatherDataModel.self, from: loaded)
single(.success(NetworkResult.success(decoded)))
} catch {
single(.failure(NetworkError.decodingError))
}
}.resume()
return Disposables.create()
}
}
RGB거리에는 집이 N개 있다. 거리는 선분으로 나타낼 수 있고, 1번 집부터 N번 집이 순서대로 있다.
집은 빨강, 초록, 파랑 중 하나의 색으로 칠해야 한다. 각각의 집을 빨강, 초록, 파랑으로 칠하는 비용이 주어졌을 때, 아래 규칙을 만족하면서 모든 집을 칠하는 비용의 최솟값을 구해보자.
1번 집의 색은 2번, N번 집의 색과 같지 않아야 한다.
N번 집의 색은 N-1번, 1번 집의 색과 같지 않아야 한다.
i(2 ≤ i ≤ N-1)번 집의 색은 i-1, i+1번 집의 색과 같지 않아야 한다.
입력
첫째 줄에 집의 수 N(2 ≤ N ≤ 1,000)이 주어진다. 둘째 줄부터 N개의 줄에는 각 집을 빨강, 초록, 파랑으로 칠하는 비용이 1번 집부터 한 줄에 하나씩 주어진다. 집을 칠하는 비용은 1,000보다 작거나 같은 자연수이다.
출력
첫째 줄에 모든 집을 칠하는 비용의 최솟값을 출력한다.
접근방법: DP
해당문제를 풀기전 규칙을 먼저 이해해야한다. 규칙은 단순하다.
직선 위 N개의 집이 주어졌을 때, 서로 인접한 집과의 색이 같지 않고, 첫번째와 마지막의 집의 색이 같으면 안된다.
직선을 원으로 만들어 첫번째와 마지막 집을 인접시키면 모든 집은 인접한 집과 다른 색을 갖는다는 점과 같지만
이 문제에선 이 방식은 사용하지않지만 규칙을 이해하는데 쉬웠다.
첫번째 집부터 마지막집까지 색칠하는 과정을 거치는데 첫번째와 마지막집의 색이 같으면 안되므로 첫번째집의 색을 알고 있어야한다.
이를 DP방식을 이용하여 풀려면 이전까지 색칠한 비용을 알고 이용하여 그다음 집까지 색칠비용을 구한다.
인접한 집과 색이 같으면 안되므로
첫번째 집이 빨강이면 두번째 집은 파랑과 초록이 가능하다
두번째 집이 파랑이라면 세번째 집은 빨강과 초록이 가능하다.
세번째 집이 초록이라면 네번째 집은 빨강과 파랑이 가능하다.
주어진 비용을 2차원 배열로 저장하면 빨강: 0, 초록: 1, 파랑: 2 인덱스를 갖는 배열이 된다.
위 방식을 사용하면 dp배열을 아래와 같이 정의할 수 있다.
dp[n][0]: n번째 집을 빨강으로 색칠한 최소비용
dp[n][1]: n번째 집을 초록으로 색칠한 최소비용
dp[n][2]: n번째 집을 파랑으로 색칠한 최소비용
dp[n][0] = arr[n][0] + min(dp[n-1][1], dp[n-1][2]) (arr: 색칠비용이 담긴 2차원배열)
이렇게 마지막 집까지 모두 색칠했을 때, 마지막 집과 첫번째 집의 색이 다른 경우의 비용을 갱신하여 값을 구한다.
코드로 구현하면 다음과 같다.
import Foundation
// 입력받기
let N = Int(readLine()!)!
var arr = [[0]]
let INF = 100000000
var ans = INF
for _ in 1...N {
arr.append(readLine()!.split(separator: " ").map{Int(String($0))!})
}
// 첫번째 집이 어떤색을 칠하는지? 가 기준이 된다.
// 집이 나열된 선분을 순환구조로 나타낸다면, 인접한 집과의 색은 같으면 안된다.
// 첫번째 집을 세가지 색 모두 칠해보고 색칠비용의 최솟값을 구한다.
for i in 0...2 {
// dp[i][j]: i번째집을 j색으로 칠했을 때의 최소비용
var dp = Array(repeating: Array(repeating: 0, count: 3), count: N+1)
// 첫번째 집과 마지막집의 색이 같으면 안되므로, 우리는 첫번째 집의 색을 기억하고 있어야한다.
// 따라서 첫번째 집의 색 이외의 경우의수는 임의의 최댓값을 넣는다.
for j in 0..<3 {
if i == j {
dp[1][j] = arr[1][j]
} else {
dp[1][j] = INF
}
}
// 규칙에따라 현재집은 이전의 집과 다른색을 칠해야한다.
// n번째 집까지 색칠한 최소비용: n-1번째까지 칠한 최소비용 + n번째 집의 색칠비용
for j in 2...N {
dp[j][0] = arr[j][0] + min(dp[j-1][1], dp[j-1][2])
dp[j][1] = arr[j][1] + min(dp[j-1][0], dp[j-1][2])
dp[j][2] = arr[j][2] + min(dp[j-1][0], dp[j-1][1])
}
// 첫번째 집과 마지막번째 집의 색이 같지 않다면, 최소비용을 갱신한다.
for k in 0..<3 {
if i != k {
ans = min(ans, dp[N][k])
}
}
}
print(ans)