--- name: mapbox-android-patterns description: Integration patterns for Mapbox Maps SDK on Android with Kotlin, Jetpack Compose, lifecycle management, and mobile optimization best practices. --- # Mapbox Android Integration Patterns Official integration patterns for Mapbox Maps SDK on Android. Covers Kotlin, Jetpack Compose, View system, proper lifecycle management, token handling, offline maps, and mobile-specific optimizations. **Use this skill when:** - Setting up Mapbox Maps SDK for Android in a new or existing project - Integrating maps with Jetpack Compose or View system - Implementing proper lifecycle management and cleanup - Managing tokens securely in Android apps - Working with offline maps and caching - Integrating Navigation SDK - Optimizing for battery life and memory usage - Debugging crashes, memory leaks, or performance issues --- ## Core Integration Patterns ### Jetpack Compose Pattern (Modern) **Modern approach using Jetpack Compose and Kotlin** ```kotlin import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.AndroidView import com.mapbox.maps.MapView import com.mapbox.maps.Style import com.mapbox.maps.plugin.animation.camera import com.mapbox.geojson.Point @Composable fun MapboxMap( modifier: Modifier = Modifier, center: Point, zoom: Double, onMapReady: (MapView) -> Unit = {} ) { val mapView = rememberMapViewWithLifecycle() AndroidView( modifier = modifier, factory = { mapView }, update = { view -> // Update camera when state changes view.getMapboxMap().apply { setCamera( CameraOptions.Builder() .center(center) .zoom(zoom) .build() ) } } ) LaunchedEffect(mapView) { mapView.getMapboxMap().loadStyleUri(Style.MAPBOX_STREETS) { onMapReady(mapView) } } } @Composable fun rememberMapViewWithLifecycle(): MapView { val context = LocalContext.current val mapView = remember { MapView(context).apply { id = View.generateViewId() } } // Lifecycle-aware cleanup DisposableEffect(mapView) { onDispose { mapView.onDestroy() } } return mapView } // Usage in Composable @Composable fun MapScreen() { var center by remember { mutableStateOf(Point.fromLngLat(-122.4194, 37.7749)) } var zoom by remember { mutableStateOf(12.0) } MapboxMap( modifier = Modifier.fillMaxSize(), center = center, zoom = zoom, onMapReady = { mapView -> // Add sources and layers } ) } ``` **Key points:** - Use `AndroidView` to integrate MapView in Compose - Use `remember` to preserve MapView across recompositions - Use `DisposableEffect` for proper lifecycle cleanup - Handle state updates in `update` block ### View System Pattern (Classic) **Traditional Android View system with proper lifecycle** ```kotlin import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import com.mapbox.maps.MapView import com.mapbox.maps.Style import com.mapbox.maps.plugin.gestures.addOnMapClickListener import com.mapbox.geojson.Point class MapActivity : AppCompatActivity() { private lateinit var mapView: MapView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_map) mapView = findViewById(R.id.mapView) mapView.getMapboxMap().loadStyleUri(Style.MAPBOX_STREETS) { style -> // Map loaded, add sources and layers setupMap(style) } // Add click listener mapView.getMapboxMap().addOnMapClickListener { point -> handleMapClick(point) true } } private fun setupMap(style: Style) { // Add your custom sources and layers } private fun handleMapClick(point: Point) { // Handle map clicks } // CRITICAL: Lifecycle methods for proper cleanup override fun onStart() { super.onStart() mapView.onStart() } override fun onStop() { super.onStop() mapView.onStop() } override fun onDestroy() { super.onDestroy() mapView.onDestroy() } override fun onLowMemory() { super.onLowMemory() mapView.onLowMemory() } } ``` **XML layout (activity_map.xml):** ```xml ``` **Key points:** - Call `mapView.onStart()`, `onStop()`, `onDestroy()`, `onLowMemory()` in corresponding Activity methods - Wait for style to load before adding layers - Store MapView reference as lateinit var (will be initialized in onCreate) ### Fragment Pattern ```kotlin import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import com.mapbox.maps.MapView import com.mapbox.maps.Style class MapFragment : Fragment() { private var mapView: MapView? = null override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { val view = inflater.inflate(R.layout.fragment_map, container, false) mapView = view.findViewById(R.id.mapView) mapView?.getMapboxMap()?.loadStyleUri(Style.MAPBOX_STREETS) { style -> setupMap(style) } return view } private fun setupMap(style: Style) { // Add sources and layers } override fun onStart() { super.onStart() mapView?.onStart() } override fun onStop() { super.onStop() mapView?.onStop() } override fun onDestroyView() { super.onDestroyView() mapView?.onDestroy() mapView = null } override fun onLowMemory() { super.onLowMemory() mapView?.onLowMemory() } } ``` **Key points:** - Set `mapView` to null in `onDestroyView()` to prevent leaks - Use nullable `mapView?` for safety - Call lifecycle methods appropriately --- ## Token Management ### ✅ Recommended: String Resources with BuildConfig **1. Add to `local.properties` (DO NOT commit):** ```properties # local.properties (add to .gitignore) MAPBOX_ACCESS_TOKEN=pk.your_token_here ``` **2. Configure in `build.gradle.kts` (Module):** ```kotlin android { defaultConfig { // Read from local.properties val properties = Properties() properties.load(project.rootProject.file("local.properties").inputStream()) buildConfigField( "String", "MAPBOX_ACCESS_TOKEN", "\"${properties.getProperty("MAPBOX_ACCESS_TOKEN", "")}\"" ) // Also add to resources for SDK resValue( "string", "mapbox_access_token", properties.getProperty("MAPBOX_ACCESS_TOKEN", "") ) } buildFeatures { buildConfig = true } } ``` **3. Add to `.gitignore`:** ```gitignore local.properties ``` **4. Usage in code:** ```kotlin import com.yourapp.BuildConfig // Access token automatically picked up from resources // No need to set manually if in string resources // Or access programmatically: val token = BuildConfig.MAPBOX_ACCESS_TOKEN ``` **Why this pattern:** - Token not in source code or version control - Works in local development and CI/CD (via environment variables) - Automatically injected at build time - No hardcoded secrets ### ❌ Anti-Pattern: Hardcoded Tokens ```kotlin // ❌ NEVER DO THIS - Token in source code MapboxOptions.accessToken = "pk.YOUR_MAPBOX_TOKEN_HERE" ``` --- ## Memory Management and Lifecycle ### ✅ Proper Lifecycle Management ```kotlin import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import com.mapbox.maps.MapView class MapLifecycleObserver( private val mapView: MapView ) : DefaultLifecycleObserver { override fun onStart(owner: LifecycleOwner) { mapView.onStart() } override fun onStop(owner: LifecycleOwner) { mapView.onStop() } override fun onDestroy(owner: LifecycleOwner) { mapView.onDestroy() } fun onLowMemory() { mapView.onLowMemory() } } // Usage in Activity/Fragment class MapActivity : AppCompatActivity() { private lateinit var mapView: MapView private lateinit var lifecycleObserver: MapLifecycleObserver override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_map) mapView = findViewById(R.id.mapView) lifecycleObserver = MapLifecycleObserver(mapView) // Automatically handle lifecycle lifecycle.addObserver(lifecycleObserver) } override fun onLowMemory() { super.onLowMemory() lifecycleObserver.onLowMemory() } } ``` ### ✅ ViewModel Pattern ```kotlin import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.mapbox.geojson.Point import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch data class MapState( val center: Point = Point.fromLngLat(-122.4194, 37.7749), val zoom: Double = 12.0, val markers: List = emptyList() ) class MapViewModel : ViewModel() { private val _mapState = MutableStateFlow(MapState()) val mapState: StateFlow = _mapState fun updateCenter(point: Point) { _mapState.value = _mapState.value.copy(center = point) } fun addMarker(point: Point) { val currentMarkers = _mapState.value.markers _mapState.value = _mapState.value.copy( markers = currentMarkers + point ) } fun loadData() { viewModelScope.launch { // Load data from repository // Update state when ready } } } ``` **Benefits:** - State survives configuration changes - Separates business logic from UI - Lifecycle-aware - Easy to test --- ## Offline Maps ### Download Region for Offline Use ```kotlin import com.mapbox.maps.TileStore import com.mapbox.maps.TileRegionLoadOptions import com.mapbox.common.TileRegion import com.mapbox.geojson.Point import com.mapbox.bindgen.Expected class OfflineManager(private val context: Context) { private val tileStore = TileStore.create() fun downloadRegion( regionId: String, bounds: CoordinateBounds, minZoom: Int = 0, maxZoom: Int = 16, onProgress: (Float) -> Unit, onComplete: (Result) -> Unit ) { val tilesetDescriptor = tileStore.createDescriptor( TilesetDescriptorOptions.Builder() .styleURI(Style.MAPBOX_STREETS) .minZoom(minZoom.toByte()) .maxZoom(maxZoom.toByte()) .build() ) val loadOptions = TileRegionLoadOptions.Builder() .geometry(bounds.toGeometry()) .descriptors(listOf(tilesetDescriptor)) .acceptExpired(false) .build() val cancelable = tileStore.loadTileRegion( regionId, loadOptions, { progress -> val percent = (progress.completedResourceCount.toFloat() / progress.requiredResourceCount.toFloat()) * 100 onProgress(percent) } ) { expected -> if (expected.isValue) { onComplete(Result.success(Unit)) } else { onComplete(Result.failure(Exception(expected.error?.message))) } } } fun getTileRegions(callback: (List) -> Unit) { tileStore.getAllTileRegions { expected -> if (expected.isValue) { callback(expected.value ?: emptyList()) } else { callback(emptyList()) } } } fun removeTileRegion(regionId: String, callback: (Boolean) -> Unit) { tileStore.removeTileRegion(regionId) callback(true) } fun estimateStorageSize( bounds: CoordinateBounds, minZoom: Int, maxZoom: Int ): Long { // Rough estimate: 50 KB per tile average val tileCount = estimateTileCount(bounds, minZoom, maxZoom) return tileCount * 50_000L // bytes } private fun estimateTileCount( bounds: CoordinateBounds, minZoom: Int, maxZoom: Int ): Long { // Simplified tile count estimation var count = 0L for (zoom in minZoom..maxZoom) { val tilesAtZoom = Math.pow(4.0, zoom.toDouble()).toLong() count += tilesAtZoom } return count } } ``` **Key considerations:** - **Battery impact:** Downloading uses significant battery - **Storage limits:** Monitor available disk space - **Zoom levels:** Higher zoom = more tiles = more storage - **Network type:** WiFi vs cellular ### Check Available Storage ```kotlin import android.os.StatFs import android.os.Environment fun getAvailableStorageBytes(): Long { val stat = StatFs(Environment.getDataDirectory().path) return stat.availableBlocksLong * stat.blockSizeLong } fun hasEnoughStorage(requiredBytes: Long): Boolean { val available = getAvailableStorageBytes() return available > requiredBytes * 2 // 2x buffer } ``` --- ## Navigation SDK Integration ### Basic Navigation Setup ```kotlin import com.mapbox.navigation.core.MapboxNavigation import com.mapbox.navigation.core.MapboxNavigationProvider import com.mapbox.navigation.core.directions.session.RoutesObserver import com.mapbox.navigation.core.trip.session.RouteProgressObserver import com.mapbox.navigation.core.trip.session.TripSessionState import com.mapbox.api.directions.v5.models.DirectionsRoute import com.mapbox.geojson.Point class NavigationActivity : AppCompatActivity() { private lateinit var mapboxNavigation: MapboxNavigation private lateinit var mapView: MapView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_navigation) mapView = findViewById(R.id.mapView) // Initialize Navigation SDK mapboxNavigation = MapboxNavigationProvider.create( NavigationOptions.Builder(this) .accessToken(getString(R.string.mapbox_access_token)) .build() ) setupObservers() } private fun setupObservers() { // Observe route updates mapboxNavigation.registerRoutesObserver(object : RoutesObserver { override fun onRoutesChanged(result: RoutesUpdatedResult) { val routes = result.navigationRoutes if (routes.isNotEmpty()) { // Show route on map showRouteOnMap(routes.first()) } } }) // Observe navigation progress mapboxNavigation.registerRouteProgressObserver(object : RouteProgressObserver { override fun onRouteProgressChanged(routeProgress: RouteProgress) { // Update UI with progress val distanceRemaining = routeProgress.distanceRemaining val durationRemaining = routeProgress.durationRemaining } }) } fun startNavigation(destination: Point) { // Request route val origin = mapboxNavigation.navigationOptions.locationEngine .getLastLocation { location -> location?.let { val originPoint = Point.fromLngLat(it.longitude, it.latitude) requestRoute(originPoint, destination) } } } private fun requestRoute(origin: Point, destination: Point) { val routeOptions = RouteOptions.builder() .applyDefaultNavigationOptions() .coordinates(listOf(origin, destination)) .build() mapboxNavigation.requestRoutes( routeOptions, object : NavigationRouterCallback { override fun onRoutesReady( routes: List, routerOrigin: RouterOrigin ) { mapboxNavigation.setNavigationRoutes(routes) mapboxNavigation.startTripSession() } override fun onFailure( reasons: List, routeOptions: RouteOptions ) { // Handle error } override fun onCanceled( routeOptions: RouteOptions, routerOrigin: RouterOrigin ) { // Handle cancellation } } ) } private fun showRouteOnMap(route: NavigationRoute) { // Draw route on map } override fun onDestroy() { super.onDestroy() mapboxNavigation.onDestroy() } } ``` **Navigation SDK features:** - Turn-by-turn guidance - Voice instructions - Route progress tracking - Rerouting - Traffic-aware routing - Offline navigation (with offline regions) --- ## Mobile Performance Optimization ### Battery Optimization ```kotlin import android.content.Context import android.os.PowerManager class BatteryAwareMapActivity : AppCompatActivity() { private lateinit var mapView: MapView private lateinit var powerManager: PowerManager override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_map) mapView = findViewById(R.id.mapView) powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager observeBatteryState() } private fun observeBatteryState() { if (powerManager.isPowerSaveMode) { enableLowPowerMode() } // Register broadcast receiver for power save mode changes registerReceiver( object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (powerManager.isPowerSaveMode) { enableLowPowerMode() } else { enableNormalMode() } } }, IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED) ) } private fun enableLowPowerMode() { // Reduce frame rate mapView.getMapboxMap().setMaximumFps(30) // Disable 3D features // Reduce tile quality } private fun enableNormalMode() { mapView.getMapboxMap().setMaximumFps(60) } } ``` ### Memory Optimization ```kotlin override fun onLowMemory() { super.onLowMemory() mapView.onLowMemory() // Clear map cache mapView.getMapboxMap().clearData { result -> if (result.isValue) { Log.d("Map", "Cache cleared") } } } override fun onTrimMemory(level: Int) { super.onTrimMemory(level) when (level) { ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW, ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> { // Clear non-essential data mapView.getMapboxMap().clearData { } } } } ``` ### Network Optimization ```kotlin import android.net.ConnectivityManager import android.net.NetworkCapabilities class NetworkAwareMapActivity : AppCompatActivity() { private lateinit var connectivityManager: ConnectivityManager override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager observeNetworkState() } private fun observeNetworkState() { val networkCallback = object : ConnectivityManager.NetworkCallback() { override fun onCapabilitiesChanged( network: Network, capabilities: NetworkCapabilities ) { when { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> { // WiFi - use full quality enableHighQuality() } capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> { // Cellular - reduce data usage enableLowDataMode() } } } } connectivityManager.registerDefaultNetworkCallback(networkCallback) } private fun enableHighQuality() { // Use full resolution tiles } private fun enableLowDataMode() { // Reduce tile resolution // Limit prefetching } } ``` --- ## Common Mistakes and Solutions ### ❌ Mistake 1: Not Calling Lifecycle Methods ```kotlin // ❌ BAD: MapView lifecycle not managed class MapActivity : AppCompatActivity() { private lateinit var mapView: MapView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) mapView = findViewById(R.id.mapView) // No lifecycle methods called! } } // ✅ GOOD: Proper lifecycle management class MapActivity : AppCompatActivity() { private lateinit var mapView: MapView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) mapView = findViewById(R.id.mapView) } override fun onStart() { super.onStart() mapView.onStart() } override fun onStop() { super.onStop() mapView.onStop() } override fun onDestroy() { super.onDestroy() mapView.onDestroy() } override fun onLowMemory() { super.onLowMemory() mapView.onLowMemory() } } ``` ### ❌ Mistake 2: Memory Leaks in Fragments ```kotlin // ❌ BAD: MapView not cleaned up in Fragment class MapFragment : Fragment() { private lateinit var mapView: MapView override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { val view = inflater.inflate(R.layout.fragment_map, container, false) mapView = view.findViewById(R.id.mapView) return view } // No cleanup! } // ✅ GOOD: Proper cleanup class MapFragment : Fragment() { private var mapView: MapView? = null override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { val view = inflater.inflate(R.layout.fragment_map, container, false) mapView = view.findViewById(R.id.mapView) return view } override fun onDestroyView() { super.onDestroyView() mapView?.onDestroy() mapView = null // Prevent leaks } } ``` ### ❌ Mistake 3: Ignoring Location Permissions ```kotlin // ❌ BAD: Enabling location without checking permissions mapView.location.enabled = true // ✅ GOOD: Request and check permissions import androidx.activity.result.contract.ActivityResultContracts class MapActivity : AppCompatActivity() { private val locationPermissionRequest = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { permissions -> when { permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true -> { enableLocationTracking() } permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true -> { enableLocationTracking() } else -> { // Handle denied } } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) requestLocationPermissions() } private fun requestLocationPermissions() { when { ContextCompat.checkSelfPermission( this, Manifest.permission.ACCESS_FINE_LOCATION ) == PackageManager.PERMISSION_GRANTED -> { enableLocationTracking() } else -> { locationPermissionRequest.launch( arrayOf( Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION ) ) } } } private fun enableLocationTracking() { mapView.location.enabled = true } } ``` **Add to AndroidManifest.xml:** ```xml ``` ### ❌ Mistake 4: Adding Layers Before Map Loads ```kotlin // ❌ BAD: Adding layers immediately override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) mapView = findViewById(R.id.mapView) addCustomLayers() // Map not loaded yet! } // ✅ GOOD: Wait for style to load override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) mapView = findViewById(R.id.mapView) mapView.getMapboxMap().loadStyleUri(Style.MAPBOX_STREETS) { style -> addCustomLayers(style) } } ``` --- ## Testing Patterns ### Unit Testing Map Logic ```kotlin import org.junit.Test import org.junit.Assert.* import com.mapbox.geojson.Point class MapLogicTest { @Test fun testCoordinateConversion() { val point = Point.fromLngLat(-122.4194, 37.7749) // Test your map logic without creating actual MapView val converted = MapLogic.convert(point) assertEquals(-122.4194, converted.longitude(), 0.001) assertEquals(37.7749, converted.latitude(), 0.001) } } ``` ### Instrumented Testing with Maps ```kotlin import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class MapActivityTest { @get:Rule val activityRule = ActivityScenarioRule(MapActivity::class.java) @Test fun testMapLoads() { activityRule.scenario.onActivity { activity -> val mapView = activity.findViewById(R.id.mapView) assertNotNull(mapView) } } } ``` --- ## Troubleshooting ### Map Not Displaying **Checklist:** 1. ✅ Token configured in string resources? 2. ✅ Correct package name in token restrictions? 3. ✅ MapboxMaps dependency added to build.gradle? 4. ✅ MapView lifecycle methods called? 5. ✅ Internet permission in AndroidManifest.xml? ```xml ``` ### Memory Leaks **Use Android Studio Profiler:** 1. Run → Profile 'app' → Memory 2. Look for MapView instances not being garbage collected 3. Ensure `mapView.onDestroy()` is called 4. Set `mapView = null` in Fragments after destroy ### Slow Performance **Common causes:** - Too many markers (use clustering or symbols) - Large GeoJSON sources (use vector tiles) - Not handling lifecycle properly - Not calling `onLowMemory()` - Running on emulator (use device for accurate testing) --- ## Platform-Specific Considerations ### Android Version Support - **Android 6.0+ (API 23+)**: Minimum supported version - **Android 12+ (API 31+)**: New permission handling - **Android 13+ (API 33+)**: Runtime notification permissions ### Device Optimization ```kotlin import android.app.ActivityManager import android.content.Context fun isLowRamDevice(): Boolean { val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager return activityManager.isLowRamDevice } // Adjust map quality based on device if (isLowRamDevice()) { // Reduce detail, limit features } ``` ### Screen Density ```kotlin val density = resources.displayMetrics.density when { density >= 4.0 -> { // xxxhdpi displays // Use highest quality } density >= 3.0 -> { // xxhdpi displays // High quality } density >= 2.0 -> { // xhdpi displays // Standard quality } } ``` --- ## Reference - [Mapbox Maps SDK for Android](https://docs.mapbox.com/android/maps/guides/) - [API Reference](https://docs.mapbox.com/android/maps/api-reference/) - [Examples](https://docs.mapbox.com/android/maps/examples/) - [Navigation SDK](https://docs.mapbox.com/android/navigation/guides/) - [Gradle Installation](https://docs.mapbox.com/android/maps/guides/install/) - [Migration Guides](https://docs.mapbox.com/android/maps/guides/migrate-to-v10/)