返回

Mapbox Android SDK 10 相机空闲状态检测方案

Android

检测 Mapbox Android SDK 10 中相机空闲状态

在 Mapbox Android SDK 9 迁移到 SDK 10 的过程中,开发者普遍注意到 OnCameraIdleListener 已被移除。此监听器曾用于检测相机何时停止移动,无论是用户手势操作还是程序化控制。本文将探讨 SDK 10 中实现相机空闲状态检测的替代方案,并提供代码示例和详细步骤。

问题分析

OnCameraIdleListener 的移除意味着需要采用新的机制来判断相机是否处于空闲状态。 SDK 10 提供了 MapChangeListener 接口,该接口包含一个 onMapIdle() 回调方法。但根据开发者反馈,onMapIdle() 的触发过于频繁,即便地图和相机没有移动也会被调用,无法满足精确判断相机空闲状态的需求。

主要原因是 onMapIdle() 关注的是整个地图的渲染状态,而不仅仅是相机的移动。因此,我们需要结合其他方法来更准确地检测相机空闲。

解决方案

1. 基于 CameraAnimationsLifecycleListener 和延时机制

Mapbox SDK 10 的 MapboxMap 对象提供了一个 cameraAnimationsLifecycleListener 属性,可以监听相机动画的生命周期。通过监听动画的结束,并在动画结束后设置一个延时,可以有效地判断相机是否空闲。

原理:

当用户停止操作地图或程序化相机动画结束时,cameraAnimationsLifecycleListener 会触发 onAnimationEnd 回调。由于用户操作或动画结束后相机可能仍有微小的惯性移动,设置延时可以过滤掉这些细微的移动,确保相机真正处于静止状态。

步骤:

  1. 实现 CameraAnimationsLifecycleListener 接口。
  2. onAnimationStart 回调中,取消之前的延时任务(如果存在)。
  3. onAnimationEnd 回调中,设置一个延时任务。
  4. 延时任务执行时,触发相机空闲事件。

代码示例:

import android.os.Handler
import android.os.Looper
import com.mapbox.maps.CameraAnimationsLifecycleListener
import com.mapbox.maps.MapboxMap
import java.util.Timer
import java.util.TimerTask

class CameraIdleDetector(private val mapboxMap: MapboxMap, private val idleDelay: Long = 500) {

    private var handler = Handler(Looper.getMainLooper())
    private var idleRunnable: Runnable? = null
    private var cameraIdleListener: (() -> Unit)? = null
    private val cameraAnimationsLifecycleListener = object : CameraAnimationsLifecycleListener {
            override fun onAnimationStart() {
                // 取消之前的延时任务
                idleRunnable?.let { handler.removeCallbacks(it) }
            }

            override fun onAnimationEnd() {
               startIdleTimer()

            }
        }

    init {
        mapboxMap.cameraAnimationsLifecycleListener = cameraAnimationsLifecycleListener
    }

  private fun startIdleTimer() {
        idleRunnable = Runnable {
              cameraIdleListener?.invoke()
          }
        idleRunnable?.let{
             handler.postDelayed(it, idleDelay)
        }
    }

    fun setOnCameraIdleListener(listener: () -> Unit) {
        cameraIdleListener = listener
    }

     fun cancel() {
          mapboxMap.cameraAnimationsLifecycleListener=null
     }
}

使用方法:

  val cameraIdleDetector = CameraIdleDetector(mapboxMap)
        cameraIdleDetector.setOnCameraIdleListener {
           // 相机空闲时的操作,例如加载新的地图数据或更新UI
            Log.i("Camera", "Camera is Idle")
        }

  override fun onDestroy() {
        cameraIdleDetector.cancel()
        super.onDestroy()
   }

安全提示:

  • 延时时间 idleDelay 需要根据实际情况调整,过短可能导致误判,过长则会影响用户体验。
  • CameraIdleDetector 类应该在 ActivityFragmentonDestroy 方法中取消监听,以防止内存泄漏。

2. 利用 MapboxMap.queryRenderedFeatures 进行连续采样

可以通过定时器或 Choreographer 以较高的频率获取相机视野内的特征,并比较连续采样中特征的位置变化。 如果一段时间内特征的位置没有变化或变化小于某个阈值,则认为相机处于空闲状态。

原理:

