Retofit이란?
이번에는 Retofit 라이브러리를 이용하여 기상청 단기예보 오픈 API와 통신하는 방법에 대해서 알아보도록 하자.
Retofit이란 모바일에서 HTTP API 통신을 할 때 사용하는 라이브러리이다. 안드로이드 애플리케이션에서 통신 기능에 사용하는 코드를 사용하기 쉽게 만들어놓은 라이브러리로 REST 기반의 웹서비스를 통해 JSON 구조를 쉽게 가져오고 업로드할 수 있다.
다음에 나올 코드를 보다 보면 기상청 단기예보 오픈 API Key가 숨겨진 것을 확인할 수 있는데 API Key 숨기는 법을 모른다면 다음 링크를 통해서 글을 읽어보고 오면 좋을 것 같다.
폴더와 파일 위치
폴더와 파일에 위치는 위 이미지와 같이 구성했다.
라이브러리
Retrofit 라이브러리를 build.gradle(app)에 추가한다.
// data binding
buildFeatures {
dataBinding = true
}
}
dependencies {
// retrofit2
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
// 위치
implementation 'com.google.android.gms:play-services-location:19.0.1'
converter-gson 은 JSON을 object형식으로 변환하도록 도와준다.
다음으로 AndroidMainfest.xml에 들어가 인터넷 권한과 위치 권한을 추가한다.
인터넷 권한에 경우 http 통신을 위한 것이고 위치 권한은 현재 위치 기준으로 날씨 예보를 받기 위해서이다.
<!--날씨 API 가져오기-->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<application
android:usesCleartextTraffic="true"
android:usesCleartextTraffic="true"로 설정하는 이유로는
cleartext HTTP와 같은 cleartext 네트워크 트래픽을 사용할지 여부를 나타내는 flag로 이 플래그가 flase로 되어 있으면, 플랫폼 구성 요소 (예 : HTTP 및 FTP 스택, DownloadManager, MediaPlayer)는 일반 텍스트 트래픽 사용에 대한 앱의 요청을 거부하게 되기 때문이다. 이 flag를 설정하게 되면 모든 cleartext 트래픽은 허용 처리가 된다.
ModelWeather
import com.google.gson.annotations.SerializedName
// 날씨 정보를 담는 데이터 클래스
data class ModelWeather (
@SerializedName("rainType") var rainType: String = "", // 강수 형태
@SerializedName("humidity") var humidity: String = "", // 습도
@SerializedName("sky") var sky: String = "", // 하능 상태
@SerializedName("temp") var temp: String = "", // 기온
@SerializedName("fcstTime") var fcstTime: String = "", // 예보시각
)
// xml 파일 형식을 data class로 구현
data class WEATHER (val response : RESPONSE)
data class RESPONSE(val header : HEADER, val body : BODY)
data class HEADER(val resultCode : Int, val resultMsg : String)
data class BODY(val dataType : String, val items : ITEMS, val totalCount : Int)
data class ITEMS(val item : List<ITEM>)
// category : 자료 구분 코드, fcstDate : 예측 날짜, fcstTime : 예측 시간, fcstValue : 예보 값
data class ITEM(val category : String, val fcstDate : String, val fcstTime : String, val fcstValue : String)
WeatherInterface
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Query
// 결과 xml 파일에 접근해서 정보 가져오기
interface WeatherInterface {
// getUltraSrtFcst : 초단기 예보 조회 + 인증키
@GET(BuildConfig.API_KEY)
fun getWeather(
@Query("numOfRows") num_of_rows: Int, // 한 페이지 경과 수
@Query("pageNo") page_no: Int, // 페이지 번호
@Query("dataType") data_type: String, // 응답 자료 형식
@Query("base_date") base_date: String, // 발표 일자
@Query("base_time") base_time: String, // 발표 시각
@Query("nx") nx: Int, // 예보지점 X 좌표
@Query("ny") ny: Int // 예보지점 Y 좌표
): Call<WEATHER>
}
레트로핏에서 Service는 API를 정의하는 인터페이스를 말한다.
어떤 형태와 방식으로 통신을 할지 어노테이션과 파라미터를 지정하면 된다.
Service는 기본적으로 Call<> 객체를 반환한다.
서버의 api가 String을 반환한다고 가정하면
클라이언트는 Retrofit을 통해 Call<String.>을 받게 된다.
WeatherObject
서비스가 인터페이스 형식 -> API를 call할때 WeatherInterface를 이용할 수 없다. 따라서, retrofit 구현체가 필요하다.
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
object WeatherObject {
private fun getRetrofit(): Retrofit{
return Retrofit.Builder()
.baseUrl(BuildConfig.URL_WEATHER)
.addConverterFactory(GsonConverterFactory.create()) // Json데이터를 사용자가 정의한 Java 객채로 변환해주는 라이브러리
.build()
}
fun getRetrofitService(): WeatherInterface{
return getRetrofit().create(WeatherInterface::class.java) //retrofit객체 만듦!
}
}
getRetrofit()에서 사용할 기본 URL(local.properties에 작성했던 url) 설정과 json데이터를 받기 위해 GsonConverterFactory를 설정해 주고 반환해준다.
이제 api service를 받아서 사용할 수 있도록 getRetrofitService 함수를 다음과 같이 작성하여 반환해준다.
WeatherAdapter
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
class WeatherAdapter (var items : Array<ModelWeather>) : RecyclerView.Adapter<WeatherAdapter.ViewHolder>() {
// 뷰 홀더 만들어서 반환, 뷰릐 레이아웃은 list_item_weather.xml
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WeatherAdapter.ViewHolder {
val itemView = LayoutInflater.from(parent.context).inflate(R.layout.list_item_weather, parent, false)
return ViewHolder(itemView)
}
// 전달받은 위치의 아이템 연결
override fun onBindViewHolder(holder: WeatherAdapter.ViewHolder, position: Int) {
val item = items[position]
holder.setItem(item)
}
// 아이템 갯수 리턴
override fun getItemCount() = items.count()
// 뷰 홀더 설정
inner class ViewHolder(itemView : View) : RecyclerView.ViewHolder(itemView) {
@SuppressLint("SetTextI18n")
fun setItem(item : ModelWeather) {
val imgWeather = itemView.findViewById<ImageView>(R.id.imgWeather) // 날씨 이미지
val tvTime = itemView.findViewById<TextView>(R.id.tvTime) // 시각
val tvHumidity = itemView.findViewById<TextView>(R.id.tvHumidity) // 습도
val tvTemp = itemView.findViewById<TextView>(R.id.tvTemp) // 온도
imgWeather.setImageResource(getRainImage(item.rainType, item.sky))
tvTime.text = getTime(item.fcstTime)
tvHumidity.text = item.humidity +"%"
tvTemp.text = item.temp + "°"
}
}
fun getTime(factTime : String): String {
if(factTime != "지금"){
var hourSystem : Int = factTime.toInt()
var hourSystemString = ""
if(hourSystem == 0){
return "오전 12시"
}else if(hourSystem > 2100){
hourSystem -= 1200
hourSystemString = hourSystem.toString()
return "오후 ${hourSystemString[0]}${hourSystemString[1]}시"
}else if(hourSystem == 1200){
return "오후 12시"
} else if(hourSystem > 1200){
hourSystem -= 1200
hourSystemString = hourSystem.toString()
return "오후 ${hourSystemString[0]}시"
}
else if(hourSystem >= 1000){
hourSystemString = hourSystem.toString()
return "오전 ${hourSystemString[0]}${hourSystemString[1]}시"
}else{
hourSystemString = hourSystem.toString()
return "오전 ${hourSystemString[0]}시"
}
}else{
return factTime
}
}
// 강수 형태
fun getRainImage(rainType : String, sky: String) : Int {
return when(rainType) {
"0" -> getWeatherImage(sky)
"1" -> R.drawable.rainy
"2" -> R.drawable.hail
"3" -> R.drawable.snowy
"4" -> R.drawable.brash
else -> getWeatherImage(sky)
}
}
fun getWeatherImage(sky : String) : Int {
// 하늘 상태
return when(sky) {
"1" -> R.drawable.sun // 맑음
"3" -> R.drawable.cloudy // 구름 많음
"4" -> R.drawable.blur // 흐림
else -> R.drawable.ic_launcher_foreground // 오류
}
}
}
리사이클러뷰에 들어갈 아이템 뷰를 설정한다.
Common
import android.graphics.Point
class Common {
// baseTime 설정하기
fun getBaseTime(h : String, m : String) : String {
var result = ""
// 45분 전이면
if (m.toInt() < 45) {
// 0시면 2330
if (h == "00") result = "2330"
// 아니면 1시간 전 날씨 정보 부르기
else {
var resultH = h.toInt() - 1
// 1자리면 0 붙여서 2자리로 만들기
if (resultH < 10) result = "0" + resultH + "30"
// 2자리면 그대로
else result = resultH.toString() + "30"
}
}
// 45분 이후면 바로 정보 받아오기
else result = h + "30"
return result
}
// 위경도를 기상청에서 사용하는 격자 좌표로 변환
fun dfsXyConv(v1: Double, v2: Double) : Point {
val RE = 6371.00877 // 지구 반경(km)
val GRID = 5.0 // 격자 간격(km)
val SLAT1 = 30.0 // 투영 위도1(degree)
val SLAT2 = 60.0 // 투영 위도2(degree)
val OLON = 126.0 // 기준점 경도(degree)
val OLAT = 38.0 // 기준점 위도(degree)
val XO = 43 // 기준점 X좌표(GRID)
val YO = 136 // 기준점 Y좌표(GRID)
val DEGRAD = Math.PI / 180.0
val re = RE / GRID
val slat1 = SLAT1 * DEGRAD
val slat2 = SLAT2 * DEGRAD
val olon = OLON * DEGRAD
val olat = OLAT * DEGRAD
var sn = Math.tan(Math.PI * 0.25 + slat2 * 0.5) / Math.tan(Math.PI * 0.25 + slat1 * 0.5)
sn = Math.log(Math.cos(slat1) / Math.cos(slat2)) / Math.log(sn)
var sf = Math.tan(Math.PI * 0.25 + slat1 * 0.5)
sf = Math.pow(sf, sn) * Math.cos(slat1) / sn
var ro = Math.tan(Math.PI * 0.25 + olat * 0.5)
ro = re * sf / Math.pow(ro, sn)
var ra = Math.tan(Math.PI * 0.25 + (v1) * DEGRAD * 0.5)
ra = re * sf / Math.pow(ra, sn)
var theta = v2 * DEGRAD - olon
if (theta > Math.PI) theta -= 2.0 * Math.PI
if (theta < -Math.PI) theta += 2.0 * Math.PI
theta *= sn
val x = (ra * Math.sin(theta) + XO + 0.5).toInt()
val y = (ro - ra * Math.cos(theta) + YO + 0.5).toInt()
return Point(x, y)
}
}
현재 위치를 격자 좌표로 변환해주기 위해서 위 코드를 사용한다.
MainActivity
import androidx.databinding.DataBindingUtil.setContentView
import retrofit2.Call
import retrofit2.Response
import java.text.SimpleDateFormat
import java.util.*
import android.Manifest
import android.annotation.SuppressLint
import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import android.widget.Toast
import android.graphics.Point
import android.os.Build
import android.os.Looper
import androidx.annotation.RequiresApi
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.LocationServices
class MainActivity : AppCompatActivity() {
private var baseDate = "20210510" // 발표 일자
private var baseTime = "1400" // 발표 시각
private var curPoint : Point? = null // 현재 위치의 격자 좌표를 저장할 포인트
lateinit var binding: ActivityMainBinding
@SuppressLint("SetTextI18n", "MissingPermission")
@RequiresApi(Build.VERSION_CODES.S)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = setContentView(this, R.layout.activity_main)
binding.mainActivity = this
// Get permission
val permissionList = arrayOf<String>(
// 위치 권한
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
)
// 권한 요청
ActivityCompat.requestPermissions(this@MainActivity, permissionList, 1)
// 오늘 날짜 텍스트뷰 설정
binding.tvDate.text = SimpleDateFormat("MM월 dd일", Locale.getDefault()).format(Calendar.getInstance().time) + "날씨"
requestLocation()
// <새로고침> 버튼 누를 때 위치 정보 & 날씨 정보 다시 가져오기
binding.btnRefresh.setOnClickListener {
requestLocation()
}
}
// 날씨 가져와서 설정하기
private fun setWeather(nx : Int, ny : Int) {
// 준비 단계 : base_date(발표 일자), base_time(발표 시각)
// 현재 날짜, 시간 정보 가져오기
val cal = Calendar.getInstance()
baseDate = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(cal.time) // 현재 날짜
val timeH = SimpleDateFormat("HH", Locale.getDefault()).format(cal.time) // 현재 시각
val timeM = SimpleDateFormat("HH", Locale.getDefault()).format(cal.time) // 현재 분
// API 가져오기 적당하게 변환
baseTime = Common().getBaseTime(timeH, timeM)
// 현재 시각이 00시이고 45분 이하여서 baseTime이 2330이면 어제 정보 받아오기
if (timeH == "00" && baseTime == "2330") {
cal.add(Calendar.DATE, -1).toString()
baseDate = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(cal.time)
}
// 날씨 정보 가져오기
// (한 페이지 결과 수 = 60, 페이지 번호 = 1, 응답 자료 형식-"JSON", 발표 날싸, 발표 시각, 예보지점 좌표)
val call = WeatherObject.getRetrofitService().getWeather(60, 1, "JSON", baseDate, baseTime, nx, ny)
// 비동기적으로 실행하기
call.enqueue(object : retrofit2.Callback<WEATHER> {
// 응답 성공 시
override fun onResponse(call: Call<WEATHER>, response: Response<WEATHER>) {
if (response.isSuccessful) {
// 날씨 정보 가져오기
val it: List<ITEM> = response.body()!!.response.body.items.item
// 현재 시각부터 1시간 뒤의 날씨 6개를 담을 배열
val weatherArr = arrayOf(ModelWeather(), ModelWeather(), ModelWeather(), ModelWeather(), ModelWeather(), ModelWeather())
// 배열 채우기
var index = 0
val totalCount = response.body()!!.response.body.totalCount - 1
for (i in 0..totalCount) {
index %= 6
when(it[i].category) {
"PTY" -> weatherArr[index].rainType = it[i].fcstValue // 강수 형태
"REH" -> weatherArr[index].humidity = it[i].fcstValue // 습도
"SKY" -> weatherArr[index].sky = it[i].fcstValue // 하늘 상태
"T1H" -> weatherArr[index].temp = it[i].fcstValue // 기온
else -> continue
}
index++
}
weatherArr[0].fcstTime = "지금"
// 각 날짜 배열 시간 설정
for (i in 1..5) weatherArr[i].fcstTime = it[i].fcstTime
// 리사이클러 뷰에 데이터 연결
binding.weatherRecyclerView.adapter = WeatherAdapter(weatherArr)
// 토스트 띄우기
Toast.makeText(applicationContext, it[0].fcstDate + ", " + it[0].fcstTime + "의 날씨 정보입니다.", Toast.LENGTH_SHORT).show()
}
}
// 응답 실패 시
override fun onFailure(call: Call<WEATHER>, t: Throwable) {
binding.tvError.text = "api fail : " + t.message.toString() + "\n 다시 시도해주세요."
binding.tvError.visibility = View.VISIBLE
Log.d("api fail", t.message.toString())
}
})
}
// 내 현재 위치의 위경도를 격자 좌표로 변환하여 해당 위치의 날씨정보 설정하기
@SuppressLint("MissingPermission")
private fun requestLocation() {
val locationClient = LocationServices.getFusedLocationProviderClient(this@MainActivity)
try {
// 나의 현재 위치 요청
val locationRequest = LocationRequest.create()
locationRequest.run {
priority = LocationRequest.PRIORITY_HIGH_ACCURACY
interval = 60 * 1000 // 요청 간격(1초)
}
val locationCallback = object : LocationCallback() {
// 요청 결과
override fun onLocationResult(p0: LocationResult) {
p0.let {
for (location in it.locations) {
// 현재 위치의 위경도를 격자 좌표로 변환
curPoint = Common().dfsXyConv(location.latitude, location.longitude)
// 오늘 날짜 텍스트뷰 설정
binding.tvDate.text = SimpleDateFormat("MM월 dd일", Locale.getDefault()).format(Calendar.getInstance().time) + " 날씨"
// nx, ny지점의 날씨 가져와서 설정하기
setWeather(curPoint!!.x, curPoint!!.y)
}
}
}
}
// 내 위치 실시간으로 감지
Looper.myLooper()?.let {
locationClient.requestLocationUpdates(locationRequest, locationCallback,
it)
}
} catch (e : SecurityException) {
e.printStackTrace()
}
}
}
이제 API를 사용할 곳에서 WeatherInterface 인터페이스를 통해 요청을 보내고 Callback을 통해 응답을 받아서 ModelWeather에 날씨 정보를 넣고 리사이클 러뷰 설정한 부분이다.
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<layout 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">
<data>
<variable
name="mainActivity"
type="com.junjange.retrofitactivity.MainActivity" />
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity"
android:gravity="center"
android:orientation="vertical">
<TextView
android:id="@+id/tvDate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="mm/dd의 날씨"
android:textSize="30dp"
android:textColor="@color/black"
android:layout_margin="10dp"
android:gravity="center"/>
<TextView
android:id="@+id/tvError"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="에러 : "
android:textSize="30dp"
android:textColor="@color/black"
android:layout_margin="10dp"
android:gravity="center"
android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/weatherRecyclerView"
android:orientation="horizontal"
android:layout_width="match_parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
android:layout_height="wrap_content"/>
<Button
android:id="@+id/btnRefresh"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="새로고침"/>
</LinearLayout>
</layout>
list_item_weather.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/layout_background"
android:orientation="vertical"
android:gravity="center"
android:padding="10dp"
android:layout_margin="10dp">
<TextView
android:id="@+id/tvTime"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="00시 날씨"
android:textColor="@color/white"
android:textSize="14sp"
android:gravity="center"
/>
<ImageView
android:id="@+id/imgWeather"
android:layout_width="60dp"
android:layout_height="60dp"
android:src="@drawable/sun"
android:layout_margin="10dp"
/>
<TextView
android:id="@+id/tvTemp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="기온"
android:textColor="@color/white"
android:textSize="18sp" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_margin="10dp">
<ImageView
android:layout_width="18dp"
android:layout_height="18dp"
android:src="@drawable/humidity"/>
<TextView
android:id="@+id/tvHumidity"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0%"
android:textColor="@color/white"
android:textSize="14sp"/>
</LinearLayout>
</LinearLayout>
리사이클러뷰 리스트 아이템
layout_background.xml
drawable 폴더에 추가
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#92c0f4" />
<stroke android:color="#92c0f4" android:width="1dp" />
<corners android:radius="7dp" />
</shape>
위 코드를 모두 작성하고 실행하면 다음 결과 화면과 같이 나오는 것을 확인할 수 있다.
전체 코드를 한 번에 보고 싶다면 아래 GitHub에서 확인할 수 있다.
참고
GitHub
'Develop > Kotlin' 카테고리의 다른 글
[kotlin] 코틀린 Android MVVM Retrofit(BE 연결) 구현 (0) | 2022.05.01 |
---|---|
[kotlin] 코틀린 Android RecyclerView Item 오류(Position 오류) (0) | 2022.05.01 |
[kotlin] 코틀린 Android local.properties에 API Key 숨기고 활용하는 방법 (0) | 2022.04.19 |
[kotlin] 코틀린 Android 코루틴(Coroutine) 활용 (0) | 2021.11.19 |
[kotlin] 코틀린 Android MVVM 패턴 구현 (3) | 2021.08.30 |