1.  메인 스레드와 백그라운드 스레드

프로세스(process)란?

 

  🚀  프로세스(process)란 단순히 실행 중인 프로그램(program)으로 사용자가 작성한 프로그램이 운영체제에 의해 메모리 공간을 할당받아 실행 중인 것을 말함
  🚀  이러한 프로세스는 프로그램에 사용되는 데이터와 메모리 등의 자원 그리고 스레드로 구성

 

스레드(thread)란?


  🚀  스레드(thread)란 프로세스(process) 내에서 실제로 작업을 수행하는 주체를 의미
         ➡️  모든 프로세스에는 한 개 이상의 스레드가 존재하여 작업을 수행
  🚀  두 개 이상의 스레드를 가지는 프로세스를 멀티스레드 프로세스(multi-threaded process)라고 함

 

  📍  앱이 처음 시작될 때 시스템이 스레드 하나를 생성하는데 이를 메인 스레드라고 함
     

    ⚡️  메인 스레드의 역할

  • 액티비티의 모든 생명 주기 관련 콜백 실행을 담당
  • 버튼, 에디트텍스트와 같은 UI위젯을 사용한 사용자 이벤트와 UI드로잉 이벤트를 담당. UI 스레드라고도 불림.

작업량이 큰 연산이나, 네트워크 통신, 데이터베이스 쿼리 등은 처리에 긴 시간이 걸림. 이 모든 작업을 메인 스레드의 큐에 넣고 작업하면 한 작업의 처리가 완료될 때까지 다른 작업을 처리하지 못하기 때문에 사용자 입장에서는 마치 앱이 먹통이 된 것처럼 보이게 됨. 또, 몇 초 이상 메인 스레드가 멈추면 '앱이 응답하지 않습니다.'라는 메시지를 받게 됨

  📍  백그라운드 스레드를 활용하면 이러한 먹통 현상을 피할 수 있음. * 백그라운드 스레드 = 워커 스레드
      ✓  메인 스레드에서 너무 많은 일을 처리하지 않도록 백그라운드 스레드를 만들어 일을 덜어주는 것
      ✓  백그라운드 스레드에서 복잡한 연산이나, 네트워크 작업, 데이터베이스 작업 등을 수행
      ✓  주의할 점은 '백그라운드 스레드에서는 절대로 UI 관련 작업을 하면 안 된다는 점
            ➡️  각 백그라운드 스레드가 언제 처리를 끝내고 UI에 접근할지 순서를 알 수 없기 때문에 UI는 메인 스레드에서만 수정할 수있게 한 것. 따라서 백그라운드 스레드에서 UI 자원을 사용하려면 메인 스레드에 UI 자원 사용 메시지를 전달하는 방법을 이용해야 함

    📍  UI 스레드에서 UI 작업을 하는데 Handler 클래스, AsyncTask 클래스, runOnUiThread() 메서드 등을 활용할 수 있음

 


1)  runOnUiThread() 메서드

  🚀  runOnUiThread()는 UI 스레드(메인 스레드)에서 코드를 실행시킬 때 쓰는 액티비티 클래스의 메서드

Activity.java에 정의된 메서드
public final void runOnUiThread(Runnable action) {
    if (Thread.currentThread() != mUiThread) {
        mHandler.post(action);
    } else {
        action.run();
    }
}

 

  ✓  if문을 살펴보면, 만약 현재 스레드가 UI 스레드가 아니면 핸들러를 이용해 UI 스레드의 이벤트큐에 action을 전달 post
  ✓  만약 UI 스레드이면 action.run()을 수행. 즉 어떤 스레드에 있던지 runOnUiThread() 메서드는 UI스레드에서 Runable 객체를 실행

 

  📍  다음과 같은 UI 관련 코드를 runOnUiThread()로 감싸주어 사용

runOnUiThread(object : Runnable {
    override fun run() {
        // 여기에 원하는 로직을 구현
    }
})

 

   📍  코틀린의 SAM Single Abstract Method를 사용하면 더 간단하게 표현

runOnUiThread {
    // 여기에 원하는 로직을 구현
}

 


2.  스톱워치 만들기

 

1)  기본 레이아웃 설정

colors.xml과 strings.xml 설정
  • 스톱워치에서 4가지 색상과 3가지 문자열을 사용
  • [app] - [res] - [values] colors.xml파랑, 빨강, 노랑을 추가
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="purple_200">#FFBB86FC</color>
    <color name="purple_500">#FF6200EE</color>
    <color name="purple_700">#FF3700B3</color>
    <color name="teal_200">#FF03DAC5</color>
    <color name="teal_700">#FF018786</color>
    <color name="black">#FF000000</color>
    <color name="white">#FFFFFFFF</color>

    <!-- 직접 추가한 색 -->
    <color name="blue">#603CFF</color>
    <color name="red">#FF6767</color>
    <color name="yellow">#E1BF5A</color>
</resources>

 

  • [app] - [res] - [values] strings.xml에서 '시작', '일시정지', '초기화' 문자열을 추가
<resources>
    <string name="app_name">thread_0529</string>

    <!-- 추가한 문자열 -->
    <string name="start">시작</string>
    <string name="pause">일시정지</string>
    <string name="refresh">초기화</string>
</resources>

 

 

버튼 추가
  • [초기화]와 [시작] 버튼 생성
     <Button
        android:id="@+id/btnStart"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginBottom="80dp"
        android:backgroundTint="@color/blue"
        android:padding="20dp"
        android:text="@string/start"
        android:textColor="@color/white"
        android:textSize="16sp"
        android:textStyle="bold"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

    <Button
        android:id="@+id/btnRefresh"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginBottom="50dp"
        android:backgroundTint="@color/yellow"
        android:padding="20dp"
        android:text="@string/refresh"
        android:textColor="@color/white"
        android:textSize="16sp"
        android:textStyle="bold"
        app:layout_constraintBottom_toTopOf="@+id/btnStart"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

 

 

텍스트뷰 추가
  • 흐르는 시간을 표현해줄 텍스트뷰를 생성
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/tvMinute"
android:text="00"
android:textSize="45sp" />

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/tvSecond"
android:text=":00"
android:textSize="45sp" />

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/tvMillisecond"
android:text=".00"
android:textSize="30sp" />

 

  • 레이아웃 미리보기 창에서 텍스트뷰들을 드래그하여 원하는 위치로 놓아줌

 

  📍 수직 방향 제약 추가하기

  • 세 텍스트 뷰의 수직 방향에 제약을 추가 ▶️  초를 나타내는 텍스트뷰를 중심으로 왼쪽에는 분을 나타내는 텍스트뷰, 오른쪽에
    는 밀리초를 나타내는 텍스트 뷰를 위치
  • 먼저 초 텍스트뷰의 위쪽을 레이아웃의 위쪽에 초 텍스트 아래쪽을 [초기화] 버튼의 상단에 드래그 ▶️ 그럼 텍스트뷰가 상하 제약의 중간 지점에 놓임
  • 분 텍스트뷰와 밀리초 텍스트뷰를 일직선 위에 놓이도록 제약을 추가.

         모든 텍스트뷰를 일직선 위에 놓으려면 베이스라인을 사용

              분 텍스트뷰 위에서 마우스 우클릭 -> [Show Baseline]을 선택. 그럼 텍스트의 아래쪽에 베이스라인 막대가 보임.분

              텍스트뷰 막대를 초 텍스트뷰 막대 모양에 드래그. 밀리초 텍스트뷰 역시 똑같은 방법으로 베이스라인을 정렬.

 

 

  📍 수평 방향 제약 추가하기

 

      ⚡️  컨스트레인트 레이아웃에는 뷰 여러 개의 수직 또는 수평 여백을 손쉽게 관리하는 체인 Chain을 제공

  • Ctrl 키를 누른 상태에서 세 텍스트뷰를 모두 클릭하여 선택
  • 선택한 텍스트뷰 위에서 마우스 우클릭 후, [Chains] - [Create Horizontal Chain]을 선택
  • 그럼 다음과 같이 세 텍스트뷰가 수평 방향으로 균등한 여백을 두고 위치

 

  • 딱 붙어있어야 되는 경우 세 텍스트뷰 위에서 마우스 우클릭 후, [Chains] - [Horizontal Chain Style] - [packed] 선택


 

