[Android Studio] 미세먼지 앱

jeunguri·2022년 4월 25일
0

토이 프로젝트

목록 보기
3/8
post-thumbnail


앱 소개

앱을 실행하면 첫번째 이미지처럼 미세먼지 정보가 뜨고, 위젯 추가시 갱신되는 동안 상단바에 refresh 아이콘이 뜬다. 위젯이 완전히 갱신되고 나면 refresh 아이콘이 사라진다.

주요 기능

  • 내 위치 정보 가져오기
  • 오픈API로부터 미세먼지 정보 가져오기
  • 홈 스크린에 미세먼지 위젯 추가

활용 기술

  • LocationManager
  • Retrofit2
  • Coroutine
  • App Widgets



Open API 사용 신청하기


공공데이터포털

API활용 신청서의 "활용용도" 이외에는 사용을 제한하며 반드시 자료의 출처 표기 의무를 준수하여야 한다.

측청소정보와 대기오염정보 활용신청을 해주고, kakao Developer 통해 좌표계 변환해서 받아오기 위해 애플리케이션을 추가해준다.



위치 정보 불러오기


의존성 추가

AndroidManifest.xml

<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
  • ACCESS_COARSE_LOCATION : 도시 블럭 단위의 정밀도의 위치 정보 얻을 수 있다.
  • ACCESS_FINE_LOCATION : ACCESS_COARSE_LOCATION보다 더 정밀한 위치 정보 얻을 수 있다.

앱수준 build.gradle

dependencies {
    implementation 'com.google.android.gms:play-services-location:19.0.1'
}

좌표 찍어보기

MainActivity

class MainActivity : AppCompatActivity() {

    private lateinit var fusedLocationProviderClient: FusedLocationProviderClient
    private var cancellationTokenSource: CancellationTokenSource? = null

    private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) }

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

        initVariables()
        requestLocationPermissions()
    }

    override fun onDestroy() {
        super.onDestroy()
        cancellationTokenSource?.cancel()
    }

    @SuppressLint("MissingPermission")
    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)

        val locationPermissionGranted =
            requestCode == REQUEST_ACCESS_LOCATION_PERMISSIONS &&
                    grantResults[0] == PackageManager.PERMISSION_GRANTED

        if (!locationPermissionGranted) {
            finish()
        } else {
            fetchAirQualityData()
        }
    }

    private fun initVariables() {
    	// 마지막으로 확인된 위치 정보 얻기
        fusedLocationProviderClient = LocationServices.getFusedLocationProviderClient(this)
    }

    private fun requestLocationPermissions() {
        ActivityCompat.requestPermissions(
            this,
            arrayOf(
                Manifest.permission.ACCESS_COARSE_LOCATION,
                Manifest.permission.ACCESS_FINE_LOCATION
            ),
            REQUEST_ACCESS_LOCATION_PERMISSIONS
        )
    }
    
    @SuppressLint("MissingPermission")
    private fun fetchAirQualityData() {
        // fetchData
        cancellationTokenSource = CancellationTokenSource()

        fusedLocationProviderClient.getCurrentLocation(
            LocationRequest.PRIORITY_HIGH_ACCURACY,
            cancellationTokenSource!!.token
        ).addOnSuccessListener { location ->
            scope.launch {

            }
        }
    }

    companion object {
        private const val REQUEST_ACCESS_LOCATION_PERMISSIONS = 100
    }
}

코드를 작성하고 실행해주면 아래의 이미지와 같이 좌표가 찍힌다.



카카오 API를 사용해서 TM 변환

앱수준 build.gradle

implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.3'

implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'

implementation 'com.squareup.okhttp3:logging-interceptor:4.8.0'

MainActivity

private val scope = MainScope()

override fun onDestroy() {
        super.onDestroy()
        scope.cancel()
    }

MainActivity에서 코루틴 scope를 정의해준다.
scope 코루틴은 액티비티가 모두 종료될 때 cancel 되어야 한다.



레트로핏을 사용해서 통신 (카카오 개발 가이드 참고)

먼저, kakao developers에서 받은 REST API키를 gradle.properties 에 저장해주고 앱수준 build.gradle 에 buildConfigField 필드에 추가해준다.

// build.gradle
android {
	buildConfigField "String", "KAKAO_API_KEY", project.properties["KAKAO_API_KEY"]
}

// gradle.properties
KAKAO_API_KEY="REST API키"

data/services/KakaoLocalApiService

interface KakaoLocalApiService {

    @Headers("Authorization: KakaoAK ${BuildConfig.KAKAO_API_KEY}") // API_KEY 전달
    @GET("v2/local/geo/transcoord.json?output_coord=TM")
    suspend fun getTmCoordinates(
        @Query("x") longitude: Double,
        @Query("y") latitude: Double
    ): Response<TmCoordinatesResponse>
}

