[Android/Kotlin] 네이버 지도에 실시간 이동 경로 표시하기

코코아의 개발일지·2024년 5월 25일
0

Android-Kotlin

목록 보기
28/31
post-thumbnail

✍🏻 서론

(지난 내용 복습)
개발을 준비하고 있는 여행 관련 프로젝트에서 다음과 같은 두 가지 사전 과제를 요구받았다.

  1. 백그라운드에서 실시간으로 사용자의 위치를 받아올 것
  2. 지도에 사용자의 위치를 선으로 연결해서 보여줄 것

아직 개발을 본격적으로 시작하지는 않았지만 서비스에 대해 간단한 소개를 하자면,
사용자의 이동 경로를 지도에 표시하여 커뮤니티에 공유할 수 있는 느낌이다.
따라서, 위에서 작성한 두 가지가 우리 프로젝트의 핵심 기능이었다.


이전 편에서는 내가 왜 지도를 보여줄 sdk로 네이버 지도를 선택했는지와, 네이버 지도에 현재 위치를 표시하는 과정을 설명했었다.

🔗 이전 포스트
[Android/Kotlin] 네이버 지도로 현재 위치 불러오기

그럼 이번 포스트에서는 앱의 핵심 기능인 백그라운드 실시간 위치 정보 가져오기지도에 사용자 경로 표시하기를 다뤄보겠다.
번외로 경로 삭제 기능도 추가해 보았다.

[📱 완성 화면 미리 보기 📱]

실시간 경로 표시 경로 삭제



💻 코드 작성

1️⃣ 백그라운드에서 사용자 위치 받아오기

1) build.gradle(:app)

build.gradle(:app)에서 사용자 위치를 받기 위한 라이브러리를 추가해 준다.

dependencies {
	// 사용자 위치
    implementation 'com.google.android.gms:play-services-location:21.2.0'
}

2) Menifest에서 권한 코드 추가

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>

FOREGROUND_SERVICEFOREGROUND_SERVICE_LOCATION 권한이 함께 추가된 이유는 Android 14 이상부터 사용하는 포그라운드 권한을 명시해주어야 한다는 내용이 추가되었기 때문이다.

또한, LocationService라는 서비스 클래스에서 실시간 위치 권한을 받아올 계획이기에
application 태그 안에서

<service
	android:name=".LocationService"
	android:enabled="true"
	android:exported="false"
	android:foregroundServiceType="location"/>

해당 코드를 추가해 준다.
foregroundServiceType="location" 부분도 Android 14 이상부터 필수적으로 요구하는 항목으로, 위에서 선언한 FOREGROUND_SERVICE_LOCATION 권한과 맞물리는 부분이다. 여기에 우리 앱이 서비스 유형으로 위치 권한을 사용할 것임을 location으로 표시해 준다.
(위치 말고도 camera, mediaPlayback 등 다양한 유형이 있다.)

2) LocationService 서비스 코드 작성

전체 코드

class LocationService : Service() {
    private val mLocationCallback: LocationCallback = object : LocationCallback() {
        override fun onLocationResult(locationResult: LocationResult) {
            super.onLocationResult(locationResult)
            if (locationResult.lastLocation != null) {
                val latitude = locationResult.lastLocation!!.latitude
                val longitude = locationResult.lastLocation!!.longitude
                Log.v("LOCATION_UPDATE", "$latitude, $longitude")
                locationInterface?.sendLocation(latitude, longitude)
            }
        }
    }

    override fun onBind(intent: Intent): IBinder? {
        throw UnsupportedOperationException("Not yet implemented")
    }