2)  버튼에 이벤트 연결하기

MainActivity.kt
class MainActivity : AppCompatActivity(), View.OnClickListener {
    var isRunning = false // 실행 여부 확인용 변수

    private lateinit var btnStart: Button
    private lateinit var btnRefresh: Button
    private lateinit var tvMinute: TextView
    private lateinit var tvSecond: TextView
    private lateinit var tvMillisecond: TextView

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

        // 뷰 가져오기
        btnStart = findViewById(R.id.btnStart)
        btnRefresh = findViewById(R.id.btnRefresh)
        tvMinute = findViewById(R.id.tvMinute)
        tvSecond = findViewById(R.id.tvSecond)
        tvMillisecond = findViewById(R.id.tvMillisecond)

        // 버튼별 OnClickListener 등록
        btnStart.setOnClickListener(this)
        btnRefresh.setOnClickListener(this)
    }

    override fun onClick(p0: View?) {
        when(p0?.id) {
            R.id.btnStart -> {
                if(isRunning) {
                    pause()
                } else {
                    start()
                }
            }
            R.id.btnRefresh -> {
                refresh()
            }
        }
    }

    private fun start() {
        // 스톱워치 측정을 시작하는 로직
    }

    private fun pause() {
        // 스톱워치 측정을 일시정지하는 로직
    }

    private fun refresh() {
        // 초기화하는 로직
    }
}

 

  ✓  클릭 이벤트를 처리하는 View.OnClickListener 인터페이스를 구현

  ✓  스톱워치가 현재 실행되고 있는지를 확인하는 데 사용하는 isRunning 변수를 false로 초기화해 생성

  ✓  findViewById() 메서드로 xml 레이아웃 파일에서 정의한 뷰들을 액티비티에서 사용할 수 있게 가져옴

  ✓  btnStart와 btnRefresh에 구현한 리스너를 등록. setOnClickListener() 메서드를 이용해서 onClickListener를 추가해주어야 클릭이 가능

  ✓  클릭 이벤트가 발생했을 때 어떤 기능을 수행할 지 구현. 따라서 View.OnClickListener 인터페이스는 반드시 onClick() 메서드를 오버라이딩해야함.

 


3)  스톱워치 시작 기능 구현하기

  [시작] 버튼을 누르면 스톱워치가 시작되고 [시작] 버튼 텍스트가 '일시정지'로 바뀜

 

MainActivity에 타이머 관련 변수 timer와 time을 추가하고 초기화
class MainActivity : AppCompatActivity(), View.OnClickListener {
    var isRunning = false // 실행 여부 확인용 변수
    var timer: Timer? = null
    var time = 0

 

start() 메서드를 구현
private fun start() {
    btnStart.text = "일시정지" // 버튼 텍스트 변경
    btnStart.setBackgroundColor(getColor(R.color.red)) // 배경색을 빨강으로 변경
    isRunning = true // 실행상태 변경

    // 스톱워치 측정을 시작하는 로직
    timer = timer(period = 10) {
        time ++ // 0.01초마다 time에 1을 더함

        // 시간 계산
        val milliSecond = time % 100
        val second = (time % 6000) / 100
        val minute = time / 6000

        runOnUiThread { // 텍스트뷰가 UI 스레드에서 실행되도록 메서드 사용
        
            // 시간이 한 자리일 때 전체 텍스트 길이를 두 자리로 유지
            if (isRunning) {
                tvMillisecond.text = if (milliSecond < 10) ".0${milliSecond}" else 
                  ".${milliSecond}" // 밀리초
                tvSecond.text = if (second < 10) ":0${second}" else ":${second}" //초
                tvMinute.text = if (minute < 10)"0${minute}" else "${minute}"//분
             }
        }
    }
}
💡  코틀린에서 제공하는 timer(period = [주기]) {} 메서드는 일정한 주기로 반복하는 동작을 수행할 때 사용
      {} 안에 쓰인 코드들은 모두 백그라운드 스레드에서 실행
      주기를 나타내는 period 변수를 10으로 지정했으므로 10밀리초마다 실행

 


4)  스톱워치 멈춤 기능 구현

private fun pause() {
    // 텍스트 속성 변경
    btnStart.text = "시작"
    btnStart.setBackgroundColor(getColor(R.color.blue))

    // 스톱워치 측정을 일시정지하는 로직
    isRunning = false // 멈춤 상태로 전환
    timer?.cancel() // 타이머 멈추기
}

 


5)  스톱워치 초기화 기능 구현

private fun refresh() {
    timer?.cancel() // 백그라운드 타이머 멈추기

    btnStart.text = "시작"
    btnStart.setBackgroundColor(getColor(R.color.blue))
    isRunning = false // 멈춤 상태로 전환

    // 초기화하는 로직
    time = 0
    tvMillisecond.text = ".00"
    tvSecond.text = ":00"
    tvMinute.text = "00"
}

 

 

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]


1.  컨스트레인트 레이아웃  ConstraintLayout

한 화면을 구성하는 뷰들에 서로 제약을 주는 레이아웃. 컨스트레인트 레이아웃을 자주 쓰는 가장 큰 이유는 '다양한 화면 크기에 대응하는 반응형 UI를 쉽게 구성'할 수 있기 때문. 또 중첩된 레이아웃을 사용하지 않고도 크고 복잡한 레이아웃을 만들 수 있어 성능면에서 유리함

 


1)  기본 속성

  👾  컨스트레인트 레이아웃에서 자식 뷰의 위치를 정의하려면 자식 뷰의 수직 / 수평 방향에 제약 조건을 각각 하나 이상 추가
  👾  자식 뷰에 아무런 제약도 추가하지 않으면 왼쪽 상단에 배치. 컨스트레인트 레이아웃에서 자주 쓰는 속성은 다음과 같음.

app:layout_constraint[내 방향]_to[기준 뷰 방향]Of = "[기준 뷰 ID or parent]"


  ✓  [내 방향]을 [기준 뷰 방향]에 맞추고 그 기준 뷰가 무엇인지 알려줌

 

예제
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <Button
        android:id="@+id/button_1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        
        // 시작 부분을 부모의 시작부분에 맞춤
        app:layout_constraintStart_toStartOf="parent"
        android:layout_marginStart="32dp"
        // 윗면을 부모의 윗면에 맞춤
        app:layout_constraintTop_toTopOf="parent"
        android:layout_marginTop="100dp"
        
        android:text="Button 1" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        
        // 시작부분을 버튼 끝부분에 맞춤
        app:layout_constraintStart_toEndOf="@+id/button_1"
        // 밑부분을 버튼부분에 맞춤
        app:layout_constraintBottom_toBottomOf="@+id/button_1"
        // 윗부분을 버튼 윗부분과 맞춤
        app:layout_constraintTop_toTopOf="@+id/button_1"
        android:layout_marginStart="32dp"
        android:text="버튼을 클릭해주세요!" />


</androidx.constraintlayout.widget.ConstraintLayout>

 


2)  컨스트레인트 레이아웃에서 마진을 줄 때 주의점

  👾  자식 뷰 사이에 여백을 정해줄 때 레이아웃에서는 layout_margin 속성을 사용
  👾  리니어 레이아웃과 상대적 레이아웃에서는 별다른 고려 없이 사용해도 되지만, 컨스트레인드 레이아웃을 사용할 때는 반드시 해당 방향으로 제약이 존재해야 마진값이 적용되는 규칙이 있음

예를 들면 위쪽 방향으로 여백을 100dp 만큼 주고 싶을 경우, 위쪽 방향에 제약이 없는 상태에서 android:layout_marginTop = "100dp"를 하면 적용이 되지 않음. 그러므로 반드시 android:layout_constraintTop_toTopOf="parent"와 같은 위쪽 방향에 제약을 추가할 수 있는 속성을 추가해야 함.

 


3)  match_constaint 속성

