티스토리 뷰

728x90

3-1. 교통 화면 - 버스


1) 사용한 Api

 

 

국토교통부_(TAGO)_버스정류소정보

정류소명, 정류소번호를 기준으로 시내버스 정류소정보를 조회하는 버스정류소조회 서비스. 제공하는 도시는 [도시코드 목록 조회] 오퍼레이션으로 검색이 가능하다.

www.data.go.kr

위의 API 중 [좌표 기반 근접 정류소 목록 조회]와 [정류소 별 경유 노선 목록 조회]를 사용하였습니다.

 

 

국토교통부_(TAGO)_버스도착정보

정류소를 기준으로 현재 운행중인 버스의 도착예정정보를 조회하는 도착정보조회 서비스. 제공하는 도시는 [도시코드 목록 조회] 오퍼레이션으로 검색이 가능하다.

www.data.go.kr

위의 API 중 [정류소 별 도착 예정 정보 목록 조회]와 [정류소 별 특정 노선버스 도착 예정 정보 조회]를 사용하였습니다.

 

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

카카오 Api 중 키워드로 장소 검색하기를 사용하였습니다.

 

 

NAVER CLOUD PLATFORM

cloud computing services for corporations, IaaS, PaaS, SaaS, with Global region and Security Technology Certification

www.ncloud.com

네이버 지도를 사용하였습니다.

 

GitHub - fornewid/naver-map-compose: NAVER Map Android SDK for Jetpack Compose 🗺

NAVER Map Android SDK for Jetpack Compose 🗺. Contribute to fornewid/naver-map-compose development by creating an account on GitHub.

github.com

Compose 버전에서는 위의 라이브러리를 이용하였습니다.

kakao Api를 사용하기 때문에 kakao map을 사용하려고 하였으나 2가지 이유로 네이버 지도를 사용하게 되었습니다.

  1. 카카오 지도의 경우 compose 제공이나 라이브러리가 없어서 커스텀이 많이 필요해 보였습니다.
  2. 지도에 버스 정류소 아이콘이 이미 있어서 api에서 나오지 않는 경우 처리를 할 수 없습니다.

스크린샷 2023-03-04 오후 8.45.06.png

1번의 경우 시간이야 걸리겠지만 작업을 시도해 볼 수 있지만 2번의 경우 위와 같이 아이콘이 나오는데 지울 수 있는 방법을 못 찾았습니다.

네이버 지도의 경우에는 위와 같이 버스 정류소는 표시가 되지 않기 때문에 네이버 지도를 선택하게 되었습니다.


2) API 사용

interface BusService {
        /***
     * 좌표 기반 근접 정류소 목록 조회
     * @param serviceKey 필수) 인증키
     * @param latitude 필수) WGS84 위도 좌표
     * @param longitude 필수) WGS84 경도 좌표
     * @param numOfRows 한 페이지 결과 수
     * @param pageNo 페이지 번호
     * @param type 데이터 타입(xml, json)
     * ***/
    @GET("BusSttnInfoInqireService/getCrdntPrxmtSttnList")
    suspend fun fetchBusStopList(
        @Query("serviceKey") serviceKey: String = BuildConfig.BUS_AUTH_KEY,
        @Query("gpsLati") latitude: Double,
        @Query("gpsLong") longitude: Double,
        @Query("numOfRows") numOfRows: Int = 10,
        @Query("pageNo") pageNo: Int = 1,
        @Query("_type") type: String = "json",
    ): BusApiResult<List<BusStop>>

    /**
     * 정류소 별 도착 예정 정보 목록 조회
     * @param serviceKey 필수) 인증키
     * @param cityCode 필수) 도시 코드
     * @param nodeId 필수) 정류소 ID
     * @param pageNo 페이지 번호
     * @param numOfRows 한 페이지 결과 수
     * @param type 데이터 타입(xml, json)
     * **/
    @GET("ArvlInfoInqireService/getSttnAcctoArvlPrearngeInfoList")
    suspend fun fetchEstimatedArrivalInfoList(
        @Query("serviceKey") serviceKey: String = BuildConfig.BUS_AUTH_KEY,
        @Query("cityCode") cityCode: Int,
        @Query("nodeId") nodeId: String,
        @Query("pageNo") pageNo: Int = 1,
        @Query("numOfRows") numOfRows: Int = 10,
        @Query("_type") type: String = "json"
    ): BusApiResult<List<EstimatedArrivalInfo>>

