포스트

10주차 과제 (10)

예외처리도 끝났고, 남은건 내가 원래 과제에서 제출하고자 했던

DataSource → DiffableDataSource의 변환과,

튜터님의 피드백인 VM에서 Network 와, Coredata 함수의 분리를 하고 이번 과제의 종점을 찍으려 한다.

DataSource → DiffableDataSource로 변환.

1. 기존방식.

Before CleanShot 2024-05-11 at 19 51 42@2x

UI가 Controller에 각 Section에 Item들은 몇개인지, contents를 렌더링 할때 Cell 제공하라고 요청

CleanShot 2024-05-11 at 19 54 04@2x

Controller가 Response에 관한 웹서비스 요청을 가지고 있는 경우(API)엔 조금 까다로워진다.

CleanShot 2024-05-11 at 19 55 17@2x

웹 서비스 응답을 통해 data의 변경사항을 알린다면, 변경사항을 반영 하기 위해 UI를 업데이트 하여 변경사항이 반영 될 수 있다.

CleanShot 2024-05-11 at 19 56 13@2x

UI 업데이트가 실패할 경우 이런 에러를 마주하게 된다.

이때 reloadData()를 호출하여 문제를 해결 할지도 모른다.

출처

기존에는 위와 같이 했는데 WWDC에서 DiffableDatasource를 소개했다.

2. DiffableDataSource 란?

이건 TableViewDataSource

CleanShot 2024-05-11 at 19 03 21@2x

  • 테이블 뷰들은 데이터의 표시만 관리하며 데이터 자체는 관리하지 않습니다. 데이터를 관리하기 위해 UITableViewDataSource 프로토콜을 구현하는 개체인 데이터 소스 개체를 테이블에 제공합니다. 데이터 소스 개체는 테이블의 데이터 관련 요청에 응답합니다. 또한 테이블의 데이터를 직접 관리하거나 앱의 다른 부분과 조정하여 데이터를 관리합니다. 데이터 소스 개체의 다른 책임은 다음과 같습니다.
    • 테이블에서 섹션과 행의 수를 보고합니다.
    • 각 행의 각 행의 셀을 제공합니다.
    • 섹션 헤더 및 발명의 제목을 제공합니다.
    • 테이블 인덱스를 구성합니다.
    • 기본 데이터에 대한 변경 사항이 필요한 사용자 또는 테이블 시작 업데이트에 응답합니다.

(From, Docs) 이건 TableViewDiffableDataSource

CleanShot 2024-05-11 at 19 04 29@2x

  • DiffableDatasource 개체는 테이블 뷰 개체와 함께 작동하는 특수한 유형의 데이터 소스입니다. 테이블 뷰의 데이터와 UI에 대한 업데이트를 관리하는 데 필요한 동작을 간단하고 효율적인 방법으로 제공합니다. 또한 UITableViewDataSource 프로토콜을 준수하며 프로토콜의 모든 방법에 대한 구현을 제공합니다.
    • 테이블 보기에서 다양한 데이터 소스를 연결합니다.
    • 테이블 뷰의 셀 제공자를 구성합니다.
    • 데이터 현재 상태를 생성합니다.
    • UI에서 데이터를 표시합니다.

(From, Docs)

가장 큰 차이점이라면, 전자는 protocol 후자는 class 이다.

정의는 둘다 거의 같다고 볼 수 있다.

위에 Docs에서 가져온 내용이 있으니 한번 보는것도 좋다.

컬렉션뷰도 상동하므로 첨부는 하지 않는다.

그리고 우리는 DiffableDataSource에서 Hashable에 주목해야한다.

나중에 저걸 채택해야하기 때문.

3. 왜 DiffableDatasource를 쓰는가?

Hashable은 우리가 알고있는 그 Hash가 맞다.

Hash가 뭔데요? 난 처음듣는데?

원본 데이터를 특정한 규칙에따라 처리하여 숫자로 만든 것 Hash값이 같다는건 두 데이터가 같다는것.

