调用 invalidate() 方法后,使用新数据的 MPAndroid Chart 消失了。

4
在我的天气应用中,有一个主碎片(MainFragment)带有一个按钮,点击该按钮会打开不同的碎片(SearchFragment),允许用户选择位置,并获取该位置的天气数据并在多个视图中显示,包括一个MPAndroid LineChart。我的问题是每当我从搜索碎片返回时,尽管为图表获取了新数据并调用了chart.notifyDataSetChanged()chart.invalidate()(也尝试过在其他线程上工作时建议使用chart.postInvalidate()),但在调用invalidate()后,图表仅会消失。我错过了什么?
const val UNIT_SYSTEM_KEY = "UNIT_SYSTEM"
const val LATEST_CURRENT_LOCATION_KEY = "LATEST_CURRENT_LOC"

class MainFragment : Fragment() {

// Lazy inject the view model
private val viewModel: WeatherViewModel by viewModel()
private lateinit var weatherUnitConverter: WeatherUnitConverter

private val TAG = MainFragment::class.java.simpleName

// View declarations
...

// OnClickListener to handle the current weather's "Details" layout expansion/collapse
private val onCurrentWeatherDetailsClicked = View.OnClickListener {
    if (detailsExpandedLayout.visibility == View.GONE) {
        detailsExpandedLayout.visibility = View.VISIBLE
        detailsExpandedArrow.setImageResource(R.drawable.ic_arrow_up_black)
    } else {
        detailsExpandedLayout.visibility = View.GONE
        detailsExpandedArrow.setImageResource(R.drawable.ic_down_arrow)
    }
}

// OnClickListener to handle place searching using the Places SDK
private val onPlaceSearchInitiated = View.OnClickListener {
    (activity as MainActivity).openSearchPage()
}

// RefreshListener to update the UI when the location settings are changed
private val refreshListener = SwipeRefreshLayout.OnRefreshListener {
    Toast.makeText(activity, "calling onRefresh()", Toast.LENGTH_SHORT).show()
    swipeRefreshLayout.isRefreshing = false
}

// OnClickListener to allow navigating from this fragment to the settings one
private val onSettingsButtonClicked: View.OnClickListener = View.OnClickListener {
    (activity as MainActivity).openSettingsPage()
}

override fun onCreateView(
    inflater: LayoutInflater, container: ViewGroup?,
    savedInstanceState: Bundle?
): View {
    val view = inflater.inflate(R.layout.main_fragment, container, false)
    // View initializations
    .....
    hourlyChart = view.findViewById(R.id.lc_hourly_forecasts)
    return view
}

   override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    setUpChart()
    lifecycleScope.launch {
        // Shows a lottie animation while the data is being loaded
        //scrollView.visibility = View.GONE
        //lottieAnimView.visibility = View.VISIBLE
        bindUIAsync().await()
        // Stops the animation and reveals the layout with the data loaded
        //scrollView.visibility = View.VISIBLE
        //lottieAnimView.visibility = View.GONE
    }
}



