mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
perf(map): viewport culling, label limit, scroll refresh, SetVisible gating
This commit is contained in:
1 parent
25a41def35
commit
d8d3d81648
3 files changed
+130
-13
No files matched your search
@@ -90,6 +90,10 @@ import com.rtbishop.look4sat.core.presentation.TopBar
|
||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||
import org.osmdroid.tileprovider.tilesource.XYTileSource
|
||||
import org.osmdroid.events.DelayedMapListener
|
||||
import org.osmdroid.events.MapListener
|
||||
import org.osmdroid.events.ScrollEvent
|
||||
import org.osmdroid.events.ZoomEvent
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.views.CustomZoomButtonsController
|
||||
import org.osmdroid.views.MapView
|
||||
@@ -145,6 +149,21 @@ fun MapDestination() {
|
||||
val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container))
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val mapView = rememberMapViewWithLifecycle()
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
DisposableEffect(lifecycle) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_START -> viewModel.onAction(MapAction.SetVisible(true))
|
||||
Lifecycle.Event.ON_STOP -> viewModel.onAction(MapAction.SetVisible(false))
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycle.removeObserver(observer)
|
||||
viewModel.onAction(MapAction.SetVisible(false))
|
||||
}
|
||||
}
|
||||
MapScreen(uiState, viewModel::onAction, mapView)
|
||||
}
|
||||
|
||||
@@ -731,6 +750,27 @@ private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Above this many satellites inside the viewport labels are dropped in favor of a single
|
||||
* shared dot icon. Per-satellite label bitmaps cost ~90KB each, so drawing thousands of them
|
||||
* exhausts memory and stalls the UI thread — and overlapping labels are unreadable anyway. */
|
||||
private const val LABEL_LIMIT = 128
|
||||
|
||||
/** Degrees of space around the viewport so markers don't pop in at the edges */
|
||||
private const val VIEWPORT_MARGIN = 8.0
|
||||
|
||||
/** Debounce for viewport-driven marker refreshes, in milliseconds */
|
||||
private const val MAP_LISTENER_DELAY = 128L
|
||||
|
||||
/** Shared dot icon used when too many satellites are visible to label them */
|
||||
private var dotIcon: Drawable? = null
|
||||
|
||||
/** Scratch list reused every frame to avoid per-tick allocation */
|
||||
private val visibleSats = ArrayList<Pair<OrbitalObject, GeoPos>>()
|
||||
|
||||
/** Last emitted positions, replayed on scroll/zoom so culled markers appear without waiting for a tick */
|
||||
private var lastPositions: Map<OrbitalObject, GeoPos>? = null
|
||||
private var lastAction: ((OrbitalObject) -> Unit)? = null
|
||||
|
||||
/** Pool of reusable Marker objects keyed by satellite name, to avoid re-creation every frame */
|
||||
private val markerPool = HashMap<String, Marker>()
|
||||
private var lastMapView: MapView? = null
|
||||
@@ -741,13 +781,20 @@ private fun setPositions(
|
||||
action: (OrbitalObject) -> Unit
|
||||
) {
|
||||
try {
|
||||
lastPositions = posMap
|
||||
lastAction = action
|
||||
// Clear caches when the MapView instance changes (e.g. config change)
|
||||
if (lastMapView !== mapView) {
|
||||
lastMapView = mapView
|
||||
markerPool.clear()
|
||||
iconCache.evictAll()
|
||||
dotIcon = null
|
||||
footprintPolyline = null
|
||||
footprintPoints = null
|
||||
mapView.addMapListener(DelayedMapListener(object : MapListener {
|
||||
override fun onScroll(event: ScrollEvent?) = refreshPositions(mapView)
|
||||
override fun onZoom(event: ZoomEvent?) = refreshPositions(mapView)
|
||||
}, MAP_LISTENER_DELAY))
|
||||
}
|
||||
// Reuse the existing FolderOverlay — creating a new one and replacing it
|
||||
// causes osmdroid to detach shared Marker objects, making them invisible.
|
||||
@@ -756,17 +803,48 @@ private fun setPositions(
|
||||
}
|
||||
folder.items.clear()
|
||||
|
||||
val activeNames = HashSet<String>(posMap.size)
|
||||
posMap.forEach { (satellite, geoPos) ->
|
||||
// Cull satellites outside the viewport: only meaningful once zoomed in, but that is
|
||||
// exactly when marker labels are shown and drawing is most expensive.
|
||||
visibleSats.clear()
|
||||
if (mapView.width > 0 && mapView.height > 0) {
|
||||
val box = mapView.boundingBox
|
||||
val latNorth = box.latNorth + VIEWPORT_MARGIN
|
||||
val latSouth = box.latSouth - VIEWPORT_MARGIN
|
||||
val lonWest = box.lonWest - VIEWPORT_MARGIN
|
||||
val lonEast = box.lonEast + VIEWPORT_MARGIN
|
||||
val wrapsDateLine = box.lonWest > box.lonEast
|
||||
for ((satellite, geoPos) in posMap) {
|
||||
val lat = geoPos.latitude
|
||||
if (lat !in latSouth..latNorth) continue
|
||||
val lon = geoPos.longitude
|
||||
val isLonVisible = if (wrapsDateLine) lon >= lonWest || lon <= lonEast
|
||||
else lon in lonWest..lonEast
|
||||
if (isLonVisible) visibleSats.add(satellite to geoPos)
|
||||
}
|
||||
} else {
|
||||
for (entry in posMap) visibleSats.add(entry.key to entry.value)
|
||||
}
|
||||
|
||||
val showLabels = visibleSats.size <= LABEL_LIMIT
|
||||
val activeNames = HashSet<String>(visibleSats.size)
|
||||
for ((satellite, geoPos) in visibleSats) {
|
||||
val name = satellite.data.name
|
||||
activeNames.add(name)
|
||||
val marker = markerPool.getOrPut(name) {
|
||||
Marker(mapView).apply {
|
||||
setInfoWindow(null)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||
icon = getCachedTextIcon(name, mapView)
|
||||
// Resolve the satellite via relatedObject so the listener is allocated
|
||||
// once per marker instead of once per satellite per tick
|
||||
setOnMarkerClickListener { clicked, _ ->
|
||||
(clicked.relatedObject as? OrbitalObject)?.let(action)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
marker.relatedObject = satellite
|
||||
val icon = if (showLabels) getCachedTextIcon(name, mapView) else getDotIcon(mapView)
|
||||
if (marker.icon !== icon) marker.icon = icon
|
||||
// Update position in-place — reuse existing GeoPoint if available
|
||||
val pos = marker.position
|
||||
if (pos != null) {
|
||||
@@ -775,22 +853,34 @@ private fun setPositions(
|
||||
} else {
|
||||
marker.position = GeoPoint(geoPos.latitude, geoPos.longitude)
|
||||
}
|
||||
marker.setOnMarkerClickListener { _, _ ->
|
||||
action(satellite)
|
||||
true
|
||||
}
|
||||
folder.add(marker)
|
||||
}
|
||||
// Evict markers for satellites no longer tracked
|
||||
val iter = markerPool.keys.iterator()
|
||||
while (iter.hasNext()) {
|
||||
if (iter.next() !in activeNames) iter.remove()
|
||||
}
|
||||
// Evict markers that are no longer tracked or no longer visible
|
||||
markerPool.keys.retainAll(activeNames)
|
||||
visibleSats.clear()
|
||||
} catch (e: Exception) {
|
||||
println(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-applies the last known positions against the new viewport after a pan or zoom */
|
||||
private fun refreshPositions(mapView: MapView): Boolean {
|
||||
// Grid mode disables the satellite layer — don't re-populate markers off-screen
|
||||
if (mapView.overlays.getOrNull(OVERLAY_POSITIONS)?.isEnabled == false) return true
|
||||
val posMap = lastPositions ?: return false
|
||||
val action = lastAction ?: return false
|
||||
setPositions(posMap, mapView, action)
|
||||
mapView.invalidate()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun getDotIcon(mapView: MapView): Drawable = dotIcon ?: run {
|
||||
val size = 20
|
||||
val bitmap = createBitmap(size, size)
|
||||
Canvas(bitmap).drawCircle(size / 2f, size / 2f, size / 2f - 2f, textPaint)
|
||||
bitmap.toDrawable(mapView.context.resources).also { dotIcon = it }
|
||||
}
|
||||
|
||||
private fun getCachedTextIcon(name: String, mapView: MapView): Drawable {
|
||||
iconCache[name]?.let { return it }
|
||||
val labelRect = Rect()
|
||||
@@ -966,9 +1056,23 @@ private fun rememberMapViewWithLifecycle(): MapView {
|
||||
lifecycle.addObserver(lifecycleObserver)
|
||||
onDispose { lifecycle.removeObserver(lifecycleObserver) }
|
||||
}
|
||||
// The overlay caches below are file-level (shared across MapView instances), so they must be
|
||||
// released with the MapView or they keep the Activity and its bitmaps alive after disposal.
|
||||
DisposableEffect(mapView) {
|
||||
onDispose { clearMapCaches() }
|
||||
}
|
||||
return mapView
|
||||
}
|
||||
|
||||
private fun clearMapCaches() {
|
||||
markerPool.clear()
|
||||
iconCache.evictAll()
|
||||
dotIcon = null
|
||||
lastPositions = null
|
||||
lastAction = null
|
||||
lastMapView = null
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) {
|
||||
LifecycleEventObserver { _, event ->
|
||||
|
||||
@@ -48,6 +48,7 @@ sealed interface MapAction {
|
||||
data class SelectItem(val item: OrbitalObject) : MapAction
|
||||
data class SelectDefaultItem(val catnum: Int) : MapAction
|
||||
data class ToggleGridMode(val value: Boolean) : MapAction
|
||||
data class SetVisible(val isVisible: Boolean) : MapAction
|
||||
}
|
||||
|
||||
data class MapData(
|
||||
|
||||
@@ -37,6 +37,7 @@ import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
||||
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
@@ -45,6 +46,7 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -69,6 +71,9 @@ class MapViewModel(
|
||||
private var dataUpdateJob: Job? = null
|
||||
private var dataUpdateRate = 1000L
|
||||
private var selectedOrbitalObject: OrbitalObject? = null
|
||||
|
||||
/** Gates the prediction loop so it doesn't burn CPU while the map isn't on screen */
|
||||
private val isScreenVisible = MutableStateFlow(true)
|
||||
val uiState: StateFlow<MapState> = _uiState
|
||||
|
||||
init {
|
||||
@@ -100,6 +105,7 @@ class MapViewModel(
|
||||
is MapAction.SelectItem -> selectSatellite(action.item)
|
||||
is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum)
|
||||
is MapAction.ToggleGridMode -> settingsRepo.updateOtherSettings { it.copy(stateOfMapGrid = action.value) }
|
||||
is MapAction.SetVisible -> isScreenVisible.value = action.isVisible
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +143,11 @@ class MapViewModel(
|
||||
selectedOrbitalObject = orbitalObject
|
||||
viewModelScope.launch {
|
||||
dataUpdateJob?.cancelAndJoin()
|
||||
dataUpdateJob = launch {
|
||||
// Default dispatcher is mandatory: viewModelScope is Main.immediate, and every
|
||||
// satelliteRepo call internally hops to Default and resumes back on the caller's
|
||||
// dispatcher. On Main that posts one continuation per satellite per tick, which
|
||||
// floods the looper and ANRs for users tracking thousands of objects.
|
||||
dataUpdateJob = launch(Dispatchers.Default) {
|
||||
val dateNow = Date()
|
||||
getStationPosition()
|
||||
getSatTrack(orbitalObject, stationPos, dateNow)
|
||||
@@ -148,6 +158,8 @@ class MapViewModel(
|
||||
else -> updateFreq
|
||||
}
|
||||
while (isActive) {
|
||||
// Suspends while the map is off-screen instead of predicting into the void
|
||||
isScreenVisible.first { it }
|
||||
dateNow.time = System.currentTimeMillis()
|
||||
updateMapState(orbitalObject, allSatellites, stationPos, dateNow)
|
||||
delay(effectiveRate)
|
||||
|
||||
Reference in new issue
Block a user