    @SuppressLint("ForegroundServiceType")
    private fun startLocationService() {
        val channelId = "location_notification_channel"
        val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
        val resultIntent = Intent()
        val pendingIntent = PendingIntent.getActivity(
            applicationContext,
            0,
            resultIntent,
            PendingIntent.FLAG_IMMUTABLE
        )
        val builder = NotificationCompat.Builder(applicationContext, channelId)
        builder.apply {
            setSmallIcon(R.mipmap.ic_launcher)
            setContentTitle("Location Service")
            setDefaults(NotificationCompat.DEFAULT_ALL)
            setContentText(LocalDateTime.now().toString()) // 측정 시작 시간
            setContentIntent(pendingIntent)
            setAutoCancel(false)
            priority = NotificationCompat.PRIORITY_MAX
        }
        if (notificationManager.getNotificationChannel(channelId) == null) {
            val notificationChannel = NotificationChannel(
                channelId,
                "Location Service",
                NotificationManager.IMPORTANCE_HIGH
            )
            notificationChannel.description = "This channel is used by location service"
            notificationManager.createNotificationChannel(notificationChannel)
        }
        val locationRequest = LocationRequest.Builder(INTERVAL_MILLS)
            .setIntervalMillis(INTERVAL_MILLS)
            .setPriority(Priority.PRIORITY_HIGH_ACCURACY)
            .build()
        if (ActivityCompat.checkSelfPermission(
                this,
                Manifest.permission.ACCESS_FINE_LOCATION
            ) != PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission(
                this,
                Manifest.permission.ACCESS_COARSE_LOCATION
            ) != PackageManager.PERMISSION_GRANTED
        ) {
            // TODO: Consider calling
            //    ActivityCompat#requestPermissions
            // here to request the missing permissions, and then overriding
            //   public void onRequestPermissionsResult(int requestCode, String[] permissions,
            //                                          int[] grantResults)
            // to handle the case where the user grants the permission. See the documentation
            // for ActivityCompat#requestPermissions for more details.
            return
        }
        LocationServices.getFusedLocationProviderClient(this)
            .requestLocationUpdates(locationRequest, mLocationCallback, Looper.getMainLooper())
        startForeground(Constants.LOCATION_SERVICE_ID, builder.build())
    }

    private fun stopLocationService() {
        LocationServices.getFusedLocationProviderClient(this)
            .removeLocationUpdates(mLocationCallback)
        stopForeground(true)
        stopSelf()
    }

    override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
        val action = intent.action
        if (action != null) {
            if (action == Constants.ACTION_START_LOCATION_SERVICE) {
                startLocationService()
            } else if (action == Constants.ACTION_STOP_LOCATION_SERVICE) {
                stopLocationService()
            }
        }
        return super.onStartCommand(intent, flags, startId)
    }

    fun setLocationUpdateInterface(locationInterface: LocationUpdateInterface) {
        this.locationInterface = locationInterface
        Log.d("LocationService", "setLocationUpdateInterface()")
    }

    companion object {
        const val INTERVAL_MILLS = 60 * 1000L // 1 minutes
    }
}

INTERVAL_MILLS로 얼마마다 위치를 받아줄 건지 설정을 해줬는데, 나는 우선 1분마다 받아오도록 만들었다.

위치 정보를 받기 시작하면 중단하기 전까지 notification으로 위 내용이 계속해서 표시된다.

3) MainActivity에서 서비스의 위치 가져오기

인터페이스 정의

interface LocationUpdateInterface {
    fun sendLocation(latitude: Double, longitude: Double)
}

LocationService -> MainActivity로 위치를 넘겨받을 인터페이스를 정의해 준다.

LocationService.kt에서 인터페이스 활용

class LocationService(private var locationInterface: LocationUpdateInterface? = null) : Service() {

    private val binder = LocalBinder()

    inner class LocalBinder : Binder() {
        fun getService(): LocationService = this@LocationService
    }

    private val mLocationCallback: LocationCallback = object : LocationCallback() {
        override fun onLocationResult(locationResult: LocationResult) {
            super.onLocationResult(locationResult)
            if (locationResult.lastLocation != null) {
                val latitude = locationResult.lastLocation!!.latitude
                val longitude = locationResult.lastLocation!!.longitude
                Log.v("LOCATION_UPDATE", "$latitude, $longitude")
                locationInterface?.sendLocation(latitude, longitude)
            }
        }
    }

    override fun onBind(intent: Intent): IBinder? {
        return binder
    }
    
    fun setLocationUpdateInterface(locationInterface: LocationUpdateInterface) {
        this.locationInterface = locationInterface
        Log.d("LocationService", "setLocationUpdateInterface()")
    }
}