@SuppressLint("SimpleDateFormat")
    private fun bindUIAsync() = lifecycleScope.async(Dispatchers.Main) {
        // fetch current weather
        val currentWeather = viewModel.currentWeatherData

    // Observe the current weather live data
    currentWeather.observe(viewLifecycleOwner, Observer { currentlyLiveData ->
        if (currentlyLiveData == null) return@Observer

        currentlyLiveData.observe(viewLifecycleOwner, Observer { currently ->

            setCurrentWeatherDate(currently.time.toDouble())

            // Get the unit system pref's value
            val unitSystem = viewModel.preferences.getString(
                UNIT_SYSTEM_KEY,
                UnitSystem.SI.name.toLowerCase(Locale.ROOT)
            )

            // set up views dependent on the Unit System pref's value
            when (unitSystem) {
                UnitSystem.SI.name.toLowerCase(Locale.ROOT) -> {
                    setCurrentWeatherTemp(currently.temperature)
                    setUnitSystemImgView(unitSystem)
                }
                UnitSystem.US.name.toLowerCase(Locale.ROOT) -> {
                    setCurrentWeatherTemp(
                        weatherUnitConverter.convertToFahrenheit(
                            currently.temperature
                        )
                    )
                    setUnitSystemImgView(unitSystem)
                }
            }

            setCurrentWeatherSummaryText(currently.summary)
            setCurrentWeatherSummaryIcon(currently.icon)
            setCurrentWeatherPrecipProb(currently.precipProbability)
        })
    })

    // fetch the location
    val weatherLocation = viewModel.weatherLocation
    // Observe the location for changes
    weatherLocation.observe(viewLifecycleOwner, Observer { locationLiveData ->
        if (locationLiveData == null) return@Observer

        locationLiveData.observe(viewLifecycleOwner, Observer { location ->
            Log.d(TAG,"location update = $location")
            locationTxtView.text = location.name
        })
    })

    // fetch hourly weather
    val hourlyWeather = viewModel.hourlyWeatherEntries

    // Observe the hourly weather live data
    hourlyWeather.observe(viewLifecycleOwner, Observer { hourlyLiveData ->
        if (hourlyLiveData == null) return@Observer

        hourlyLiveData.observe(viewLifecycleOwner, Observer { hourly ->
            val xAxisLabels = arrayListOf<String>()
            val sdf = SimpleDateFormat("HH")
            for (i in hourly.indices) {
                val formattedLabel = sdf.format(Date(hourly[i].time * 1000))
                xAxisLabels.add(formattedLabel)
            }
            setChartAxisLabels(xAxisLabels)
        })
    })

    // fetch weekly weather
    val weeklyWeather = viewModel.weeklyWeatherEntries

    // get the timezone from the prefs
    val tmz = viewModel.preferences.getString(LOCATION_TIMEZONE_KEY, "America/Los_Angeles")!!

    // observe the weekly weather live data
    weeklyWeather.observe(viewLifecycleOwner, Observer { weeklyLiveData ->
        if (weeklyLiveData == null) return@Observer

        weeklyLiveData.observe(viewLifecycleOwner, Observer { weatherEntries ->
            // update the recyclerView with the new data
            (weeklyForecastRCV.adapter as WeeklyWeatherAdapter).updateWeeklyWeatherData(
                weatherEntries, tmz
            )
            for (day in weatherEntries) { //TODO:sp replace this with the full list once the repo issue is fixed
                val zdtNow = Instant.now().atZone(ZoneId.of(tmz))
                val dayZdt = Instant.ofEpochSecond(day.time).atZone(ZoneId.of(tmz))
                val formatter = DateTimeFormatter.ofPattern("MM-dd-yyyy")
                val formattedNowZtd = zdtNow.format(formatter)
                val formattedDayZtd = dayZdt.format(formatter)
                if (formattedNowZtd == formattedDayZtd) { // find the right week day whose data we want to use for the UI
                    initTodayData(day, tmz)
                }
            }
        })
    })

    // get the hourly chart's computed data
    val hourlyChartLineData = viewModel.hourlyChartData

    // Observe the chart's data
    hourlyChartLineData.observe(viewLifecycleOwner, Observer { lineData ->
        if(lineData == null) return@Observer

        hourlyChart.data = lineData // Error due to the live data value being of type Unit
    })

    return@async true
}

...

private fun setChartAxisLabels(labels: ArrayList<String>) {
    // Populate the X axis with the hour labels
    hourlyChart.xAxis.valueFormatter = IndexAxisValueFormatter(labels)
}

/**
 * Sets up the chart with the appropriate
 * customizations.
 */