컨스트레인트 레이아웃을 이용해 앱의 레이아웃을 구성하다 보면 layout_width와 layout_height 속성에서 빈번하게 0dp 값을 보게 됨
그 뷰의 너비나 높이가 0dp라는 것은 match_constraint와 같음. match_parent가 부모 레이아웃 크기에 뷰 크기를 맞춘다는 것이라
 match_constraint는 제약에 뷰 크기를 맞춤.

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <Button
        android:id="@+id/button_1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        android:layout_marginTop="100dp"
        android:text="Button 1" />

    <Button
        android:id="@+id/button_2"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toEndOf="@+id/button_1"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="@+id/button_1"
        android:text="Button 2" />

</androidx.constraintlayout.widget.ConstraintLayout>

 

  ✓  [Button 2]의 너비가 제약에 맞춰짐. [Button 1]의 끝점과 부모 레이아웃의 끝점만큼의 크기를 차지

 

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]


1.  프로젝트 생성하고 의존성 추가하기

서울 공공도서관 앱은 지도 정보가 필요하므로 Google Maps Activity를 사용

AndroidManifest.xml
  • API 키 입력
  • 인터넷 접근 권한 추가
  • 도서관 정보 API가 보안 프로토콜인 HTTPS가 아니라 HTTP를 사용하기 때문에 application 태그에 usesCleartextTraffic 속성 추가
<meta-data
    android:name="com.google.android.geo.API_KEY"
    android:value="API_KEY 입력" />
<uses-permission android:name="android.permission.INTERNET"/>
<application
    android:usesCleartextTraffic="true"

 

build.gradle
  • 레트로핏과 viewBinding 설정
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
buildFeatures {
    viewBinding true
}


2.  데이터 클래스 생성

웹 브라우저에 주소를 요청해서 받은 json 샘플 데이터로 Kotlin 데이터 클래스를 생성


1)  레트로핏 관련 파일만 모아둘 패키지를 생성


2)  data 클래스 생성

  • retrofit 패키지를 우클릭 - [New] - [Kotlin data class File from JSON] 을 클릭
  • 복사한 JSON 데이터를 그대로 넣어줌. 클래스의 이름을 LibraryResponse 로 지정
  • [Enable Inner Class Model]가 체크 해제된 것을 확인 


3)  레트로핏 클래스 작성

  • retrofit 패키지에 RetrofitConnection 클래스 생성
  • 레트로핏 객체를 생성하는 getInstance() 메서드 생성
class RetrofitConnection {
    // 객체를 하나만 생성하는 싱글턴 패턴을 적용.
    companion object {
        // API 서버의 주소가 BASE_URL이 돰.
        private const val BASE_URL = "http://openapi.seoul.go.kr:8088/"
        private var INSTANCE: Retrofit? = null

        fun getInstance(): Retrofit {
            if (INSTANCE == null) {
                INSTANCE = Retrofit.Builder()
                    .baseUrl(BASE_URL)
                    .addConverterFactory(GsonConverterFactory.create())
                    .build()
            }
            return INSTANCE!!
        }
    }
}

 

  ✓  컨버퍼 팩토리서버에서 온 JSON 응답을 데이터 클래스 객체로 변환. Gson이라는 레트로핏 기본 컨버터 팩토리를 사용


4)  HTTP 메서드를 정의해놓은 인터페이스 작성


  🚀  HTTP 메서드를 작성해 레트로핏이 데이터를 가져올 수 있도록 작업.
  🚀  인터페이스를 작성하면 레트로핏 라이브러리가 인터페이스에 정의된 API 엔드포인트들을 자동으로 구현


  ①  인터페이스를 추가

      -  LibraryService 인터페이스 생성

 

  ②  인터페이스를 작성
      -  LibraryService 인터페이스를 작성. 필요한 API는 GET 메서드.

interface LibraryService {
    @GET("{apiKey}/json/SeoulPublicLibraryInfo/1/200/")
    fun getLibrary(@Path("apiKey") key: String): Call<LibraryResponse>
}

 


5)  레트로핏으로 데이터 불러오기


  🚀  작업한 인터페이스를 적용하고 데이터를 불러오는 코드를 작성

  ①  MapActivity에 onMapReady() 아래에 loadLibrary() 메서드 작성

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

    val sydney = LatLng(-34.0, 151.0)
    mMap.addMarker(MarkerOptions().position(sydney).title("Marker in Sydney"))
    mMap.moveCamera(CameraUpdateFactory.newLatLng(sydney))

    loadLibrary()
}
private fun loadLibrary() {
}

 

  ②  정의한 인터페이스를 실행 가능한 객체로 변환

// 레트로핏 객체를 이용하면 LibraryService 인터페이스 구현체를 가져올 수 있음
val retrofitAPI = RetrofitConnection.getInstance().create(LibraryService::class.java)

 

  ③  인터페이스에 정의된 getLibrary() 메서드에 api키를 입력하고, enqueue() 메서드를 호출해서 서버에 요청

private fun loadLibrary() {

    val retrofitAPI = RetrofitConnection.getInstance().create(LibraryService::class.java)
    retrofitAPI.getLibrary("API키").enqueue(object : Callback<LibraryResponse> {
        override fun onResponse(call: Call<LibraryResponse>, response: Response<LibraryResponse>) {
            TODO("Not yet implemented")
        }

        override fun onFailure(call: Call<LibraryResponse>, t: Throwable) {
            TODO("Not yet implemented")
        }
    })
}

 

 

  ④  오버라이드한 메서드를 구현

  • 서버 요청이 실패했을 경우 간단한 토스트 메시지로 알려줌
  • 서버에서 데이터를 정상적으로 받았다면 지도에 마커를 표시하는 메서드를 호출하도록 코드를 추가
private fun loadLibrary() {
    // 레트로핏 객체를 이용하면 LibraryService 인터페이스 구현체를 가져올 수 있음.
    val retrofitAPI = RetrofitConnection.getInstance().create(LibraryService::class.java)
    retrofitAPI.getLibrary("4f666a5957736f6d35367479796e4e").enqueue(object: Callback<LibraryResponse> {
    
        override fun onResponse(
            call: Call<LibraryResponse>, 
            response: Response<LibraryResponse>) { // 지도에 마커 표시
        
            showLibrary(response.body() as LibraryResponse)
        }

        override fun onFailure(call: Call<LibraryResponse>, t: Throwable) {
            Toast.makeText(baseContext, "서버에서 데이터를 가져올 수 없습니다.", Toast.LENGTH_SHORT).show()
        }
    })
}

 


6)  지도에 도서관 마커 표시하기

 

  ①  지도에 마커를 표시하는 showLibrary() 메서드를 loadLibrary() 메서드 아래에 생성

private fun showLibrary(libraryResponse: LibraryResponse) { }

 

  ②  파라미터로 전달된 LibraryResponse의 SeoulPublicLibraryInfo.row에 도서관 목록이 담겨 있음

private fun showLibrary(libraryResponse: LibraryResponse) {
    // 도서관 목록을 반복문으로 하나씩 꺼냄.
    for (lib in libraryResponse.SeoulPublicLibraryInfo.row) {
        // 마커의 좌표를 생성
        val position = LatLng(lib.XCNTS.toDouble(), lib.YDNTS.toDouble())
        // 좌표와 도서관 이름으로 마커를 생성
        val marker = MarkerOptions().position(position).title(lib.LBRRY_NAME)
        // 지도에 마커를 추가
        mMap.addMarker(marker)
    }
}

 

  ③  지도를 보여주는 카메라가 시드니를 가르키므로 카메라의 위치 조정이 필요

  • 수동으로 카메라의 좌표를 직접 입력해 주는 방법도 있지만 마커 전체의 영역을 구하고, 마커의 영역만큼 보여주는 코드를 작성
private fun showLibrary(libraryResponse: LibraryResponse) {
    val latLngBounds = LatLngBounds.builder() // 마커 영역 지정

    // 도서관 목록을 반복문으로 하나씩 꺼냄.
    for (lib in libraryResponse.SeoulPublicLibraryInfo.row) {
        val position = LatLng(lib.XCNTS.toDouble(), lib.YDNTS.toDouble())
        val marker = MarkerOptions().position(position).title(lib.LBRRY_NAME)
        mMap.addMarker(marker)

        latLngBounds.include(marker.position) // 마커 추가
    }

    val bounds = latLngBounds.build() // 저장해 둔 마커의 영역을 구함
    val padding = 0
    // bounds와 padding으로 카메라를 업데이트
    val updated = CameraUpdateFactory.newLatLngBounds(bounds, padding)

    mMap.moveCamera(updated)
}

 


