Skip to content

야곰 아카데미 iOS 일기장 프로젝트 저장소입니다

Notifications You must be signed in to change notification settings

YaRkyungmin/ios-diary

 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

96 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

일기장📗


📖 목차

🍀 소개
💻 실행 화면
🛠️ 사용 기술
👀 다이어그램
🧨 트러블 슈팅
📚 참고 링크
👩‍👧‍👧 about TEAM


🍀 소개

  • 일기를 작성, 수정, 저장 할 수 있는 일기장 앱입니다.

💻 실행 화면

새로운 일기장 추가 화면 기존 일기장 편집 화면
추가 실행화면 펼쳐 보기
일기장 삭제 화면 일기장 공유 화면
백그라운드 모드 진입 화면 제목없는 일기장
화면모드 변경

🛠️ 사용 기술

구현 내용 도구
아키텍쳐 MVC
UI UIKit
Localized Locale
리스트 표시 Modern Collection Veiw
데이터 관리 Core Data

👀 Diagram

📐 UML


🧨 트러블 슈팅

1️⃣ TextView와 Keyboard

편집중인 텍스트가 키보드에 의해 가리지 않도록하기 위하여 diaryTextViewkeyboard 사이에 레이아웃 설정이 필요했습니다.

🚨 문제점

  • diaryTextView와 keyboardLayoutGuide 사이에 constraint를 아래와 같이 잡아주었습니다.
    • 키보드의 위치를 추적하는 keyboardLayoutGuide와 constraint를 설정하였습니다.
    • 더불어 readability에 최적화된 width를 제공해주는 readableContentGuide와 constraint를 설정하여, 긴 글을 쉽게 읽을 수 있도록 하였습니다.
    NSLayoutConstraint.activate([
        diaryTextView.topAnchor.constraint(equalTo: view.readableContentGuide.topAnchor),
        diaryTextView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
        diaryTextView.leftAnchor.constraint(equalTo: view.readableContentGuide.leftAnchor),
        diaryTextView.rightAnchor.constraint(equalTo: view.readableContentGuide.rightAnchor)
    ])
  • 하지만 keyboardLayoutGuide 같은 경우 iOS 15에서부터 적용할 수 있습니다.
  • 아래와 같이 iOS15 이상을 사용하는 사람들이 대부분이지만 그 이하 버전을 사용하는 6%를 위하여 다른 방법을 모색할 필요가 있었습니다.