Hashable을 사용함으로써 데이터의 서로 다름을 보장한다는것.

뭐 이정도로 생각하면 되겠다.

다시 돌아와서 DiffableDatasource는 새로운 Snapshot 데이터 유형을 추가하여 UI 상태 관리를 크게 단순화 한다.

snapshot은 고유한 sectionitem 식별자를 사용하여 전체 UI 상태를 캡슐화한다.

Bold로 한게 포인트.

따라서 TableView, CollectionView를 업데이트 할때 먼저 새로운 Snapshot을 생성하고 현재 UI 상태로 채우고 Datasource에 적용한다.

장점

  1. 애니메이션
    • 데이터를 추가, 업데이트, 삭제할 때마다 자동으로 데이터 변경 애니메이션이 적용된다.
  2. 자동 데이터 동기화
    • UI 데이터의 동기화 부분 대신 앱의 동적인 데이터와 내용에 집중할 수 있다. -Centralized Truth를 사용하기 때문에 UI와 데이터소스간의 Truth가 맞지 않아 크래시가 발생하는 일이 없음
  3. 코드감소
    • 전반적으로 더 적은 코드를 작성할 수 있다.

출처

CleanShot 2024-05-11 at 20 28 35@2x

CleanShot 2024-05-11 at 20 29 02@2x

CleanShot 2024-05-11 at 20 29 14@2x

1. Section Snapshots

CollectionView와 TableView의 단일 섹션에 대한 데이터를 캡슐화 한다.

  1. data source를 섹션크기의 덩어리로 구성 할 수 있게 한다.
  2. outline-style UI 렌더링(iOS14 전체에서 볼 수 있는 공통적인 시각적인 디자인)을 지원하는 데 필요한 계층적 데이터 모델링을 허용하기 위해

출처

4. 적용 (WWDC)

CleanShot 2024-05-11 at 20 31 27@2x

CleanShot 2024-05-11 at 20 30 37@2x

CleanShot 2024-05-11 at 20 30 54@2x

CleanShot 2024-05-11 at 20 31 06@2x

CleanShot 2024-05-11 at 20 32 20@2x

CleanShot 2024-05-11 at 20 32 35@2x

CleanShot 2024-05-11 at 20 32 51@2x

WWDC의 pdf를 그대로 가져와서 옮겼는데, 이제 이걸 실제로 적용을 해보려 한다.

5. 실제 적용.

우선 우리가 적용한 모델에 Hashable 프로토콜을 채택해준다.

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
struct BookModel: Codable, Hashable { // modified
    
    var meta: Meta
    var documents: [Document]
    
}

struct Meta: Codable, Hashable { // modified
    
    var isEnd: Bool
    var pageableCount: Int
    var totalCount: Int
    
    enum CodingKeys: String, CodingKey {
        case isEnd = "is_end"
        case pageableCount = "pageable_count"
        case totalCount = "total_count"
    }
    
    init(isEnd: Bool, pageableCount: Int, totalCount: Int) {
        self.isEnd = isEnd
        self.pageableCount = pageableCount
        self.totalCount = totalCount
    }
}

struct Document: Codable,Hashable { // modified
    
    var authors: [String]
    var contents: String
    var price: Int
    var title: String
    var thumbnail: String
    
    
    init(authors: [String], contents: String, price: Int, title: String, thumbnail: String) {
        self.authors = authors
        self.contents = contents
        self.price = price
        self.title = title
        self.thumbnail = thumbnail
    }
}

DiffableModel이라는 파일을 하나 만들어주고

그 다음 섹션과, 섹션의 아이템을 열거형으로 만들어 준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
enum DiffableSectionModel {
    
    case recent
    case search
    
}

enum DiffableSectionItemModel: Hashable {
    
    case recent(RecentModel)
    case search(Document)
    
}

이때 ItemModel에는, 이전에 TableView와 CollectionView에서 사용하던 item Type을 가져왔다.

