Giter Club home page Giter Club logo

ios09-bbus's Introduction

🚌 캠퍼들의 출퇴근을 책임진다. BBus, BoostBus!

실시간 버스 정보, 승하차 알람 기능을 제공하는 '카카오버스' 앱 클론 프로젝트입니다.


🫂 팀 소개

Team.mOS
making Open Source라는 의미로 외부 라이브러리를 사용하지 않겠다는 굳은 의지를 표현하였습니다.
저희는 페어 프로그래밍을 적극 활용하여, 구조 설계의 대부분을 모두 함께 진행하였습니다.

S002_강민상 S013_김태훈 S045_이지수 S057_최수정
버스 노선 화면 로직
GPS 기반 버스 하차 기능
프로젝트 리더
git workflow 관리
Combine 활용
Network 레이어 설계
Coordinator 구조
Timer

⚒️ 기능 소개

Video Label


BBus 는 서울특별시에서 제공하는 공공 API를 사용하여
실시간 버스 및 정거장 정보를 확인할 수 있습니다.
또한 GPS 위치 정보를 활용한 승하차 알림 서비스를 지원합니다.

버스 및 정거장 검색 실시간 버스 도착 정보 제공 실시간 버스 노선 정보 제공
특정 버스 또는 정거장을
검색할 수 있습니다.
특정 정거장의 실시간 버스 도착 정보를
확인할 수 있습니다.
특정 버스 노선의 실시간 정보를
확인할 수 있습니다.
자주 타는 버스 즐겨찾기 버스 승차 알람 버스 하차 알람
즐겨찾기된 버스들의 도착 정보를
홈 화면에서 확인할 수 있습니다.
푸시 알림을 통해 승차 알람을
받을 수 있습니다.
푸시 알림을 통해 하차 알람을
받을 수 있습니다.

⚙️ 기술 스택



🏗 아키텍쳐


🗂 폴더 구조

Global : 모든 화면에서 공통적으로 사용되는 변수, 이미지, 객체 등
Foreground : 각 화면별 View, ViewController, ViewModel, Model
Background : 화면과 상관 없이 동작되는 기능

BBus
├── Global
│   ├── Constant
│   ├── Coordinator
│   ├── DTO
│   ├── Extension
│   ├── Network
│   ├── Resource
│   └── View
├── Foreground 
│   ├── AlarmSetting
│   ├── BusRoute
│   ├── Home
│   ├── MovingStatus
│   ├── Search
│   └── Station
├── Background 
│   ├── GetOffAlarm
│   └── GetOnAlarm
├── AppDelegate
├── SceneDelegate
└── info.plist

🏃🏻 기술적 도전

Coordinator 를 사용한 MVVM-C 패턴 적용
공공 API 트래픽 제한 문제 해결
Combine을 통한 비동기 로직 처리
실시간 데이터를 위한 Timer
CoreLocation 을 통한 GPS 정보를 활용한 승하차 추적 서비스
스토리보드 없이 Programmatically View 사용
외부 라이브러리 NO
Github Action을 활용한 CI 배포


저희가 협업했던 과정을 보시려면?

Github Wiki

ios09-bbus's People

Contributors

freedeveloper97 avatar modyhoon avatar sujeong000 avatar tmfrlrkvlek avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar

ios09-bbus's Issues

[Refactor] MockData 관련 configure() 작업

완료조건

  • 모든 MockData들을 외부에서 주입받아 적용하는 방식으로 수정
    • 기존
      private lazy var firstBusTimeLabel: UILabel = {
          let label = UILabel()
          label.font = UIFont.boldSystemFont(ofSize: 14)
          label.text = "1분 29초"
          return label
      }()
    • 변경
      view.configure(firstBusTime: "1분 29초")

[리팩토링] Coordinator 패턴을 현재 앱 상황에 맞게 개선

참고

#9

