diff --git a/api/android-for-arcgis.api b/api/android-for-arcgis.api index 60defdf..42cd187 100644 --- a/api/android-for-arcgis.api +++ b/api/android-for-arcgis.api @@ -112,6 +112,20 @@ public final class com.mapconductor.arcgis.ArcGISGeoViewHolder$DefaultImpls { public static java.lang.Object fromScreenOffset-3MmeM6k(com.mapconductor.arcgis.ArcGISGeoViewHolder, long, kotlin.coroutines.Continuation); public static com.mapconductor.core.features.GeoPoint fromScreenOffsetSync-k-4lQ0M(com.mapconductor.arcgis.ArcGISGeoViewHolder, long); } +public final class com.mapconductor.arcgis.ArcGISGraphicsZIndex { + public com.mapconductor.arcgis.ArcGISGraphicsZIndex(); + public final synchronized void place(com.arcgismaps.mapping.view.Graphic, java.lang.Integer); + public final synchronized void forget(com.arcgismaps.mapping.view.Graphic); + public final synchronized int getSize(); +} +final class com.mapconductor.arcgis.ArcGISGraphicsZIndex$Entry { + public final com.arcgismaps.mapping.view.Graphic getGraphic(); + public final int getZIndex(); + public final void setZIndex(int); + public final long getSeq(); + public final int getRank(); + public final void setRank(int); +} public final class com.mapconductor.arcgis.ArcGISMapView2DController extends com.mapconductor.core.controller.BaseMapViewController implements com.mapconductor.arcgis.ArcGISMapViewControllerInterface { public static final com.mapconductor.arcgis.ArcGISMapView2DController$Companion Companion; public com.mapconductor.arcgis.ArcGISMapView2DController(com.mapconductor.arcgis.ArcGISMapView2DHolder, com.mapconductor.arcgis.marker.ArcGISMarkerController, com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayController, com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayController, com.mapconductor.arcgis.circle.ArcGISCircleOverlayController, com.mapconductor.arcgis.groundimage.ArcGISGroundImageController, com.mapconductor.arcgis.raster.ArcGISRasterLayerController, kotlinx.coroutines.CoroutineScope, kotlinx.coroutines.CoroutineScope); @@ -335,6 +349,9 @@ public final class com.mapconductor.arcgis.ArcGISOverlayStacking { public final void add(java.util.List, com.arcgismaps.mapping.layers.Layer, com.mapconductor.core.controller.OverlayStacking$Key); public final void inherit(com.arcgismaps.mapping.layers.Layer, com.arcgismaps.mapping.layers.Layer); } +public final class com.mapconductor.arcgis.ArcGISOverlaySyncKt { + public static final java.lang.Object finishEvenIfCancelled(kotlin.jvm.functions.Function1, ? extends java.lang.Object>, kotlin.coroutines.Continuation); +} public final class com.mapconductor.arcgis.ArcGISTypeAliasKt { } public final class com.mapconductor.arcgis.GeoPointKt { @@ -416,6 +433,7 @@ public final class com.mapconductor.arcgis.circle.ArcGISCircleOverlayController public com.mapconductor.arcgis.circle.ArcGISCircleOverlayController(com.mapconductor.core.circle.CircleManagerInterface, com.mapconductor.arcgis.circle.ArcGISCircleOverlayRenderer); public com.mapconductor.arcgis.circle.ArcGISCircleOverlayController(com.mapconductor.core.circle.CircleManagerInterface, com.mapconductor.arcgis.circle.ArcGISCircleOverlayRenderer, int, kotlin.jvm.internal.DefaultConstructorMarker); public com.mapconductor.arcgis.circle.ArcGISCircleOverlayRenderer getRenderer(); + public java.lang.Object add(java.util.List, kotlin.coroutines.Continuation); public com.mapconductor.core.circle.CircleOverlayRendererInterface getRenderer(); } public final class com.mapconductor.arcgis.circle.ArcGISCircleOverlayRenderer extends com.mapconductor.core.circle.AbstractCircleOverlayRenderer { @@ -432,6 +450,7 @@ public final class com.mapconductor.arcgis.circle.ArcGISCircleOverlayRenderer ex } public final class com.mapconductor.arcgis.groundimage.ArcGISGroundImageController extends com.mapconductor.core.groundimage.GroundImageController { public com.mapconductor.arcgis.groundimage.ArcGISGroundImageController(com.mapconductor.arcgis.groundimage.ArcGISGroundImageOverlayRenderer); + public java.lang.Object add(java.util.List, kotlin.coroutines.Continuation); } public final class com.mapconductor.arcgis.groundimage.ArcGISGroundImageHandle { public com.mapconductor.arcgis.groundimage.ArcGISGroundImageHandle(java.lang.String, long, java.lang.String, com.mapconductor.core.groundimage.GroundImageTileProvider, com.arcgismaps.mapping.layers.WebTiledLayer); @@ -504,7 +523,9 @@ public final class com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayControlle public com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayController(com.mapconductor.core.polygon.PolygonManagerInterface, com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayRenderer); public com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayController(com.mapconductor.core.polygon.PolygonManagerInterface, com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayRenderer, int, kotlin.jvm.internal.DefaultConstructorMarker); public com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayRenderer getRenderer(); + public java.lang.Object add(java.util.List, kotlin.coroutines.Continuation); public com.mapconductor.core.polygon.PolygonOverlayRendererInterface getRenderer(); + public static final java.lang.Object access$add$s-1853025194(com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayController, java.util.List, kotlin.coroutines.Continuation); } public final class com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayRenderer extends com.mapconductor.core.polygon.AbstractPolygonOverlayRenderer { public com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayRenderer(com.arcgismaps.mapping.view.GraphicsOverlay, com.mapconductor.arcgis.ArcGISGeoViewHolder, kotlinx.coroutines.CoroutineScope); @@ -523,7 +544,9 @@ public final class com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayControl public com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayController(com.mapconductor.core.polyline.PolylineManagerInterface, com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayRenderer); public com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayController(com.mapconductor.core.polyline.PolylineManagerInterface, com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayRenderer, int, kotlin.jvm.internal.DefaultConstructorMarker); public com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayRenderer getRenderer(); + public java.lang.Object add(java.util.List, kotlin.coroutines.Continuation); public com.mapconductor.core.polyline.PolylineOverlayRendererInterface getRenderer(); + public static final java.lang.Object access$add$s-802468452(com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayController, java.util.List, kotlin.coroutines.Continuation); } public final class com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayRenderer extends com.mapconductor.core.polyline.AbstractPolylineOverlayRenderer { public com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayRenderer(com.arcgismaps.mapping.view.GraphicsOverlay, com.mapconductor.arcgis.ArcGISGeoViewHolder, kotlinx.coroutines.CoroutineScope); @@ -541,6 +564,7 @@ public final class com.mapconductor.arcgis.raster.ArcGISRasterLayerController ex public com.mapconductor.arcgis.raster.ArcGISRasterLayerController(com.mapconductor.core.raster.RasterLayerManagerInterface, com.mapconductor.arcgis.raster.ArcGISRasterLayerOverlayRenderer); public com.mapconductor.arcgis.raster.ArcGISRasterLayerController(com.mapconductor.core.raster.RasterLayerManagerInterface, com.mapconductor.arcgis.raster.ArcGISRasterLayerOverlayRenderer, int, kotlin.jvm.internal.DefaultConstructorMarker); public com.mapconductor.arcgis.raster.ArcGISRasterLayerOverlayRenderer getRenderer(); + public java.lang.Object add(java.util.List, kotlin.coroutines.Continuation); public final java.lang.Object rebuildLocalLayersIfNeeded(kotlin.coroutines.Continuation); public com.mapconductor.core.raster.RasterLayerOverlayRendererInterface getRenderer(); } diff --git a/src/androidTest/java/com/mapconductor/arcgis/ArcGISGraphicsZIndexTest.kt b/src/androidTest/java/com/mapconductor/arcgis/ArcGISGraphicsZIndexTest.kt new file mode 100644 index 0000000..7ef85e3 --- /dev/null +++ b/src/androidTest/java/com/mapconductor/arcgis/ArcGISGraphicsZIndexTest.kt @@ -0,0 +1,232 @@ +package com.mapconductor.arcgis + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.arcgismaps.ArcGISEnvironment +import com.arcgismaps.mapping.view.Graphic +import com.arcgismaps.mapping.view.GraphicsOverlay +import com.arcgismaps.mapping.view.MapView +import com.mapconductor.arcgis.circle.ArcGISCircleOverlayRenderer +import com.mapconductor.arcgis.marker.ArcGISMarkerRenderer +import com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayRenderer +import com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayRenderer +import com.mapconductor.core.circle.CircleEntity +import com.mapconductor.core.circle.CircleState +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.marker.DefaultMarkerIcon +import com.mapconductor.core.marker.MarkerOverlayRendererInterface +import com.mapconductor.core.marker.MarkerState +import com.mapconductor.core.polygon.PolygonEntity +import com.mapconductor.core.polygon.PolygonState +import com.mapconductor.core.polyline.PolylineEntity +import com.mapconductor.core.polyline.PolylineState +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * 帯の中の重なり順は zIndex で決まり、足した順に左右されないこと。 + * + * ArcGIS は同じ GraphicsOverlay の中を Graphic の `zIndex` の順に描き、同じ値なら足した順に描く + * (3D・2D とも。タブレットで確かめた)。以前は zIndex をどの Graphic にも載せておらず + * (ポリゴンは属性に書いてコレクションを並べ替えていたが、2D はそれでは描く順が変わらない)、 + * zIndex=1 を先に、zIndex=0 を後に足すと zIndex=1 が下になった。 + */ +@RunWith(AndroidJUnit4::class) +class ArcGISGraphicsZIndexTest { + private lateinit var holder: ArcGISMapView2DHolder + + @Before + fun setUp() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + InstrumentationRegistry.getInstrumentation().runOnMainSync { + ArcGISEnvironment.applicationContext = context.applicationContext + val map = MapView(context) + val wrap = WrapMapView(context).also { it.arcGISMapView = map } + holder = ArcGISMapView2DHolder(wrap, map) + } + } + + private fun rect(offset: Double) = + listOf( + GeoPoint(35.0 + offset, 139.0 + offset), + GeoPoint(35.0 + offset, 139.1 + offset), + GeoPoint(35.1 + offset, 139.1 + offset), + GeoPoint(35.1 + offset, 139.0 + offset), + ) + + /** ArcGIS が [upper] を [lower] より上に描くか(zIndex、同じなら足した順)。 */ + private fun drawsAbove( + overlay: GraphicsOverlay, + upper: Graphic, + lower: Graphic, + ): Boolean = + if (upper.zIndex != lower.zIndex) { + upper.zIndex > lower.zIndex + } else { + overlay.graphics.indexOfFirst { it === upper } > overlay.graphics.indexOfFirst { it === lower } + } + + @Test + fun polygonsStackByZIndexWhateverOrderTheyArrive() = + runBlocking { + val overlay = GraphicsOverlay() + val renderer = ArcGISPolygonOverlayRenderer(overlay, holder) + val topState = PolygonState(points = rect(0.0), id = "top", zIndex = 1) + val top = renderer.createPolygon(topState)!! + val bottom = renderer.createPolygon(PolygonState(points = rect(0.05), id = "bottom", zIndex = 0))!! + renderer.onPostProcess() + assertTrue("zIndex=1 added first must draw above zIndex=0", drawsAbove(overlay, top, bottom)) + // 2D はコレクションを並べ替えても描く順が変わらない。描く順を決めるのは Graphic の zIndex。 + assertTrue("the order must live in Graphic.zIndex", top.zIndex > bottom.zIndex) + + // zIndex を下げれば下へ。 + val lowered = PolygonState(points = rect(0.0), id = "top", zIndex = -1) + renderer.updatePolygonProperties(top, PolygonEntity(top, lowered), PolygonEntity(top, topState)) + renderer.onPostProcess() + assertTrue("lowered polygon must draw below", drawsAbove(overlay, bottom, top)) + } + + @Test + fun circlesStackByZIndexWhateverOrderTheyArrive() = + runBlocking { + val overlay = GraphicsOverlay() + val renderer = ArcGISCircleOverlayRenderer(overlay, holder) + val topState = CircleState(center = GeoPoint(35.0, 139.0), radiusMeters = 1000.0, id = "top", zIndex = 1) + val top = renderer.createCircle(topState)!! + val bottom = renderer.createCircle(CircleState(center = GeoPoint(35.0, 139.01), radiusMeters = 1000.0, id = "bottom"))!! + renderer.onPostProcess() + assertTrue("zIndex=1 added first must draw above the default zIndex", drawsAbove(overlay, top, bottom)) + + val lowered = CircleState(center = GeoPoint(35.0, 139.0), radiusMeters = 1000.0, id = "top", zIndex = -1) + renderer.updateCircleProperties(top, CircleEntity(top, lowered), CircleEntity(top, topState)) + renderer.onPostProcess() + assertTrue("lowered circle must draw below", drawsAbove(overlay, bottom, top)) + } + + @Test + fun polylinesStackByZIndexWhateverOrderTheyArrive() = + runBlocking { + val overlay = GraphicsOverlay() + val renderer = ArcGISPolylineOverlayRenderer(overlay, holder) + val topState = PolylineState(points = listOf(GeoPoint(35.0, 139.0), GeoPoint(35.1, 139.1)), id = "top", zIndex = 1) + val top = renderer.createPolyline(topState)!! + val bottom = + renderer.createPolyline( + PolylineState(points = listOf(GeoPoint(35.1, 139.0), GeoPoint(35.0, 139.1)), id = "bottom", zIndex = 0), + )!! + renderer.onPostProcess() + assertTrue("zIndex=1 added first must draw above zIndex=0", drawsAbove(overlay, top, bottom)) + + val lowered = PolylineState(points = listOf(GeoPoint(35.0, 139.0), GeoPoint(35.1, 139.1)), id = "top", zIndex = -1) + renderer.updatePolylineProperties(top, PolylineEntity(top, lowered), PolylineEntity(top, topState)) + renderer.onPostProcess() + assertTrue("lowered polyline must draw below", drawsAbove(overlay, bottom, top)) + } + + @Test + fun markersStackByZIndexWhateverOrderTheyArrive() = + runBlocking { + val overlay = GraphicsOverlay() + val renderer = ArcGISMarkerRenderer(overlay, holder) + val icon = DefaultMarkerIcon().toBitmapIcon() + + fun params(state: MarkerState) = + object : MarkerOverlayRendererInterface.AddParamsInterface { + override val state = state + override val bitmapIcon = icon + } + val added = + renderer.onAdd( + listOf( + params(MarkerState(id = "top", position = GeoPoint(35.0, 139.0), zIndex = 1)), + params(MarkerState(id = "bottom", position = GeoPoint(35.0, 139.0001))), + ), + ) + val top = added[0]!! + val bottom = added[1]!! + renderer.onPostProcess() + assertTrue("zIndex=1 added first must draw above the default zIndex", drawsAbove(overlay, top, bottom)) + } + + /** + * 同じ zIndex なら渡された順(後から渡したものが上)。ArcGIS は同じ Graphic.zIndex の並びを + * 保証しない(2D で、zIndex=0 の 2 枚の間に zIndex=1 が挟まると後の方が下に描かれた)ので、 + * Graphic.zIndex は (zIndex, 渡された順) の順位で、どれも違う値になること。 + */ + @Test + fun equalZIndexKeepsTheOrderTheyArrive() = + runBlocking { + val overlay = GraphicsOverlay() + val renderer = ArcGISPolygonOverlayRenderer(overlay, holder) + val hi = renderer.createPolygon(PolygonState(points = rect(0.0), id = "hi", zIndex = 1))!! + val lo = renderer.createPolygon(PolygonState(points = rect(0.01), id = "lo", zIndex = 0))!! + val first = renderer.createPolygon(PolygonState(points = rect(0.02), id = "a", zIndex = 0))!! + val middle = renderer.createPolygon(PolygonState(points = rect(0.03), id = "m", zIndex = 1))!! + val second = renderer.createPolygon(PolygonState(points = rect(0.04), id = "b", zIndex = 0))!! + renderer.onPostProcess() + val drawn = listOf(lo, first, second, hi, middle) + val zs = drawn.map { it.zIndex } + assertEquals("Graphic.zIndex must be distinct: $zs", zs.size, zs.toSet().size) + assertEquals("bottom to top must be lo, a, b, hi, m: $zs", zs.sorted(), zs) + } + + /** マーカーも同じ(zIndex が全部同じでも、渡された順に違う値)。 */ + @Test + fun equalZIndexMarkersKeepTheOrderTheyArrive() = + runBlocking { + val overlay = GraphicsOverlay() + val renderer = ArcGISMarkerRenderer(overlay, holder) + val icon = DefaultMarkerIcon().toBitmapIcon() + + fun params(state: MarkerState) = + object : MarkerOverlayRendererInterface.AddParamsInterface { + override val state = state + override val bitmapIcon = icon + } + val added = + renderer.onAdd( + listOf("a", "b", "c").mapIndexed { i, id -> + params(MarkerState(id = id, position = GeoPoint(35.0, 139.0 + i * 0.0001))) + }, + ) + val zs = added.map { it!!.zIndex } + assertEquals("Graphic.zIndex must be distinct: $zs", 3, zs.toSet().size) + assertEquals("later markers must draw above: $zs", zs.sorted(), zs) + } + + /** zIndex だけ変えたとき、同じ zIndex の中では最初に渡された順を保つ。外して足し直すと一番上。 */ + @Test + fun zIndexChangeAndReAddKeepTheRules() = + runBlocking { + val overlay = GraphicsOverlay() + val renderer = ArcGISCircleOverlayRenderer(overlay, holder) + + fun state( + id: String, + z: Int, + ) = CircleState(center = GeoPoint(35.0, 139.0), radiusMeters = 1000.0, id = id, zIndex = z) + val aState = state("a", 0) + val a = renderer.createCircle(aState)!! + val b = renderer.createCircle(state("b", 0))!! + val cState = state("c", 5) + val c = renderer.createCircle(cState)!! + // c を 0 へ: a, b, c の順(c は最後に渡された)。 + renderer.updateCircleProperties(c, CircleEntity(c, state("c", 0)), CircleEntity(c, cState)) + assertTrue(a.zIndex < b.zIndex && b.zIndex < c.zIndex) + // a を 0 → 2 → 0 と動かしても、0 の中では最初の順(一番下)に戻る。 + renderer.updateCircleProperties(a, CircleEntity(a, state("a", 2)), CircleEntity(a, aState)) + assertTrue(a.zIndex > c.zIndex) + renderer.updateCircleProperties(a, CircleEntity(a, aState), CircleEntity(a, state("a", 2))) + assertTrue(a.zIndex < b.zIndex) + // b を外して足し直すと、zIndex=0 の中で一番上。 + renderer.removeCircle(CircleEntity(b, state("b", 0))) + val b2 = renderer.createCircle(state("b", 0))!! + assertTrue(c.zIndex < b2.zIndex) + val zs = listOf(a, c, b2).map { it.zIndex } + assertEquals(zs.toSet().size, 3) + } +} diff --git a/src/androidTest/java/com/mapconductor/arcgis/ArcGISGroundImageLayerThreadTest.kt b/src/androidTest/java/com/mapconductor/arcgis/ArcGISGroundImageLayerThreadTest.kt new file mode 100644 index 0000000..0ec01dc --- /dev/null +++ b/src/androidTest/java/com/mapconductor/arcgis/ArcGISGroundImageLayerThreadTest.kt @@ -0,0 +1,121 @@ +package com.mapconductor.arcgis + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.arcgismaps.ArcGISEnvironment +import com.arcgismaps.mapping.layers.Layer +import com.arcgismaps.mapping.view.MapView +import com.mapconductor.arcgis.groundimage.ArcGISGroundImageOverlayRenderer +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoRectBounds +import com.mapconductor.core.groundimage.GroundImageEntity +import com.mapconductor.core.groundimage.GroundImageState +import com.mapconductor.core.tileserver.TileServerRegistry +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.os.Looper +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Collections + +/** + * GroundImage は operational layer の並びをメインスレッドで足し外しすること。 + * + * ラスターのレンダラ(とマーカーのタイル)は同じ並びをメインスレッドで触る。GroundImage だけが + * Default のまま触っていて、GroundImage とラスターを同時に外すと(ページを閉じる・ + * clearOverlays・両方の state を同時に外す)並びの反復と削除がぶつかり、 + * `ConcurrentModificationException` で落ちた(タブレットの 2D で再現)。 + */ +@RunWith(AndroidJUnit4::class) +class ArcGISGroundImageLayerThreadTest { + private lateinit var real: ArcGISMapView2DHolder + + /** 足し外しが走ったスレッドを記録する並び。 */ + private class ThreadRecordingList : java.util.AbstractList() { + val items = ArrayList() + val mutationsOffMain: MutableList = Collections.synchronizedList(ArrayList()) + var mutations = 0 + + private fun record(what: String) { + mutations++ + if (Looper.myLooper() != Looper.getMainLooper()) { + mutationsOffMain.add("$what on ${Thread.currentThread().name}") + } + } + + override val size: Int get() = items.size + + override fun get(index: Int): Layer = items[index] + + override fun add( + index: Int, + element: Layer, + ) { + record("add") + items.add(index, element) + } + + override fun removeAt(index: Int): Layer { + record("remove") + return items.removeAt(index) + } + + override fun set( + index: Int, + element: Layer, + ): Layer { + record("set") + return items.set(index, element) + } + } + + private class RecordingHolder( + real: ArcGISMapView2DHolder, + override val operationalLayers: MutableList, + ) : ArcGISGeoViewHolder by real + + @Before + fun setUp() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + InstrumentationRegistry.getInstrumentation().runOnMainSync { + ArcGISEnvironment.applicationContext = context.applicationContext + val map = MapView(context) + val wrap = WrapMapView(context).also { it.arcGISMapView = map } + real = ArcGISMapView2DHolder(wrap, map) + } + } + + @Test + fun groundImageTouchesTheLayerListOnTheMainThread() = + runBlocking { + val layers = ThreadRecordingList() + val renderer = + ArcGISGroundImageOverlayRenderer( + holder = RecordingHolder(real, layers), + tileServer = TileServerRegistry.get(), + ) + val state = + GroundImageState( + id = "gi-thread", + bounds = GeoRectBounds(GeoPoint(35.0, 139.0), GeoPoint(35.1, 139.1)), + image = BitmapDrawable(null, Bitmap.createBitmap(16, 16, Bitmap.Config.ARGB_8888)), + ) + val handle = renderer.createGroundImage(state)!! + assertEquals(1, layers.size) + + renderer.removeGroundImage(GroundImageEntity(handle, state)) + // 削除は renderer 自身のスコープで走る。 + repeat(50) { if (layers.isNotEmpty()) delay(20) } + assertEquals(0, layers.size) + assertTrue("add and remove must both have happened", layers.mutations >= 2) + assertTrue( + "operational layers must be changed on the main thread: ${layers.mutationsOffMain}", + layers.mutationsOffMain.isEmpty(), + ) + } +} diff --git a/src/androidTest/java/com/mapconductor/arcgis/ArcGISOverlaySyncCancellationTest.kt b/src/androidTest/java/com/mapconductor/arcgis/ArcGISOverlaySyncCancellationTest.kt new file mode 100644 index 0000000..d6848b0 --- /dev/null +++ b/src/androidTest/java/com/mapconductor/arcgis/ArcGISOverlaySyncCancellationTest.kt @@ -0,0 +1,144 @@ +package com.mapconductor.arcgis + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.arcgismaps.ArcGISEnvironment +import com.arcgismaps.mapping.view.GraphicsOverlay +import com.arcgismaps.mapping.view.MapView +import com.mapconductor.arcgis.circle.ArcGISCircleOverlayController +import com.mapconductor.arcgis.circle.ArcGISCircleOverlayRenderer +import com.mapconductor.arcgis.marker.ArcGISMarkerController +import com.mapconductor.arcgis.marker.ArcGISMarkerRenderer +import com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayController +import com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayRenderer +import com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayController +import com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayRenderer +import com.mapconductor.core.circle.CircleState +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.marker.MarkerManager +import com.mapconductor.core.marker.MarkerState +import com.mapconductor.core.marker.MarkerTilingOptions +import com.mapconductor.core.polygon.PolygonState +import com.mapconductor.core.polyline.PolylineState +import android.os.Handler +import android.os.Looper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * 同期(compose の LaunchedEffect から来る add)が途中で取り消されても、地図に載せた Graphic を + * 見失わないこと。 + * + * compose は overlay の state が変わるたびに前の LaunchedEffect を取り消す。レンダラは Graphic を + * メインスレッドで載せる(`withContext(Dispatchers.Main)`)が、呼び出し側は compose の + * ディスパッチャにいるので、載せ終わって戻る前に取り消されると withContext は結果を捨てる。 + * Graphic は地図に載ったまま entity に登録されず、次の同期が同じ id をもう一度載せるので + * 2 つになる。残った方はどこからも外されない(clearOverlays でも消えない)。 + * タブレットの 2D で、起動直後にマーカー・ポリゴン・ポリラインが同じ id で 2 つずつ入った。 + */ +@RunWith(AndroidJUnit4::class) +class ArcGISOverlaySyncCancellationTest { + private lateinit var holder: ArcGISMapView2DHolder + private val main = Handler(Looper.getMainLooper()) + + @Before + fun setUp() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + InstrumentationRegistry.getInstrumentation().runOnMainSync { + ArcGISEnvironment.applicationContext = context.applicationContext + val map = MapView(context) + val wrap = WrapMapView(context).also { it.arcGISMapView = map } + holder = ArcGISMapView2DHolder(wrap, map) + } + } + + /** + * メインスレッドを止めている間に、別のディスパッチャから [sync] を始めて取り消す。 + * レンダラの載せる処理はメインスレッドが空いてから走り、呼び出し側は取り消し済みで戻る。 + */ + private fun cancelWhileTheMainThreadIsBusy(sync: suspend () -> Unit) { + val hold = CountDownLatch(1) + val held = CountDownLatch(1) + main.post { + held.countDown() + hold.await(5, TimeUnit.SECONDS) + } + held.await(5, TimeUnit.SECONDS) + val job = CoroutineScope(Dispatchers.Default).launch { sync() } + Thread.sleep(200) + job.cancel() + hold.countDown() + runBlocking { job.join() } + // 取り消された呼び出しの後片付け(レンダラ自身のスコープで走るもの)を待つ。 + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + runBlocking { delay(200) } + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + } + + private fun count(overlay: GraphicsOverlay): Int { + var n = 0 + InstrumentationRegistry.getInstrumentation().runOnMainSync { n = overlay.graphics.size } + return n + } + + private val square = + listOf(GeoPoint(35.0, 139.0), GeoPoint(35.0, 139.1), GeoPoint(35.1, 139.1), GeoPoint(35.1, 139.0)) + + @Test + fun aCancelledPolygonSyncDoesNotOrphanItsGraphic() { + val overlay = GraphicsOverlay() + val controller = ArcGISPolygonOverlayController(renderer = ArcGISPolygonOverlayRenderer(overlay, holder)) + val state = PolygonState(points = square, id = "p") + cancelWhileTheMainThreadIsBusy { controller.add(listOf(state)) } + // 次の同期(compose が新しい state で走らせるもの)。 + runBlocking { controller.add(listOf(state)) } + assertEquals("one graphic per polygon id", 1, count(overlay)) + runBlocking { controller.clear() } + runBlocking { delay(200) } + assertEquals("clear must take every graphic away", 0, count(overlay)) + } + + @Test + fun aCancelledPolylineSyncDoesNotOrphanItsGraphic() { + val overlay = GraphicsOverlay() + val controller = ArcGISPolylineOverlayController(renderer = ArcGISPolylineOverlayRenderer(overlay, holder)) + val state = PolylineState(points = listOf(GeoPoint(35.0, 139.0), GeoPoint(35.1, 139.1)), id = "l") + cancelWhileTheMainThreadIsBusy { controller.add(listOf(state)) } + runBlocking { controller.add(listOf(state)) } + assertEquals("one graphic per polyline id", 1, count(overlay)) + } + + @Test + fun aCancelledCircleSyncDoesNotOrphanItsGraphic() { + val overlay = GraphicsOverlay() + val controller = ArcGISCircleOverlayController(renderer = ArcGISCircleOverlayRenderer(overlay, holder)) + val state = CircleState(center = GeoPoint(35.0, 139.0), radiusMeters = 500.0, id = "c") + cancelWhileTheMainThreadIsBusy { controller.add(listOf(state)) } + runBlocking { controller.add(listOf(state)) } + assertEquals("one graphic per circle id", 1, count(overlay)) + } + + @Test + fun aCancelledMarkerSyncDoesNotOrphanItsGraphic() { + val overlay = GraphicsOverlay() + val controller = + ArcGISMarkerController( + markerManager = MarkerManager.defaultManager(), + renderer = ArcGISMarkerRenderer(overlay, holder), + markerTiling = MarkerTilingOptions.Default, + ) + val state = MarkerState(id = "m", position = GeoPoint(35.0, 139.0)) + cancelWhileTheMainThreadIsBusy { controller.add(listOf(state)) } + runBlocking { controller.add(listOf(state)) } + assertEquals("one graphic per marker id", 1, count(overlay)) + } +} diff --git a/src/main/java/com/mapconductor/arcgis/ArcGISGraphicsZIndex.kt b/src/main/java/com/mapconductor/arcgis/ArcGISGraphicsZIndex.kt new file mode 100644 index 0000000..991b8d4 --- /dev/null +++ b/src/main/java/com/mapconductor/arcgis/ArcGISGraphicsZIndex.kt @@ -0,0 +1,106 @@ +package com.mapconductor.arcgis + +import com.arcgismaps.mapping.view.Graphic +import java.util.IdentityHashMap + +/** + * 帯の中の重なり順。ポリゴン・サークル・ポリライン・マーカーの GraphicsOverlay ごとに 1 つ持つ。 + * + * 帯(ポリゴン < サークル < ポリライン < マーカー)は GraphicsOverlay 自体の並びで決まる + * ([ArcGISOverlayStacking])。帯の**中**は (state の zIndex, 渡された順) で決め、その順位を + * 各 Graphic の `zIndex` プロパティに書く。 + * + * state の zIndex をそのまま書かないのは、ArcGIS が同じ `zIndex` の Graphic を足した順に + * 描くとは限らないから(API リファレンスも「usually」「in rare cases ... may change the display + * order」とし、順序が要るなら zIndex を明示せよとしている)。タブレットの 2D で、zIndex=0 の + * ポリゴン 2 枚の間に zIndex=1 のものが挟まると、後から足した方が下に描かれた。マーカーは + * zIndex が全部 0 でも足した順にならなかった。順位はどれも違う値なので、同値の扱いに頼らない。 + * + * 順位は狭義単調増加であればよく、連番である必要はない。外したときは詰めない。足すときは + * 前後の間に空きがあればそこへ、無ければ後ろの順位を空きが出るところまで 1 ずつずらす。 + * いちばん上に足す(よくある場合)は 1 つ書くだけ。 + * + * メインスレッドから使う(各レンダラは Graphic をメインスレッドで触る)。念のため同期する。 + */ +internal class ArcGISGraphicsZIndex { + private class Entry( + val graphic: Graphic, + var zIndex: Int, + val seq: Long, + var rank: Int, + ) + + private val entries = ArrayList() + private val byGraphic = IdentityHashMap() + private var nextSeq = 0L + + /** + * [graphic] を [zIndex](`null` は 0)の位置に置く。初めての Graphic は同じ zIndex の中で + * 一番上(渡された順)。既に置いた Graphic の zIndex が変わったときは、最初に渡された順を + * 保ったまま移す。 + */ + @Synchronized + fun place( + graphic: Graphic, + zIndex: Int?, + ) { + val z = zIndex ?: 0 + val existing = byGraphic[graphic] + val entry = + if (existing != null) { + if (existing.zIndex == z) return + entries.removeAt(indexOf(existing)) + existing.zIndex = z + existing + } else { + Entry(graphic, z, nextSeq++, 0).also { byGraphic[graphic] = it } + } + val at = insertionPoint(entry) + entries.add(at, entry) + assignRanksFrom(at) + } + + /** [graphic] を外す。順位は詰めない(残りの並びはそのまま正しい)。 */ + @Synchronized + fun forget(graphic: Graphic) { + val entry = byGraphic.remove(graphic) ?: return + entries.removeAt(indexOf(entry)) + } + + /** 置いている Graphic の数(テスト用)。 */ + @get:Synchronized + val size: Int get() = entries.size + + private fun compare( + a: Entry, + b: Entry, + ): Int = if (a.zIndex != b.zIndex) a.zIndex.compareTo(b.zIndex) else a.seq.compareTo(b.seq) + + private fun insertionPoint(entry: Entry): Int { + var lo = 0 + var hi = entries.size + while (lo < hi) { + val mid = (lo + hi) ushr 1 + if (compare(entries[mid], entry) < 0) lo = mid + 1 else hi = mid + } + return lo + } + + private fun indexOf(entry: Entry): Int { + val i = insertionPoint(entry) + check(i < entries.size && entries[i] === entry) { "stack order lost track of a graphic" } + return i + } + + /** [from] の順位を前の 1 つ上にし、後ろが狭義単調増加に戻るところまでずらす。 */ + private fun assignRanksFrom(from: Int) { + var previous = if (from > 0) entries[from - 1].rank else -1 + for (i in from until entries.size) { + val entry = entries[i] + if (i > from && entry.rank > previous) break + entry.rank = previous + 1 + if (entry.graphic.zIndex != entry.rank) entry.graphic.zIndex = entry.rank + previous = entry.rank + } + } +} diff --git a/src/main/java/com/mapconductor/arcgis/ArcGISOverlaySync.kt b/src/main/java/com/mapconductor/arcgis/ArcGISOverlaySync.kt new file mode 100644 index 0000000..caf69f1 --- /dev/null +++ b/src/main/java/com/mapconductor/arcgis/ArcGISOverlaySync.kt @@ -0,0 +1,19 @@ +package com.mapconductor.arcgis + +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext + +/** + * 同期(`add`)を、始めたら最後まで終える。 + * + * compose は overlay の state が変わるたびに前の同期(LaunchedEffect)を取り消す。レンダラは + * Graphic や層を自分のスコープ(メインスレッド・Default)で地図に載せ、呼び出し側は compose の + * ディスパッチャで待っている。載せ終わって戻る前に取り消されると、`withContext` は結果を捨てて + * CancellationException を投げるので、地図に載ったものが entity に登録されない。次の同期が同じ + * id をもう一度載せて 2 つになり、残った方はどこからも外されない(clearOverlays でも消えない)。 + * タブレットの 2D で、起動直後にマーカー・ポリゴン・ポリラインが同じ id で 2 つずつ入った。 + * + * 取り消された同期も最後まで走らせ、載せたものを必ず登録する。取り消しの理由だった新しい + * state は、次の同期(semaphore の後ろで待っている)がそのまま反映する。 + */ +internal suspend fun finishEvenIfCancelled(block: suspend () -> T): T = withContext(NonCancellable) { block() } diff --git a/src/main/java/com/mapconductor/arcgis/circle/ArcGISCircleOverlayController.kt b/src/main/java/com/mapconductor/arcgis/circle/ArcGISCircleOverlayController.kt index 8446b26..29ab7b6 100644 --- a/src/main/java/com/mapconductor/arcgis/circle/ArcGISCircleOverlayController.kt +++ b/src/main/java/com/mapconductor/arcgis/circle/ArcGISCircleOverlayController.kt @@ -1,11 +1,16 @@ package com.mapconductor.arcgis.circle import com.mapconductor.arcgis.ArcGISActualCircle +import com.mapconductor.arcgis.finishEvenIfCancelled import com.mapconductor.core.circle.CircleController import com.mapconductor.core.circle.CircleManager import com.mapconductor.core.circle.CircleManagerInterface +import com.mapconductor.core.circle.CircleState class ArcGISCircleOverlayController( circleManager: CircleManagerInterface = CircleManager(), override val renderer: ArcGISCircleOverlayRenderer, -) : CircleController(circleManager, renderer) +) : CircleController(circleManager, renderer) { + /** 取り消されても最後まで([finishEvenIfCancelled])。 */ + override suspend fun add(data: List) = finishEvenIfCancelled { super.add(data) } +} diff --git a/src/main/java/com/mapconductor/arcgis/circle/ArcGISCircleOverlayRenderer.kt b/src/main/java/com/mapconductor/arcgis/circle/ArcGISCircleOverlayRenderer.kt index 9f37704..39e6b01 100644 --- a/src/main/java/com/mapconductor/arcgis/circle/ArcGISCircleOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/arcgis/circle/ArcGISCircleOverlayRenderer.kt @@ -14,6 +14,7 @@ import com.arcgismaps.mapping.view.Graphic import com.arcgismaps.mapping.view.GraphicsOverlay import com.mapconductor.arcgis.ArcGISActualCircle import com.mapconductor.arcgis.ArcGISGeoViewHolder +import com.mapconductor.arcgis.ArcGISGraphicsZIndex import com.mapconductor.arcgis.toArcGISColor import com.mapconductor.arcgis.toPoint import com.mapconductor.core.circle.AbstractCircleOverlayRenderer @@ -30,6 +31,8 @@ class ArcGISCircleOverlayRenderer( override val holder: ArcGISGeoViewHolder<*, *>, override val coroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), ) : AbstractCircleOverlayRenderer() { + private val stackOrder = ArcGISGraphicsZIndex() + override suspend fun createCircle(state: CircleState): ArcGISActualCircle? = withContext(coroutine.coroutineContext) { val centerPoint = centerPoint(state.center, state.geodesic) @@ -62,6 +65,7 @@ class ArcGISCircleOverlayRenderer( outline = stroke, ) val circle = Graphic(circleGeometry, fillSymbol) + stackOrder.place(circle, state.zIndex) circleLayer.graphics.add(circle) circle @@ -71,6 +75,7 @@ class ArcGISCircleOverlayRenderer( coroutine.launch { val circles = listOf(entity.circle) circleLayer.graphics.removeAll(circles) + stackOrder.forget(entity.circle) } } @@ -124,6 +129,9 @@ class ArcGISCircleOverlayRenderer( } } } + if (finger.zIndex != prevFinger.zIndex) { + stackOrder.place(graphic, current.state.zIndex) + } return@withContext graphic } diff --git a/src/main/java/com/mapconductor/arcgis/groundimage/ArcGISGroundImageController.kt b/src/main/java/com/mapconductor/arcgis/groundimage/ArcGISGroundImageController.kt index 5d01f14..6a78734 100644 --- a/src/main/java/com/mapconductor/arcgis/groundimage/ArcGISGroundImageController.kt +++ b/src/main/java/com/mapconductor/arcgis/groundimage/ArcGISGroundImageController.kt @@ -1,11 +1,16 @@ package com.mapconductor.arcgis.groundimage +import com.mapconductor.arcgis.finishEvenIfCancelled import com.mapconductor.core.groundimage.GroundImageController import com.mapconductor.core.groundimage.GroundImageManager +import com.mapconductor.core.groundimage.GroundImageState class ArcGISGroundImageController( renderer: ArcGISGroundImageOverlayRenderer, ) : GroundImageController( groundImageManager = GroundImageManager(), renderer = renderer, - ) + ) { + /** 取り消されても最後まで([finishEvenIfCancelled])。 */ + override suspend fun add(data: List) = finishEvenIfCancelled { super.add(data) } +} diff --git a/src/main/java/com/mapconductor/arcgis/groundimage/ArcGISGroundImageOverlayRenderer.kt b/src/main/java/com/mapconductor/arcgis/groundimage/ArcGISGroundImageOverlayRenderer.kt index e35e8d9..a327987 100644 --- a/src/main/java/com/mapconductor/arcgis/groundimage/ArcGISGroundImageOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/arcgis/groundimage/ArcGISGroundImageOverlayRenderer.kt @@ -28,6 +28,14 @@ class ArcGISGroundImageOverlayRenderer( private val tileServer: LocalTileServer, override val coroutine: CoroutineScope = CoroutineScope(Dispatchers.Default), ) : AbstractGroundImageOverlayRenderer() { + /** + * operational layer の並び(`operationalLayers`)はスレッドセーフでない。ラスターの + * レンダラ・マーカーのタイルはメインスレッドで触るので、ここも足す・外すはメインスレッドで行う + * (以前は [coroutine](Default)のまま触っていて、GroundImage とラスターを同時に外すと + * `ConcurrentModificationException` で落ちた)。タイルの用意と層の load は Default のまま。 + */ + private suspend fun onLayerList(block: () -> T): T = withContext(Dispatchers.Main.immediate) { block() } + override suspend fun createGroundImage(state: GroundImageState): ArcGISGroundImageHandle? = withContext(coroutine.coroutineContext) { val operationalLayers = holder.operationalLayers ?: return@withContext null @@ -54,7 +62,9 @@ class ArcGISGroundImageOverlayRenderer( } updateLayer(handle.layer, state) // GroundImage の帯(ラスターの上)。GraphicsOverlay(ポリゴン等)は常にこの上。 - ArcGISOverlayStacking.add(operationalLayers, handle.layer, ArcGISOverlayStacking.key(OverlayKind.GroundImage)) + onLayerList { + ArcGISOverlayStacking.add(operationalLayers, handle.layer, ArcGISOverlayStacking.key(OverlayKind.GroundImage)) + } handle } @@ -112,12 +122,14 @@ class ArcGISGroundImageOverlayRenderer( } // Swap layers to ensure cache busting via generation query. - operationalLayers.remove(groundImage.layer) - ArcGISOverlayStacking.add( - operationalLayers, - nextHandle.layer, - ArcGISOverlayStacking.key(OverlayKind.GroundImage), - ) + onLayerList { + operationalLayers.remove(groundImage.layer) + ArcGISOverlayStacking.add( + operationalLayers, + nextHandle.layer, + ArcGISOverlayStacking.key(OverlayKind.GroundImage), + ) + } updateLayer(nextHandle.layer, current.state) nextHandle } @@ -125,7 +137,7 @@ class ArcGISGroundImageOverlayRenderer( override suspend fun removeGroundImage(entity: GroundImageEntityInterface) { coroutine.launch { val handle = entity.groundImage - holder.operationalLayers?.remove(handle.layer) + onLayerList { holder.operationalLayers?.remove(handle.layer) } tileServer.unregister(handle.routeId) } } diff --git a/src/main/java/com/mapconductor/arcgis/marker/ArcGISMarkerController.kt b/src/main/java/com/mapconductor/arcgis/marker/ArcGISMarkerController.kt index 9d31377..2e2f68b 100644 --- a/src/main/java/com/mapconductor/arcgis/marker/ArcGISMarkerController.kt +++ b/src/main/java/com/mapconductor/arcgis/marker/ArcGISMarkerController.kt @@ -1,6 +1,7 @@ package com.mapconductor.arcgis.marker import com.mapconductor.arcgis.ArcGISActualMarker +import com.mapconductor.arcgis.finishEvenIfCancelled import com.mapconductor.core.controller.OnCameraChangeReceiverInterface import com.mapconductor.core.map.MapCameraPosition import com.mapconductor.core.marker.AbstractMarkerController @@ -89,7 +90,10 @@ class ArcGISMarkerController( rasterLayerCallback = callback } - override suspend fun add(data: List) { + /** 取り消されても最後まで([finishEvenIfCancelled])。 */ + override suspend fun add(data: List) = finishEvenIfCancelled { addNow(data) } + + private suspend fun addNow(data: List) { // ingest はタイル担当 entity を marker = null で登録し直すので、先に昇格を戻す。 // semaphore は再入不可なので withPermit の外で呼ぶこと。 viewportSwitch.retract() diff --git a/src/main/java/com/mapconductor/arcgis/marker/ArcGISMarkerRenderer.kt b/src/main/java/com/mapconductor/arcgis/marker/ArcGISMarkerRenderer.kt index d86560a..6e242e3 100644 --- a/src/main/java/com/mapconductor/arcgis/marker/ArcGISMarkerRenderer.kt +++ b/src/main/java/com/mapconductor/arcgis/marker/ArcGISMarkerRenderer.kt @@ -9,6 +9,7 @@ import com.arcgismaps.mapping.view.Graphic import com.arcgismaps.mapping.view.GraphicsOverlay import com.mapconductor.arcgis.ArcGISActualMarker import com.mapconductor.arcgis.ArcGISGeoViewHolder +import com.mapconductor.arcgis.ArcGISGraphicsZIndex import com.mapconductor.arcgis.WrapMapView import com.mapconductor.arcgis.toPoint import com.mapconductor.core.ResourceProvider @@ -34,6 +35,8 @@ class ArcGISMarkerRenderer( ) { override val supportsAnimationOverlay: Boolean = true + private val stackOrder = ArcGISGraphicsZIndex() + /** * マーカーのアイコンを縦へ引き伸ばす倍率。 * @@ -129,6 +132,7 @@ class ArcGISMarkerRenderer( it.attributes[BASE_HEIGHT_KEY] = height it.attributes[BASE_OFFSET_Y_KEY] = anchorY.toFloat() } + stackOrder.place(marker, params.state.zIndex) return@map marker }.also { markerLayer.graphics.addAll(it) @@ -141,6 +145,7 @@ class ArcGISMarkerRenderer( coroutine.launch { val elements = data.map { params -> params.marker } markerLayer.graphics.removeAll(elements.toSet()) + elements.forEach { it?.let(stackOrder::forget) } } } @@ -206,6 +211,7 @@ class ArcGISMarkerRenderer( marker.geometry = GeoPoint.from(params.current.state.position).toViewPoint() // Always set visibility explicitly like Google Maps (remove conditional check) marker.isVisible = params.current.visible + stackOrder.place(marker, params.current.state.zIndex) // ArcGISはマーカーを再作成しなくてよいので、同じマーカーのインスタンスを返す marker diff --git a/src/main/java/com/mapconductor/arcgis/polygon/ArcGISPolygonOverlayController.kt b/src/main/java/com/mapconductor/arcgis/polygon/ArcGISPolygonOverlayController.kt index eb3a5da..5bc6ab4 100644 --- a/src/main/java/com/mapconductor/arcgis/polygon/ArcGISPolygonOverlayController.kt +++ b/src/main/java/com/mapconductor/arcgis/polygon/ArcGISPolygonOverlayController.kt @@ -1,11 +1,16 @@ package com.mapconductor.arcgis.polygon import com.mapconductor.arcgis.ArcGISActualPolygon +import com.mapconductor.arcgis.finishEvenIfCancelled import com.mapconductor.core.polygon.PolygonController import com.mapconductor.core.polygon.PolygonManager import com.mapconductor.core.polygon.PolygonManagerInterface +import com.mapconductor.core.polygon.PolygonState class ArcGISPolygonOverlayController( polygonManager: PolygonManagerInterface = PolygonManager(), override val renderer: ArcGISPolygonOverlayRenderer, -) : PolygonController(polygonManager, renderer) +) : PolygonController(polygonManager, renderer) { + /** 取り消されても最後まで([finishEvenIfCancelled])。 */ + override suspend fun add(data: List) = finishEvenIfCancelled { super.add(data) } +} diff --git a/src/main/java/com/mapconductor/arcgis/polygon/ArcGISPolygonOverlayRenderer.kt b/src/main/java/com/mapconductor/arcgis/polygon/ArcGISPolygonOverlayRenderer.kt index 15c6a0b..4f7afe7 100644 --- a/src/main/java/com/mapconductor/arcgis/polygon/ArcGISPolygonOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/arcgis/polygon/ArcGISPolygonOverlayRenderer.kt @@ -12,6 +12,7 @@ import com.arcgismaps.mapping.view.Graphic import com.arcgismaps.mapping.view.GraphicsOverlay import com.mapconductor.arcgis.ArcGISActualPolygon import com.mapconductor.arcgis.ArcGISGeoViewHolder +import com.mapconductor.arcgis.ArcGISGraphicsZIndex import com.mapconductor.arcgis.toArcGISColor import com.mapconductor.core.features.GeoPointInterface import com.mapconductor.core.geometry.closeRing @@ -31,6 +32,8 @@ class ArcGISPolygonOverlayRenderer( override val holder: ArcGISGeoViewHolder<*, *>, override val coroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), ) : AbstractPolygonOverlayRenderer() { + private val stackOrder = ArcGISGraphicsZIndex() + override suspend fun createPolygon(state: PolygonState): ArcGISActualPolygon? = withContext(coroutine.coroutineContext) { val resolved = resolveHoles(state) @@ -52,9 +55,9 @@ class ArcGISPolygonOverlayRenderer( val graphic = Graphic(geometry, fillSymbol).also { it.attributes.set("id", state.id) - it.attributes.set("zIndex", state.zIndex) } + stackOrder.place(graphic, state.zIndex) polygonLayer.graphics.add(graphic) graphic } @@ -90,7 +93,7 @@ class ArcGISPolygonOverlayRenderer( } } if (finger.zIndex != prevFinger.zIndex) { - current.polygon.attributes.set("zIndex", current.state.zIndex) + stackOrder.place(current.polygon, current.state.zIndex) } polygon } @@ -101,24 +104,12 @@ class ArcGISPolygonOverlayRenderer( // of rebuilding the whole graphics list. coroutine.launch { polygonLayer.graphics.remove(entity.polygon) + stackOrder.forget(entity.polygon) } } override suspend fun onPostProcess() { - // Sort graphics by zIndex to ensure correct rendering order - withContext(coroutine.coroutineContext) { - val graphics = polygonLayer.graphics.toList() - if (graphics.size <= 1) return@withContext - - val sortedGraphics = - graphics.sortedBy { graphic -> - (graphic.attributes.get("zIndex") as? Int) ?: 0 - } - if (graphics == sortedGraphics) return@withContext - - polygonLayer.graphics.clear() - polygonLayer.graphics.addAll(sortedGraphics) - } + // 重なり順は各 Graphic の zIndex が持つ(ArcGISGraphicsZIndex)。並べ替えは要らない。 } private suspend fun resolveHoles(state: PolygonState): PolygonState = diff --git a/src/main/java/com/mapconductor/arcgis/polyline/ArcGISPolylineOverlayController.kt b/src/main/java/com/mapconductor/arcgis/polyline/ArcGISPolylineOverlayController.kt index 83d09bd..cea11c3 100644 --- a/src/main/java/com/mapconductor/arcgis/polyline/ArcGISPolylineOverlayController.kt +++ b/src/main/java/com/mapconductor/arcgis/polyline/ArcGISPolylineOverlayController.kt @@ -1,11 +1,16 @@ package com.mapconductor.arcgis.polyline import com.mapconductor.arcgis.ArcGISActualPolyline +import com.mapconductor.arcgis.finishEvenIfCancelled import com.mapconductor.core.polyline.PolylineController import com.mapconductor.core.polyline.PolylineManager import com.mapconductor.core.polyline.PolylineManagerInterface +import com.mapconductor.core.polyline.PolylineState class ArcGISPolylineOverlayController( polylineManager: PolylineManagerInterface = PolylineManager(), override val renderer: ArcGISPolylineOverlayRenderer, -) : PolylineController(polylineManager, renderer) +) : PolylineController(polylineManager, renderer) { + /** 取り消されても最後まで([finishEvenIfCancelled])。 */ + override suspend fun add(data: List) = finishEvenIfCancelled { super.add(data) } +} diff --git a/src/main/java/com/mapconductor/arcgis/polyline/ArcGISPolylineOverlayRenderer.kt b/src/main/java/com/mapconductor/arcgis/polyline/ArcGISPolylineOverlayRenderer.kt index 1451116..6adff91 100644 --- a/src/main/java/com/mapconductor/arcgis/polyline/ArcGISPolylineOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/arcgis/polyline/ArcGISPolylineOverlayRenderer.kt @@ -9,6 +9,7 @@ import com.arcgismaps.mapping.view.Graphic import com.arcgismaps.mapping.view.GraphicsOverlay import com.mapconductor.arcgis.ArcGISActualPolyline import com.mapconductor.arcgis.ArcGISGeoViewHolder +import com.mapconductor.arcgis.ArcGISGraphicsZIndex import com.mapconductor.arcgis.toArcGISColor import com.mapconductor.arcgis.toPoint import com.mapconductor.core.features.GeoPoint @@ -25,11 +26,17 @@ import kotlinx.coroutines.withContext class ArcGISPolylineOverlayRenderer( val polylineLayer: GraphicsOverlay, override val holder: ArcGISGeoViewHolder<*, *>, - override val coroutine: CoroutineScope = CoroutineScope(Dispatchers.Default), + // GraphicsOverlay.graphics はスレッドセーフでない。ポリゴン・サークルと同じく + // メインスレッドで触る(以前は Default で、ポリラインを 2 本同時に外すと + // removePolyline 同士が ConcurrentModificationException で落ちた)。 + // 頂点の補間だけは重いので Default で計算する。 + override val coroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), ) : AbstractPolylineOverlayRenderer() { + private val stackOrder = ArcGISGraphicsZIndex() + override suspend fun createPolyline(state: PolylineState): ArcGISActualPolyline? = withContext(coroutine.coroutineContext) { - val geometry = createGeometry(state) + val geometry = withContext(Dispatchers.Default) { createGeometry(state) } val lineSymbol = SimpleLineSymbol().apply { @@ -42,6 +49,7 @@ class ArcGISPolylineOverlayRenderer( Graphic(geometry, lineSymbol).also { it.attributes.set("id", state.id) } + stackOrder.place(graphic, state.zIndex) polylineLayer.graphics.add(graphic) graphic @@ -57,7 +65,7 @@ class ArcGISPolylineOverlayRenderer( val prevFinger = prev.fingerPrint if (finger.points != prevFinger.points || finger.geodesic != prevFinger.geodesic) { - polyline.geometry = createGeometry(current.state) + polyline.geometry = withContext(Dispatchers.Default) { createGeometry(current.state) } } (polyline.symbol as SimpleLineSymbol).let { symbol -> @@ -68,6 +76,9 @@ class ArcGISPolylineOverlayRenderer( symbol.width = current.state.strokeWidth.value } } + if (finger.zIndex != prevFinger.zIndex) { + stackOrder.place(polyline, current.state.zIndex) + } polyline } @@ -75,6 +86,7 @@ class ArcGISPolylineOverlayRenderer( override suspend fun removePolyline(entity: PolylineEntityInterface) { coroutine.launch { polylineLayer.graphics.remove(entity.polyline) + stackOrder.forget(entity.polyline) } } diff --git a/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerController.kt b/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerController.kt index 51e06f3..764ace7 100644 --- a/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerController.kt +++ b/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerController.kt @@ -1,15 +1,20 @@ package com.mapconductor.arcgis.raster import com.arcgismaps.mapping.layers.Layer +import com.mapconductor.arcgis.finishEvenIfCancelled import com.mapconductor.core.raster.RasterLayerController import com.mapconductor.core.raster.RasterLayerEntity import com.mapconductor.core.raster.RasterLayerManager import com.mapconductor.core.raster.RasterLayerManagerInterface +import com.mapconductor.core.raster.RasterLayerState class ArcGISRasterLayerController( rasterLayerManager: RasterLayerManagerInterface = RasterLayerManager(), override val renderer: ArcGISRasterLayerOverlayRenderer, ) : RasterLayerController(rasterLayerManager, renderer) { + /** 取り消されても最後まで([finishEvenIfCancelled])。 */ + override suspend fun add(data: List) = finishEvenIfCancelled { super.add(data) } + /** * Builds the in-process layers again, so ArcGIS drops the tiles it holds * for them. Wanted after the camera settles on a level that was answered