private fun setUpChart() {
    hourlyChart.apply {
        description.isEnabled = false
        setNoDataText("Data is loading...")

        // enable touch gestures
        setTouchEnabled(true)
        dragDecelerationFrictionCoef = 0.9f

        // enable dragging
        isDragEnabled = true
        isHighlightPerDragEnabled = true
        setDrawGridBackground(false)
        axisRight.setDrawLabels(false)
        axisLeft.setDrawLabels(false)
        axisLeft.setDrawGridLines(false)
        xAxis.setDrawGridLines(false)
        xAxis.isEnabled = true

        // disable zoom functionality
        setScaleEnabled(false)
        setPinchZoom(false)
        isDoubleTapToZoomEnabled = false

        // disable the chart's legend
        legend.isEnabled = false

        // append extra offsets to the chart's auto-calculated ones
        setExtraOffsets(0f, 0f, 0f, 10f)

        data = LineData()
        data.isHighlightEnabled = false
        setVisibleXRangeMaximum(6f)
        setBackgroundColor(resources.getColor(R.color.bright_White, null))
    }

    // X Axis setup
    hourlyChart.xAxis.apply {
        position = XAxis.XAxisPosition.BOTTOM
        textSize = 14f
        setDrawLabels(true)
        setDrawAxisLine(false)
        granularity = 1f // one hour
        spaceMax = 0.2f // add padding start
        spaceMin = 0.2f // add padding end
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            typeface = resources.getFont(R.font.work_sans)
        }
        textColor = resources.getColor(R.color.black, null)
    }

    // Left Y axis setup
    hourlyChart.axisLeft.apply {
        setDrawLabels(false)
        setDrawGridLines(false)
        setPosition(YAxis.YAxisLabelPosition.OUTSIDE_CHART)
        isEnabled = false
        isGranularityEnabled = true
        // temperature values range (higher than probable temps in order to scale down the chart)
        axisMinimum = 0f
        axisMaximum = when (getUnitSystemValue()) {
            UnitSystem.SI.name.toLowerCase(Locale.ROOT) -> 50f
            UnitSystem.US.name.toLowerCase(Locale.ROOT) -> 150f
            else -> 50f
        }
    }

    // Right Y axis setup
   hourlyChart.axisRight.apply {
       setDrawGridLines(false)
       isEnabled = false
   }
}
}

ViewModel类:

class WeatherViewModel(
private val forecastRepository: ForecastRepository,
private val weatherUnitConverter: WeatherUnitConverter,
context: Context
) : ViewModel() {

private val appContext = context.applicationContext

// Retrieve the sharedPrefs
val preferences:SharedPreferences
    get() = PreferenceManager.getDefaultSharedPreferences(appContext)

// This will run only when currentWeatherData is called from the View
val currentWeatherData = liveData {
    val task = viewModelScope.async {  forecastRepository.getCurrentWeather() }
    emit(task.await())
}

val hourlyWeatherEntries = liveData {
    val task = viewModelScope.async {  forecastRepository.getHourlyWeather() }
    emit(task.await())
}

val weeklyWeatherEntries = liveData {
    val task = viewModelScope.async {
        val currentDateEpoch = LocalDate.now().toEpochDay()
        forecastRepository.getWeekDayWeatherList(currentDateEpoch)
    }
    emit(task.await())
}

val weatherLocation = liveData {
    val task = viewModelScope.async(Dispatchers.IO) {
        forecastRepository.getWeatherLocation()
    }
    emit(task.await())
}

val hourlyChartData = liveData {
    val task = viewModelScope.async(Dispatchers.Default) {
        // Build the chart data
        hourlyWeatherEntries.observeForever { hourlyWeatherLiveData ->
            if(hourlyWeatherLiveData == null) return@observeForever

            hourlyWeatherLiveData.observeForever {hourlyWeather ->
                createChartData(hourlyWeather)
            }
        }
    }
    emit(task.await())
}

/**
 * Creates the line chart's data and returns them.
 * @return The line chart's data (x,y) value pairs
 */
private fun createChartData(hourlyWeather: List<HourWeatherEntry>?): LineData {
    if(hourlyWeather == null) return LineData()

    val unitSystemValue = preferences.getString(UNIT_SYSTEM_KEY, "si")!!
    val values = arrayListOf<Entry>()

    for (i in hourlyWeather.indices) { // init data points
        // format the temperature appropriately based on the unit system selected
        val hourTempFormatted = when (unitSystemValue) {
            UnitSystem.SI.name.toLowerCase(Locale.ROOT) -> hourlyWeather[i].temperature
            UnitSystem.US.name.toLowerCase(Locale.ROOT) -> weatherUnitConverter.convertToFahrenheit(
                hourlyWeather[i].temperature
            )
            else -> hourlyWeather[i].temperature
        }

        // Create the data point
        values.add(
            Entry(
                i.toFloat(),
                hourTempFormatted.toFloat(),
                appContext.resources.getDrawable(determineSummaryIcon(hourlyWeather[i].icon), null)
            )
        )
    }
    Log.d("MainFragment viewModel", "$values")
    // create a data set and customize it
    val lineDataSet = LineDataSet(values, "")

    val color = appContext.resources.getColor(R.color.black, null)
    val offset = MPPointF.getInstance()
    offset.y = -35f

    lineDataSet.apply {
        valueFormatter = YValueFormatter()
        setDrawValues(true)
        fillDrawable = appContext.resources.getDrawable(R.drawable.gradient_night_chart, null)
        setDrawFilled(true)
        setDrawIcons(true)
        setCircleColor(color)
        mode = LineDataSet.Mode.HORIZONTAL_BEZIER
        this.color = color // line color
        iconsOffset = offset
        lineWidth = 3f
        valueTextSize = 9f
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            valueTypeface = appContext.resources.getFont(R.font.work_sans_medium)
        }
    }

    // create a LineData object using our LineDataSet
    val data = LineData(lineDataSet)
    data.apply {
        setValueTextColor(R.color.colorPrimary)
        setValueTextSize(15f)
    }
    return data
}

