본문 바로가기

iOS

[iOS] MVVM+DiffableDataSource+CompositionalLayout

728x90

Okay. 가보자고.

이번 글에서는 개념보다는 어떻게 구현하면 되는지, 구현할 때 주의할 점이 무엇인지 등에 좀 더 초점을 두고 적었습니다 .. 참고 부탁 .. 

 

Project

Unsplash API를 이용해서 사진 리스트를 보여주는 프로젝트를 만들어보자. 

 

좀 더 자세하게 살펴본다면 아래와 같다.

  • MVVM 패턴을 기반으로 
  • Diffable Data Source 으로 데이터 관리를 하고 
  • Compositional Layout 으로 UI를 만들고 
  • Alamofire 로 서버 통신을 하는 

프로젝트를 만들어보자 !! 

관련 코드 전체는 Github에서 볼 수 있다. 

 

서버 통신 

아무도 궁금해하지 않을 TMI)
예전에는 화면을 먼저 만들고 서버 통신을 한 뒤, UI를 완성시키는 순서로 작업을 했었는데 ..
최근에는 위의 방식보다 데이터 기반으로 UI를 만들고 플로우를 완성하는 것이 보다 유연한 방식이라는 것을 깨닫게 되어서 서버 통신을 하고 UI 작업을 하는 편이다. 

 

Base 

서버 통신을 위한 첫번째 작업은 Base 파일을 만드는 것이다.

 

사람/팀마다 Base에 어떤 것을 구성할 지는 다르지만, 나같은 경우 아래와 같은 파일을 Base 폴더 안에 넣는다.

  • Network
  • Base
    • APIKey (보통 .gitignore에 포함해서 리모트에는 올라가지 않도록 한다.)
    • URLConstant 
    • NetworkResult 
  • APIManager
    • PhotoList
      • PhotoListAPIManager

 

APIManager 

import Alamofire

final class ListAPIManager {
    static let shared = ListAPIManager()
    
    private init() { }
    
    func fetchPhotoList(completionHandler: @escaping ([Photo]?, Int?, Error?) -> Void) {
        let url = URLConstant.listURL
        let headers: HTTPHeaders = [APIKey.authorization : APIKey.key]
        let params: [String : Any] = ["page" : 1,
                                      "per_page" : 10,
                                      "order_by" : "popular"]
        
        let request = AF.request(url,
                                 method: .get,
                                 parameters: params,
                                 encoding: URLEncoding.default,
                                 headers: headers)
        
        request.responseDecodable(of: [Photo].self) { response in
            let statusCode = response.response?.statusCode
            
            switch response.result {
            case .success(let value):
                completionHandler(value, statusCode, nil)
                
            case .failure(let error):
                completionHandler(nil, statusCode, error)
            }
        }
    }
}

APIManager 파일에서는 서버통신만 한다. 

그렇기 때문에 통신 이후 데이터에 대한 처리 및 UI 관련 코드는 다른 곳에서 처리한다. 

 

이 때 탈출 클로저를 통해서 서버 통신 이후 받은 응답 데이터, 상태 코드, 에러를 반환한다.

세 개의 데이터에 대해서 옵셔널 형태로 지정된 이유는 상황에 따라서 데이터가 있을 수도 있고 없을 수도 있기 때문이다.

  • 서버 통신이 성공한 경우는 응답 데이터는 잘 넘어올 것이고 에러는 없을 것이다.
  • 반대로 실패한 경우는 응답 데이터가 제대로 넘어오지 않아 nil이 될 것이고 에러는 있을 것이다.

 

위의 코드에서 살펴볼 점은 

✅ responseDecodable : Alamofire에서 제공하는 응답 데이터 디코드 메서드로 (of: (디코드 할 데이터의 타입).self)를 함께 작성해야 한다.

 

✅ request를 할 때, API 문서를 보면서 전달할 내용을 모두 작성했는지 확인해야 한다. 

Unsplash의 photo list API의 경우 헤더를 제외하고 URL Parameter는 옵셔널 형태였다. 즉, 필수로 작성하지 않아도 되지만, 응답 받을 데이터에 대한 일종의 필터를 적용하여 데이터를 받을 수 있다. 

(위의 코드에서는 1번째 페이지에 해당하는 10개의 사진 리스트를 받았고, 이 때 유명한 순서대로 데이터를 받았다.)

그리고 URL에 대한 인코딩이 필요한 경우이므로 encoding: URLEncoding.default으로 설정해야 한다. 

 

