Retrofit 실습 예제 (1) 시도별 미세먼지 현황 앱

2024. 7. 31. 21:44[개발]/Kotlin 활용 앱 개발

1) 공공데이터 포털에서 API 인증 키 발급

https://www.data.go.kr/data/15073861/openapi.do

 

한국환경공단_에어코리아_대기오염정보

각 측정소별 대기오염정보를 조회하기 위한 서비스로 기간별, 시도별 대기오염 정보와 통합대기환경지수 나쁨 이상 측정소 내역, 대기질(미세먼지/오존) 예보 통보 내역 등을 조회할 수 있다.

www.data.go.kr

 

2) gradle에 라이브러리 추가(큰따옴표로 추가하기)

    implementation("com.google.code.gson:gson:2.10.1")
    implementation("com.squareup.retrofit2:retrofit:2.9.0")
    implementation("com.squareup.retrofit2:converter-gson:2.9.0")
    implementation("com.squareup.okhttp3:okhttp:4.10.0")
    implementation("com.squareup.okhttp3:logging-interceptor:4.10.0")
    implementation("com.github.skydoves:powerspinner:1.2.6")

 

3) manifest 파일 코드 추가

// 인터넷 사용 권한
	<uses-permission android:name="android.permission.INTERNET"/>

    <application
		...
        // HTTP 타입에 대한 접근 허용
        android:usesCleartextTraffic="true">

 

4) 코드 작성

// data class 

package com.example.miseya

import com.google.gson.annotations.SerializedName

// 실제 받는 json 파일과 아주 똑같이 data class 를 생성
data class Dust(val response: DustResponse)

data class DustResponse(
    // 원본 데이터의 이름을 카멜 표기법 형식으로 변환하기 위한 Serialize 작업
    // body, header: json 파일에서 가장 먼저 들어오는 데이터(response 밑에 바디, 헤더)
    @SerializedName("body")
    val dustBody: DustBody,
    @SerializedName("header")
    val dustHeader: DustHeader
)

data class DustBody(
    // body 안에 totalCount, dustItem, pageNo, numOfRows 데이터 존재
    val totalCount: Int,
    @SerializedName("items")
    val dustItem: MutableList<DustItem>?,
    val pageNo: Int,
    val numOfRows: Int
)

data class DustHeader(
    val resultCode: String,
    val resultMsg: String
)

data class DustItem(
    val so2Grade: String,
    val coFlag: String?,
    val khaiValue: String,
    val so2Value: String,
    val coValue: String,
    val pm25Flag: String?,
    val pm10Flag: String?,
    val o3Grade: String,
    val pm10Value: String,
    val khaiGrade: String,
    val pm25Value: String,
    val sidoName: String,
    val no2Flag: String?,
    val no2Grade: String,
    val o3Flag: String?,
    val pm25Grade: String,
    val so2Flag: String?,
    val dataTime: String,
    val coGrade: String,
    val no2Value: String,
    val stationName: String,
    val pm10Grade: String,
    val o3Value: String
)

 

package com.example.miseya

import retrofit2.http.GET
import retrofit2.http.QueryMap

interface NetWorkInterface {
    @GET("getCtprvnRltmMesureDnsty") //시도별 실시간 측정정보 조회 주소
    // HashMap 형태의 (키, 값)으로 Request Parameter들을 넣어 데이터를 요청 -> 리턴받는 데이터는 Dust(data class)
    suspend fun getDust(@QueryMap param: HashMap<String, String>): Dust
}

 

package com.example.miseya

//import com.example.miseya.BuildConfig
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit

object NetWorkClient {

    // 서비스 url값 입력
    private const val DUST_BASE_URL = "http://apis.data.go.kr/B552584/ArpltnInforInqireSvc/"


    private fun createOkHttpClient(): OkHttpClient {
        val interceptor = HttpLoggingInterceptor()

//        // 통신이 잘 안될 때 디버깅
//        if (BuildConfig.DEBUG)
//            interceptor.level = HttpLoggingInterceptor.Level.BODY
//        else
//            interceptor.level = HttpLoggingInterceptor.Level.NONE

        // Timeout Interval 부여
        return OkHttpClient.Builder()
            .connectTimeout(20, TimeUnit.SECONDS)
            .readTimeout(20, TimeUnit.SECONDS)
            .writeTimeout(20, TimeUnit.SECONDS)
            .addNetworkInterceptor(interceptor)
            .build()
    }