api를 통해 받아온 json을 data class로 만들어야 하는데 'JSON to Kotlin Class' 플러그인을 사용하면 편리하게 만들 수 있다. 보통 api를 빠르게 테스트할 때 주로 사용한다.

위의 이미지처럼 json 데이터를 넣어주면 아래와 같이 자동으로 data class가 생성된다.

models/tmcoordinates package

// Document.kt
data class Document(
    @SerializedName("x")
    val x: Double?,
    @SerializedName("y")
    val y: Double?
)

// Meta.kt
data class Meta(
    @SerializedName("total_count")
    val totalCount: Int?
)

// TmCoordinatesResponse.kt
data class TmCoordinatesResponse(
    @SerializedName("documents")
    val documents: List<Document>?,
    @SerializedName("meta")
    val meta: Meta?
)

이렇게 하면 KakaoLocalApiService에 대한 정의는 끝난다.

이제 Retrofit을 생성해준다.

data/Repository

object Repository {

    private val kakaoLocalApiService: KakaoLocalApiService by lazy {
        Retrofit.Builder()
            .baseUrl(Url.KAKAO_API_BASE_URL)
            .addConverterFactory(GsonConverterFactory.create())
            .client(buildHttpClient())
            .build()
            .create() 
    }

    private fun buildHttpClient(): OkHttpClient =
        OkHttpClient.Builder()
            .addInterceptor(
                HttpLoggingInterceptor().apply {
                    level = if (BuildConfig.DEBUG) {
                    	// DEBUG일때만 다 보여주기
                        HttpLoggingInterceptor.Level.BODY
                    } else {
                    	// NONE : 보여주지 않음
                        HttpLoggingInterceptor.Level.NONE
                    }
                }
            )
            .build()
}

data/Url

object Url {
    const val KAKAO_API_BASE_URL = "https://dapi.kakao.com/"
}


근접 측정소 정보 불러오기


tm 정보를 가지고 근처 측정소 정보를 불러오는 것도 위에서 했던 것처럼 코드를 작성하면 된다.


먼저 인증키를 받아와준다.
// build.gradle
android {
	buildConfigField "String", "AIR_KOREA_SERVICE_KEY", project.properties["AIR_KOREA_SERVICE_KEY"]
}

// gradle.properties
AIR_KOREA_SERVICE_KEY="service_key"

data/services/AirKoreaApiService

interface AirKoreaApiService {

    @GET("B552584/MsrstnInfoInqireSvc/getNearbyMsrstnList" +
            "?serviceKey=${BuildConfig.AIR_KOREA_SERVICE_KEY}" +
            "&returnType=json")
    suspend fun getNearbyMonitoringStation(
        @Query("tmX") tmX: Double,
        @Query("tmY") tmY: Double
    ): Response<MonitoringStationsResponse>
}


인증키를 넣고 미리보기를 통해 샘플데이터를 받아 위와 같이 kotlin data class file from JSON 통해 data class를 만들어주면 자동으로 data class가 생성된다.

data/models/monitoringstation package

// Body.kt
data class Body(
    @SerializedName("items")
    val monitoringStations: List<MonitoringStation>?,
    @SerializedName("numOfRows")
    val numOfRows: Int?,
    @SerializedName("pageNo")
    val pageNo: Int?,
    @SerializedName("totalCount")
    val totalCount: Int?
)

// Header.kt
data class Header(
    @SerializedName("resultCode")
    val resultCode: String?,
    @SerializedName("resultMsg")
    val resultMsg: String?
)

// MonitoringStation.kt
data class MonitoringStation(
    @SerializedName("addr")
    val addr: String?,
    @SerializedName("stationName")
    val stationName: String?,
    @SerializedName("tm")
    val tm: Double?
)

// MonitoringStationsResponse.kt
data class MonitoringStationsResponse(
    @SerializedName("response")
    val response: Response?
)

// Response.kt
data class Response(
    @SerializedName("body")
    val body: Body?,
    @SerializedName("header")
    val header: Header?
)

Url 작성하고 retrofit baseUrl에 담아준다.

const val AIR_KOREA_API_BASE_URL = "http://apis.data.go.kr/"

data/Repository

object Repository {