이전 코드에서 추가된 부분이다.
LocationService의 생성자로 LocationUpdateInterface를 받아 사용자 위치를 받아올 때마다 MainActivity로 위도와 경도를 보낼 수 있도록 한다.

MainActivity.kt에서 위치 전달받기

class MainActivity : AppCompatActivity(), OnMapReadyCallback, LocationUpdateInterface {
    private lateinit var binding: ActivityMainBinding
    // ...
    private var locationService: LocationService? = null
    private var isBound = false

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = ActivityMainBinding.inflate(layoutInflater)

        setContentView(binding.root)

        if (!hasPermission()) {
            requestLocationPermission()
        } else {
            initMapView()
        }
        initClickListeners()
    }

    override fun onDestroy() {
        super.onDestroy()
        unbindLocationService()
    }
    
    private fun initClickListeners() {
        binding.buttonStartLocationUpdates.setOnClickListener {
            if (!hasPermission()) {
                requestLocationPermission()
            } else {
                startLocationService()
            }
        }
        binding.buttonStopLocationUpdates.setOnClickListener { stopLocationService() }
    }

    // 위치 권한이 있을 경우 true, 없을 경우 false 반환
    private fun hasPermission(): Boolean {
        for (permission in PERMISSIONS) {
            if (ContextCompat.checkSelfPermission(this, permission)
                != PackageManager.PERMISSION_GRANTED
            ) {
                return false
            }
        }
        return true
    }

    // 위치 권한 요청
    private fun requestLocationPermission() {
        ActivityCompat.requestPermissions(
            this@MainActivity,
            arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
            REQUEST_CODE_LOCATION_PERMISSION
        )
    }

    private val connection = object : ServiceConnection {
        override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
            val binder = service as LocationService.LocalBinder
            locationService = binder.getService()
            locationService?.setLocationUpdateInterface(this@MainActivity)
            isBound = true
        }

        override fun onServiceDisconnected(name: ComponentName?) {
            isBound = false
        }
    }

    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        if (requestCode == REQUEST_CODE_LOCATION_PERMISSION && grantResults.isNotEmpty()) {
            if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                startLocationService()
            } else {
                Toast.makeText(this, "Permission denied!", Toast.LENGTH_SHORT).show()
            }
        }
    }

	// 위치 추적 진행 여부 확인
    private val isLocationServiceRunning: Boolean
        get() {
            val activityManager = getSystemService(ACTIVITY_SERVICE) as ActivityManager
            for (service in activityManager.getRunningServices(Int.MAX_VALUE)) {
                if (LocationService::class.java.name == service.service.className) {
                    if (service.foreground) {
                        return true
                    }
                }
            }
            return false
        }

	// 위치 추적 시작
    private fun startLocationService() {
        if (!isLocationServiceRunning) {
            val intent = Intent(applicationContext, LocationService::class.java)
            intent.setAction(Constants.ACTION_START_LOCATION_SERVICE)
            startService(intent)
            Toast.makeText(this, "Location service started", Toast.LENGTH_SHORT).show()
            bindLocationService()
        }
    }

	// 위치 추적 중단
    private fun stopLocationService() {
        if (isLocationServiceRunning) {
            val intent = Intent(applicationContext, LocationService::class.java)
            intent.setAction(Constants.ACTION_STOP_LOCATION_SERVICE)
            startService(intent)
            Toast.makeText(this, "Location service stopped", Toast.LENGTH_SHORT).show()
        }
    }

    private fun bindLocationService() {
        val intent = Intent(this, LocationService::class.java)
        bindService(intent, connection, Context.BIND_AUTO_CREATE)
    }

    private fun unbindLocationService() {
        if (isBound) {
            unbindService(connection)
            isBound = false
        }
    }

    override fun sendLocation(latitude: Double, longitude: Double) {
        Log.d("MAIN_LOCATION", "$latitude, $longitude")
    }
}

로그를 찍어 확인하면, 앱이 포커스를 잃더라도 1분 단위로 백그라운드에서 위치 정보를 계속해서 받아오는 것을 확인할 수 있다.


2️⃣ 네이버 지도에서 이동 경로 표시하기 (선/마커)

1) 선으로 경로 표시

class MainActivity : AppCompatActivity(), OnMapReadyCallback, LocationUpdateInterface {
    private lateinit var binding: ActivityMainBinding
    // ...

