From 482f4d089bc319dbe072e1d317af01e802a99122 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Thu, 5 Nov 2026 14:58:13 -0800 Subject: [PATCH 1/4] Redraw tiles once per burst of tiled-marker changes Same change as android-for-maplibre: relabelling street trees changes a few dozen tiled markers at once, and each update() replaced the raster source, so the map fetched every visible tile again per marker (on MapLibre, Pixel 5a: 45 replacements, 494 tile renders, last tile 6.1 s after the change). - Changes arriving within a frame share one tile refresh; a pending refresh is cancelled on destroy() so it cannot register the tile route again. - The tiling check reads the entity count without copying all entities. - The icon is baked only when the marker is drawn natively. --- .../marker/GoogleMapMarkerController.kt | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/mapconductor/googlemaps/marker/GoogleMapMarkerController.kt b/src/main/java/com/mapconductor/googlemaps/marker/GoogleMapMarkerController.kt index 03b9ca5..22a1f6e 100644 --- a/src/main/java/com/mapconductor/googlemaps/marker/GoogleMapMarkerController.kt +++ b/src/main/java/com/mapconductor/googlemaps/marker/GoogleMapMarkerController.kt @@ -22,8 +22,11 @@ import com.mapconductor.core.tileserver.TileServerRegistry import com.mapconductor.googlemaps.GoogleMapActualMarker import com.mapconductor.googlemaps.GoogleMapViewHolder import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds import android.util.Log import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext @@ -139,11 +142,12 @@ internal class GoogleMapMarkerController private constructor( if (currentFinger == prevFinger) return semaphore.withPermit { + // 件数だけが要る。allEntities() は全件の写しを作るので、ラベルの付け替えのように + // 数十本が続けて変わると、そのたびに 14 万件を写すことになる。 val tilingEnabled = - markerTiling.enabled && markerManager.allEntities().size >= markerManager.minMarkerCount + markerTiling.enabled && markerManager.getMemoryStats().entityCount >= markerManager.minMarkerCount val wantsTiled = tilingEnabled && !state.draggable && state.getAnimation() == null val wasTiled = tiledMarkerIds.contains(state.id) - val markerIcon = state.icon?.toBitmapIcon() ?: defaultMarkerIcon if (wantsTiled) { if (!wasTiled) { @@ -161,13 +165,15 @@ internal class GoogleMapMarkerController private constructor( tiling = true, ), ) - syncTiledOverlay() + scheduleTileRefresh() return } if (wasTiled) { tiledMarkerIds.remove(state.id) } + // ネイティブマーカーとして描くときだけ焼き込む。タイル担当なら要らない。 + val markerIcon = state.icon?.toBitmapIcon() ?: defaultMarkerIcon val renderEntity = MarkerEntity( @@ -203,7 +209,7 @@ internal class GoogleMapMarkerController private constructor( renderer.onPostProcess() if (tiledMarkerIds.isNotEmpty()) { - syncTiledOverlay() + scheduleTileRefresh() } else { removeTileOverlay() } @@ -211,6 +217,25 @@ internal class GoogleMapMarkerController private constructor( viewportSwitch.requestReapply() } + private var tileRefreshJob: Job? = null + + /** + * 1 本ずつの変更からの描き直しを 1 回にまとめる。 + * + * ラベルの付け替えのように数十本が続けて変わると、1 本ごとにラスターソースを差し替え、 + * そのたびに地図が見えているタイルを全部取り直していた(MapLibre・Pixel 5a で 45 回、 + * 描き直したタイル 494 枚、最後のタイルまで 6 秒)。続けて来た変更は + * [TILE_REFRESH_COALESCE] の間に受けて、まとめて 1 回だけ差し替える。 + */ + private fun scheduleTileRefresh() { + if (tileRefreshJob?.isActive == true) return + tileRefreshJob = + (renderer as GoogleMapMarkerRenderer).coroutine.launch { + delay(TILE_REFRESH_COALESCE) + semaphore.withPermit { syncTiledOverlay() } + } + } + override suspend fun clear() { viewportSwitch.destroy() semaphore.withPermit { @@ -249,6 +274,8 @@ internal class GoogleMapMarkerController private constructor( override fun destroy() { viewportSwitch.destroy() + // 待っている描き直しが後から走ると、外したタイル経路をまた登録してしまう。 + tileRefreshJob?.cancel() // Clean up tile server registration // Unregister this map's route only. Never stop the server here: it is // a process-wide singleton shared by all map controllers and overlay @@ -376,6 +403,9 @@ internal class GoogleMapMarkerController private constructor( } companion object { + /** 続けて来る変更を 1 回の描き直しにまとめる窓。1 フレーム分。 */ + private val TILE_REFRESH_COALESCE = 16.milliseconds + private const val TAG = "GoogleMapMarkerController" fun create( From 3ca06b643a12e9707b8c3d697a2714bccadf2525 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Thu, 5 Nov 2026 16:06:25 -0800 Subject: [PATCH 2/4] Size marker tiles like a rounding map Google Maps shows the tiles of the floored camera zoom, so at 17.75 it stretched z17 tiles by 1.68x and street-tree icons and labels came out twice as large as on MapLibre, which shows z18 at 0.84x. Pass the rounded camera zoom to MarkerTileRenderer so the tiles are drawn at the size MapLibre shows, and refresh the tiles when the rounded zoom changes. --- .../marker/GoogleMapMarkerController.kt | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/mapconductor/googlemaps/marker/GoogleMapMarkerController.kt b/src/main/java/com/mapconductor/googlemaps/marker/GoogleMapMarkerController.kt index 22a1f6e..99d0c2f 100644 --- a/src/main/java/com/mapconductor/googlemaps/marker/GoogleMapMarkerController.kt +++ b/src/main/java/com/mapconductor/googlemaps/marker/GoogleMapMarkerController.kt @@ -22,6 +22,7 @@ import com.mapconductor.core.tileserver.TileServerRegistry import com.mapconductor.googlemaps.GoogleMapActualMarker import com.mapconductor.googlemaps.GoogleMapViewHolder import java.util.UUID +import kotlin.math.floor import kotlin.time.Duration.Companion.milliseconds import android.util.Log import kotlinx.coroutines.Dispatchers @@ -59,8 +60,13 @@ internal class GoogleMapMarkerController private constructor( private val defaultMarkerIcon: BitmapIcon = DefaultMarkerIcon().toBitmapIcon() private val tiledMarkerIds = LinkedHashSet() + // タイルを描くスレッドからも読む([displayTileZoom])。 + @Volatile private var lastKnownZoom: Double = 0.0 + /** 前回タイルを描き直したときの [displayTileZoom]。 */ + private var lastDisplayTileZoom: Int? = null + // Tile rendering via RasterLayer private val tileServer = TileServerRegistry.get() private var markerTileRenderer: MarkerTileRenderer? = null @@ -255,9 +261,20 @@ internal class GoogleMapMarkerController private constructor( viewportSwitch.onCameraChanged(mapCameraPosition) lastKnownZoom = mapCameraPosition.zoom - // Also update the MarkerTileRenderer's camera zoom for fractional zoom support + // Google Maps は切り捨てたズームのタイルを出す。マーカーとラベルを MapLibre 系と + // 同じ大きさに見せるため、タイルは四捨五入したズームに合わせて描いている + // ([MarkerTileRenderer] の displayTileZoom)。それが変わったら、地図が持っている + // タイルは大きさが合わなくなるので取り直させる。 + val displayZoom = displayTileZoom() + if (displayZoom != lastDisplayTileZoom) { + lastDisplayTileZoom = displayZoom + if (markerTileRenderer != null) scheduleTileRefresh() + } } + /** カメラのズームを四捨五入したズーム(MapLibre 系がタイルに使うズーム)。 */ + private fun displayTileZoom(): Int = floor(lastKnownZoom + 0.5).toInt() + /** * マーカータイルのラスターレイヤの表示だけを切り替える。 * @@ -366,6 +383,7 @@ internal class GoogleMapMarkerController private constructor( debugTileOverlay = markerTiling.debugTileOverlay, iconScaleCallback = markerTiling.iconScaleCallback, declutterPx = markerTiling.declutterPx, + displayTileZoom = ::displayTileZoom, ) markerTileRenderer = tileRenderer From 55dbeebe6d918b384facf0dd27ab486eb2591484 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Thu, 5 Nov 2026 16:16:46 -0800 Subject: [PATCH 3/4] Swap a raster layer without a blank frame Changing a raster layer's source removed the old tile overlay before adding the new one, so the layer vanished until the new tiles arrived. For marker tiles that is about 0.3 s, and it now happens every time a pinch crosses a half zoom (the tiles are redrawn for the rounded zoom), as well as on every relabel. Add the new overlay first and remove the old one once the new one has served its tiles and gone quiet for 150 ms (at most 1.5 s). Checked by screen recording a one-finger zoom across 17.5 on a Pixel 5a and a TB520FU: the tiles switch to the new size with no frame missing them. --- .../GoogleMapRasterLayerOverlayRenderer.kt | 66 ++++++++++++++++++- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/mapconductor/googlemaps/raster/GoogleMapRasterLayerOverlayRenderer.kt b/src/main/java/com/mapconductor/googlemaps/raster/GoogleMapRasterLayerOverlayRenderer.kt index 870bdcf..fe7a8e6 100644 --- a/src/main/java/com/mapconductor/googlemaps/raster/GoogleMapRasterLayerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/googlemaps/raster/GoogleMapRasterLayerOverlayRenderer.kt @@ -23,9 +23,12 @@ import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Typeface +import android.os.SystemClock import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class GoogleMapRasterLayerOverlayRenderer( @@ -35,6 +38,12 @@ class GoogleMapRasterLayerOverlayRenderer( ) : RasterLayerOverlayRendererInterface { private val localRequests = mutableMapOf() + /** 各オーバーレイがタイルを最後に返した時刻。差し替えた古い方を外す時機に使う。 */ + private val tileActivity = mutableMapOf() + + /** 差し替え済みで、新しい方のタイルが揃うのを待って外す古いオーバーレイ。 */ + private val retiring = mutableSetOf() + internal fun updateLocalZoom(zoom: Double) { localRequests.values.forEach { it.updateZoom(zoom) } } @@ -77,9 +86,12 @@ class GoogleMapRasterLayerOverlayRenderer( val prev = params.prev val next = params.current.state if (prev.state.source != next.source || prev.state.debug != next.debug) { + // 先に外すと、新しいタイルが届くまでの間(マーカータイルで 0.3 秒ほど) + // レイヤーが丸ごと消えて点滅する。新しい方を足してから古い方を外す。 localRequests.remove(prev.layer)?.close() - prev.layer.remove() - addLayer(next) + val added = addLayer(next) + retireWhenReplaced(prev.layer, added) + added } else { updateLayer(prev.layer, next) prev.layer @@ -91,11 +103,49 @@ class GoogleMapRasterLayerOverlayRenderer( withContext(coroutine.coroutineContext) { data.forEach { entity -> localRequests.remove(entity.layer)?.close() + tileActivity.remove(entity.layer) entity.layer.remove() } + retiring.forEach { it.remove() } + retiring.clear() } } + /** + * [old] を、[replacement] がタイルを返し終えたころに外す。 + * + * Google Maps にはタイルを描き終えた通知が無いので、新しい方がタイルを 1 枚以上返し、 + * そのあと [RETIRE_QUIET_MS] のあいだ頼まれなくなったら揃ったとみなす。地図が + * 何も頼まない(見えている範囲にタイルが無い)こともあるので、[RETIRE_TIMEOUT_MS] で + * 打ち切って外す。 + */ + private fun retireWhenReplaced( + old: TileOverlay, + replacement: TileOverlay?, + ) { + retiring.add(old) + val activity = replacement?.let { tileActivity[it] } + coroutine.launch { + val start = SystemClock.elapsedRealtime() + while (true) { + val now = SystemClock.elapsedRealtime() + if (now - start >= RETIRE_TIMEOUT_MS) break + val last = activity?.lastServedAt ?: 0L + if (last > 0L && now - last >= RETIRE_QUIET_MS) break + delay(RETIRE_POLL_MS) + } + if (retiring.remove(old)) { + tileActivity.remove(old) + old.remove() + } + } + } + + private class TileActivity { + @Volatile + var lastServedAt: Long = 0L + } + override suspend fun onPostProcess() {} private fun addLayer(state: RasterLayerState): TileOverlay? { @@ -130,6 +180,7 @@ class GoogleMapRasterLayerOverlayRenderer( } else { null } + val activity = TileActivity() val provider = object : TileProvider { private var firstTileLogged = false @@ -159,6 +210,7 @@ class GoogleMapRasterLayerOverlayRenderer( // A cancelled/failed tile is retryable. NO_TILE would // cache a permanent hole when this zoom is revisited. val bytes = runCatching { requests.renderTile(url, zoom) }.getOrNull() ?: return null + activity.lastServedAt = SystemClock.elapsedRealtime() val rendered = if (state.debug) { addDebugOverlay(bytes, tileSpec.tileSize, x, schemeY, zoom, tileSpec.scheme) } else { @@ -197,6 +249,7 @@ class GoogleMapRasterLayerOverlayRenderer( imageBytes } + activity.lastServedAt = SystemClock.elapsedRealtime() // Return a new Tile with the image data return Tile(tileSpec.tileSize, tileSpec.tileSize, renderedBytes) } @@ -214,6 +267,7 @@ class GoogleMapRasterLayerOverlayRenderer( .transparency(opacityToTransparency(state.opacity)) .visible(state.visible) val overlay = holder.map.addTileOverlay(options) + if (overlay != null) tileActivity[overlay] = activity if (requests != null) { if (overlay != null) localRequests[overlay] = requests else requests.close() } @@ -262,6 +316,14 @@ class GoogleMapRasterLayerOverlayRenderer( private companion object { private const val TAG = "GoogleMapRasterLayerOverlayRenderer" + + /** 新しいオーバーレイへの依頼がこれだけ途切れたら、古い方を外す。 */ + private const val RETIRE_QUIET_MS = 150L + + /** 新しい方が何も頼まれなくても、古い方はこれだけ待って外す。 */ + private const val RETIRE_TIMEOUT_MS = 1_500L + + private const val RETIRE_POLL_MS = 30L private const val MARKER_TILE_RASTER_ID_PREFIX = "marker-tile-" } From 11b8177020f14ccf3673dcb3a5b39f33c76c5739 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Thu, 5 Nov 2026 16:18:56 -0800 Subject: [PATCH 4/4] Track tile activity with an AtomicLong The private nested class compiled into the recorded API surface. An AtomicLong does the same job without adding a class. --- .../GoogleMapRasterLayerOverlayRenderer.kt | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/mapconductor/googlemaps/raster/GoogleMapRasterLayerOverlayRenderer.kt b/src/main/java/com/mapconductor/googlemaps/raster/GoogleMapRasterLayerOverlayRenderer.kt index fe7a8e6..f5dd859 100644 --- a/src/main/java/com/mapconductor/googlemaps/raster/GoogleMapRasterLayerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/googlemaps/raster/GoogleMapRasterLayerOverlayRenderer.kt @@ -17,6 +17,7 @@ import okhttp3.OkHttpClient import okhttp3.Request import java.io.ByteArrayOutputStream import java.io.IOException +import java.util.concurrent.atomic.AtomicLong import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Canvas @@ -39,7 +40,7 @@ class GoogleMapRasterLayerOverlayRenderer( private val localRequests = mutableMapOf() /** 各オーバーレイがタイルを最後に返した時刻。差し替えた古い方を外す時機に使う。 */ - private val tileActivity = mutableMapOf() + private val tileActivity = mutableMapOf() /** 差し替え済みで、新しい方のタイルが揃うのを待って外す古いオーバーレイ。 */ private val retiring = mutableSetOf() @@ -130,7 +131,7 @@ class GoogleMapRasterLayerOverlayRenderer( while (true) { val now = SystemClock.elapsedRealtime() if (now - start >= RETIRE_TIMEOUT_MS) break - val last = activity?.lastServedAt ?: 0L + val last = activity?.get() ?: 0L if (last > 0L && now - last >= RETIRE_QUIET_MS) break delay(RETIRE_POLL_MS) } @@ -141,11 +142,6 @@ class GoogleMapRasterLayerOverlayRenderer( } } - private class TileActivity { - @Volatile - var lastServedAt: Long = 0L - } - override suspend fun onPostProcess() {} private fun addLayer(state: RasterLayerState): TileOverlay? { @@ -180,7 +176,7 @@ class GoogleMapRasterLayerOverlayRenderer( } else { null } - val activity = TileActivity() + val activity = AtomicLong(0L) val provider = object : TileProvider { private var firstTileLogged = false @@ -210,7 +206,7 @@ class GoogleMapRasterLayerOverlayRenderer( // A cancelled/failed tile is retryable. NO_TILE would // cache a permanent hole when this zoom is revisited. val bytes = runCatching { requests.renderTile(url, zoom) }.getOrNull() ?: return null - activity.lastServedAt = SystemClock.elapsedRealtime() + activity.set(SystemClock.elapsedRealtime()) val rendered = if (state.debug) { addDebugOverlay(bytes, tileSpec.tileSize, x, schemeY, zoom, tileSpec.scheme) } else { @@ -249,7 +245,7 @@ class GoogleMapRasterLayerOverlayRenderer( imageBytes } - activity.lastServedAt = SystemClock.elapsedRealtime() + activity.set(SystemClock.elapsedRealtime()) // Return a new Tile with the image data return Tile(tileSpec.tileSize, tileSpec.tileSize, renderedBytes) }