구조 개선의 필요성

  • window와 MovingStatusViewController를 연관짓는 구조로의 개선이 필요하다.
  • MovingStatus 화면은 항상 최상위에 띄워지므로 다른 화면의 push/pop에 종속적이면 안된다. 따라서 MovingStatusCoordinator가AlarmSetting(뿐만 아니라 AppCoordinator와 HomeCoordinator를 제외한 모든 Coordinator)의 자식으로 들어가면 안된다.
  • 아래 구조로 바꾸면 위의 두 가지는 만족될 것으로 보임.
  • NavigationController와 MovingStatusViewController를 자식으로 갖는 상위의 RootViewController를 만들고 window.rootViewController = rootViewController 가 되는 구조.
    ㄴ window & rootViewController -- AppCoordinator
        ㄴ view & NavigationController
        ㄴ view & MovingStatusViewController
  • window를 주입받아 사용하는 구조로의 개선이 필요하다.
  • 문제는 이 부분에서 생기는데,
  • 홈화면 -> 버스정거장화면 -> 버스노선화면 -> 알람설정화면 -> 이동현황화면을 띄우는 시나리오가 있다고 가정.
  • 위에서 언급한 상위 뷰컨트롤러를 만들게 되면 전체 구조는 아래와 같아져야함.
  • MovingStatus는 window에 추가하므로 MovingStatusCoordinator에는 AppCoordinator가 가지고 있는 window를 주입해주어야 함.
  • 그런데 MovingStatusCoordinator는 AlarmSettingCoordinator가 생성함.
  • 이때 Delegate 패턴을 써서 AppCoordinor를 window.addSubview를 하는 Delegate로 주입해주는 해결법을 떠올릴 수 있음.
  • 다만 이렇게 되면 현재 Coordinator 패턴에서는 아래와 같은 이슈가 발생함.
  • 최상위인 AppCoordinator에서 최하위인 AlarmSettingCoordinator까지 Delegate 전달이 필요.
  • 그런데 구조가 부모-자식-손자-... 형태이므로 중간에 많은 Coordinator들을 반드시 거쳐서 전달해야함.
  • MovingStatus화면을 새로 띄울 수 있는 화면은 AlarmSetting 화면밖에 없고 다른 화면(홈,노선,정류장화면)에서는 MovingStatus화면을 띄울 수 없는데도 중간에 거쳐가는 화면이라는 이유 하나만으로 Delegate를 주입 받아야하는 문제 발생.
  • 이렇게 되면 이동현황 화면에 접근할 수 없는 화면에서도 delegate를 지니는 접근성 문제도 생기고, delegate가 필요가 없는데도 받아서 다음 화면에 전달해줘야하는 오버헤드도 발생.
  • 현재 구조에서 좀 더 좋은 구조로 개선하는 것이 필요하지 않을까.

개선

image

[Refactor] static 변수를 상속하여 사용하는 방법

개요

StationBodyCollectionViewCellFavoriteCollectionViewCell 를 상속받는다.
이 때, height 같은 경우 static 하기 때문에 상속하여 override할 수 없다.
한참을 찾아보다가 다음과 같은 해답을 얻게 되었다.

class 접근자를 사용하면 된다.

FavoriteCollectionViewCell.swift

class var height: CGFloat { return 70 }

StationBodyCollectionViewCell.swift

override class var height: CGFloat { return 90 }

완료조건

  • static 변수를 class 변수로 바꾸기 (단, 연산 프로퍼티여야 한다.)

레퍼런스

class

[검색] 2-2. 검색어를 가지고 대응되는 버스 노선 id 목록을 얻어올 수 있어야 한다.

작업 내용

  • UseCase와 ViewModel내에 데이터를 가져오는 데 필요한 로직을 작성
  • 검색어를 바탕으로 Persistent Storage 내에 있는 버스 노선 json 데이터를 가져올 수 있어야 한다.

레퍼런스

없음.

기타

Persistent에서 초기 모든 버스 정보 데이터를 받아오는 시점과,
받아왔다고 생각하고 버스 번호로 검색하는 시점이 다를 수 있다.

  1. 예를 들어, 초기에 모든 버스 정보 데이터를 받아오는 코드가 다음과 같고,
private func startSearch() {
self.cancellable = usecases.getRouteList()
    .decode(type: [BusRouteDTO].self, decoder: JSONDecoder())
    .sink(receiveCompletion: { error in
        // 에러 처리
        if case .failure(let error) = error {
            print(error)
            print("error")
        }
    }, receiveValue: { routeList in
        self.routeList = routeList
    })
}
  1. 버스 번호로 검색하는 코드가 다음과 같다고 가정해보자.
self.cancellable = self.usecase?.$routeList
      .sink(receiveValue: { did in
          dump(self.usecase?.searchBus(by: "46"))
      })

호출 순서는 1 -> 2로 보장이 된다.
하지만 1에서 저장했다고 생각한 self.routeList = routeList 이 시점보다 더 빨리
2번의 self.usecase?.searchBus(by: "46") 가 실행 될 수 있다.

이 경우에 self.routeList가 nil이므로, 데이터를 제대로 못받아오는 현상이 발생한다.

그래서 다음와 같이 해결하였다.

.receive(on: Self.thread)

combine의 sink 하는 과정에서 Thread를 일치시켜줘야 일의 순서가 보장된다는 것을 확인하였다.

자세한 코드는 635e734 커밋에 포함되어 있다.

[뷰] 1-1. 홈 화면이 제공되어야 한다.

작업 내용

  • 검색 화면 이동
  • 즐겨찾기 목록 조회
  • 즐겨찾기 목록 헤더 클릭 시 정거장 화면 이동
  • 즐겨찾기 목록 셀 클릭 시 버스 화면 이동
  • 새로고침 버튼 추가

