From 054c76d093bfabbcc43550abfe7397c51210d66f Mon Sep 17 00:00:00 2001 From: Louis Date: Sat, 17 Oct 2026 14:01:38 +0200 Subject: [PATCH] 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 --- wetter/app/build.gradle.kts | 3 ++ .../java/app/wetter/data/DeviceLocation.kt | 8 ++-- .../java/app/wetter/data/WeatherRepository.kt | 42 ++++++++++++++----- .../app/wetter/ui/components/DetailTiles.kt | 3 +- .../java/app/wetter/ui/scene/WeatherScene.kt | 31 ++++++++------ .../java/app/wetter/ui/screens/HomeScreen.kt | 5 ++- 6 files changed, 63 insertions(+), 29 deletions(-) diff --git a/wetter/app/build.gradle.kts b/wetter/app/build.gradle.kts index f0d99c8..eaa9ba4 100644 --- a/wetter/app/build.gradle.kts +++ b/wetter/app/build.gradle.kts @@ -22,6 +22,9 @@ android { buildTypes { release { isMinifyEnabled = true + isShrinkResources = true + // local installs only; a real release key comes with /replica-deploy + signingConfig = signingConfigs.getByName("debug") proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } diff --git a/wetter/app/src/main/java/app/wetter/data/DeviceLocation.kt b/wetter/app/src/main/java/app/wetter/data/DeviceLocation.kt index a610b81..9599691 100644 --- a/wetter/app/src/main/java/app/wetter/data/DeviceLocation.kt +++ b/wetter/app/src/main/java/app/wetter/data/DeviceLocation.kt @@ -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 } } diff --git a/wetter/app/src/main/java/app/wetter/data/WeatherRepository.kt b/wetter/app/src/main/java/app/wetter/data/WeatherRepository.kt index 5a9cc2d..1b2f964 100644 --- a/wetter/app/src/main/java/app/wetter/data/WeatherRepository.kt +++ b/wetter/app/src/main/java/app/wetter/data/WeatherRepository.kt @@ -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>() + private val previews = ConcurrentHashMap>() + + /** Forecast and air quality at the same time; air is optional. */ + private suspend fun fetch(lat: Double, lon: Double): Pair = 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> = 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() + 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 } diff --git a/wetter/app/src/main/java/app/wetter/ui/components/DetailTiles.kt b/wetter/app/src/main/java/app/wetter/ui/components/DetailTiles.kt index 4f3161f..f4f74c2 100644 --- a/wetter/app/src/main/java/app/wetter/ui/components/DetailTiles.kt +++ b/wetter/app/src/main/java/app/wetter/ui/components/DetailTiles.kt @@ -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) diff --git a/wetter/app/src/main/java/app/wetter/ui/scene/WeatherScene.kt b/wetter/app/src/main/java/app/wetter/ui/scene/WeatherScene.kt index 70b68d5..6abecfc 100644 --- a/wetter/app/src/main/java/app/wetter/ui/scene/WeatherScene.kt +++ b/wetter/app/src/main/java/app/wetter/ui/scene/WeatherScene.kt @@ -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() diff --git a/wetter/app/src/main/java/app/wetter/ui/screens/HomeScreen.kt b/wetter/app/src/main/java/app/wetter/ui/screens/HomeScreen.kt index 87fb571..e744df2 100644 --- a/wetter/app/src/main/java/app/wetter/ui/screens/HomeScreen.kt +++ b/wetter/app/src/main/java/app/wetter/ui/screens/HomeScreen.kt @@ -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,