    suspend fun getNearbyMonitoringStation(latitude: Double, longitude: Double): MonitoringStation? {
        val tmCoordinates = kakaoLocalApiService
            .getTmCoordinates(longitude, latitude)
            .body()
            ?.documents
            ?.firstOrNull()

        val tmX = tmCoordinates?.x
        val tmY = tmCoordinates?.y

        return airKoreaApiService
            .getNearbyMonitoringStation(tmX!!, tmY!!)
            .body()
            ?.response
            ?.body
            ?.monitoringStations
            // 선택한 요소를 비교해 가장 작은 값을 전달하고 null인 값은 자동으로 후순위로 밀림
            // => 가장 가까운 측정소 하나만 받아오게 됨
            ?.minByOrNull { it.tm ?: Double.MAX_VALUE }
    }

    private val airKoreaApiService: AirKoreaApiService by lazy {
        Retrofit.Builder()
            .baseUrl(Url.AIR_KOREA_API_BASE_URL)
            .addConverterFactory(GsonConverterFactory.create())
            .client(buildHttpClient())
            .build()
            .create()
    }
}

이렇게 하면 현재 GPS 정보로 경도/위도를 가져와 tm좌표계로 변환하고, tm좌표계를 전달하여 가까운 측정소를 가져오는 것까지 코드 구현을 하였다.

아래의 코드를 통해 실제로 가까운 측정소를 출력해본다.

MainActivity

private fun fetchAirQualityData() {
        // fetchData
        cancellationTokenSource = CancellationTokenSource()

        fusedLocationProviderClient.getCurrentLocation(
            LocationRequest.PRIORITY_HIGH_ACCURACY,
            cancellationTokenSource!!.token
        ).addOnSuccessListener { location ->
            scope.launch {
                val monitoringStation =
                    Repository.getNearbyMonitoringStation(location.latitude, location.longitude)

                binding.textView.text = monitoringStation?.stationName
            }
        }
    }

AndroidManifest.xml

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

<application
        android:usesCleartextTraffic="true"
    </application>

웹브라우저 작업을 할 때 http의 경우 허용하지 않는다. api의 경우에도 마찬가지이다. 따라서 AndroidManifest에서 usesCleartextTraffic을 true 값으로 주고, 레트로핏 통신을 가능하게 하기 위해 INTERNET 퍼미션도 허용해준다.

위와 같이 코드를 작성하고 실행시키면 아래와 같이 현재 위치에서 가장 근접한 측정소의 네임이 호출된다.



실시간 대기 정보 불러오기


가져온 측정소 정보를 전달하여 실시간 미세먼지 정보를 가져오도록 한다.

대기오염정보 샘플데이터를 받아와 kotlin data class file from JSON 통해 data class를 자동 생성해준다.

models/airquality package

// AirQualityResponse.kt
// Body.kt
// Header.kt
// MeasuredValue.kt
// Response.kt

Grade를 enum값으로 전달해준다.

models/airquality/Grade

enum class Grade(
    val label: String,
    val emoji: String,
    @ColorRes val colorResId: Int
    ) {

    @SerializedName("1")
    GOOD("좋음", "😍", R.color.blue),

    @SerializedName("2")
    NORMAL("보통", "😊", R.color.green),

    @SerializedName("3")
    BAD("나쁨", "😰", R.color.yellow),

    @SerializedName("4")
    AWFUL("매우 나쁨", "😡", R.color.red),

    UNKNOWN("미측정", "🙄", R.color.gray);

    override fun toString(): String {
        return "$label $emoji"
    }
}

Grade에 대한 정의를 완료하고, MesuredValue.kt에서 Grade를 매기는 것에 응답값으로 String이 아닌 Grade 값을 주어 Grade 파일로 매핑이 되어야 하므로 String 값을 Grade 값으로 꼭 바꿔줘야 한다!!



services/AirKoreaApiService

interface AirKoreaApiService {

 @GET("B552584/ArpltnInforInqireSvc/getMsrstnAcctoRltmMesureDnsty" +
            "?serviceKey=${BuildConfig.AIR_KOREA_SERVICE_KEY}" +
            "&returnType=json" +
            "&dataTerm=DAILY" +
            "&ver=1.3")
    suspend fun getRealtimeAirQualities(
        @Query("stationName") stationName: String
    ): Response<AirQualityResponse>
}

Repository에서 직접 호출부를 구현한다.

Repository

suspend fun getLatestAirQualityData(stationName: String): MeasuredValue? =
        airKoreaApiService
            .getRealtimeAirQualities(stationName)
            .body()
            ?.response
            ?.body
            ?.measuredValues
            ?.firstOrNull()

그리고 구현한 것을 MainActivity에서 호출해준다.

MainActivity

private fun fetchAirQualityData() {
        
  ...
  cancellationTokenSource!!.token
        ).addOnSuccessListener { location ->
            scope.launch {
                val monitoringStation =
                    Repository.getNearbyMonitoringStation(location.latitude, location.longitude)
                val measuredValue =
                    Repository.getLatestAirQualityData(monitoringStation!!.stationName!!)

                binding.textView.text = measuredValue.toString()

            }
        }
    }