레퍼런스

없음.

기타

없음.

트러블슈팅

  • addAction이 중첩됨으로써 같은 동작이 여러번 반복되는 현상
    Cell.Reuse.mp4

[컴바인] DataTaskPublisher

DataTaskPublisher

현재 Service에서 처리해야 할 에러는 다음과 같다.

  1. AccessKey 없을 경우 Error
  2. 입력된 도메인 기반 URL 생성 실패 에러
  3. Request 요청 실패 에러

하지만 DataTaskPublisher(data: Data, response: URLResponse) 혹은 URLSession.URLError만을 반환한다.

Serviceget함수는 DataTaskPublisher가 반환하는 URLSession.URLError를 포함한 총 3가지 에러를 처리해야 하기 때문에, 이를 포괄할 수 있는 Publisher 하나가 필요하다.

func get(url: String, params: [String: String]) -> AnyPublisher<Data, Error> {
    let bbusPublisher = PassthroughSubject<Data, Error>() // 포괄하는 Publisher
    DispatchQueue.global().async {
        // 여러 에러 혹은 결과값 전송 로직
    }
    return bbusPublisher.eraseToAnyPublisher() 
}

그 로직을 위와 같이 생성해주었다. 그렇다면 여러 에러 혹은 결과값 전송 로직 위치에 AccessKey 접근 및 URL 생성, Request 요청 등이 이루어져야 한다.
DataTaskPublisher를 사용하게되면 DataTaskPublisher에 바인딩해 DataTaskPublisher로 온 값을 위의 생성한 bbusPublisher로 보내주는 로직이 필요하다.

처음 생성했던 해당 로직은 다음과 같다.

    // URL 생성 관련 에러 요청 로직 생략
    URLSession.shared.dataTaskPublisher(for: request)
    .sink(receiveCompletion: { error in
        bbusPublisher.send(completion: .failure(error))
    }, receiveValue: { data in
        bbusPublisher.send(data)
    })
    .store(in: &self.cancellables) 
    // sink 함수의 리턴값이 무조건 어딘가에 저장되어 있어야 한다. store는 해당 반환 값을 저장해주는 함수이다
    // 현재 그 어딘가는 Service.shared.cancellables이다.

문제점은 저 store이다. get 요청을 할 때마다 Service 클래스에 sink의 리턴값이 계속해서 쌓이는 구조인 것이다.
호출이 끝난 경우 삭제해보려 했으나, 반환 값은 sink할 때는 모르기 때문에 해당 로직을 넣을 수 없었다.
말이 요상하니 코드로 보자.

    URLSession.shared.dataTaskPublisher(for: request)
    .sink(receiveCompletion: { error in
        bbusPublisher.send(completion: .failure(error))
        self.cancellables.remove(??sink의 반환 값??)
    }, receiveValue: { data in
        bbusPublisher.send(data)
        self.cancellables.remove(??sink의 반환 값??)
    })
    .store(in: &self.cancellables)

sink의 매개변수 내부에 sink의 반환 값을 넣자니 말도 안되는 것..
밥을 먹지도 않았는데 설거지를 하는 것과 비슷한..

그래서 결론적으로 DataTaskPublisher를 사용하지 않는 구조를 선택했다.
그 코드는 다음과 같다

URLSession.shared.dataTask(with: request) { data, response, error in
    if let error = error { // URLError 에러 처리
        bbusPublisher.send?.send(completion: .failure(error))
        return
    }
    guard let response = response as? HTTPURLResponse else { // Response 없는 경우 처리
        bbusPublisher.send?.send(completion: .failure(NetworkError.noResponseError))
        return
    }
    if response.statusCode != 200 { // Response 의 상태코드가 200 이 아닌 경우 처리
        bbusPublisher.send?.send(completion: .failure(NetworkError.responseError))
        return
    }
    if let data = data {  // data가 정상적으로 온 경우 전송
        bbusPublisher.send?.send(data)
    }
    else { // data가 없는 경우 처리
        publisher?.send(completion: .failure(NetworkError.noDataError))
    }
}.resume()

위 로직이 오히려 더 깔끔하다고 생각한다.
애플이 잘 만들어 놓은 DataTaskPublisher를 쓰지 못하는건 아쉽지만.. bbusPublisher안의 DataTaskPublisher보단 이게 더 좋은 구조라는 생각이 든다!

이 작업 이후에 논의된 결론이지만, 어차피 반환 값을 AnyPublisher<Data, Error>로 변경해야 했으니 필연적인 선택이기도 했다.

최대한 자세히 설명했는데 이해에 도움이 되었으면.. !

[검색화면] 최초 접근시 간헐적으로 BAD_ACCESS가 발생하는 현상

발생 일시

21.11.11 검색 화면 모델 관련 데이터 수집 이후

발생 위치

검색 화면 진입

증상