    /**
     * 정류소의 특정 노선 버스 도착 예정 정보 목록 조회
     * @param serviceKey 필수) 인증키
     * @param cityCode 필수) 도시 코드
     * @param nodeId 필수) 정류소 ID
     * @param routeId 필수) 노선 ID
     * @param pageNo 페이지 번호
     * @param numOfRows 한 페이지 결과 수
     * @param type 데이터 타입(xml, json)
     * * **/
    @GET("ArvlInfoInqireService/getSttnAcctoSpcifyRouteBusArvlPrearngeInfoList")
    suspend fun fetchRouteEstimatedArrivalInfoList(
        @Query("serviceKey") serviceKey: String = BuildConfig.BUS_AUTH_KEY,
        @Query("cityCode") cityCode: Int,
        @Query("nodeId") nodeId: String,
        @Query("routeId") routeId: String,
        @Query("pageNo") pageNo: Int = 1,
        @Query("numOfRows") numOfRows: Int = 10,
        @Query("_type") type: String = "json"
    ): BusApiResult<List<EstimatedArrivalInfo>>

    /** 노선별 경유 정류소 목록 조회 
     * @param serviceKey 필수) 인증키
     * @param cityCode 필수) 도시 코드
     * @param routeId 필수) 노선 ID
     * @param pageNo 페이지 번호
     * @param numOfRows 한 페이지 결과 수
     * @param type 데이터 타입(xml, json)
     * **/
    @GET("BusRouteInfoInqireService/getRouteAcctoThrghSttnList")
    suspend fun fetchBusStopRouteList(
        @Query("serviceKey") serviceKey: String = BuildConfig.BUS_AUTH_KEY,
        @Query("pageNo") pageNo: Int = 1,
        @Query("numOfRows") numOfRows: Int = 20,
        @Query("_type") type: String = "json",
        @Query("cityCode") cityCode: Int,
        @Query("routeId") routeId: String
    ): BusApiResult<List<BusStopRoute>>
}
data class BusApiResult <T> (
    val response: BusApiResultContents<T>
)

data class BusApiResultContents <T> (
    val header: BusApiResultHeader,
    val body: BusApiResultBody<T>
)

/**
 * @param resultCode 결과 코드
 * @param resultMsg 결과 메세지
 * **/
data class BusApiResultHeader(
    val resultCode: String,
    val resultMsg: String
)

/**
 * @param numOfRows 한 페이지 결과 수
 * @param pageNo 페이지 수
 * @param totalCount 데이터 총 개수
 * **/
data class BusApiResultBody <T> (
    val items: BusApiResultItem<T>,
    val numOfRows: Int,
    val pageNo: Int,
    val totalCount: Int,
)

data class BusApiResultItem <T> (
    val item: T
)

버스 API는 양식이 동일한 부분이 많이 있어서 공통으로 사용할 수 있도록 위와 같이 만들었습니다.

class BusClient @Inject constructor(
    private val service: BusService
) {

    /** 버스 정류소 조회 **/
    suspend fun fetchBusStopList(
        latitude: Double,
        longitude: Double
    ) = service.fetchBusStopList(
        latitude = latitude,
        longitude = longitude
    ).response.body.items.item
   ...
}

그다음 Client에서 공통 영역에서 필요에 따라 공통부분은 제외하여 Repository에 전달하여서 사용하였습니다.

Repository에서 사용한 내용은 아래에서 다루도록 하겠습니다.
그 외는 Retrofit 사용 방법이라 넘어가겠습니다.


3) 데이터베이스

/**
 * 즐겨 찾기
 * @param type Bus, BusStop, Subway, SubwayDestination 중 1개 선택 (const 제공)
 * @param startTime 시작 시간
 * @param endTime 종료 시간
 * @param name 이름
 * @param id 서버 조회를 위한 아이디
 * @param timeStamp 등록 시간
 * **/
@Entity(indices = [
    Index(unique = true, value = ["id", "name"])
])
data class FavoriteEntity(
    @PrimaryKey(autoGenerate = true) val index: Int,
    val type: String,
    val startTime: String,
    val endTime: String,
    val name: String,
    val id: String,
    val timeStamp: Long
) {
    fun toFavorite(): Favorite = Favorite(
        type = type,
        time = "$startTime - $endTime",
        name = name,
        id = id
    )
    companion object {
        const val TypeBus = "Bus"
        const val TypeBusStop = "BusStop"
        const val TypeSubway = "Subway"
        const val TypeSubwayDestination = "SubwayDestination"
        const val Separator = "|||"
    }
}

/**
 * 즐겨찾기 정보
 * @param type 즐겨찾기 타입
 * @param time 홈 노출 시간
 * @param name 이름
 * @param id 아이디
 * **/
