From c61d888ad7f6b9bb616a3d7815431dff5eeacda2 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Fri, 6 Nov 2026 10:23:14 -0800 Subject: [PATCH 1/3] Let ImageIcon and GroundImageState take an image URL ImageIcon(imageUrl = ...) and GroundImageState(bounds, imageUrl = ...) load the image themselves, matching react-sdk's GroundImageState.imageUrl. Until it arrives the icon is transparent at its final size and anchor (a label is still drawn) and the ground image is a 1 px transparent placeholder; when it arrives the markers and ground images using it are redrawn. - RemoteImageLoader fetches each URL once (http, https, file, data), keeps up to 32 MB of decoded bitmaps, remembers a failure for 30 s and logs it once. No new dependencies: HttpURLConnection with redirects followed by hand, as in android-kml's KMLLoader. - A URL icon's hash is the URL plus whether the image has arrived, so it changes exactly once. toBitmapIcon() and the tile parts stay synchronous; BitmapIconCache keeps the placeholder and the real bitmap apart. - MarkerState hears when a pending URL icon arrives and reports a write, but only if it still carries that icon. Nothing else re-reads icons, so without this the placeholder would stay until some other field changed. - GroundImageState.imageUrl swaps `image` to the loaded bitmap if the URL is still the same; setting `image` clears imageUrl. copy() keeps the URL. Public API: additions only (the two constructors and imageUrl getters/setter). --- api/android-sdk-core.api | 7 + docs/groundimage/GroundImage.kt.md | 29 ++- docs/marker/ImageIcon.kt.md | 33 ++- .../core/RemoteImageDeviceTest.kt | 196 ++++++++++++++++++ .../mapconductor/core/RemoteImageDrawable.kt | 99 +++++++++ .../mapconductor/core/RemoteImageLoader.kt | 192 +++++++++++++++++ .../core/groundimage/GroundImage.kt | 82 +++++++- .../com/mapconductor/core/marker/ImageIcon.kt | 56 +++++ .../com/mapconductor/core/marker/Marker.kt | 27 ++- 9 files changed, 715 insertions(+), 6 deletions(-) create mode 100644 src/androidTest/java/com/mapconductor/core/RemoteImageDeviceTest.kt create mode 100644 src/main/java/com/mapconductor/core/RemoteImageDrawable.kt create mode 100644 src/main/java/com/mapconductor/core/RemoteImageLoader.kt diff --git a/api/android-sdk-core.api b/api/android-sdk-core.api index 465734e..9d1e8ae 100644 --- a/api/android-sdk-core.api +++ b/api/android-sdk-core.api @@ -658,8 +658,12 @@ public final class com.mapconductor.core.groundimage.GroundImageState implements public final void setBounds(com.mapconductor.core.features.GeoRectBounds); public final android.graphics.drawable.Drawable getImage(); public final void setImage(android.graphics.drawable.Drawable); + public final java.lang.String getImageUrl(); + public final void setImageUrl(java.lang.String); public final float getOpacity(); public final void setOpacity(float); + public com.mapconductor.core.groundimage.GroundImageState(com.mapconductor.core.features.GeoRectBounds, java.lang.String, float, int, boolean, java.lang.String, java.io.Serializable, kotlin.jvm.functions.Function1); + public com.mapconductor.core.groundimage.GroundImageState(com.mapconductor.core.features.GeoRectBounds, java.lang.String, float, int, boolean, java.lang.String, java.io.Serializable, kotlin.jvm.functions.Function1, int, kotlin.jvm.internal.DefaultConstructorMarker); public final int getTileSize(); public final void setTileSize(int); public final boolean getClickable(); @@ -1582,6 +1586,8 @@ public final class com.mapconductor.core.marker.ImageIcon extends com.mapconduct public final long getLabelStrokeColor-0d7_KjU(); public final java.lang.String getLabelPosition(); public final java.lang.String getLabelWritingMode(); + public com.mapconductor.core.marker.ImageIcon(java.lang.String, float, float, long, long, boolean, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, java.lang.String, java.lang.String, int, kotlin.jvm.internal.DefaultConstructorMarker); + public final java.lang.String getImageUrl(); public long getAnchor-F1C5BW0(); public long getInfoAnchor-F1C5BW0(); public boolean equals(java.lang.Object); @@ -1589,6 +1595,7 @@ public final class com.mapconductor.core.marker.ImageIcon extends com.mapconduct public com.mapconductor.core.marker.BitmapIcon toBitmapIcon(); public final com.mapconductor.core.marker.ImageIcon copy-MQQ_14o(android.graphics.drawable.Drawable, float, float, long, long, boolean, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, java.lang.String, java.lang.String); public com.mapconductor.core.marker.ImageIcon(android.graphics.drawable.Drawable, float, float, long, long, boolean, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, java.lang.String, java.lang.String, kotlin.jvm.internal.DefaultConstructorMarker); + public com.mapconductor.core.marker.ImageIcon(java.lang.String, float, float, long, long, boolean, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, java.lang.String, java.lang.String, kotlin.jvm.internal.DefaultConstructorMarker); } final class com.mapconductor.core.marker.ImageIcon$Layout { public final int getCanvasSize(); diff --git a/docs/groundimage/GroundImage.kt.md b/docs/groundimage/GroundImage.kt.md index a2a4d86..f462019 100644 --- a/docs/groundimage/GroundImage.kt.md +++ b/docs/groundimage/GroundImage.kt.md @@ -20,6 +20,26 @@ class GroundImageState( ) : ComponentState ``` +The image can also be given as a URL: + +```kotlin +GroundImageState( + bounds: GeoRectBounds, + imageUrl: String, + opacity: Float = 1.0f, + tileSize: Int = GroundImageTileProvider.DEFAULT_TILE_SIZE, + clickable: Boolean = true, + id: String? = null, + extra: Serializable? = null, + onClick: OnGroundImageEventHandler? = null, +) +``` + +The SDK downloads the image (http, https, file or data URLs; http(s) needs the app's +`android.permission.INTERNET`). Until it arrives `image` is a transparent placeholder; once it +arrives `image` becomes the loaded image and the overlay is redrawn. If the image cannot be loaded +the overlay stays transparent and a warning is logged once. + #### Parameters - `bounds` @@ -29,6 +49,9 @@ class GroundImageState( - `image` - Type: `Drawable` - Description: The `Drawable` resource to be displayed as the overlay. +- `imageUrl` + - Type: `String` + - Description: The URL of the image to load, instead of `image`. See above. - `opacity` - Type: `Float` - Description: The opacity of the image, ranging from `0.0f` (fully transparent) to `1.0f` @@ -62,7 +85,11 @@ after initialization to dynamically update the ground image on the map. - Description: The geographical area the image covers. - `image` - Type: `Drawable` - - Description: The image `Drawable` to display. + - Description: The image `Drawable` to display. Setting it clears `imageUrl`. +- `imageUrl` + - Type: `String?` + - Description: The URL the image is loaded from, or `null` if the image was given as a + `Drawable`. Setting it loads that image; a result for an earlier URL is ignored. - `opacity` - Type: `Float` - Description: The opacity of the image. diff --git a/docs/marker/ImageIcon.kt.md b/docs/marker/ImageIcon.kt.md index 96e1e7c..e87a198 100644 --- a/docs/marker/ImageIcon.kt.md +++ b/docs/marker/ImageIcon.kt.md @@ -23,12 +23,37 @@ class ImageIcon( ) : AndroidDrawableIcon ``` +An `ImageIcon` can also load its image from a URL: + +```kotlin +ImageIcon( + imageUrl: String, + iconSize: Dp = Settings.Default.iconSize, + scale: Float = 1.0f, + anchor: Offset = Offset(0.5f, 0.5f), + infoAnchor: Offset = Offset(0.5f, 0.5f), + debug: Boolean = false, + // label parameters are the same as for the Drawable constructor +) +``` + +The SDK downloads the image (http, https, file or data URLs; http(s) needs the app's +`android.permission.INTERNET`). Until the image arrives the icon is transparent at its final size +and anchor (a label is still drawn), and every marker using it is redrawn once it arrives. Each URL +is fetched once, however many icons or markers use it. If the image cannot be loaded the icon stays +transparent and a warning is logged once. `imageUrl` returns the URL, or `null` for an icon built +from a `Drawable`; `copy()` keeps the URL. + ## Parameters - `image` - Type: `Drawable` - Default: (none) - Description: The Android `Drawable` to be used as the icon. +- `imageUrl` + - Type: `String` + - Default: (none) + - Description: The URL of the image to load instead of `image`. See above. - `iconSize` - Type: `Dp` - Default: `Settings.Default.iconSize` @@ -90,7 +115,13 @@ val customIcon = ImageIcon( anchor = Offset(0.5f, 1.0f) // Anchor at the bottom-center ) -// 3. Use the icons when creating Markers for the map +// 3. Load the image from a URL. The marker shows once the image has arrived. +val remoteIcon = ImageIcon( + imageUrl = "https://example.com/marker.png", + anchor = Offset(0.5f, 1.0f) +) + +// 4. Use the icons when creating Markers for the map val marker1 = Marker( position = LatLng(34.0522, -118.2437), icon = defaultIcon, diff --git a/src/androidTest/java/com/mapconductor/core/RemoteImageDeviceTest.kt b/src/androidTest/java/com/mapconductor/core/RemoteImageDeviceTest.kt new file mode 100644 index 0000000..3e2c648 --- /dev/null +++ b/src/androidTest/java/com/mapconductor/core/RemoteImageDeviceTest.kt @@ -0,0 +1,196 @@ +package com.mapconductor.core + +import androidx.compose.ui.unit.Dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoRectBounds +import com.mapconductor.core.groundimage.GroundImageState +import com.mapconductor.core.marker.ImageIcon +import com.mapconductor.core.marker.MarkerState +import java.io.ByteArrayOutputStream +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import kotlinx.coroutines.asCoroutineDispatcher + +/** + * URL で渡す画像(`ImageIcon(imageUrl)` / `GroundImageState(imageUrl)`)。 + * + * 取得は差し替えた [RemoteImageLoader.fetch] がラッチで止めるので、「読み込み中」と + * 「届いた後」を順に確かめられる。復号は端末の BitmapFactory を使う。 + */ +@RunWith(AndroidJUnit4::class) +class RemoteImageDeviceTest { + private val executor = Executors.newSingleThreadExecutor() + private val release = CountDownLatch(1) + private val fetches = AtomicInteger() + private var failing = false + + private val png: ByteArray by lazy { + val bitmap = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.RED) } + ByteArrayOutputStream().also { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }.toByteArray() + } + + @Before + fun setUp() { + RemoteImageLoader.resetForTest() + RemoteImageLoader.dispatcher = executor.asCoroutineDispatcher() + RemoteImageLoader.fetch = { url -> + fetches.incrementAndGet() + release.await(5, TimeUnit.SECONDS) + if (failing) throw IllegalStateException("offline: $url") + png + } + } + + @After + fun tearDown() { + RemoteImageLoader.resetForTest() + executor.shutdownNow() + } + + /** 取得のスレッドで、それまでに積まれた仕事が終わるのを待つ。 */ + private fun drain() { + executor.submit {}.get(5, TimeUnit.SECONDS) + } + + private fun loadAll() { + release.countDown() + drain() + } + + @Test + fun oneFetchPerUrlAndEveryoneGetsTheSameImage() { + val results = mutableListOf() + RemoteImageLoader.load("https://example.test/a.png") { synchronized(results) { results.add(it) } } + RemoteImageLoader.load("https://example.test/a.png") { synchronized(results) { results.add(it) } } + loadAll() + + assertEquals(1, fetches.get()) + assertEquals(2, results.size) + assertSame(results[0], results[1]) + assertEquals(8, results[0]!!.width) + // 読み終えた後は、その場で返る。 + var immediate: Bitmap? = null + RemoteImageLoader.load("https://example.test/a.png") { immediate = it } + assertSame(results[0], immediate) + assertEquals(1, fetches.get()) + } + + @Test + fun aFailureIsRememberedForAWhile() { + failing = true + var first: Bitmap? = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) + RemoteImageLoader.load("https://example.test/missing.png") { first = it } + loadAll() + assertNull(first) + + var second: Bitmap? = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) + RemoteImageLoader.load("https://example.test/missing.png") { second = it } + assertNull(second) + assertEquals(1, fetches.get()) + } + + @Test + fun dataUrlsAreDecodedWithoutTheNetwork() { + RemoteImageLoader.resetForTest() + val url = "data:image/png;base64," + android.util.Base64.encodeToString(png, android.util.Base64.NO_WRAP) + val done = CountDownLatch(1) + var bitmap: Bitmap? = null + RemoteImageLoader.load(url) { + bitmap = it + done.countDown() + } + assertTrue(done.await(5, TimeUnit.SECONDS)) + assertEquals(8, bitmap!!.width) + } + + @Test + fun iconLooksTheSameSizeAndChangesOnceWhenTheImageArrives() { + val icon = ImageIcon(imageUrl = "https://example.test/icon.png", iconSize = Dp(16f)) + val same = ImageIcon(imageUrl = "https://example.test/icon.png", iconSize = Dp(16f)) + assertEquals("https://example.test/icon.png", icon.imageUrl) + assertEquals(icon, same) + val pendingHash = icon.hashCode() + val pending = icon.toBitmapIcon() + val center = pending.bitmap.width / 2 + assertEquals(0, Color.alpha(pending.bitmap.getPixel(center, center))) + + loadAll() + + assertNotEquals(pendingHash, icon.hashCode()) + assertEquals(icon, same) + val ready = icon.toBitmapIcon() + assertEquals(pending.size, ready.size) + assertEquals(pending.anchor, ready.anchor) + assertEquals(Color.RED, ready.bitmap.getPixel(center, center)) + // copy は URL を引き継ぐ。 + assertEquals("https://example.test/icon.png", icon.copy(scale = 2f).imageUrl) + assertNull(ImageIcon(image = ColorDrawable(Color.RED)).imageUrl) + } + + @Test + fun markerRedrawsWhenItsIconArrivesButNotAfterTheIconWasReplaced() { + val icon = ImageIcon(imageUrl = "https://example.test/marker.png") + val kept = MarkerState(position = GeoPoint.fromLatLong(35.0, 139.0), icon = icon) + val replaced = MarkerState(position = GeoPoint.fromLatLong(35.0, 139.0), icon = icon) + val keptWrites = AtomicInteger() + val replacedWrites = AtomicInteger() + kept.mutations.listen { keptWrites.incrementAndGet() } + replaced.icon = ImageIcon(image = ColorDrawable(Color.BLUE)) + replaced.mutations.listen { replacedWrites.incrementAndGet() } + val before = kept.fingerPrint() + + loadAll() + + assertEquals(1, keptWrites.get()) + assertEquals(0, replacedWrites.get()) + assertNotEquals(before, kept.fingerPrint()) + } + + @Test + fun groundImageShowsTheImageOnceLoadedAndKeepsOnlyTheLatestUrl() { + val bounds = + GeoRectBounds( + southWest = GeoPoint.fromLatLong(35.0, 139.0), + northEast = GeoPoint.fromLatLong(35.1, 139.1), + ) + val state = GroundImageState(bounds = bounds, imageUrl = "https://example.test/ground.png") + val placeholder = state.image + assertEquals(1, placeholder.intrinsicWidth) + + // 読み終える前に別の URL へ替えたら、前の結果は使わない。 + val other = GroundImageState(bounds = bounds, imageUrl = "https://example.test/old.png") + other.imageUrl = "https://example.test/new.png" + + loadAll() + + assertEquals(8, state.image.intrinsicWidth) + assertNotEquals(placeholder.hashCode(), state.image.hashCode()) + assertEquals("https://example.test/new.png", other.imageUrl) + assertEquals( + GroundImageState(bounds = bounds, imageUrl = "https://example.test/new.png").image, + other.image, + ) + + // 画像を直接書き換えたら URL は消える。copy は URL を引き継ぐ。 + assertEquals("https://example.test/ground.png", state.copy(opacity = 0.5f).imageUrl) + state.image = ColorDrawable(Color.GREEN) + assertNull(state.imageUrl) + assertFalse(state.image is UrlImageDrawable) + } +} diff --git a/src/main/java/com/mapconductor/core/RemoteImageDrawable.kt b/src/main/java/com/mapconductor/core/RemoteImageDrawable.kt new file mode 100644 index 0000000..55d2052 --- /dev/null +++ b/src/main/java/com/mapconductor/core/RemoteImageDrawable.kt @@ -0,0 +1,99 @@ +package com.mapconductor.core + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.ColorFilter +import android.graphics.Paint +import android.graphics.PixelFormat +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable + +/** + * URL の画像を、読み終えるまでは透明に、読み終えたらその画像を描く Drawable。 + * [com.mapconductor.core.marker.ImageIcon] が `imageUrl` で作られたときの中身。 + * + * 画像は一度届いたら手放さない([RemoteImageLoader] のキャッシュから追い出されても描ける)。 + * 透明から画像への変化は一方向で、一度しか起きない。 + */ +@InternalMapConductorApi +internal class RemoteImageDrawable( + val url: String, +) : Drawable() { + private val lock = Any() + + @Volatile + private var bitmap: Bitmap? = RemoteImageLoader.cached(url) + private var settled = bitmap != null + private var listeners: MutableList<() -> Unit>? = null + private val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG) + + init { + if (!settled) RemoteImageLoader.load(url, ::onLoaded) + } + + /** 画像が届いているか。 */ + val isReady: Boolean + get() = bitmap != null + + /** URL と、届いているかどうか。届いた時点で一度だけ変わる。 */ + fun identity(): Int = 31 * url.hashCode() + if (isReady) 1 else 0 + + /** + * 読み込みが終わったら(失敗も含めて)[listener] を一度だけ呼ぶ。 + * もう終わっていれば呼ばずに false を返す。呼ぶのは読み込みのスレッド。 + */ + fun whenLoaded(listener: () -> Unit): Boolean = + synchronized(lock) { + if (settled) return false + val list = listeners ?: mutableListOf<() -> Unit>().also { listeners = it } + list.add(listener) + true + } + + private fun onLoaded(result: Bitmap?) { + val toCall = + synchronized(lock) { + bitmap = result + settled = true + listeners.also { listeners = null } + } + toCall?.forEach { it() } + } + + override fun draw(canvas: Canvas) { + val image = bitmap ?: return + canvas.drawBitmap(image, null, bounds, paint) + } + + override fun setAlpha(alpha: Int) { + paint.alpha = alpha + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + paint.colorFilter = colorFilter + } + + @Deprecated("Deprecated in Java") + override fun getOpacity(): Int = PixelFormat.TRANSLUCENT + + override fun getIntrinsicWidth(): Int = bitmap?.width ?: -1 + + override fun getIntrinsicHeight(): Int = bitmap?.height ?: -1 +} + +/** + * URL から読んだ画像を包む [BitmapDrawable]。 + * [com.mapconductor.core.groundimage.GroundImageState] が `imageUrl` で作られたときの `image`。 + * + * 同じ URL の同じ画像なら等しい。毎回作り直す `GroundImage(imageUrl = …)` でも + * 指紋が変わらず、作り直しのたびに描き直さずに済む。読み終えるまでは 1 px の透明な画像。 + */ +@InternalMapConductorApi +internal class UrlImageDrawable( + val url: String, + bitmap: Bitmap?, +) : BitmapDrawable(null, bitmap ?: RemoteImageLoader.transparentPixel) { + override fun equals(other: Any?): Boolean = other is UrlImageDrawable && other.url == url && other.bitmap === bitmap + + override fun hashCode(): Int = 31 * url.hashCode() + System.identityHashCode(bitmap) +} diff --git a/src/main/java/com/mapconductor/core/RemoteImageLoader.kt b/src/main/java/com/mapconductor/core/RemoteImageLoader.kt new file mode 100644 index 0000000..53cf5d9 --- /dev/null +++ b/src/main/java/com/mapconductor/core/RemoteImageLoader.kt @@ -0,0 +1,192 @@ +package com.mapconductor.core + +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLDecoder +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Base64 +import android.util.Log +import android.util.LruCache +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +/** + * URL で渡された画像([com.mapconductor.core.marker.ImageIcon] の `imageUrl`、 + * [com.mapconductor.core.groundimage.GroundImageState] の `imageUrl`)を読む。 + * + * - 同じ URL は同時に一度だけ取りに行き、待っている全員に同じ [Bitmap] を渡す。 + * 14 万本のマーカーが 1 つのアイコンを共有していても、通信は 1 回で済む。 + * - 読んだ画像は大きさの合計で [MAX_CACHE_BYTES] まで覚えておく。 + * - 失敗は [RETRY_AFTER_MS] のあいだ覚えておき、その間の要求にはすぐ null を返す。 + * ログは URL ごとに一度だけ出す。 + * - 読めるのは http / https / file / data。http(s) にはアプリの `android.permission.INTERNET` が要る。 + * + * 読み込みの完了は、呼び出し元とは別のスレッドから通知する。 + */ +@InternalMapConductorApi +internal object RemoteImageLoader { + private const val TAG = "MapConductor" + private const val MAX_CACHE_BYTES = 32 * 1024 * 1024 + private const val RETRY_AFTER_MS = 30_000L + private const val MAX_REDIRECTS = 10 + + /** URL からバイト列を取る。テストで差し替える。 */ + @Volatile + internal var fetch: (url: String) -> ByteArray = ::defaultFetch + + /** 取得と復号を走らせる先。テストで差し替える。 */ + @Volatile + internal var dispatcher: CoroutineDispatcher = Dispatchers.IO + + private val scope = CoroutineScope(SupervisorJob()) + private val lock = Any() + private val cache = + object : LruCache(MAX_CACHE_BYTES) { + override fun sizeOf( + key: String, + value: Bitmap, + ): Int = value.allocationByteCount + } + private val waiting = HashMap Unit>>() + private val failedAt = HashMap() + private val reported = HashSet() + + /** 読み終えるまでの代わりに使う、1 px の透明な画像。 */ + val transparentPixel: Bitmap by lazy { + Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888).apply { density = Bitmap.DENSITY_NONE } + } + + /** 読み終えて覚えている画像。無ければ null(読み込みは始めない)。 */ + fun cached(url: String): Bitmap? = synchronized(lock) { cache.get(url) } + + /** + * [url] の画像を読み、終わったら [onDone] を一度だけ呼ぶ(失敗は null)。 + * + * 覚えている画像や、最近失敗した URL なら、その場で呼ぶ。 + */ + fun load( + url: String, + onDone: (Bitmap?) -> Unit, + ) { + val settled: Bitmap? + synchronized(lock) { + val hit = cache.get(url) + val failed = failedAt[url] + when { + hit != null -> settled = hit + failed != null && System.currentTimeMillis() - failed < RETRY_AFTER_MS -> settled = null + else -> { + val listeners = waiting[url] + if (listeners != null) { + listeners.add(onDone) + } else { + waiting[url] = mutableListOf(onDone) + start(url) + } + return + } + } + } + onDone(settled) + } + + private fun start(url: String) { + scope.launch(dispatcher) { + val result = + runCatching { + decode(fetch(url)) ?: throw IllegalArgumentException("not an image") + } + val bitmap = result.getOrNull() + val listeners = + synchronized(lock) { + if (bitmap != null) { + cache.put(url, bitmap) + failedAt.remove(url) + } else { + failedAt[url] = System.currentTimeMillis() + if (reported.add(url)) { + Log.w(TAG, "Failed to load image: $url", result.exceptionOrNull()) + } + } + waiting.remove(url).orEmpty() + } + listeners.forEach { it(bitmap) } + } + } + + /** 覚えている画像と失敗を忘れ、差し替えた取得手段を元に戻す。テスト用。 */ + internal fun resetForTest() { + synchronized(lock) { + cache.evictAll() + failedAt.clear() + reported.clear() + } + fetch = ::defaultFetch + dispatcher = Dispatchers.IO + } + + /** + * 画素の大きさのまま復号する。密度は [Bitmap.DENSITY_NONE] にして、`BitmapDrawable` に + * 包んでも端末の密度で縮まないようにする。 + */ + private fun decode(bytes: ByteArray): Bitmap? { + val options = BitmapFactory.Options().apply { inScaled = false } + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)?.apply { + density = Bitmap.DENSITY_NONE + } + } + + private fun defaultFetch(url: String): ByteArray = + when { + url.startsWith("data:") -> decodeDataUrl(url) + url.startsWith("file://") -> File(URL(url).path).readBytes() + url.startsWith("http://") || url.startsWith("https://") -> fetchHttp(url) + else -> throw IllegalArgumentException("Unsupported image URL: $url") + } + + private fun decodeDataUrl(url: String): ByteArray { + val comma = url.indexOf(',') + require(comma >= 0) { "Malformed data URL" } + val header = url.substring(0, comma) + val body = url.substring(comma + 1) + return if (header.endsWith(";base64")) { + Base64.decode(body, Base64.DEFAULT) + } else { + URLDecoder.decode(body, "UTF-8").toByteArray() + } + } + + /** + * リダイレクトは手で追う(HttpURLConnection の自動追従は http と https をまたがない)。 + * android-kml の `KMLLoader` と同じ。 + */ + private fun fetchHttp(urlStr: String): ByteArray { + var url = URL(urlStr) + repeat(MAX_REDIRECTS) { + val connection = url.openConnection() as HttpURLConnection + try { + connection.connectTimeout = 15_000 + connection.readTimeout = 30_000 + connection.instanceFollowRedirects = true + when (val status = connection.responseCode) { + HttpURLConnection.HTTP_OK -> return connection.inputStream.use { it.readBytes() } + in 300..399 -> { + val location = + connection.getHeaderField("Location") + ?: throw IllegalStateException("HTTP $status without Location: $url") + url = URL(url, location) + } + else -> throw IllegalStateException("HTTP $status: $url") + } + } finally { + connection.disconnect() + } + } + throw IllegalStateException("Too many redirects: $urlStr") + } +} diff --git a/src/main/java/com/mapconductor/core/groundimage/GroundImage.kt b/src/main/java/com/mapconductor/core/groundimage/GroundImage.kt index a903d32..a4c5e55 100644 --- a/src/main/java/com/mapconductor/core/groundimage/GroundImage.kt +++ b/src/main/java/com/mapconductor/core/groundimage/GroundImage.kt @@ -3,12 +3,21 @@ package com.mapconductor.core.groundimage import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import com.mapconductor.core.ComponentState +import com.mapconductor.core.RemoteImageLoader import com.mapconductor.core.StateMutationSignal +import com.mapconductor.core.UrlImageDrawable import com.mapconductor.core.features.GeoPoint import com.mapconductor.core.features.GeoRectBounds import java.io.Serializable import android.graphics.drawable.Drawable +/** + * 地図に貼る画像。 + * + * 画像は `Drawable` で渡すか、[imageUrl](http / https / file / data)で渡す。URL のときは + * SDK が読み、読み終えるまでは透明、読み終えたら [image] がその画像に替わって描き直される。 + * http(s) にはアプリの `android.permission.INTERNET` が要る。react-sdk の `imageUrl` と同じ。 + */ class GroundImageState( bounds: GeoRectBounds, image: Drawable, @@ -29,8 +38,37 @@ class GroundImageState( // var bounds by StateFlowDelegate(bounds) var bounds by mutations.notifying(bounds) - var image by mutations.notifying(image) + private val currentImage = mutations.notifying(image) + private val currentImageUrl = mutations.notifying(null) + + /** 描く画像。書き換えると [imageUrl] は null になる。 */ + var image: Drawable + get() = currentImage.value + set(value) { + currentImageUrl.value = null + currentImage.value = value + } + + /** + * 画像の URL。`Drawable` で作ったか、[image] を書き換えたなら null。 + * + * 書き換えると、読み終えるまで [image] は透明になり、読み終えたらその画像になる。 + * 読めなかったときは透明のまま。読み終える前にまた書き換えたら、古い方の結果は捨てる。 + */ + var imageUrl: String? + get() = currentImageUrl.value + set(value) { + currentImageUrl.value = value + if (value == null) return + currentImage.value = UrlImageDrawable(value, null) + RemoteImageLoader.load(value) { bitmap -> + if (bitmap != null && currentImageUrl.value == value) { + currentImage.value = UrlImageDrawable(value, bitmap) + } + } + } var opacity by mutations.notifying(opacity) + var tileSize by mutations.notifying(tileSize) /** @@ -45,6 +83,29 @@ class GroundImageState( var extra by mutations.notifying(extra) var onClick by mutations.notifying(onClick) + /** [imageUrl] の画像を貼る。読み終えるまでは透明。ほかの引数は `Drawable` を渡すコンストラクタと同じ。 */ + constructor( + bounds: GeoRectBounds, + imageUrl: String, + opacity: Float = 1.0f, + tileSize: Int = GroundImageTileProvider.DEFAULT_TILE_SIZE, + clickable: Boolean = true, + id: String? = null, + extra: Serializable? = null, + onClick: OnGroundImageEventHandler? = null, + ) : this( + bounds = bounds, + image = UrlImageDrawable(imageUrl, null), + opacity = opacity, + tileSize = tileSize, + clickable = clickable, + id = id, + extra = extra, + onClick = onClick, + ) { + this.imageUrl = imageUrl + } + fun fingerPrint(): GroundImageFingerPrint = GroundImageFingerPrint( id = id.hashCode(), @@ -82,8 +143,22 @@ class GroundImageState( id: String? = this.id, extra: Serializable? = this.extra, onClick: OnGroundImageEventHandler? = this.onClick, - ): GroundImageState = - GroundImageState( + ): GroundImageState { + // 画像を差し替えないなら URL ごと引き継ぐ(読み込み中でも、届いたら写る)。 + val url = imageUrl + if (url != null && image === this.image) { + return GroundImageState( + bounds = bounds, + imageUrl = url, + opacity = opacity, + tileSize = tileSize, + clickable = clickable, + id = id, + extra = extra, + onClick = onClick, + ) + } + return GroundImageState( bounds = bounds, image = image, opacity = opacity, @@ -93,6 +168,7 @@ class GroundImageState( extra = extra, onClick = onClick, ) + } override fun equals(other: Any?): Boolean = (other as? GroundImageState)?.hashCode() == this.hashCode() diff --git a/src/main/java/com/mapconductor/core/marker/ImageIcon.kt b/src/main/java/com/mapconductor/core/marker/ImageIcon.kt index 8c55e3b..0b4c79a 100644 --- a/src/main/java/com/mapconductor/core/marker/ImageIcon.kt +++ b/src/main/java/com/mapconductor/core/marker/ImageIcon.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.sp import androidx.core.graphics.createBitmap import com.mapconductor.core.BitmapIconCache +import com.mapconductor.core.RemoteImageDrawable import com.mapconductor.core.ResourceProvider import com.mapconductor.settings.Settings import android.graphics.Canvas @@ -38,6 +39,14 @@ import android.graphics.drawable.GradientDrawable * * タイル描画は本体の画像と文字を別々に使うので、ラベル付きのアイコンをいくつ作っても、 * 本体の画像は元の画像と大きさごとに 1 枚で済む。 + * + * ## URL の画像 + * + * `ImageIcon(imageUrl = "https://…")` は画像を SDK が読む。読み終えるまでは同じ大きさの + * 透明な画像(ラベルは描く)で、届いたらそのアイコンを使っているマーカーが描き直される。 + * 同じ URL は一度しか取りに行かない。http / https / file / data を読め、http(s) には + * アプリの `android.permission.INTERNET` が要る。読めなかったときは透明のまま。 + * react-sdk / ios-sdk の `ImageIcon` の `imageUrl` と同じ。 */ class ImageIcon( image: Drawable, @@ -56,9 +65,54 @@ class ImageIcon( ) : AndroidDrawableIcon( drawable = image, ) { + /** + * [imageUrl](http / https / file / data)の画像を使う。読み終えるまでは透明。 + * ほかの引数は `Drawable` を渡すコンストラクタと同じ。 + */ + constructor( + imageUrl: String, + iconSize: Dp = Settings.Default.iconSize, + scale: Float = 1.0f, + anchor: Offset = Offset(0.5f, 0.5f), + infoAnchor: Offset = Offset(0.5f, 0.5f), + debug: Boolean = false, + label: String? = null, + labelTextColor: Color? = Color.Black, + labelTextSize: TextUnit = 18.sp, + labelTypeFace: Typeface = Typeface.DEFAULT, + labelStrokeColor: Color = Color.White, + labelPosition: String = AbstractDefaultIcon.DEFAULT_LABEL_POSITION, + labelWritingMode: String = AbstractDefaultIcon.DEFAULT_LABEL_WRITING_MODE, + ) : this( + image = RemoteImageDrawable(imageUrl), + iconSize = iconSize, + scale = scale, + anchor = anchor, + infoAnchor = infoAnchor, + debug = debug, + label = label, + labelTextColor = labelTextColor, + labelTextSize = labelTextSize, + labelTypeFace = labelTypeFace, + labelStrokeColor = labelStrokeColor, + labelPosition = labelPosition, + labelWritingMode = labelWritingMode, + ) + private val requestedAnchor = anchor private val requestedInfoAnchor = infoAnchor + /** URL で作ったアイコンならその URL。`Drawable` で作ったなら null。 */ + val imageUrl: String? + get() = (drawable as? RemoteImageDrawable)?.url + + /** + * URL の画像がまだ届いていなければ、届いたとき(失敗も含む)に [listener] を一度だけ呼ぶ。 + * 待つ必要が無ければ呼ばずに false を返す。[MarkerState] が描き直しに使う。 + */ + internal fun whenImageLoaded(listener: () -> Unit): Boolean = + (drawable as? RemoteImageDrawable)?.whenLoaded(listener) ?: false + private class Layout( val canvasSize: Int, val result: IconLabelLayout.Result, @@ -143,6 +197,8 @@ class ImageIcon( private fun getDrawableIdentity(): Int = when (drawable) { + // URL と、画像が届いたかどうか。届いた時点で変わり、マーカーが描き直される。 + is RemoteImageDrawable -> drawable.identity() is BitmapDrawable -> { val bmp = drawable.bitmap if (bmp == null || bmp.isRecycled) { diff --git a/src/main/java/com/mapconductor/core/marker/Marker.kt b/src/main/java/com/mapconductor/core/marker/Marker.kt index d5f9416..f518cfe 100644 --- a/src/main/java/com/mapconductor/core/marker/Marker.kt +++ b/src/main/java/com/mapconductor/core/marker/Marker.kt @@ -53,7 +53,14 @@ class MarkerState( */ override val mutations = StateMutationSignal() - var icon by mutations.notifying(icon) + private val currentIcon = mutations.notifying(icon) + var icon: MarkerIconInterface? + get() = currentIcon.value + set(value) { + val changed = currentIcon.value != value + currentIcon.value = value + if (changed) redrawWhenImageLoaded(value) + } var clickable by mutations.notifying(clickable) var draggable by mutations.notifying(draggable) var onClick by mutations.notifying(onClick) @@ -66,6 +73,24 @@ class MarkerState( private var internalAnimation by mutations.notifying(animation) + init { + redrawWhenImageLoaded(icon) + } + + /** + * URL の画像がまだ届いていないアイコンなら、届いたときに描き直させる。 + * + * アイコンの見た目(hashCode)は画像が届いた時点で変わるが、[icon] 自体は書き換わらない + * ので、ここで知らせる。そのときもまだ同じアイコンを付けていれば、だけ。 + * 読み込みのスレッドから呼ばれるが、[StateMutationSignal.notifyMutated] はどのスレッドからでもよい。 + */ + private fun redrawWhenImageLoaded(icon: MarkerIconInterface?) { + val image = icon as? ImageIcon ?: return + image.whenImageLoaded { + if (currentIcon.value === image) mutations.notifyMutated() + } + } + fun animate(animation: MarkerAnimation?) { internalAnimation = animation } From 6629c3d837b04781a3f7a46e0399b9c8996d8c62 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Fri, 6 Nov 2026 12:40:44 -0800 Subject: [PATCH 2/3] Keep failed image URLs failed, and let compositions see an icon's arrival - A URL that fails to load now stays failed until the process restarts, as in react-sdk and ios-sdk; the 30 s retry is gone. - MarkerState.icon also reads a counter that goes up when its URL icon's image arrives. The icon is the same instance before and after, so a composition that reads marker.icon (MapTiler and Longdo draw markers as Compose overlays) had no reason to run again. - api: the GroundImageState(imageUrl) constructors moved with the source; the surface is unchanged. --- api/android-sdk-core.api | 4 ++-- .../core/RemoteImageDeviceTest.kt | 2 +- .../mapconductor/core/RemoteImageLoader.kt | 24 +++++++------------ .../com/mapconductor/core/marker/Marker.kt | 18 ++++++++++++-- 4 files changed, 27 insertions(+), 21 deletions(-) diff --git a/api/android-sdk-core.api b/api/android-sdk-core.api index 9d1e8ae..e1ffa9d 100644 --- a/api/android-sdk-core.api +++ b/api/android-sdk-core.api @@ -662,8 +662,6 @@ public final class com.mapconductor.core.groundimage.GroundImageState implements public final void setImageUrl(java.lang.String); public final float getOpacity(); public final void setOpacity(float); - public com.mapconductor.core.groundimage.GroundImageState(com.mapconductor.core.features.GeoRectBounds, java.lang.String, float, int, boolean, java.lang.String, java.io.Serializable, kotlin.jvm.functions.Function1); - public com.mapconductor.core.groundimage.GroundImageState(com.mapconductor.core.features.GeoRectBounds, java.lang.String, float, int, boolean, java.lang.String, java.io.Serializable, kotlin.jvm.functions.Function1, int, kotlin.jvm.internal.DefaultConstructorMarker); public final int getTileSize(); public final void setTileSize(int); public final boolean getClickable(); @@ -672,6 +670,8 @@ public final class com.mapconductor.core.groundimage.GroundImageState implements public final void setExtra(java.io.Serializable); public final kotlin.jvm.functions.Function1 getOnClick(); public final void setOnClick(kotlin.jvm.functions.Function1); + public com.mapconductor.core.groundimage.GroundImageState(com.mapconductor.core.features.GeoRectBounds, java.lang.String, float, int, boolean, java.lang.String, java.io.Serializable, kotlin.jvm.functions.Function1); + public com.mapconductor.core.groundimage.GroundImageState(com.mapconductor.core.features.GeoRectBounds, java.lang.String, float, int, boolean, java.lang.String, java.io.Serializable, kotlin.jvm.functions.Function1, int, kotlin.jvm.internal.DefaultConstructorMarker); public final com.mapconductor.core.groundimage.GroundImageFingerPrint fingerPrint(); public final com.mapconductor.core.groundimage.GroundImageState copy(com.mapconductor.core.features.GeoRectBounds, android.graphics.drawable.Drawable, float, int, boolean, java.lang.String, java.io.Serializable, kotlin.jvm.functions.Function1); public boolean equals(java.lang.Object); diff --git a/src/androidTest/java/com/mapconductor/core/RemoteImageDeviceTest.kt b/src/androidTest/java/com/mapconductor/core/RemoteImageDeviceTest.kt index 3e2c648..68f2ace 100644 --- a/src/androidTest/java/com/mapconductor/core/RemoteImageDeviceTest.kt +++ b/src/androidTest/java/com/mapconductor/core/RemoteImageDeviceTest.kt @@ -92,7 +92,7 @@ class RemoteImageDeviceTest { } @Test - fun aFailureIsRememberedForAWhile() { + fun aFailedUrlStaysFailed() { failing = true var first: Bitmap? = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) RemoteImageLoader.load("https://example.test/missing.png") { first = it } diff --git a/src/main/java/com/mapconductor/core/RemoteImageLoader.kt b/src/main/java/com/mapconductor/core/RemoteImageLoader.kt index 53cf5d9..a84d7d1 100644 --- a/src/main/java/com/mapconductor/core/RemoteImageLoader.kt +++ b/src/main/java/com/mapconductor/core/RemoteImageLoader.kt @@ -22,8 +22,8 @@ import kotlinx.coroutines.launch * - 同じ URL は同時に一度だけ取りに行き、待っている全員に同じ [Bitmap] を渡す。 * 14 万本のマーカーが 1 つのアイコンを共有していても、通信は 1 回で済む。 * - 読んだ画像は大きさの合計で [MAX_CACHE_BYTES] まで覚えておく。 - * - 失敗は [RETRY_AFTER_MS] のあいだ覚えておき、その間の要求にはすぐ null を返す。 - * ログは URL ごとに一度だけ出す。 + * - 失敗した URL はアプリが終わるまで失敗のままにし、その後の要求にはすぐ null を返す。 + * ログは URL ごとに一度だけ出す。react-sdk / ios-sdk と同じ。 * - 読めるのは http / https / file / data。http(s) にはアプリの `android.permission.INTERNET` が要る。 * * 読み込みの完了は、呼び出し元とは別のスレッドから通知する。 @@ -32,7 +32,6 @@ import kotlinx.coroutines.launch internal object RemoteImageLoader { private const val TAG = "MapConductor" private const val MAX_CACHE_BYTES = 32 * 1024 * 1024 - private const val RETRY_AFTER_MS = 30_000L private const val MAX_REDIRECTS = 10 /** URL からバイト列を取る。テストで差し替える。 */ @@ -53,8 +52,7 @@ internal object RemoteImageLoader { ): Int = value.allocationByteCount } private val waiting = HashMap Unit>>() - private val failedAt = HashMap() - private val reported = HashSet() + private val failed = HashSet() /** 読み終えるまでの代わりに使う、1 px の透明な画像。 */ val transparentPixel: Bitmap by lazy { @@ -67,7 +65,7 @@ internal object RemoteImageLoader { /** * [url] の画像を読み、終わったら [onDone] を一度だけ呼ぶ(失敗は null)。 * - * 覚えている画像や、最近失敗した URL なら、その場で呼ぶ。 + * 覚えている画像や、失敗した URL なら、その場で呼ぶ。 */ fun load( url: String, @@ -76,10 +74,9 @@ internal object RemoteImageLoader { val settled: Bitmap? synchronized(lock) { val hit = cache.get(url) - val failed = failedAt[url] when { hit != null -> settled = hit - failed != null && System.currentTimeMillis() - failed < RETRY_AFTER_MS -> settled = null + url in failed -> settled = null else -> { val listeners = waiting[url] if (listeners != null) { @@ -106,12 +103,8 @@ internal object RemoteImageLoader { synchronized(lock) { if (bitmap != null) { cache.put(url, bitmap) - failedAt.remove(url) - } else { - failedAt[url] = System.currentTimeMillis() - if (reported.add(url)) { - Log.w(TAG, "Failed to load image: $url", result.exceptionOrNull()) - } + } else if (failed.add(url)) { + Log.w(TAG, "Failed to load image: $url", result.exceptionOrNull()) } waiting.remove(url).orEmpty() } @@ -123,8 +116,7 @@ internal object RemoteImageLoader { internal fun resetForTest() { synchronized(lock) { cache.evictAll() - failedAt.clear() - reported.clear() + failed.clear() } fetch = ::defaultFetch dispatcher = Dispatchers.IO diff --git a/src/main/java/com/mapconductor/core/marker/Marker.kt b/src/main/java/com/mapconductor/core/marker/Marker.kt index f518cfe..00c9ba9 100644 --- a/src/main/java/com/mapconductor/core/marker/Marker.kt +++ b/src/main/java/com/mapconductor/core/marker/Marker.kt @@ -1,6 +1,7 @@ package com.mapconductor.core.marker import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size @@ -54,8 +55,18 @@ class MarkerState( override val mutations = StateMutationSignal() private val currentIcon = mutations.notifying(icon) + + /** + * URL の画像が届くたびに増える。[icon] を読んだコンポジションは、これも読んでいるので + * 描き直される(アイコン自体は同じインスタンスのまま見た目だけ変わるため)。 + */ + private val iconImageArrivals = mutableIntStateOf(0) + var icon: MarkerIconInterface? - get() = currentIcon.value + get() { + iconImageArrivals.intValue + return currentIcon.value + } set(value) { val changed = currentIcon.value != value currentIcon.value = value @@ -87,7 +98,10 @@ class MarkerState( private fun redrawWhenImageLoaded(icon: MarkerIconInterface?) { val image = icon as? ImageIcon ?: return image.whenImageLoaded { - if (currentIcon.value === image) mutations.notifyMutated() + if (currentIcon.value === image) { + iconImageArrivals.intValue++ + mutations.notifyMutated() + } } } From 7f510ae3bd708fc1bdd2856030aec211ced6e137 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Fri, 6 Nov 2026 12:42:29 -0800 Subject: [PATCH 3/3] Read an icon's image arrival from iconRevision, not from MarkerState.icon Reading MarkerState.icon also read a Compose counter, and that getter runs 144k times on tile worker threads and in the controllers. The counter is now a separate MarkerState.iconRevision (@InternalMapConductorApi, in its own file so the API record keeps MarkerState); only overlays that draw markers in Compose read it. --- .../mapconductor/core/RemoteImageDeviceTest.kt | 3 +++ .../java/com/mapconductor/core/marker/Marker.kt | 14 ++++---------- .../core/marker/MarkerIconRevision.kt | 17 +++++++++++++++++ 3 files changed, 24 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/mapconductor/core/marker/MarkerIconRevision.kt diff --git a/src/androidTest/java/com/mapconductor/core/RemoteImageDeviceTest.kt b/src/androidTest/java/com/mapconductor/core/RemoteImageDeviceTest.kt index 68f2ace..aa25189 100644 --- a/src/androidTest/java/com/mapconductor/core/RemoteImageDeviceTest.kt +++ b/src/androidTest/java/com/mapconductor/core/RemoteImageDeviceTest.kt @@ -7,6 +7,7 @@ import com.mapconductor.core.features.GeoRectBounds import com.mapconductor.core.groundimage.GroundImageState import com.mapconductor.core.marker.ImageIcon import com.mapconductor.core.marker.MarkerState +import com.mapconductor.core.marker.iconRevision import java.io.ByteArrayOutputStream import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors @@ -159,6 +160,8 @@ class RemoteImageDeviceTest { assertEquals(1, keptWrites.get()) assertEquals(0, replacedWrites.get()) + assertEquals(1, kept.iconRevision) + assertEquals(0, replaced.iconRevision) assertNotEquals(before, kept.fingerPrint()) } diff --git a/src/main/java/com/mapconductor/core/marker/Marker.kt b/src/main/java/com/mapconductor/core/marker/Marker.kt index 00c9ba9..bc14c3f 100644 --- a/src/main/java/com/mapconductor/core/marker/Marker.kt +++ b/src/main/java/com/mapconductor/core/marker/Marker.kt @@ -56,17 +56,11 @@ class MarkerState( private val currentIcon = mutations.notifying(icon) - /** - * URL の画像が届くたびに増える。[icon] を読んだコンポジションは、これも読んでいるので - * 描き直される(アイコン自体は同じインスタンスのまま見た目だけ変わるため)。 - */ - private val iconImageArrivals = mutableIntStateOf(0) + /** [iconRevision] の中身。URL の画像が届くたびに増える。 */ + internal val iconRevisionState = mutableIntStateOf(0) var icon: MarkerIconInterface? - get() { - iconImageArrivals.intValue - return currentIcon.value - } + get() = currentIcon.value set(value) { val changed = currentIcon.value != value currentIcon.value = value @@ -99,7 +93,7 @@ class MarkerState( val image = icon as? ImageIcon ?: return image.whenImageLoaded { if (currentIcon.value === image) { - iconImageArrivals.intValue++ + iconRevisionState.intValue++ mutations.notifyMutated() } } diff --git a/src/main/java/com/mapconductor/core/marker/MarkerIconRevision.kt b/src/main/java/com/mapconductor/core/marker/MarkerIconRevision.kt new file mode 100644 index 0000000..dab03a5 --- /dev/null +++ b/src/main/java/com/mapconductor/core/marker/MarkerIconRevision.kt @@ -0,0 +1,17 @@ +package com.mapconductor.core.marker + +import com.mapconductor.core.InternalMapConductorApi + +/** + * [MarkerState.icon] の URL の画像が届くたびに増える数。Compose から読むと、そのとき描き直される。 + * + * URL の画像が届いても、アイコンは同じインスタンスのまま `hashCode` だけが変わるので、 + * `marker.icon` を読んでいるだけのコンポジションは走り直さない。マーカーをコンポーズの + * オーバーレイとして描くプロバイダは、これも読んで `remember` のキーにする。 + * + * [MarkerState.icon] の読み出し自体は Compose の状態を増やさない。タイルの描画スレッドや + * コントローラが 14 万回読むので、そこへ余計な読み取りを足さないため。 + */ +@InternalMapConductorApi +val MarkerState.iconRevision: Int + get() = iconRevisionState.intValue