일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
- dateFormatter
- Raw value and Associated value
- LightWeight Migration
- codability
- 스위프트 클로저
- Clean swift
- persistentStoreCoordinator
- iOS Static Library 사용하는방법
- Swift 고차함수
- Java
- iOS Static Library
- 1009번
- NSSortDescriptor
- Swift LinkedList
- Associated Value
- Persistent store Coordinator
- NSPredicates
- Swift
- NSManagedObject SubClass
- CoreData Stack
- Swift closure
- CoreData Filter
- 다익스트라 이해
- leetcode #01
- CoreData
- 2022 부스트캠프
- CoreData Concurrency
- 일급 객체
- 트레일링 클로저
- expensive operation
- Today
- Total
하루를살자
[iOS] MVVM + Clean Architecture [3-1] - Presentation Layer 적용 본문
[iOS] MVVM + Clean Architecture [3-1] - Presentation Layer 적용
Kai1996 2022. 9. 12. 15:47이번 포스트에선 현재 진행 중인 Picteresting 프로젝트를 에 Presentation Layer를 적용한 내용을 정리해보는 시간을 갖겠습니다.
1.0 ViewModel 리팩토링.
리팩터링 이전의 HomeViewModel, ImageEntity
- HomeViewModel
domainLayer 리팩터링을 진행하면서 개선된 Usecase를 적용한 HomeViewModel 은 아래와 같습니다.
final class HomeViewModel {
var didUpdateLikeStatusAt: ((Int) -> Void)?
var imageList: Observable<[ImageEntity]> = Observable([])
private let fetchImageUsecase: DefaultFetchImageUsecase
private let likeImageUsecase: LikeImageUsecase
private(set) var imagesPerPage = 15
init(fetchImageUsecase: DefaultFetchImageUsecase,
likeImageUsecase: LikeImageUsecase){
self.fetchImageUsecase = fetchImageUsecase
self.likeImageUsecase = likeImageUsecase
}
subscript(index: IndexPath) -> ImageEntity? {
return imageList.value[index.row]
}
func resetList() {
//...
}
func updateLikeStatus() {
//...
}
func resetLikeStatus() {
//...
}
func fetchImages() {
//...
}
func toogleLikeState(item entity: ImageEntity, completion: @escaping ((Error?) -> Void)) {
//...
}
}
현재 HomeViewModel의 imageList는 HomeViewController에 바인딩되어 있는 상태이고, ImageEntity는 HomeViewController의 collectionViewCell에 사용되는 모델입니다.
ImageEntity를 Presentation Layer의 Observable 한 타입으로 선언한 이유는 처음 프로젝트 개발을 할 때 Entity의 개념이 ViewModel과 같다고 생각해서였는데요, 이제 entity와 viewModel의 차이점을 배운 이상 적용을 해봐야겠죠?
ImageViewModel에 어떤 데이터가 필요할지 간단하게 살펴보고 외부 Layer -> 내부 Layer 방향 (ImageCell 에서부터 viewModel 방향)으로 리팩터링 진행을 해보겠습니다. 일단 기존 ImageEntity와 imageCell에 사용될 값들을 한번 비교해보고 어떤 프로퍼티를 ImageViewModel로 빼줄지 생각해봅시다.
ImageEntity
final class ImageEntity: Identifiable {
let id: String
let imageURL: URL
private(set) var width: CGFloat?
private(set) var height: CGFloat?
private(set) var isLiked: Bool
private(set) var memo: String?
private(set) var image: UIImage?
init(id:String, imageURL:URL, isLiked: Bool, width: CGFloat, height: CGFloat) {
self.id = id
self.isLiked = isLiked
self.imageURL = imageURL
self.width = width
self.height = height
}
init(id:String, imageURL:URL, isLiked: Bool, memo: String) {
self.id = id
self.isLiked = isLiked
self.imageURL = imageURL
self.memo = memo
}
}
extension ImageEntity {
func toogleLikeStates() {
self.isLiked = !isLiked
}
func configureMemo(memo: String) {
self.memo = memo
}
func saveImage(image:UIImage) {
self.image = image
}
}
imageCell
현재는 두 개의 화면 모두 같은 ImageCell과 ImageEntity로 구현을 했는데 ViewModel을 따로 만들어 주어도 ImageViewModel이라는 녀석 하나로 두 화면의 Cell 데이터를 보여주게 만들 예정입니다.
Image Cell 이 필요한 데이터 목록
- 이미지의 Like 상태
- 사용자의 메모
- 이미지의 URL
- 이미지 width, height
기존 ImageEntity를 바로 Cell에 Configure 해주는 것과 ImageViewModel 을 거쳐 Cell 에 Configure 해주는 방식은 현재 cell에서 model을 받아 home / save에 나타낼 화면에 맞추어 cell configuration 하는 복잡한 로직을 더욱 직관적으로 만들 수 있어집니다.
아래 코드는 기존 ImageCell 이 `image` 를 모델로 사용할 때와 `ImageViewModel` 을 model로 사용하여 configure 해주는 메서드입니다.
- ImageCell
//리팩토링 이전의 ImageCell configuration
func configure(model: ImageEntity, indexPath: IndexPath, sceneType: SceneType) {
self.model = model
self.imageView.setImage(urlSource: model){ image in
model.saveImage(image: image)
}
switch sceneType {
case .save:
setCellToSaveState(model: model)
case .home:
setCellToHomeState(model: model, index: indexPath.item)
}
}
private func setCellToSaveState(model: ImageEntity) {
setLikeButtonToOn()
self.memoLabel.text = model.memo
}
private func setCellToHomeState(model: ImageEntity, index: Int) {
setMemoLabel(index: index)
if model.isLiked == true {
setLikeButtonToOn()
}else {
setLikeButtonToUndoLike()
}
}
private func setMemoLabel(index: Int) {
self.memoLabel.text = "\(index + 1)번째 사진"
}
//리팩토링 이후 cell configuration 메서드
func configureAsHomeCell(model: ImageViewModel, index: Int) {
self.model = model
self.imageView.setImage(urlSource: model){ image in
model.saveImage(image: image)
}
self.memoLabel.text = model.memo
if model.isLiked == true {
setLikeButtonToOn()
}else {
setLikeButtonToUndoLike()
}
}
func configureAsSaveCell(model: ImageViewModel) {
self.model = model
self.imageView.setImage(urlSource: model){ image in
model.saveImage(image: image)
}
setLikeButtonToOn()
self.memoLabel.text = model.memo
}
- 여기서 키 포인트는 View를 최대한 멍청하게 만들어라입니다. View는 말 그대로 사용자에게 데이터를 보여준다 라는 의미로 생각해야 합니다.
- imageEntity을 model로 사용할 경우 memoLabel의 "N번째 사진"이라고 설정해주어야합니다.
- 그 반대로 imageViewModel을 사용할 경우 이미 viewModel에서 memo 의 여부를 판단하고 "N번째 사진" / 사용자 메모를 가지고 있기 때문에 view 에 "N번째 사진" 이라는 데이터를 직접 Cell 안에서 하드코딩해줄 필요가 없어집니다.
- 현재 예제는 간단하기 때문에 하나의 디폴트 데이터를 viewModel 에서 설정해주었지만, 만약 여러 개의 디폴트 값이 cell에 직접적으로 하드 코딩되어있다면 어디에 데이터가 세팅되어있는지 일일이 찾아다녀야겠죠?
ImageEntity 리팩터링 적용
- ImageEntity -> Image
final class Image: Identifiable {
let id: String
let imageURL: URL
private(set) var width: Float
private(set) var height: Float
private(set) var memo: String?
private(set) var isLiked: Bool
init(id:String, imageURL:URL, width: Float, height: Float, memo: String?, isliked: Bool) {
self.id = id
self.imageURL = imageURL
self.width = width
self.memo = memo
self.height = height
self.isLiked = isliked
}
func changeLikeState(to state: Bool){
self.isLiked = state
}
func changeMemo(to newMemo: String) {
self.memo = newMemo
}
}
- ImageViewModel
final class ImageViewModel: Identifiable {
let id: String
let imageURL: URL
private(set) var memo: String
private(set) var isLiked: Bool
init (model: Image, index: Int) {
self.id = model.id
self.imageURL = model.imageURL
self.isLiked = model.isLiked
self.memo = model.memo ?? "\(index) 번째 사진"
}
}
extension ImageViewModel {
func toogleLikeStates() {
self.isLiked = !isLiked
}
func setMemo(memo: String) {
self.memo = memo
}
}
- 결과적으로 ImageEntity 를 Image 와 ImageViewModel 로 나누워 주었습니다.
- Image 은 서버로 부터온 데이터와 그외의 디폴트 데이터를 맵핑시켜주는 역할을합니다.
- 사용자로부터 바뀌어진 데이터들은 Cell 에 Index 해당하는 Image 로 부터 Repository 에 데이터 변경 요청을 보내고 응답을 받아와 ImageViewModel 을 업데이트 시켜줍니다.
Data Flow
1.0 서버에서 부터 raw 한 데이터들을 ImageDTO 를 사용하여 받는다.
2.0 실제 Cell 에 사용될 타입 과 디폴트 데이터들을 ImageDTO 에서 Image 로 맵핑한다.
3.0 ImageViewModel 에 실제로 화면에 보여지는 데이터 를 Image 를 사용해서 초기화/업데이트 한다.
4.0 Cell 에 ImageViewModel 을 맵핑 한다.
5.0 Cell 은 사용자에게 이미지에 대한 정보를 보여준다.
6.0 사용자가 Cell 를 터치하여 input 이벤트를 발생시킨다.
7.0 사용자의 input 에 따라 Cell index 에 해당하는 Image 데이터 를 가지고 CoreData 에 변경된 정보를 저장/업데이트 한다.
8.0 CoreData 에 성공적으로 변경이 적용됐다면 Image 의 정보를 업데이트 한다.
9.0 업데이트 된정보로 3.0, 4.0, 5.0 과정을 되풀이한다.
2.0 ViewController를 Testable 하게 만들기.
- ViewController 를 testable 한 viewController로 만들기 위해 지금 까지 빌드업해온 viewModel의 프로퍼티, 메서드들을 Input, Output과 같은 인터페이스로 만들어 MockViewModel을 만들 수 있도록 설계해봅시다.
- 이때 viewModel 은 최상단에 있는 viewModel을 가리킵니다. (viewModel 이 subLayer로 나누어져 있을 경우)
- 현 프로젝트에는 상세페이지로 들어가는 요구사항이 없기 때문에 Actions과 Flow Coordinator는 생략했습니다.
2.1 Input과 Output
Input: 사용자가 발생시킬 수 있는 이벤트들을 특정해서 메서드 형식으로 인터페이스화 시킵니다.
Output: 사용자의 Input으로 인해 생성되거나 변한 데이터를 변수로 나타냅니다.
protocol HomeViewModelInput {
func didLoadNextPage()
func didLikeImage()
}
protocol HomeViewModelOutput {
var items: Observable<[ImageViewModel]> { get }
var errors: Observable<String>{ get }
}
protocol HomeViewModel: HomeViewModelInput, HomeViewModelOutput {}
- Input과 output 프로토콜을 정의한 뒤 HomeViewModel의 구현체를 구현해주었습니다.
'Architecture' 카테고리의 다른 글
[iOS] MVVM + Clean Architecture [3] - Presentation Layer (0) | 2022.08.22 |
---|---|
[iOS] MVVM + Clean Architecture [2] - Domain Layer (0) | 2022.08.16 |
[iOS] MVVM + Clean Architecture [1] (0) | 2022.08.15 |
Clean code (0) | 2022.06.10 |