    private val userPolyline = PolylineOverlay()
    private val coords = mutableListOf<LatLng>()
    
    // 유저의 이동 경로 초기화
    private fun initPolyLine(startLatLng: LatLng) {
        coords.addAll(listOf(startLatLng, startLatLng))
        userPolyline.coords = coords
        userPolyline.color = Color.DKGRAY
        userPolyline.map = naverMap
    }

    // 유저의 이동 경로 업데이트
    private fun updateCoords(latLng: LatLng) {
        coords.add(latLng)
        userPolyline.coords = coords
    }
    
    override fun onMapReady(naverMap: NaverMap) {
        this.naverMap = naverMap
        // 내장 위치 추적 기능
        naverMap.locationSource = locationSource
        // 현재 위치 버튼 기능
        naverMap.uiSettings.isLocationButtonEnabled = true
        // 위치를 추적하면서 카메라도 따라 움직인다.
        naverMap.locationTrackingMode = LocationTrackingMode.Follow

        // 사용자 현재 위치 받아오기
        // ...
        fusedLocationClient.lastLocation
            .addOnSuccessListener { location: Location? ->
                currentLocation = location
                // ...
                // 사용자의 현재 위치를 동선에 저장
                initPolyLine(LatLng(naverMap.cameraPosition.target.latitude, naverMap.cameraPosition.target.longitude))
            }
    }
    
    override fun sendLocation(latitude: Double, longitude: Double) {
        Log.d("MAIN_LOCATION", "$latitude, $longitude")
        updateCoords(LatLng(latitude, longitude)) // 이동 경로 업데이트
    }

2) 마커로 경로 표시

마커를 하나씩 추가하면 된다.
나는 시작 위치를 표시할 마커와 이동 경로를 표시할 마커를 구분해 줬다.
(시작 위치는 다른 색깔로, 조금 더 큰 사이즈로 표현함)

private val movementMarkers = mutableListOf<Marker>()

// 시작 위치 마커
    private fun setInitialMarker() {
        // 시작 위치를 표시할 마커
        val startMarker = Marker()
        startMarker.iconTintColor = Color.MAGENTA
        startMarker.position = LatLng(
            naverMap.cameraPosition.target.latitude,
            naverMap.cameraPosition.target.longitude
        )
        startMarker.captionText = "시작 위치"
        startMarker.map = naverMap
    }

    // 사용자의 이동 위치를 추적하는 마커
    private fun setMovementMarker(latLng: LatLng) {
        val marker = Marker().apply {
            position = latLng
            width = 50
            height = 75
            captionText = LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) // 측정 시각 표시
            map = naverMap
        }
        // 마커 리스트에 추가
        movementMarkers.add(marker)
    }
    
    override fun onMapReady(naverMap: NaverMap) {
        this.naverMap = naverMap
        
        // 사용자 현재 위치 받아오기
        // ...
        fusedLocationClient.lastLocation
            .addOnSuccessListener { location: Location? ->
                currentLocation = location
                // ...
                // 시작 위치 마커 표시
                setInitialMarker()
            }
           
    }
    
    override fun sendLocation(latitude: Double, longitude: Double) {
        Log.d("MAIN_LOCATION", "$latitude, $longitude")
        updateCoords(LatLng(latitude, longitude)) // 이동 경로 업데이트
        setMovementMarker(LatLng(latitude, longitude)) // 이동 경로 마커 업데이트
    }

    

📱 완성 화면

그렇게 완성된 실시간 위치를 가져와서 경로를 표시해주는 모습이다!
1분 간격으로 약 7분 35초동안 기록한 화면이고, 위의 영상은 16배속을 한 영상이다.
영상을 보면 사용자가 이동한 위치마다 마커를 표시하고 선으로 이어주는 것을 확인할 수 있다.
추가로 신호등을 기다리는 부분에서는 기다리는 시간이 길어서 많은 이동 없이 마커가 찍히는 모습이다.



3️⃣ (번외) 디자인 변경 및 경로 삭제 기능 추가

1) 버튼 스타일 변경

버튼 디자인을 조금 바꿔주고, 아래처럼 경로 초기화 버튼을 한 번 플로팅 버튼으로 추가해 보다가

