코틀린(Kotlin)/해보기

[코틀린(Kotlin)] 미세먼지 앱 만들어보기

초보왕보초 2024. 1. 30. 21:16
728x90
  • 특정 지역의 미세 먼지 수치, 등급을 알려주도록 한다.
  • 데이터는 공공데이터포털을 이용한다.
  • 수신된 JSON 데이터를 가공한다.
  • 미세먼지 등급에 따라 아이콘/배경색을 변경한다.

 

 

공공데이터 포털 접속 후 환국환경공단_에어코리아_대기오염정보의 API 활용

 

그 중에서, 시도별 실시간 측정정보 조회을 활용

 

미세먼지 등급에 따라 나뉘는 아이콘 및 배경색의 기준

 

 

 

Gradle에 라이브러리 추가

plugins {
	...
    id("kotlin-kapt")
}
...
buildFeatures {
	viewBinding = true
    dataBinding = true
    buildConfig = true
}

dependencies {
	...
    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") // Gson 컨버터 추가
    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")
}

retrofit은 안드로이드에서 REST api 통신을 지원하기 위한 라이브러리이다.

기존의 사용자가 http 통신을 위해 HttpURLCConnection을 성립시키고 결과를 받아오는 과정이 다소 복잡하다.

여기에 더해서 AsnyncTask가 deprecated되면서 서버와의 비동기 통신을 위해 골머리를 앓게 되는데, retrofit은 Call 인터페이스의 enqueue를 통해 비동기 구현이 가능하다.
(Call 인터페이스의 enqueue를 이용하지 않고 rxjava, rxkotlin을 사용하여 비동기 처리할 수도 있다.)

 

 

AndroidManifest.xml

더보기
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

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

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.Ex_Data_Retrofit"
        tools:targetApi="31">
        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>
  • https 통신을 하기 때문에 인터넷 사용권한 추가 필요
    (안드로이드 9.0 파이부터는 http → https로 변경)

 

 

activity_main.xml

더보기
<?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="#40C33C"
        android:elevation="14dp"
        android:foreground="?attr/selectableItemBackground"
        android:gravity="center"
        android:hint="도시 선택"
        android:padding="10dp"
        android:textColor="@color/white"
        android:textColorHint="#1840D3"
        android:textSize="16sp"
        app:layout_constraintEnd_toStartOf="@+id/spinnerView_goo"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:spinner_arrow_gravity="end"
        app:spinner_arrow_tint="#FBE200"
        app:spinner_divider_color="@color/white"
        app:spinner_divider_show="true"
        app:spinner_divider_size="0.4dp"
        app:spinner_item_array="@array/Sido"
        app:spinner_item_height="46dp"
        app:spinner_popup_animation="normal"
        app:spinner_popup_background="#085020"
        tools:ignore="HardcodedText,UnusedAttribute" />

    <com.skydoves.powerspinner.PowerSpinnerView
        android:id="@+id/spinnerView_goo"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:background="#40C33C"
        android:elevation="14dp"
        android:foreground="?attr/selectableItemBackground"
        android:gravity="center"
        android:hint="지역 선택"
        android:padding="10dp"
        android:textColor="@color/white"
        android:textColorHint="#1840D3"
        android:textSize="16sp"
        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="#FBE200"
        app:spinner_divider_color="@color/white"
        app:spinner_divider_show="true"
        app:spinner_divider_size="0.4dp"
        app:spinner_item_height="46dp"
        app:spinner_popup_animation="normal"
        app:spinner_popup_background="#085020"
        tools:ignore="HardcodedText,UnusedAttribute" />

    <ImageView
        android:id="@+id/iv_face"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="8dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:srcCompat="@drawable/ic_good" />

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

 

 

strings.xml

더보기
<resources>
    <string name="app_name">ex_Data_Miseya</string>

    <string-array name="Sido">
        <item>전국</item>
        <item>서울</item>
        <item>부산</item>
        <item>대구</item>
        <item>인천</item>
        <item>광주</item>
        <item>대전</item>
        <item>울산</item>
        <item>경기</item>
        <item>강원</item>
        <item>충북</item>
        <item>충남</item>
        <item>전북</item>
        <item>전남</item>
        <item>경북</item>
        <item>경남</item>
        <item>제주</item>
        <item>세종</item>
    </string-array>
</resources>

 

 

MainActivity.kt