data class Favorite(
    val type: String,
    val time: String,
    val name: String,
    val id: String
)

즐겨찾기 데이터베이스 엔티티와 화면에서 사용할 정보를 담은 데이터 클래스입니다.

타입은 오타 방지를 위하여 const를 사용하고 있습니다.

Separator는 이름과 아이디에 구분을 위해 사용하는 문자입니다.

@Dao
interface FavoriteDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertFavorite(favoriteEntity: FavoriteEntity)

    @Query("DELETE FROM FavoriteEntity WHERE id = :id")
    suspend fun deleteFavorite(id: String)

    @Query("SELECT EXISTS(SELECT * FROM FavoriteEntity WHERE id = :id)")
    suspend fun fetchFavoriteCountById(id: String): Int

    @Transaction
    suspend fun favoriteInsertOrDelete(
        type: String,
        id: String,
        name: String
    ) {
        if (fetchFavoriteCountById(id)){
            deleteFavorite(id)
        } else {
            insertFavorite(
                FavoriteEntity(
                    index = 0,
                    type = type,
                    startTime = "",
                    endTime = "",
                    name = name,
                    id = id,
                    timeStamp = Calendar.getInstance().timeInMillis
                )
            )
        }
    }

    @Query("SELECT * FROM FavoriteEntity ORDER BY startTime ASC, timeStamp ASC")
    fun fetchFavoriteList(): Flow<List<FavoriteEntity>>

    @Query("SELECT EXISTS(SELECT * FROM FavoriteEntity WHERE id = :id)")
    fun fetchFavoriteById(id: String): Flow<Boolean>

}

즐겨찾기 추가, 삭제, 조회하는 코드입니다.

데이터베이스 기능에 Transaction이 있다는 것은 알고 있었고 예제도 본 적은 있는데 실제로 사용해보는 건 처음인 것 같습니다.

EXISTS도 분명 학교에서 배웠을 텐데 데이터베이스를 많이 안 쓰다 보니 다 까먹었습니다.

이번 프로젝트 진행하면서 데이터베이스를 사용하는 방법을 많이 복습&학습하고 있습니다.


4) 교통 화면

3 교통.png

교통 화면에서는 버스와 지하철 시간표 조회로 이동할 수 있는 화면입니다.
버스와 지하철 페이지에서 즐겨찾기를 등록하면 4번 영역과 같이 표현이 됩니다.
즐겨찾기는 버스 정류소, 버스 번호 + 정류소, 지하철역, 목적지를 설정한 지하철역의 4가지 타입을 등록할 수 있습니다.

즐겨찾기 영역에서는 각 타입에 맞게 카드를 보여줍니다.

UI 그리는 코드는 너무 길어지므로 특별한 것 외에는 제외하겠습니다.
또한 중복되어 설명할 것이 없을 경우에도 생략을 합니다.

이번 페이지는 데이터베이스에서 조회해서 리스트 출력하는 것 밖에는 없어서 코드도 제외합니다.


5) 버스 정류소 검색 화면

3.1.1 버스 정류장 검색.png

이 화면에서는 지도를 보면서 버스 정류소를 찾는 페이지입니다.

정류소를 탐색하는 방법은 3 가지 입니다.

  • 퍼미션을 허용할 경우 내 위치를 기준으로 주변 정류소 조회를 합니다.
  • 지도의 카메라의 위치를 기준으로 주변 정류소를 조회합니다.
  • 키워드 검색을 통해 해당 위치를 기준으로 주변 정류소를 조회합니다.

네이버 맵의 경우 이전 포스팅에서 사용했던 것을 조금 수정해서 사용한 것이라서 자세한 사용 방법은 링크로 남깁니다.

 

 

Compose Naver Map

1) API 키 발급 이번에 역시 키 발급 방법에 관련해서는 생략하겠습니다. Naver Colud Platform 사이트에 서비스 > Application Services > Maps에서 활용 신청을 하시면 됩니다. NAVER CLOUD PLATFORM cloud computing servic

alanboyce.tistory.com

/**
* 키워드로 장소 검색하기
* **/
@GET("https://dapi.kakao.com/v2/local/search/keyword.json")
suspend fun fetchPlaceListByKeyword(
    @Header("Authorization") token: String = "KakaoAK ${BuildConfig.KAKAO_REST_API_KEY}",
    @Query("query") query: String = "서울역"
): PlaceResult

키워드로 검색 Api입니다.

