간단한 안드로이드 앱 만들기 (날씨 앱)

7. UI 업데이트 기능 분리 및 날씨 계산 유틸 분리 (중간정리)

리저브콜드브루 2025. 2. 12. 16:18
728x90
반응형

LocationManager

LocationManager의 기능을 분리하여 모듈성을 개선하려고 한다.

class LocationManager(private val activity: AppCompatActivity, private val textView: TextView) {

    private val fusedLocationClient = LocationServices.getFusedLocationProviderClient(activity) //FusedLocationProviderClient: 위치 서비스 초기화

    //위치 권한 확인
    fun checkLocationPermission(): Boolean {
        return ActivityCompat.checkSelfPermission(
            activity,
            Manifest.permission.ACCESS_FINE_LOCATION
        ) == PackageManager.PERMISSION_GRANTED
    }

    //위치 권한 요청
    fun requestLocationPermission(requestCode: Int) {
        ActivityCompat.requestPermissions(
            activity,
            arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
            requestCode
        )
    }

    // 현재 위치 가져오기
    fun getCurrentLocation() {
        if (checkLocationPermission()) {
            val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY,0L) // 즉시 한 번만 업데이트
                .setMaxUpdates(1) // 위치를 한 번만 가져옴
                .build()

            val locationCallback = object : LocationCallback() {
                @RequiresApi(Build.VERSION_CODES.TIRAMISU)
                override fun onLocationResult(locationResult: LocationResult) {
                    val location = locationResult.lastLocation
                    if (location != null) {
                        val latitude = location.latitude
                        val longitude = location.longitude

                        //위치 정보 UI 업데이트
                        updateLocationUI(latitude, longitude)

                        // DataStore에 저장
                        val locationDataStore = LocationDataStore(activity)
                        activity.lifecycleScope.launch {
                            locationDataStore.saveCurLatitude(latitude)
                            locationDataStore.saveCurLongitude(longitude)
                        }


                    } else {
                        textView.text = "위치 정보를 가져올 수 없습니다."
                        Log.e("Location", "새로 요청한 위치도 null입니다.")
                    }
                }
            }

            fusedLocationClient.requestLocationUpdates(
                locationRequest,
                locationCallback,
                Looper.getMainLooper()
            )
        }
    }

    //위치 정보 UI 업데이트
    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
    private fun updateLocationUI(latitude: Double, longitude: Double) {
        Log.d("Location", "위도: $latitude, 경도: $longitude") // 디버그용 로그

        val geocoder = Geocoder(activity, Locale.getDefault())
        val geocoderCallback = object : Geocoder.GeocodeListener {
            override fun onGeocode(addresses: MutableList<android.location.Address>) {
                activity.runOnUiThread { // UI 업데이트를 메인 스레드에서 수행, 비동기 스레드에서 UI 업데이트를 시도하면 에러발생
                    if (addresses.isNotEmpty()) {
                        val address = addresses[0]

                        // 도시(locality) 정보가 null이면 광역시/도(adminArea)로 대체
                        val city = address.locality ?: address.adminArea ?: "위치 불명"

                        textView.text = city // 도시 이름 표시
                        Log.d("Location", "도시: $city")
                    } else {
                        textView.text = "위치 확인 불가"
                        Log.e("Location", "Geocoder가 주소를 찾지 못했습니다.")
                    }
                }
            }

            override fun onError(errorMessage: String?) {
                activity.runOnUiThread { // 오류 발생 시에도 메인 스레드에서 UI 업데이트
                    textView.text = "위치 정보 오류"
                    Log.e("Location", "Geocoder 오류: $errorMessage")
                }
            }
        }

        // 비동기 방식으로 Geocoder 사용
        geocoder.getFromLocation(latitude, longitude, 1, geocoderCallback)
    }
}
  • LocationManager 기능
    • 위치 권한 확인
    • 위치 권한 요청
    • 현 위치 가져오기
    • 위치 정보 UI 업데이트

코드 분리

위치 정보 UI 업데이트  >> UIUpdater

그 외 >> LocationProvider

모듈화 프로젝트 구조

 

UIUpdater 추가

도시 정보 UI 업데이트 + 날씨 정보 UI 업데이트

package com.example.weatherapp.ui

...