✅ 탈출 클로저를 () 을 통해 데이터를 담아서 실행해야 서버 통신 이후 코드를 작성할 수 있다.

 

 

필요한 곳에서 호출

앞서 작성한 APIManager에 대해 서버 통신이 필요한 곳에서 shared 프로퍼티로 접근하여 fetch 메서드를 실행하면 된다.

 

이 프로젝트는 MVVM 패턴을 적용한 프로젝트이고,

해당 패턴에서 비즈니스 로직은 ViewModel이 관리한다.

그리고 이 때, 서버 통신은 비즈니스 로직에 해당하므로 뷰모델에서 작성하면 된다.

 

import Foundation

final class ListViewModel {
    var list: CObservable<[Photo]> = CObservable([])
    
    func requestPhotoList() {
        ListAPIManager.shared.fetchPhotoList { [weak self] photoList, statusCode, error in
            guard let self = self else { return }
            guard let photoList = photoList else { return }
            guard let statusCode = statusCode else { return }
            
            if statusCode == 200 {
                self.list.value = photoList
                dump(photoList)
            } else {
                print("🔴", statusCode)
            }
        }
    }
}

 

 

MVVM Pattern 

MVVM 패턴의 핵심은 뷰모델이다.

그리고 뷰모델에서 주의해야 할 것은, 뷰모델은 비즈니스 로직을 다루고 있는 부분으로 UI 관련 코드는 없어야 한다.

*즉, import UIKit를 하면 안된다. 

 

이 프로젝트에서 MVVM 패턴을 적용한 폴더링/파일 분리는 아래와 같다.

Model

Photo.swift

서버에서 전달 받은 응답 데이터 모델이다.

필요한 값만 전달 받아서 사용할 수 있다.

 

import Foundation

struct Photo: Codable, Hashable {
    let id: String
    let urls: Urls
    let likes: Int
    
    enum CodingKeys: String, CodingKey {
        case id
        case urls, likes
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.id = try container.decode(String.self, forKey: .id)
        self.urls = try container.decode(Urls.self, forKey: .urls)
        self.likes = try container.decode(Int.self, forKey: .likes)
    }
}

struct Urls: Codable, Hashable {
    let raw, full, regular, small: String
    let thumb, smallS3: String

    enum CodingKeys: String, CodingKey {
        case raw, full, regular, small, thumb
        case smallS3 = "small_s3"
    }
}

이후에 나오지만, 해당 데이터 타입은 DiffableDataSource의 타입으로 지정되는데, 이는 중복된 값이 있으면 안되므로 각 데이터 모델은 Hashable 프로토콜을 채택해야 한다. 

 

 

View

MVVM 패턴에서 뷰는 주로 Controller를 의미하고, 또는 Cell/HeaderView 등도 여기에 포함될 수 있다.

(관련 코드는 이후 DiffableDataSource/Compositional 설명 시 나오므로 여기서 생략 ..)

 

 

ViewModel

위에서 서버 통신 관련 설명을 할 때, 언급한 것과 같이 뷰모델은 비즈니스 로직을 담당하고 있다.

그리고 View에서 인터랙션을 받아, 뷰모델에게 전달하면 뷰모델이 비즈니스 로직을 처리해서 모델을 관리하고

다시 가공/처리된 모델이 뷰모델을 통해 뷰에게 전달되어 변화된 UI를 보여주는 것이다.

 

뷰모델에서 로직 처리를 한 뒤, 모델의 변화를 알리기 위해서 Observable 파일이 필요하다.

import Foundation

class CObservable<T> {
    private var listener: ((T) -> Void)?
    
    var value: T {
        didSet {
            listener?(value)
        }
    }
    
    init(_ value: T) {
        self.value = value
    }
    
    func bind(_ closure: @escaping (T) -> Void) {
        closure(value)
        listener = closure
    }
}

이 클래스를 통해 정의된 모델은 bind 함수를 통해 그 값이 변화될 때 특정한 로직을 수행할 수 있다.

 

 

이 프로젝트를 통해서 보자면, 

  1. 뷰모델에서 모델을 Observable 형태로 관리한다.
  2. 뷰(컨트롤러)에서 뷰모델을 통해 모델에 접근하여 bind 메서드를 정의한다. (모델이 변화될 때 어떻게 변하면 되는지를 작성)
  3. 그리고 뷰모델은 상황에 따라 모델을 변화시킨다.

ViewModel.swift

var list: CObservable<[Photo]> = CObservable([])