private fun determineSummaryIcon(icon: String): Int {
    return when (icon) {
        "clear-day" -> R.drawable.ic_sun
        "clear-night" -> R.drawable.ic_moon
        "rain" -> R.drawable.ic_precipitation
        "snow" -> R.drawable.ic_snowflake
        "sleet" -> R.drawable.ic_sleet
        "wind" -> R.drawable.ic_wind_speed
        "fog" -> R.drawable.ic_fog
        "cloudy" -> R.drawable.ic_cloud_coverage
        "partly-cloudy-day" -> R.drawable.ic_cloudy_day
        "partly-cloudy-night" -> R.drawable.ic_cloudy_night
        "hail" -> R.drawable.ic_hail
        "thunderstorm" -> R.drawable.ic_thunderstorm
        "tornado" -> R.drawable.ic_tornado
        else -> R.drawable.ic_sun
    }
}

LazyDeferred: 延迟对象:
fun<T> lazyDeferred(block: suspend CoroutineScope.() -> T) : Lazy<Deferred<T>> {
    return lazy {
        GlobalScope.async {
            block.invoke(this)
        }
    }
}

ScopedFragment :

abstract class ScopedFragment : Fragment(), CoroutineScope {
private lateinit var job: Job

override val coroutineContext: CoroutineContext
    get() = job + Dispatchers.Main

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    job = Job()
}

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

请问您能否发布VM的代码吗? 这个问题可能与线程有关(在后台线程上更新UI)或生命周期管理有关。返回后数据可能为空。 - Anis BEN NSIR
@AnisBENNSIR 当然,我已经添加了它以及LazyDeferred的定义。 - Stelios Papamichail
ScopedFragment是什么样子? - Some random IT boy
@SomerandomITboy 刚刚将其添加到帖子中 :) - Stelios Papamichail
3个回答

2

没有整个环境,我很难帮你调试整个问题,但我很乐意为你提供一些初看起来有点不对的东西。

首先,我会避免自己管理所有的CoroutinesScopes和生命周期,因为这很容易出错。所以我会依赖于Android团队已经完成的工作。在这里快速查看,它真的很容易设置和使用。开发体验非常好。

LiveData上发布Deferred并在视图端等待看起来像是一个代码坏味道...

  • 如果有网络错误怎么办? 这将导致抛出异常或取消异常。

  • 如果任务已经执行并导致某种类型的UI一致性问题怎么办?这些都是我不想处理的一些边缘情况。

只需观察LiveData,因为这是它的主要目的:它是一个值持有者,并且旨在在Fragment中的多个生命周期事件中存活。因此,一旦视图重新创建,ViewModel内部的LiveData中的值就已准备好。