실시간 대기 정보 보여주기


레이아웃 구성

activity_main.xml

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
			android:id="@+id/contentsLayout"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            tools:background="@color/gray"
            tools:context=".MainActivity">

            <TextView
                android:id="@+id/measuringStationNameTextView"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginTop="50dp"
                android:textColor="@color/white"
                android:textSize="40sp"
                android:textStyle="bold"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent"
                tools:text="강남대로" />

            <TextView
                android:id="@+id/totalGradeLabelTextView"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginTop="6dp"
                android:textColor="@color/white"
                android:textSize="20sp"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/measuringStationNameTextView"
                tools:text="매우 나쁨" />

            <TextView
                android:id="@+id/totalGradeEmojiTextView"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:textColor="@color/white"
                android:textSize="95sp"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/totalGradeLabelTextView"
                tools:text="😀" />

            <TextView
                android:id="@+id/fineDustInformationTextView"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_marginTop="16dp"
                android:gravity="center"
                android:textColor="@color/white"
                android:textSize="16sp"
                app:layout_constraintEnd_toStartOf="@id/ultraFineDustInformationTextView"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/totalGradeEmojiTextView"
                tools:text="미세먼지: 40 😀" />

            <TextView
                android:id="@+id/ultraFineDustInformationTextView"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:gravity="center"
                android:textColor="@color/white"
                android:textSize="16sp"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toEndOf="@id/fineDustInformationTextView"
                app:layout_constraintTop_toTopOf="@id/fineDustInformationTextView"
                tools:text="초세먼지: 10 😥" />

            <View
                android:id="@+id/upperDivider"
                android:layout_width="0dp"
                android:layout_height="1dp"
                android:layout_marginHorizontal="20dp"
                android:layout_marginTop="20dp"
                android:alpha="0.5"
                android:background="@color/white"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/fineDustInformationTextView" />

            <LinearLayout
                android:layout_width="0dp"
                android:layout_height="0dp"
                android:layout_marginHorizontal="30dp"
                android:layout_marginVertical="10dp"
                android:orientation="vertical"
                app:layout_constraintBottom_toBottomOf="@id/lowerDivider"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="@id/upperDivider">

                <include
                    android:id="@+id/so2Item"
                    layout="@layout/view_measured_item"
                    android:layout_width="match_parent"
                    android:layout_height="0dp"
                    android:layout_weight="1" />

                <include
                    android:id="@+id/coItem"
                    layout="@layout/view_measured_item"
                    android:layout_width="match_parent"
                    android:layout_height="0dp"
                    android:layout_weight="1" />

                <include
                    android:id="@+id/o3Item"
                    layout="@layout/view_measured_item"
                    android:layout_width="match_parent"
                    android:layout_height="0dp"
                    android:layout_weight="1" />

                <include
                    android:id="@+id/no2Item"
                    layout="@layout/view_measured_item"
                    android:layout_width="match_parent"
                    android:layout_height="0dp"
                    android:layout_weight="1" />

            </LinearLayout>

            <View
                android:id="@+id/lowerDivider"
                android:layout_width="0dp"
                android:layout_height="1dp"
                android:layout_marginHorizontal="20dp"
                android:layout_marginBottom="12dp"
                android:alpha="0.5"
                android:background="@color/white"
                app:layout_constraintBottom_toTopOf="@id/measuringStationAddressTextView"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent" />

            <TextView
                android:id="@+id/measuringStationAddressTextView"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_marginHorizontal="30dp"
                android:layout_marginBottom="20dp"
                android:maxLines="1"
                android:textColor="@color/white"
                app:autoSizeMaxTextSize="12sp"
                app:autoSizeMinTextSize="8sp"
                app:autoSizeTextType="uniform"
                app:layout_constraintBottom_toTopOf="@id/additionalInformationTextView"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                tools:text="측정소 위치: 서울시 강남대로..." />

            <TextView
                android:id="@+id/additionalInformationTextView"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:alpha="0.3"
                android:background="@color/black"
                android:drawablePadding="6dp"
                android:paddingHorizontal="16dp"
                android:paddingVertical="6dp"
                android:text="@string/additionalInformation"
                android:textColor="@color/white"
                android:textSize="10sp"
                app:drawableStartCompat="@drawable/ic_outline_info_24"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                tools:ignore="SmallSp" />

        </androidx.constraintlayout.widget.ConstraintLayout>