어차피 지도가 스크롤 되는 게 아닌데 굳이 플로팅 버튼으로 할 필요는 없겠다 싶었다.
그리고 이 경로 초기화 버튼을 어디 배치할까 하다가 버튼이 너무 많으면 지도를 가리겠다 싶어서 위치 측정 시작/중단 버튼을 하나로 합치자고 마음먹었다.

위치 측정 버튼 클릭 시 위치 측정 시작하기/중단하기로 버튼 텍스트와 좌측 아이콘을 교체해주도록 구현해 보겠다.

2) 기존 위치 측정 시작/중단 버튼 하나로 통합

// 위치 측정 시작 또는 종료
    private fun setLocationService() {
        val intent = Intent(applicationContext, LocationService::class.java)
        if (!isLocationServiceRunning) { // 실행 X -> 실행하기
            bindLocationService()
            intent.setAction(Constants.ACTION_START_LOCATION_SERVICE)
            startService(intent)
            Toast.makeText(this, "위치 서비스 시작", Toast.LENGTH_SHORT).show()
            setLocationButtonUI(true)
        } else { // 실행 -> 실행 중단하기
            intent.setAction(Constants.ACTION_STOP_LOCATION_SERVICE)
            startService(intent)
            Toast.makeText(this, "위치 서비스 중단", Toast.LENGTH_SHORT).show()
            setLocationButtonUI(false)
        }
    }

// 서비스 실행 여부에 따른 버튼 교체
    private fun setLocationButtonUI(isRunning: Boolean) {
        with(binding.locationRecordBtn) {
            text = if (isRunning) { // 위치 측정 중이라면 -> 중단 버튼 표시
                setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_stop, 0, 0, 0)
                getText(R.string.stop_location_updates)
            } else { // 위치 측정 중이 아니라면 -> 시작 버튼 표시
                setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_start, 0, 0, 0)
                getText(R.string.start_location_update)
            }
        }
    }

기존에 시작/중단 버튼이 따로 있어 startLocationService()stopLocationService()로 나눠진 함수도 setLocationService() 함수로 하나로 합쳐 주었다.

3) 경로 삭제 기능 추가

private fun initClickListeners() {
        //..
        // 경로 초기화 버튼
        binding.resetRouteBtn.setOnClickListener {
            resetRoute()
        }
}
    
// 이동 경로 초기화
private fun resetRoute() {
        if (!isLocationServiceRunning) { // 측정이 끝난 상태
            // polyLine 제거
            userPolyline.map = null
            coords.clear()
            // marker 제거
            startMarker.map = null
            if (movementMarkers.isNotEmpty()) {
                movementMarkers.forEach { it.map = null }
                movementMarkers.clear()
            }
            Toast.makeText(this, "지금까지 기록된 경로를 삭제했습니다.", Toast.LENGTH_SHORT).show()
        } else {
            Toast.makeText(this, "위치 측정이 아직 종료되지 않았습니다.\n종료 후 경로를 삭제해 주세요.", Toast.LENGTH_SHORT).show()
        }
}

경로 초기화 버튼을 눌렀을 때 지도에 현재 위치를 제외한 모든 것 (polyLine, marker)를 제거하도록 했다.
또한, 위치 관측이 중단된 상태에서만 경로를 삭제하도록 토스트 메시지를 표시해 주었다.

📱 완성 화면

위치 측정 버튼 클릭 시 버튼의 UI가 바뀌는 모습을 확인할 수 있다.
위치 측정 중단 > 시작으로 바로 넘어가게 되면 coords에 다시 시작 버튼을 누른 현재 위치가 추가되고, 마커도 하나 더 생긴다.
경로 초기화 버튼도 잘 작동하는 것을 확인할 수 있다.

나중에 기록 중이라면 -> 인디케이터를 통해 이를 표시해 주면 더 좋을 것 같다.

🔗 전체 코드
: https://github.com/nahy-512/MapDemo
** 코드를 계속 업데이트 하고 있어서 포스트와 깃허브 코드는 조금 다를 수 있습니다.


📚 참고 자료

profile
우당탕탕 성장하는 개발자

0개의 댓글