7)  onMapReady에서 loadLibrary() 메서드 호출하기


  🚀  onMapReady()에 기본으로 작성되어 있는 코드를 주석 처리하고 loadLibrary() 메서드를 호출

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

//        val sydney = LatLng(-34.0, 151.0)
//        mMap.addMarker(MarkerOptions().position(sydney).title("Marker in Sydney"))
//        mMap.moveCamera(CameraUpdateFactory.newLatLng(sydney))

    loadLibrary()
}

 


8)  도서관 클릭 시 홈페이지로 이동하기


  🚀  클릭리스너로 새 창을 띄우거나 추가적인 처리를 할 수 있음. 여기서는 도서관 홈페이지의 url이 있는지 검사하고, 있으면 홈페이지를 웹 브라우저에 띄우는 코드를 작성

 

  ①  마커에 tag 정보를 추가

  • 마커를 클릭하면 id와 같은 구분 값을 tag에 저장해두고 사용할 수 있음
  • 지도에 마커를 추가하는 코드로 수정하고 tag 값에 홈페이지 주소를 저장
MapsActivity에서 showLibrary() 메서드의 다음 부분을 수정
for (lib in libraryResponse.SeoulPublicLibraryInfo.row) {
    val position = LatLng(lib.XCNTS.toDouble(), lib.YDNTS.toDouble())
    val marker = MarkerOptions().position(position).title(lib.LBRRY_NAME)
    //mMap.addMarker(marker)
    
    val obj = mMap.addMarker(marker)
    obj?.tag = lib.HMPG_URL

    latLngBounds.include(marker.position) // 마커 추가
}

 

  ②  클릭리스너를 달고 tag 홈페이지 주소를 웹 브라우저에 띄움

  • onMapReady() 안에서 추가로 코드를 작성
  • 지도에 마커클릭리스너를 달고 리스너를 통해 전달되는 마커의 tag를 검사해서 값이 있으면 인텐트로 홈페이지를 띄움
mMap.setOnMarkerClickListener {
    if (it.tag != null) {
        var url = it.tag as String
        if (!url.startsWith("http")) {
            url = "http://${url}"
        }
        val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
        startActivity(intent)
    }
    true
}

 

 

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]


1.  화면 만들기


1) activity_main.xml 편집하기

 

  📍  파일의 [Design] 모드에서 기존의 텍스트뷰는 지우고 팔레트의 컨테이너 카테고리에서 리사이클뷰를 선택해 화면 상단에 배치

  •  id 속성에 'recyclerMemo'를 입력

  📍  팔레트의 텍스트 카테고리에서 플레인텍스트를 드래그해서 화면 하단에 배치

  •  id 속성은 'editMemo', text 속성은 지우고, hint 속성을 '메모를 입력하세요'라고 수정
  • 여러 줄을 입력할 수 있어야 하므로 inputType 속성의 깃발를 눌러 textMultiLine을 'true'로 변경
  • textPersonName도 그대로 체크 상태로 두고 [Apply] 클릭

  📍  팔레트의 버튼 카테고리에서 버튼을 하나 드래그해서 우측 하단에 가져다 둠

  •  id 속성에는 'buttonSave', text 속성에는 '저장'이라고 입력

 


 

2)  item_recycler.xml 추가하기

 

리사이클러뷰의 아이템 용도로 사용할 item_recycler.xml 레이아웃 파일을 생성해서 편집

 

  📍  [app] - [res] - [layout] 디렉토리에서 새 리소스 파일을 생성

 

  📍  레이아웃 파일이 생성되면 [Design]모드에서 컴포넌트 트리의 최상위 컨스트레이트 레이아웃을 클릭

  • layout_height 속성을 '100dp'로 수정해서 아이템의 높이를 미리 정함

  📍  번호와 메모의 내용을 표시할 텍스트뷰 2개와 날짜를 표시할 텍스트뷰을 하나 배치

  • 번호를 표시할 텍스트뷰 : id - textNo, text = 01
  • 내용을 표시할 텍스트뷰 : id - textContent, maxLines - 2, ellipsize - end, gravity - center_vertical, text - 메모 내용 표시
  • 날짜를 표시할 텍스트뷰 : id - textDatetime, text - 2024/01/01 12:12
  • maxLines속성이 2인데, 두 줄을 넘어가면 말줄임표(...)가 나오도록 하는 속성

 


2.  소스 코드 연결하기

레이아웃과 소스코드를 연결. 코드에서 binding을 사용하므로 build.gradle 파일에 viewBinding 설정을 추가.

viewBinding {
    enabled = true
}

 


1)  RecyclerAdapter 클래스 만들기


Memo 클래스를 데이터로 사용하는 RecyclerAdapter 클래스를 정의

[app] - [java] 밑에 있는 패키지에 RecyclerAdapter라는 이름의 클래스를 생성

class RecyclerAdapter : RecyclerView.Adapter<Holder>() {

    var listData = mutableListOf<Memo>()

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
        // 뷰 홀더가 만들어질 때 호출
        val binding = ItemRecyclerBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return Holder(binding)
    }

    override fun onBindViewHolder(holder: Holder, position: Int) {
        // 뷰 홀더가 재사용될 때 호출
        val memo = listData[position]
        holder.setMemo(memo)
    }

    override fun getItemCount(): Int { // 어댑터에서 관리하는 아이템의 개수
       return listData.size
    }
}

class Holder(val binding: ItemRecyclerBinding): RecyclerView.ViewHolder(binding.root) {
    fun setMemo(memo: Memo) {
        binding.textNo.text = "${memo.num}"
        binding.textContent.text = memo.content
        val sdf = SimpleDateFormat("yyyy/MM/dd hh:mm")
        binding.textDatetime.text = sdf.format(memo.datetime)
    }
}

 


2)  MainActivity에서 코드 조합하기

onCreate()
class MainActivity : AppCompatActivity() {
    private val binding by lazy { ActivityMainBinding.inflate(layoutInflater)}
    private val helper = SQLiteHelper(this, "memo", 1)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)
        
        val adapter = RecyclerAdapter()
        adapter.listData.addAll(helper.selectMemo())

        binding.recyclerMemo.adapter = adapter
        binding.recyclerMemo.layoutManager = LinearLayoutManager(this)

    }
}

 

private val binding by lazy {ActivityMainBinding.inflate(layoutInflater) }
private val helper = SQLiteHelper(this, "memo", 1)

 

  👩🏻‍💻  클래스 코드 블럭 맨 윗줄에서 바인딩을 생성하고 binding 변수에 저장. 그리고 아랫줄에 SQLiteHelper를 생성하고 변수에 저장

 

setContentView(binding.root)
val adapter = RecyclerAdapter()

 

  👩🏻‍💻  onCreate()의 setContentView에 binding.root를 전달하고, 다음 줄에서 RecyclerAdapter를 생성

 

adapter.listData.addAll(helper.selectMemo())

 

  👩🏻‍💻  adapter의 listData에 데이터베이스에서 가져온 데이터를 세팅

 

binding.recyclerMemo.adapter = adapter
binding.recyclerMemo.layoutManager = LinearLayoutManager(this)

 

  👩🏻‍💻  화면의 리사이클러뷰 위젯에 adapter를 연결하고 레이아웃 매니저를 설정

 


저장 버튼에 클릭리스너
binding.buttonSave.setOnClickListener {
    if (binding.editMemo.text.toString().isNotEmpty()) {
        val memo = Memo(null, binding.editMemo.text.toString(), System.currentTimeMillis())
        helper.insertMemo(memo)
        adapter.listData.clear()

        adapter.listData.addAll(helper.selectMemo())
        adapter.notifyDataSetChanged()
        binding.editMemo.setText("")
    }
}

binding.buttonSave.setOnClickListener {
    if (binding.editMemo.text.toString().isNotEmpty()) {
        val memo = Memo(null, binding.editMemo.text.toString(), System.currentTimeMillis())
    }
}

 

  👩🏻‍💻  메모를 입력하는 플레인텍스트를 검사해서 값이 있으면 해당 내용으로 Memo 클래스를 생성

 