이때 ItemModel에는 반드시 Hashable을 해준다.

이제 DiffableDatasource 를 만든다.

Diffable Extension을 하나 만들어 주었다.

거기에 적용을 해볼 예정.

Datasource를 만들때 UIkit을 import를 할수밖에 없는 상황이 발생하므로 VM에서 변경한다.

이제 DiffableDatasource 객체를 하나 만들어준다.

CleanShot 2024-05-11 at 21 44 56@2x

extension에서는 불가하므로 이녀석만 mainVC로 이동

1
2
    var tableDatasource: UITableViewDiffableDataSource<DiffableSectionModel, DiffableSectionItemModel>?
    var collectinDatasource: UICollectionViewDiffableDataSource<DiffableSectionModel, DiffableSectionItemModel>?

미리 둘다 만들어 준다

옵셔널인 이유는 ? 를 안하면 Initializing이 필요하기 때문

extension으로 가서 함수를 하나 만들어 준다.

1. TableView

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 func configureDiffableDataSource () {
        tableDatasource = UITableViewDiffableDataSource(tableView: resultView.tableView, cellProvider: { tableView, indexPath, itemIdentifier in
            
            switch itemIdentifier {
            case .search(let document):
                let cell = tableView.dequeueReusableCell(withIdentifier: Constants.tableViewCellIdentifier, for: indexPath) as! ResultTableViewCell
                
                cell.configure(model: document)
                
                return cell
            case .recent(_): // recent는 CollectionView에 사용할것
                return UITableViewCell()
            }
            
        })
    }

item에 search, recent 두개를 한번에 해두어서

recent일 때는 그냥 UItableviewcell을 리턴하게 했다.

그다음엔 snapshot을 적용할 함수를 만들어 준다.

함수를 만들다가 내방식과 튜터님의 방식이 적용이 안된다는걸 파악했고, 다시 고친다.

우선 sectionitemModel을 지웠다.

그리고

var tableDatasource: UITableViewDiffableDataSource<DiffableSectionModel, Document>? datasource도 item을 그냥 document로 반환시킨다.

그리고 diffable에 적용할 함수를 구현한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func configureDiffableDataSource () {
        tableDatasource = UITableViewDiffableDataSource(tableView: resultView.tableView, cellProvider: { tableView, indexPath, itemIdentifier in
            
            let cell = tableView.dequeueReusableCell(withIdentifier: Constants.tableViewCellIdentifier, for: indexPath) as! ResultTableViewCell
            
            cell.configure(model: itemIdentifier)
            cell.selectionStyle = .none
            
            return cell
        })
    }
    
    func configureSnapshot() {
        var snapshot = NSDiffableDataSourceSnapshot<DiffableSectionModel, Document>()
        snapshot.appendSections([.search])
        snapshot.appendItems(searchVM.document)
        
        tableDatasource?.apply(snapshot)
    }

그 후, VC의 bind에서

1
2
3
4
5
6
searchVM.$document
            .receive(on: DispatchQueue.main)
            .sink { [weak self] _ in
                self?.configureSnapshot() // modified
            }
            .store(in: &cancellables)

reloaddata에서 이렇게 스냅샷 적용으로 바꿔준다.

작동 완료.

이제 tableview의 cellForRowAt, numberOfRowsInSection은 필요가 없어졌다.

하지만 무한스크롤은 필요해서 그부분만 제외하고 나머지는 살려둔다.

wishlistVC도 상동

2. CollectionView