bad_access.mp4

재현 방법

  • 유발조건 모르겠음. 계속 재시작 해보는데도 유발이 안되고, 어쩌다가 한번 됨
  • 계속 껐다 켰다 껐다 켰다 해보다보면 유발됨

스크린샷

  • 증상과 같음

레퍼런스

없음.

원인분석

  • 추측이지만, 동시접근 문제인것 같음
  • store(&cancellable) 쪽에서 동시접근 이슈로 인해 터지는 것으로 추측되며, 전체를 큐로 감싸주니 serial queue이기 때문에 터지지 않음. -> 이것도 확인 필요

[즐겨찾기] 14-1. 즐겨찾기 목록은 정류장 종류 별로 정렬되어 보여져야 한다.

작업 내용

  • 저장된 즐겨찾기 목록을 가져와서 홈 화면에 표시
    • 정거장은 헤더로, 버스들은 셀들로

레퍼런스

없음.

기타

정거장 종류 사이에는 순서가 정해져 있다.
현재는 즐겨찾기에 추가된 순서대로 정렬되어 보여져야 한다.

현재 즐겨찾기 DTO 는 다음과 같이 되어있다.

struct FavoriteItem: Codable, Equatable {
    let stId: String
    let busRouteId: String
    let ord: String
    let arsId: String
}

위 DTO는 하나의 정거장 + 버스 즐겨찾기를 나타낸다.

이것만 가지고는 표시될 정거장의 순서를 저장할 수 없다. 또한, 추후 이 순서를 바꿔야 하는 요구사항이 들어오게 될 경우 변경에 용이하지 않다.
따라서 위 DTO를 개선해야할 필요성을 느낌

현재는 즐겨찾기 하나하나가 독립된 객체라고 한다면, 변경되어야 하는 구조는 다음과 같다.
정거장 1
ㄴ 버스1
ㄴ 버스2
정거장 2
ㄴ 버스1
ㄴ 버스 2

따라서 psudo code를 작성해보면

struct FavoriteStation: Codable {
  let order: Int
  let stationId: String
  let arsId: String
  let buses: [FavoriteBusRoute]
}
struct FavoriteBusRoute: Codable, Equatable {
  let busRouteId: String
  let ord: String
}

이러한 구조가 되어야 할 것이다.

이러면 Persistent의 저장, 생성, 삭제 로직도 바꾸어야 한다.
일단 바꾸어서 구현해보고 팀원의 의견을 들어보도록 하자.


음.. 이렇게 될경우 Persistent의 create로직이 대폭 수정되어야 할 뿐만 아니라, 객체의 내부까지 접근해야하는 구체타입을 지정해줘야하는 문제가 발생한다.
이렇게 하지말고, 순서를 저장하는 객체를 독립적으로 둔다면? 기존 코드에서 추가로 가능할 것 같다.
가령

struct FavoriteOrder: Codable {
  let orderByStationId: [String:Int] 
}

이런식으로 선언해놓고 순서를 저장해두면 가능하지 않을까


NONO 지금 구조를 살리려면 아래같이 해야함

struct FavoriteOrder: Codable, Equatable {
  static func == (lhs: FavoriteOrder, rhs: FavoriteOrder) {
    return lhs.stationId == rhs.stationId
  }

  let stationId: String
  let order: Int
}

[컴바인] DataTaskPublisher

DataTaskPublisher

현재 Service에서 처리해야 할 에러는 다음과 같다.

  1. AccessKey 없음 Error
  2. 입력된 도메인 기반 URL 생성 에러
  3. Request 요청 에러
    하지만 DataTaskPublisher는 (rata: Data, response: URLResponse) 혹은 URLSession.URLError만을 반환한다.

그렇다면 Service 단의 get함수는
DataTaskPublisher가 반환하는 URLSession.URLError를 포함한 총 3가지 에러를 처리해야 하는 것이다.

URLSession.URLError만 처리하는 것이 아니기에 3가지 에러를 모두 전송하는 하나의 Publisher 타입이 필요하다.

func get(url: String, params: [String: String]) -> AnyPublisher<Data, Error> {
    let bbusPublisher = PassthroughSubject<Data, Error>()
    DispatchQueue.global().async {
        // 여러 에러 혹은 결과값 전송 로직
    }
    return bbusPublisher.eraseToAnyPublisher()
}

그 로직을 위와 같이 생성해주었다.
그렇다면 여러 에러 혹은 결과값 전송 로직 위치에 AccessKey 접근 및 URL 생성, Request 요청 등이 이루어져야 한다. 내부 로직이 모두 비동기로 돌아간다는 것이다.

DataTaskPublisher를 사용하게되면 DataTaskPublisher에 바인딩을 해 DataTaskPublisher로 온 값을 위의 생성한 bbusPublisher로 보내주는 로직이 필요하다.