    // Retrofit은 항상 아래 형태로 정의하니까 기억해놓고 써먹기
    private val dustRetrofit = Retrofit.Builder()
        .baseUrl(DUST_BASE_URL)
        // json을 gson으로 컨버팅: 어지러운 원본 json 파일을 깔끔한 data class 파일로 바꿔줌
        .addConverterFactory(GsonConverterFactory.create())
        .client(createOkHttpClient()
        ).build()

    // NetworkInterface에서 Retrofit 생성
    val dustNetWork: NetWorkInterface = dustRetrofit.create(NetWorkInterface::class.java)

}

 

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main_bg"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#9ED2EC"
    tools:context=".MainActivity">


    <com.skydoves.powerspinner.PowerSpinnerView
        android:id="@+id/spinnerView_sido"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:background="#27FF00"
        android:foreground="?attr/selectableItemBackground"
        android:gravity="center"
        android:hint="도시 선택"
        android:padding="10dp"
        android:textColor="#FFFFFF"
        android:textColorHint="#FFFFFF"
        android:textSize="14.5sp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintEnd_toStartOf="@+id/spinnerView_goo"
        app:spinner_arrow_gravity="end"
        app:spinner_arrow_tint="#FFDD00"
        app:spinner_divider_color="#FFFFFF"
        app:spinner_divider_show="true"
        app:spinner_divider_size="0.4dp"
        app:spinner_item_array="@array/sido_array"
        app:spinner_item_height="46dp"
        app:spinner_popup_animation="normal"
        app:spinner_popup_background="#999999"
        app:spinner_popup_elevation="14dp"
        tools:ignore="HardcodedText,UnusedAttribute" />

    <com.skydoves.powerspinner.PowerSpinnerView
        android:id="@+id/spinnerView_goo"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:background="#38FF00"
        android:foreground="?attr/selectableItemBackground"
        android:gravity="center"
        android:hint="지역 선택"
        android:padding="10dp"
        android:textColor="#999999"
        android:textColorHint="#999999"
        android:textSize="14.5sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@+id/spinnerView_sido"
        app:layout_constraintTop_toTopOf="parent"
        app:spinner_arrow_gravity="end"
        app:spinner_arrow_tint="#FFEB3B"
        app:spinner_divider_color="#999999"
        app:spinner_divider_show="true"
        app:spinner_divider_size="0.4dp"
        app:spinner_item_height="46dp"
        app:spinner_popup_animation="normal"
        app:spinner_popup_elevation="14dp"
        tools:ignore="HardcodedText,UnusedAttribute" />


    <ImageView
        android:id="@+id/iv_face"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:layout_marginEnd="8dp"
        android:layout_marginBottom="8dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:srcCompat="@drawable/mise1" />

    <TextView
        android:id="@+id/tv_p10value"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:text=" - ㎍/㎥"
        android:textSize="18sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/iv_face" />

    <TextView
        android:id="@+id/tv_p10grade"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"
        android:text=""
        android:textColor="#048578"
        android:textSize="30sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/tv_p10value" />

    <TextView
        android:id="@+id/tv_cityname"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="50dp"
        android:layout_marginEnd="8dp"
        android:layout_marginBottom="8dp"
        android:text="도시를 선택해 주세요."
        android:textColor="#242323"
        android:textSize="36sp"
        android:textStyle="bold"
        app:layout_constraintBottom_toTopOf="@+id/iv_face"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/tv_date"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="16dp"
        android:layout_marginEnd="8dp"
        android:text=""
        android:textSize="18sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/tv_cityname" />
</androidx.constraintlayout.widget.ConstraintLayout>

 

package com.example.miseya

import android.graphics.Color
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.example.miseya.databinding.ActivityMainBinding
import com.skydoves.powerspinner.IconSpinnerAdapter
import kotlinx.coroutines.launch

