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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user