layout/view_measured_item.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    android:paddingHorizontal="10dp"
    android:orientation="horizontal"
    tools:background="@color/gray"
    tools:layout_height="50dp">

    <TextView
        android:id="@+id/labelTextView"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:textColor="@color/white"
        android:textSize="16sp"
        tools:text="이황산가스" />

    <TextView
        android:id="@+id/gradeTextView"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:gravity="end"
        android:textColor="@color/white"
        android:textSize="16sp"
        tools:text="좋음 😀" />

    <TextView
        android:id="@+id/valueTextView"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:gravity="end"
        android:textColor="@color/white"
        android:textSize="16sp"
        tools:text="130 ppm" />

</LinearLayout>


monitoringStation 정보 뿌려주기

MainActivity

scope.launch {
                val monitoringStation =
                    Repository.getNearbyMonitoringStation(location.latitude, location.longitude)
                val measuredValue =
                    Repository.getLatestAirQualityData(monitoringStation!!.stationName!!)

                displayAirQualityData(monitoringStation, measuredValue!!)
            }
            

@SuppressLint("SetTextI18n")
    fun displayAirQualityData(monitoringStation: MonitoringStation, measuredValue: MeasuredValue) {
        binding.measuringStationNameTextView.text = monitoringStation.stationName
        binding.measuringStationAddressTextView.text = monitoringStation.addr

		// 어떠한 이슈로 파싱이 안되어서 grade를 제대로 가져오지 못할 경우 null이기 때문에
		// 이것을 UNKNOWN으로 변환해주는 작업이 필요
        (measuredValue.khaiGrade ?: Grade.UNKNOWN).let { grade ->
            binding.root.setBackgroundResource(grade.colorResId)
            binding.totalGradeLabelTextView.text = grade.label
            binding.totalGradeEmojiTextView.text = grade.emoji
        }

        with(measuredValue) {
            binding.fineDustInformationTextView.text =
                "미세먼지: $pm10Value ㎍/㎥ ${(pm10Grade ?: Grade.UNKNOWN).emoji}"
            binding.ultraFineDustInformationTextView.text =
                "초미세먼지: $pm25Value ㎍/㎥ ${(pm25Grade ?: Grade.UNKNOWN).emoji}"

            with(binding.so2Item) {
                labelTextView.text = "아황산가스"
                gradeTextView.text = (so2Grade ?: Grade.UNKNOWN).toString()
                valueTextView.text = "$so2Value ppm"
            }
            with(binding.coItem) {
                labelTextView.text = "일산화탄소"
                gradeTextView.text = (coGrade ?: Grade.UNKNOWN).toString()
                valueTextView.text = "$coValue ppm"
            }
            with(binding.o3Item) {
                labelTextView.text = "오존"
                gradeTextView.text = (o3Grade ?: Grade.UNKNOWN).toString()
                valueTextView.text = "$o3Value ppm"
            }
            with(binding.no2Item) {
                labelTextView.text = "이산화질소"
                gradeTextView.text = (no2Grade ?: Grade.UNKNOWN).toString()
                valueTextView.text = "$no2Value ppm"
            }
        }
    }

에러 발생시 처리

에러가 났을 때 스와이프를 통해 재시도 할 수 있도록 한다.
먼저 스와이프를 사용하기 위해 아래와 같이 의존성을 추가한다.

dependencies {
    implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
}

activity_main.xml

<androidx.swiperefreshlayout.widget.SwipeRefreshLayout 
    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/refresh"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <ProgressBar
            android:id="@+id/progressBar"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center" />

        <TextView
            android:id="@+id/errorDescription"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:gravity="center"
            android:text="@string/error_message"
            android:visibility="gone" />

constraintLayout을 감싸는 swiperefreshlayout과 FrameLayout 그리고 스와이프시 로딩을 띄우기 위해 ProgressBar, 에러가 발생시 에러문구를 나타낼 TextView를 구성한다.

constraintLayout의 경우 실제로 보여지기 전까지는 숨겨지는 것이 좋기 때문에 alpha="0" 을 준다. (visibility를 주지 않고 alpha를 준 이유는 로딩이 끝난 후 alpha 값을 변환시켜 페이드인/아웃 효과를 주기 위함)

try-catch문을 통해 예외처리를 해준다.

MainActiity

scope.launch {
                binding.errorDescription.visibility = View.GONE
                binding.progressBar.isGone = true
                try {
                    val monitoringStation =
                        Repository.getNearbyMonitoringStation(location.latitude, location.longitude)
                    val measuredValue =
                        Repository.getLatestAirQualityData(monitoringStation!!.stationName!!)

                    displayAirQualityData(monitoringStation, measuredValue!!)
                } catch (exception: Exception) {
                    exception.printStackTrace()
                    binding.errorDescription.visibility = View.VISIBLE
                    binding.contentsLayout.alpha = 0F
                } finally {
                    binding.errorDescription.visibility = View.GONE
                    binding.refresh.isRefreshing = false
                }
            }