helper.insertMemo(memo)

 

  👩🏻‍💻  helper 클래스의 insertMemo() 메서드에 앞에서 생성한 Memo를 전달해 데이터베이스에 저장

 

adapter.listData.clear()

 

  👩🏻‍💻  어댑터의 데이터를 모두 초기화

 

adapter.listData.addAll(helper.selectMemo())
adapter.notifyDataSetChanged()

 

  👩🏻‍💻  데이터베이스에서 새로운 목록을 읽어와 어댑터에 세팅하고 갱신. 새로 생성되는 메모에는 번호가 자동으로 입력되므로 번호를 갱신하기 위해서 새로운 데이터를 세팅하는 것

 

binding.editMemo.setText("")

 

  👩🏻‍💻  메모 내용을 입력하는 위젯의 내용을 지워서 초기화

 


3.  삭제 버튼 추가하기

메모 목록에 삭제 버튼을 추가하여 메모를 삭제할 수 있도록 만듦


1) item_recycler.xml 파일을 열고 목록 아이템의 우측에 삭제 버튼을 배치

  • id : buttonDelete
  • text : 삭제

 


 

2) 메모를 삭제하려면 SQLite의 데이터와 어댑터에 있는 Memo 컬렉션의 데이터를 삭제


  ✓  SQLite의 데이터를 삭제하기 위해서 MainActivity.kt를 열고 클래스의 두 번째 줄에 생성해 둔 helper를 어댑터에 전달
  ✓  어댑터 생성 코드 바로 아랫줄에 작성하는데 어댑터에는 아직 helper 프로퍼티가없기 때문에 빨간색으로 나타남

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(binding.root)

    val adapter = RecyclerAdapter()
    adapter.helper = helper // 추가 코드

3) RecyclerAdapter.kt를 열고 클래스 블록 가장 윗줄에 helper 프로퍼티를 만듦

class RecyclerAdapter : RecyclerView.Adapter<Holder>() {
    var helper: SQLiteHelper? = null // 추가 코드
    val listData = mutableListOf<Memo>()

 


4) RecyclerAdapter.kt의 Holder 클래스에 init 블록을 만듦

 

  ✓  추가한 buttonDetele에 클릭리스너를 달아줌

class Holder(val binding: ItemRecyclerBinding) :RecyclerView.ViewHolder(binding.root) {
    init {
        binding.buttonDelete.setOnClickListener { }
    }

 


 

삭제 버튼을 클릭하면 어댑터의 helper와 listData에 접근해야 되는데, 지금은 어댑터 밖에 Holder 클래스가 있기 때문에 접근할 수 없음. Holder 클래스 전체를 어댑터 클래스 안으로 옮기고 class 앞에 inner 키워드를 붙여줌

  ➡️  Holder 클래스의 위치가 바뀌었기 때문에 RecyclerAdapter.Holder의 제네릭을 다시 import

inner class Holder(val binding: ItemRecyclerBinding: RecyclerView.ViewHolder(binding.root) {
    init {
        binding.buttonDelete.setOnClickListener {
    }
}

fun setMemo(memo: Memo) {
    binding.textNo.text = "${memo.num}"
    binding.textContent.text = memo.content
    val sdf = SimpleDateFormat("yyyy/MM/dd hh:mm")
    binding.textDatetime.text = sdf.format(memo.datetime)
}

 

class RecyclerAdapter : RecyclerView.Adapter<RecyclerAdapter.Holder> () { ... }

 


홀더는 한 화면에 그려지는 개수만큼 만든 후 재사용을 함으로 1번 메모가 있는 홀더를 스크롤해서 위로 올리면 아래에서 새로운 메모가 1번 홀더를 재사용하는 구조, 따라서 클릭하는 시에 어떤 데이터가 있는지 알야야 하므로 Holder 클래스의 init 위에 변수를 하나 선언하고 setMemo() 메서드로 넘어온 Memo를 임시로 저장

inner class Holder(val binding: ItemRecyclerBinding) :RecyclerView.ViewHolder(binding.root) {
    private var mMemo: Memo? = null
    init {
        binding.buttonDelete.setOnClickListener {}
    }

    fun setMemo(memo: Memo) {
        binding.textNo.text = "${memo.num}"
        binding.textContent.text = memo.content
        val sdf = SimpleDateFormat("yyyy/MM/dd hh:mm")
        binding.textDatetime.text = sdf.format(memo.datetime)
        this.mMemo = memo
    }
}

 


 

init 블록 안에 있는 buttonDelete의 클릭리스너 블록 안에서 SQLite의 데이터를 먼저 삭제하고, listData의 데이터도 삭제 후 어댑터를 갱신. deleteMemo()는 null을 허용하지 않는데, mMemo는 null을 허용하도록 설정되었기 때문에 !!를 사용해서 강제해야 함.

init {
    binding.buttonDelete.setOnClickListener {
        helper?.deleteMemo(mMemo!!)
        listData.remove(mMemo)
        notifyDataSetChanged()
    }
}

 


 

에뮬레이터에서 실행하고 테스트 : 메모 데이터 하나를 삭제한 후 앱을 껐다 켰을 때도 삭제되어 있으면 정상적으로 동작하는 것

 

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]


💡  간단한 메모를 저장하고 사용하는 프로젝트 

 

  이 프로젝트에서 메모 데이터를 저장하고 사용할 테이블을 다음처럼 설계

CREATE TABLE memo (
    num INTEGER PRIMARY KEY,
    content TEXT,
    datetime INTEGER
)

 

 -  SQLite 에서는 INTEGER 타입이 primary key 이면 숫자가 자동 증가됨


1.  SQLiteOpenHelper 사용하기

  👾  SQLiteOpenHelper 클래스는 데이터베이스를 파일로 생성하고 코틀린 코드에서 사용할 수 있도록 데이터베이스와 연결하는 역할

 

1) 프로젝트 생성 후 [app] - [java] 디렉토리 밑에 있는 패키지에 SQLiteHelper 클래스를 생성


2) 클래스 정의 및 생성자

 

-  SQLite 데이터베이스를 사용하려면 SQLiteOpenHelper 클래스를 상속받아야 함

SQLiteOpenHelper는 생성 시에 Context, 데이터베이스명, 팩토리, 버전 정보가 필요. 팩토리는 사용하지 않아도 되므로 나머지 세 가지 정보를 내가 만든 클래스의 생성자에 파라미터로 정의한 후에 상속받은 SQLiteOpenHelper에 전달.

class SQLiteHelper(context: Context, name: String, version: Int):
    SQLiteOpenHelper(context, name, null, version)  {

 

3) SQLiteOpenHelper 메서드를 구현

class SQLiteHelper(context: Context, name: String, version: Int):
    SQLiteOpenHelper(context, name, null, version)  {
    override fun onCreate(p0: SQLiteDatabase?) {
        // 데이터베이스가 처음 생성될 때 호출
    }

    override fun onUpgrade(p0: SQLiteDatabase?, p1: Int, p2: Int) {
        // 데이터베이스 업그레이드 시 호출
    }

 

4) 아직 데이터베이스가 생성되지 않았기 때문에 onCreate() 메서드에서 테이블을 생성

  -  이 메서드 안에 테이블 생성 쿼리를 작성하고 실행. 데이터베이스가 생성되어 있으면 더 이상 실행되지 않음. onCreate() 메서드 안에 앞에서 만든 테이블 생성 쿼리를 문자열로 입력한 후, db의 execSQL() 메서드에 전달해서 실행

override fun onCreate(p0: SQLiteDatabase?) {
    val sql = "CREATE TABLE `memo` (`num` INTEGER PRIMARY KEY," +
        "`content` TEXT, `datetime` INTEGER)"
        p0?.execSQL(sql)
}

 

5) Memo 데이터 클래스

 

  -  SQLiteHelper 클래스의 바깥에 Memo 클래스를 하나 생성하고 정의

        ➡️  이 클래스는 SQLite 데이터베이스와 상호작용하여 메모 데이터를 관리하는 기본적인 CRUD(생성, 읽기, 업데이트, 삭제) 작업을 수행할 수 있도록 함

data class Memo(var num: Long?, var content: String, var datetime: Long)

 

