본문 바로가기

책/오준석의 안드로이드 생존코딩 코틀린편

10. 지도와 GPS

프로젝트명 GpsMap
기능
  • GPS로 현재 위치 정보를 얻어 지도를 표시한다.
  • 주기적으로 현재 위치를 갱신하며 선을 그린다.
핵심 구성요소
  • Google Maps Activity : 지도를 표시하는 기본 템플릿
  • FusedLocationProviderClient : 현재 위치 정보를 얻는 클래스
라이브러리 설정
  • Anko : 인텐트, 다이얼로그, 로그 등을 구현하는데 도움이 되는 라이브러리
  • play-services-maps : 구글 지도 라이브러리
  • play-services-location : 위치 정보 라이브러리
  1. 준비하기 : 프로젝트 생성 및 안드로이드 설정
  2. 스텝1 : 구글 지도 표시하기
  3. 스텝2 : 현재 위치 정보 얻기
  4. 스텝3 : 주기적으로 현재 위치 정보 업데이트하기
  5. 스텝4 : 이동 자취를 선으로 그리기

준비하기

Google Maps Activity

  1. Google Maps Activity 를 선택한다.
  2. Next를 클릭한다.
  3. Name에 MapsActivity라고 입력하고 Finish를 클릭한다.

다음과 같이 라이브러리를 추가한다.

dependencies {
    ....
    // Anko 라이브러리
    implementation 'org.jetbrains.anko:anko:0.10.5'

    // 위치 정보
    implementation 'com.google.android.gms:play-services-location:17.0.0'

    // 구글 지도. MapsActivity 추가 시 자동으로 추가
    implementation 'com.google.android.gms:play-services-maps:17.0.0'
    ....
}

스텝1 구글 지도 표시하기

res/values/google_maps_api.xml 파일 내의 내용이다.

주석 1 에서의 url을 웹 브라우저 에서 열도록 한다.

<resources>
    <!--
    TODO: Before you run your application, you need a Google Maps API key.

    To get one, follow this link, follow the directions and press "Create" at the end:

    <!-- 1 https로 시작하는 링크를 복사하여 웹 브라우저에서 해당 페이지를 표시한다. -->
    https://console.developers.google.com/flows/enableapi......

    You can also add your credentials to an existing key, using these values:

    Package name:
    abstractask.example.mapsactivity

    SHA-1 certificate fingerprint:
    05:40:53:4D:07:F9:27:53:A7:9D:08:35:B9:39:0D:2E:0F:F0:08:EA

    Alternatively, follow the directions here:
    https://developers.google.com/maps/documentation/android/start#get-key

    Once you have your key (it starts with "AIza"), replace the "google_maps_key"
    string in this file.
    -->
    <!-- 2 YOUR_KEY_HERE 에 API 키를 입력해야 구글지도를 사용할 수 있다. -->
    <string name="google_maps_key" 
            templateMergeStrategy="preserve" 
            translatable="false">YOUR_KEY_HERE</string>
</resources>

동의하고 계속 하기를 클릭한다. 그 후 API  키 만들기를 눌러 API를 복사해둔다.

클립보드를 복사하여 API키를 복사해 온다.

실행해서 다음과 같이 지도가 나온다면 성공한 것이다.

자동으로 생성된 MapsActivity.kt 파일의 내용이다.

  1. supportFragmentManager로 부터  SupportMapFragment 를 얻는다. getMapAsync() 메서드로 지도가 준비되면 알림을 받는다.
  2. 지도가 준비되면 GoogleMap 객체를 얻는다.
  3. 위도와 경도로 시드니의 위치를 정하고 구글 지도 객체에 마커를 표시하고 카메라를 이동한다.
package abstractask.example.mapsactivity

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle

import com.google.android.gms.maps.CameraUpdateFactory
import com.google.android.gms.maps.GoogleMap
import com.google.android.gms.maps.OnMapReadyCallback
import com.google.android.gms.maps.SupportMapFragment
import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps.model.MarkerOptions

class MapsActivity : AppCompatActivity(), OnMapReadyCallback {

    private lateinit var mMap: GoogleMap

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_maps)
        // SupportMapFragment를 가져와서 지도가 준비되면 알림을 받는다. 1
        val mapFragment = supportFragmentManager
                .findFragmentById(R.id.map) as SupportMapFragment
        mapFragment.getMapAsync(this)
    }

    /**
     * 사용 가능한 맵을 조작한다.
     * 지도를 사용할 준비가 되면 이 콜백이 호출된다.
     * 여기서 마커나 선, 청취자를 추가하거나 카메라를 이동할 수 있다.
     * 호주 시드니 큰처에 마커를 추가하고 있다.
     * Google Play 서비스가 기기에 설치되어 있지 않은 경우 사용자에게 
     * SupportMapFragment 안에 Google Play 서비스를 설치하라는 메시지가 표시된다.
     * 이 메서드는 사용자가 Google Play서비스를 설치하고 앱으로 돌아온 후에만 호출(실행)된다.
     */
    override fun onMapReady(googleMap: GoogleMap) {
        mMap = googleMap  // 2

        // 시드니에 마커를 추가하고 카메라를 이동한다. 3
        val sydney = LatLng(-34.0, 151.0)
        mMap.addMarker(MarkerOptions().position(sydney).title("Marker in Sydney"))
        mMap.moveCamera(CameraUpdateFactory.newLatLng(sydney))
    }
}

res/layouts/activity_maps.xml 파일의 디자인이다. 

  • 속성 창의 name이 com.google.android.gms.maps.SupportMapFragment 인데 이것은 구글 지도가 내장된 프래그먼트로 build.gradle파일에 자동으로 의존성이 추가된 play-services-maps 라이브러리에서 제공한다. 

스텝2 주기적으로 현재 위치 정보 업데이트 하기 

위치권한 확인

AndroidManifest.xml 파일 위치권한 확인 

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="abstractask.example.mapsactivity">

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

</manifest>

위치 정보 요청

  1. 위치 정보를 주기적으로 얻는데 필요한 객체들을 선언
  2. onCreate 메서드에서 주석 3의 locationInit() 메서드를 호출
  3. LocationRequest는 위치정보에 대한 세부 정보를 요청. 이 요청은 GPS를 사용하여 가장 정확항 위치를 요구하면서 10초마다 위치정보를 갱신한다. 그 사이에 다른 앱에서 위치를 갱신했다면 5초마다 확인히여 그 값을 활용하여 베터리를 절약한다. 
    • priority : 정확도
      • LocationRequest.PRIORITY_HIGH_ACCURACY : 가장 정확한 위치를 요청
      • LocationRequest.PRIORITY_BALANCED_POWER_ACCURACY : 블록 수준의 정확도를 요청
      • LocationRequest.PRIORITY_LOW_POWER : 도시 수준의 정확도를 요청
      • LocationRequest.PRIORITY_NO_POWER : 추가 전력 소모 없이 최상의 정확도를 요청
    • interval : 위치를 갱신하는 데 필요한 시간은 밀리초 단위로 입력
    • fatestInterval : 다른 앱에서 위치를 갱신했을 때 그 정보를 가장 빠른 간격(밀리초 단위)로 입력
  4. 이러한 요청은 액티비티가 활성화 되는 onResume() 메서드에서 수행 . 현재는 위치권한요청 문제로 오류 메시지가 뜬다.
  5. requestLocationUpdates() 메서드에 전달되는 인자 중 LocationCallBack을 구현한 내부 클래스는 LocationResult 객체를 반환하고 lastLocation프로퍼티로 Location객체를 얻는다.
  6. 기기의 GPS 설정이 꺼져 있거나 현재 위치 정보를 얻을 수 없는 경우에 Location 객체가 null일 수 있다. Location객체가 null이 아닐 때 해당 위도와 경도 위치로 카메라를  이동한다.
package abstractask.example.mapsactivity

import android.Manifest
import android.content.pm.PackageManager
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.core.app.ActivityCompat
import com.google.android.gms.location.*

import com.google.android.gms.maps.CameraUpdateFactory
import com.google.android.gms.maps.GoogleMap
import com.google.android.gms.maps.OnMapReadyCallback
import com.google.android.gms.maps.SupportMapFragment
import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps.model.MarkerOptions
import com.google.android.gms.location.FusedLocationProviderClient as FusedLocationProviderClient

class MapsActivity : AppCompatActivity(), OnMapReadyCallback {

    private lateinit var mMap: GoogleMap

    // 1
    private lateinit var fusedLocationProviderClient: FusedLocationProviderClient
    private lateinit var locationRequest: LocationRequest
    private lateinit var locationCallback: MyLocationCallBack

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_maps)

        val mapFragment = supportFragmentManager
            .findFragmentById(R.id.map) as SupportMapFragment
        mapFragment.getMapAsync(this)

        // 2
        locationInit()
    }


    override fun onMapReady(googleMap: GoogleMap) {
        mMap = googleMap

        // Add a marker in Sydney and move the camera
        val sydney = LatLng(-34.0, 151.0)
        mMap.addMarker(MarkerOptions().position(sydney).title("Marker in Sydney"))
        mMap.moveCamera(CameraUpdateFactory.newLatLng(sydney))
    }

    // 위치 정보를 얻기 위한 각종 초기화
    private fun locationInit(){
        fusedLocationProviderClient = FusedLocationProviderClient(this)

        locationCallback = MyLocationCallBack()

        /* 위치 정보를 요청 하는 시간 주기를 설정하는 객체 */
        locationRequest = LocationRequest()
        // GPS 우선
        locationRequest.priority = LocationRequest.PRIORITY_HIGH_ACCURACY
        // 업데이트 인터벌
        // 위치 정보가 없을 때에는 업데이트 안함
        // 상황에 따라 짧아질 수 있음, 정확하지 않음
        // 다른 앱에서 짧은 인터벌로 위치 정보를 요청하면 짧아질 수 있음
        locationRequest.interval = 10000
        // 정확함. 이것보다 짧은 업데이트는 하지 않음
        locationRequest.fastestInterval = 5000
    }

    override fun onResume() {
        super.onResume()

        // 위치 정보를 주기적으로 요청하는 코드는 액티비티 화면에 보일 때만 수행하는 것이 좋음
        // onResume 위치정보 요청 , onPause 위치정보 요청 삭제 가 일반적
        addLocationListener()
    }

    private fun addLocationListener() {
        /* 구글 플레이 서비스를 최신 버젼으로 업데이트 해야 위치 서비스엔 연결된다.
         * 위치 서비스에 연결된 앱은 다음 메서드를 호출하여 위치정보를 요청 할 수 있다.
         * 매개변수
         *   - locationRequest : 위치 요청 객체
         *   - locationCallback : 위치개 갱신되면 호출되는 콜백
         *   - looper : 특정 루프 스레드를 저정. 특별한 경우가 아니라면 null을 지정
         */

        fusedLocationProviderClient.requestLocationUpdates(
            locationRequest,
            locationCallback,
            null
        )
    }

    // 6
    inner class MyLocationCallBack : LocationCallback() {
        override fun onLocationResult(locationResult: LocationResult?) {
            super.onLocationResult(locationResult)

            val location = locationResult?.lastLocation
            // 7
            location?.run {
                // 14 level로 확대하고 현재 위치로 카메라 이동
                val latLng = LatLng(latitude, longitude)
                mMap.animateCamera(CameraUpdateFactory.newLatLngZoom(latLng,17f))
            }
        }
    }
}

위치 권한 요청, 권한 선택에 대한 처리, 위치 정보 요청 삭제

class MapsActivity : AppCompatActivity(), OnMapReadyCallback {

    ....
    private val REQUEST_ACCESS_FINE_LOCATION = 1000

    override fun onCreate(savedInstanceState: Bundle?) {
        ....
        locationInit()
    }
    override fun onMapReady(googleMap: GoogleMap) {
       ....
    }

    // 위치 정보를 얻기 위한 각종 초기화
    private fun locationInit(){
        ....
    }

    override fun onResume() {
        super.onResume()

        // 권한 요청
        permissionCheck(cancel = {
            showPermissionInfoDialog()
        }, ok = {
            // 위치 정보를 주기적으로 요청하는 코드는 액티비티 화면에 보일 때만 수행하는 것이 좋음
            // onResume 위치정보 요청 , onPause 위치정보 요청 삭제 가 일반적
            addLocationListener()
        })
    }

    // 현재 위치를 주기적으로 요청
    @SuppressLint("MissingPermission")
    private fun addLocationListener() {
        /* 구글 플레이 서비스를 최신 버젼으로 업데이트 해야 위치 서비스엔 연결된다.
         * 위치 서비스에 연결된 앱은 다음 메서드를 호출하여 위치정보를 요청 할 수 있다.
         * 매개변수
         *   - locationRequest : 위치 요청 객체
         *   - locationCallback : 위치개 갱신되면 호출되는 콜백
         *   - looper : 특정 루프 스레드를 저정. 특별한 경우가 아니라면 null을 지정
         */
        fusedLocationProviderClient.requestLocationUpdates(
            locationRequest,
            locationCallback,
            null
        )
    }

    inner class MyLocationCallBack : LocationCallback() {
        ....
    }
    
    private fun permissionCheck(cancel : () -> Unit, ok: () -> Unit) {
        // 위치 권한이 있는지 검사
        if(ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
                != PackageManager.PERMISSION_GRANTED
        ) {
            if(ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.ACCESS_FINE_LOCATION)) {
                // 이전에 권한을 한 번 거부한 적이 있는 경우에 실행할 함수
                cancel()
            } else {
                // 권한 요청
                ActivityCompat.requestPermissions(
                    this,
                    arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
                    REQUEST_ACCESS_FINE_LOCATION
                )
            }
        } else {
            // 권한을 수락했을 때 실행할 함수
            ok()
        }
    }

    //위치 정보가 필요한 이유 다이얼로그 표시
    private fun showPermissionInfoDialog(){
        alert("현재 위치 정보를 얻으려면 위치 권한이 필요합니다.","위치 권한이 필요한 이유") {
            yesButton {
                // 권한 요청
                ActivityCompat.requestPermissions(
                    this@MapsActivity,
                    arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
                    REQUEST_ACCESS_FINE_LOCATION
                )
            }
            noButton {  }
        }.show()
    }
    // 권한 선택에 대한 처리
    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        when(requestCode) {
            REQUEST_ACCESS_FINE_LOCATION -> {
                if(grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED){
                    // 권한 허용됨
                } else {
                    // 권한 거부
                    toast("권한 거부됨")
                }
            }
        }
    }

    // 현재 위치 요청을 삭제
    override fun onPause() {
        super.onPause()
    }
    private fun removeLocationListener() {
        fusedLocationProviderClient.removeLocationUpdates(locationCallback)
    }
}

위치 정보 갱신 확인하기

주석 1 에서 Location 객체의 위도와 경도값을 확인하기 위해 로그를 출력한다.

class MapsActivity : AppCompatActivity(), OnMapReadyCallback {

    ....

    override fun onCreate(savedInstanceState: Bundle?) {
        ....
    }
    override fun onMapReady(googleMap: GoogleMap) {
       ....
    }

    // 위치 정보를 얻기 위한 각종 초기화
    private fun locationInit(){
        ....
    }

    override fun onResume() {
        ....
    }

    // 현재 위치를 주기적으로 요청
    @SuppressLint("MissingPermission")
    private fun addLocationListener() {
       ....
    }

    inner class MyLocationCallBack : LocationCallback() {
        override fun onLocationResult(locationResult: LocationResult?) {
            super.onLocationResult(locationResult)

            val location = locationResult?.lastLocation
            // 7
            location?.run {
                // 14 level로 확대하고 현재 위치로 카메라 이동
                val latLng = LatLng(latitude, longitude)
                mMap.animateCamera(CameraUpdateFactory.newLatLngZoom(latLng,17f))

                // 1
                Log.d("MapsActivity","위도 : $latitude, 경도: $longitude")
                
            }
        }
    }    
    private fun permissionCheck(cancel : () -> Unit, ok: () -> Unit) {
        ....
    }

    //위치 정보가 필요한 이유 다이얼로그 표시
    private fun showPermissionInfoDialog(){
        ....
    }
    // 권한 선택에 대한 처리
    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        ....
    }

    // 현재 위치 요청을 삭제
    override fun onPause() {
        ....
    }
    private fun removeLocationListener() {
        ....
    }
}

스텝3 이동 자취를 선으로 그리기

구글 지도는 이동 자취를 그리는 다양한 메서드를 제공한다.

  • addPolyLine() : 선의 집합으로 지도에 경로와 노선을 표시한다.
  • addCircle() : 원을 표시한다.
  • addPolygon() : 영역을 표시한다.

 

이동 경로 그리기

  1. PolylineOptions 객체를 생성한다. 여기서는 굵기 5f, 색생 빨강으로 설정했다.
  2. 위치정보가 갱신되면 PolylineOptions 객체에 좌표를 추가한다.
  3. 위치정보가 갱신되면 지도에 PolylineOptions 객체를 추가한다.
class MapsActivity : AppCompatActivity(), OnMapReadyCallback {

    ....
    // PolyLine 옵션 1
    private val polylineOptions : PolylineOptions = PolylineOptions().width(5f).color(Color.RED)

    override fun onCreate(savedInstanceState: Bundle?) {
        ....
    }
    override fun onMapReady(googleMap: GoogleMap) {
       ....
    }

    // 위치 정보를 얻기 위한 각종 초기화
    private fun locationInit(){
        ....
    }

    override fun onResume() {
        ....
    }

    // 현재 위치를 주기적으로 요청
    @SuppressLint("MissingPermission")
    private fun addLocationListener() {
       ....
    }

    inner class MyLocationCallBack : LocationCallback() {
        override fun onLocationResult(locationResult: LocationResult?) {
            super.onLocationResult(locationResult)

            val location = locationResult?.lastLocation
            // 7
            location?.run {
               ....
               
               // PolyLine에 좌표 추가 2
               polylineOptions.add(latLng)
               // 선 그리기 3
               mMap.addPolyline(polylineOptions)              
            }
        }
    }    
    private fun permissionCheck(cancel : () -> Unit, ok: () -> Unit) {
        ....
    }

    //위치 정보가 필요한 이유 다이얼로그 표시
    private fun showPermissionInfoDialog(){
        ....
    }
    // 권한 선택에 대한 처리
    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        ....
    }

    // 현재 위치 요청을 삭제
    override fun onPause() {
        ....
    }
    private fun removeLocationListener() {
        ....
    }
}

화면 유지하기


class MapsActivity : AppCompatActivity(), OnMapReadyCallback {
    ....
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 화면이 꺼지지 않게 하기
        window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
        // 세로 모드로 화면 고정
        requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT

        setContentView(R.layout.activity_maps)

        val mapFragment = supportFragmentManager
            .findFragmentById(R.id.map) as SupportMapFragment
        mapFragment.getMapAsync(this)
        
        locationInit()
    }
....
}

에뮬레이터에서 테스트 하기

에뮬레이터에서 다음과 같은 순서로 좌표를 지정할 수 있다.

좌표의 괘적 목록을 파일로 테스트 하려면 다음 사이트를 이용하면 편리하다.

오픈스트리트맵 : https://www.openstreetmap.org 

 

오픈스트리트맵

OpenStreetMap은 여러분과 같은 사람들이 만들어, 개방형 라이선스에 따라 자유롭게 사용할 수 있는 세계 지도입니다.

www.openstreetmap.org

 

' > 오준석의 안드로이드 생존코딩 코틀린편' 카테고리의 다른 글

12. 실로폰  (0) 2020.06.24
11. 손전등  (0) 2020.06.23
8. 수평 측정기  (0) 2020.06.16
7. 나만의 웹 브라우저  (0) 2020.06.15
6. 스톱워치  (0) 2020.06.11