你的lazyDeferred函数非常聪明,但在Android世界中也很危险。这些coroutines不属于任何受生命周期控制的范围,因此它们有很高的机会被泄漏。相信我,你不想任何coroutines被泄漏,因为它们甚至在viewmodel和fragment销毁后继续工作,这绝对不是你想要的。

所有这些都可以通过使用我之前提到的依赖项轻松解决,我将再次粘贴在这里

以下是如何在ViewModel中使用这些实用程序以确保事物的生命周期和coroutines不会引起任何问题的代码片段:

class WeatherViewModel(
    private val forecastRepository: ForecastRepository,
    context: Context
) : ViewModel() {

    private val appContext = context.applicationContext

    // Retrieve the sharedPrefs
    val preferences:SharedPreferences
        get() = PreferenceManager.getDefaultSharedPreferences(appContext)

    // This will run only when currentWeatherData is called from the View
    val currentWeatherData = liveData {
        val task = viewModelScope.async { forecastRepository.getCurrentWeather() }
        emit(task.await())
    }

    val hourlyWeatherEntries = liveData {
        val task = viewModelScope.async { forecastRepository.getHourlyWeather() }
        emit(task.await())

    }

    val weeklyWeatherEntries = liveData {
        val task = viewModelScope.async {
            val currentDateEpoch = LocalDate.now().toEpochDay()
            forecastRepository.getWeekDayWeatherList(currentDateEpoch)
        }
        emit(task.await())
    }

    val weatherLocation = liveData {
        val task = viewModelScope.async(Dispatchers.IO) {
            forecastRepository.getWeatherLocation()
        }
        emit(task.await())
    }

}

通过使用以下方法,所有网络调用都以并行方式执行,并且它们都与viewModelScope相关联,而不需要编写单个处理CoroutineScope生命周期的代码。当ViewModel失效时,作用域也会失效。当视图重新创建时,例程不会执行两次,值将准备好读取。
关于图表的配置:我强烈建议在创建视图后立即配置图表,因为它们高度相关。配置是您想要仅执行一次的操作,如果某些指令执行多次可能会导致视觉错误(我认为这可能正在发生),只是这样说,因为我在使用Piechart时遇到了问题。
更多关于图表的内容:构造LineData的所有逻辑最好放在后台线程上,并通过LiveData在ViewModel侧公开,就像您对所有其他内容所做的那样。
val property = liveData {
    val deferred = viewModelScope.async(Dispatchers.Default) {
        // Heavy building logic like:
        createChartData()
    }
    emit(deferred.await())
}

Kotlin技巧:在进行冗长的MPAndroid配置函数时,避免重复自己。

可以采用如下方式:

view.configureThis()
view.configureThat()
view.enabled = true

Do:

view.apply {
    configureThis()
    configureThat()
    enabled = true
}

我很遗憾只能给你一些提示,无法准确地指出你的问题,因为该错误与运行时生命周期演变相关,但希望这些提示对你有用。
回答你的评论:
如果你的数据流(LiveData)依赖于另一个数据流(另一个LiveData)要发射的内容,那么你需要使用 LiveData.map 和 LiveData.switchMap 操作。
我想 hourlyWeatherEntries 会时不时地发射值。
在这种情况下,你可以使用 LiveData.switchMap。
它的作用是每次源 LiveData 发射一个值时,你将获得一个回调,并期望返回一个包含新值的新 LiveData。
你可以像以下方式排列:
val hourlyChartData = hourlyWeatherEntries.switchMap { hourlyWeather ->
    liveData {
        val task = viewModelScope.async(Dispatchers.IO) {
            createChartData(hourlyWeather)
        }
        emit(task.await())
    }
}

使用这种方法的好处是它完全是惰性的。这意味着除非某个lifecycleOwner正在主动观察data,否则不会发生任何计算。这只是意味着除非在Fragment中观察到data,否则不会触发任何回调。
关于map和switchMap的进一步解释
由于我们需要进行一些异步计算,我们不知道何时完成,因此不能使用map。map在LiveDatas之间应用线性转换。查看以下内容:
val liveDataOfNumbers = liveData { // Returns a LiveData<Int>
    viewModelScope.async {
         for(i in 0..10) {
             emit(i)
             delay(1000)
         }
    }
}

