[2024. 01. 26] 미세먼지 앱 만들기

Gahyeon Lee·2024년 1월 26일
0

TIL

목록 보기
47/47
post-thumbnail

Miseya (미세먼지 앱)

  • 특정 지역의 미세 먼지 수치, 등급을 알려준다.
  • 데이터는 공공데이터포털(https://www.data.go.kr)을 이용한다.
  • 수신된 JSON 데이터를 가공한다.
  • 미세먼지 등급에 따라 아이콘/배경색이 변경된다.

1. 공공데이터 포털

https://www.data.go.kr/index.do

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

  • '시도별 실시간 측정정보 조회' 데이터 활용신청 후 인증키 발급
    https://www.data.go.kr/data/15073861/openapi.do

    미리보기가 가능하다. 요청변수의 serviceKey(인증키)에는 일반 인증키(Decoding)가 들어가고, return Type은 json이다.

  • 미세먼지 등급

  • 등급별 아이콘 표시



2. Request Parameter

💡 https를 날리면 요청 변수에 따라서 리턴해줄 것이다.
https://apis.data.go.kr/B552584/ArpltnInforInqireSvc/getCtprvnRltmMesureDnsty?serviceKey=YBZfZVJMj1yF6gVroDHjfOwdMCo%2Baq87JOXQOC4lPccAi6ekELdgoIA7n4f4zVIiQcOVls%2FSVg1pEC39CVbmsA%3D%3D&returnType=json&numOfRows=50&pageNo=1&sidoName=서울&ver=1.0

  • https://apis.data.go.kr/B552584/ArpltnInforInqireSvc/ → API 서비스명(=해당 서비스URL)
  • getCtprvnRltmMesureDnsty? → 세부 옵션(=요청주소 뒷부분)
  • &serviceKey=YBZfZVJMj1yF6gVroDidoName… → 인증키
  • &returnType=json → 결과 데이터 유형
  • &numOfRows=50 → 한페이지 결과수
  • &pageNo=1 → 페이지 번호
  • &sidoName=서울 → 시도 이름(서울,부산,대구,인천….)
  • &ver=1.0 → 버전

3. Response Data


위처럼 복잡한 json의 format을 바꿔주는 툴로 돌리면 아래와 같이 보기 쉽게 정리된다.

여기서 이번에 만들 앱에 필요한 정보는 pm10Value와 stationName이다. 이 정보 그대로 들어올 수 있게 위 format 그대로 data class를 만들 것이다.

4. Android 앱 개발

1) Gradle에 라이브러리 추가

buildFeatures {
        viewBinding = true
    }

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

2) retrofit

retrofit은 안드로이드에서REST api 통신을 지원하기 위한 라이브러리다. 기존의 사용자가 http 통신을 위해 HttpURLConnection을 성립시키고 결과를 받아오는 과정이 다소 복잡하다. 여기에 더해서 AsyncTask가 deprecated되면서 서버와의 비동기 통신을 위해 골머리를 앓게 되는데.. retrofit은 Call 인터페이스의 enqueue을 통해 비동기 구현이 가능하다.(call 인터페이스의 enqueue를 이용하지 않고 rxjava, rxkotlin를 사용하여 비동기 처리할 수도 있다.)

3) 소스 코드

  • AndroidManifest.xml
    • http통신을 하기 때문에 인터넷 사용권한 추가 필요
<?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.Miseya"
        android:usesCleartextTraffic="true"
        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>
  • data 클래스 작성
    • 서버 통신시 request body 또는 response body에서 사용할 JSON형태의 모델 클래스 작성 -> Kotlin에서는 data class 형태로 작성한다!
    • 변수명은 원래 서버에서 사용하는 값과 똑같이 작성해야 한다.
    • 만약 앱 내에서 다른 변수명으로 사용하고 싶다면 아래 코드처럼 ' @SerializedName("서버에서 변수명") val 앱내변수명:자료형 ' 을 사용한다.
    • DustDTO.kt
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
)
  • string.xml
<resources>
    <string name="app_name">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>
  • retrofit
  1. NetWorkInterface.kt
import com.gahyeon.miseya.data.Dust
import retrofit2.http.GET
import retrofit2.http.QueryMap