num과 datetime의 타입을 데이터베이스에서는 INTEGER로 정의했는데, 여기서는 Long. 숫자의 범위가 서로 다르기 때문.
특별한 이유가 없다면 SQLite에서 INTEGER로 선언한 것은 소스 코드에서는 Long으로 사용.
그리고 num만 null을 허용한 것은 PRIMARY KEY 옵션으로 값이 자동으로 증가 되기 때문에 데이터 삽입 시에는 필요하지 않음.

 


2. 삽입 메서드

1) SQLiteOpenHelper를 이용해서 값을 입력할 때는 코틀린의 Map 클래스처럼 키, 값 형태로 사용되는 ContentValues 클래스를 사용

 

  👾  ContentValues에 put("컬럼명", 값)으로 저장

fun insertMemo(memo: Memo) {
    val value = ContentValues()
    value.put("content", memo.content)
    value.put("datetime", memo.datetime)
}

 

2) 상속받은 SQLiteOpenHelper에 이미 구현된 writableDatabase에 테이블명과 함께 앞에서 작성한 값을 전달해서 insert()하고, 사용한 후에는 close()를 호출하여 꼭 닫아줘야 함

fun insertMemo(memo: Memo) {
    val value = ContentValues()
    value.put("content", memo.content)
    value.put("datetime", memo.datetime)

    writableDatabase.insert("memo", null, value)
    writableDatabase.close()
}

 

  -  첫 번째 파라미터 : 테이블 이름 / 두 번째 파라미터 : 값이 null 일 때 입력 방지 / 세 번째 파라미터 : 삽입할 데이터를 포함하는 ContentValues 객체


3.  조회 메서드

1)  조회 메서드는 반환값이 있으므로 메서드의 가장 윗줄에 반환할 값을 변수로 선언하고, 가장 아랫줄에 반환하는 코드를 작성한 후 그 사이에 구현 코드를 작성하는 것이 좋음

fun selectMemo(): MutableList<Memo> {
    val list = mutableListOf<Memo>()
    
    return list
}

2) 메모의 전체 데이터를 조회하는 쿼리를 작성

val sql = "SELECT * FROM memo"

3) 읽기 전용 데이터베이스를 변수에 담음

val rd = readableDatabase

 

4) 데이터베이스의 rawQuery() 메서드에 앞에서 작성해둔 쿼리를 담아서 실행하면 커서 cursor 형태로 반환

 val cursor = rd.rawQuery(sql, null)

 

커서 Cursor


데이터셋을 처리할 때 현재 위치를 포함하는 데이터 요소. 커서를 사용하면 쿼리를 통해 반환된 데이터셋을 반복문으로 반복하며 하나씩 처
리할 수 있음. 반복할 때마다 커서가 현재 위치를 가르키고 있어 [데이터 읽기 -> 다음 줄 이동]의 단순 로직으로 데이터를 쉽게 처리할 수 있음.


 

5) 커서의 moveToNext() 메서드가 실행되면 다음 줄에 사용할 수있는 레코드가 있는지 여부를 반환하고, 해당 커서를 다음 위치로 이동. 레코드가 없으면 반복문을 빠져나감. 모든 레코드를 읽을 때까지 반복.

while(cursor.moveToNext()) { }

 

6) 반복문을 돌면서 테이블에 정의된 3개의 컬럼에서 값을 꺼낸 후 각각 변수에 담음

val num = cursor.getLong(0)
val content = cursor.getString(1)
val datetime = cursor.getLong(2)

 


 

7) 앞에서 변수에 저장해두었던 값들로 Memo 클래스를 생성하고 반환할 목록에 더함

list.add(Memo(num, content, datetime))

 

8) while 문의 블록 밖에서 커서와 읽기 전용 데이터베이스를 모두 닫아 줌

cursor.close()
rd.close()

    fun selectMemo(): MutableList<Memo> {
        val list = mutableListOf<Memo>()
        val sql = "SELECT * FROM memo"
        val rd = readableDatabase

        val cursor = rd.rawQuery(sql, null)
        while(cursor.moveToNext()) {
            val num = cursor.getLong(0)
            val content = cursor.getString(1)
            val datetime = cursor.getLong(2)
            list.add(Memo(num, content, datetime))
        }
        cursor.close()
        rd.close()
        return list
    }

 


4. 수정 메서드

1) INSERT와 동일하게 ContentValues를 사용해서 수정할 값을 저장

 fun updateMemo(memo: Memo) {
        val values = ContentValues()
        values.put("content", memo.content)
        values.put("datetime", memo.datetime)
 }

 

2) writableDatabase의 update() 메서드를 사용하여 수정한 다음 close()를 호출


update() 메서드의 파라미터는 총 4개인데 (테이블명, 수정할 값, 수정할 조건)
수정할 조건은 PRIMARY KEY로 지정된 컬럼을 사용하며 여기에서는 PRIMARY KEY인 컬럼이 num이기 때문에 'num = 숫자'가 됨.
세 번째 값을 'num = ?'의 형태로 입력하고, 네 번째 ?에 매핑할 값을 arrayOf("${memo.num}")의 형태로 전달할 수도 있음. 여기서는 세 번째에 조건과 값을 모두 할당했기 때문에 네 번째에 null을 사용

fun updateMemo(memo: Memo) {
    val values = ContentValues()
    values.put("content", memo.content)
    values.put("datetime", memo.datetime)

    val wd = writableDatabase
    wd.update("memo", values, "num = ${memo.num}", null)
    wd.close()
}

 


5. 삭제 메서드

SQLiteOpenHelper 클래스를 사용하면 insert(), update() 메서드의 사용법만 알면 쿼리를 몰라도 데이터베이스를 사용할 수 있음. 하지만 복잡한 데이터베이스를 다룰 때는 쿼리를 직접 작성하면 데이터를 더 정밀하게 다룰 수 있으므로 쿼리를 공부하는 것이 중요. 삭제 메서드 (DELETE)는 쿼리를 직접 입력해서 데이터를 삭제하는 코드로 작성.


1) 데이터 삭제 메서드를 작성

  -  조건식은 '컬럼명 = 값'의 형태가 됨. 삭제 쿼리를 작성하고 변수에 저장

 fun deleteMemo(memo: Memo) {
        val sql = "DELETE FROM memo WHERE num=${memo.num}"
 }

 

2) writableDatabase의 execSQL() 메서드로 쿼리를 실행한 후 close()를 호출. execSQL() 메서드로 쿼리를 직접 실행할 수 있음

fun deleteMemo(memo: Memo) {
        val sql = "DELETE FROM memo WHERE num=${memo.num}"
        val wd = writableDatabase
        wd.execSQL(sql)
        wd.close()
}

 

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]


📌  구글 플레이 서비스의 Google Maps API를 사용하면 구글 지도 데이터를 기반으로 앱에 지도를 추가할 수 있음

      -  구글 지도는 Google Maps Platform 서비스 중의 하나이며, 교통정보 기반의 경로 찾기와 장소 정보, 검색 등의 기능을 제공.
      -  국내에서는 구글 지도의 경로찾기 메뉴중 버스만 사용할 수 있음.

 

1.  구글 지도 시작하기

 

 

안드로이드 스튜디오는 구글 지도를 쉽게 사용할 수 있도록 프로젝트 생성 시 프로젝트의 종류를 선택하는 메뉴에서 Google Maps Activity를 제공

 

  ➡️  MainActivity 대신 MapsActivity가 생성됨

 

 

 

 

 

 

 

Google Maps API 키 설정


구글 지도를 포함한 구글 플레이 서비스에 엑세스하려면 구글 플레이 서비스의 API 키가 필요
이전에는 google_maps_api.xml 파일이 자동 생성되고, 해당 파일에 API키를 입력했으나, 범블비와 칩멍크 버전 부터는 AndroidManifest.xml에 API키를 입력하도록 변경이 됨

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

    To get one, follow the directions here:

    https://developers.google.com/maps/documentation/android-sdk/get-api-key

    Once you have your API key (it starts with "AIza"), define a new property in your
    project's local.properties file (e.g. MAPS_API_KEY=Aiza...), and replace the
    "YOUR_API_KEY" string in this file with "${MAPS_API_KEY}".