val liveDataOfDoubleNumbers = liveDataOfNumbers.map { number -> number * 2}


这在计算是线性和简单的情况下非常有用。在幕后发生的事情是,该库通过MediatorLiveData为您处理观察和发射值。这里发生的是,每当liveDataOfNumbers发射一个值并且正在观察liveDataOfDoubleNumbers时,回调被应用; 因此,liveDataOfDoubleNumbers正在发射:0、2、4、6…。
上面的代码片段等效于以下内容:
val liveDataOfNumbers = liveData { // Returns a LiveData<Int>
    viewModelScope.async {
         for(i in 0..10) {
             emit(i)
             delay(1000)
         }
    }
}

val liveDataOfDoubleNumbers = MediatorLiveData<Int>().apply {
    addSource(liveDataOfNumbers) { newNumber ->
        // Update MediatorLiveData value when liveDataOfNumbers creates a new number
        value = newNumber * 2
    }
}

但只是使用map要简单得多。

太棒了!

现在来看看你的用例。你的计算是线性的,但我们想要将这项工作推迟到后台协程中进行。因此,我们无法确定什么时候会结束。

为了应对这些用例,他们创建了switchMap操作符。它所做的就是与map完全相同,但将所有内容包装在另一个LiveData中。中间的LiveData只是作为即将从协程返回的响应的容器。

因此,最终发生的情况是:

  1. 您的协程发布到intermediateLiveData
  2. switchMap执行类似以下操作:
return MediatorLiveData().apply {
    // intermediateLiveData is what your callback generates
    addSource(intermediateLiveData) { newValue -> this.value = newValue }
} as LiveData

总结: 1. Coroutine将值传递给intermediateLiveData 2. intermediateLiveData将值传递给hourlyChartData 3. hourlyChartData将值传递给UI
而且所有操作都不需要添加或删除observeForever 由于liveData {…}是一个帮助我们创建异步LiveData的构建器,可以用它使我们的switchMap回调更简洁。
函数liveData返回类型为LiveData<T>的LiveData。如果您的存储库调用已经返回LiveData,则非常简单!
val someLiveData = originalLiveData.switchMap { newValue ->
   someRepositoryCall(newValue).map { returnedRepoValue -> /*your transformation code here*/}
}

这条评论非常有见地,解释得非常清楚。我真的很惊讶你能如此清晰地传达所有这些技巧的要点。非常感谢。明天我会尝试应用这里的所有内容 :) - Stelios Papamichail
1
告诉我进展如何,晚安。希望枕头能给你带来解决方案 :) - Some random IT boy
我已经添加了一个小节,介绍如何使用其他实用工具来实现你想要达到的目标。 - Some random IT boy
我已经检查了这个部分和文档,但我不明白为什么所有使用 liveData {} 的字段都具有 LiveData<LiveData<T>> 类型?有没有解决方法?这迫使我嵌套观察器以获取我的视图的实际值,我猜想这并不理想?此外,对于 hourlyChartData,我还必须嵌套观察器,之前提出的问题再次出现。 - Stelios Papamichail
聊天突然卡住了。请随时将代码库发送给我,我很乐意审查代码。 - Some random IT boy
显示剩余2条评论

0

将setupChart和setData逻辑分开。在观察者外部设置图表,然后在观察者内部设置数据,最后调用invalidate()。


我已经应用了你的更改(更新了我的帖子),但到目前为止还没有运气。现在它甚至根本不会显示图表。 - Stelios Papamichail
好的,让我来检查一下。 - Sina

0
尝试将invalidate()部分注释掉,并在调用搜索函数之前尝试使用yourlineChart.clear();yourlineChart.clearValues();。这将清除图表的先前值,并形成具有新值的图表。因此,invalidate()chart.notifyDataSetChanged()将不再必要,这应该解决您的问题。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接