💡 해결방법

  • iOS 15 이상일 때와 그 미만 버전일 때를 나누어 레이아웃을 적용해주었습니다.

    • iOS 15이상: keyboardLayoutGuide 적용
    • iOS 15미만: diaryTextView의 contentInset 변경
  • iOS 15미만일 때에는 NotificationCenter를 통해 키보드가 나타나고 사라질 때를 추적하여, diaryTextViewcontentInset.bottom을 키보드 높이만큼 변경하였습니다.

    코드 보기
    // DiaryDetailViewController.swift
    override func viewWillAppear(_ animated: Bool) {
        if #unavailable(iOS 15.0) {
            addKeyboardObserver()
        }
    }
    
    private func addKeyboardObserver() {
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(keyboardWillShow),
            name: UIResponder.keyboardWillShowNotification,
            object: nil
        )
    }
    
    @objc private func keyboardWillShow(_ notification: Notification) {
        guard let userInfo = notification.userInfo as NSDictionary?,
              let keyboardFrame = (userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue else {
            return
        }
    
        diaryTextView.contentInset = UIEdgeInsets(
            top: .zero,
            left: .zero,
            bottom: keyboardFrame.size.height,
            right: .zero
        )
    }

2️⃣ Locale 적용

🚨 문제점

  • Date 포맷을 DateFormatter의 lacale을 ko_KR로 적용하였을 때 지역화가 되지 않는 문제점이 발생했습니다.
    dateFormatter.locale = Locale(identifier: "ko_KR")

💡 해결방법

  • 프로퍼티를 읽을 당시 사용자의 지역 설정을 나타내는 Locale의 current 프로퍼티를 사용하여 지역화 문제를 해결했습니다.
    dateFormatter.locale = Locale.current.identifier

3️⃣ 100Kg DiaryManager (Model과 ViewController사이의 중간 객체)

🚨 문제점

  • 기존의 DiaryManager타입에서 DiaryViewControllerDiaryDetailViewController의 비지니스 로직을 모두 가지고 있도록 구현한 뒤 각각의 ViewController에 주입시켜줬습니다. 각각의 ViewController의 비지니스 로직은 분리시켜줄 수 있었지만 DiaryManager가 무거워지는 문제가 발생했습니다.

💡 해결방법

  • 각각의 ViewController마다 UseCase를 따로 만들어 DiaryManager가 가지고 있는 로직을 분리해준 뒤, 다른 UseCase끼리 통신하기 위해서는 ViewControllerDelegate를 이용하여 통신할 수 있도록 하여 DiaryManager의 복잡성을 낮췄습니다.


4️⃣ 일기 화면에서 수정된 text 반영하기

🚨 문제점
두번째 화면(일기 화면)에서 작성 및 수정된 text를 첫번째 화면(리스트 화면)에 반영해주기 위하여 아래와 같은 방법들을 시도해보았습니다.

  • 기본적으로 CollectionView에서 DiffableDataSource를 사용하고 있으므로 snapshot을 활용하여 CollectionView의 data를 업데이트 해주고 있습니다.

1. FetchedResultsController

  • 적용

    • FetchedResultsController는 Core Data fetch requset 요청의 결과를 관리하고 사용자에게 데이터를 표시하는 데 사용하는 컨트롤러입니다. 이 컨트롤러의 delegate는 fetch results가 변경되었을 때 fetched results controller가 호출할 메서드를 가지고 있습니다.
    • 메서드들 중 아래 메서드를 활용한다면 fetch results가 변경되었을 때 쉽게 snapShot을 apply를 해줄 수 있을 것 같아 적용해보기로 하였습니다.
    optional func controller(
        _ controller: NSFetchedResultsController<NSFetchRequestResult>,
        didChangeContentWith **snapshot**: NSDiffableDataSourceSnapshot
    )
    • 하지만 해당 controller 메서드의 snapshot를 활용하려고 하였을 때, apply하는 부분에서 아래와 같은 오류가 계속 발생하였습니다🥲(이 부분의 문제 해결을 위하여 오랫동안 붙잡고 있었으나, 결국 원인을 찾지 못하였습니다.) 이에 매개변수의 snapshot를 활용하는 대신 기존에 구현해 놓은 applySnapshot()를 호출하였습니다.
    Could not cast value of type 'NSTemporaryObjectID_default' (0x1ba22d4c8) to 'Diary.DiaryEntity' (0x104c15c10).
    
    코드 보기
    // DiaryViewController.swift
    private func applySnapshot() {
        guard let diaryDataSource,
              let fetchedObjects = fetchedResultsController?.fetchedObjects else { return }
    
        var snapshot = NSDiffableDataSourceSnapshot<Section, DiaryEntity>()
    
        snapshot.appendSections([.main])
        snapshot.appendItems(fetchedObjects)
        diaryDataSource.apply(snapshot, animatingDifferences: true)
    }
    // DiaryViewController.swift
    extension DiaryViewController: NSFetchedResultsControllerDelegate {
    func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
        diaryDataSource?.apply(snapshot as NSDiffableDataSourceSnapshot<DiaryViewController.Section, DiaryEntity>, animatingDifferences: true)
        }
    }
  • 장점

    • fetch results의 변경시점을 신경쓰지 않아도 되어 변경할 때 하고 싶은 작업을 수행할 수 있었습니다.
  • 단점

    • diaryDataSource?.apply(snapshot as...) 대신 기존에 구현해놓은 applySnapshot를 활용하면 fetch results가 변경되었을 때 첫번째 화면에 데이터를 반영하는 것에는 문제가 없었지만, controller 매개변수가 제공하는 sanpshot를 활용하지 못해 해당 contorller를 사용하는 의미가 없다고 판단하였습니다.
    • 또한 controller 같은 메서드를 사용한다면 ViewController가 특정 delegate를 의존하고 있는 것이므로 의존성 문제가 발생할 수 있어 다른 방법을 찾아보기로 하였습니다.

2. Delegate 패턴

  • 적용

    코드 보기
    protocol DiaryDetailViewControllerDelegate: AnyObject {
        func diaryDetailViewController(_ diaryDetailViewController: DiaryDetailViewController, upsert diary: Diary)
        func diaryDetailViewController(_ diaryDetailViewController: DiaryDetailViewController, delete diary: Diary)
    } 
    // MARK: DiaryDetailViewController Delegate
    extension DiaryViewController: DiaryDetailViewControllerDelegate {
        func diaryDetailViewController(_ diaryDetailViewController: DiaryDetailViewController, upsert diary: Diary) {
            useCase?.upsert(diary)
            loadData()
            applySnapshot()
        }
    
        func diaryDetailViewController(_ diaryDetailViewController: DiaryDetailViewController, delete diary: Diary) {
            useCase?.delete(diary)
            loadData()
            applySnapshot()
        }
    }
  • 단점

    • Delegate 패턴을 이용하여 diaryDetailViewController에서 diaryViewController로 변경된 데이터의 업데이트를 요청하고 applySnapshot()을 호출 하면 보이지 않는 뷰에 대해서 계속해서 applySnapshot()하는 단점이 있었습니다.

💡 해결방법