처음 생성했던 본 로직은 다음과 같다.

    // 생략
    URLSession.shared.dataTaskPublisher(for: request)
    .sink(receiveCompletion: { error in
        bbusPublisher.send(completion: .failure(error))
    }, receiveValue: { data in
        bbusPublisher.send(data)
    })
    .store(in: &self.cancellables) // sink 함수의 리턴값이 무조건 어딘가에 저장되어 있어야 한다.
    // 생략

문제점은 저 store이다. sink의 리턴값이 get 요청을 할 때마다 Service 클래스에 지속해서 쌓이는 구조인 것이다. 호출이 끝난 경우 삭제를 해보려 했으나, 반환 값은 sink할 때는 모르기 때문에 해당 로직을 넣을 수 없었다. 코드로 보자.

    URLSession.shared.dataTaskPublisher(for: request)
    .sink(receiveCompletion: { error in
        bbusPublisher.send(completion: .failure(error))
        self.cancellables.remove(??sink의 반환 값??)
    }, receiveValue: { data in
        bbusPublisher.send(data)
        self.cancellables.remove(??sink의 반환 값??)
    })
    .store(in: &self.cancellables)

sink의 파라미터에 sink의 반환 값을 넣자니 말도 안되는 것..

그래서 결론적으로 DataTaskPublisher를 사용하지 않는 구조를 선택했다.
그 코드는 다음과 같다

URLSession.shared.dataTask(with: request) { data, response, error in
    if let error = error {
        bbusPublisher.send?.send(completion: .failure(error))
        return
    }
    guard let response = response as? HTTPURLResponse else {
        bbusPublisher.send?.send(completion: .failure(NetworkError.noResponseError))
        return
    }
    if response.statusCode != 200 {
        bbusPublisher.send?.send(completion: .failure(NetworkError.responseError))
        return
    }
    if let data = data {
        bbusPublisher.send?.send(data)
    }
    else {
        publisher?.send(completion: .failure(NetworkError.noDataError))
    }
}.resume()

오히려 더 깔끔하다고 생각한다.
애플이 잘 만들어 놓은 DataTaskPublisher를 쓰지 못하는건 아쉽지만.. Publisher안의 Publisher보단 이게 더 좋은 구조라는 생각이 든다!

이 작업 이후에 논의된 결론이지만, 어차피 반환 값을 AnyPublisher<Data, Error>로 변경해야 했으니 필연적인 선택이기도 했다.

최대한 자세히 설명했는데 이해에 도움이 되었으면.. !

[뷰] 1-4. 정거장 화면이 제공되어야 한다.

작업 내용

  • 정거장 정보를 표시한다.
  • 버스 정보들을 테이블뷰 형식으로 나열한다.
  • 새로고침 버튼을 추가한다.
  • 셀의 별 버튼을 클릭하면 색상이 변경된다.

레퍼런스

없음.

기타

없음.

[뷰] 1-2. 검색 화면(버스)이 제공되어야 한다.

작업 내용

  • 상단 네비게이션 바 내부에 입력할 수 있는 TextField를 넣는다.
  • 클릭시 숫자 키패드가 나온다
  • 정류장 탭과 Swap 되어야 한다.
    • 탭을 지정해서 바꿀수도 있고, 우측으로 슬라이드 했을 때도 넘어가야 한다.
  • 이 화면에서는 버스 탭에 Tint가 하이라이팅 되어야 한다.

레퍼런스

없음.

기타

[뷰] 1-6. 알람 설정 화면이 제공되어야 한다.

작업 내용

  • NavigationBar Title에 버스번호, 정거장란이 구현되어야 한다.
  • 승차알람, 하차알람 섹션이 구분되어야 한다.
  • 승차알람 tableViewCell 이 구현되어야 한다.
  • 승차알람 tableViewCell 에 알람버튼이 추가되어야 한다.
  • 하차알람 tableViewCell 이 구현되어야 한다.
  • 하차알람 tableViewCell 에 알람버튼이 추가되어야 한다.
  • Coordinator 사용 화면 전환

레퍼런스

TableView에서 스크롤해도 헤더 고정되는 기능 제거하기

