diff --git a/src/main/java/com/mapconductor/googlemaps/marker/GoogleMapMarkerController.kt b/src/main/java/com/mapconductor/googlemaps/marker/GoogleMapMarkerController.kt index 03b9ca5..99d0c2f 100644 --- a/src/main/java/com/mapconductor/googlemaps/marker/GoogleMapMarkerController.kt +++ b/src/main/java/com/mapconductor/googlemaps/marker/GoogleMapMarkerController.kt @@ -22,8 +22,12 @@ 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 +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext @@ -56,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 @@ -139,11 +148,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 +171,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 +215,7 @@ internal class GoogleMapMarkerController private constructor( renderer.onPostProcess() if (tiledMarkerIds.isNotEmpty()) { - syncTiledOverlay() + scheduleTileRefresh() } else { removeTileOverlay() } @@ -211,6 +223,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 { @@ -230,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() + /** * マーカータイルのラスターレイヤの表示だけを切り替える。 * @@ -249,6 +291,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 @@ -339,6 +383,7 @@ internal class GoogleMapMarkerController private constructor( debugTileOverlay = markerTiling.debugTileOverlay, iconScaleCallback = markerTiling.iconScaleCallback, declutterPx = markerTiling.declutterPx, + displayTileZoom = ::displayTileZoom, ) markerTileRenderer = tileRenderer @@ -376,6 +421,9 @@ internal class GoogleMapMarkerController private constructor( } companion object { + /** 続けて来る変更を 1 回の描き直しにまとめる窓。1 フレーム分。 */ + private val TILE_REFRESH_COALESCE = 16.milliseconds + private const val TAG = "GoogleMapMarkerController" fun create( diff --git a/src/main/java/com/mapconductor/googlemaps/raster/GoogleMapRasterLayerOverlayRenderer.kt b/src/main/java/com/mapconductor/googlemaps/raster/GoogleMapRasterLayerOverlayRenderer.kt index 870bdcf..f5dd859 100644 --- a/src/main/java/com/mapconductor/googlemaps/raster/GoogleMapRasterLayerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/googlemaps/raster/GoogleMapRasterLayerOverlayRenderer.kt @@ -17,15 +17,19 @@ 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 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 +39,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 +87,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,8 +104,41 @@ 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?.get() ?: 0L + if (last > 0L && now - last >= RETIRE_QUIET_MS) break + delay(RETIRE_POLL_MS) + } + if (retiring.remove(old)) { + tileActivity.remove(old) + old.remove() + } } } @@ -130,6 +176,7 @@ class GoogleMapRasterLayerOverlayRenderer( } else { null } + val activity = AtomicLong(0L) val provider = object : TileProvider { private var firstTileLogged = false @@ -159,6 +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.set(SystemClock.elapsedRealtime()) val rendered = if (state.debug) { addDebugOverlay(bytes, tileSpec.tileSize, x, schemeY, zoom, tileSpec.scheme) } else { @@ -197,6 +245,7 @@ class GoogleMapRasterLayerOverlayRenderer( imageBytes } + activity.set(SystemClock.elapsedRealtime()) // Return a new Tile with the image data return Tile(tileSpec.tileSize, tileSpec.tileSize, renderedBytes) } @@ -214,6 +263,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 +312,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-" }