/***
 * 좌표 기반 근접 정류소 목록 조회
 * @param serviceKey 필수) 인증키
 * @param latitude 필수) WGS84 위도 좌표
 * @param longitude 필수) WGS84 경도 좌표
 * @param numOfRows 한 페이지 결과 수
 * @param pageNo 페이지 번호
 * @param type 데이터 타입(xml, json)
 * ***/
@GET("BusSttnInfoInqireService/getCrdntPrxmtSttnList")
suspend fun fetchBusStopList(
    @Query("serviceKey") serviceKey: String = BuildConfig.BUS_AUTH_KEY,
    @Query("gpsLati") latitude: Double,
    @Query("gpsLong") longitude: Double,
    @Query("numOfRows") numOfRows: Int = 10,
    @Query("pageNo") pageNo: Int = 1,
    @Query("_type") type: String = "json",
): BusApiResult<List<BusStop>>

좌표 기반으로 근접 정류소를 조회하는 Api입니다.

override fun searchBusStopList(
    keyword: String,
    latitude: Double,
    longitude: Double,
    cameraLocation: (Double, Double) -> Unit,
) = flow {
    // 버스 정류소 리스트
    try {
        if (keyword.isNotEmpty()) {
            // 키워드로 검색된 장소 리스트
            val placeList = kakaoClient.fetchPlaceListByKeyword(keyword = keyword).documents

            // 리스트 중 SW8 (지하철 역)이 있을 경우 지하철 역 장소 저장
            // 해당 사항 없을 경우 첫 번째 장소 저장
            val place = placeList.find {
                it.categoryGroupCode == "SW8"
            } ?: placeList.firstOrNull()

            place?.let {
                // 지도 카메라 위치 지정
                cameraLocation(it.y, it.x)
                emit(busClient.fetchBusStopList(latitude = it.y, longitude = it.x))
            } ?: emit(emptyList())
        } else {
            cameraLocation(latitude, longitude)
            emit(busClient.fetchBusStopList(latitude = latitude, longitude = longitude))
        }
    } catch (e: Exception) {
        e.printStackTrace()
        emit(emptyList())
    }

키워드 검색 결과가 리스트로 넘어오는데 그중 하나만 선택해서 좌표로 버스 정류소를 조회해야 합니다.
그래서 제공되는 카테고리 중 지하철역이 있을 경우에는 지하철역을 그 외는 리스트에 첫 번째 장소로 버스 정류소를 조회합니다.

좌표 기반으로 검색한 경우에는 바로 버스 조회를 수행합니다.

private val _place: MutableSharedFlow<String> = MutableSharedFlow(replay = 1)
val place = _place.flatMapLatest {
    kakaoRepository.searchBusStopList(
        keyword = it,
        latitude = _cameraLatLng.value.latitude,
        longitude = _cameraLatLng.value.longitude,
        cameraLocation = { lat, lng ->
            _cameraLatLng.value = LatLng(lat, lng)
        }
    )
}

/** 키워드로 버스 정류장 검색 **/
fun searchBusStopByKeyword(keyword: String) {
    _place.tryEmit(keyword)
}

/** 위치로 버스 정류장 검색 **/
fun searchBusStopByLocation(latLng: LatLng) {
    _cameraLatLng.value = latLng
    _place.tryEmit("")
}

ViewModel에서 호출하는 코드입니다.

val busStopList: List<BusStop> by viewModel.place.collectAsState(initial = emptyList())
...

// 네이버 지도
CommonNaverMap(
    list = busStopList,
    cameraLatLng = viewModel.cameraLatLng.value,
    cameraPositionState = cameraPositionState
) { cityCode, nodeId, name ->
    goToArrivalInfo(cityCode, nodeId, name)
}
...

화면에서는 위와 같이 데이터를 받아서 네이버 지도에 좌표값을 넘겨줍니다.

...
NaverMap(
    locationSource = rememberFusedLocationSource(),
    properties = mapProperties,
    uiSettings = mapUiSettings,
    cameraPositionState = cameraPositionState,
) {
    list.forEach {
        Marker(
            state = MarkerState(
                position = LatLng(it.latitude, it.longitude)
            ),
            captionText = it.nodeNm,
            onClick = { _ ->
                onMarkerClick(it.cityCode, it.nodeId, it.nodeNm)
                true
            }
        )
    }

    LaunchedEffect(cameraLatLng) {
        scope.launch {
            cameraPositionState.animate(
                update = CameraUpdate.scrollTo(cameraLatLng),
                animation = CameraAnimation.Fly,
            )
        }
    }
}

좌표 값이 리스트로 넘어오고 리스트를 forEach를 통해 마커를 그립니다.

리스트의 값이 변경되면 기존에 있던 마커들은 사라지고 새로운 좌표에 마커들을 그립니다.


6) 정류소 별 버스 도착 시간 조회

