Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

일기장 [STEP 2] Prism, Gama #146

Draft
wants to merge 23 commits into
base: ic_11_prism
Choose a base branch
from

Conversation

PrismSpirit
Copy link

안녕하세요. @havilog!😃
Prism, Gama 입니다.
일기장 프로젝트의 STEP 2 가 완료되어 PR 요청드립니다!


📱 실행화면

img img img
일기 추가 및 편집모드 진입 상세화면에서 일기 공유 상세화면에서 일기 삭제
img img img
첫줄은 제목 나머지는 내용 옆으로 쓸어 일기 공유 옆으로 쓸어 일기 삭제
img img img
일기 자동저장
(키보드를 닫을 때)
일기 자동저장
(목록 화면으로 돌아갈 때)
일기 자동저장
(백그라운드 진입 시)

🤔 고민했던 점

- 1️⃣ Diary.xcdatamodel 코어 데이터 파일이 누락되는 문제

/Users/.../Diary.xcdatamodeld:: error: Could not fetch generated file paths: No current version for model at path /Users/.../Diary.xcdatamodeld: [0]

라는 오류가 풀을 받고 프로젝트를 새로 실행할 때마다 발생하였습니다. 각자 로컬에서의 절대 경로로 설정되어 있었기에 발생하는 문제였습니다. 프로젝트 파일에서 코어 데이터 파일의 path를 이전의 “/Users/…/Diary.xcdatamodeled”를 Diary.xcdatamodeld로 설정하여 해결했습니다.


- 2️⃣ 스크롤이 내려가지 않는 짧은 텍스트의 경우에 키보드가 내려가지지 않는 문제

텍스트뷰의 스크롤뷰의 bounds보다 콘텐츠가 작아도, 스크롤뷰에서 세로 드래그를 허용하는지에 대한 alwaysBounceVertical 프로퍼티가 기본적으로 false로 되어 있어서 발생한 문제였습니다. 이를 true로 설정하고 텍스트가 적더라도 스크롤이 내려가게 하여 키보드 또한 내려갈 수 있도록 해결했습니다.


- 3️⃣ 의존성을 주입하는 DI Container를 싱글톤으로 구성

의존성을 주입하기 위해 프로젝트 여러 곳에서 객체를 생성할 때마다 초기화 파라미터가 많아지면서 코드의 가독성이 떨어지고 있었습니다. 따라서 하나의 Container를 만들고 이를 통해 의존성을 주입하는 방식을 프로젝트에서 사용하도록 결정했습니다.

private let viewModel = DiaryListViewModel(diaryListUseCase: DiaryListUseCase(diaryRepository: DiaryRepository(diaryPersistentStorage: DiaryStorage(coreDataStorage: CoreDataStorage.shared))))

이와 같이, DiaryListViewModel을 초기화하려고 할 때 의존성의 연속적인 주입으로 인해 초기화 파라미터가 많아지고 있었습니다.

private let viewModel: DiaryListViewModel

init(viewModel: DiaryListViewModel) {
    self.viewModel = viewModel
    super.init(nibName: nil, bundle: nil)
}

따라서 DiaryListViewModel을 DiaryListViewController 안에서 여러 개의 파라미터를 가지고 초기화하기 보다, 이니셜라이저로 주입 받는 것으로 축약한 다음, SceneDelegate에서 DI Container의 메소드를 통해 DiaryListViewController를 만들어 사용하도록 했습니다.

func makeDiaryListViewController() -> DiaryListViewController {
    return DiaryListViewController(viewModel: makeDiaryListViewModel())
}
let viewController = AppDIContainer.shared.makeDiaryListViewController()
let navigationController = UINavigationController(rootViewController: viewController)
window?.rootViewController = navigationController

또한 이때, DI Container에서 만들어진 인스턴스들을 전역적으로 관리하고, 앱의 전체 생명주기와 동일하게 주기를 가질 수 있도록 싱글톤으로 구현하게 되었습니다. 싱글톤과 DI Container를 함께 사용할 경우, Container 객체를 한 번만 생성하고, 이를 통해서만 의존성을 사용할 수 있게 하여 일관성을 유지할 수 있다고 생각합니다.


