Android协程上下文导致异常处理失效问题求助
天气APP无效城市搜索时崩溃,异常未被Activity捕获
我开发的天气APP带有搜索栏,用户可搜索地点获取对应天气数据。设置了异常处理逻辑,期望输入无效城市名时APP不崩溃,而是显示提示Snackbar,但实际输入无效内容时APP仍崩溃。
当输入无效城市名时,地理编码API返回空数组触发EmptyApiResponseException,该异常会依次被数据源、仓库、ViewModel捕获并重新抛出,按设计应该最终被Activity的catch块捕获,但实际未生效,导致崩溃。怀疑是协程上下文的问题,但不知道怎么修复。
Activity中的搜索提交代码
override fun onQueryTextSubmit(query: String): Boolean { search.collapseActionView() lifecycleScope.launch(Dispatchers.IO) { try { viewModel.getSearchedLocationWeather(query) withContext(Dispatchers.Main) { val currentDestination = findNavController(R.id.nav_host_fragment_content_home).currentDestination if (currentDestination?.id == R.id.nav_home) { val action = MainScreenFragmentDirections.actionNavHomeToSearchResultFragment() findNavController(R.id.nav_host_fragment_content_home).navigate( action ) } else if (currentDestination?.id == R.id.nav_saved) { val action = SavedLocationFragmentDirections.actionNavSavedToNavSearch() findNavController(R.id.nav_host_fragment_content_home).navigate( action ) } } } catch (e: Exception) { Log.d(WEATHER_ERROR, "$e for $query") withContext(Dispatchers.Main) { val snackbar = Snackbar.make( findViewById(android.R.id.content), getString(R.string.invalid_query_input), Snackbar.LENGTH_SHORT ) val snackbarView = snackbar.view val snackbarText = snackbarView.findViewById<TextView>(com.google.android.material.R.id.snackbar_text) snackbarText.setTextColor(ContextCompat.getColor(this@MainActivity, R.color.white)) snackbarView.background = ContextCompat.getDrawable(this@MainActivity, R.drawable.snackbar_background) snackbar.show() } } } searchView.clearFocus() return true }
ViewModel中的方法
fun getSearchedLocationWeather(query: String) { viewModelScope.launch(Dispatchers.Main) { try { weatherRepository.getSearchedWeather(query).collect { data -> try { val weatherData = convertWeatherDtoToWeatherModel(data.weather) _currentWeather.value = LocationWeatherModel( location = LocationModel(data.location.cityName, data.location.country), weather = weatherData ) } catch (e: Exception) { Log.d(WEATHER_ERROR, "$query viewmodel") throw e } } } catch (e: Exception) { Log.d(WEATHER_ERROR, "$query viewmodel") throw e } } }
仓库中的方法
override fun getSearchedWeather(cityName: String): Flow<LocationWithWeatherDataDto> = flow{ try{ remoteWeatherDataSource.getWeather(cityName).collect{ emit(it) } } catch (e : Exception){ Log.e(WEATHER_ERROR, "Error fetching weather data for $cityName", e) throw e } }.flowOn(Dispatchers.IO)
远程数据源的getWeather方法
override fun getWeather(cityName: String): Flow<LocationWithWeatherDataDto> = flow { try { getCoordinates(cityName).collect { coordinates -> val weatherData: WeatherApiModel val preferredUnit = dataStoreRepository.getUnit() weatherData = when(preferredUnit){ Unit.IMPERIAL -> weatherService.getWeather( coordinates.latitude, coordinates.longitude, "minutely,daily,alerts", "imperial", BuildConfig.OWM_KEY ) Unit.METRIC -> weatherService.getWeather( coordinates.latitude, coordinates.longitude, "minutely,daily,alerts", "metric", BuildConfig.OWM_KEY ) else -> { weatherService.getWeather( coordinates.latitude, coordinates.longitude, "minutely,daily,alerts", "imperial", BuildConfig.OWM_KEY ) } } val combinedData = LocationWithWeatherDataDto( LocationDto(coordinates.name, coordinates.country), weatherData.toWeatherDto() ) emit(combinedData) } } catch (e: HttpException) { Log.e(WEATHER_ERROR, "$e in DataSource") throw WeatherFetchException("HTTP error fetching weather data", e) } catch (e: Exception) { Log.e(WEATHER_ERROR, "$e in DataSource") throw WeatherFetchException("Error fetching weather data for $cityName in data source", e) } }.flowOn(Dispatchers.IO)
地理编码获取坐标方法
override fun getCoordinates(name: String): Flow<CoordinateApiModel> = flow { try { val coordinatesList = withContext(Dispatchers.IO) { weatherService.getCiyCoordinates(name, 1, BuildConfig.OWM_KEY) } if (coordinatesList.isNotEmpty()) { val firstCoordinate = coordinatesList[0] emit(firstCoordinate) } else { throw EmptyApiResponseException("Coordinates list is empty.") } } catch (e: HttpException) { throw WeatherFetchException("HTTP error fetching weather", e) } catch (e: Exception) { throw e } }
问题原因分析
核心问题是协程作用域隔离:
- Activity的
lifecycleScope协程中调用viewModel.getSearchedLocationWeather(query),但该方法内部启动了viewModelScope的新协程,两个协程属于不同作用域,异常无法跨作用域传播到Activity的catch块。 - ViewModel中调用
collect收集Flow时,异常被抛出到viewModelScope的协程中,而非Activity的lifecycleScope协程,导致Activity的catch块根本捕获不到。
修复方案
方案1:让ViewModel方法返回Flow,由Activity收集处理
修改ViewModel方法,直接返回Flow,将收集逻辑交给Activity,确保异常在同一协程作用域内被捕获:
// ViewModel中修改 fun getSearchedLocationWeather(query: String): Flow<LocationWeatherModel> = flow { weatherRepository.getSearchedWeather(query).collect { data -> val weatherData = convertWeatherDtoToWeatherModel(data.weather) emit(LocationWeatherModel( location = LocationModel(data.location.cityName, data.location.country), weather = weatherData )) } }.catch { e -> Log.d(WEATHER_ERROR, "$query viewmodel") throw e }.flowOn(Dispatchers.Main)
Activity中修改调用逻辑:
// Activity中修改onQueryTextSubmit override fun onQueryTextSubmit(query: String): Boolean { search.collapseActionView() lifecycleScope.launch(Dispatchers.IO) { try { viewModel.getSearchedLocationWeather(query).collect { weatherModel -> viewModel._currentWeather.value = weatherModel withContext(Dispatchers.Main) { val currentDestination = findNavController(R.id.nav_host_fragment_content_home).currentDestination if (currentDestination?.id == R.id.nav_home) { val action = MainScreenFragmentDirections.actionNavHomeToSearchResultFragment() findNavController(R.id.nav_host_fragment_content_home).navigate( action ) } else if (currentDestination?.id == R.id.nav_saved) { val action = SavedLocationFragmentDirections.actionNavSavedToNavSearch() findNavController(R.id.nav_host_fragment_content_home).navigate( action ) } } } } catch (e: Exception) { Log.d(WEATHER_ERROR, "$e for $query") withContext(Dispatchers.Main) { val snackbar = Snackbar.make( findViewById(android.R.id.content), getString(R.string.invalid_query_input), Snackbar.LENGTH_SHORT ) val snackbarView = snackbar.view val snackbarText = snackbarView.findViewById<TextView>(com.google.android.material.R.id.snackbar_text) snackbarText.setTextColor(ContextCompat.getColor(this@MainActivity, R.color.white)) snackbarView.background = ContextCompat.getDrawable(this@MainActivity, R.drawable.snackbar_background) snackbar.show() } } } searchView.clearFocus() return true }
方案2:在ViewModel中使用LiveData传递错误信息
如果不想修改Flow返回方式,可以在ViewModel中定义LiveData传递错误,避免跨协程作用域抛出异常:
// ViewModel中添加 private val _errorMessage = MutableLiveData<String?>() val errorMessage: LiveData<String?> = _errorMessage fun getSearchedLocationWeather(query: String) { viewModelScope.launch(Dispatchers.Main) { try { weatherRepository.getSearchedWeather(query).collect { data -> try { val weatherData = convertWeatherDtoToWeatherModel(data.weather) _currentWeather.value = LocationWeatherModel( location = LocationModel(data.location.cityName, data.location.country), weather = weatherData ) _errorMessage.value = null // 清除错误 } catch (e: Exception) { Log.d(WEATHER_ERROR, "$query viewmodel") _errorMessage.value = getString(R.string.invalid_query_input) } } } catch (e: Exception) { Log.d(WEATHER_ERROR, "$query viewmodel") _errorMessage.value = getString(R.string.invalid_query_input) } } }
Activity中观察LiveData显示Snackbar:
// Activity初始化时添加 viewModel.errorMessage.observe(this) { message -> message?.let { val snackbar = Snackbar.make( findViewById(android.R.id.content), it, Snackbar.LENGTH_SHORT ) val snackbarView = snackbar.view val snackbarText = snackbarView.findViewById<TextView>(com.google.android.material.R.id.snackbar_text) snackbarText.setTextColor(ContextCompat.getColor(this, R.color.white)) snackbarView.background = ContextCompat.getDrawable(this, R.drawable.snackbar_background) snackbar.show() viewModel._errorMessage.value = null // 重置错误信息 } }
关键注意点
- 协程异常仅能在同一协程作用域内被捕获,跨作用域的异常需通过回调、LiveData、Flow等方式传递。
viewModelScope的默认异常处理器会将未捕获的异常抛出,导致APP崩溃,因此需在ViewModel内处理异常或传递到上层。
内容的提问来源于stack exchange,提问作者user19334054
相关产品推荐
相关产品推荐

