From e8cb6f6123e2927bba3530630c696512889dc98a Mon Sep 17 00:00:00 2001 From: MaraMincho <103064352+MaraMincho@users.noreply.github.com> Date: Fri, 12 Jan 2024 10:58:59 +0900 Subject: [PATCH] =?UTF-8?q?[GWL-405]=20=EB=AC=B4=ED=95=9C=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#439)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: HomeFeature DownSampling 모듈 추가 * feat: 다운 샘플링 적용 * feat: ImageCacher 및 PrepareForReuse 적용 * feat: Repository And UseCase 파일 생성 * feat: FetchCheckManager 구현 * move: 파일 위치 변경 * feat: coordaintor 및 UseCase 적용 * feat: 무한 스크롤 구현 * delete: 목 데이터 삭제 * feat: 화면에 피드가 표시되었을 때 Page넘버를 증가하는 기능 추가 * delete: Home화면 Resources 폴더 제거 * style: 피드백 적용 및 쓰지 않는 import문 제거 --- .../Coordinator/AppCoordinator.swift | 2 +- .../Coordinator/TabBarCoordinator.swift | 9 +- iOS/Projects/Features/Home/Project.swift | 11 ++- .../Home/Sources/Data/FeedRepository.swift | 66 +++++++++++++ .../Home/Sources/Data/HomeRepository.swift | 9 -- .../Sources/Domain/Entity/FeedElement.swift | 2 +- .../Home/Sources/Domain/HomeUseCase.swift | 60 ++++++++++++ .../FeedRepositoryRepresentable.swift | 16 ++++ .../Coordinator/HomeCoordinator.swift | 6 +- .../HomeScene/VIew/FeedImageCell.swift | 21 +++-- .../VIew/FeedItemCollectionViewCell.swift | 11 ++- .../ViewController/HomeViewController.swift | 93 ++++++++++--------- .../HomeScene/ViewModel/HomeViewModel.swift | 31 ++++++- 13 files changed, 270 insertions(+), 67 deletions(-) create mode 100644 iOS/Projects/Features/Home/Sources/Data/FeedRepository.swift delete mode 100644 iOS/Projects/Features/Home/Sources/Data/HomeRepository.swift create mode 100644 iOS/Projects/Features/Home/Sources/Domain/RepositoryInterface/FeedRepositoryRepresentable.swift rename iOS/Projects/Features/Home/Sources/{ => Presntaion}/Coordinator/HomeCoordinator.swift (84%) diff --git a/iOS/Projects/App/WeTri/Sources/CommonScene/Coordinator/AppCoordinator.swift b/iOS/Projects/App/WeTri/Sources/CommonScene/Coordinator/AppCoordinator.swift index fbe43751..6d3a67bb 100644 --- a/iOS/Projects/App/WeTri/Sources/CommonScene/Coordinator/AppCoordinator.swift +++ b/iOS/Projects/App/WeTri/Sources/CommonScene/Coordinator/AppCoordinator.swift @@ -29,7 +29,7 @@ final class AppCoordinator: AppCoordinating { } func start() { - showSplashFlow() + showTabBarFlow() } private func showSplashFlow() { diff --git a/iOS/Projects/App/WeTri/Sources/TabBarScene/Coordinator/TabBarCoordinator.swift b/iOS/Projects/App/WeTri/Sources/TabBarScene/Coordinator/TabBarCoordinator.swift index c177f7cc..3d2dcffc 100644 --- a/iOS/Projects/App/WeTri/Sources/TabBarScene/Coordinator/TabBarCoordinator.swift +++ b/iOS/Projects/App/WeTri/Sources/TabBarScene/Coordinator/TabBarCoordinator.swift @@ -40,7 +40,7 @@ final class TabBarCoordinator: TabBarCoordinating { } func start() { - let tabBarViewControllers = [TabBarPage.record, TabBarPage.profile].map { + let tabBarViewControllers = TabBarPage.allCases.map { return makePageNavigationController(page: $0) } let tabBarController = makeTabBarController(tabBarViewControllers: tabBarViewControllers) @@ -58,6 +58,13 @@ final class TabBarCoordinator: TabBarCoordinating { private func startTabBarCoordinator(page: TabBarPage, pageNavigationViewController: UINavigationController) { switch page { + case .home: + let coordinator = HomeCoordinator( + navigationController: pageNavigationViewController, + delegate: self + ) + childCoordinators.append(coordinator) + coordinator.start() case .record: let recordCoordinator = RecordFeatureCoordinator( navigationController: pageNavigationViewController, diff --git a/iOS/Projects/Features/Home/Project.swift b/iOS/Projects/Features/Home/Project.swift index 211c08d4..e945f1b5 100644 --- a/iOS/Projects/Features/Home/Project.swift +++ b/iOS/Projects/Features/Home/Project.swift @@ -7,7 +7,16 @@ let project = Project.makeModule( targets: .feature( .home, testingOptions: [.unitTest], - dependencies: [.designSystem, .log, .combineCocoa, .trinet, .combineExtension, .coordinator, .commonNetworkingKeyManager], + dependencies: [ + .designSystem, + .log, + .combineCocoa, + .trinet, + .combineExtension, + .coordinator, + .commonNetworkingKeyManager, + .downSampling, + ], testDependencies: [] ) ) diff --git a/iOS/Projects/Features/Home/Sources/Data/FeedRepository.swift b/iOS/Projects/Features/Home/Sources/Data/FeedRepository.swift new file mode 100644 index 00000000..4b4eea40 --- /dev/null +++ b/iOS/Projects/Features/Home/Sources/Data/FeedRepository.swift @@ -0,0 +1,66 @@ +// +// FeedRepository.swift +// HomeFeature +// +// Created by MaraMincho on 12/7/23. +// Copyright © 2023 kr.codesquad.boostcamp8. All rights reserved. +// + +import Combine +import CommonNetworkingKeyManager +import Foundation +import Trinet + +// MARK: - FeedRepository + +public struct FeedRepository: FeedRepositoryRepresentable { + let decoder = JSONDecoder() + let provider: TNProvider + init(session: URLSessionProtocol = URLSession.shared) { + provider = .init(session: session) + } + + public func fetchFeed(at page: Int) -> AnyPublisher<[FeedElement], Never> { + return Future<[FeedElement], Error> { promise in + Task { [provider] in + do { + let data = try await provider.request(.fetchPosts(page: page), interceptor: TNKeychainInterceptor.shared) + let feedElementList = try decoder.decode([FeedElement].self, from: data) + promise(.success(feedElementList)) + } catch { + promise(.failure(error)) + } + } + } + .catch { _ in return Empty() } + .eraseToAnyPublisher() + } +} + +// MARK: - FeedEndPoint + +public enum FeedEndPoint: TNEndPoint { + case fetchPosts(page: Int) + public var path: String { + return "" + } + + public var method: TNMethod { + return .post + } + + public var query: Encodable? { + return nil + } + + public var body: Encodable? { + switch self { + case let .fetchPosts(page): + return page + } + } + + public var headers: TNHeaders { + return .default + } +} diff --git a/iOS/Projects/Features/Home/Sources/Data/HomeRepository.swift b/iOS/Projects/Features/Home/Sources/Data/HomeRepository.swift deleted file mode 100644 index a9482ac8..00000000 --- a/iOS/Projects/Features/Home/Sources/Data/HomeRepository.swift +++ /dev/null @@ -1,9 +0,0 @@ -// -// HomeRepository.swift -// HomeFeature -// -// Created by MaraMincho on 12/7/23. -// Copyright © 2023 kr.codesquad.boostcamp8. All rights reserved. -// - -import Foundation diff --git a/iOS/Projects/Features/Home/Sources/Domain/Entity/FeedElement.swift b/iOS/Projects/Features/Home/Sources/Domain/Entity/FeedElement.swift index d5ed5957..9349434d 100644 --- a/iOS/Projects/Features/Home/Sources/Domain/Entity/FeedElement.swift +++ b/iOS/Projects/Features/Home/Sources/Domain/Entity/FeedElement.swift @@ -8,7 +8,7 @@ import Foundation -struct FeedElement: Hashable { +public struct FeedElement: Hashable, Codable { /// 개시물의 아이디 입니다. let ID: Int diff --git a/iOS/Projects/Features/Home/Sources/Domain/HomeUseCase.swift b/iOS/Projects/Features/Home/Sources/Domain/HomeUseCase.swift index 77dc7570..c1c6e580 100644 --- a/iOS/Projects/Features/Home/Sources/Domain/HomeUseCase.swift +++ b/iOS/Projects/Features/Home/Sources/Domain/HomeUseCase.swift @@ -6,4 +6,64 @@ // Copyright © 2023 kr.codesquad.boostcamp8. All rights reserved. // +import Combine import Foundation + +// MARK: - HomeUseCaseRepresentable + +public protocol HomeUseCaseRepresentable { + func fetchFeed() -> AnyPublisher<[FeedElement], Never> + mutating func didDisplayFeed() +} + +// MARK: - HomeUseCase + +public struct HomeUseCase: HomeUseCaseRepresentable { + private let feedRepositoryRepresentable: FeedRepositoryRepresentable + + private var latestFeedPage = 0 + private var feedElementPublisher: PassthroughSubject<[FeedElement], Never> = .init() + private let checkManager: FetchCheckManager = .init() + + public init(feedRepositoryRepresentable: FeedRepositoryRepresentable) { + self.feedRepositoryRepresentable = feedRepositoryRepresentable + } + + public func fetchFeed() -> AnyPublisher<[FeedElement], Never> { + if checkManager[latestFeedPage] == false { + return Empty().eraseToAnyPublisher() + } + checkManager[latestFeedPage] = true + return feedElementPublisher.eraseToAnyPublisher() + } + + public mutating func didDisplayFeed() { + latestFeedPage += 1 + } +} + +// MARK: - FetchCheckManager + +final class FetchCheckManager { + subscript(page: Int) -> Bool { + get { + return check(at: page) + } + set { + set(at: page) + } + } + + private var requestedFetchPageNumber: [Int: Bool] = [:] + /// FetchCheckManager를 통해서 특정 페이지에 대해 요청을 했다고 저장합니다. + private func set(at page: Int) { + requestedFetchPageNumber[page] = true + } + + /// FetchCheckManager를 통해서, 특정 페이지에 대한 과거 요청에 대해서 살펴 봅니다. + /// + /// 만약 요청한 적이 없다면 false를, 요청을 했다면 true를 리턴 + private func check(at page: Int) -> Bool { + return requestedFetchPageNumber[page] ?? false + } +} diff --git a/iOS/Projects/Features/Home/Sources/Domain/RepositoryInterface/FeedRepositoryRepresentable.swift b/iOS/Projects/Features/Home/Sources/Domain/RepositoryInterface/FeedRepositoryRepresentable.swift new file mode 100644 index 00000000..164e4122 --- /dev/null +++ b/iOS/Projects/Features/Home/Sources/Domain/RepositoryInterface/FeedRepositoryRepresentable.swift @@ -0,0 +1,16 @@ +// +// FeedRepositoryRepresentable.swift +// HomeFeature +// +// Created by MaraMincho on 1/2/24. +// Copyright © 2024 kr.codesquad.boostcamp8. All rights reserved. +// + +import Combine +import Foundation + +// MARK: - FeedRepositoryRepresentable + +public protocol FeedRepositoryRepresentable { + func fetchFeed(at page: Int) -> AnyPublisher<[FeedElement], Never> +} diff --git a/iOS/Projects/Features/Home/Sources/Coordinator/HomeCoordinator.swift b/iOS/Projects/Features/Home/Sources/Presntaion/Coordinator/HomeCoordinator.swift similarity index 84% rename from iOS/Projects/Features/Home/Sources/Coordinator/HomeCoordinator.swift rename to iOS/Projects/Features/Home/Sources/Presntaion/Coordinator/HomeCoordinator.swift index 946bbf79..c59b8678 100644 --- a/iOS/Projects/Features/Home/Sources/Coordinator/HomeCoordinator.swift +++ b/iOS/Projects/Features/Home/Sources/Presntaion/Coordinator/HomeCoordinator.swift @@ -37,7 +37,11 @@ public final class HomeCoordinator: HomeCoordinating { } public func pushHome() { - let viewModel = HomeViewModel() + let repository = FeedRepository(session: URLSession.shared) + + let useCase = HomeUseCase(feedRepositoryRepresentable: repository) + + let viewModel = HomeViewModel(useCase: useCase) let viewController = HomeViewController(viewModel: viewModel) diff --git a/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/VIew/FeedImageCell.swift b/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/VIew/FeedImageCell.swift index c3c76f66..275b40b5 100644 --- a/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/VIew/FeedImageCell.swift +++ b/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/VIew/FeedImageCell.swift @@ -6,6 +6,7 @@ // Copyright © 2023 kr.codesquad.boostcamp8. All rights reserved. // +import Cacher import UIKit // MARK: - FeedImageCell @@ -24,6 +25,12 @@ final class FeedImageCell: UICollectionViewCell { fatalError("cant use this init") } + override func prepareForReuse() { + super.prepareForReuse() + + feedImage.image = nil + } + private func setupViewHierarchyAndConstraints() { contentView.addSubview(feedImage) feedImage.topAnchor.constraint(equalTo: contentView.topAnchor).isActive = true @@ -43,13 +50,15 @@ final class FeedImageCell: UICollectionViewCell { guard let imageURL else { return } - - DispatchQueue.global().async { - guard let data = try? Data(contentsOf: imageURL) else { return } - DispatchQueue.main.async { [weak self] in - self?.feedImage.image = UIImage(data: data) - self?.layoutIfNeeded() + guard let data = MemoryCacheManager.shared.fetch(cacheKey: imageURL.absoluteString) else { + DispatchQueue.global().async { + guard let data = try? Data(contentsOf: imageURL) else { return } + DispatchQueue.main.async { [weak self] in + self?.feedImage.image = UIImage(data: data) + } } + return } + feedImage.image = UIImage(data: data) } } diff --git a/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/VIew/FeedItemCollectionViewCell.swift b/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/VIew/FeedItemCollectionViewCell.swift index 570a5712..a13df4b5 100644 --- a/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/VIew/FeedItemCollectionViewCell.swift +++ b/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/VIew/FeedItemCollectionViewCell.swift @@ -24,6 +24,12 @@ class FeedItemCollectionViewCell: UICollectionViewCell { fatalError("생성할 수 없습니다.") } + override func prepareForReuse() { + super.prepareForReuse() + + profileImage.image = nil + } + // MARK: - Property private var dataSource: UICollectionViewDiffableDataSource? = nil @@ -65,6 +71,7 @@ class FeedItemCollectionViewCell: UICollectionViewCell { label.text = "2023.12.07" label.font = .preferredFont(forTextStyle: .subheadline) label.textColor = DesignSystemColor.primaryText + label.contentMode = .scaleAspectFit label.translatesAutoresizingMaskIntoConstraints = false return label @@ -310,10 +317,10 @@ extension FeedItemCollectionViewCell { return } + let url = url.compactMap { $0 } snapshot.deleteAllItems() snapshot.appendSections([0]) - let url = url.compactMap { $0 } - snapshot.appendItems(Array(Set(url)), toSection: 0) + snapshot.appendItems(url, toSection: 0) dataSource?.apply(snapshot) } } diff --git a/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewController/HomeViewController.swift b/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewController/HomeViewController.swift index b2151e38..3a5f7f1a 100644 --- a/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewController/HomeViewController.swift +++ b/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewController/HomeViewController.swift @@ -8,6 +8,7 @@ import Combine import DesignSystem +import Log import UIKit // MARK: - HomeViewController @@ -19,7 +20,12 @@ final class HomeViewController: UIViewController { private var subscriptions: Set = [] - var dataSource: UICollectionViewDiffableDataSource? = nil + private var dataSource: UICollectionViewDiffableDataSource? = nil + + private let fetchFeedPublisher: PassthroughSubject = .init() + private let didDisplayFeedPublisher: PassthroughSubject = .init() + + private var feedCount: Int = 0 // MARK: UI Components @@ -76,12 +82,26 @@ final class HomeViewController: UIViewController { private extension HomeViewController { func setup() { + setCollectionViewDelegate() setDataSource() + addSection() setupStyles() setupHierarchyAndConstraints() setNavigationItem() bind() - testCollectionViewDataSource() + fetchFeedPublisher.send() + } + + func setCollectionViewDelegate() { + feedListCollectionView.delegate = self + } + + func addSection() { + guard var snapshot = dataSource?.snapshot() else { + return + } + snapshot.appendSections([0]) + dataSource?.apply(snapshot) } func setDataSource() { @@ -111,11 +131,19 @@ private extension HomeViewController { } func bind() { - let output = viewModel.transform(input: .init()) - output.sink { state in + let output = viewModel.transform( + input: HomeViewModelInput( + requestFeedPublisher: fetchFeedPublisher.eraseToAnyPublisher(), + didDisplayFeed: didDisplayFeedPublisher.eraseToAnyPublisher() + ) + ) + + output.sink { [weak self] state in switch state { case .idle: break + case let .fetched(feed): + self?.updateFeed(feed) } } .store(in: &subscriptions) @@ -126,14 +154,18 @@ private extension HomeViewController { navigationItem.leftBarButtonItem = titleBarButtonItem } - func testCollectionViewDataSource() { + func updateFeed(_ item: [FeedElement]) { guard let dataSource else { return } var snapshot = dataSource.snapshot() - snapshot.appendSections([0]) - snapshot.appendItems(fakeData(), toSection: 0) - dataSource.apply(snapshot) + snapshot.appendItems(item) + DispatchQueue.main.async { [weak self] in + dataSource.apply(snapshot) + self?.didDisplayFeedPublisher.send() + } + + feedCount = snapshot.numberOfItems } enum Constants { @@ -156,40 +188,17 @@ private extension HomeViewController { return UICollectionViewCompositionalLayout(section: section) } +} + +// MARK: UICollectionViewDelegate - func fakeData() -> [FeedElement] { - return [ - .init( - ID: 1, - publicID: "", - nickName: "정다함", - publishDate: .now, - profileImage: URL(string: "https://i.ytimg.com/vi/fzzjgBAaWZw/hqdefault.jpg"), - sportText: "달리기", - content: "오운완. 오늘도 운동 조졌음. 기분은 좋네 ^^", - postImages: [ - URL(string: "https://cdn.seniordaily.co.kr/news/photo/202108/2444_1812_1557.jpg"), - URL(string: "https://t1.daumcdn.net/thumb/R1280x0/?fname=http://t1.daumcdn.net/brunch/service/guest/image/7MpZeU0-hBKjmb4tKFHR-Skd7bA.JPG"), - URL(string: "https://t1.daumcdn.net/brunch/service/guest/image/9xI2XnpJpggfVZV6l1opHBwyeqU.JPG"), - ], - like: 2 - ), - - .init( - ID: 2, - publicID: "", - nickName: "고양이 애호가", - publishDate: .now, - profileImage: URL(string: "https://ca.slack-edge.com/T05N9HAKPFW-U05PCNTCV9N-8bbbd8736a14-512"), - sportText: "수영", - content: "고양이 애호가입니다. 차린건 없지만 고양이 보고가세요", - postImages: [ - URL(string: "https://i.ytimg.com/vi/YCaGYUIfdy4/maxresdefault.jpg")!, - URL(string: "https://www.cats.org.uk/uploads/images/featurebox_sidebar_kids/grief-and-loss.jpg")!, - URL(string: "https://www.telegraph.co.uk/content/dam/pets/2017/01/06/1-JS117202740-yana-two-face-cat-news_trans_NvBQzQNjv4BqJNqHJA5DVIMqgv_1zKR2kxRY9bnFVTp4QZlQjJfe6H0.jpg?imwidth=450")!, - ], - like: 2 - ), - ] +extension HomeViewController: UICollectionViewDelegate { + func collectionView(_: UICollectionView, willDisplay _: UICollectionViewCell, forItemAt indexPath: IndexPath) { + + /// 사용자가 아직 보지 않은 셀의 갯수 + let toShowCellCount = (feedCount - 1) - indexPath.row + if toShowCellCount < 3 { + fetchFeedPublisher.send() + } } } diff --git a/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewModel/HomeViewModel.swift b/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewModel/HomeViewModel.swift index 71d2d326..25b337bc 100644 --- a/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewModel/HomeViewModel.swift +++ b/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewModel/HomeViewModel.swift @@ -11,7 +11,10 @@ import Foundation // MARK: - HomeViewModelInput -public struct HomeViewModelInput {} +public struct HomeViewModelInput { + let requestFeedPublisher: AnyPublisher + let didDisplayFeed: AnyPublisher +} public typealias HomeViewModelOutput = AnyPublisher @@ -19,6 +22,7 @@ public typealias HomeViewModelOutput = AnyPublisher public enum HomeState { case idle + case fetched(feed: [FeedElement]) } // MARK: - HomeViewModelRepresentable @@ -32,17 +36,38 @@ protocol HomeViewModelRepresentable { final class HomeViewModel { // MARK: - Properties + private var useCase: HomeUseCaseRepresentable private var subscriptions: Set = [] + var tempID: Int = 0 + init(useCase: HomeUseCaseRepresentable) { + self.useCase = useCase + } } // MARK: HomeViewModelRepresentable extension HomeViewModel: HomeViewModelRepresentable { - public func transform(input _: HomeViewModelInput) -> HomeViewModelOutput { + public func transform(input: HomeViewModelInput) -> HomeViewModelOutput { subscriptions.removeAll() + let fetched: HomeViewModelOutput = input.requestFeedPublisher + .flatMap { [useCase] _ in + useCase.fetchFeed() + } + .map { feed in + return HomeState.fetched(feed: feed) + } + .eraseToAnyPublisher() + + input.didDisplayFeed + .sink { [weak self] _ in + self?.useCase.didDisplayFeed() + } + .store(in: &subscriptions) + let initialState: HomeViewModelOutput = Just(.idle).eraseToAnyPublisher() - return initialState + return initialState.merge(with: fetched) + .eraseToAnyPublisher() } }