interface NetWorkInterface {
    @GET("getCtprvnRltmMesureDnsty")  //시도별 실시간 측정정보 요청 주소 뒷부분
    suspend fun getDust(@QueryMap param: HashMap<String, String>): Dust  // 파라미터에 HashMap컬렉션 타입으로 <> 안에 요청변수들(serviceKey, returnType 등)을 키와 value로 2개가 들어감. -> 요청하면 Dust를 리턴받음.
}
  1. NetWorkClient.kt
object NetWorkClient {

    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

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

    private val dustRetrofit = Retrofit.Builder()
        .baseUrl(DUST_BASE_URL).addConverterFactory(GsonConverterFactory.create()).client(
            createOkHttpClient()
        ).build()

    val dustNetWork: NetWorkInterface = dustRetrofit.create(NetWorkInterface::class.java)

}
  • 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="#009688"
        android:foreground="?attr/selectableItemBackground"
        android:gravity="center"
        android:hint="도시 선택"
        android:padding="10dp"
        android:textColor="@color/black"
        android:textColorHint="@color/white"
        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="#FFEB3B"
        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="@color/white"
        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="#4CAF50"
        android:foreground="?attr/selectableItemBackground"
        android:gravity="center"
        android:hint="지역 선택"
        android:padding="10dp"
        android:textColor="@color/black"
        android:textColorHint="@color/white"
        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="@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="@color/white"
        app:spinner_popup_elevation="14dp"
        tools:ignore="HardcodedText,UnusedAttribute" />


    <ImageView
        android:id="@+id/iv_face"
        android:layout_width="200dp"
        android:layout_height="200dp"
        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/lv1" />

    <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>
  • MainActivity.kt
import android.graphics.Color
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.gahyeon.miseya.data.DustItem
import com.gahyeon.miseya.databinding.ActivityMainBinding
import com.gahyeon.miseya.retrofit.NetWorkClient
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)

        binding.spinnerViewSido.setOnSpinnerItemSelectedListener<String> { _, _, _, text ->  // text: spinner에서 선택한 지역
            communicateNetWork(setUpDustParameter(text))
        }

        binding.spinnerViewGoo.setOnSpinnerItemSelectedListener<String> { _, _, _, text ->

            Log.d("miseya", "selectedItem: spinnerViewGoo selected >  $text")
            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  // selectedItem이 여러 개일 수 있다. 예를 들어 용산구를 선택했을 때 용산구 데이터가 여러 개일 수 있는데 우리는 실시간 데이터를 원하기 때문에 0번째 것만 사용하는 것.
            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.lv1)
                    binding.tvP10grade.text = "좋음"
                }

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

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

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

    // 여기부터 http 통신을 하기 위한 코루틴의 별도 thread
    private fun communicateNetWork(param: HashMap<String, String>) = lifecycleScope.launch() {

        val responseData = NetWorkClient.dustNetWork.getDust(param)

        Log.d("Parsing Dust ::", responseData.toString())

        items = responseData.response.dustBody.dustItem!!

        val goo = ArrayList<String>()  // 지역 선택에 ~구 리스트를 뿌려주는 작업.
        items.forEach {
            Log.d("add Item :", it.stationName)
            goo.add(it.stationName)  // item(response 받은 데이터)에서 stationName(지역명)을 꺼내서 for문이 돌면서 goo에 리스트를 넣음.
        }

        // 별도 thread에서는 ui에 있는 레이아웃들을 건드릴 수 없다. 건드리려면 ui thread를 실행시킨다고 별도로 지정해 줘야 함.
        runOnUiThread {
            binding.spinnerViewGoo.setItems(goo)  // 2번째 스피너에 goo를 setItems으로 넣음
        }

    }

    private fun setUpDustParameter(sido: String): HashMap<String, String> {  // api의 요청변수(Request Parameter)의 키-값
        val authKey =
            "2Z1zOtQt1qEn7nBjoeaTOxHj6RVd3Pls03B6Plg1yUoO24srtbEewjLxjlCnoyz9vOubVtcR04571819tH1i8g=="  // 환경공단에서 발급 받은 일반 인증키(Decoding)

        return hashMapOf(  // api 요청 변수의 항목명(영문)과 정확히 일치해야 한다.
            "serviceKey" to authKey,  // "api 요청 변수의 항목명(영문)" to value값
            "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
    }
}
profile
코린이 강아지 집사🐶

0개의 댓글