관리 메뉴

나만을 위한 블로그

[Android] 내 위치 정보 가져와서 사용하는 법 (with. Hilt) 본문

Android

[Android] 내 위치 정보 가져와서 사용하는 법 (with. Hilt)

참깨빵위에참깨빵_ 2024. 12. 18. 00:22
728x90
반응형

※ 모든 코드는 예시 코드기 때문에 실제로 쓰려면 반드시 리팩토링, 예외처리를 추가한다

 

위치 정보를 활용하는 기능 구현 중 액티비티, 프래그먼트 곳곳에 위치 권한을 요청하고 허용, 거절 상태에 따라 분기되는 함수가 보였다.

Base 클래스에 빼자니 필요없는 화면도 있어서 아닌 거 같고, 뷰모델에 박아두고 쓰자니 자유롭게 재사용할 수 없는 느낌이라 아닌 거 같아서 이리저리 시험해보다 괜찮아 보이는 방법을 찾은 것 같아 포스팅한다.

 

코드부터 본다.

 

import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.core.app.ActivityCompat
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.tasks.CancellationToken
import com.google.android.gms.tasks.CancellationTokenSource
import com.google.android.gms.tasks.OnTokenCanceledListener
import com.example.data.model.LatdLntd
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject

class GlobalLocationManager @Inject constructor(
    private val fusedLocationProviderClient: FusedLocationProviderClient,
    @ApplicationContext private val context: Context,
) {
    private val _currentLocation = MutableStateFlow<LatdLntd?>(null)
    val currentLocation: StateFlow<LatdLntd?> = _currentLocation.asStateFlow()

    fun updateLocation(
        onLocationCanceled: (() -> Unit)? = null,
        onLocationSuccess: ((LatdLntd) -> Unit)? = null,
        onLocationError: ((Exception) -> Unit)? = null,
        onLocationComplete: (() -> Unit)? = null,
    ) {
        if (checkLocationPermission()) {
            fusedLocationProviderClient.lastLocation
                .addOnSuccessListener { location ->
                    if (location != null) {
                        val cachedLocation = LatdLntd(location.latitude, location.longitude)
                        _currentLocation.value = cachedLocation
                        onLocationSuccess?.invoke(cachedLocation)
                    } else {
                        requestCurrentLocation(onLocationSuccess, onLocationCanceled, onLocationError, onLocationComplete)
                    }
                }
                .addOnFailureListener { exception ->
                    requestCurrentLocation(onLocationSuccess, onLocationCanceled, onLocationError, onLocationComplete)
                }
        } else {
            onLocationError?.invoke(SecurityException("위치 권한 거절됨"))
        }
    }

    private fun requestCurrentLocation(
        onLocationSuccess: ((LatdLntd) -> Unit)? = null,
        onLocationCanceled: (() -> Unit)? = null,
        onLocationError: ((Exception) -> Unit)? = null,
        onLocationComplete: (() -> Unit)? = null,
    ) {
        if (checkLocationPermission()) {
            fusedLocationProviderClient.getCurrentLocation(
                LocationRequest.PRIORITY_HIGH_ACCURACY,
                object : CancellationToken() {
                    override fun onCanceledRequested(p0: OnTokenCanceledListener): CancellationToken {
                        onLocationCanceled?.invoke()
                        return CancellationTokenSource().token
                    }

                    override fun isCancellationRequested(): Boolean = false
                }
            ).addOnSuccessListener { location ->
                if (location != null) {
                    val newLocation = LatdLntd(location.latitude, location.longitude)
                    _currentLocation.value = newLocation
                    onLocationSuccess?.invoke(newLocation)
                }
            }.addOnFailureListener { exception ->
                onLocationError?.invoke(exception)
            }.addOnCompleteListener {
                onLocationComplete?.invoke()
            }
        }
    }

    fun checkLocationPermission(): Boolean =
        ActivityCompat.checkSelfPermission(
            context,
            Manifest.permission.ACCESS_FINE_LOCATION
        ) == PackageManager.PERMISSION_GRANTED &&
                ActivityCompat.checkSelfPermission(
                    context,
                    Manifest.permission.ACCESS_COARSE_LOCATION
                ) == PackageManager.PERMISSION_GRANTED
}

 

data class LatdLntd(
    val latd: Double?,
    val lntd: Double?,
)

 

module 설정도 잊지 않고 해 준다.

 