class MainActivity : AppCompatActivity() {
    private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) }
    var items = mutableListOf<DustItem>()

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

        // setOnSpinnerItemSelectedListener: oldItem, oldIndex, NewIndex, NewItem 받음
        // NewItem으로 선택한 텍스트(spinner 안에서 선택한 시/도 텍스트)가 communicateNetWork(setUpDustParameter()) 에 들어감
        binding.spinnerViewSido.setOnSpinnerItemSelectedListener<String> { _, _, _, text ->
            // setUpDustParameter는 (키, 값)으로 매칭된 HashMap형태의 데이터를 반환
            // communicateNetWork는 이 데이터를 인자로 받아
            communicateNetWork(setUpDustParameter(text))
        }

        // 구 값이 선택됐을 때 동작
        binding.spinnerViewGoo.setOnSpinnerItemSelectedListener<String> { _, _, _, text ->

            Log.d("miseya", "selectedItem: spinnerViewGoo selected >  $text")
            // 구이름(text)에 해당하는 item을 selectedItem으로 선언
            var selectedItem = items.filter { f -> f.stationName == text }
            Log.d("miseya", "selectedItem: sidoName > " + selectedItem[0].sidoName)
            Log.d("miseya", "selectedItem: pm10Value > " + selectedItem[0].pm10Value)

            binding.tvCityname.text = selectedItem[0].sidoName + "  " + selectedItem[0].stationName
            binding.tvDate.text = selectedItem[0].dataTime
            binding.tvP10value.text = selectedItem[0].pm10Value + " ㎍/㎥"

            when (getGrade(selectedItem[0].pm10Value)) {
                1 -> {
                    binding.mainBg.setBackgroundColor(Color.parseColor("#9ED2EC"))
                    binding.ivFace.setImageResource(R.drawable.mise1)
                    binding.tvP10grade.text = "좋음"
                }

                2 -> {
                    binding.mainBg.setBackgroundColor(Color.parseColor("#D6A478"))
                    binding.ivFace.setImageResource(R.drawable.mise2)
                    binding.tvP10grade.text = "보통"
                }

                3 -> {
                    binding.mainBg.setBackgroundColor(Color.parseColor("#DF7766"))
                    binding.ivFace.setImageResource(R.drawable.mise3)
                    binding.tvP10grade.text = "나쁨"
                }

                4 -> {
                    binding.mainBg.setBackgroundColor(Color.parseColor("#BB3320"))
                    binding.ivFace.setImageResource(R.drawable.mise4)
                    binding.tvP10grade.text = "매우나쁨"
                }
            }
        }
    }

    // communicateNetWork는 onCreate() 안의 메인 스레드 안에서 돌지 못함
    // Why? -> 통신 도중에 렉이 걸리면 메인 화면이 멈춰버리고 화면 중지가 되고 에러가 뜸. 이걸 방지하려고 BG에서 돌게 지정
    // -> 코루틴으로 돌게 함
    private fun communicateNetWork(param: HashMap<String, String>) = lifecycleScope.launch() {
        // 응답받는 데이터 = Retrofit(NetworkClient)의 dustNetwork를 통해 getDust()를 실행, 데이터 받아오기
        val responseData = NetWorkClient.dustNetWork.getDust(param)
        Log.d("Parsing Dust ::", responseData.toString())

        val adapter = IconSpinnerAdapter(binding.spinnerViewGoo)

        // 필요한 데이터만 빼서 List 형태로 따로 지정
        // Ex) '서울' -> 서울에 해당하는 item들만 빼기
        items = responseData.response.dustBody.dustItem!!

        // 두 번째 스피너에 넣을 구 이름들 정의
        val goo = ArrayList<String>()
        items.forEach {
            Log.d("add Item :", it.stationName)
            goo.add(it.stationName)
        }

        // 코루틴의 별도 스레드에서 돌아가는 중이라 UI를 건드릴 수가 없음(여기서는 스피너)
        // -> 아래 코드 따로 넣어줘야 함
        runOnUiThread {
            binding.spinnerViewGoo.setItems(goo)
        }

    }

    // Request Parameter 생성
    private fun setUpDustParameter(sido: String): HashMap<String, String> {
        // (키, 값)으로 매칭된 HashMap형태의 데이터 반환
        // 인증키 선언
        val authKey = "YlgP80NwhNBwLETbohJrUha7ygmEO09Y35mxU/uyz0N90+HesPbErivbWW5/8bp6aNZxNP8HwPo9WokX3r6O8w=="

        // 요청변수명과 동일해야 함
        // 요청변수명과 value를 매칭하여 입력
        return hashMapOf(
            "serviceKey" to authKey,
            "returnType" to "json",
            "numOfRows" to "100",
            "pageNo" to "1",
            "sidoName" to sido,
            "ver" to "1.0"
        )
    }

    fun getGrade(value: String): Int {
        val mValue = value.toInt()
        var grade = 1
        grade = if (mValue >= 0 && mValue <= 30) {
            1
        } else if (mValue >= 31 && mValue <= 80) {
            2
        } else if (mValue >= 81 && mValue <= 100) {
            3
        } else 4
        return grade
    }
}

 

★ 앱 아이콘 커스터마이징하는 방법 ★

1) res → New → Image Asset 클릭

2) Path에서 원하는 이미지 파일 선택

3) Resize, background 등 원하는 스타일로 커스터마이즈

4) 완료하면 mipmap → ic_launcher 파일에 반영되어 있음