diff --git a/build.gradle.kts b/build.gradle.kts index 47a06cf..39326d7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -48,6 +48,12 @@ android { compose = true } + testOptions { + // JVM 単体テストで Compose ランタイムを回すと android.os.Trace を呼ぶ。 + // スタブの既定値(何もしない)で通す。InfoBubbleRegistrationTest を参照。 + unitTests.isReturnDefaultValues = true + } + publishing { singleVariant("release") { withSourcesJar() @@ -88,6 +94,7 @@ dependencies { implementation(libs.kotlinx.coroutines.android) testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7ff38ca..fa989c2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,6 +25,7 @@ androidx-compose-ui-unit = { group = "androidx.compose.ui", name = "ui-unit", ve # Coroutines kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } # Test junit = { group = "junit", name = "junit", version.ref = "junit" } diff --git a/src/main/java/com/mapconductor/compose/info/InfoBubbleCompose.kt b/src/main/java/com/mapconductor/compose/info/InfoBubbleCompose.kt index d4179fa..e77bb47 100644 --- a/src/main/java/com/mapconductor/compose/info/InfoBubbleCompose.kt +++ b/src/main/java/com/mapconductor/compose/info/InfoBubbleCompose.kt @@ -3,6 +3,7 @@ package com.mapconductor.compose.info import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color @@ -10,7 +11,9 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.mapconductor.compose.MapViewScope import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointInterface import com.mapconductor.core.marker.DefaultMarkerIcon +import com.mapconductor.core.marker.MarkerIconInterface import com.mapconductor.core.marker.MarkerState import java.util.UUID @@ -50,27 +53,18 @@ fun MapViewScope.InfoBubble( ) } - val entry = - InfoBubbleEntry( - id = marker.id, - positionProvider = { marker.position }, - // Marker本体もicon未指定時はDefaultMarkerIconを描画するため、InfoBubbleの - // 接続位置計算にも同じアイコン寸法・anchor・infoAnchorを使用する。 - icon = marker.icon ?: DefaultMarkerIcon(), - content = wrapped, - ) - - DisposableEffect(marker) { - val newMap = bubbleFlow.value.toMutableMap() - newMap[entry.id] = entry - bubbleFlow.value = newMap - - onDispose { - val newMap = bubbleFlow.value.toMutableMap() - newMap.remove(entry.id) - bubbleFlow.value = newMap - } - } + RegisterInfoBubble( + id = marker.id, + anchorKey = marker, + positionProvider = { marker.position }, + // Marker本体もicon未指定時はDefaultMarkerIconを描画するため、InfoBubbleの + // 接続位置計算にも同じアイコン寸法・anchor・infoAnchorを使用する。 + // marker.icon はスナップショット状態なので、ここで読めば差し替えで再合成される。 + icon = marker.icon, + defaultIcon = true, + tailOffset = Offset(0.5f, 1.0f), + content = wrapped, + ) } /** @@ -110,24 +104,15 @@ fun MapViewScope.InfoBubble( } // Re-register when position changes so the bubble moves to the new coordinates. - DisposableEffect(position) { - val entry = - InfoBubbleEntry( - id = id, - positionProvider = { position }, - icon = null, - content = wrapped, - ) - val newMap = bubbleFlow.value.toMutableMap() - newMap[id] = entry - bubbleFlow.value = newMap - - onDispose { - val newMap = bubbleFlow.value.toMutableMap() - newMap.remove(id) - bubbleFlow.value = newMap - } - } + RegisterInfoBubble( + id = id, + anchorKey = position, + positionProvider = { position }, + icon = null, + defaultIcon = false, + tailOffset = Offset(0.5f, 1.0f), + content = wrapped, + ) } /** @@ -144,25 +129,68 @@ fun MapViewScope.InfoBubbleCustom( tailOffset: Offset, content: @Composable () -> Unit, ) { + RegisterInfoBubble( + id = marker.id, + anchorKey = marker, + positionProvider = { marker.position }, + icon = marker.icon, + defaultIcon = true, + tailOffset = tailOffset, + content = content, + ) +} + +/** + * 吹き出しを [MapViewScope.bubbleFlow] に登録し、中身を常に最新に保つ。 + * + * 以前は各関数が `DisposableEffect(marker)` の中で**最初の合成時の** [InfoBubbleEntry] を + * 登録していた。エントリの `content` はそのときのラムダで、色や中身の値を捕まえたまま + * 固まるので、`bubbleColor` やラムダに渡した値、マーカーのアイコンが後から変わっても + * 画面の吹き出しは古いままだった。 + * + * ここではエントリを作り直さず、`content` を「最新のラムダを読んで呼ぶ」ラムダにする + * ([rememberUpdatedState])。中身や見た目が変わっても再合成されるのは吹き出しの中だけで、 + * `bubbleFlow` は鳴らない。位置・アイコン・接続点のように**どこに出すか**が変わったときだけ + * エントリを差し替える。 + * + * @param anchorKey 位置を決めるもの(マーカーまたは座標)。変わったら登録し直す。 + * @param icon 接続位置の計算に使うアイコン。[defaultIcon] が true で null なら既定のピン。 + */ +@Composable +private fun MapViewScope.RegisterInfoBubble( + id: String, + anchorKey: Any, + positionProvider: () -> GeoPointInterface, + icon: MarkerIconInterface?, + defaultIcon: Boolean, + tailOffset: Offset, + content: @Composable () -> Unit, +) { + val currentContent = rememberUpdatedState(content) val entry = - InfoBubbleEntry( - id = marker.id, - positionProvider = { marker.position }, - icon = marker.icon ?: DefaultMarkerIcon(), - tailOffset = tailOffset, - content = content, - ) + remember(id, anchorKey, icon, tailOffset) { + InfoBubbleEntry( + id = id, + positionProvider = positionProvider, + icon = icon ?: if (defaultIcon) DefaultMarkerIcon() else null, + tailOffset = tailOffset, + content = { currentContent.value() }, + ) + } - // Re-register when marker or tail changes - DisposableEffect(marker, tailOffset) { + DisposableEffect(entry) { val newMap = bubbleFlow.value.toMutableMap() newMap[entry.id] = entry bubbleFlow.value = newMap onDispose { - val newMap = bubbleFlow.value.toMutableMap() - newMap.remove(entry.id) - bubbleFlow.value = newMap + // 差し替えでは新しいエントリが同じ id で入る。消すのは自分が入れたものだけ。 + val current = bubbleFlow.value + if (current[entry.id] === entry) { + val newMap = current.toMutableMap() + newMap.remove(entry.id) + bubbleFlow.value = newMap + } } } } diff --git a/src/test/java/com/mapconductor/compose/info/InfoBubbleRegistrationTest.kt b/src/test/java/com/mapconductor/compose/info/InfoBubbleRegistrationTest.kt new file mode 100644 index 0000000..2ed556d --- /dev/null +++ b/src/test/java/com/mapconductor/compose/info/InfoBubbleRegistrationTest.kt @@ -0,0 +1,253 @@ +package com.mapconductor.compose.info + +import androidx.compose.runtime.AbstractApplier +import androidx.compose.runtime.BroadcastFrameClock +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Composition +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.mapconductor.compose.MapViewScope +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.marker.BitmapIcon +import com.mapconductor.core.marker.MarkerIconInterface +import com.mapconductor.core.marker.MarkerState +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * InfoBubble / InfoBubbleCustom が、後から変わった中身・見た目・アイコンに追従すること。 + * + * 以前は `DisposableEffect(marker)` の中で最初の合成時のエントリを登録していたので、 + * 中身のラムダが捕まえた値や `bubbleColor`、マーカーのアイコンが変わっても吹き出しは + * 古いままだった。落ちもせず吹き出しも出るので、画面で「数字が変わらない」を見るまで + * 気づけない。 + * + * 直し方の約束: + * - 中身や見た目が変わっても**エントリは同じ参照のまま**(bubbleFlow を鳴らさない) + * - それでもエントリの content を描けば**最新の中身**が出る + * - 位置・アイコン・接続点が変わったときだけエントリを差し替える + * + * UI を持たない Applier の上で Compose ランタイムだけを回す。中身の描画確認は + * UI を出さない InfoBubbleCustom で行う。 + */ +@OptIn(ExperimentalCoroutinesApi::class) +class InfoBubbleRegistrationTest { + // MapViewScope の OverlayCollector は Main で購読を始める。JVM には Main が無い。 + @Before + fun setUp() = Dispatchers.setMain(Dispatchers.Unconfined) + + @After + fun tearDown() = Dispatchers.resetMain() + + private class NoNodeApplier : AbstractApplier(Unit) { + override fun insertTopDown( + index: Int, + instance: Unit, + ) = Unit + + override fun insertBottomUp( + index: Int, + instance: Unit, + ) = Unit + + override fun remove( + index: Int, + count: Int, + ) = Unit + + override fun move( + from: Int, + to: Int, + count: Int, + ) = Unit + + override fun onClear() = Unit + } + + /** [content] を合成し、[steps] を順に実行して、各手順のあと再合成が落ち着くまで回す。 */ + private fun compose( + content: @Composable () -> Unit, + vararg steps: () -> Unit, + ) = runBlocking { + val clock = BroadcastFrameClock() + withContext(clock + Dispatchers.Unconfined) { + val recomposer = Recomposer(coroutineContext) + val job = + launch(start = CoroutineStart.UNDISPATCHED) { + recomposer.runRecomposeAndApplyChanges() + } + val composition = Composition(NoNodeApplier(), recomposer) + composition.setContent(content) + + suspend fun settle() { + repeat(5) { + Snapshot.sendApplyNotifications() + yield() + clock.sendFrame(0L) + yield() + } + } + settle() + for (step in steps) { + step() + settle() + } + composition.dispose() + recomposer.close() + job.join() + } + } + + /** DefaultMarkerIcon は JVM では作れない(Typeface などが要る)ので、寸法だけの代役。 */ + private class StubIcon( + override val iconSize: Dp, + ) : MarkerIconInterface { + override val scale = 1f + override val anchor = Offset(0.5f, 1f) + override val infoAnchor = Offset(0.5f, 0f) + override val debug = false + + override fun toBitmapIcon(): BitmapIcon = error("not drawn in this test") + } + + private fun marker() = + MarkerState( + id = "m1", + position = GeoPoint.fromLatLong(35.0, 139.0), + icon = StubIcon(48.dp), + ) + + @Test + fun customBubbleShowsLatestContentWithoutReRegistering() { + val scope = MapViewScope() + val marker = marker() + var label by mutableStateOf("count 0") + val rendered = mutableListOf() + val entries = mutableListOf() + + compose( + { + // 値をラムダの外で読む: ラムダは String を捕まえるので、毎回別のラムダになる。 + val text = label + scope.InfoBubbleCustom(marker = marker, tailOffset = Offset(0.5f, 1f)) { + rendered += text + } + // プロバイダ側の描画(MapViewBase と同じく bubbleFlow を読んで content を呼ぶ)。 + val bubbles by scope.bubbleFlow.collectAsState() + bubbles.values.forEach { entry -> key(entry.id) { entry.content() } } + }, + { entries += scope.bubbleFlow.value["m1"] }, + { label = "count 1" }, + { entries += scope.bubbleFlow.value["m1"] }, + { label = "count 2" }, + { entries += scope.bubbleFlow.value["m1"] }, + ) + + assertEquals("count 2", rendered.last()) + assertTrue("count 1 も描かれている: $rendered", "count 1" in rendered) + assertSame("中身の変更でエントリを作り直さない", entries[0], entries[1]) + assertSame(entries[1], entries[2]) + assertNull("合成を抜けたら登録も消える", scope.bubbleFlow.value["m1"]) + } + + @Test + fun styledBubbleKeepsEntryWhenOnlyAppearanceChanges() { + val scope = MapViewScope() + val marker = marker() + var color by mutableStateOf(Color.White) + val entries = mutableListOf() + + compose( + { + scope.InfoBubble(marker = marker, bubbleColor = color) {} + val bubbles by scope.bubbleFlow.collectAsState() + bubbles.size // 読むだけ + }, + { entries += scope.bubbleFlow.value["m1"] }, + { color = Color.Yellow }, + { color = Color.Green }, + { entries += scope.bubbleFlow.value["m1"] }, + ) + assertSame("見た目の変更でエントリを作り直さない", entries[0], entries[1]) + } + + @Test + fun iconChangeOnTheSameMarkerReplacesTheEntry() { + val scope = MapViewScope() + val marker = marker() + val oldIcon = marker.icon + val newIcon = StubIcon(64.dp) + val entries = mutableListOf() + + compose( + { scope.InfoBubble(marker = marker) {} }, + { entries += scope.bubbleFlow.value["m1"] }, + { marker.icon = newIcon }, + { entries += scope.bubbleFlow.value["m1"] }, + ) + + assertNotSame("アイコンが変わったら接続位置の計算を変えるため差し替える", entries[0], entries[1]) + assertSame(newIcon, entries[1]?.icon) + assertSame(oldIcon, entries[0]?.icon) + } + + @Test + fun positionBubbleIsReRegisteredOnlyWhenThePositionChanges() { + val scope = MapViewScope() + var position by mutableStateOf(GeoPoint.fromLatLong(35.0, 139.0)) + var label by mutableStateOf("a") + val entries = mutableListOf() + + compose( + { + val text = label + scope.InfoBubble(position = position) { text.length } + }, + { + entries += + scope.bubbleFlow.value.values + .single() + }, + { label = "b" }, + { + entries += + scope.bubbleFlow.value.values + .single() + }, + { position = GeoPoint.fromLatLong(36.0, 140.0) }, + { + entries += + scope.bubbleFlow.value.values + .single() + }, + ) + + assertSame(entries[0], entries[1]) + assertNotSame(entries[1], entries[2]) + assertEquals(36.0, entries[2]!!.positionProvider().latitude, 0.0) + } +}