diff --git a/.gitignore b/.gitignore index 194598dc..a082e771 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,8 @@ build/ .env .build/ -.swiftpm/ \ No newline at end of file +.swiftpm/ + +# Local Android engine debugging artifacts +engine_patches/ +example/android/engine_patch_repo/ diff --git a/android/src/main/kotlin/kr/yhs/flutter_kakao_maps/model/KakaoMapOption.kt b/android/src/main/kotlin/kr/yhs/flutter_kakao_maps/model/KakaoMapOption.kt index 02bbb315..1c6cb6eb 100644 --- a/android/src/main/kotlin/kr/yhs/flutter_kakao_maps/model/KakaoMapOption.kt +++ b/android/src/main/kotlin/kr/yhs/flutter_kakao_maps/model/KakaoMapOption.kt @@ -5,6 +5,7 @@ import com.kakao.vectormap.KakaoMapReadyCallback import com.kakao.vectormap.LatLng import com.kakao.vectormap.MapType import com.kakao.vectormap.MapViewInfo +import com.kakao.vectormap.camera.CameraPosition import kr.yhs.flutter_kakao_maps.converter.CameraTypeConverter.asLatLng data class KakaoMapOption( @@ -40,6 +41,9 @@ data class KakaoMapOption( onReady = method } + fun copyWithCameraPosition(cameraPosition: CameraPosition): KakaoMapOption = + copy(initialPosition = cameraPosition.position, zoomLevel = cameraPosition.zoomLevel) + companion object { fun fromMessageable(onReady: ((KakaoMap) -> Unit), rawArgs: Map): KakaoMapOption { return KakaoMapOption( diff --git a/android/src/main/kotlin/kr/yhs/flutter_kakao_maps/views/KakaoMapView.kt b/android/src/main/kotlin/kr/yhs/flutter_kakao_maps/views/KakaoMapView.kt index aa49cdf9..48419ead 100644 --- a/android/src/main/kotlin/kr/yhs/flutter_kakao_maps/views/KakaoMapView.kt +++ b/android/src/main/kotlin/kr/yhs/flutter_kakao_maps/views/KakaoMapView.kt @@ -5,35 +5,48 @@ import android.app.Application import android.content.Context import android.os.Bundle import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout import com.kakao.vectormap.KakaoMap import com.kakao.vectormap.MapView -import io.flutter.plugin.common.MethodChannel +import com.kakao.vectormap.camera.CameraPosition +import com.kakao.vectormap.camera.CameraUpdateFactory import io.flutter.plugin.platform.PlatformView import kr.yhs.flutter_kakao_maps.controller.KakaoMapController import kr.yhs.flutter_kakao_maps.model.KakaoMapOption class KakaoMapView( private val activity: Activity, - private val context: Context, + context: Context, private val controller: KakaoMapController, - private val viewId: Int, private val option: KakaoMapOption, - private val channel: MethodChannel, + private val recreateMapViewOnResume: Boolean, + recreateMapViewDelayMillis: Long, + private val recoverGLSurfaceViewOnResume: Boolean, ) : PlatformView, Application.ActivityLifecycleCallbacks { - private val mapView = MapView(activity) + private val container = FrameLayout(context) + private val recreateDelayMillis = recreateMapViewDelayMillis.coerceAtLeast(0L) + private var mapView = createMapView(option) private lateinit var kakaoMap: KakaoMap + private var lastCameraPosition: CameraPosition? = null + private var pendingCameraPosition: CameraPosition? = null + private var isActivityResumed = false + private var wasActivityPaused = false + private var isDisposed = false + private var pendingRecreateRunnable: Runnable? = null + private var pendingRecovery = false + private var pendingRecoveryRunnable: Runnable? = null + private var pendingRecoveryTimeoutRunnable: Runnable? = null init { - controller.mapView = mapView - mapView.start(controller, option) + container.addView(mapView, matchParentLayoutParams()) activity.application.registerActivityLifecycleCallbacks(this) } - override fun getView(): View = mapView + override fun getView(): View = container override fun dispose() { - activity.application.unregisterActivityLifecycleCallbacks(this) - mapView.finish() + disposeMapView() controller.dispose() } @@ -44,11 +57,41 @@ class KakaoMapView( override fun onActivityResumed(activity: Activity) { if (activity != this.activity) return + val pendingRecreate = recreateMapViewOnResume && wasActivityPaused + val recoveryActive = recoverGLSurfaceViewOnResume && !recreateMapViewOnResume && wasActivityPaused + isActivityResumed = true + if (pendingRecreate) { + wasActivityPaused = false + scheduleMapViewRecreation() + return + } + if (recoveryActive) { + wasActivityPaused = false + // Resume normally first; recovery only adds the fork GL-thread un-pause if the + // engine rehost (detach/reattach) pauses the view again after this point. + mapView.resume() + pendingRecovery = true + val timeoutRunnable = Runnable { + pendingRecoveryTimeoutRunnable = null + if (pendingRecovery) { + pendingRecovery = false + } + } + pendingRecoveryTimeoutRunnable = timeoutRunnable + container.postDelayed(timeoutRunnable, 600L) + return + } + wasActivityPaused = false mapView.resume() } override fun onActivityPaused(activity: Activity) { if (activity != this.activity) return + isActivityResumed = false + wasActivityPaused = true + cancelPendingMapViewRecreation() + cancelPendingRecovery() + captureCameraPosition() mapView.pause() } @@ -58,7 +101,140 @@ class KakaoMapView( override fun onActivityDestroyed(activity: Activity) { if (activity != this.activity) return + disposeMapView() + } + + private fun createMapView(startOption: KakaoMapOption): MapView { + val mapView = MapView(activity) + val wrappedOption = startOption.also { it.setOnReady(::onMapReady) } + controller.mapView = mapView + + mapView.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + if (pendingRecovery) { + cancelPendingRecoveryTimeout() + val recoveryRunnable = Runnable { + pendingRecoveryRunnable = null + executeGLSurfaceViewRecovery() + } + pendingRecoveryRunnable = recoveryRunnable + container.post(recoveryRunnable) + } + } + override fun onViewDetachedFromWindow(v: View) = Unit + }) + + mapView.start(controller, wrappedOption) + return mapView + } + + private fun onMapReady(map: KakaoMap) { + kakaoMap = map + pendingCameraPosition?.let { cameraPosition -> + map.moveCamera(CameraUpdateFactory.newCameraPosition(cameraPosition)) + lastCameraPosition = cameraPosition + pendingCameraPosition = null + } ?: run { + captureCameraPosition() + } + controller.onMapReady(map) + } + + private fun scheduleMapViewRecreation() { + cancelPendingMapViewRecreation() + val runnable = Runnable { + pendingRecreateRunnable = null + recreateMapView() + } + pendingRecreateRunnable = runnable + container.postDelayed(runnable, recreateDelayMillis) + } + + private fun cancelPendingMapViewRecreation() { + pendingRecreateRunnable?.let(container::removeCallbacks) + pendingRecreateRunnable = null + } + + private fun cancelPendingRecoveryTimeout() { + pendingRecoveryTimeoutRunnable?.let(container::removeCallbacks) + pendingRecoveryTimeoutRunnable = null + } + + private fun cancelPendingRecovery() { + cancelPendingRecoveryTimeout() + pendingRecoveryRunnable?.let(container::removeCallbacks) + pendingRecoveryRunnable = null + pendingRecovery = false + } + + private fun executeGLSurfaceViewRecovery() { + if (isDisposed) return + val surfaceView = runCatching { mapView.getSurfaceView() }.getOrNull() + + // Primary: cast to Kakao fork GLSurfaceView and call onResume() + val castResult = runCatching { + val glSurfaceView = surfaceView as? com.kakao.vectormap.graphics.gl.GLSurfaceView + if (glSurfaceView != null) { + glSurfaceView.onResume() + true + } else { + false + } + }.getOrElse { + false + } + + // Fallback: reflection if cast yielded null (e.g. Vulkan surface view) + if (!castResult && surfaceView != null) { + runCatching { + surfaceView.javaClass.getMethod("onResume").invoke(surfaceView) + } + } + + runCatching { + mapView.resume() + } + + pendingRecovery = false + } + + private fun recreateMapView() { + if (isDisposed) return + val cameraPosition = captureCameraPosition() + pendingCameraPosition = cameraPosition + val oldMapView = mapView + oldMapView.finish() + container.removeView(oldMapView) + + val startOption = cameraPosition?.let(option::copyWithCameraPosition) ?: option + mapView = createMapView(startOption) + container.addView(mapView, matchParentLayoutParams()) + if (isActivityResumed) { + mapView.resume() + } + } + + private fun captureCameraPosition(): CameraPosition? { + if (!::kakaoMap.isInitialized) return lastCameraPosition + lastCameraPosition = + runCatching { kakaoMap.cameraPosition }.getOrElse { lastCameraPosition } + return lastCameraPosition + } + + private fun disposeMapView() { + if (isDisposed) return + isDisposed = true + cancelPendingMapViewRecreation() + cancelPendingRecovery() mapView.finish() + container.removeAllViews() activity.application.unregisterActivityLifecycleCallbacks(this) } + + private fun matchParentLayoutParams(): FrameLayout.LayoutParams { + return FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + } } diff --git a/android/src/main/kotlin/kr/yhs/flutter_kakao_maps/views/KakaoMapViewFactory.kt b/android/src/main/kotlin/kr/yhs/flutter_kakao_maps/views/KakaoMapViewFactory.kt index 0b000b9f..c38395bf 100644 --- a/android/src/main/kotlin/kr/yhs/flutter_kakao_maps/views/KakaoMapViewFactory.kt +++ b/android/src/main/kotlin/kr/yhs/flutter_kakao_maps/views/KakaoMapViewFactory.kt @@ -24,13 +24,20 @@ class KakaoMapViewFactory(private val activity: Activity, private val messenger: val controller = KakaoMapController(viewId, context, channel, overlayChannel) val option = KakaoMapOption.fromMessageable(controller::onMapReady, convertedArgs) + val recreateMapViewOnResume = + convertedArgs["recreateAndroidMapViewOnResume"] as Boolean? ?: false + val recreateMapViewDelayMillis = + (convertedArgs["androidMapViewRecreationDelayMillis"] as Number?)?.toLong() ?: 300L + val recoverGLSurfaceViewOnResume = + convertedArgs["recoverAndroidGLSurfaceViewOnResume"] as Boolean? ?: true return KakaoMapView( activity = activity, context = context, controller = controller, - viewId = viewId, option = option, - channel = channel, + recreateMapViewOnResume = recreateMapViewOnResume, + recreateMapViewDelayMillis = recreateMapViewDelayMillis, + recoverGLSurfaceViewOnResume = recoverGLSurfaceViewOnResume, ) } } diff --git a/lib/controller/controller_implement.dart b/lib/controller/controller_implement.dart index 41168ec9..c19789e5 100644 --- a/lib/controller/controller_implement.dart +++ b/lib/controller/controller_implement.dart @@ -466,4 +466,36 @@ class KakaoMapControllerImplement extends KakaoMapController { Future resume() async { await channel.invokeMethod("resume"); } + + void _resetAfterNativeMapRecreation() { + for (final style in _poiStyle.values) { + style._isAdded = false; + } + for (final styles in _polygonStyle.values) { + for (final style in styles) { + style._isAdded = false; + } + } + for (final styles in _polylineStyle.values) { + for (final style in styles) { + style._isAdded = false; + } + } + for (final styles in _routeStyle.values) { + for (final style in styles) { + style._isAdded = false; + } + } + + _labelController.clear(); + _lodLabelController.clear(); + _shapeController.clear(); + _routeController.clear(); + _poiStyle.clear(); + _polygonStyle.clear(); + _polylineStyle.clear(); + _routeStyle.clear(); + buildingHeightScale = null; + _initalizeOverlayController(); + } } diff --git a/lib/controller/overlay/overlay_manager.dart b/lib/controller/overlay/overlay_manager.dart index 049663b0..f2157437 100644 --- a/lib/controller/overlay/overlay_manager.dart +++ b/lib/controller/overlay/overlay_manager.dart @@ -9,8 +9,8 @@ mixin OverlayManager { final Map _lodLabelController = {}; final Map _shapeController = {}; final Map _routeController = {}; - late final DimScreenController _dimController; - late final TrackingController _trackingController; + late DimScreenController _dimController; + late TrackingController _trackingController; // Overlay Style final Map _poiStyle = {}; diff --git a/lib/widget/map_widget.dart b/lib/widget/map_widget.dart index 272352e8..a4ddd27a 100644 --- a/lib/widget/map_widget.dart +++ b/lib/widget/map_widget.dart @@ -63,6 +63,20 @@ class KakaoMap extends StatefulWidget { /// 자세한 내용은 [Platform View](https://docs.flutter.dev/platform-integration/android/platform-views#texture-layer-or-texture-layer-hybrid-composition)을 참고해주세요. final bool forceHybridComposition; + /// Android에서 Activity resume 후 Flutter 엔진의 VirtualDisplay rehost 시 Kakao GLSurfaceView의 + /// GL 스레드를 비파괴적으로 재개하는 복구 옵션입니다. + /// [recreateAndroidMapViewOnResume]가 true인 경우 이 옵션은 비활성화되며, 파괴적 재생성이 우선합니다. + final bool recoverAndroidGLSurfaceViewOnResume; + + /// Android에서 앱이 백그라운드에서 복귀할 때 native MapView를 재생성합니다. + /// 이 옵션은 파괴적 비상 복구 수단입니다. [recoverAndroidGLSurfaceViewOnResume]가 비파괴적 기본 복구 경로입니다. + /// 이 옵션을 사용하면 복귀 시 [onMapReady]가 다시 호출되며, native 지도에 등록된 오버레이는 다시 구성해야 합니다. + final bool recreateAndroidMapViewOnResume; + + /// [recreateAndroidMapViewOnResume]가 활성화된 경우, Activity resume 이후 MapView 재생성을 지연할 시간입니다. + /// Flutter 엔진이 PlatformView의 Surface를 다시 연결한 뒤 재생성하기 위해 짧은 지연을 둡니다. + final Duration androidMapViewRecreationDelay; + const KakaoMap({ super.key, required this.onMapReady, @@ -79,6 +93,9 @@ class KakaoMap extends StatefulWidget { this.onTerrainLongClick, this.onMapError, this.forceHybridComposition = false, + this.recoverAndroidGLSurfaceViewOnResume = true, + this.recreateAndroidMapViewOnResume = false, + this.androidMapViewRecreationDelay = const Duration(milliseconds: 300), }); @override @@ -90,11 +107,21 @@ class _KakaoMapState extends State with KakaoMapControllerHandler { static const VIEW_TYPE = "plugin/kakao_map"; late final MethodChannel channel; late final KakaoMapController controller; + bool _hasNativeMapReady = false; @override Widget build(BuildContext context) { - Map rawParams = widget.option?.toMessageable() ?? + final Map rawParams = widget.option?.toMessageable() ?? (const KakaoMapOption()).toMessageable(); + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { + rawParams['recoverAndroidGLSurfaceViewOnResume'] = + widget.recoverAndroidGLSurfaceViewOnResume; + if (widget.recreateAndroidMapViewOnResume) { + rawParams['recreateAndroidMapViewOnResume'] = true; + rawParams['androidMapViewRecreationDelayMillis'] = + widget.androidMapViewRecreationDelay.inMilliseconds; + } + } // GestureRecognizer final Set> gestureRecognizers = {}; @@ -162,6 +189,11 @@ class _KakaoMapState extends State with KakaoMapControllerHandler { @override void onMapReady() { + final mapController = controller; + if (_hasNativeMapReady && mapController is KakaoMapControllerImplement) { + mapController._resetAfterNativeMapRecreation(); + } + _hasNativeMapReady = true; _setEventHandler(); controller.fetchBuildingHeightScale(); widget.onMapReady.call(controller); diff --git a/test/kakao_map_widget_test.dart b/test/kakao_map_widget_test.dart index 8041b2a4..7b53b297 100644 --- a/test/kakao_map_widget_test.dart +++ b/test/kakao_map_widget_test.dart @@ -111,11 +111,17 @@ void main() { expect(encodedAndroidParams, isNotNull); - final decodedAndroidParams = const StandardMessageCodec().decodeMessage( - ByteData.sublistView(encodedAndroidParams!), + final decodedAndroidParams = Map.from( + const StandardMessageCodec().decodeMessage( + ByteData.sublistView(encodedAndroidParams!), + ) as Map, ); - expect(decodedAndroidParams, equals(expectedParams)); + // Android always injects recoverAndroidGLSurfaceViewOnResume; strip it before comparing + // to the platform-agnostic expectedParams derived from KakaoMapOption.toMessageable(). + final androidParamsWithoutRecovery = Map.from(decodedAndroidParams) + ..remove('recoverAndroidGLSurfaceViewOnResume'); + expect(androidParamsWithoutRecovery, equals(expectedParams)); debugDefaultTargetPlatformOverride = TargetPlatform.iOS; @@ -133,4 +139,45 @@ void main() { debugDefaultTargetPlatformOverride = previous; } }); + + testWidgets('should pass Android MapView recreation params when enabled', ( + tester, + ) async { + final previous = debugDefaultTargetPlatformOverride; + + try { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + + await tester.pumpWidget( + wrapApp( + KakaoMap( + key: UniqueKey(), + onMapReady: (_) {}, + recreateAndroidMapViewOnResume: true, + androidMapViewRecreationDelay: const Duration(milliseconds: 120), + ), + ), + ); + + expect(lastPlatformViewsCreateCall, isNotNull); + + final androidCreateArgs = Map.from( + lastPlatformViewsCreateCall!.arguments as Map, + ); + final encodedAndroidParams = androidCreateArgs['params'] as Uint8List?; + + expect(encodedAndroidParams, isNotNull); + + final decodedAndroidParams = Map.from( + const StandardMessageCodec().decodeMessage( + ByteData.sublistView(encodedAndroidParams!), + ) as Map, + ); + + expect(decodedAndroidParams['recreateAndroidMapViewOnResume'], isTrue); + expect(decodedAndroidParams['androidMapViewRecreationDelayMillis'], 120); + } finally { + debugDefaultTargetPlatformOverride = previous; + } + }); }