- 4️⃣ View와 ViewModel간의 데이터 바인딩 방법
View -> ViewModel로 전달되는 Input 이벤트와 ViewModel -> View로 전달되는 Output 이벤트를 enum을 사용해 정의해주었습니다. 특정 이벤트가 발생했을 때 data를 publish하길 원했기 때문에 이벤트의 전달은 PassThroughSubject에 send() 메서드를 통해 전달하도록 했습니다. ViewModel의 transfrom() 메서드를 통해 View에서 ViewModel로 들어온 이벤트의 종류에 따른 적절한 처리를 수행하고 View의 bind() 메서드를 통해 ViewModel에서 View로 들어온 이벤트를 처리할 수 있도록 했습니다.

🙏 조언을 얻고 싶었던 점

- 1️⃣ 화면 전환 전, 진입 의도에 따라 어떻게 화면의 동작을 다르게 설정할 수 있을지 궁금합니다.

Step 2의 요구 사항에 의하면, 일기를 생성할 경우에만 일기 화면에서 일기를 편집할 수 있게 키보드를 자동으로 올리고, 리스트 화면에서 일기를 터치한 경우에는 텍스트 영역을 터치해야 일기를 편집할 수 있도록 되어 있습니다.

- Step 1-1의 리스트 화면에서 + 버튼을 터치하면 일기를 생성하고 Step 1-2의 일기 화면에서 일기를 편집합니다
    - 이 때는 키보드를 자동으로 보여줍니다
- Step 1-1의 리스트 화면에서 지난 일기를 터치하면 일기 화면으로 이동하고, 텍스트 영역을 터치하면 일기를 편집합니다

편집 모드 여부를 isEditModeActivated라는 Bool 타입 변수를 만들어, 값이 true일 경우에만 키보드가 올라오도록 설정했습니다. 그리고 다이어리를 추가하는 경우에만 해당 변수를 true로 설정하여 화면을 이동하게 만들었습니다.

if isEditModeActivated {
    textView.becomeFirstResponder()
}
case .diaryDidAdd(let diary):
    self.tableView.insertRows(at: [IndexPath(row: 0, section: 0)], with: .automatic)
    self.navigationController?.pushViewController(AppDIContainer.shared.makeDiaryDetailViewController(diary: diary), animated: true)
    let diaryDetailViewController = AppDIContainer.shared.makeDiaryDetailViewController(diary: diary, isEditModeActivated: true)
    self.navigationController?.pushViewController(diaryDetailViewController, animated: true)

혹시 다른 더 괜찮은 방법이 있는지 여쭙고 싶습니다!


- 2️⃣ 상세 화면에서 목록 화면으로 돌아갈 때 자동저장 중 오류 발생 시 처리
현재 뷰 내에서 일기를 저장하는 경우에는 Alert을 띄워도 이상하지 않지만, 한가지 예시로 상세 화면에서 목록 화면으로 돌아갈 때 자동저장 중 오류가 발생할 경우 Alert을 띄우면(화면 좌측에서 스와이프하는 순간 alert이 등장) 조금 어색해 보인다고 생각합니다. 그래서 alert 대신 화면 하단에 작은 toastView를 띄우는 방법도 생각해봤는데 어떤 방식이 좋을 지 아직 결정하지 못한 상황입니다. 현재로써는 alert보다는 toastView를 띄우는게 더 자연스럽다고 생각하는데 이외에 더 좋은 방법이 있을까요?


PrismSpirit and others added 23 commits May 10, 2024 10:22
Copy link

@havilog havilog left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지금 나가봐야해서 .. 미리 리뷰 드려요
조언에 대한 부분은 디엠이나 추가 코멘트로 남길게요!
두 분 모두 수고하셨어요!!

