하루를살자

[iOS] MVVM + Clean Architecture [3-1] - Presentation Layer 적용 본문

Architecture

[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 

왼쪽 (랜덤한 이미지를 보여주는 Home 화면) , 오른쪽 (사용자가 Like 한 이미지를 보여주는 Save 화면)

현재는 두 개의 화면 모두 같은 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의 구현체를 구현해주었습니다.

 

 

 

 

 

 

 

 

Comments