fun displayAirQualityData(monitoringStation: MonitoringStation, measuredValue: MeasuredValue) {
        binding.contentsLayout.animate()
            .alpha(1F)
            .start()

animate()를 통해 알파값을 1F로 주어 미세먼지정보 창이 보여지도록 하고,
에러가 뜨면 미세먼지정보에 대한 페이지를 보이지 않게 하고 에러문구를 뜨게하기 위해 에러문구(errorDescription)는 VISIBLE, 미세먼지정보 창(contentsLayout)의 알파값은 0F으로 준다.

스와이프 refresh에 대한 이벤트리스너를 구현해준다.

private fun bindViews() {
        binding.refresh.setOnRefreshListener {
            fetchAirQualityData()
        }
    }


위젯 제공하기


위젯 구성 요소 공식문서

  • AppWidgetProviderInfo
  • AppWidgetProvider
  • Layout

먼저, 뼈대를 구성하는 초기 레이아웃 xml 파일을 만든다.

layout/widget_simple.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@drawable/shape_widget_background"
    android:gravity="center"
    android:orientation="vertical"
    tools:background="@color/black"
    tools:layout_height="50dp"
    tools:layout_width="110dp">

    <TextView
        android:id="@+id/labelTextView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/fine_dust"
        android:textColor="@color/white"
        android:textSize="10sp"
        android:visibility="gone"
        tools:ignore="SmallSp"
        tools:visibility="visible" />

    <TextView
        android:id="@+id/resultTextView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/loading"
        android:textColor="@color/white"
        android:textSize="15sp"
        tools:text="😀" />

    <TextView
        android:id="@+id/gradeLabelTextView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textColor="@color/white"
        android:textSize="12sp"
        android:visibility="gone"
        tools:text="매우 나쁨"
        tools:visibility="visible" />

</LinearLayout>

다음으로 위젯에 관한 정보를 담은 xml 파일을 생성한다. 레아웃 사이즈, 업데이트 주기 정보 등에 대한 것을 정의한다.

자세한 정보는 공식문서를 확인하자.

xml/widget_simple_info.xml

<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:initialLayout="@layout/widget_simple"
    android:minWidth="110dp"
    android:minHeight="50dp"
    android:resizeMode="none"
    android:updatePeriodMillis="3600000"
    android:widgetCategory="home_screen" />

이렇게 하면 위젯에 정보를 제공하는 파일은 완료된다.

appwidget/SimpleAirQualityWidgetProvider

class SimpleAirQualityWidgetProvider : AppWidgetProvider() {

    override fun onUpdate(
        context: Context?,
        appWidgetManager: AppWidgetManager?,
        appWidgetIds: IntArray?
    ) {
        super.onUpdate(context, appWidgetManager, appWidgetIds)
    }
}


이제 시스템에서 위젯을 요청하는 이벤트 발생 시 수신해서 실제로 위젯을 제공하고 갱신 및 활성화/비활성화, 삭제 등을 하는 AppWidgetProvider를 정의해준다.

AndroidManifest.xml

<receiver android:name=".appwidget.SimpleAirQualityWidgetProvider"
            android:exported="false">
            <intent-filter>
                <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
            </intent-filter>
            <meta-data android:name="android.appwidget.provider"
                android:resource="@xml/widget_simple_info"/>
        </receiver>

receiver를 등록하고 실행하면 android:exported needs to be explicitly specified for <receiver> 오류가 발생한다.
해결방법은 이곳에서 확인, android:exported="false" 를 추가해주니 해결되었다.



위젯 갱신하기


onUpdate()에서 위치 정보를 가져오고 그 정보를 기반으로 인터넷 통신을 해야 한다.

백그라운 상태에서의 위치접근은 제약이 많이 따른다. 따라서 포어그라운드 권한 요청을 통해 위치를 가져올 것이다.

AndroidManifest.xml

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

MainActivity

override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)

		val locationPermissionGranted =
            requestCode == REQUEST_ACCESS_LOCATION_PERMISSIONS &&
                    grantResults[0] == PackageManager.PERMISSION_GRANTED

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            if (!locationPermissionGranted) {
                finish()
            } else {
                val backgroundLocationPermissionGranted =
                    ActivityCompat.checkSelfPermission(
                        this,
                        Manifest.permission.ACCESS_BACKGROUND_LOCATION
                    ) == PackageManager.PERMISSION_GRANTED
                val shouldShowBackgroundPermissionRationale =
                    shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_BACKGROUND_LOCATION)

                if (!backgroundLocationPermissionGranted && shouldShowBackgroundPermissionRationale) {
                    showBackgroundLocationPermissionRationaleDialog()
                } else {
                    fetchAirQualityData()
                }
            }
        } else {
            if (!locationPermissionGranted) {
                finish()
            } else {
                fetchAirQualityData()
            }
        }
    }
    
    