Comment on lines +10 to +59
final class AppDIContainer {
static let shared = AppDIContainer()

lazy var coreDataStorage: CoreDataStorage = CoreDataStorage()
lazy var diaryStorage: DiaryStorage = DiaryStorage(coreDataStorage: coreDataStorage)

// MARK: - Repository

func makeDiaryRepository() -> DiaryRepositoryProtocol {
return DiaryRepository(diaryPersistentStorage: diaryStorage)
}

// MARK: - UseCase

func makeDiaryListDetailUseCase() -> DiaryListDetailUseCase {
return DiaryListDetailUseCase(diaryRepository: makeDiaryRepository())
}

func makeDiaryListUseCase() -> DiaryListUseCase {
return DiaryListUseCase(diaryRepository: makeDiaryRepository())
}

// MARK: - ViewController

func makeDiaryListViewController() -> DiaryListViewController {
return DiaryListViewController(viewModel: makeDiaryListViewModel())
}

func makeDiaryDetailViewController(diary: Diary, isEditModeActivated: Bool = false) -> DiaryListDetailViewController {
return DiaryListDetailViewController(viewModel: makeDiaryListDetailViewModel(diary: diary), isEditModeActivated: isEditModeActivated)
}

func makeDiaryTableViewCell() -> DiaryTableViewCell {
return DiaryTableViewCell()
}

// MARK: - ViewModel

func makeDiaryListViewModel() -> DiaryListViewModel {
return DiaryListViewModel(diaryListUseCase: makeDiaryListUseCase())
}

func makeDiaryListDetailViewModel(diary: Diary) -> DiaryListDetailViewModel {
return DiaryListDetailViewModel(diary: diary, diaryListDetailUseCase: makeDiaryListDetailUseCase())
}

func makeDiaryCellViewModel(diary: Diary) -> DiaryListCellViewModel {
return DiaryListCellViewModel(diary: diary)
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. DIContainer의 경우 제네릭하게 만드는걸 권장해요

만약 1000개의 객체를 등록한다고 했을 때 1000개의 함수가 생긴다면.. 유지보수하기 많이 힘들거에요!

  1. 의존성의 경우 보통 register 후 resolve하는 두 단계로 구성하는데요, 이는 구현마다 다르지만

현재 구현된건 미리 정의된 아이를 가져오는 Factory에 가까워 보여요~
두 개념의 차이를 확인해보시면 좋을 듯 합니다!

  1. 조금 더 고도화 한다면,
  • Singleton으로 꺼내올지 / 매번 객체를 생성할지 판단 여부
  • @propertyWrapper를 이용해 resolve를 간편화 할 수 있는 방법

등을 찾아보시면 도움이 될 듯 합니다




let appDIContainer = AppDIContainer()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 프로퍼티는 안쓰일거 같네요


extension Future where Failure == Error {
convenience init(operation: @escaping () async throws -> Output) {
self.init { promise in
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요건 좀 애매한 부분인데,
비동기 라이브러리중에 Promisekit이라는 아이가 있어요!
promise라는 네이밍은 그 친구랑 네이밍이 헷갈릴 수도 있을거 같네요 ㅋㅋ

import Combine

extension Future where Failure == Error {
convenience init(operation: @escaping () async throws -> Output) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 친구가 간단한 상황에서는 괜찮지만,
비동기 프로그래밍에서 연속적으로 이벤트가 올 때 cancel에 대한 처리 등 엣지케이스에서 에러가 발생할 수도 있을거 같아요!
Task와 Combine을 같이 사용할 때에는 항상 엣지 케이스를 생각해서 작성하시면 좋을 듯 합니다!

Comment on lines +26 to +36
func saveContext() {
let context = persistentContainer.viewContext
if context.hasChanges {
do {
try context.save()
} catch {
// TODO: - Log to Crashlytics
assertionFailure("CoreDataStorage Unresolved error \(error), \((error as NSError).userInfo)")
}
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

saveContext를 throw로 만든 뒤, 일기장 저장에 실패했을 경우 유저에게 노티를 준 뒤 저장에 대한 실패처리를 해야할 거 같아요!

Comment on lines +18 to +25
init(diary: Diary) {
let splittedBody = diary.body.split(separator: "\n").map { String($0) }

self.id = diary.id
self.title = !splittedBody.isEmpty ? splittedBody[0] : "No Title"
self.subTitle = splittedBody.count > 1 ? splittedBody[1] : "No Additional Text"
self.editedDate = diary.editedDate.formatted(.defaultDateFormatStyle)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 initializer는 실패할 수도 있을거 같네요!
init(diray: Diary) throws로 실패 가능한 initializer를 만들거나,
diary를 받아서 저장만 한 뒤에, 로직을 태워서 title등을 파싱해야 테스트 코드 작성이 가능할거 같아요

import UIKit

extension UIViewController {
func showToast(message: String, result: Result<Void, Never>) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요것도 취향의 영역이긴 한데..
저는 기획자나 디자이너가 토스트 넣어달라 그러면 완강하게 반대하는 편입니다 ㅎㅋ
토스트 자체가 뒤에 UI를 가리기 때문에 애플에서 지양하는 컴포넌트라고 생각해서요!

private let output: PassthroughSubject<DiaryListDetailViewModel.Input, Never> = .init()
private var cancellables: Set<AnyCancellable> = .init()

private var isEditModeActivated: Bool
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

대부분의 경우에서는 flag를 bool로 만드는것보다는 enum을 활용하는게 확장성이 좋습니다!


private func configureNotificationReceiver() {
NotificationCenter.default
.publisher(for: Notification.Name("DidEnterBackground"))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 친구는 스트링 리터럴 말고, 미리 선언된 값이 있을거에요!
인터페이스가 잘 기억 안나는데 검색하시면 DidEnterBackground 퍼블리셔 나올겁니다

Comment on lines +42 to +84
func transform(input: AnyPublisher<Input, Never>) -> AnyPublisher<Output, Never> {
input.sink { [weak self] event in
guard let self else { return }
switch event {
case .viewWillAppear:
self.output.send(.updateTextView(text: self.body))
case .keyboardDidDismiss, .didEnterBackground:
self.updateDiary(id: self.id, body: self.body)
case .diaryDeleteActionSheetDidTouchUp(let id):
self.deleteDiary(id: id)
}
}
.store(in: &cancellables)
return output.eraseToAnyPublisher()
}

private func updateDiary(id: UUID, body: String) {
diaryListDetailUseCase.updateDiary(diaryID: id, body: body).sink { completion in
switch completion {
case .finished: break
case .failure(let error):
// TODO: Alert Needed
break
}
} receiveValue: { _ in

}
.store(in: &cancellables)
}

private func deleteDiary(id: UUID) {
diaryListDetailUseCase.deleteDiary(diaryID: id).sink { completion in
switch completion {
case .finished: break
case .failure(let error):
// TODO: Alert Needed
break
}
} receiveValue: { [weak self] _ in
self?.output.send(.diaryDidDelete)
}
.store(in: &cancellables)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

input -> transform -> output 개념도 많이 쓰이긴 하지만,
제 생각에 현재 시점에서 combine을 굳이 사용할 이유가 없어보여요(2222)

코어데이터에서 async 로 온 아이를 async하게 가공 한 뒤에, self의 published property를 바꿔주는 방식으로
개발해보면 어떨까요??

@havilog
Copy link

havilog commented May 19, 2024

  • 1️⃣ 화면 전환 전, 진입 의도에 따라 어떻게 화면의 동작을 다르게 설정할 수 있을지 궁금합니다.

지금 방식도 간단한 차이에서는 크게 이상하지는 않구요,
코멘트에 남긴대로 enum으로 변경하면 조금 더 확장성이 높아질거 같고,
더 나아가 커스텀 스킴을 정의해서 사용할 수도 있을거 같아요
url 기반도 있지만, 스킴 핸들링에는 많은 방식이 있어서 찾아보시면 좋을 듯 합니다

https://benoitpasquier.com/deep-linking-url-scheme-ios/
https://github.com/forXifLess/LinkNavigator

best practice는 아니구 그냥 요런 아이도 있다~

  • 2️⃣ 상세 화면에서 목록 화면으로 돌아갈 때 자동저장 중 오류 발생 시 처리

코멘트에도 남겼지만, 저는 토스트는 최대한 지양하는게 좋다고 생각하는 주의여서 (취향입니다)
자동저장의 경우 실패에 대한 에러핸들링을 포기하는 것도 고려해볼 수 있을거 같아요!

다만 이 부분은 기획, 디자이너의 영역에 더 가까워서 ㅎㅎ
정답은 없을거 같네요!

@PrismSpirit PrismSpirit marked this pull request as draft May 21, 2024 14:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants