안드로이드 : 구글 맵과 서울시 공공 Open API 연동
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<com.google.android.gms.maps.MapView
android:layout_width="0dp"
android:layout_height="0dp"
android:id="@+id/mapView"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clickable="true"
app:srcCompat="@android:drawable/ic_menu_mylocation"
android:id="@+id/myLocationButton"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:fabSize="mini"/>
</androidx.constraintlayout.widget.ConstraintLayout>
구글 맵 연동
1. 애플리케이션 생성
2. 구글 맵을 사용하려면 API 키를 발급 받아야 합니다.
1) https://cloud.google.com 에 접속
2) 구글 계정으로 로그인
3) Console 클릭
4) 왼쪽 창에서 API 및 서비스에서 대시보드 선택
5) 프로젝트 선택하고 새로만들기 선택
6) 만들어진 프로젝트 선택하고 API 및 서비스 사용 설정을 클릭
7) Maps SDK for Android 를 선택
8) 사용자 인증 정보를 클릭해서 API 를 선택
9) 키가 발급 되는데 그 화면에서 키제한을 클릭
10) Android 를 선택
11) 항목 추가를 눌러서 프로젝트의 패키지 이름을 등록
12) 안드로이드 프로젝트에서 오른쪽의 gradle 이라는 버튼을 누르고
app > tasts > android > signingReport 를 클릭해서 SHA1 의 값을 복사한 후 앱의 지문에 등록을 해줍니다.
AIzaSyAf3i9M0Dgl7WOwCGLGQs8ynnMP1_Yr5EY
3. 구글 맵 사용을 위한 설정
1) 모듈 수준의 build.gradle 파일의 dependencies 에 등록
// 구글 맵을 사용하기 위한 의존성
implementation 'com.google.android.gms:play-services-maps:17.0.0'
// 맵 클러스터링을 이용하기 위한 라이브러기
implementation 'com.google.maps.android:android-maps-utils:0.5+'
2) AndroidManifest.xml 파일에서 application 태그 안에 하위 태그로 추가
<!-- 구글 클라우드 플랫폼에서 발급받은 API 키를 여기에 설정 -->
<meta-data android:name="com.google.android.geo.API_KEY"
android:value="발급 받은 키"/>
<!-- 구글 플레이 서비스 버전 설정-->
<meta-data android:name="com.google.android.gms.version"
android:value="@integer/google_play_services_version"/>
<!-- 안드로이드 파이 버전부터 필요 -->
<uses-library
android:name="org.apache.http.legacy"
android:required="false"/>
3) 현재 위치 정보를 사용하기 위한 권한 설정 - AndroidManifest.xml 파일에서 application 태그 밖에 합니다.
FINE_LOCATION, COARSE_LOCATION
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
4) 레이아웃 설정 - 맵 뷰 1 개와 FloatingActionButton
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<com.google.android.gms.maps.MapView
android:layout_width="0dp"
android:layout_height="0dp"
android:id="@+id/mapView"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clickable="true"
app:srcCompat="@android:drawable/ic_menu_mylocation"
android:id="@+id/myLocationButton"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:fabSize="mini"/>
</androidx.constraintlayout.widget.ConstraintLayout>
다른 곳에서 레이아웃을 복사해서 사용할 때는 Root 태그의 android:context 를 확인해서 매칭 되는 Activity 이름이 맞는지 확인
SDK 버전에 따라서 뷰 클래스의 패키지 이름이 다를 수 있습니다.
머터리얼 디자인의 뷰는 패키지 이름이 계속 변경 됩니다.
복사했는데 뷰 이름이 오류가 나면 외부에다가 뷰 이름만 다시 입력해서 패키지를 수정하면 됩니다!!
서울시 공공 Open API 를 이용해서 공공화장실 정보 가져와서 지도에 출력하기
http://openAPI.seoul.go.kr:8088/(인증키)/xml/SearchPublicToiletPOIService/1/5/
http://openAPI.seoul.go.kr:8088/(인증키)/json/SearchPublicToiletPOIService/1/5/
http://openAPI.seoul.go.kr:8088/(인증키)/xml/SearchPublicToiletPOIService/1/1000/
1. 앱에 인터넷 권한 설정
2. URL 이 http이면 애플리케이션 태그에 useCleartextTraffic 속성을 true 로 설정
3. Open API 데이터를 가져와서 파싱해서 사용
=> 데이터를 가져오는 부분은 스레드로 생성
=> 스레드로 가져온 데이터를 출력하는 부분은 Handler 로 만들어야 합니다.
=> 이전 API 에서 AsyncTask 이용해서 이 작업을 수행했는데, AsyncTask 는 deprecated 되었습니다.
6f6574504d61737339366763446376
MainActivity.kt
package kr.co.tjoeun.practice
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.location.Location
import android.location.LocationManager
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.Message
import android.util.Log
import android.widget.Toast
import androidx.core.app.ActivityCompat
import com.google.android.gms.maps.CameraUpdateFactory
import com.google.android.gms.maps.GoogleMap
import com.google.android.gms.maps.model.BitmapDescriptorFactory
import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps.model.MarkerOptions
import com.google.maps.android.clustering.ClusterManager
// Layout 파일에 설정한 뷰들의 id를 프로퍼티로 설정하기 위한 import
import kotlinx.android.synthetic.main.activity_main.*
import org.json.JSONObject
import java.net.URL
class MainActivity : AppCompatActivity() {
// 런타임에서 권한이 필요한 퍼미션 목록
val PERMISSIONS = arrayOf(
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION
)
// 퍼미션 승인 요청시 사용하는 요청 코드
val REQUEST_PERMISSION_CODE = 1
// 기본 맵 줌 레벨
val DEFAULT_ZOOM_LEVEL = 17f
// 현재위치를 가져올수 없는 경우 서울 시청의 위치로 지도를 보여주기 위해 서울시청의 위치를 변수로 선언
// LatLng 클래스는 위도와 경도를 가지는 클래스
val CITY_HALL = LatLng(37.5662952, 126.97794509999994)
// 구글 맵 객체를 참조할 멤버 변수
var googleMap: GoogleMap? = null
// 마지막 센서가 감지한 마지막 위치를 리턴하는 메소드
// 퍼미션을 확인해야 하지만 앞에서 다른 곳에서 했기 때문에 여기서는 패스 하겠다는 의미
@SuppressLint("MissingPermission")
fun getMyLocation(): LatLng {
// 위치를 측정하는 프로바이더를 GPS 센서로 지정
val locationProvider: String = LocationManager.GPS_PROVIDER
// 위치 서비스 객체를 불러옴
val locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager
// 마지막으로 업데이트된 위치를 가져옴
val lastKnownLocation: Location? =
locationManager.getLastKnownLocation(locationProvider)
// 위도 경도 객체로 반환
return LatLng(lastKnownLocation!!.latitude, lastKnownLocation!!.longitude)
}
// 권한 존재 여부를 확인하는 사용자 정의 메소드
// 앱에서 사용하는 권한이 있는지 체크하는 메소드
fun hasPermissions(): Boolean {
// 퍼미션목록중 하나라도 권한이 없으면 false 반환
for (permission in PERMISSIONS) {
if (ActivityCompat.checkSelfPermission(
this,
permission
) != PackageManager.PERMISSION_GRANTED
) {
return false
}
}
return true
}
// 구글 맵을 초기화 시켜주는 메소드
@SuppressLint("MissingPermission")
fun initMap() {
// 맵뷰에서 구글 맵을 불러오는 함수. 콜백함수에서 구글 맵 객체가 전달됨
mapView.getMapAsync {
// 맵 클러스터링을 위한 설정
// ClusterManager 객체 초기화
clusterManager = ClusterManager(this, it)
clusterRenderer = ClusterRenderer(this, it, clusterManager)
// OnCameraIdleListener 와 OnMarkerClickListener 를 clusterManager 로 지정
it.setOnCameraIdleListener(clusterManager)
// 클릭하면 title 과 snippet 이 보여지도록 설정 되어 있습니다.
it.setOnMarkerClickListener(clusterManager)
// 구글맵 멤버 변수에 구글맵 객체 저장
googleMap = it
// 현재위치로 이동 버튼 비활성화
it.uiSettings.isMyLocationButtonEnabled = false
// 위치 사용 권한이 있는 경우
when {
hasPermissions() -> {
// 현재위치 표시 활성화
it.isMyLocationEnabled = true
// 현재위치로 카메라 이동
it.moveCamera(
CameraUpdateFactory.newLatLngZoom(
getMyLocation(),
DEFAULT_ZOOM_LEVEL
)
)
}
else -> {
// 권한이 없으면 서울시청의 위치로 이동
it.moveCamera(CameraUpdateFactory.newLatLngZoom(CITY_HALL,
DEFAULT_ZOOM_LEVEL))
}
}
}
}
// 권한 허용 여부가 결정 되면 호출 되는 메소드를 재정의
override fun onRequestPermissionsResult(
requestCode: Int, permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
// 맵 초기화
initMap()
}
// FloatingActionButton 을 누르면 호출 되는 메소드
// 현재 위치 버튼 클릭한 경우
fun onMyLocationButtonClick() {
when {
hasPermissions() -> googleMap?.moveCamera(
CameraUpdateFactory.newLatLngZoom(getMyLocation(), DEFAULT_ZOOM_LEVEL)
)
else -> Toast.makeText(applicationContext, "위치사용권한 설정에 동의해주세요",
Toast.LENGTH_LONG)
.show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// 맵뷰에 onCreate 함수 호출
mapView.onCreate(savedInstanceState)
// 앱이 실행될때 런타임에서 위치 서비스 관련 권한 체크
if (hasPermissions()) {
// 권한이 있는 경우 맵 초기화
initMap()
} else {
// 권한 요청
ActivityCompat.requestPermissions(this, PERMISSIONS, REQUEST_PERMISSION_CODE)
}
// 현재 위치 버튼 클릭 이벤트 리스너 설정
myLocationButton.setOnClickListener { onMyLocationButtonClick() }
}
//맵뷰의 수명 주기 관련 메소드
// 액티비티가 화면에 보여질 때 호출되는 메소드
// 맵 뷰는 비동기로 동작을 하기 때문에 onCreate 메소드의 수행이 계속 되어 버리기 때문에
// 나중에 화면이 보여질 때 지도를 업데이트를 해주어야 합니다.
// onResume 에서 맵 뷰의 onResume 을 호출해주어야 합니다.
override fun onResume() {
super.onResume()
mapView.onResume()
}
// 액티비티가 비활성화 될 때 호출 되는 메소드
override fun onPause() {
super.onPause()
mapView.onPause()
}
// 액티비티가 파괴 될 때 호출 되는 메소드
override fun onDestroy() {
super.onDestroy()
mapView.onDestroy()
}
// 메모리가 부족할 때 호출 되는 메소드
override fun onLowMemory() {
super.onLowMemory()
mapView.onLowMemory()
}
// 서울 열린 데이터 광장에서 발급받은 API 키를 입력
val API_KEY = "6f74435a7367676135384143515370"
//화장실의 위도와 경도 및 이름 들을 저장할 Map 의 List 생성
var toiletList: MutableList<Map<String, Any>> = mutableListOf<Map<String, Any>>()
// 화장실 이미지로 사용할 Bitmap
// Lazy 는 바로 생성하지 않고 처음 사용 될 때 생성하는 문법
val bitmap by lazy {
val drawable = resources.getDrawable(R.drawable.restroom_sign, null) as BitmapDrawable
Bitmap.createScaledBitmap(drawable.bitmap, 64, 64, false)
}
// Open API 다운로드 받는 스레드
//다운로드 받아서 파싱할 스레드
inner class ToiletThread : Thread() {
override fun run() {
//데이터의 시작과 종료 인덱스
var startIdx = 1
var endIdx = 1000
//데이터의 전체 개수를 저장하기 위한 프로퍼티
var count = 0
do {
//파싱할 URL 생성
var url =
URL("http://openAPI.seoul.go.kr:8088" +
"/${API_KEY}/json/SearchPublicToiletPOIService/${startIdx}/${endIdx}")
//연결해서 문자열 가져오기
val connection = url.openConnection()
val data = connection.getInputStream().readBytes().toString(charset("UTF-8"))
//JSON 파싱
val jsonData = JSONObject(data)
val root = jsonData.getJSONObject("SearchPublicToiletPOIService")
// 데이터 개수 구하기
if (count == 0) {
count = root.getInt("list_total_count")
}
val row = root.getJSONArray("row")
for (i in 0 until row.length()) {
val obj = row.getJSONObject(i)
val map = mutableMapOf<String, Any>()
map.put("FNAME", obj.getString("FNAME"))
map.put("ANAME", obj.getString("ANAME"))
map.put("Lat", obj.getDouble("Y_WGS84"))
map.put("Lng", obj.getDouble("X_WGS84"))
toiletList.add(map)
}
//인덱스를 변경해서 데이터 계속 가져오기
startIdx = startIdx + 1000
endIdx = endIdx + 1000
} while (startIdx < count)
handler.sendEmptyMessage(0)
}
}
// 스레드가 다운로드 받아서 파싱한 결과를 가지고 맵 뷰에 마커를 출력해달라고 요청청
val handler: Handler = object : Handler(Looper.getMainLooper()) {
override fun handleMessage(msg: Message) {
Log.e("toiletList", toiletList.toString())
for (map in toiletList) {
addMarkers(map as MutableMap<String, Any>)
}
}
}
// 마커를 추가하는 함수
fun addMarkers(toilet: MutableMap<String, Any>) {
// 맵이 직접 마커를 생성 - 작은 지역에 마커가 많으면 보기가 안좋습니다.
/*
googleMap?.addMarker(
MarkerOptions()
.position(LatLng(toilet.get("Lat") as Double, toilet.get("Lng") as Double))
.title(toilet.get("FNAME") as String)
.snippet(toilet.get("ANAME") as String)
.icon(BitmapDescriptorFactory.fromBitmap(bitmap))
)
*/
// 클러스터 매니저가 마커를 출력도록 설정을 변경
clusterManager?.addItem(
MyItem(
LatLng(toilet.get("Lat") as Double, toilet.get("Lng") as Double),
toilet.get("FNAME") as String,
toilet.get("ANAME") as String,
BitmapDescriptorFactory.fromBitmap(bitmap)
)
)
}
var toiletThread: ToiletThread? = null
// 앱이 활성화될때 서울시 데이터를 읽어옴
override fun onStart() {
super.onStart()
if (toiletThread == null) {
toiletThread = ToiletThread()
toiletThread!!.start()
}
}
// 앱이 비활성화 될때 백그라운드 작업 취소
override fun onStop() {
super.onStop()
toiletThread!!.isInterrupted
toiletThread = null
}
// ClusterManager 변수 선언
var clusterManager: ClusterManager<MyItem>? = null
// ClusterRenderer 변수 선언
var clusterRenderer: ClusterRenderer? = null
}
MyItem.kt
package kr.co.tjoeun.practice
import com.google.android.gms.maps.model.BitmapDescriptor
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.clustering.ClusterItem
// ClusterItem 을 구현하는 클래스.
// getSnippet(), getTitle(), getPosition() 함수를 구현해야함.
// 생성자에서 전달받은 데이터를 반환할수 있게 구현
// 멤버 프로퍼티는 인터페이스 Getter 와 이름이 다르게 지정
// 마커의 아이콘을 변경하기 위해 icon 파라미터를 추가로 받음
// 프로퍼티 이름에 언더바가 붇어 있어서 getter 를 오버라이딩 한 것 입니다.
// 언더바를 안 붙였으면 오버라이딩 할 필요가 없었습니다.
class MyItem(val _position: LatLng, val _title: String,
val _snippet: String, val _icon: BitmapDescriptor) :
ClusterItem {
override fun getSnippet(): String {
return _snippet
}
override fun getTitle(): String {
return _title
}
override fun getPosition(): LatLng {
return _position
}
fun getIcon(): BitmapDescriptor {
return _icon
}
// 검색에서 아이템을 찾기위해 동등성 함수 오버라이드
// GPS 상 위도,경도, 제목, 설명 항목이 모두 같으면 같은 객체로 취급한다.
// 인스턴스 내부의 값을 비교해서 일치 여부를 판단하기 위해 오버라이딩 하는 메소드
override fun equals(other: Any?): Boolean {
if (other is MyItem) {
return (other.position.latitude == position.latitude
&& other.position.longitude == position.longitude
&& other.title == _title
&& other.snippet == _snippet
)
}
return false
}
// equals() 를 오버라이드 한 경우 오버라이드 필요
// 같은 객체는 같은 해시코드를 반환해야 함.
override fun hashCode(): Int {
var hash = _position.latitude.hashCode() * 31
hash = hash * 31 + _position.longitude.hashCode()
hash = hash * 31 + title.hashCode()
hash = hash * 31 + snippet.hashCode()
return hash
}
}
ClusterRenderer.kt
package kr.co.tjoeun.practice
import android.content.Context
import com.google.android.gms.maps.GoogleMap
import com.google.android.gms.maps.model.MarkerOptions
import com.google.maps.android.clustering.ClusterManager
import com.google.maps.android.clustering.view.DefaultClusterRenderer
// ClusterRenderer 클래스는 마커를 렌더링하는 작업을 담당하는 클래스
// 마커의 모양을 결정하는 클래스
class ClusterRenderer(context: Context?, map: GoogleMap?, clusterManager: ClusterManager<MyItem>?) :
DefaultClusterRenderer<MyItem>(context, map, clusterManager) {
init {
// 전달받은 clusterManager 객체에 renderer 를 자신으로 지정
clusterManager?.renderer = this
}
// 클러스터 아이템이 렌더링 되기 전 호출되는 함수
override fun onBeforeClusterItemRendered(item: MyItem?, markerOptions: MarkerOptions?)
{
// 마커의 아이콘 지정
markerOptions?.icon(item?.getIcon())
markerOptions?.visible(true)
}
}