perf: draw-phase scene animation, parallel and de-duplicated loading, release build

- scene time and scroll offset are read only while drawing, so frames no longer recompose the page
- forecast and air quality load in parallel; concurrent refreshes of one location share a request
- saving a previewed place stores the already loaded forecast instead of fetching again
- platform fused location provider first; recent last-known position used at once
- release build signed with the debug key for local installs

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-17 14:01:38 +02:00
co-authored by Claude Opus 5.5
parent c446798403
commit 054c76d093
6 changed files with 63 additions and 29 deletions
@@ -25,7 +25,9 @@ class DeviceLocation(private val context: Context) {
@SuppressLint("MissingPermission")
suspend fun get(): Location? {
if (!hasPermission()) return null
val providers = listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER, LocationManager.PASSIVE_PROVIDER)
// FUSED_PROVIDER is the AOSP one (API 31+), it works without Play Services
val fused = if (android.os.Build.VERSION.SDK_INT >= 31) listOf(LocationManager.FUSED_PROVIDER) else emptyList()
val providers = (fused + listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER, LocationManager.PASSIVE_PROVIDER))
.filter { runCatching { manager.isProviderEnabled(it) }.getOrDefault(false) }
val lastKnown = providers.mapNotNull { runCatching { manager.getLastKnownLocation(it) }.getOrNull() }
.maxByOrNull { it.time }
@@ -44,7 +46,7 @@ class DeviceLocation(private val context: Context) {
}
private companion object {
const val FRESH_MS = 15 * 60 * 1000L
const val TIMEOUT_MS = 12_000L
const val FRESH_MS = 60 * 60 * 1000L
const val TIMEOUT_MS = 8_000L
}
}
@@ -1,6 +1,10 @@
package app.wetter.data
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -18,6 +22,15 @@ class WeatherRepository(
private val device: DeviceLocation,
) {
private val currentLock = Mutex()
private val inFlight = ConcurrentHashMap<Long, CompletableDeferred<RefreshResult>>()
private val previews = ConcurrentHashMap<Place, Pair<String, String?>>()
/** Forecast and air quality at the same time; air is optional. */
private suspend fun fetch(lat: Double, lon: Double): Pair<String, String?> = coroutineScope {
val air = async { runCatching { api.airJson(lat, lon) }.getOrNull() }
val forecast = async { api.forecastJson(lat, lon) }
forecast.await() to air.await()
}
val locations: Flow<List<LocationEntity>> = dao.locations()
@@ -36,17 +49,23 @@ class WeatherRepository(
suspend fun refresh(location: LocationEntity, force: Boolean = false): RefreshResult {
val cached = dao.forecastOnce(location.id)
if (!force && cached != null && System.currentTimeMillis() - cached.fetchedAt < STALE_MS) return RefreshResult.Skipped
return runCatching {
val forecast = api.forecastJson(location.lat, location.lon)
val air = runCatching { api.airJson(location.lat, location.lon) }.getOrNull()
// a second caller for the same location waits for the running request instead of starting one
val mine = CompletableDeferred<RefreshResult>()
inFlight.putIfAbsent(location.id, mine)?.let { return it.await() }
val result = runCatching {
val (forecast, air) = fetch(location.lat, location.lon)
dao.putForecast(ForecastCacheEntity(location.id, System.currentTimeMillis(), forecast, air))
RefreshResult.Ok
}.getOrElse { RefreshResult.Failed(it) }
inFlight.remove(location.id)
mine.complete(result)
return result
}
suspend fun refreshAll(force: Boolean = false) {
updateCurrentLocation()
dao.locationsOnce().forEach { refresh(it, force) }
suspend fun refreshAll(force: Boolean = false) = coroutineScope {
val saved = dao.locationsOnce().filter { !it.isCurrent }.map { async { refresh(it, force) } }
updateCurrentLocation()?.let { refresh(it, force) }
saved.forEach { it.await() }
}
/** Device position -> the single is_current row. Returns null without permission or fix. */
@@ -77,9 +96,9 @@ class WeatherRepository(
/** Full forecast for a place that is not saved yet (S15). */
suspend fun preview(place: Place): Forecast {
val forecast = api.forecastJson(place.lat, place.lon)
val air = runCatching { api.airJson(place.lat, place.lon) }.getOrNull()
return api.parse(forecast, air, System.currentTimeMillis())
val raw = fetch(place.lat, place.lon)
previews[place] = raw
return api.parse(raw.first, raw.second, System.currentTimeMillis())
}
suspend fun save(place: Place): Long {
@@ -93,7 +112,10 @@ class WeatherRepository(
lat = place.lat, lon = place.lon, timezone = place.timezone, sortOrder = dao.nextSortOrder(),
)
)
dao.location(id)?.let { refresh(it, force = true) }
// the preview already loaded this forecast: store it instead of fetching again
val raw = previews.remove(place)
if (raw != null) dao.putForecast(ForecastCacheEntity(id, System.currentTimeMillis(), raw.first, raw.second))
else dao.location(id)?.let { refresh(it, force = true) }
return id
}
@@ -139,7 +139,8 @@ fun WindTile(cur: Current, units: Units, modifier: Modifier) {
val title = stringResource(R.string.card_wind)
Tile(title, Icons.Outlined.Air, CircleShape, "$title $value ${units.windUnit}, ${stringResource(R.string.wind_from, from)}", modifier, centered = true) {
// the blob points where the wind blows to
Box(Modifier.fillMaxSize(.78f).align(Alignment.Center).clip(WShapes.triangle(cur.windDir + 180f + 90f)).background(c.fillSecondary))
val blob = androidx.compose.runtime.remember(cur.windDir) { WShapes.triangle(cur.windDir + 180f + 90f) }
Box(Modifier.fillMaxSize(.78f).align(Alignment.Center).clip(blob).background(c.fillSecondary))
Column(Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(Modifier.height(WSpace.s12))
ValueText(value, units.windUnit, c.onFillSecondary)
@@ -1,11 +1,10 @@
package app.wetter.ui.scene
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.animateFloat
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.withFrameNanos
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
@@ -51,19 +50,22 @@ data class SceneSpec(val condition: Condition, val isDay: Boolean, val intensity
* [animate] false draws one still frame (battery saver, reduced motion, setting off).
*/
@Composable
fun WeatherScene(spec: SceneSpec, modifier: Modifier = Modifier, animate: Boolean = true, scroll: Float = 0f) {
fun WeatherScene(spec: SceneSpec, modifier: Modifier = Modifier, animate: Boolean = true, scroll: () -> Float = { 0f }) {
val top by animateColorAsState(spec.top, tween(600), label = "top")
val bottom by animateColorAsState(spec.bottom, tween(600), label = "bottom")
val t = if (animate) {
val transition = rememberInfiniteTransition(label = "scene")
val v by transition.animateFloat(0f, 1f, infiniteRepeatable(tween(60_000, easing = LinearEasing)), label = "t")
v
} else 0.2f
// Read only inside the draw lambda: a new frame redraws the canvas, it never recomposes.
val time = remember { mutableFloatStateOf(0.2f) }
if (animate) {
LaunchedEffect(Unit) {
val start = withFrameNanos { it }
while (true) withFrameNanos { time.floatValue = ((it - start) / 60e9f + 0.2f) % 1f }
}
}
val particles = remember(spec.condition) { Particles.make(spec.condition) }
Canvas(modifier.fillMaxSize()) {
drawRect(Brush.verticalGradient(listOf(top, bottom)))
drawSceneLayers(spec, t, scroll, particles)
drawSceneLayers(spec, time.floatValue, scroll(), particles)
}
}
@@ -137,14 +139,17 @@ fun DrawScope.drawSceneLayers(spec: SceneSpec, t: Float, scroll: Float, particle
}
}
private val hillPath = Path()
private fun DrawScope.drawHill(color: Color, baseY: Float, amp: Float, wavelength: Float, phase: Float) {
val w = size.width
val path = Path().apply {
val path = hillPath.apply {
rewind()
moveTo(0f, size.height)
var x = 0f
while (x <= w + 8f) {
lineTo(x, baseY - amp * sin(x / wavelength * 2 * PI.toFloat() + phase * .2f))
x += 8f
x += 16f
}
lineTo(w, size.height)
close()
@@ -207,11 +207,12 @@ fun ForecastPage(
val spec = forecast?.let { SceneSpec(conditionOf(it.current.code), it.current.isDay, intensityOf(it.current.code)) }
?: SceneSpec(app.wetter.data.Condition.CLOUDY, true)
val listState = rememberLazyListState()
val scroll by remember { derivedStateOf { if (listState.firstVisibleItemIndex == 0) listState.firstVisibleItemScrollOffset.toFloat() else 2000f } }
val fg = spec.onScene
Box(Modifier.fillMaxSize()) {
WeatherScene(spec, animate = animate && settings.animations, scroll = scroll)
WeatherScene(spec, animate = animate && settings.animations, scroll = {
if (listState.firstVisibleItemIndex == 0) listState.firstVisibleItemScrollOffset.toFloat() else 2000f
})
PullToRefreshBox(isRefreshing = refreshing, onRefresh = onRefresh, modifier = Modifier.fillMaxSize()) {
LazyColumn(
state = listState,