더보기
class MainActivity : AppCompatActivity() {
    private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) }

    // items = DustItem(pm10Value와 stationname이 담긴 리스트)
    var items = mutableListOf<DustItem>()

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

        // 클릭 된 @array/sido가 text가 된다
        binding.spinnerViewSido.setOnSpinnerItemSelectedListener<String> { _, _, _, text ->
            // text를 파라미터로 setUpDustParameter를 만든다
            // => text로 요청변수(Request Parameter)를 생성한다
            communitcateNetWork(setUpDustParameter(text))
        }

        // commuicateNetWork에서 넣어준 stationName이 text가 된다
        binding.spinnerViewGoo.setOnSpinnerItemSelectedListener<String> { _, _, _, text ->
            Log.d("miseya", "selectedItem: spinnerViewGoo selected > $text")
            // selectedItem = text로 들어온 stationName을 filter해서 가져올 아이템을 고를 수 있도록 한다
            var selectedItem = items.filter { f -> f.stationName == text }
            Log.d("miseya", "selectedItem: sidoName > " + selectedItem[0].sidoName)
            Log.d("miseya", "selectedItem: pm10value > " + selectedItem[0].pm10Value)

            // 시도명 + " " + 지역구명
            // ex) 서울시 용산구
            // 리스트인 이유는 자료가 여러개 일 수 있기 때문에, 그 중 첫번째 것을 쓰도록 함
            binding.tvCityname.text = selectedItem[0].sidoName + " " + selectedItem[0].stationName
            // 측정 시간
            binding.tvDate.text = selectedItem[0].dataTime
            // pm10Value
            binding.tvP10value.text = selectedItem[0].pm10Value + " ㎍/㎥"

            // value에 따른 아이콘 및 배경색, 텍스트
            when (getGrade(selectedItem[0].pm10Value)) {
                1 -> {
                    binding.mainBg.setBackgroundColor(Color.parseColor("#9ED2EC"))
                    binding.ivFace.setImageResource(R.drawable.ic_good)
                    binding.tvP10value.text = "좋음"
                }

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

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

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

    // LifecycleScope.launch() = 메인 스레드에서 실행되는게 아님, 백그라운드에서 실행 됨
    private fun communitcateNetWork(param: HashMap<String, String>) = lifecycleScope.launch() {
        // param으로 들어온 HashMap<String, String>을 getDust로 호출
        // -> retrofit(NetWorkClient) 실행되고 responseData가 리턴된다
        // responseData는 DustDTO.kt에서 정의 해놓은 형태로
        val responseData = NetWorkClient.dustNetWork.getDust(param)
        Log.d("Parsing Dust ::", responseData.toString())

        val adapter = IconSpinnerAdapter(binding.spinnerViewGoo)
        // DustDTO의 구조
        items = responseData.response.dustBody.dustItem!!

        // items에는 dustItem이 다 들어있기 때문에 새로운 String타입 리스트를 만든 후,
        // forEach를 이용해 stationName만 add 시킨다
        val goo = ArrayList<String>()
        items.forEach {
            Log.d("add Item :", it.stationName)
            goo.add(it.stationName)
        }

        // 지금 이 과정이 코루틴의 별도 스레드기 때문에 runOnUiThread를 통해 메인 UI 스레드에서 실행시키도록 한다
        runOnUiThread {
            // 위에서 만든 stationName을 spinnerViewGoo에 넣어준다
            binding.spinnerViewGoo.setItems(goo)
        }
    }

    // 27라인에서 리턴할 때 HashMap<String, String> 타입으로 communicateNetWork를 호출한다
    private fun setUpDustParameter(sido: String): HashMap<String, String> {
        val authKey =
            "API 키=="

        // 요청변수와 정확히 일치 해야 한다
        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 in 0..30) {
            1
        } else if (mValue in 31..80) {
            2
        } else if (mValue in 81..100) {
            3
        } else {
            4
        }
        return grade
    }
}

 

 

NetWorkClient.kt (retrofit 인스턴스 생성)

더보기
// retrofit은 거의 대부분 구조가 이럼
object NetWorkClient {
    // 서비스 URL
    private const val DUST_BASE_URL = "API 서비스 URL"

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

        // 통신이 잘 안될 때 디버깅을 위한 용도
        if (BuildConfig.DEBUG) {
            interceptor.level = HttpLoggingInterceptor.Level.BODY
        } else {
            interceptor.level = HttpLoggingInterceptor.Level.NONE
        }

        return OkHttpClient.Builder()
            .connectTimeout(20, TimeUnit.SECONDS)
            .readTimeout(20, TimeUnit.SECONDS)
            .writeTimeout(20, TimeUnit.SECONDS)
            .addNetworkInterceptor(interceptor)
            .build()
    }

    // 3. dustRetrofit이 create 될 때 URL, Gson컨버팅, 클라이언트 = createOkHttpClient()
    private val dustRetrofit = Retrofit.Builder()
        .baseUrl(DUST_BASE_URL).addConverterFactory(GsonConverterFactory.create()).client(
            createOkHttpClient()
        ).build()

    // 1. NetWorkInterface 타입의 dustNetWork 변수 선언
    // 2. NetWorkInterface가 파라미터인 dustRetrofit.create()
    val dustNetWork: NetWorkInterface = dustRetrofit.create(NetWorkInterface::class.java)
}
  • Gson은 Json 데이터를 가공하는데 있어 좀 더 편하고 효율적으로 관리할 수 있도록 도와주는 라이브러리로, Google에서 제공하는 Json이라고 보면 된다.

 

 

DustDTO.kt (data 클래스)

더보기
// data의 구조는 API에 구조와 항상 맞춰야 한다.
// 항상 이런 구조가 아님. 
// 그 형태에 맞춰서 변형 시켜줘야 한다.
data class Dust(val response: DustResponse)

data class DustResponse(
    @SerializedName("body")
    val dustBody: DustBody,
    @SerializedName("header")
    val dustHeader: DustHeader
)

data class DustBody(
    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
)
  • 서버 통신시 request body 또는 response body에서 사용할 JSON 형태의 모델 클래스 작성
    →  Kotlin에서는 data class 형태로 작성한다.
  • 변수명은 원래 서버에서 사용하는 값과 똑같이 작성해야 된다. 
  • 만약 앱 내에서 다른 변수명으로 사용하고 싶다면 위의 작성한 코드처럼,
    @SerializedName("서버에서 변수명")
    val 앱 내 변수명 : 자료형 을 사용한다

 

 

NetWorkInterface (API 인터페이스 정의)

더보기
interface NetWorkInterface {
    // 시도별 실시간 측정정보 조회 주소의 요청 주소
    @GET("(요청주소)get~~~~")
    // HashMap 형태로 키-밸류 요청
    suspend fun getDust(@QueryMap param: HashMap<String, String>): Dust
}

 

 

실행결과

728x90