-->
    <meta-data
        android:name="com.google.android.geo.API_KEY"
        android:value="YOUR_API_KEY" />

 

 

키를 입력 후에 안드로이드 스튜디오에서 앱을 빌드하고 시작하면, 시드니에 마커가 표시된 지도를 표시

 

  📍 지도가 안 뜨면 구글 cloud 접속 하여 API key 수정에서 패키지를 추가해주면 됨 !  ▶️ 패키지명은 무조건 소문자 !!

 

 


2.  구글 지도 코드 살펴보기

activity_maps.xml의 SupportMapFragment

 

프로젝트를 생성하면 activity_maps.xml 파일이 자동 생성됨. 코드를 보면 android:name에 "com.google.android.gms.maps.SupportMapFragment"가 설정되어 있음.

Google Maps API는 SupportMapFragment에 구글 지도를 표시.

<?xml version="1.0" encoding="utf-8"?>
<fragment xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:map="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/map"
    android:name="com.google.android.gms.maps.SupportMapFragment"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MapsActivity" />

 

 

MapsActivity.kt의 SupportMapFragment.getMapAsync
 override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = ActivityMapsBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // Obtain the SupportMapFragment and get notified when the map is ready to be used.
        val mapFragment = supportFragmentManager
            .findFragmentById(R.id.map) as SupportMapFragment
        mapFragment.getMapAsync(this)
    }

 

MapsActivity.kt 파일을 열면 onCreate() 메서드 블록 안에서는 supportFragmentManager의 findFragmentById() 메서드로 id가 map인 SupportMapFragment를 찾은 후 getMapAsync()를 호출해서 안드로이드에 구글 지도를 그려달라는 요청을 함.

 

MapsActivity.kt의 OnMapReadyCallback

 

class MapsActivity : AppCompatActivity(), OnMapReadyCallback { }

 

안드로이드는 구글 지도가 준비되면 OnMapReadyCallback 인터페이스의 onMapReady() 메서드를 호출하면서 파라미터로 준비된 GoogleMap을 전달해 줌.

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))
    }

 

메서드 안에서 미리 선언된 mMap 프로퍼티에 GoogleMap을 저장해두면 액티비티 전체에서 맵을 사용할 수 있음.
구글에서 기본으로 제공하는 시드니의 좌표 코드가 있음.

 

* API Level 12 이하 버전에서의 호환성이 필요없다면 SupportMapFragment 대신 MapFragment를 사용할 수 있음.
* 스마트폰에 구글 플레이 서비스가 설치되어 있지 않으면 사용자가 구글 플레이 서비스를 설치할 때까지 onMapReady() 메서드가 호출되지 않음.

 


3.  카메라와 지도 뷰

구글 지도에서는 카메라를 통해 현재 화면의 지도 뷰를 변경할 수 있음.
  ✓  지도 뷰는 평면에서 아래를 내려다 보면서 모델링 되며 카메라의 포지션은 위도/경도, 방위, 기울기 및 확대/축소 속성으로 지정
  ✓  카메라의 위치는 CameraPosition 클래스에 각종 옵션을 사용해서 조절할 수 있음

CameraPosition.Builder().옵션1.옵션2.build()

Target


    카메라의 목표 지점 Target은 지도 중심의 위치이며 위도 및 경도 좌표로 지정

CameraPosition.Builder().target(LatLng(-34.0, 151.0))

 


Zoom

 

  ✓  카메라의 줌 Zoom (확대/축소) 레벨에 따라 지도의 배율이 결정. 줌 레벨이 높을 수록 더 자세한 지도를 볼 수 있는 반면, 줌 레벨이 작을수록 더 넓은 지도를 볼 수 있음.

CameraPosition.Builder().zoom(15.5f)

 

 

📍  줌 레벨이 0인 지도의 배율은 전 세계의 너비가 약 256dp가 되며 레벨의 범위는 다음과 같음

레벨 설명
1.0 세계
5.0 대륙
10.0 도시
15.0 거리
20.0 건물

 


Bearing


  ✓  카메라의 베어링 Bearing은 지도의 수직선이 북쪽을 기준으로 시계 방향 단위로 측정되는 방향.

CameraPosition.Builder().bearing(300f)

Tilt


  ✓  카메라의 기울기 Tilt는 지도의 중앙 위치와 지구 표면 사이의 원호에서 카메라 위치를 지정
  ✓  기울기로 시야각을 변경하면 멀리 떨어진 지형이 더 작게 나타나고 주변 지형이 더 켜져 맵이 원근으로 나타남

CameraPosition.Builder().tilt(50f)

 


4.  소스 코드에서 카메라 이동하기

옵션을 이용해서 CameraPosition 객체를 생성하고 moveCamera() 메서드로 카메라의 위치를 이동시켜 지도을 변경할 수 있음.
  ➡️  MapsActivity.kt 파일의 onMapReady() 메서드 안에 작성.

CameraPosition.Builder 객체로 카메라 포지션을 설정


  📍  build() 메서드를 호출해서 CameraPosition 객체를 생성

val cameraPosition = CameraPosition.Builder()
    .target(LATLNG)
    .zoom(15.0f)
    .build()

 

CameraUpdateFactory.newCameraPosition() 메서드에 CameraPosition 객체를 전달하면 카메라 포지션에 지도에서 사용할 수 있는 카메라 정보가 생성
val cameraUpdate =
    CameraUpdateFactory.newCameraPosition(cameraPosition)

 

변경된 카메라 정보를 GoogleMap의 () 메서드에 전달하면 카메라 포지션을기준으로 지도의 위치, 배율, 기울기 등이 변경되어 표시
mMap.moveCamera(cameraUpdate)

 


5.  마커

마커 Marker는 지도에 위치를 표시. 아이콘의 색상, 이미지, 위치를 변경할 수 있으며 대화식으로 설계되었기 때문에 마커를 클릭하면 정보창을 띄우거나 클릭리스너처럼 클릭에 대한 코드 처리를 할 수 있음.

 

마커 표시하기

 

  1. mMap = googleMap 코드 아래에 특정 장소의 위도와 경도 좌표값으로 LatLng 객체를 생성

val LATLNG = LatLng(35.8715, 128.6017)

 

  2.  마커를 추가하려면 마커의 옵션을 정의한 MarkerOptions 객체가 필요.

        MarkerOptions 객체를 생성하고 머커의 좌표와 제목을 설정.

val markerOptions = MarkerOptions()
    .position(LATLNG)
    .title("Marker")

 

  3. GoogleMap 객체의 addMarker() 메서드에 MarkerOptions를 전달하면 구글 지도에 마커가 추가.

mMap.addMarker(markerOptions)

 

  4. 카메라를 마커의 좌표로 이동하고 줌을 거리 레벨로 확대

val cameraPosition = CameraPosition.Builder()
    .target(LATLNG)
    .zoom(15.0f)
    .build()

val cameraUpdate =
    CameraUpdateFactory.newCameraPosition(cameraPosition)
    mMap.moveCamera(cameraUpdate)

 

  5. MarkerOptions 객체의 title(), snippet() 메서드로 정보 창을 수정할 수 있으며 마커를 클릭하면 정보창이 표시

val markerOptions = MarkerOptions()
    .position(LATLNG)
    .title("Daegu City Hall")
    .snippet("35.8715, 128.6017")

 

 

마커 아이콘 변경하기

 

마커 아이콘은 기본적으로 제공되는 아이콘뿐만 아니라 비트맵 이미지로 변경할수 있음.
PNG 이미지 파일을 프로젝트에 추가하고 비트맵으로 변환해서 아이콘으로 변경.

 

  1. drawable 디렉토리에 마커 아이콘으로 적용할 PNG 이미지 파일을 추가

  2. onMapReady() 안에 아래의 코드를 추가

val bitmapDrawable: BitmapDrawable

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
    bitmapDrawable = getDrawable(R.drawable.marker) as BitmapDrawable
} else {
    bitmapDrawable = resources.getDrawable(R.drawable.marker) as BitmapDrawable
}

 

  3. BitmapDescriptorFactory.fromBitmap() 메서드에 BitmapDrawable의 비트맵 객체를 전달하는 마커 아이콘을 위한
