간단한 안드로이드 앱 만들기 (날씨 앱)
14. 옵저버 오남용 수정
리저브콜드브루
2025. 2. 25. 13:55
728x90
반응형
이전까지의 코드에서 API 호출된 응답(Response)에 옵저버를 붙여서 날씨 데이터를 업데이트했고 날씨 데이터에 옵저버를 붙여서 UI 업데이트가 되도록 만들었다.
- LiveData(날씨 Response)를 다른 LiveData(날씨 Data)가 관찰하고, LiveData(날씨 Data) 를 UI 업데이트를 위해 관찰하는 구조에서 오류가 많았다
이 부분을 끊고자 많은 부분을 학습하고 수정했다
요점은 아래와 같다
- API 호출에 대한 응답은 옵저버를 붙이지 않는다
- 날씨 데이터값을 LiveData로써 업데이트는 유지
- 액티비티에서 날씨 데이터에 옵저버를 붙이도록 한다
변경된 주요 클래스
Repository
- API 응답값을 LiveData로 캐싱하던 부분을 삭제했다
class WeatherRepository {
private val nowCastResponse = mutableMapOf<String, NcstWeatherResponse>()
private val foreCastResponse = mutableMapOf<String, FcstWeatherResponse>()
suspend fun fetchNowcastData(serviceKey: String, cityKey: String, nx: Int, ny: Int): NcstWeatherResponse? {
return try {
val (baseDate, baseTime) = WeatherUtils.getCurrentNcstBaseTime()
val response: Response<NcstWeatherResponse> = RetrofitClient.instance.create(WeatherApi::class.java)
.getWeather(serviceKey, baseDate = baseDate, baseTime = baseTime, nx = nx, ny = ny)
if (response.isSuccessful) {
response.body()?.let {
nowCastResponse[cityKey] = it // 캐싱
return it
}
}
Log.e("WeatherRepository", "Nowcast API 요청 실패: ${response.errorBody()?.string()}")
nowCastResponse[cityKey] // 기존 캐시 데이터 반환
} catch (e: IOException) {
Log.e("WeatherRepository", "네트워크 오류: ${e.message}")
nowCastResponse[cityKey]
} catch (e: HttpException) {
Log.e("WeatherRepository", "HTTP 오류: ${e.message}")
nowCastResponse[cityKey]
}
}
suspend fun fetchForecastData(serviceKey: String, cityKey: String, nx: Int, ny: Int): FcstWeatherResponse? {
return try {
val (baseDate, baseTime) = WeatherUtils.getCurrentFcstBaseTime()
val response: Response<FcstWeatherResponse> = RetrofitClient.instance.create(WeatherApi::class.java)
.getForecast(serviceKey, baseDate = baseDate, baseTime = baseTime, fcstDate = baseDate, fcstTime = baseTime, nx = nx, ny = ny)
if (response.isSuccessful) {
response.body()?.let {
foreCastResponse[cityKey] = it // 캐싱
return it
}
}
Log.e("WeatherRepository", "Forecast API 요청 실패: ${response.errorBody()?.string()}")
foreCastResponse[cityKey]
} catch (e: IOException) {
Log.e("WeatherRepository", "네트워크 오류: ${e.message}")
foreCastResponse[cityKey]
} catch (e: HttpException) {
Log.e("WeatherRepository", "HTTP 오류: ${e.message}")
foreCastResponse[cityKey]
}
}
}
WeatherVieModel
- t1h, sky, pty 관련 날씨 데이터들을 WeatherData라는 데이터 클래스 하나로 통합 관리하도록 수정했다
data class WeatherData(
val temperature: String = "N/A",
val skyCondition: Int = 0,
val precipitationType: Int = 1,
val weatherIcon: Int = R.drawable.sunny_128
)
class WeatherViewModel(private val weatherRepository: WeatherRepository) : ViewModel() {
private val _weatherDataMap = mutableMapOf<String, MutableLiveData<WeatherData>>()
val weatherDataMap: MutableMap<String, MutableLiveData<WeatherData>> get() = _weatherDataMap
fun getWeatherLiveData(cityKey: String): MutableLiveData<WeatherData> {
return _weatherDataMap.getOrPut(cityKey) { MutableLiveData() }
}
fun fetchWeatherData(serviceKey: String, cityKey: String, nx: Int, ny: Int) {
Log.d("WeatherViewModel", "Fetching weather data for $cityKey")
viewModelScope.launch {
val nowcastData = weatherRepository.fetchNowcastData(serviceKey, cityKey, nx, ny)
val forecastData = weatherRepository.fetchForecastData(serviceKey, cityKey, nx, ny)
if (nowcastData != null && forecastData != null) {
val itemsNowcast = nowcastData.response?.body?.items?.item
val itemsForecast = forecastData.response?.body?.items?.item
val temperature = itemsNowcast?.find { it.category == "T1H" }?.obsrValue ?: "N/A"
val precipitationType = itemsNowcast?.find { it.category == "PTY" }?.obsrValue?.toIntOrNull() ?: 0
val skyCondition = itemsForecast?.find { it.category == "SKY" }?.fcstValue?.toIntOrNull() ?: 1
val weatherIcon = WeatherUtils.getWeatherIcon128(skyCondition, precipitationType)
Log.d("WeatherViewModel", "CityKey: $cityKey, Temp: $temperature, SKY: $skyCondition, PTY: $precipitationType, Icon: $weatherIcon")
val updatedWeatherData = WeatherData(
temperature = temperature,
skyCondition = skyCondition,
precipitationType = precipitationType,
weatherIcon = weatherIcon
)
getWeatherLiveData(cityKey).postValue(updatedWeatherData)
} else {
Log.e("WeatherViewModel", "Failed to fetch weather data for $cityKey")
}
}
}
}
변경된 액티비티 코드
MainActivity
- 액티비티에서 UI 업데이트를 위한 옵저버를 붙인다
class MainActivity : AppCompatActivity() {
private lateinit var weatherViewModel: WeatherViewModel
private lateinit var locationDataStore: LocationDataStore
private lateinit var locationProvider: LocationProvider
private lateinit var mapController: MapController // 지도 관리 클래스 추가
private lateinit var mapUIManager: MapUIManager
private lateinit var textViewLocation: TextView // 위치를 표시할 텍스트뷰
private lateinit var textViewTemperature: TextView //현재 위치의 현재 온도
private lateinit var imageViewWeatherIcon: ImageView //현재 위치의 날씨아이콘
private lateinit var mapView: MapView // 카카오맵 뷰
private lateinit var searchButton : ImageButton
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)
//초기화
initializeUI()
initializeDependencies()
setUIListener() // UI 리스너 등록
//API 호출 부분
checkLocationPermission() // 위치 권한 및 데이터 업데이트
callFetchWeatherData()
//UI 업데이트
updateLocationUI()
mapController.init{ kakaoMap ->
mapUIManager = MapUIManager(this, kakaoMap, mapController, locationDataStore)
}
}
override fun onResume() {
super.onResume()
mapController.onResume()
}
override fun onPause() {
super.onPause()
mapController.onPause()
}
private fun initializeUI() {
textViewLocation = findViewById(R.id.textView_location) //텍스트뷰 초기화
textViewTemperature = findViewById(R.id.textView_temparature)
imageViewWeatherIcon = findViewById(R.id.image_weather_icon)
mapView = findViewById(R.id.map_view)
searchButton = findViewById(R.id.searchButton)
}
private fun initializeDependencies() {
locationDataStore = LocationDataStore(this)
locationProvider = LocationProvider(this, locationDataStore)
weatherViewModel = ViewModelProvider(this, WeatherViewModelFactory())[WeatherViewModel::class.java]
mapController = MapController(this, mapView)
}
private fun checkLocationPermission() {
if (locationProvider.checkLocationPermission()) {
locationProvider.fetchLocation()
} else {
locationProvider.requestLocationPermission(LOCATION_PERMISSION_REQUEST_CODE)
}
}
private fun setUIListener()
{
searchButton.setOnClickListener{
val intent = Intent(this, SearchActivity::class.java)
startActivity(intent)
overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
}
}
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private fun updateLocationUI() {
lifecycleScope.launch {
val latitude = locationDataStore.cur_latitude.first()
val longitude = locationDataStore.cur_longitude.first()
if (latitude != null && longitude != null) {
Log.d("MainActivity", "위도: $latitude, 경도: $longitude")
val geocoder = Geocoder(this@MainActivity, Locale.getDefault())
geocoder.getFromLocation(latitude, longitude, 1) { addresses ->
if (addresses.isNotEmpty()) {
val address = addresses[0]
val city = address.locality ?: address.adminArea ?: "위치 불명"
textViewLocation.text = city
Log.d("MainActivity", "도시: $city")
} else {
textViewLocation.text = "위치 확인 불가"
Log.e("MainActivity", "Geocoder가 주소를 찾지 못했습니다.")
}
}
} else {
textViewLocation.text = "위치 정보 없음"
}
}
}
// 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.fetchLocation() //권한이 허용된 경우 위치 가져오기
} else {
textViewLocation.text = "위치 권한이 필요합니다."
}
}
private fun callFetchWeatherData() {
this.lifecycleScope.launch {
val latitude = locationDataStore.cur_latitude.first()
val longitude = locationDataStore.cur_longitude.first()
val cityKey = locationDataStore.locationKey.first()
if (latitude != null && longitude != null && cityKey != null) {
val (nx, ny) = WeatherUtils.LatLngToGridConverter.latLngToGrid(latitude, longitude)
// 옵저버 등록
registerWeatherUIObserver(cityKey)
// 날씨 데이터를 가져옴
weatherViewModel.fetchWeatherData(
serviceKey = "",
cityKey = cityKey,
nx = nx,
ny = ny
)
} else {
Log.e("MainActivity", "위치 정보 없음. 날씨 데이터를 가져올 수 없습니다.")
}
}
}
private fun registerWeatherUIObserver(cityKey: String) {
Log.d("MainActivity", "LiveData 옵저버 등록: $cityKey")
if (weatherViewModel.getWeatherLiveData(cityKey).hasActiveObservers()) {
Log.d("MainActivity", "이미 옵저버가 등록됨: $cityKey")
return
}
weatherViewModel.getWeatherLiveData(cityKey).observe(this) { weatherData ->
Log.d("MainActivity", "도시: $cityKey | 날씨 데이터 변경 감지됨: $weatherData")
textViewTemperature.text = "${weatherData.temperature}°C"
imageViewWeatherIcon.setImageResource(weatherData.weatherIcon)
mapUIManager.updateWeatherLabel(weatherData.weatherIcon)
}
}
}
SearchActivity
MainActivity와 유사한 구조가 되도록 수정
class SearchActivity : AppCompatActivity() {
private lateinit var recyclerView: RecyclerView
private lateinit var adapter: CityWeatherAdapter
private val cityWeatherList = mutableListOf<CityWeatherInfo>()
private lateinit var weatherViewModel: WeatherViewModel
private lateinit var cityList: List<CityCallInfo> // 주요 도시 위도/경도 데이터
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_search)
//초기화
initializeUI()
initializeDependencies()
cityList = CityDataProvider.cityList
//API 호출 부분
loadCityWeatherData()
}
private fun initializeUI() {
recyclerView = findViewById(R.id.recyclerView_search)
adapter = CityWeatherAdapter(cityWeatherList)
recyclerView.layoutManager = LinearLayoutManager(this)
recyclerView.adapter = adapter
}
private fun initializeDependencies(){
weatherViewModel = ViewModelProvider(this, WeatherViewModelFactory())[WeatherViewModel::class.java]
}
private fun loadCityWeatherData() {
for (city in cityList) {
val cityKey = "${city.latitude}_${city.longitude}"
// 도시 기본 데이터 추가 (초기 UI 표시)
cityWeatherList.add(CityWeatherInfo(cityKey, city.name, R.drawable.sunny_128, "로딩 중..."))
// RecyclerView 갱신
adapter.updateList(cityWeatherList)
// LiveData 옵저버 등록
registerWeatherObserver(cityKey, city.name)
// 날씨 데이터 가져오기
fetchWeatherData(cityKey, city.name, city.latitude, city.longitude)
}
}
private fun fetchWeatherData(cityKey: String, cityName: String, latitude: Double, longitude: Double) {
val (nx, ny) = WeatherUtils.LatLngToGridConverter.latLngToGrid(latitude, longitude)
lifecycleScope.launch {
weatherViewModel.fetchWeatherData(
serviceKey = "", // 기상청 API 키
cityKey = cityKey,
nx = nx,
ny = ny
)
}
}
private fun registerWeatherObserver(cityKey: String, cityName: String) {
Log.d("SearchActivity", "LiveData 옵저버 등록: $cityKey")
if (weatherViewModel.getWeatherLiveData(cityKey).hasActiveObservers()) {
Log.d("SearchActivity", "이미 옵저버가 등록됨: $cityKey")
return
}
weatherViewModel.getWeatherLiveData(cityKey).observe(this) { weatherData ->
Log.d("SearchActivity", "도시: $cityName ($cityKey) | 업데이트 감지됨: $weatherData")
updateCityWeatherUI(cityKey, weatherData.temperature, weatherData.weatherIcon)
}
}
private fun updateCityWeatherUI(cityKey: String, temperature: String, iconRes: Int) {
val index = cityWeatherList.indexOfFirst { it.cityKey == cityKey }
if (index != -1) {
cityWeatherList[index] = cityWeatherList[index].copy(
temperature = "$temperature°C",
weatherIcon = iconRes
)
adapter.notifyItemChanged(index)
}
}
}
728x90
반응형