@RequiresApi(Build.VERSION_CODES.Q)
    private fun requestBackgroundLocationPermissions() {
        ActivityCompat.requestPermissions(
            this,
            arrayOf(
                Manifest.permission.ACCESS_BACKGROUND_LOCATION),
            REQUEST_BACKGROUND_ACCESS_LOCATION_PERMISSIONS
        )
    }
    
companion object {
        private const val REQUEST_BACKGROUND_ACCESS_LOCATION_PERMISSIONS = 101
    } 



이제 위젯을 갱신해보자

먼저 서비스를 시작해야하기 때문에 서비스를 정의해야 한다. 서비스 안에서 lifecycle 코루틴을 사용할 것이기 때문에 의존성을 추가해준다.

implementation 'androidx.lifecycle:lifecycle-service:2.4.1'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'

Foreground Service 는 Notification 접근이 필요하다.
Foreground Service를 사용하기 위해서는 안드로이드 9 (API level 28) 이상부터는 권한 허가를 해줘야 한다.

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

먼저 lifecycle을 상속시켜준다.

class SimpleAirQualityWidgetProvider : AppWidgetProvider() { }

채널을 만들고, notification도 만들어준다.

SimpleAirQualityWidgetProvider

private fun createChannelIfNeeded() {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                (getSystemService(NOTIFICATION_SERVICE) as? NotificationManager)
                    ?.createNotificationChannel(
                        NotificationChannel(
                            WIDGET_REFRESH_CHANNEL_ID,
                            "위젯 갱신 채절",
                            NotificationManager.IMPORTANCE_LOW
                        )
                    )
            }
        }

        private fun createNotification(): Notification =
            NotificationCompat.Builder(this)
                .setChannelId(WIDGET_REFRESH_CHANNEL_ID)
                .setSmallIcon(R.drawable.ic_baseline_refresh_24)
                .build()

서비스가 시작되면 위치정보를 가장 먼저 가져와야 한다.

LocationServices.getFusedLocationProviderClient(this).lastLocation
                .addOnSuccessListener { location ->
                    lifecycleScope.launch {
                        try {
                            val nearByMonitoringStation = Repository.getNearbyMonitoringStation(
                                location.latitude,
                                location.longitude
                            )
                            val measuredValue =
                                Repository.getLatestAirQualityData(nearByMonitoringStation!!.stationName!!)
                            val updateViews =
                                RemoteViews(packageName, R.layout.widget_simple).apply {
                                    setViewVisibility(R.id.labelTextView, View.VISIBLE) // visible=gone 이기 때문에 VISIBLE 처리 해줌
                                    setViewVisibility(R.id.gradeLabelTextView, View.VISIBLE)

                                    val currentGrade = (measuredValue?.khaiGrade ?: Grade.UNKNOWN)
                                    setTextViewText(R.id.resultTextView, currentGrade.emoji)
                                    setTextViewText(R.id.gradeLabelTextView, currentGrade.label)
                                }

                            updateWidget(updateViews)
                        } catch (exception : Exception) {
                            exception.printStackTrace()
                        } finally {
                            stopSelf()
                        }
                    }
                }

            return super.onStartCommand(intent, flags, startId)
        }
        