기타

  • AlarmButtonDelegate가 세 종류여야 함. -> 네이밍을 좀 더 구체적으로 할 필요가 있음.

    • 홈 화면의 AlarmButtonDelegate는 shouldGoToAlarmSettingScene()이고
    • 알람 설명 화면의 승차알람버튼은 shouldSettingGetOnAlarm(?)
    • 알림 설명 화면의 하차알람버튼은 shouldGoToMovingStatusScene()
    • 이런 식으로 프로토콜 내의 메소드들 선언이 다 다르므로 홈화면에서 델리게이트 네임을 구체화해주세요.
  • TableView SectionHeader inset 문제

    • 실제 카카오버스 앱처럼 Separator Inset의 Left를 70정도로 설정해주었더니 Header의 타이틀에도 Left Inset이 생김.
    • Header 또한 Section과 Section을 구분하는 Separator로 정의되어 있는 것 같음.
    • Separator와 Separator Inset을 없애고 TableViewCell의 bottom에 높이 1짜리 Separator 역할을 하는 뷰를 넣어 해결함.
    • 이 방식으로 했더니 Section 별로 Separator Inset을 다르게 설정할 수 있는 효과를 얻었음.
    • 다만 Header의 bottom에 선을 넣어주고 싶었는데 처리하지 못함.
      • 추후 Header도 커스텀하여 bottom에 Separator View를 넣어주면 해결할 수 있을 것.
  • TableView 스크롤 시 섹션 헤더가 상단에 고정되는 문제

    • TableView는 기본적으로 스크롤 시 섹션 헤더를 상단에 고정해주는 기능을 제공함.
    • TableView의 style을 .grouped로 설정하면 고정이 해제됨.
  • UIButton을 항상 둥근 모양으로 유지해야하는 이슈

    • 알람 설정 버튼을 커스텀 버튼으로 구현하였음.
    • 알람 설정 버튼 크기가 변해도 계속 CornerRadius = frame.height / 2가 유지되어야 할 필요성이 발생.
    • layoutSubviews() 를 오버라이드하여 CornerRadius를 설정해주는 코드를 넣어 해결함.
  • StackView에 Padding 적용 이슈

    • stackView.layoutMargins 뿐만아니라 isLayoutMarginsRelativeArrangement = true 해주어야 패딩이 제대로 적용됨.
      stackView.isLayoutMarginsRelativeArrangement = true
      stackView.layoutMargins = UIEdgeInsets(top: 3, left: 3, bottom: 3, right: 3)
  • ImaveView 크기와 별개로 image의 크기를 작게 넢고 싶은 경우

    let image = UIImage().withAlignmentRectInsets(UIEdgeInsets())

[Refactor] 남은 정거장 수와 혼잡도를 나타내는 Label을 커스텀 Label로 분리

개요

image
위에 보이는 Badge를 기존에는 BusCellTrailingView 에서 직접 생성하고 다뤘음
하지만 AttributedText 등을 적용하고 나니, padding이 제대로 적용되지 않는 현상 발견
그래서 그것을 해결하기 위해 constraint로 label의 width와 height을 직접 지정해 주었으나
10번째전 혼잡 -> 1번째전 이런식으로 Label의 크기가 런타임 시점에 바뀌는 경우가 대응이 안됨...

따라서 Padding을 가지고 있는 CustomView로 그것을 대체하기로 결정

완료조건

  • Custom Label 생성

레퍼런스

Label에 공백 지정하기

[메모리 누수] coordinator.terminate() 하여도 각 ViewController 및 하위 뷰들은 메모리에 남아있다.

개요

메모리 누수 상태

스크린샷 2021-11-09 오후 4 30 16

스크린샷 2021-11-09 오후 4 30 31

coordinator.terminate() 를 두 번 실행한 결과이다.
그럼에도 Viewcontroller와 각 하위 View들이 메모리에서 상주되어있다.
원인은 아직 찾지 못함

완료조건

  • 각 화면이 pop 될때 누수가 발생하지 않고 메모리 할당해제가 잘 되어야 한다.

레퍼런스

두 가지 assign 메소드
assign(to🔛) 하면 강한참조가 일어나는 이슈
assign(to🔛) 하면 강한참조가 일어나는 이슈2
assign(to:)를 사용하면 강한참조가 일어나지 않음

[Refactor] Global 폴더링

작업 내용

  • Color, Image 등 상수로 선언(enum)
  • Global 폴더링
  • LaunchScreen 추가
  • 앱아이콘 추가
  • 다크모드 대응 완료

레퍼런스

없음.

기타

[뷰] 1-7. 이동 현황 화면이 제공되어야 한다.

작업 내용

  • 헤더 커스텀뷰에 버스번호, 현위치 및 소요시간 란, 닫기 버튼이 구현되어야 한다.
  • 승차부터 하차간의 경로를 표시하는 TableView가 구현되어야 한다.
  • 경로를 표현하는 TableViewCell 이 구현되어야 한다.
  • 현재위치를 표시하는 TagView가 구현되어야 한다.
  • 알람 종료 버튼이 추가되어야 한다.

레퍼런스

없음.

기타

현재 이동현황화면 구현방법 요약

  • 다른 화면의 경우 coordinator가 ViewController를 생성한 다음presenter(navigationController)를 사용해서 push해서 뷰를 띄움. 이렇게 하면 뷰컨이 navigationController의 child로 들어감
  • 반면 이동현황 화면은 coordinator가 ViewController 생성 후 presenter(NavigationController).push를 사용하지 않고 window.addSubview로 뷰를 띄움. 생성한 ViewController는 SceneDelegate에 저장해둠.