class UIUpdater(
    private val activity: AppCompatActivity,
    private val weatherViewModel: WeatherViewModel,
    private val locationDataStore: LocationDataStore,
    private val textViewLocation: TextView,
    private val textViewTemperature: TextView) {

    //위치 정보 UI 업데이트
    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
    fun updateLocationUI() {
        // Fetch location and update UI
        activity.lifecycleScope.launch {

            val latitude = locationDataStore.cur_latitude.first()
            val longitude = locationDataStore.cur_longitude.first()
            if (latitude != null && longitude != null) {
                Log.d("UIUpdater", "위도: $latitude, 경도: $longitude")

                val geocoder = Geocoder(activity, Locale.getDefault())
                val geocoderCallback = Geocoder.GeocodeListener { addresses ->

                    activity.runOnUiThread { // UI 업데이트를 메인 스레드에서 수행, 비동기 스레드에서 UI 업데이트를 시도하면 에러발생
                        if (addresses.isNotEmpty()) {
                            val address = addresses[0]

                            // 도시(locality) 정보가 null이면 광역시/도(adminArea)로 대체
                            val city = address.locality ?: address.adminArea ?: "위치 불명"
                            textViewLocation.text = city // 도시 이름 표시
                            Log.d("UIUpdater", "도시: $city")
                        } else {
                            textViewLocation.text = "위치 확인 불가"
                            Log.e("UIUpdater", "Geocoder가 주소를 찾지 못했습니다.")
                        }
                    }
                }
                // 비동기 방식으로 Geocoder 사용
                geocoder.getFromLocation(latitude, longitude, 1, geocoderCallback)
            } else {
                textViewLocation.text = "위치 정보 없음"
            }
        }


    }

    fun observeAndUpdateUI() {
        // 현재 온도 관찰
        weatherViewModel.currentTemperature.observe(activity) { temperature ->
            textViewTemperature.text = temperature
        }

        // Fetch location and update UI
        activity.lifecycleScope.launch {

            val latitude = locationDataStore.cur_latitude.first()
            val longitude = locationDataStore.cur_longitude.first()
            if (latitude != null && longitude != null) {
                val (nx, ny) = WeatherViewModel.LatLngToGridConverter.latLngToGrid(latitude, longitude)

                weatherViewModel.fetchWeatherData(
                    serviceKey = "API_KEY", // 기상청 API 키
                    nx = nx,
                    ny = ny
                )
            } else {
                textViewLocation.text = "위치 정보 없음"
            }
        }
    }
}

 

LocationProvider 추가

위치 권한 확인, 요청 + 위치 정보 업데이트

package com.example.weatherapp.location

...

class LocationProvider(private val activity: AppCompatActivity, private val textView: TextView) {

    private val fusedLocationClient = LocationServices.getFusedLocationProviderClient(activity) //FusedLocationProviderClient: 위치 서비스 초기화

    //위치 권한 확인
    fun checkLocationPermission(): Boolean {
        return ActivityCompat.checkSelfPermission(
            activity,
            Manifest.permission.ACCESS_FINE_LOCATION
        ) == PackageManager.PERMISSION_GRANTED
    }

    //위치 권한 요청
    fun requestLocationPermission(requestCode: Int) {
        ActivityCompat.requestPermissions(
            activity,
            arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
            requestCode
        )
    }

    // 현재 위치 파악 및 저장
    fun fetchAndSaveLocation() {
        if (checkLocationPermission()) {
            val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY,0L) // 즉시 한 번만 업데이트
                .setMaxUpdates(1) // 위치를 한 번만 가져옴
                .build()

            val locationCallback = object : LocationCallback() {
                @RequiresApi(Build.VERSION_CODES.TIRAMISU)
                override fun onLocationResult(locationResult: LocationResult) {
                    val location = locationResult.lastLocation
                    if (location != null) {
                        val latitude = location.latitude
                        val longitude = location.longitude

                        // DataStore에 저장
                        val locationDataStore = LocationDataStore(activity)
                        activity.lifecycleScope.launch {
                            locationDataStore.saveCurLatitude(latitude)
                            locationDataStore.saveCurLongitude(longitude)
                        }
                    }else {
                            Log.e("Location", "새로 요청한 위치도 null입니다.")
                    }
                }
            }

            fusedLocationClient.requestLocationUpdates(
                locationRequest,
                locationCallback,
                Looper.getMainLooper()
            )
        }
        else {
            Log.e("Location", "위치 권한이 없습니다.")
        }
    }
}
  • 현재 위치를 가져올때 ui업데이트를 바로 하지않고 정보를 저장만 하도록 수정

 

UIUpdater를 호출부분 수정 (MainActivity)

package com.example.weatherapp

...

class MainActivity : AppCompatActivity() {
    // lateinit: 나중에 초기화 할 변수
    private lateinit var weatherViewModel: WeatherViewModel
    private lateinit var locationDataStore: LocationDataStore
    private lateinit var locationProvider: LocationProvider
    private lateinit var uiUpdater: UIUpdater

    private lateinit var textViewLocation: TextView // 위치를 표시할 텍스트뷰
    private lateinit var textViewTemperature: TextView //현재 위치의 현재 온도


    // companion object: 클래스의 정적 멤버 선언
    // 상수, 유틸리티 함수, 팩토리 메서드 등에 유용
    companion object {
        private const val LOCATION_PERMISSION_REQUEST_CODE = 1000 //위치 권한 요청 코드
    }

    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        initializeViews()
        initializeDependencies()