private fun updateWidget(updateViews: RemoteViews) {
            val widgetProvider = ComponentName(this, SimpleAirQualityWidgetProvider::class.java)
            AppWidgetManager.getInstance(this).updateAppWidget(widgetProvider, updateViews)
        }

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {

            if (ActivityCompat.checkSelfPermission(
                    this,
                    Manifest.permission.ACCESS_BACKGROUND_LOCATION
                ) != PackageManager.PERMISSION_GRANTED
            ) {
                val updateViews = RemoteViews(packageName, R.layout.widget_simple).apply {
                    setTextViewText(
                        R.id.resultTextView,
                        "권한 없음"
                    )
                }
                updateWidget(updateViews)
                stopSelf()

                return super.onStartCommand(intent, flags, startId)
            }
            
            
override fun onDestroy() {
            super.onDestroy()
            stopForeground(true)
        }

권한이 없으면 remoteView에다 '권한없음'을 보여주고 stopSelf 통해서 서비스를 종료시킨다.
포어그라운드로 보냈던 startForeground를 했기 때문에 이걸 종료해줘야 상태바에서 사라지게 된다. 그러기 위해 onDestroy()에서 stopForeground(true) 값을 줘 notification을 지운다.

override fun onUpdate(
        context: Context?,
        appWidgetManager: AppWidgetManager?,
        appWidgetIds: IntArray?
    ) {
        super.onUpdate(context, appWidgetManager, appWidgetIds)

        ContextCompat.startForegroundService(
            context!!,
            Intent(context, UpdateWidgetService::class.java)
        )
    }

서비스를 포어그라운드로 동작시키기위해 ContextCompat.startForegroundService() 메서드를 사용한다.



최종 SimpleAirQualityWidgetProvider.kt 코드

class SimpleAirQualityWidgetProvider : AppWidgetProvider() {

    override fun onUpdate(
        context: Context?,
        appWidgetManager: AppWidgetManager?,
        appWidgetIds: IntArray?
    ) {
        super.onUpdate(context, appWidgetManager, appWidgetIds)

        ContextCompat.startForegroundService(
            context!!,
            Intent(context, UpdateWidgetService::class.java)
        )
    }

    class UpdateWidgetService : LifecycleService() {

        override fun onCreate() {
            super.onCreate()

            createChannelIfNeeded()
            startForeground(
                NOTIFICATION_ID,
                createNotification()

            )
        }

        override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {

            if (ActivityCompat.checkSelfPermission(
                    this,
                    Manifest.permission.ACCESS_BACKGROUND_LOCATION
                ) != PackageManager.PERMISSION_GRANTED
            ) {
                val updateViews = RemoteViews(packageName, R.layout.widget_simple).apply {
                    setTextViewText(
                        R.id.resultTextView,
                        "권한 없음"
                    )
                }
                updateWidget(updateViews)
                stopSelf()

                return super.onStartCommand(intent, flags, startId)
            }
            // 실시간 업데이트된 걸 가져올 필요 없이 다른 곳에서 최신 업데이트된 장소 있으면 가져오도록
            LocationServices.getFusedLocationProviderClient(this).lastLocation
                .addOnSuccessListener { location ->
                    lifecycleScope.launch {
                        try {
                            val nearByMonitoringStation = Repository.getNearbyMonitoringStation(
                                location.latitude,
                                location.longitude
                            )
                            val measuredValue =
                                Repository.getLatestAirQualityData(nearByMonitoringStation!!.stationName!!)
                            val updateViews =
                                RemoteViews(packageName, R.layout.widget_simple).apply {
                                    setViewVisibility(R.id.labelTextView, View.VISIBLE)
                                    setViewVisibility(R.id.gradeLabelTextView, View.VISIBLE)

                                    val currentGrade = (measuredValue?.khaiGrade ?: Grade.UNKNOWN)
                                    setTextViewText(R.id.resultTextView, currentGrade.emoji)
                                    setTextViewText(R.id.gradeLabelTextView, currentGrade.label)
                                }

                            updateWidget(updateViews)
                        } catch (exception : Exception) {
                            exception.printStackTrace()
                        } finally {
                            stopSelf()
                        }
                    }
                }

            return super.onStartCommand(intent, flags, startId)
        }

        override fun onDestroy() {
            super.onDestroy()
            stopForeground(true)
        }

        private fun createChannelIfNeeded() {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                (getSystemService(NOTIFICATION_SERVICE) as? NotificationManager)
                    ?.createNotificationChannel(
                        NotificationChannel(
                            WIDGET_REFRESH_CHANNEL_ID,
                            "위젯 갱신 채절",
                            NotificationManager.IMPORTANCE_LOW
                        )
                    )
            }
        }

        private fun createNotification(): Notification =
            NotificationCompat.Builder(this)
                .setChannelId(WIDGET_REFRESH_CHANNEL_ID)
                .setSmallIcon(R.drawable.ic_baseline_refresh_24)
                .build()

        private fun updateWidget(updateViews: RemoteViews) {
            val widgetProvider = ComponentName(this, SimpleAirQualityWidgetProvider::class.java)
            AppWidgetManager.getInstance(this).updateAppWidget(widgetProvider, updateViews)
        }
    }

    companion object {
        private const val WIDGET_REFRESH_CHANNEL_ID = "WIDGET_REFRESH_CHANNEL_ID"
        private const val NOTIFICATION_ID = 101
    }
}


안드로이드10 이상일 경우 location의 서비스 타입을 정의해줘야 한다.

AndroidManifest.xml

<service android:name=".appwidget.SimpleAirQualityWidgetProvider$UpdateWidgetService"
            android:foregroundServiceType="location"/>

이렇게 하면 아래 이미지와 같이 위젯이 갱신되어 뜨게 된다.

사애바에 포어그라운드에서 업데이트 된다는 표시가 떴다가 업데이트가 다 되면 표시가 사라지게 된다.

0개의 댓글