현재 구현방법의 문제점

1. ViewController의 참조 문제

  • NavigationController는 window에게 참조당하고 있음. window.rootViewController = navigationController
  • 반면 MovingStatusViewController는 window에게 참조되지 않고 있음.
  • window에 View만 띄웠지 ViewController는 window와 아무런 연결도 안 되어있는 구조.
  • 그렇다보니 MovingStatusViewController를 어딘가에 담아두지 않으면 ARC에 의해 수거되어버리는 문제 발생.
  • 이 문제를 해결하기 위해 억지로 SceneDelegate에 movingStatusViewController라는 변수를 두고 참조하게 하고 있음.
    class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    
        var window: UIWindow?
        var appCoordinator: AppCoordinator?
        var movingStatusViewController: MovingStatusViewController?  // Here
    }
  • 그래서 window가 2개의 ViewController(NavigationController와 MovingStatusViewController)를 참조하게 할 수 있는 방법을 찾아보았는데 찾지 못했음.
  • 생각해보면 결국 window도 UIView이므로 single view에 multiple viewcontroller를 지정할 수 있는가?라는 문제와 같아지는데,
  • 아래와 같은 구조처럼 하나의 view에 여러개의 "자식" viewController는 가능하겠지만,
    ㄴ parentView & parentViewController
        ㄴ firstChildView & firstChildViewController
        ㄴ secondChildView & secondChildViewController
  • 아래처럼 하나의 view에 2개 이상의 viewController는 당연히 불가능하지 않을까.
    ㄴ view & firstViewController & secondViewController
  • 애초에 view가 ViewController를 참조(소유)하는게 아니라 ViewController가 view를 참조(소유)하므로 view:ViewController가 1:n이 될 수가 없지 않은가.
  • window에서 ViewController를 지정할 수 있는건 window이기 때문에 가지는 특수성인 것 같음. 일반 UIView에서는 불가능.
  • (한가지 의문이 남는건 그러면 왜 window.viewController가 아니라 window.rootViewController라고 해서 다른 뷰컨도 존재하는 것 같은 느낌을 줬을까?)
  • 따라서 window와 MovingStatusViewController를 연관짓는 구조로의 개선이 필요하다는 결론.

2. Coordinator 참조 문제

  • 먼저 현재 Coordianator 패턴 작동 방식을 요약해보자면,
  • 화면을 Push하려면 현재 자신이 embed되어야 하는 navigationController를 알아야하므로 Coordinator 내의 presenter라는 이름의 프로퍼티에 현재 자신이 embed되어야하는 navigationController를 저장해놓고, presenter.push()함.
  • 이때 presenter는 부모 Coordinator로부터 주입받는다. AppCoordinator가 가진 presenter 하나를 자식, 손자...까지 다 물려주면서 공유하는 것.
  • 따라서 앱 실행 -> 홈 화면 -> (홈 화면에서 알람버튼 터치) -> 알람 설정 화면 -> (하차 알람 버튼 터치) -> 이동 현황 화면 과 같은 시나리오가 있다면, 현재 구조에서는 계층이 아래와 같이 형성될 것임.
    ㄴ AppCoordinator
        ㄴ HomeCoordinator
            ㄴ AlarmSettingCoordinator   
                ㄴ MovingStatusCoordinator
  • 이렇게 되면 알람설정화면에서 BackButton을 눌러 pop을 하면, AlarmSettinCoordinator가 사라지므로 자식인 MovingStatusCoordinator까지 사라져서 이동현황화면을 끌 수가 없는 문제 발생. (CooridnatorFinishDelegate가 weak이므로)
  • (현재는 weak 처리가 안되어있어서 꺼지는게 가능함. 이렇게 하면 순환참조 문제 발생할 것임. 실제로도 발생하는 것을 확인함. 따라서 weak 처리 하는게 맞음.)
  • 따라서 한 가지 결론을 얻음.
  • MovingStatus 화면은 항상 최상위에 띄워지므로 다른 화면의 push/pop에 종속적이면 안된다. 따라서MovingStatusCoordinator가AlarmSetting(뿐만 아니라 AppCoordinator와 HomeCoordinator를 제외한 모든 Coordinator)의 자식으로 들어가면 안된다.

