(지난 내용 복습)
개발을 준비하고 있는 여행 관련 프로젝트에서 다음과 같은 두 가지 사전 과제를 요구받았다.
- 백그라운드에서 실시간으로 사용자의 위치를 받아올 것
- 지도에 사용자의 위치를 선으로 연결해서 보여줄 것
아직 개발을 본격적으로 시작하지는 않았지만 서비스에 대해 간단한 소개를 하자면,
사용자의 이동 경로를 지도에 표시하여 커뮤니티에 공유할 수 있는 느낌이다.
따라서, 위에서 작성한 두 가지가 우리 프로젝트의 핵심 기능이었다.
이전 편에서는 내가 왜 지도를 보여줄 sdk로 네이버 지도를 선택했는지와, 네이버 지도에 현재 위치를 표시하는 과정을 설명했었다.
🔗 이전 포스트
[Android/Kotlin] 네이버 지도로 현재 위치 불러오기
그럼 이번 포스트에서는 앱의 핵심 기능인 백그라운드 실시간 위치 정보 가져오기와 지도에 사용자 경로 표시하기를 다뤄보겠다.
번외로 경로 삭제 기능도 추가해 보았다.
[📱 완성 화면 미리 보기 📱]
실시간 경로 표시 | 경로 삭제 |
---|
build.gradle(:app)에서 사용자 위치를 받기 위한 라이브러리를 추가해 준다.
dependencies {
// 사용자 위치
implementation 'com.google.android.gms:play-services-location:21.2.0'
}
<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_SERVICE
와 FOREGROUND_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 등 다양한 유형이 있다.)
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으로 위 내용이 계속해서 표시된다.
interface LocationUpdateInterface {
fun sendLocation(latitude: Double, longitude: Double)
}
LocationService -> MainActivity로 위치를 넘겨받을 인터페이스를 정의해 준다.
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로 위도와 경도를 보낼 수 있도록 한다.
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분 단위로 백그라운드에서 위치 정보를 계속해서 받아오는 것을 확인할 수 있다.
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)) // 이동 경로 업데이트
}
마커를 하나씩 추가하면 된다.
나는 시작 위치를 표시할 마커와 이동 경로를 표시할 마커를 구분해 줬다.
(시작 위치는 다른 색깔로, 조금 더 큰 사이즈로 표현함)
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배속을 한 영상이다.
영상을 보면 사용자가 이동한 위치마다 마커를 표시하고 선으로 이어주는 것을 확인할 수 있다.
추가로 신호등을 기다리는 부분에서는 기다리는 시간이 길어서 많은 이동 없이 마커가 찍히는 모습이다.
버튼 디자인을 조금 바꿔주고, 아래처럼 경로 초기화 버튼
을 한 번 플로팅 버튼으로 추가해 보다가
어차피 지도가 스크롤 되는 게 아닌데 굳이 플로팅 버튼으로 할 필요는 없겠다 싶었다.
그리고 이 경로 초기화 버튼을 어디 배치할까 하다가 버튼이 너무 많으면 지도를 가리겠다 싶어서 위치 측정 시작/중단 버튼을 하나로 합치자고 마음먹었다.
위치 측정 버튼 클릭 시 위치 측정 시작하기/중단하기로 버튼 텍스트와 좌측 아이콘을 교체해주도록 구현해 보겠다.
// 위치 측정 시작 또는 종료
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()
함수로 하나로 합쳐 주었다.
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
** 코드를 계속 업데이트 하고 있어서 포스트와 깃허브 코드는 조금 다를 수 있습니다.