이것도 역시 var collectionDatasource: UICollectionViewDiffableDataSource<DiffableSectionModel, RecentModel>? 이렇게 실제 사용한 모델로만 해준다.

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
func configureDiffableDataSource () {
        tableDatasource = UITableViewDiffableDataSource(tableView: resultView.tableView, cellProvider: { tableView, indexPath, itemIdentifier in
            
            let cell = tableView.dequeueReusableCell(withIdentifier: Constants.tableViewCellIdentifier, for: indexPath) as! ResultTableViewCell
            
            cell.configure(model: itemIdentifier)
            cell.selectionStyle = .none
            
            return cell
        })
        
        // added
        collectionDatasource = UICollectionViewDiffableDataSource(collectionView: recentView.collectionView, cellProvider: { collectionView, indexPath, itemIdentifier in
            
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Constants.collectionViewCellIdentifier, for: indexPath) as! RecentCollectionViewCell
            
            cell.configure(model: itemIdentifier)
            
            return cell
        })
        
    }
    
func collectionConfigureSnapshot () {
        var collectionSnapshot = NSDiffableDataSourceSnapshot<DiffableSectionModel, RecentModel>()
        collectionSnapshot.appendSections([.recent])
        collectionSnapshot.appendItems(recentVM.recentDocument)
        
        collectionDatasource?.apply(collectionSnapshot, animatingDifferences: true)
    }
1
Fatal: supplied item identifiers are not unique. Duplicate identifiers

문제가 발생

이건 나중에 다시 해결해야할듯 하다.

피드백 보완

Service Group 생성

DiffableDatasource를 검색하다 보니 VM에는 UIKit도 import할 이유가 없다고 한다.

생각해보니, 그게 맞는말이다 UIKit는 View와 관련이 있는데, 이걸 할 필요가 없었다. 근데 지금 RecentVM이나 WishVM의 경우 Coredata를 쓰기위해 Context를 만들다보니 자연스럽게 import UIKit를 하고있다.

그래서 이부분도 고려하셔서 피드백을 주신게 아닌가? 라는 생각이 든다.

특히 network의 경우는 처음에 나누었다가 나중에, VM으로 넣어버렸는데, 아직도 공부가 더 필요하다는 생각이 든다.

1. NetworkManager 이관

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
31
32
33
34
35
36
37
38
39
40
41
42
43
class NetworkManager {
    
    static let shared = NetworkManager()
    
    private init () {}
    
    let searchVM = SearchVM()
    
    func fetchTotalRequest(queryValue: String, page: Int, cancellables: Set<AnyCancellable>) {
        
        let urlString = "https://dapi.kakao.com/v3/search/book?target=title"
        let headers = ["Authorization" : "KakaoAK \(Secret.apikey)"]
        
        var urlComponent = URLComponents(string: urlString)
        urlComponent?.queryItems?.append(URLQueryItem(name: "query", value: queryValue))
        urlComponent?.queryItems?.append(URLQueryItem(name: "page", value: page.stringValue))
        
        guard let url = urlComponent?.url else {
            return
        }
        
        var request = URLRequest(url: url)
        request.allHTTPHeaderFields = headers
        request.httpMethod = "GET"
        
        let session = URLSession(configuration: .default)
        session.dataTaskPublisher(for: request)
            .map(\.data)
            .decode(type: BookModel.self, decoder: JSONDecoder())
            .eraseToAnyPublisher()
            .map { model in
                if model.meta.isEnd == false {
                    return model.documents
                } else {
                    return []
                }
            }
            .replaceError(with: [])
            .assign(to: \.document, on: searchVM)
            .store(in: &searchVM.cancellables)
    }
    
}

다음과 같이 만들어 준다.

하지만 문제는 검색결과 값이 넘어가지 않는 상태이다.

YouTube를 보고 내용을 좀 수정해야할 필요성을 느낀다.

문득 이부분을 Completion Handler를 통해서 전달을 해볼까 했는데, 위와같은 방법이 있어서 적용을 해보려한다.