3. window 접근 문제

  • 항상 앱 최상위에 보여지려면 window.addSubview(MovingStatusViewController.view) 를 해야함.
  • 이때 window는 AppCoordinator가 private 프로퍼티로 가지고 있음. (SceneDelegate로부터 주입받음)
    class AppCoordinator: NSObject, Coordinator {
      private let window: UIWindow    // 윈도우    
      ... 생략 ...
    }
    class SceneDelegate: UIResponder, UIWindowSceneDelegate {
      var window: UIWindow?
      var appCoordinator: AppCoordinator?
    
      func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
          guard let windowScene = (scene as? UIWindowScene) else { return }
    
          self.window = UIWindow(windowScene: windowScene)
          guard let window = self.window else { return }
          self.appCoordinator = AppCoordinator(window: window)    // 윈도우 주입
          appCoordinator?.start()
      }
      ... 생략 ...
    }
  • 따라서 window 관련 로직은 모두 AppCoordinator가 담당하도록 하라는 의미로 해석하였음.
  • AppCoordinator가 아닌 다른 곳에서도 UIApplication.shared.windows.first로 window 접근이 가능하긴 함. (현재 이 방법을 써서 window에 추가하도록 구현되어있음.)
    guard let window = UIApplication.shared.windows.first else { return }
    
    let movingStatusViewController = MovingStatusViewController()
    window.addSubview(movingStatusViewController.view)
  • 하지만 window 관리자인 AppCoordinator를 무시하고 MovingStatusCoordinator에서 UIApplication.shared.windows.first으로 window에 직접 접근 하는 것은 옳지 않은 방법이라고 생각됨.
  • 결론은 window를 주입받아 사용하는 구조로의 개선이 필요하다.

구조 개선의 필요성

  • window와 MovingStatusViewController를 연관짓는 구조로의 개선이 필요하다.
  • MovingStatus 화면은 항상 최상위에 띄워지므로 다른 화면의 push/pop에 종속적이면 안된다. 따라서 MovingStatusCoordinator가AlarmSetting(뿐만 아니라 AppCoordinator와 HomeCoordinator를 제외한 모든 Coordinator)의 자식으로 들어가면 안된다.
  • 아래 구조로 바꾸면 위의 두 가지는 만족될 것으로 보임.
  • NavigationController와 MovingStatusViewController를 자식으로 갖는 상위의 RootViewController를 만들고 window.rootViewController = rootViewController 가 되는 구조.
    ㄴ window & rootViewController -- AppCoordinator
        ㄴ view & NavigationController
        ㄴ view & MovingStatusViewController
  • window를 주입받아 사용하는 구조로의 개선이 필요하다.
  • 문제는 이 부분에서 생기는데,
  • 홈화면 -> 버스정거장화면 -> 버스노선화면 -> 알람설정화면 -> 이동현황화면을 띄우는 시나리오가 있다고 가정.
  • 위에서 언급한 상위 뷰컨트롤러를 만들게 되면 전체 구조는 아래와 같아져야함.
  • MovingStatus는 window에 추가하므로 MovingStatusCoordinator에는 AppCoordinator가 가지고 있는 window를 주입해주어야 함.
  • 그런데 MovingStatusCoordinator는 AlarmSettingCoordinator가 생성함.
  • 이때 Delegate 패턴을 써서 AppCoordinor를 window.addSubview를 하는 Delegate로 주입해주는 해결법을 떠올릴 수 있음.
  • 다만 이렇게 되면 현재 Coordinator 패턴에서는 아래와 같은 이슈가 발생함.
  • 최상위인 AppCoordinator에서 최하위인 AlarmSettingCoordinator까지 Delegate 전달이 필요.
  • 그런데 구조가 부모-자식-손자-... 형태이므로 중간에 많은 Coordinator들을 반드시 거쳐서 전달해야함.
  • MovingStatus화면을 새로 띄울 수 있는 화면은 AlarmSetting 화면밖에 없고 다른 화면(홈,노선,정류장화면)에서는 MovingStatus화면을 띄울 수 없는데도 중간에 거쳐가는 화면이라는 이유 하나만으로 Delegate를 주입 받아야하는 문제 발생.
  • 이렇게 되면 이동현황 화면에 접근할 수 없는 화면에서도 delegate를 지니는 접근성 문제도 생기고, delegate가 필요가 없는데도 받아서 다음 화면에 전달해줘야하는 오버헤드도 발생.
  • 현재 구조에서 좀 더 좋은 구조로 개선하는 것이 필요하지 않을까.

[Refactor] 코드리뷰간 언급된 Convention 관련 코드 반영

개요

#15 의 피드백을 반영하였음

완료조건

  • SearchCoordinator에서 NSObject, Coordinator 제거, self 붙이기
  • translatesAutoresizingMaskIntoConstraintsaddSubview 순서
  • NavigationController.pop() 대신 terminate() 사용
  • MARK 달기
  • Button의 액션을 Delegate의 DidSet에서 시행 -> KeyboardAccessoryView.swift
  • SearchViewController에서 delegate configure 관련 코드 정리
    • 추가 작업 필요. 하나로 합칠 수 있다면 편할듯
  • KeyboardAccessoryView, SearchNavigationView 매직 넘버 처리
    • 논의 피룡
  • SearchResultScrollView 메소드 개행

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.