안드로이드는 특이하게 앱이 앱 권환을 OS 에 요청하는 방식을 취한다. 예제로는 구글맵스 API 를 사용해 지도를 추가하고 상호작용하는 법을 통해 아래의 내용을 학습한다.
앱이 요청할 수 있는 권한에는 기기 위치 획득, 연락처 접근, 카메라 실행, 블루투스 연결 등이 있다.
앱이 가진 권한에 따라 작업을 정의하는 법을 배워보자.
안드로이드 6 마시멜로 이상에서 실행 중이고 타겟 API 가 23 이상일 경우 일부 기능에 대해서는 런타임에 권한에 대한 경고를 표시한 뒤 사용하도록 할 수 있다.
이런 권한 요청에는 앱을 개발할 때 AndroidManifest.xml 수정이 필수이다. 예를 들어 SEND_SMS 권한을 포함하려면 아래와 같이 한다.
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature
android:name="android.hardware.telephony"
android:required="false" />
<uses-permission android:name="android.permission.SEND_SMS"/>
</manifest>
일반적으로 안전한 권한(구글에서 정한)은 자동으로 허용된다. 그러나 위험한 권한은 사용자로부터 승인을 받도록 되어 있다. 사용자 승인 없이 기능을 수행하려고 하면 앱이 크래시 될 수도 있다.
그러므로 권한 요청 전 이미 승인했는지 여부를 확인하고 Dialog 창을 호출한다. shouldShowRequestPermissionRationale(Activity, String) 이라는 함수로 이런 작업이 가능하다.
권한 요청을 위해서는 build.gradle.kts , libs.versions.xml 에 의존성 주입을 해야 한다.
// build.gradle.kts
implementation(libs.androidx.activity.ktx)
implementation(libs.androidx.fragment.ktx)
// libs.versions.toml
android-acitivy-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" }
android-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragmentKtx" }
다음은 권한 요청을 하는 코드이다.
class MainActivity: AppCompatActivity() {
private lateinit var requestPermissionLauncher: ActivityResultLauncheer<String>
override fun onCreate() {
// ...
requestPermissionLauncher = registerForActivityResult(RequestPermission()) { isGranted ->
if (isGranted) {
// ...
} else {
// ...
}
}
}
}
권한 요청을 위한 런처를 등록하고 있다. 참조를 유지하는 이유는 결과값을 사용하기 위해서이다. 액티비티가 다시 시작되면 권한 상태값을 이용해 다른 작업을 수행할 수 있다.
override fun onResume() {
when {
hasLocationPermission() -> getLastLocation()
shouldShowRequestPermissionRationale(this, ACESS_FINE_LOCATION) -> {
showPermissionRationale {
requestPermissionLauncher.launch(ACCESS_FINE_LOCATION)
} // 권한 설명 필요한 경우
}
else -> requestPermissionLauncher.launch(ACCESS_FINE_LOCATION) // 권한 설명 필요없는 경우
}
}
private fun hasLocationPermission() = checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PERMISSION_GRANTED
private fun showPermissionRationale(
positiveAction: () -> Unit) {
AlertDialog.Builder(this)
.setTitle("Location Permission")
.setMessage("We need your permission to find your current position")
.setPositiveButton(android.R.string.ok) { _, _ -> positiveAction() }
.setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() }
.create().show()
}
}
hasLocationPermission() 을 이용해 위치 권한을 확인하고 있다. checkSelfPermission(Context, String) 을 사용하는 로컬 함수이다. showPermissionRationale 함수는 사용자에게 권한이 필요한 이유를 설명하는 대화상자를 표시한다.
requestPermissionLauncher 의 결과값인 isGranted 에 따라 위의 코드에서 결과값을 처리할 수 있다.
registerForActivityResult(RequestPermission()) { isGranted ->
if (isGranted) { getLastLocation() }
else {
showPermissionRationale {
requestPermissionLauncher.launch(ACCESS_FINE_LOCATION)
}
}
}
사용자가 위치 접근 권한을 허용하면 사용자의 기기에 가장 마지막으로 기록된 위치를 얻을 수 있다.
구글 플레이 로케이션 서비스(Google Play Location service) 를 통해 FusedLocationProviderClient 클래스를 제공한다. Fused Location Provider API 를 제공하는 이 클래스는 구글 플레이 로케이션 서비스 라이브러리 추가가 필요하다.
// build.gradle.kts
implementation(libs.gms.play.services.location)
// libs.versions.toml
playServicesLocation = "21.0.1"
gms-play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" }
FusedLocationProviderClient 인스턴스는 LocationServices.getFusedLocationProviderClient(this@MainActivity) 를 호출하면 된다.
이 때의 작업들은 이미 사용자로부터 위치 권한을 획득했다고 가정한다.
마지막 위치는 인스턴스의 lastLocation 이라는 Task<Location> 이다. 즉, 비동기로 마지막 위치를 가져오며 실패 상황의 리스너도 추가 가능하다. Location 인스턴스는 위/경도 데이터를 담고 있다.
.addOnSuccessListener { location: Location? -> }
가끔 null 인 경우가 있는데 사용자가 위치 호출 중 앱의 위치 서비스를 비활성화하는 경우가 이에 해당한다.
지도 자체는 프래그먼트이며 SupportMapFragment 클래스를 사용한다.
지도 배치는 GoogleMap 인스턴스의 moveCamera 에다가 CameraUpdateFactory.newLatLng(LatLng) 를 반영하고, 지도에서 줌을 수행하려면 newLatLngZoom(LatLng, Float) 을 반영하면 된다. 마커는 MarkerOptions 를 사용한다.
마커를 더 다양한 방법으로 다루어보자.
지도 클릭을 감지하기 위한 리스너를 추가하면 된다.
override fun onMapReady(googleMap: GoogleMap) {
mMap = googleMap.apply {
setOnMapClickListener { latlng ->
addMarkerAtLocation(latlng, "Deploy here")
}
}
}
기존에 추가된 마커를 다루기 위해서는 우선 GoogleMap.addMarker(MarkerOptions) 의 반환값을 참조 유지하도록 로컬 변수를 만들어야 할 것이다. 마커의 위치를 변환하는 데엔 Marker 의 position 세터가 필요하다.
MarkerOptions() 인스턴스의 BitmapDescriptor 를 이용해 마커를 커스텀 아이콘으로 대체 가능하다. BitmapDescriptor 는 BitmapDescriptorFactory 를 사용한다.
마커를 만들기 위해 벡터 드로어블을 만든다. New -> Vector Asset 을 선택하면 만들 수 있다. 만들어진 애셋은 ContextCompat.getDrawable(Context, Int) 를 호출하여 참조를 받아올 수 있다. Drawable 인스턴스는 그려져야 할 영역도 정의해야 하는데 Drawable.setBound(0,0,drawable.intrinsicWidth,drawable.intrinsicHeight) 를 호출한다. 이외에 틴트 설정, 비트맵 생성, 캔버스 생성은 아래와 같이 정리할 수 있다.
private fun getBitmapDescriptorFromVector(@DrawableRes vectorDrawableResourceId: Int): BitmapDescriptor? {
var bitmap = ContextCompat.getDrawable(this, vectorDrawableResourceId)?.let { vectorDrawable ->
vectorDrawable.setBounds(0, 0, vectorDrawable.intrinsicWidth, vectorDrawable.intrinsicHeight)
// DrawableCompat 으로 감싸기
val drawableWithTint = DrawableCompat.wrap(vectorDrawable)
// 틴트 색상 설정
DrawableCompat.setTint(drawableWithTint, Color.RED)
// 비트맵 생성
val bitmap = Bitmap.createBitmap(
vectorDrawable.intrinsicWidth,
vectorDrawable.intrinsicHeight,
Bitmap.Config.ARGB_8888
)
// Canvas 생성
val canvas = Canvas(bitmap)
// 드로어블 그리기
drawableWithTint.draw(canvas)
}
return BitmapDescriptorFactory.fromBitmap(bitmap)
.also { bitmap?.recycle() }
}
이렇게 BitMapDescriptorFactory 를 생성하여 인스턴스를 얻을 수 있다. 비트맵을 리사이클하는 것을 잊으면 안되는데 메모리 누수를 방지하기 위함이다.