如果相机持续移动,视野内特征的屏幕坐标也会不断变化。通过连续采样和位置比较,可以判断相机是否静止。

步骤:

  1. 创建一个定时器或使用 Choreographer 来触发采样。
  2. 在每次采样时,使用 MapboxMap.queryRenderedFeatures 获取当前视野内的特征。
  3. 比较当前采样和上一次采样的特征位置。
  4. 如果位置变化小于阈值,则增加计数。
  5. 如果连续多次采样位置变化都小于阈值,则认为相机空闲。

代码示例 (使用Choreographer):

import android.view.Choreographer
import com.mapbox.geojson.Point
import com.mapbox.maps.MapboxMap
import com.mapbox.maps.ScreenCoordinate
import kotlin.math.abs

class ContinuousSamplingCameraIdleDetector(
    private val mapboxMap: MapboxMap,
    private val idleThreshold: Double = 5.0,
    private val idleFrameCount: Int = 3
) {

    private var lastFeaturePositions: MutableList<Point>? = null
    private var idleFrameCounter = 0
    private var cameraIdleListener: (() -> Unit)? = null
     private val choreographerCallback = object : Choreographer.FrameCallback {
                override fun doFrame(frameTimeNanos: Long) {
                   checkCameraIdle()
                    Choreographer.getInstance().postFrameCallback(this)
                 }
     }

    init {
       startSampling()
    }

     private fun startSampling() {
          Choreographer.getInstance().postFrameCallback(choreographerCallback)
     }

    private fun checkCameraIdle() {
            val currentFeaturePositions = mutableListOf<Point>()
             val screenCoordinates=  mutableListOf<ScreenCoordinate>()
              screenCoordinates.add(ScreenCoordinate(mapboxMap.width.toFloat()/2,mapboxMap.height.toFloat()/2 ))
            mapboxMap.queryRenderedFeatures(screenCoordinates).forEach { feature ->
                feature.geometry()?.let { geometry ->
                     if (geometry is Point) {
                         currentFeaturePositions.add(geometry)
                      }

                 }

             }
             if (lastFeaturePositions == null || currentFeaturePositions.isEmpty()) {
                    lastFeaturePositions = currentFeaturePositions
                return
              }
              val averageMovement = calculateAverageMovement(lastFeaturePositions!!,currentFeaturePositions)
                if(averageMovement < idleThreshold){
                 idleFrameCounter++
                   if(idleFrameCounter >=idleFrameCount){
                       cameraIdleListener?.invoke()
                        stopSampling()
                      }

               }
               else{
                 idleFrameCounter=0
              }

            lastFeaturePositions = currentFeaturePositions
         }

      private fun calculateAverageMovement(previousPositions: List<Point>, currentPositions: List<Point>): Double {
          if (previousPositions.isEmpty() || currentPositions.isEmpty()||currentPositions.size!=previousPositions.size) {
                return Double.MAX_VALUE
          }
          var totalMovement=0.0
           for(i in previousPositions.indices){
                val previousPosition = previousPositions[i]
               val currentPosition = currentPositions[i]
              val projectedPoint = mapboxMap.projection.project(currentPosition)
               val previousProjectedPoint = mapboxMap.projection.project(previousPosition)
                totalMovement+= calculateDistance(projectedPoint.x.toDouble(),projectedPoint.y.toDouble(),
                                                previousProjectedPoint.x.toDouble(),previousProjectedPoint.y.toDouble() )
           }
           return totalMovement/currentPositions.size
       }
    private fun calculateDistance(x1:Double,y1:Double,x2:Double,y2:Double):Double{
         return abs(x1-x2)+abs(y1-y2)
      }
    fun setOnCameraIdleListener(listener: () -> Unit) {
        cameraIdleListener = listener
    }

   private fun stopSampling() {
         Choreographer.getInstance().removeFrameCallback(choreographerCallback)
    }
    fun cancel() {
       stopSampling()
    }

}

使用方法:

 val cameraIdleDetector = ContinuousSamplingCameraIdleDetector(mapboxMap)
        cameraIdleDetector.setOnCameraIdleListener {
           // 相机空闲时的操作
            Log.i("Camera", "Camera is Idle")
        }

   override fun onDestroy() {
         cameraIdleDetector.cancel()
       super.onDestroy()
    }

安全提示:

  • `