북킵은 관심있는 책을 추가하고 도서를 상태별로 관리하며 독서기록과 메모를 남기는 앱입니다. Aladin API로 새로운 책을 추가하고 enum
으로 책 상태를 관리합니다. 또한 timer
를 사용하여 몰입감있는 독서 경험을 선사하며 독서기록과 메모를 CRUD
할 수 있습니다.
개인 프로젝트
2023.10.01 ~ 2023.10.21 (3주, 이후 꾸준히 업데이트 중)
UIKit / MVVM / RxSwift / Realm / Kingfisher / Alamofire / Open API / Crashlytics
- Swift 5.8 / Xcode 14.3 / SnapKit 5.6 / Kingfisher 7.9 / Alamofire 5.8 / Realm 10.42
- iOS 16.0
HomeView의 UICollectionView를 구성할때 Diffable Datasource와 Compositional Layout을 통해 index가 아닌 cell data를 기반으로 cell을 구성했습니다.
dataSource = UICollectionViewDiffableDataSource<SectionLayoutKind, RealmBook>(collectionView: collectionView, cellProvider: { collectionView, indexPath, itemIdentifier in
let status = itemIdentifier.readingStatus
switch status {
case .reading:
return collectionView.dequeueConfiguredReusableCell(using: readingCellRegistration, for: indexPath, item: itemIdentifier)
case .toRead:
return collectionView.dequeueConfiguredReusableCell(using: toReadCellRegistration, for: indexPath, item: itemIdentifier)
default:
return collectionView.dequeueConfiguredReusableCell(using: toReadCellRegistration, for: indexPath, item: itemIdentifier)
}
API통신은 Alamofire를 사용했는데 일전에 GCD와 URLSession data task를 사용한 통신은 해봤기 때문에 이번 프로젝트에선 Alamofire를 사용했습니다. Alamofire의 responseDecodable를 통해 JSONDecoder보다 더욱 편리하게 JSON응답을 처리 할 수 있었고 Result<Success, Failure>를 통해 명시적으로 통신 처리를 할 수 있었습니다.
class NetworkManager{
...
func requestConvertible<T: Decodable>(type: T.Type, api: AladinRouter, completion: @escaping (Result<T, AFError>) -> Void){
AF.request(api).responseDecodable(of: T.self) { response in
switch response.result{
case .success(let data):
completion(.success(data))
case .failure(let error):
completion(.failure(error))
}
}
}
}
또한 Router패턴과 URLRequestConvertible프로토콜을 채택하여서 확장성과 유지보수에 좋은 코드를 쓰기위해 노력했습니다.
enum AladinRouter: URLRequestConvertible{
...
func asURLRequest() throws -> URLRequest {
let timeoutInterval: TimeInterval = 5
let url = baseURL.appendingPathComponent(path)
var request = URLRequest(url: url)
request.method = method
request.timeoutInterval = timeoutInterval
request = try URLEncodedFormParameterEncoder(destination: .methodDependent).encode(queries, into: request)
return request
}
}
사용자의 책 검색 Use Case에서 검색 결과를 몇개까지 보여줘야 하는가에 대한 고민을 하였습니다. 약 10명의 베타 테스트 플라잇 사용자들을 조사한 결과 제목이 너무 모호하지만 않으면 보통 60개 안에는 나오는 것을 확인할 수 있었고 만약에 대비해 이것의 2배인 120개의 결과를 보여주도록 설정했습니다.
하지만 120개의 결과를 검색시 바로 UI에 보여주는것은 불필요하고 앱의 성능에도 영향을 미칠 수 있는 부분이라 생각되어 Prefetching을 적용하였고 API호출 시 페이지 값을 같이 전달하여 구현했습니다. 또한 스크롤 하단 끝 약 2/3 지점에서 다음 페이지를 불러오는것이 일반적인 Use Case에 적합했습니다.
이에 맞추어 CollectionView에 activityIndicator를 달아주어 검색이 진행되는 동안 사용자에게 시각적 인지를 주었습니다.
func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
let currentRow = indexPath.row
guard vm.searchResult.value != nil else {return} //검색 값 확인
guard let count = vm.searchResult.value?.item.count else {return} //검색 값 내에 아이템이 있는지 확인
guard let totalResults = vm.searchResult.value?.totalResults else {return} //총 검색 결과 확인: 보통 몇백 몇천
if count - 11 == currentRow && count < totalResults && count < 90{ //3번 호출 제한, 최대 120개 결과까지
activityIndicator.startAnimating()
vm.searchBook(query: searchBar.text) {
self.activityIndicator.stopAnimating()
}
}
}
}
모바일 기기 특성상 네트워크가 불안정 할 수 있기 때문에 Network Link Connector 를 사용하여 패킷이 100% Loss 되는 경우, 3G 속도, High Latency DNS 등 다양한 시나리오 속에서 앱을 테스트하여 QA를 높혔습니다.
Request 타임아웃은 5초로 설정하였고 View에서는 Toast 메시지를 띄워 대응하였습니다. Alamofire를 사용하였기 때문에 비교적 수월하게 네트워크 에러 처리를 할 수 있었고 적절한 라이브러리 사용으로 인해 작업 효율을 높혔습니다.
//SearchViewController.swift
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
...
activityIndicator.startAnimating() //인티케이터 시작
//ViewModel에서 에러 대응 클로저 실행
vm.errorHandler = { [weak self] in
//인티케이터 정지 후 토스트 띄움
self?.activityIndicator.stopAnimating()
self?.dismiss(animated: true,completion: {
let toast = Toast.text("⚠️네트워크 환경이 좋지 못합니다")
toast.show(haptic: .error)
})
}
}
해결방법: dataSource.apply()시점을 조정하여 cellProvider closure 호출
핵심 코드
func moveSection(itemToMove: RealmBook,from sourceSection: SectionLayoutKind, to destinationSection: SectionLayoutKind) {
snapshot.deleteItems([itemToMove])
dataSource.apply(snapshot, animatingDifferences: true)
snapshot.appendItems([itemToMove], toSection: destinationSection)
dataSource.apply(snapshot, animatingDifferences: true)
}
해결방법: Reading View에 UserDefaults를 사용하여 비정상 종료시 독서기록 데이터 보존 및 복구처리
핵심 코드
//ReadingViewModel.swift
func observeTimeHandler(time: TimeInterval) -> Void{
//update view
elapsedTime.value = time
//1초마다 저장
UserDefaults.standard.set(elapsedTime.value, forKey: UserDefaultsKey.LastElapsedTime.rawValue)
}
//HomeViewController.swift
if UserDefaults.standard.object(forKey: UserDefaultsKey.LastReadingState.rawValue) != nil{
...
self.showActionAlert(title: "독서 기록 복구", message: "저장하지 않은 독서기록을 불러오시겠습니까?") {
//alert action present ReadCompleteVC
let vc = ReadCompleteViewController()
vc.isbn = isbn
vc.startTime = startTime
vc.readTime = readTime
vc.navigationHandler = {
self.reloadCollectionView()
}
if let sheet = vc.sheetPresentationController{
sheet.detents = [.medium(), .large()]
sheet.prefersGrabberVisible = true
}
self.present(vc, animated: true, completion: nil)
}
}
Aladin Open API를 사용한 도서 검색 및 추가 | 페이지네이션 적용
Enum을 사용한 도서상태 관리 | Compositional Layout 사용
Timer를 사용한 독서 기록 | UserDefaults로 비정상 종료 대응
도서별 메모 추가 및 관리 | Realm Nested Table 사용