From 063184eb09b6df1d08eaf7d03dad187c7b9e545e Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sun, 8 Nov 2026 12:43:38 -0800 Subject: [PATCH 1/4] Stack ArcGIS graphics by zIndex within a band, whatever order they arrive in Within the polygon, circle, polyline and marker bands, a zIndex=1 overlay added before a zIndex=0 one ended up underneath it. Measured on the tablet (stackctl page, zIndex=1 declared first): - Polygons: right on 3D, wrong on 2D. The renderer wrote zIndex into an attribute and re-sorted the overlay's collection (clear + addAll) in onPostProcess. The collection did end up sorted (logged), but MapView kept drawing in the original order; only SceneView follows the collection order. - Circles and polylines: wrong on both, because nothing read zIndex. The marker renderer ignored it too (seen in the code and the test; the check page has no overlapping markers). ArcGIS draws a GraphicsOverlay by each Graphic's zIndex property, ties in the order they were added, on both SceneView and MapView. Every renderer now sets that property on create and on update (ArcGISGraphicsZIndex), and the polygon renderer no longer re-sorts the collection: with the property set, 3D and 2D both draw right without it, and clear + addAll could put back a graphic an asynchronous removal had just taken out. The polyline renderer touched GraphicsOverlay.graphics on Dispatchers.Default. Removing two polylines at once made the two removals race and crashed the app with ConcurrentModificationException (seen on develop as soon as the check page swapped two polylines). It now touches the graphics on the main thread like the polygon and circle renderers, and only interpolates the vertices on Default. The ground image and raster bands were already ordered: ground images carry no zIndex on Android, and raster layers go through ArcGISOverlayStacking with theirs. ArcGISGraphicsZIndexTest (instrumented) adds a zIndex=1 overlay first and a zIndex=0 one after for each kind, and checks the order ArcGIS draws them in; all five tests failed before and pass now. --- api/android-for-arcgis.api | 4 + .../arcgis/ArcGISGraphicsZIndexTest.kt | 167 ++++++++++++++++++ .../arcgis/ArcGISGraphicsZIndex.kt | 29 +++ .../circle/ArcGISCircleOverlayRenderer.kt | 5 + .../arcgis/marker/ArcGISMarkerRenderer.kt | 3 + .../polygon/ArcGISPolygonOverlayRenderer.kt | 20 +-- .../polyline/ArcGISPolylineOverlayRenderer.kt | 15 +- 7 files changed, 224 insertions(+), 19 deletions(-) create mode 100644 src/androidTest/java/com/mapconductor/arcgis/ArcGISGraphicsZIndexTest.kt create mode 100644 src/main/java/com/mapconductor/arcgis/ArcGISGraphicsZIndex.kt diff --git a/api/android-for-arcgis.api b/api/android-for-arcgis.api index 60defdf..92b2869 100644 --- a/api/android-for-arcgis.api +++ b/api/android-for-arcgis.api @@ -112,6 +112,10 @@ 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 static final com.mapconductor.arcgis.ArcGISGraphicsZIndex INSTANCE; + public final void apply(com.arcgismaps.mapping.view.Graphic, java.lang.Integer); +} 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); 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..f044307 --- /dev/null +++ b/src/androidTest/java/com/mapconductor/arcgis/ArcGISGraphicsZIndexTest.kt @@ -0,0 +1,167 @@ +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 なら足した順(後から足したものが上)。 */ + @Test + fun equalZIndexKeepsTheOrderTheyArrive() = + runBlocking { + val overlay = GraphicsOverlay() + val renderer = ArcGISPolygonOverlayRenderer(overlay, holder) + val first = renderer.createPolygon(PolygonState(points = rect(0.0), id = "a", zIndex = 3))!! + val second = renderer.createPolygon(PolygonState(points = rect(0.05), id = "b", zIndex = 3))!! + renderer.onPostProcess() + assertEquals(3, first.zIndex) + assertTrue(drawsAbove(overlay, second, first)) + } +} 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..a629537 --- /dev/null +++ b/src/main/java/com/mapconductor/arcgis/ArcGISGraphicsZIndex.kt @@ -0,0 +1,29 @@ +package com.mapconductor.arcgis + +import com.arcgismaps.mapping.view.Graphic + +/** + * 帯の中の重なり順(zIndex)。ポリゴン・サークル・ポリライン・マーカーの Graphic に使う。 + * + * 帯(ポリゴン < サークル < ポリライン < マーカー)は GraphicsOverlay 自体の並びで決まる + * ([ArcGISOverlayStacking])。帯の**中**は、各 Graphic の `zIndex` プロパティで決める。 + * 3D(SceneView)・2D(MapView)とも、同じ GraphicsOverlay の中はこの値の順に描かれ、 + * 同じ値なら足した順になる。 + * + * 以前はポリゴンだけ、zIndex を属性(`attributes["zIndex"]`)に書いて `onPostProcess` で + * コレクションを `clear()` → `addAll()` で並べ替えていた。2D はそれでは描く順が変わらず、 + * zIndex=1 を先に、zIndex=0 を後に足すと zIndex=1 が下に残った。サークルとポリラインは + * (マーカーも)zIndex を見ておらず、3D・2D とも足した順のままだった。`zIndex` プロパティだけで + * 3D・2D とも正しく描かれるので、コレクションの並べ替えはやめた(`clear()` → `addAll()` は + * 非同期の削除と重なると、消したはずの Graphic を戻しうる)。 + */ +internal object ArcGISGraphicsZIndex { + /** [graphic] に [zIndex] を載せる。`null`(サークルの既定)は 0。 */ + fun apply( + graphic: Graphic, + zIndex: Int?, + ) { + val value = zIndex ?: 0 + if (graphic.zIndex != value) graphic.zIndex = value + } +} diff --git a/src/main/java/com/mapconductor/arcgis/circle/ArcGISCircleOverlayRenderer.kt b/src/main/java/com/mapconductor/arcgis/circle/ArcGISCircleOverlayRenderer.kt index 9f37704..9179efe 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 @@ -62,6 +63,7 @@ class ArcGISCircleOverlayRenderer( outline = stroke, ) val circle = Graphic(circleGeometry, fillSymbol) + ArcGISGraphicsZIndex.apply(circle, state.zIndex) circleLayer.graphics.add(circle) circle @@ -124,6 +126,9 @@ class ArcGISCircleOverlayRenderer( } } } + if (finger.zIndex != prevFinger.zIndex) { + ArcGISGraphicsZIndex.apply(graphic, current.state.zIndex) + } return@withContext graphic } diff --git a/src/main/java/com/mapconductor/arcgis/marker/ArcGISMarkerRenderer.kt b/src/main/java/com/mapconductor/arcgis/marker/ArcGISMarkerRenderer.kt index d86560a..a2f3daa 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 @@ -128,6 +129,7 @@ class ArcGISMarkerRenderer( it.attributes["id"] = params.state.id it.attributes[BASE_HEIGHT_KEY] = height it.attributes[BASE_OFFSET_Y_KEY] = anchorY.toFloat() + ArcGISGraphicsZIndex.apply(it, params.state.zIndex) } return@map marker }.also { @@ -206,6 +208,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 + ArcGISGraphicsZIndex.apply(marker, params.current.state.zIndex) // ArcGISはマーカーを再作成しなくてよいので、同じマーカーのインスタンスを返す marker diff --git a/src/main/java/com/mapconductor/arcgis/polygon/ArcGISPolygonOverlayRenderer.kt b/src/main/java/com/mapconductor/arcgis/polygon/ArcGISPolygonOverlayRenderer.kt index 15c6a0b..c41562d 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 @@ -52,7 +53,7 @@ class ArcGISPolygonOverlayRenderer( val graphic = Graphic(geometry, fillSymbol).also { it.attributes.set("id", state.id) - it.attributes.set("zIndex", state.zIndex) + ArcGISGraphicsZIndex.apply(it, state.zIndex) } polygonLayer.graphics.add(graphic) @@ -90,7 +91,7 @@ class ArcGISPolygonOverlayRenderer( } } if (finger.zIndex != prevFinger.zIndex) { - current.polygon.attributes.set("zIndex", current.state.zIndex) + ArcGISGraphicsZIndex.apply(current.polygon, current.state.zIndex) } polygon } @@ -105,20 +106,7 @@ class ArcGISPolygonOverlayRenderer( } 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/ArcGISPolylineOverlayRenderer.kt b/src/main/java/com/mapconductor/arcgis/polyline/ArcGISPolylineOverlayRenderer.kt index 1451116..e72e81d 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,15 @@ 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() { 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 { @@ -41,6 +46,7 @@ class ArcGISPolylineOverlayRenderer( val graphic = Graphic(geometry, lineSymbol).also { it.attributes.set("id", state.id) + ArcGISGraphicsZIndex.apply(it, state.zIndex) } polylineLayer.graphics.add(graphic) @@ -57,7 +63,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 +74,9 @@ class ArcGISPolylineOverlayRenderer( symbol.width = current.state.strokeWidth.value } } + if (finger.zIndex != prevFinger.zIndex) { + ArcGISGraphicsZIndex.apply(polyline, current.state.zIndex) + } polyline } From d5759c104deebcccb3d3824b9d1707da51f322c2 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sun, 8 Nov 2026 14:13:30 -0800 Subject: [PATCH 2/4] Add and remove ground image layers on the main thread The ground image renderer added and removed its WebTiledLayer in the map's operational layers from Dispatchers.Default, while the raster renderer and the marker tiles change the same list on the main thread. Taking a ground image and a raster layer away together (the stackctl check page does this when it re-sends every overlay) crashed the app on the tablet with ConcurrentModificationException in ArcGISRasterLayerOverlayRenderer.removeLayer. It showed up once the polyline crash in front of it was fixed. The list is now only touched on the main thread; building the tiles and loading the layer stay on Default. ArcGISGroundImageLayerThreadTest (instrumented) records which thread adds and removes the layer; it failed before (both on DefaultDispatcher-worker) and passes now. --- .../ArcGISGroundImageLayerThreadTest.kt | 121 ++++++++++++++++++ .../ArcGISGroundImageOverlayRenderer.kt | 28 ++-- 2 files changed, 141 insertions(+), 8 deletions(-) create mode 100644 src/androidTest/java/com/mapconductor/arcgis/ArcGISGroundImageLayerThreadTest.kt 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/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) } } From e57d57e4835c820631e6cdcb764b235a0336c970 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sun, 8 Nov 2026 14:29:14 -0800 Subject: [PATCH 3/4] Rank ArcGIS graphics so equal zIndex keeps the order they arrived in Writing the state's zIndex into Graphic.zIndex fixed zIndex=1 drawing under zIndex=0, but ties were left to ArcGIS, which does not keep the order graphics were added (its reference says they "usually" render in that order and to set zIndex explicitly when order matters). On the tablet in 2D: - two zIndex=0 polygons with a zIndex=1 one added between them drew the later one underneath (it was right on develop, where nothing set Graphic.zIndex); - markers with equal zIndex drew in neither order, on develop as well. Each GraphicsOverlay now keeps a stack order (ArcGISGraphicsZIndex): graphics sorted by (state zIndex, arrival), with that rank written to Graphic.zIndex, so no two graphics share a value. A zIndex-only update moves the graphic but keeps its place among equals; a graphic taken off and added again goes on top of its zIndex. Ranks only need to increase, so removals leave gaps and adding on top writes one value. ArcGISGraphicsZIndexTest gets three tests (equal zIndex polygons around a higher one, equal zIndex markers, zIndex change and re-add on circles); they failed on the previous commit and pass now. The stackctl check page shows every band right with zIndex=1 added first or last, after a zIndex swap, same-id new states, partial removal and restore, clearOverlays and re-send, and a design change. --- api/android-for-arcgis.api | 14 ++- .../arcgis/ArcGISGraphicsZIndexTest.kt | 75 +++++++++++- .../arcgis/ArcGISGraphicsZIndex.kt | 107 +++++++++++++++--- .../circle/ArcGISCircleOverlayRenderer.kt | 7 +- .../arcgis/marker/ArcGISMarkerRenderer.kt | 7 +- .../polygon/ArcGISPolygonOverlayRenderer.kt | 7 +- .../polyline/ArcGISPolylineOverlayRenderer.kt | 7 +- 7 files changed, 194 insertions(+), 30 deletions(-) diff --git a/api/android-for-arcgis.api b/api/android-for-arcgis.api index 92b2869..de59b7e 100644 --- a/api/android-for-arcgis.api +++ b/api/android-for-arcgis.api @@ -113,8 +113,18 @@ public final class com.mapconductor.arcgis.ArcGISGeoViewHolder$DefaultImpls { public static com.mapconductor.core.features.GeoPoint fromScreenOffsetSync-k-4lQ0M(com.mapconductor.arcgis.ArcGISGeoViewHolder, long); } public final class com.mapconductor.arcgis.ArcGISGraphicsZIndex { - public static final com.mapconductor.arcgis.ArcGISGraphicsZIndex INSTANCE; - public final void apply(com.arcgismaps.mapping.view.Graphic, java.lang.Integer); + 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; diff --git a/src/androidTest/java/com/mapconductor/arcgis/ArcGISGraphicsZIndexTest.kt b/src/androidTest/java/com/mapconductor/arcgis/ArcGISGraphicsZIndexTest.kt index f044307..7ef85e3 100644 --- a/src/androidTest/java/com/mapconductor/arcgis/ArcGISGraphicsZIndexTest.kt +++ b/src/androidTest/java/com/mapconductor/arcgis/ArcGISGraphicsZIndexTest.kt @@ -152,16 +152,81 @@ class ArcGISGraphicsZIndexTest { assertTrue("zIndex=1 added first must draw above the default zIndex", drawsAbove(overlay, top, bottom)) } - /** 同じ zIndex なら足した順(後から足したものが上)。 */ + /** + * 同じ 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 first = renderer.createPolygon(PolygonState(points = rect(0.0), id = "a", zIndex = 3))!! - val second = renderer.createPolygon(PolygonState(points = rect(0.05), id = "b", zIndex = 3))!! + 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() - assertEquals(3, first.zIndex) - assertTrue(drawsAbove(overlay, second, first)) + 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/main/java/com/mapconductor/arcgis/ArcGISGraphicsZIndex.kt b/src/main/java/com/mapconductor/arcgis/ArcGISGraphicsZIndex.kt index a629537..991b8d4 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGISGraphicsZIndex.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGISGraphicsZIndex.kt @@ -1,29 +1,106 @@ package com.mapconductor.arcgis import com.arcgismaps.mapping.view.Graphic +import java.util.IdentityHashMap /** - * 帯の中の重なり順(zIndex)。ポリゴン・サークル・ポリライン・マーカーの Graphic に使う。 + * 帯の中の重なり順。ポリゴン・サークル・ポリライン・マーカーの GraphicsOverlay ごとに 1 つ持つ。 * * 帯(ポリゴン < サークル < ポリライン < マーカー)は GraphicsOverlay 自体の並びで決まる - * ([ArcGISOverlayStacking])。帯の**中**は、各 Graphic の `zIndex` プロパティで決める。 - * 3D(SceneView)・2D(MapView)とも、同じ GraphicsOverlay の中はこの値の順に描かれ、 - * 同じ値なら足した順になる。 + * ([ArcGISOverlayStacking])。帯の**中**は (state の zIndex, 渡された順) で決め、その順位を + * 各 Graphic の `zIndex` プロパティに書く。 * - * 以前はポリゴンだけ、zIndex を属性(`attributes["zIndex"]`)に書いて `onPostProcess` で - * コレクションを `clear()` → `addAll()` で並べ替えていた。2D はそれでは描く順が変わらず、 - * zIndex=1 を先に、zIndex=0 を後に足すと zIndex=1 が下に残った。サークルとポリラインは - * (マーカーも)zIndex を見ておらず、3D・2D とも足した順のままだった。`zIndex` プロパティだけで - * 3D・2D とも正しく描かれるので、コレクションの並べ替えはやめた(`clear()` → `addAll()` は - * 非同期の削除と重なると、消したはずの Graphic を戻しうる)。 + * 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 object ArcGISGraphicsZIndex { - /** [graphic] に [zIndex] を載せる。`null`(サークルの既定)は 0。 */ - fun apply( +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 value = zIndex ?: 0 - if (graphic.zIndex != value) graphic.zIndex = value + 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/circle/ArcGISCircleOverlayRenderer.kt b/src/main/java/com/mapconductor/arcgis/circle/ArcGISCircleOverlayRenderer.kt index 9179efe..39e6b01 100644 --- a/src/main/java/com/mapconductor/arcgis/circle/ArcGISCircleOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/arcgis/circle/ArcGISCircleOverlayRenderer.kt @@ -31,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) @@ -63,7 +65,7 @@ class ArcGISCircleOverlayRenderer( outline = stroke, ) val circle = Graphic(circleGeometry, fillSymbol) - ArcGISGraphicsZIndex.apply(circle, state.zIndex) + stackOrder.place(circle, state.zIndex) circleLayer.graphics.add(circle) circle @@ -73,6 +75,7 @@ class ArcGISCircleOverlayRenderer( coroutine.launch { val circles = listOf(entity.circle) circleLayer.graphics.removeAll(circles) + stackOrder.forget(entity.circle) } } @@ -127,7 +130,7 @@ class ArcGISCircleOverlayRenderer( } } if (finger.zIndex != prevFinger.zIndex) { - ArcGISGraphicsZIndex.apply(graphic, current.state.zIndex) + stackOrder.place(graphic, current.state.zIndex) } return@withContext graphic } diff --git a/src/main/java/com/mapconductor/arcgis/marker/ArcGISMarkerRenderer.kt b/src/main/java/com/mapconductor/arcgis/marker/ArcGISMarkerRenderer.kt index a2f3daa..6e242e3 100644 --- a/src/main/java/com/mapconductor/arcgis/marker/ArcGISMarkerRenderer.kt +++ b/src/main/java/com/mapconductor/arcgis/marker/ArcGISMarkerRenderer.kt @@ -35,6 +35,8 @@ class ArcGISMarkerRenderer( ) { override val supportsAnimationOverlay: Boolean = true + private val stackOrder = ArcGISGraphicsZIndex() + /** * マーカーのアイコンを縦へ引き伸ばす倍率。 * @@ -129,8 +131,8 @@ class ArcGISMarkerRenderer( it.attributes["id"] = params.state.id it.attributes[BASE_HEIGHT_KEY] = height it.attributes[BASE_OFFSET_Y_KEY] = anchorY.toFloat() - ArcGISGraphicsZIndex.apply(it, params.state.zIndex) } + stackOrder.place(marker, params.state.zIndex) return@map marker }.also { markerLayer.graphics.addAll(it) @@ -143,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) } } } @@ -208,7 +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 - ArcGISGraphicsZIndex.apply(marker, params.current.state.zIndex) + stackOrder.place(marker, params.current.state.zIndex) // ArcGISはマーカーを再作成しなくてよいので、同じマーカーのインスタンスを返す marker diff --git a/src/main/java/com/mapconductor/arcgis/polygon/ArcGISPolygonOverlayRenderer.kt b/src/main/java/com/mapconductor/arcgis/polygon/ArcGISPolygonOverlayRenderer.kt index c41562d..4f7afe7 100644 --- a/src/main/java/com/mapconductor/arcgis/polygon/ArcGISPolygonOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/arcgis/polygon/ArcGISPolygonOverlayRenderer.kt @@ -32,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) @@ -53,9 +55,9 @@ class ArcGISPolygonOverlayRenderer( val graphic = Graphic(geometry, fillSymbol).also { it.attributes.set("id", state.id) - ArcGISGraphicsZIndex.apply(it, state.zIndex) } + stackOrder.place(graphic, state.zIndex) polygonLayer.graphics.add(graphic) graphic } @@ -91,7 +93,7 @@ class ArcGISPolygonOverlayRenderer( } } if (finger.zIndex != prevFinger.zIndex) { - ArcGISGraphicsZIndex.apply(current.polygon, current.state.zIndex) + stackOrder.place(current.polygon, current.state.zIndex) } polygon } @@ -102,6 +104,7 @@ class ArcGISPolygonOverlayRenderer( // of rebuilding the whole graphics list. coroutine.launch { polygonLayer.graphics.remove(entity.polygon) + stackOrder.forget(entity.polygon) } } diff --git a/src/main/java/com/mapconductor/arcgis/polyline/ArcGISPolylineOverlayRenderer.kt b/src/main/java/com/mapconductor/arcgis/polyline/ArcGISPolylineOverlayRenderer.kt index e72e81d..6adff91 100644 --- a/src/main/java/com/mapconductor/arcgis/polyline/ArcGISPolylineOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/arcgis/polyline/ArcGISPolylineOverlayRenderer.kt @@ -32,6 +32,8 @@ class ArcGISPolylineOverlayRenderer( // 頂点の補間だけは重いので 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 = withContext(Dispatchers.Default) { createGeometry(state) } @@ -46,8 +48,8 @@ class ArcGISPolylineOverlayRenderer( val graphic = Graphic(geometry, lineSymbol).also { it.attributes.set("id", state.id) - ArcGISGraphicsZIndex.apply(it, state.zIndex) } + stackOrder.place(graphic, state.zIndex) polylineLayer.graphics.add(graphic) graphic @@ -75,7 +77,7 @@ class ArcGISPolylineOverlayRenderer( } } if (finger.zIndex != prevFinger.zIndex) { - ArcGISGraphicsZIndex.apply(polyline, current.state.zIndex) + stackOrder.place(polyline, current.state.zIndex) } polyline @@ -84,6 +86,7 @@ class ArcGISPolylineOverlayRenderer( override suspend fun removePolyline(entity: PolylineEntityInterface) { coroutine.launch { polylineLayer.graphics.remove(entity.polyline) + stackOrder.forget(entity.polyline) } } From 3a5c07917b3732d46d56fabbe6bf88215d8aedc8 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sun, 8 Nov 2026 14:29:14 -0800 Subject: [PATCH 4/4] Finish an overlay sync even when compose cancels it Compose cancels the running LaunchedEffect whenever an overlay's states change. The ArcGIS renderers put graphics and layers on the map in their own scope (main thread or Default) while the caller waits on the compose dispatcher; cancelled in between, withContext throws away the result, so the graphic stays on the map but is never registered. The next sync adds the same id again, and the first copy is never taken off -- clearOverlays leaves it too. Seen on the tablet in 2D: right after start, markers, polygons and polylines intermittently came in twice per id (1 of 4 launches of the check page; a run that hit it kept 5 extra marker graphics through clearOverlays), and polylines added and removed in quick succession were left behind. This, not the clear() + addAll() re-sort, is where the duplicate polygons seen earlier came from. The ArcGIS controllers now run add() to the end (NonCancellable); the newer states that caused the cancellation are applied by the next sync, which waits behind the semaphore. Removal already tolerated cancellation (it removes from the map before forgetting the entity). ArcGISOverlaySyncCancellationTest (instrumented) cancels an add while the main thread is busy and then syncs again, for polygons, polylines, circles and markers; all four found two graphics before and one now. --- api/android-for-arcgis.api | 10 ++ .../ArcGISOverlaySyncCancellationTest.kt | 144 ++++++++++++++++++ .../mapconductor/arcgis/ArcGISOverlaySync.kt | 19 +++ .../circle/ArcGISCircleOverlayController.kt | 7 +- .../ArcGISGroundImageController.kt | 7 +- .../arcgis/marker/ArcGISMarkerController.kt | 6 +- .../polygon/ArcGISPolygonOverlayController.kt | 7 +- .../ArcGISPolylineOverlayController.kt | 7 +- .../raster/ArcGISRasterLayerController.kt | 5 + 9 files changed, 207 insertions(+), 5 deletions(-) create mode 100644 src/androidTest/java/com/mapconductor/arcgis/ArcGISOverlaySyncCancellationTest.kt create mode 100644 src/main/java/com/mapconductor/arcgis/ArcGISOverlaySync.kt diff --git a/api/android-for-arcgis.api b/api/android-for-arcgis.api index de59b7e..42cd187 100644 --- a/api/android-for-arcgis.api +++ b/api/android-for-arcgis.api @@ -349,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 { @@ -430,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 { @@ -446,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); @@ -518,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); @@ -537,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); @@ -555,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/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/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/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/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/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/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/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