뷰모델에서 위와 같이 사진 리스트에 대한 모델을 생성한다.

 

 

ViewModel.swift

if statusCode == 200 {
   self.list.value = photoList
   dump(photoList)
}

그리고 서버 통신이 성공한 경우 모델의 값을 변화한다. 

 

 

Controller.swift

  viewModel.list.bind { [weak self] value in
      guard let self = self else { return }
      
      var snapshot = NSDiffableDataSourceSnapshot<Int, Photo>()
      snapshot.appendSections([0])
      snapshot.appendItems(value)
      
      self.dataSource.apply(snapshot)
  }

그리고 뷰에서 bind 함수를 작성해서 값이 변화하면 즉, 서버 통신 이후 사진 리스트가 변화되면 그 값이 value로 들어오고

value가 컬렉션 뷰에 보여지도록 한다. 

 

이렇게 작성하면, 모델이 변화할 때마다 Observable을 통해 bind 함수가 실행되어 바로 업데이트 된다.

 

 

DiffableDataSource/CompositionalLayout

새로운 컬렉션뷰 API로,

  • DiffableDataSource는 UICollectionViewDataSource를 상속 받고 있고
  • CompositionalLayout은 UICollectionViewLayout을 상속 받고 있다.

 

차이점이 있다면 .. (조금 .. 어색하다는 것과 ..)

기존의 방식은 index를 기준으로 관리가 되었다면 새로운 API는 데이터를 기준으로 관리가 된다는 점이다. 

(그래서 기존과 다르게 타입을 제대로 명시해야 한다.)

 

 

구현하는 순서는 아래와 같다.

(새로운 API의 주의할 점 중 하나는 순서를 제대로 작성해야 한다. 앞서 선언이 되고, 이후에 실행을 해야 fetal error가 나지 않기 때문이다.)

  1. 컬렉션 뷰의 레이아웃을 지정한다.
  2. 셀을 등록한다.
  3. 해당 셀을 어떻게 넣을지 데이터 소스로 관리한다.
  4. 어떤 데이터를 넣을지 데이터 소스로 관리한다.

 

Compositional Layout

private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout())

먼저 위와 같이 컬렉션 뷰를 만들어주는데, 레이아웃 관련 코드를 먼저 작성해야 한다.

 

extension ListViewController {
    private func createLayout() -> UICollectionViewLayout {
        let configuration = UICollectionViewCompositionalLayoutConfiguration()
        let layout = createCompositionalLayout()
        layout.configuration = configuration
        return layout
    }
    
    private func createCompositionalLayout() -> UICollectionViewCompositionalLayout {
        return UICollectionViewCompositionalLayout { (sectionIndex, NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
            let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
                                                  heightDimension: .fractionalHeight(1))
            let item = NSCollectionLayoutItem(layoutSize: itemSize)
            
            let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
                                                   heightDimension: .absolute(140))
            let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
            
            let section = NSCollectionLayoutSection(group: group)
            return section
        }
    }
}

컴포지셔널 레이아웃을 이용해서 컬렉션 뷰의 레이아웃을 만들 것이다 (이전에는 FlowLayout으로 지정했던 것)

 

그리고 컴포지셔널 레이아웃은 item과 group을 기준으로 각 셀의 레이아웃을 지정하기 때문에 위와 같이 코드를 작성한다. 

사진 리스트 UI의 경우 섹션은 하나만 사용할 것이기 때문에 별도의 섹션을 나눠서 관리하지 않는다.

 

Diffable Data Source

그리고 셀을 선언 후 등록한다.

 let cellRegistration = UICollectionView.CellRegistration<ListCollectionViewCell, Photo>.init { cell, indexPath, itemIdentifier in
     cell.setData(itemIdentifier)
 }

여기서 주의할 점은 타입을 명시해야한다. 그렇지 않으면 오류가 난다.

<ListCollectionViewCell, Photo>

순서대로 어떤 셀(클래스)을 사용할 것인지, 각 셀에 들어가는 데이터의 타입은 무엇인지 명시한다.

 

 

선언을 했다면, 컬렉션 뷰에 등록해야 한다. 이 때 diffable datasource가 사용된다.

private var dataSource: UICollectionViewDiffableDataSource<Int, Photo>!

 

두가지 데이터 타입이 선언되는 것을 볼 수 있는데 첫번째는 섹션에 대한 데이터이고 두번째는 하나의 셀에 들어가는 데이터의 타입이다. 

 

  dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView, cellProvider: { collectionView, indexPath, itemIdentifier in
      let cell = collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: itemIdentifier)
      return cell
  })

 

 