BitmapDescriptor 객체를 생성하고 import

var discriptor =
    BitmapDescriptorFactory.fromBitmap(bitmapDrawable.bitmap)

 

  4. MarkerOptions 객체의 icon() 메서드를 호출해서 BitmapDescriptor 객체의 아이콘을 마커에 적용하도록 수정

val markerOptions = MarkerOptions()
    .position(LATLNG)
    .icon(discriptor)
mMap.addMarker(markerOptions)

 

  * 아이콘의 크기가 클 경우 Bitmap.createScaledBitmap() 메서드를 호출해서 크기를 줄인 비트맵 객체를 반환받아야 함

 


6.  현재 위치 검색하기

스마트폰처럼 모바일 환경에서는 사용자가 위치를 이동하고 그 위치를 기반으로하는 서비스를 제공할 수 있음.
  ✓  앱에서 스마트폰의 현재 위치를 검색하려면 위치 권한이 필요
  ✓  안드로이드 플랫폼은 현재 위치를 검색하는 FusedLocationProviderClinet API를 제공
        ➡️  FusedLocationProviderClinet API는 GPS Global Positioning System 신호 및 와이파이와 통신사 네트워크 위치를 결합해서 최소한의 배터리 사용량으로 빠르고 정확하게 위치를 검색

 

Google Play Service 의존성 추가하기


  FusedLocationProviderClinet API를 사용하기 위해서 build.gradle 파일에 구글 플레이 서비스의 Loacation 라이브러리 의존성을 추가. Location 라이브러리는 Maps 라이브러리와 버전이 같아야 함. Location 라이브러리와 같아지도록 Maps의 라이브러리 버전을 맞춰줌.

implementation 'com.google.android.gms:play-services-location:18.2.0'
implementation 'com.google.android.gms:play-services-maps:18.2.0'

 

권한을 명세하고 요청/ 처리하기

 

  스마트폰의 위치 기능에 접근하기 위해 AndroidManifest.xml 파일에 위치 권한을 선언. 위치 권한은 두 가지가 있으며 기능은 다음과 같음.

<!-- 도시 블록 내에서 정확한 위치 (네트워크 위치) -->
<uses-permission
android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<!-- 정확한 위치 확보 (네트워크 위치 + GPS 위치) -->
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION"/>

 

  MapsActivity에 OnMapReadyCallback 인터페이스를 상속 받음.

class MapsActivity : AppCompatActivity(), OnMapReadyCallback {}

 

  권한 처리를 위해서 onCreate() 메서드 위에 런처를 선언. 여기서는 한 번에 2개의 권한에 대한 승인을 요청하기 때문에 Contract로 RequestMultiplePermissions()를 사용해야 함. 따라서 런처의 제네릭은 문자열 배열인 <Array<String>>이 됨

lateinit var locationPermission: ActivityResultLauncher<Array<String>>

 

  onCreate() 메서드 아래에 빈 startProcess() 메서드를 미리 만들어 둠. 

fun startProcess() {
    // 승인 후 실행할 코드를 입력
}

 

  onCreate() 메서드 안에 런처를 생성하는 코드를 작성하고 앞에서 선언해 둔 변수에 저장.

locationPermission =
    registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { 
        results -> if (results.all{ it.value }) {
                       startProcess()
                   } else {
                       Toast.makeText(this, "권한 승인이 필요합니다.",
                       Toast.LENGTH_LONG).show()
                   }
    }

 

  바로 아래줄에 런처를 실행해서 권한 승인을 요청. 2개의 권한을 파라미터에 전달해야되기 때문에 arrayOf()를 사용해서 권한 2개를 같이 launch()의 파라미터로 입력.

locationPermission.launch(arrayOf(
    Manifest.permission.ACCESS_COARSE_LOCATION,
    Manifest.permission.ACCESS_FINE_LOCATION
))

 

  위치 권한이 승인되면 startProcess() 메서드에서 구글 지도를 준비하는 작업을 진행하도록 코드를 수정. onCreate()에 작성되어 있는 val mapFragment 로 시작하는 세 줄을 잘라내기 한 후 startProcess() 메서드 안에 붙여넣기 하면 됨.

fun startProcess() {
    // 승인 후 실행할 코드를 입력
    // Obtain the SupportMapFragment and get notified when the map is ready to be used.
    val mapFragment = supportFragmentManager
        .findFragmentById(R.id.map) as SupportMapFragment
         mapFragment.getMapAsync(this)
}

 

  이제 권한이 모두 승인되고 맵이 준비되면 onMapReady() 메서드가 정상적으로 호출.

 


현재 위치 검색하기

 

 1. onCreate() 위에 onMapReady() 위치를 처리하기 위한 변수 2개를 선언
     ✓  fusedLocationClient는 위치값을 사용하기 위해서 필요하고, locationCallback은 위칫값 요청에 대한 갱신 정보를 받는 데 필요

private lateinit var fusedLocationClient: FusedLocationProviderClient
private lateinit var locationCallback: LocationCallback

 

 2. onMapReady() 안의 시드니 좌표 코드를 삭제한 다음, 위치 검색 클라이언트를 생성하는 코드를 추가하고 updateLocation() 메서드를 호출

override fun onMapReady(googleMap: GoogleMap) {
    mMap = googleMap
    fusedLocationClient =
        LocationServices.getFusedLocationProviderClient(this)
    updateLocation()
}

 

 3. updateLocation() 메서드를 작성.

  • 위치 정보를 요청할 정확도와 주기를 설정할 locationRequest를 먼저 생성하고, 해당 주기마다 반환받을 locationCallback을 생성
  • onMapReady에서 생성한 위치 검색 클라이언트의 requestLocationUpdates()에 앞에서 생성한 2개와 루퍼 정보를 넘겨줌
  • 이제 1초(1,000밀리초)에 한 번씩 변화된 위치정보가 LocationCallback의 onLocationResult()로 전달이 됨.
  • onLocationResult()는 반환받은 정보에서 위치 정보를 setLastLoation()으로 전달.
  • fusedLocationClient.requestLocationUpdates 코드는 권한 처리가 필요한데 현재 코드에서는 확인할 수 없음.
  • 따라서 메서드 상단에 해당 코드를 체크하지 않아도 된다는 의미로 @SuppressLint("MissingPermission") 애너테이션을 달아줌.
@SuppressLint("MissingPermission")
fun updateLocation() {
    val locationRequest = LocationRequest.create()
    locationRequest.run {
        priority = LocationRequest.PRIORITY_HIGH_ACCURACY
        interval = 1000
    }

locationCallback = object: LocationCallback() {
    override fun onLocationResult(locationResult: LocationResult?) {
        locationResult?.let {
            for ((i, location) in it.locations.withIndex()) {
                Log.d("Location", "$i ${location.latitude}, ${location.longitude}")
                setLastLoation(location)
            }
        }
    }
}

fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, Looper.myLooper())

 

 

4. updateLocation() 메서드 아래에 위치 정보를 받아서 마커를 그리고 화면을 이동하는 setLastLoation()을 작성

fun setLastLoation(lastLocatin: Location) { }

 

5. 전달받은 위치 정보로 좌표를 생성하고 해당 좌표로 마커를 생성

fun setLastLoation(lastLocatin: Location) {
    val LATLNG = LatLng(lastLocatin.latitude, lastLocatin.longitude)
    val markerOptions =
        MarkerOptions().position(LATLNG).title("Here!")
}

 

6. 카메라 위치를 현재 위치로 세팅하고 마커에 함께 지도에 반영.

    ✓  마커를 지도에 반영하기 전에 mMap.clear()를 호출해서 이전에 그려진 마커가있으면 지움

fun setLastLoation(lastLocatin: Location) {
    val LATLNG = LatLng(lastLocatin.latitude, lastLocatin.longitude)
    val markerOptions =
        MarkerOptions().position(LATLNG).title("Here!")

    val cameraPosition =
        CameraPosition.Builder().target(LATLNG).zoom(15.0f).build() 
    mMap.clear()
    mMap.addMarker(markerOptions)

    mMap.moveCamera(CameraUpdateFactory.newCameraPosition(cameraPosition)
}

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]

 

+ Recent posts