3.1.2 정류소 별 버스 도착 시간 조회 (다수).png3.1.2-2 정류소 별 버스 도착 시간 조회 (단건).png

이 화면에서는 버스 정류소 별 벗 도착 시간 조회를 하는 화면입니다.

1개의 버스의 경우 리스트로 하면 빈 공간이 너무 많아서 조금이라도 줄이기 위해 구분해서 화면을 만들어 보았습니다.

override fun fetchEstimatedArrivalInfoList(
    cityCode: Int,
    nodeId: String,
) = flow {
    try {
        val list = mutableListOf<BusEstimatedArrivalInfo>()
        client.fetchEstimatedArrivalInfoList(
            cityCode = cityCode,
            nodeId = nodeId
        ).groupBy { it.routeId }.forEach { (_, value) ->
            list.add(
                value.toBusEstimatedArrivalInfo(
                    isFavorite = favoriteDao.fetchFavoriteCountById(
                        id = "${value[0].nodeId}${FavoriteEntity.Separator}${value[0].routeId}"
                    )
                )
            )
        }
        emit(list)
    } catch (e: Exception) {
        e.printStackTrace()
        emit(emptyList())
    }
}

버스 도착 정보를 가져오는 코드입니다.

Api에서는 도착 시간 기준으로 도착 정보를 넘겨주는데 화면에서는 버스별로 묶어서 출력을 해야 합니다.

그래서 List에 groupBy 기능을 이용하여 버스 별로 데이터를 묶고 화면에 출력하기 편한 데이터 클래스로 변환해서 ViewModel로 전달하였습니다.

override suspend fun toggleBusFavoriteStatus(info: BusEstimatedArrivalInfo) {
    try {
        favoriteDao.favoriteInsertOrDelete(
            type = FavoriteEntity.TypeBus,
            id = "${info.nodeId}${FavoriteEntity.Separator}${info.routeId}",
            name = "${info.busNumber}${FavoriteEntity.Separator}${info.nodeName}"
        )
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

즐겨찾기 업데이트 코드입니다.

@Transaction
suspend fun favoriteInsertOrDelete(
    type: String,
    id: String,
    name: String
) {
    if (fetchFavoriteCountById(id)){
        deleteFavorite(id)
    } else {
        insertFavorite(
            FavoriteEntity(
                index = 0,
                type = type,
                startTime = "",
                endTime = "",
                name = name,
                id = id,
                timeStamp = Calendar.getInstance().timeInMillis
            )
        )
    }
}

Transaction 기능을 이용해서 해당 아이디로 데이터베이스에 정보가 있으면 삭제, 없으면 추가하도록 작업을 하였습니다.


7) 버스 노선도

3.1.3 버스 노선도.png

이 화면에서는 버스 노선표를 보여줍니다.

디자인을 넣으려고 고민을 해보긴 했는데 마땅히 생각나는 게 없어서 단순하게 표시하였습니다.

override fun fetchBusStopRouteList(
    cityCode: Int,
    routeId: String
) = flow {
    val list = mutableListOf<BusStopRoute>()
    val numberOfRow = 20
    var pageNumber = 1
    var totalCount: Int? = null

    try {
        while (totalCount == null || (pageNumber - 1) * numberOfRow < totalCount) {
            val result = client.fetchBusStopRouteList(
                cityCode = cityCode,
                routeId = routeId,
                pageNo = pageNumber
            )
            list.addAll(result.items.item)
            totalCount = result.totalCount
            pageNumber++
        }
    } catch (e: Exception) {
        e.printStackTrace()
    }
    emit(
        list.map {
            it.toBusStopRouteItem(
                isEndNode = it.index == totalCount,
                isFavorite = favoriteDao.fetchFavoriteCountById(
                    "${it.nodeId}${FavoriteEntity.Separator}${it.routeId}"
                )
            )
        }
    )
}

버스 노선도를 불러오는 코드입니다.

해당 api에 한 번 호출할 때 20개의 Row를 호출하면 전체 Row 수를 알려주는데 전체 개수만큼 반복하면서 api를 호출해 주어야 합니다. 이 코드는 지하철 작업을 진행하면서 다른 방식으로 진행하는 것이 더 좋다고 생각해서 수정할 예정입니다.

728x90
댓글