안그래도 Future 써보고 싶었는데 잘되었다.

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class NetworkManager {
    
    static let shared = NetworkManager()
    
    private init () {}
    
    let searchVM = SearchVM()
    
    private var cancellables = Set<AnyCancellable>()
    
    func fetchTotalRequest(queryValue: String, page: Int) -> Future<[Document], Error> {
        
        return Future<[Document], Error> { [weak self] complete in
            
            let urlString = "https://dapi.kakao.com/v3/search/book?target=title"
            let headers = ["Authorization" : "KakaoAK \(Secret.apikey)"]
            
            var urlComponent = URLComponents(string: urlString)
            urlComponent?.queryItems?.append(URLQueryItem(name: "query", value: queryValue))
            urlComponent?.queryItems?.append(URLQueryItem(name: "page", value: page.stringValue))
            
            guard let url = urlComponent?.url else {
                return
            }
            
            var request = URLRequest(url: url)
            request.allHTTPHeaderFields = headers
            request.httpMethod = "GET"
            
            let session = URLSession(configuration: .default)
            session.dataTaskPublisher(for: request)
                .map(\.data)
                .decode(type: BookModel.self, decoder: JSONDecoder())
                .eraseToAnyPublisher()
                .map { model in
                    if model.meta.isEnd == false {
                        return model.documents
                    } else {
                        return []
                    }
                }
                .replaceError(with: [])
                .sink(receiveValue: { document in
                    complete(.success(document))
                })
                .store(in: &self!.cancellables)
        }
    }    
}

우선 다음과 같이 코드를 수정하고.

VM에서도 다음과 같이 수정했다.

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
 func transform(input: Input) {
        
        Publishers.CombineLatest(input.searchPublisher, input.numberPublisher)
            .map { [unowned self] (value, page) in
                if value.isEmpty {
                    currentPage = 1
                    document = []
                }
                return (value, currentPage)
            }
            .eraseToAnyPublisher()
            .print()
            .sink { [weak self] (value, page) in
                guard !value.isEmpty else { return } // value가 빈 문자열인 경우 fetchTotalRequest 호출하지 않음
                NetworkManager.shared.fetchTotalRequest(queryValue: value, page: page).sink { completion in
                    switch completion {
                    case .finished:
                        print("success")
                    case .failure(let error):
                        print("erorr is \(error)")
                    }
                } receiveValue: { document in
                    self!.document = document
                }.store(in: &self!.cancellables)

            }
            .store(in: &cancellables)
        
    }

우선 출력은 되나, document에 바꿔치기가 되므로 해당 부분을 append하게 해줘야한다.

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
 func transform(input: Input) {
        
        Publishers.CombineLatest(input.searchPublisher, input.numberPublisher)
            .map { [unowned self] (value, page) in
                if value.isEmpty {
                    currentPage = 1
                    document = []
                }
                return (value, currentPage)
            }
            .eraseToAnyPublisher()
            .print()
            .sink { [weak self] (value, page) in
                guard !value.isEmpty else { return } // value가 빈 문자열인 경우 fetchTotalRequest 호출하지 않음
                NetworkManager.shared.fetchTotalRequest(queryValue: value, page: page).sink { completion in
                    switch completion {
                    case .finished:
                        print("success")
                    case .failure(let error):
                        print("erorr is \(error)")
                    }
                } receiveValue: { document in
                    document.forEach { doc in
                        self!.document.append(doc)
                    }
                }.store(in: &self!.cancellables)
            }
            .store(in: &cancellables)
        
    }

receiveValue에 다음과 같이 결과를 하나씩 append하게 바꿔주었다.

작동 확인 완료.

2. CoreDataManager 이관

이건 Recent / Wish VM 에 해당하는 부분을 옮기면 될 것 같다.