        if (locationProvider.checkLocationPermission()) {
            locationProvider.fetchAndSaveLocation() // 권한이 있을 경우 현재 위치 가져와서 저장
        } else {
            locationProvider.requestLocationPermission(LOCATION_PERMISSION_REQUEST_CODE) //위치 권한 요청
        }

        uiUpdater.updateLocationUI() //위치 UI 업데이트
        uiUpdater.observeAndUpdateUI() //날씨 UI 업데이트
    }

    private fun initializeViews() {
        textViewLocation = findViewById(R.id.textView_location) //텍스트뷰 초기화
        textViewTemperature = findViewById(R.id.textView_temparature)
    }

    private fun initializeDependencies() {
        weatherViewModel = ViewModelProvider(this).get(WeatherViewModel::class.java)
        locationDataStore = LocationDataStore(this)
        locationProvider = LocationProvider(this, textViewLocation)
        uiUpdater = UIUpdater(this, weatherViewModel, locationDataStore, textViewLocation, textViewTemperature)
    }

    // requestLocationPermission 콜백
    // 권한 요청 결과 처리
    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        if (requestCode == LOCATION_PERMISSION_REQUEST_CODE &&
            grantResults.isNotEmpty() &&
            grantResults[0] == PackageManager.PERMISSION_GRANTED
        ) {
            locationProvider.fetchAndSaveLocation() //권한이 허용된 경우 위치 가져오기
        } else {
            textViewLocation.text = "위치 권한이 필요합니다."
        }
    }

}
  • 각종 초기화 부분 메서드로 모듈화
  • 위치 정보, 날씨 정보 불러와 패치되도록 모듈화

WeatherViewModel에 속한 Util 메서드를 분리

WeatherUtils 생성하고 WeatehrViewModel로 부터 아래와 같이 분리한다.

package com.example.weatherapp.weather

import android.icu.text.SimpleDateFormat
import android.icu.util.Calendar
import java.util.Locale
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.math.sin
import kotlin.math.tan

object WeatherUtils {

    //가장 최근 정각 시각 (기상청 API getUltraSrtNcst (초단기실황) 업데이트 주기)
    fun getCurrentBaseTime(): Pair<String, String> {
        val calendar = Calendar.getInstance()

        // 현재 시각을 기준으로 정각 기준 시간으로 조정
        val minute = calendar.get(Calendar.MINUTE)
        if (minute < 40) {
            // 현재 시간이 40분 이전이라면 한 시간 전으로 이동
            calendar.add(Calendar.HOUR_OF_DAY, -1)
        }

        // 날짜와 시간 계산
        val baseDate = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(calendar.time)
        val baseTime = SimpleDateFormat("HH00", Locale.getDefault()).format(calendar.time) // 정각 시간 (예: 1200)

        return Pair(baseDate, baseTime)
    }

    object LatLngToGridConverter {
        private const val RE = 6371.00877    // 지도 반경 (km)
        private const val GRID = 5.0         // 격자 간격 (km)
        private const val SLAT1 = 30.0       // 표준 위도 1 (degree)
        private const val SLAT2 = 60.0       // 표준 위도 2 (degree)
        private const val OLON = 126.0       // 기준점의 경도 (degree)
        private const val OLAT = 38.0        // 기준점의 위도 (degree)
        private const val XO = 210 / GRID    // 기준점의 X 좌표
        private const val YO = 675 / GRID    // 기준점의 Y 좌표

        private val DEGRAD = Math.PI / 180.0
        private val RADDEG = 180.0 / Math.PI

        fun latLngToGrid(lat: Double, lon: Double): Pair<Int, Int> {
            val re = RE / GRID
            val slat1 = SLAT1 * DEGRAD
            val slat2 = SLAT2 * DEGRAD
            val olon = OLON * DEGRAD
            val olat = OLAT * DEGRAD

            val sn = tan(Math.PI * 0.25 + slat2 * 0.5) / tan(Math.PI * 0.25 + slat1 * 0.5)
            val snLog = Math.log(Math.cos(slat1) / Math.cos(slat2))
            val snValue = snLog / Math.log(sn)

            val sf = tan(Math.PI * 0.25 + slat1 * 0.5)
            val sfValue = sf.pow(snValue) * Math.cos(slat1) / snValue

            val ro = tan(Math.PI * 0.25 + olat * 0.5)
            val roValue = re * sfValue / ro.pow(snValue)

            val ra = tan(Math.PI * 0.25 + lat * DEGRAD * 0.5)
            val raValue = re * sfValue / ra.pow(snValue)

            var theta = lon * DEGRAD - olon
            if (theta > Math.PI) theta -= 2.0 * Math.PI
            if (theta < -Math.PI) theta += 2.0 * Math.PI
            theta *= snValue

            val x = raValue * sin(theta) + XO
            val y = roValue - raValue * Math.cos(theta) + YO

            return Pair(x.roundToInt(), y.roundToInt())
        }
    }

}

최종 프로젝트 구조

최종 프로젝트 구조

728x90
반응형