import android.content.Context
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationServices
import com.example.data.GlobalLocationManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object LocationModule {

    @Provides
    @Singleton
    fun provideFusedLocationProviderClient(@ApplicationContext context: Context): FusedLocationProviderClient =
        LocationServices.getFusedLocationProviderClient(context)

    @Provides
    @Singleton
    fun provideGlobalLocationManager(
        fusedLocationProviderClient: FusedLocationProviderClient,
        @ApplicationContext context: Context
    ): GlobalLocationManager = GlobalLocationManager(fusedLocationProviderClient, context)

}

 

hilt를 쓰기 때문에 이렇게 설정했다면 프래그먼트, 액티비티 어디서든 GlobalLocationManager 의존성을 주입받아서 사용하면 된다.

만약 런처를 통해 권한 허용 여부를 판단한다면 아래처럼 할 수 있다.

 

@AndroidEntryPoint
class ExampleFragment : Fragment() {

    @Inject
    lateinit var globalLocationManager: GlobalLocationManager
    ...

private val permissionCheckLauncher =
    registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
        val isCoarseLocationGranted = permissions[Manifest.permission.ACCESS_COARSE_LOCATION]
      	val isFineLocationGranted = permissions[Manifest.permission.ACCESS_FINE_LOCATION]

        if (isCoarseLocationGranted == true && isFineLocationGranted == true) {
            globalLocationManager.updateLocation()
            // ...
        }
        