이것도 역시 그냥 singleton pattern을 사용해서 할 예정

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
func saveWishDocumentToCoredata (data: Document) {
        
        let newItem = WishListModel(context: context)
        newItem.title = data.title
        newItem.author = data.authors[0]
        newItem.content = data.contents
        newItem.image = data.thumbnail
        newItem.price = Int64(data.price)
        
        do {
            try context.save()
            print("담기 완료")
        } catch {
            routerSubject.send(Router.alert(title: "예외 발생", message: "\(error.localizedDescription) 이 발생했습니다."))
        }
        
    }
    
    func getWishDocumentfromCoreData () -> Future<[WishListModel] ,Error>{
        
        return Future<[WishListModel], Error> { [unowned self] complete in
            do {
                try context.fetch(wishRequest).publisher.flatMap { data in
                    Publishers.Sequence(sequence: [data])
                }
                .collect()
                .eraseToAnyPublisher()
                .sink(receiveValue: { model in
                    complete(.success(model))
                })
                .store(in: &cancellables)
            } catch {
                routerSubject.send(Router.alert(title: "예외 발생", message: "\(error.localizedDescription) 이 발생했습니다."))
            }
        }
    }
    
    func getSpecificData (title: String) -> Future<[WishListModel] ,Error> {
        
        return Future<[WishListModel], Error> { [unowned self] complete in
            
            let predicateRequest: NSFetchRequest<WishListModel> = WishListModel.fetchRequest()
            let predicate = NSPredicate(format: "title == %@", title)
            predicateRequest.predicate = predicate
            
            do {
                try context.fetch(predicateRequest).publisher.flatMap { data in
                    Publishers.Sequence(sequence: [data])
                }
                .collect()
                .eraseToAnyPublisher()
                .sink(receiveValue: { model in
                    complete(.success(model))
                })
                .store(in: &cancellables)
            } catch {
                routerSubject.send(Router.alert(title: "예외 발생", message: "\(error.localizedDescription) 이 발생했습니다."))
            }
        }
    }
    
    func deleteAllData () {
        let fetchRequest: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: "WishListModel")
        let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
        
        do {
            try context.execute(deleteRequest)
            try context.save()
        } catch {
            routerSubject.send(Router.alert(title: "예외 발생", message: "\(error.localizedDescription) 이 발생했습니다."))
        }
    }
    
    func deleteSpeificData (selectedCell: NSManagedObject) {
        do {
            try context.delete(selectedCell)
            try context.save()
        } catch {
            routerSubject.send(Router.alert(title: "예외 발생", message: "\(error.localizedDescription) 이 발생했습니다."))
        }
        
    }

다음과 같이 코드를 모두 이관시켜준다

특징이라면 network와 같이 배열에 저장해야하는경우엔 Future를 사용하여 리턴시켰다.

VM도 다음과 같이 정리

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
func checkDuplicate (title: String) -> Bool {
        
        var flag = false
        getSpecificDocument(title: title)
        
        if wishDocument.isEmpty {
            flag = false
        } else {
            flag = true
        }
        return flag
    }
    
    func getWholeDocument () {
        CoredataManager.shared.getWishDocumentfromCoreData().sink { complete in
            switch complete {
            case .finished:
                return
            case .failure(let error):
                print(error)
            }
        } receiveValue: { [weak self] model in
            self?.wishDocument = model
        }
        .store(in: &cancellables)

    }
    
    func getSpecificDocument (title: String) {
        CoredataManager.shared.getSpecificData(title: title).sink { complete in
            switch complete {
            case .finished:
                return
            case .failure(let error):
                print(error)
            }
        } receiveValue: { [weak self] model in
            self?.wishDocument = model
        }
        .store(in: &cancellables)

    }
    
    func deleteSelectedData(selectedCell: NSManagedObject) {
        CoredataManager.shared.deleteSpeificData(selectedCell: selectedCell)
    }
    
    func removeAllData () {
        CoredataManager.shared.deleteAllData()
    }

생각해보니 coredata에 저장하는것도 vm에서 해도 될것같아서 이렇게 적는다.

1
2
3
func saveDataToRecent (data: Document) {
        CoredataManager.shared.saveRecentDocumentToCoredata(data: data)
    }

paramter가 꼬리에 꼬리를 물어도 어차피 같은 타입이므로 괜찮기 때문.

완료.

예외처리만 하면 될듯하다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.