어떤 컬렉션 뷰에 셀을 등록할 것인지 지정하면 된다.

이 때, cellRegistration이 실제로 사용이 된다고 볼 수 있고 미리 만들어져야 하기 때문에 이 앞에 cellRegistration 코드가 작성되어야 한다.

 

Data

그리고 실제로 데이터가 어떻게 들어가면 되는지, 즉, 어떤 섹션에 어떤 데이터(배열)이 들어가면 되는지 작성하면 된다.

(뼈대가 만들어지고 그 안에 데이터가 들어가야 하므로 데이터를 넣어주는 snapshot 관련 코드가 가장 마지막에 작성되어야 한다.)

 

private func bindData() {
    viewModel.requestPhotoList()
    
    viewModel.list.bind { [weak self] value in
        guard let self = self else { return }
        
        var snapshot = NSDiffableDataSourceSnapshot<Int, Photo>()
        snapshot.appendSections([0])
        snapshot.appendItems(value)
        
        self.dataSource.apply(snapshot)
    }
}

뷰모델에서 서버 통신 관련 코드를 담당하므로 먼저 호출을 하면, 데이터가 올 것이고 

bind를 통해 데이터가 바뀔 때마다 그 안의 클로저 구문이 실행될 것이다. 

 

이 때 snapshot을 통해 데이터를 업데이트 -> UI 업데이트 를 하는 것이다.

var snapshot = NSDiffableDataSourceSnapshot<Int, Photo>()

스냅샷을 선언하고 

 

 snapshot.appendSections([0])
 snapshot.appendItems(value)

0번째 섹션에 photo list 배열이 들어간다. (= 각 인덱스 하나마다 배열 하나의 요소가 들어간다고 볼 수 있다.)

 

self.dataSource.apply(snapshot)

그리고 이를 적용해주면 끝 !!

 

ViewController의 전체 코드를 보고 싶다면 아래 [더보기]를 누르면 된다.

더보기
import UIKit

import SnapKit
import Then

final class ListViewController: UIViewController {
    
    // MARK: - UI Property
    
    private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout())
    
    // MARK: - Property
    
    private var dataSource: UICollectionViewDiffableDataSource<Int, Photo>!
    
    private let viewModel = ListViewModel()
    
    // MARK: - Life Cycle

    override func viewDidLoad() {
        super.viewDidLoad()
        configureHierachy()
        configureDataSource()
        
        bindData()
    }
    
    // MARK: - CollectionView
    
    private func configureHierachy () {
        view.addSubview(collectionView)
        
        collectionView.snp.makeConstraints { make in
            make.top.leading.trailing.bottom.equalTo(view.safeAreaLayoutGuide)
        }
    }
    
    private func configureDataSource() {
        let cellRegistration = UICollectionView.CellRegistration<ListCollectionViewCell, Photo>.init { cell, indexPath, itemIdentifier in
            cell.setData(itemIdentifier)
        }
        
        dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView, cellProvider: { collectionView, indexPath, itemIdentifier in
            let cell = collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: itemIdentifier)
            return cell
        })
    }
    
    // MARK: - Data
    
    private func bindData() {
        viewModel.requestPhotoList()
        
        viewModel.list.bind { [weak self] value in
            guard let self = self else { return }
            
            var snapshot = NSDiffableDataSourceSnapshot<Int, Photo>()
            snapshot.appendSections([0])
            snapshot.appendItems(value)
            
            self.dataSource.apply(snapshot)
        }
    }
}

extension ListViewController {
    private func createLayout() -> UICollectionViewLayout {
        let configuration = UICollectionViewCompositionalLayoutConfiguration()
        let layout = createCompositionalLayout()
        layout.configuration = configuration
        return layout
    }
    
    private func createCompositionalLayout() -> UICollectionViewCompositionalLayout {
        return UICollectionViewCompositionalLayout { (sectionIndex, NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
            let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
                                                  heightDimension: .fractionalHeight(1))
            let item = NSCollectionLayoutItem(layoutSize: itemSize)
            
            let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
                                                   heightDimension: .absolute(140))
            let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
            
            let section = NSCollectionLayoutSection(group: group)
            return section
        }
    }
}

 

이렇게 해서 ..

MVVM 패턴의, DiffableDataSource와 CompositionalLayout으로 UI를 구성한, Alamofire로 서버 통신을 하는 프로젝트를 만들 수 있다.