        // ...

 

permissions 리시버 변수를 통해 위치 권한인 ACCESS_COARSE_PERMISSION, ACCESS_FINE_LOCATION이 허용되었는지 알 수 있고, 이 값들이 모두 true라면 updateLocation()을 호출하는 식이다.

또는 앱을 나오고 설정 앱에서 위치 권한을 허용할 수도 있다. 이 경우 onResume()을 활용하면 대부분 잘 먹힐 것이다.

 

override fun onResume() {
    super.onResume()
    if (globalLocationManager.checkLocationPermission()) {
        globalLocationManager.updateLocation()
    }
}

 

이렇게 하면 생각보다 빠르게 위도, 경도 값을 받아오는 걸 볼 수 있다.

 

그럼 이게 어떻게 가능한 건가? GlobalLocationManager의 updateLocation()을 확인한다.

 

fun updateLocation(
    onLocationCanceled: (() -> Unit)? = null,
    onLocationSuccess: ((LatdLntd) -> Unit)? = null,
    onLocationError: ((Exception) -> Unit)? = null,
    onLocationComplete: (() -> Unit)? = null,
) {
    if (checkLocationPermission()) {
        fusedLocationProviderClient.lastLocation
            .addOnSuccessListener { location ->
                if (location != null) {
                    val cachedLocation = LatdLntd(location.latitude, location.longitude)
                    _currentLocation.value = cachedLocation
                    onLocationSuccess?.invoke(cachedLocation)
                } else {
                    requestCurrentLocation(onLocationSuccess, onLocationCanceled, onLocationError, onLocationComplete)
                }
            }
            .addOnFailureListener { exception ->
                requestCurrentLocation(onLocationSuccess, onLocationCanceled, onLocationError, onLocationComplete)
            }
    } else {
        onLocationError?.invoke(SecurityException("위치 권한 거절됨"))
    }
}

private fun requestCurrentLocation(
    onLocationSuccess: ((LatdLntd) -> Unit)? = null,
    onLocationCanceled: (() -> Unit)? = null,
    onLocationError: ((Exception) -> Unit)? = null,
    onLocationComplete: (() -> Unit)? = null,
) {
    if (checkLocationPermission()) {
        fusedLocationProviderClient.getCurrentLocation(
            LocationRequest.PRIORITY_HIGH_ACCURACY,
            object : CancellationToken() {
                override fun onCanceledRequested(p0: OnTokenCanceledListener): CancellationToken {
                    onLocationCanceled?.invoke()
                    return CancellationTokenSource().token
                }

                override fun isCancellationRequested(): Boolean = false
            }
        ).addOnSuccessListener { location ->
            if (location != null) {
                val newLocation = LatdLntd(location.latitude, location.longitude)
                _currentLocation.value = newLocation
                onLocationSuccess?.invoke(newLocation)
            }
        }.addOnFailureListener { exception ->
            onLocationError?.invoke(exception)
        }.addOnCompleteListener {
            onLocationComplete?.invoke()
        }
    }
}

 

먼저 fusedLocationProviderClient.lastLocation을 통해 성공 시 location 값을 가져오고, 만약 이 location이 null이라면 requestCurrentLocation()을 호출해 새 위치 정보를 얻는 흐름이다.

여기서 lastLocation은 fusedLocationProviderClient를 만든 후 마지막으로 알려진(=캐싱된) 위치를 가져온다.

 

https://developer.android.com/develop/sensors-and-location/location/retrieve-current?hl=ko#last-known

 

마지막으로 알려진 위치 가져오기  |  Sensors and location  |  Android Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. 마지막으로 알려진 위치 가져오기 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Google Play 서비스 Loca

developer.android.com

위치 서비스 클라이언트를 만든 후 마지막으로 알려진 유저 기기의 위치를 가져올 수 있다. 앱이 여기 연결된 경우 통합 위치 정보 제공자의 getLastLocation()을 써서 기기 위치를 가져올 수 있다. 이 호출로 리턴되는 위치의 정밀도는 위치 정보 액세스 권한 요청 방법 가이드에 설명된 대로 매니페스트에 입력한 권한 설정에 따라 결정된다
fusedLocationClient.lastLocation
        .addOnSuccessListener { location : Location? ->
            // Got last known location. In some rare situations this can be null.
        }
getLastLocation()은 지리적 위치의 위도, 경도 좌표가 있는 Location 객체를 가져오는 데 쓸 수 있는 Task를 리턴한다. 아래 상황에선 Location 객체가 null일 수 있다

- 기기 설정에서 위치가 사용 중지된 경우. 위치를 사용 중지하면 캐시도 지워지므로, 이전에 마지막 위치를 가져온 경우에도 결과는 null일 수 있다
- 기기에서 위치를 기록한 적이 없다(새 핸드폰이거나 기본 설정으로 복원된 핸드폰인 경우)
- 기기의 구글 플레이 서비스가 재시작됐고, 서비스 재시작 후 위치를 요청한 활성 통합 위치 정보 제공자 클라이언트가 없다. 이런 상황이 발생하지 않도록 새 클라이언트를 만들고 직접 위치 업데이트를 요청할 수 있다...(중략)...FusedLocationProviderClient는 기기 위치 정보를 가져오는 여러 메서드를 제공한다. 앱의 사용 사례에 따라 둘 중 하나를 선택한다

- getLastLocation() : 위치 추정치를 더 빨리 가져오고 앱의 배터리 사용량을 최소화한다. 그러나 최근에 다른 클라이언트가 적극적으로 위치를 사용하지 않았다면 위치 정보가 최신이 아닐 수 있다
- getCurrentLocation() : 더 최신 상태고 정확한 위치를 일관되게 가져온다. 그러나 이 메서드를 쓰면 기기에서 활성 위치 계산이 발생할 수 있다. 이 메서드는 가능한 경우 최신 위치를 가져오는 데 권장되는 방법이며 requestLocationUpdates()를 써서 직접 위치 업데이트를 시작, 관리하는 것보다 안전하다. requestLocationUpdates()를 호출하는 경우 위치를 쓸 수 없거나 최신 위치를 가져온 후 요청이 정확하게 중지되지 않으면 앱이 전력을 많이 소모할 수 있다

 

이런 특징이 있기 때문에 먼저 lastLocation으로 빠르게 캐싱된 위치값을 가져온다. 그리고 여러 이유 때문에 Location이 null이라면 getCurrentLocation()으로 보다 정밀한 위도, 경도 값을 얻는다.

getCurrentLocation()까지 호출하게 되면 네트워크 환경에 따라 시간이 제법 걸릴 수도 있어서 프로그레스 바를 띄우는 등 로딩 처리를 할 필요가 있겠다.

lastLocation으로 가져오는 마지막 위치 정보는 gps, 와이파이, 5G 등 여러 경로를 통해 가져오며 안드로이드 시스템이 비동기적으로 자동 갱신해 준다. 또한 위치 정보는 당연히 유저가 gps 사용을 해제했거나 위치 권한을 거절했다면 가져올 수 없다. 유저가 막아놨으니 어쩔 수 없다. 해킹해서 가져올 건가?

 

onLocationCanceled 등 함수 타입 파라미터는 호출하는 곳에서 성공, 실패, 완료 케이스를 처리하기 위해 넣었다. 굳이 외부 함수 호출을 이 클래스에서 할 필요는 없기 때문에 이렇게 만들었다. 필요없으면 없애고 쓰면 된다.

반응형
Comments