3. Delegate 패턴 + viewDidAppear 메서드

  • 적용

    • coreData를 업데이트 하는 작업만을 delegate를 통해 작업하도록 했습니다.
    • applySnapshot()delegate를 통해서 호출하는 대신, viewDidAppear 메서드 내에서 호출해주었습니다.
    코드 보기
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        applySnapshot()
    }
  • 장점

    • 첫번째 화면(리스트 화면)으로 돌아갈 때만 applySnapshot()을 호출하므로 보이지 않는 뷰에 대해서 호출하던 단점을 보안할 수 있었습니다.

5️⃣ 배열에 안전하게 접근하기

🚨 문제점

  • didSelectItemAt에서 IndexPath.item를 통해 diaryList에 접근할때 diaryList 데이터가 미리 업데이트 되어 있지 않을 때 Index out of range가 발생했습니다.

💡 해결방법

  • IndexPath.item 를 통해 diaryList를 접근하기 전에 데이터를 미리 업데이트 하지 않은 것은 휴먼에러이지만 이런 상황에서도 Index out of range에러를 통한 Crash를 막기위한 방법을 고민했습니다.
    extension Collection {
        subscript (safe index: Index) -> Element? {
            return indices.contains(index) ? self[index] : nil
        }
    }   
  • Collection 타입의 유효 범위를 가지고 있는 indices라는 프로퍼티에 대해 알게됐고, 접근한 Index가 유효할 때는 Element 타입을 반환하고 유효하지 않을 때는 nil을 반환 하도록 subscript메서드를 정의 했습니다.

6️⃣ 제목없을 때 리스트 높이가 줄어드는 현상

🚨 문제점

  • 일기장의 첫 줄이 없을때 Diary 타입의 title 속성으로 빈 문자열이 들어갈 경우 목록에서 cell의 높이가 줄어드는 문제가 발생했습니다.

💡 해결방법

  • title속성이 비어있을 경우 제목 없음 텍스트를 넣어주도록 로직을 수정하여 해결하였습니다.

7️⃣ 동시접근 문제

🚨 문제점

  1. DiaryDetailVeiwControllertextViewDidEndEditing에서 UseCase의 메서드를 호출
  2. UseCase의 메서드에서 UseCaseDelegate를 통해 DiaryDetailVeiwControllerDelegateupsert메서드를 호출
  3. DiaryDetailVeiwControllerDelegate 메서드에서 다시 UseCase의 프로퍼티로 접근
  • 다음과 같은 상황에서 Simultaneous 에러가 발생했습니다.

💡 해결방법

  • UseCase가 struct 였기 때문에 UseCase내 mutating 메서드가 호출 되면 UseCase에 대한 메모리로 직접 접근하게 됩니다.

  • 그런 와중에 mutating 메서드 내에서 델리게이트를 통해 다시 UseCase에 접근했기 때문에 동시 접근 오류가 난 것으로 보입니다. 원래 Test했던 코드도 mutating을 붙이니 동시 접근 에러가 발생 했습니다.

  • mutating을 지우거나 UseCase를 Class로 변경했을때는 에러가 발생하지 않았습니다.

    코드 보기
    struct TestDiary {
        let content: String
    }
    
    protocol TestUseCaseDelegate: AnyObject {
        func delegateFunc()
    }
    
    struct TestUseCase {
        var testDiary: TestDiary // 호출 순서: 4번
        weak var delegate: TestUseCaseDelegate?
    
        mutating func doingTestUseCase() {
            testDiary = TestDiary(content: "경민")
            delegate?.delegateFunc() // 호출 순서: 2번
        }
    }
    
    class TestVC {
        var testUseCase: TestUseCase?
    
        init(testUseCase: TestUseCase) {
            self.testUseCase = testUseCase
        }
    
        func setupUseCaseDelegate() {
            testUseCase?.delegate = self
        }
    
        func doing() {
            testUseCase?.doingTestUseCase() // 호출 순서: 1번
        }
    }
    
    extension TestVC: TestUseCaseDelegate {
        func delegateFunc() {
            print(testUseCase?.testDiary) // 호출 순서: 3번
        }
    }
    
    let diary = TestDiary(content: "Dasan")
    let useCase = TestUseCase(testDiary: diary)
    let viewController = TestVC(testUseCase: useCase)
    
    viewController.setupUseCaseDelegate()
    viewController.doing()

8️⃣ 구조 개선

🚨 문제점

  • diaryPersistentManager에 접근하기 위해 ViewControllerDelegate패턴을 이용해 통신하였지만 Delegate간의 통신이 많아지면서 가독성이 떨어지는 문제가 발생하였습니다.

💡 해결방법

  • 각각의 UseCase에서 diaryPersistentManager를 프로퍼티로 가지고 있게 한 뒤 UseCase 인스턴스 생성시 주입해줌으로써 복잡한 구조를 개선하였습니다.


📚 참고 링크

참고 링크 펼쳐 보기


👩‍👧‍👧 about TEAM

🐼Kyungmin🐼 https://github.com/YaRkyungmin
🌳Dasan🌳 https://github.com/DasanKim

About

야곰 아카데미 iOS 일기장 프로젝트 저장소입니다

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

  • Swift 100.0%