Miseya (미세먼지 앱)
https://www.data.go.kr/index.do
한국환경공단에어코리아대기오염정보
'시도별 실시간 측정정보 조회' 데이터 활용신청 후 인증키 발급
https://www.data.go.kr/data/15073861/openapi.do
미리보기가 가능하다. 요청변수의 serviceKey(인증키)에는 일반 인증키(Decoding)가 들어가고, return Type은 json이다.
미세먼지 등급
등급별 아이콘 표시
💡 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
위처럼 복잡한 json의 format을 바꿔주는 툴로 돌리면 아래와 같이 보기 쉽게 정리된다.
여기서 이번에 만들 앱에 필요한 정보는 pm10Value와 stationName이다. 이 정보 그대로 들어올 수 있게 위 format 그대로 data class를 만들 것이다.
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")
retrofit은 안드로이드에서REST api 통신을 지원하기 위한 라이브러리다. 기존의 사용자가 http 통신을 위해 HttpURLConnection을 성립시키고 결과를 받아오는 과정이 다소 복잡하다. 여기에 더해서 AsyncTask가 deprecated되면서 서버와의 비동기 통신을 위해 골머리를 앓게 되는데.. retrofit은 Call 인터페이스의 enqueue을 통해 비동기 구현이 가능하다.(call 인터페이스의 enqueue를 이용하지 않고 rxjava, rxkotlin를 사용하여 비동기 처리할 수도 있다.)
<?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 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
)
<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>
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를 리턴받음.
}
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)
}
<?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>
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
}
}