This document describes the performance optimization strategies implemented in OpenMapView to provide smooth map interaction while minimizing memory usage and network requests.
OpenMapView employs a multi-layered approach to performance optimization:
- Memory-efficient bitmap decoding - Reduces memory footprint per tile
- Two-level caching system - Fast memory cache with persistent disk fallback
- Intelligent tile prefetching - Anticipates user panning for smoother experience
- Optimized rendering - Minimizes unnecessary redraws and allocations
Map tiles are decoded using the RGB_565 bitmap configuration instead of the default ARGB_8888:
val options = BitmapFactory.Options().apply {
inPreferredConfig = Bitmap.Config.RGB_565
inScaled = false
inDither = false
inPreferQualityOverSpeed = false
}Benefits:
- 50% memory reduction: 2 bytes per pixel vs 4 bytes for ARGB_8888
- Sufficient quality for map tiles (OSM tiles don't use transparency)
- Allows more tiles to be cached in memory
- Reduces garbage collection pressure
Example: A single 256x256 tile uses 128KB instead of 256KB.
The memory cache size is calculated dynamically based on available heap:
private val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
private val cacheSize = maxMemory / 8 // Use 1/8 of available heapThis ensures the cache adapts to different device capabilities while avoiding OutOfMemoryErrors.
OpenMapView implements a two-tier cache hierarchy:
┌─────────────────────────────────────────┐
│ Tile Request │
└─────────────────┬───────────────────────┘
│
v
┌─────────────────────────────────────────┐
│ Level 1: Memory Cache (LRU) │
│ - Fast access (nanoseconds) │
│ - ~1/8 of heap memory │
│ - Automatically sized │
└─────────────────┬───────────────────────┘
│ Cache miss
v
┌─────────────────────────────────────────┐
│ Level 2: Disk Cache (LRU) │
│ - Persistent across app restarts │
│ - 50MB maximum size │
│ - PNG compressed tiles │
└─────────────────┬───────────────────────┘
│ Cache miss
v
┌─────────────────────────────────────────┐
│ Level 3: Network Download │
│ - OpenStreetMap tile servers │
│ - HTTP with Ktor client │
└─────────────────────────────────────────┘
Implemented using Android's LruCache:
- Access time: Nanoseconds (in-memory lookup)
- Size: Dynamically calculated as 1/8 of max heap
- Eviction: Least Recently Used (LRU) algorithm
- Lifecycle: Cleared when map view is destroyed
When a tile is evicted from memory, it's automatically written to disk:
override fun entryRemoved(
evicted: Boolean,
key: TileCoordinate,
oldValue: Bitmap,
newValue: Bitmap?,
) {
if (evicted && diskCache != null) {
CoroutineScope(Dispatchers.IO).launch {
diskCache?.put(key, oldValue)
}
}
}Implemented using Jake Wharton's DiskLruCache library:
- Access time: Milliseconds (disk I/O)
- Size: 50MB maximum
- Format: PNG compressed images
- Location: App cache directory (
context.cacheDir/tiles) - Persistence: Survives app restarts
- Eviction: LRU with size limit
Benefits:
- Reduces network requests on app restart
- Tiles for frequently visited areas persist
- System can clear cache when storage is low
When a tile is found in disk cache, it's promoted back to memory cache:
val diskBitmap = diskCache?.get(tile)
if (diskBitmap != null) {
// Promote to memory for faster subsequent access
memoryCache.put(tile, diskBitmap)
return diskBitmap
}This ensures frequently accessed tiles migrate to faster storage.
OpenMapView prefetches tiles adjacent to the visible viewport to enable smooth panning:
┌───────────────────────────────────┐
│ Prefetch Buffer (512px / 2 tiles)│
│ ┌─────────────────────────────┐ │
│ │ │ │
│ │ Visible Viewport │ │
│ │ │ │
│ │ │ │
│ └─────────────────────────────┘ │
│ │
└───────────────────────────────────┘
Prefetching is triggered only when the viewport changes:
if (lastDrawnTiles != visibleTiles.toSet()) {
prefetchAdjacentTiles(visibleTiles)
lastDrawnTiles = visibleTiles.toMutableSet()
}Why this matters:
- Prevents continuous downloads during smooth panning
- Reduces redundant network requests
- Avoids unnecessary cache lookups
Prefetched tiles are marked as low-priority and don't trigger redraws:
downloadTile(tile, lowPriority = true)In the download handler:
if (!lowPriority) {
launch(Dispatchers.Main) {
onTileLoadedCallback?.invoke() // Trigger redraw
}
}This ensures:
- Visible tiles load first and trigger immediate redraws
- Background prefetch doesn't cause UI jank
- Main thread is not overwhelmed with invalidate() calls
The current implementation uses a 2-tile (512px) buffer around the visible area:
val prefetchTiles = ViewportCalculator.getVisibleTiles(
center,
zoom.toInt(),
viewWidth + 512, // Add 2 tiles horizontally
viewHeight + 512, // Add 2 tiles vertically
panOffsetX,
panOffsetY,
)Rationale:
- Balances network usage vs user experience
- Most panning gestures stay within 2-tile range
- Prevents excessive bandwidth consumption
- Keeps memory footprint reasonable
The view only invalidates when necessary:
- User interactions: Pan, zoom, marker changes
- Tile downloads complete: Only for visible (high-priority) tiles
- Explicit API calls: setCenter(), setZoom(), etc.
Prefetch downloads don't trigger redraws, reducing rendering overhead.
- Tiles are drawn directly with
canvas.drawBitmap()without intermediate transformations - Placeholder rectangles use simple fill and stroke operations
- Marker rendering uses pre-cached bitmaps from
MarkerIconFactory
A set tracks tiles currently being downloaded to prevent duplicate requests:
if (!downloadingTiles.contains(tile)) {
downloadingTiles.add(tile)
downloadTile(tile)
}This prevents the same tile from being downloaded multiple times during rapid view changes.
Ktor client is configured with reasonable timeouts:
HttpClient(Android) {
engine {
connectTimeout = 10_000 // 10 seconds
socketTimeout = 10_000 // 10 seconds
}
}All requests include a user-agent header as required by OSM tile usage policy:
header("User-Agent", "OpenMapView/0.13.1 (https://github.com/afarber/OpenMapView)")Tile downloads use Kotlin coroutines for efficient async I/O:
scope.launch(Dispatchers.IO) {
val bitmap = tileDownloader.downloadTile(url)
// ...
}Benefits:
- Non-blocking tile downloads
- Efficient thread pool management
- Easy cancellation on view destruction
MapController properly cleans up resources on destruction:
fun onDestroy() {
scope.cancel() // Cancel all coroutines
tileDownloader.close() // Close HTTP client
tileCache.close() // Close disk cache
MarkerIconFactory.clearCache() // Clear marker icon cache
}This is called from OpenMapView.onDestroy(owner: LifecycleOwner) lifecycle callback.
- Bitmaps are stored in caches, not leaked through closures
- Old bitmaps are evicted by LRU policy
- No bitmap references retained after view destruction
Typical memory footprint for a visible viewport at zoom level 10:
- Visible tiles: ~12 tiles = 1.5MB (with RGB_565)
- Prefetch buffer: ~20 additional tiles = 2.5MB
- Total active memory: ~4MB for tiles
- Memory cache capacity: ~32-64MB depending on device
Expected cache hit rates with typical usage:
- Memory cache: 80-90% for panning within same area
- Disk cache: 60-70% on app restart
- Network downloads: 10-20% for new areas
With prefetching enabled:
- Initial view: ~12 tile downloads (~400KB)
- Pan gesture: 0-4 tile downloads (~0-150KB) from cache
- Zoom change: 0-20 tile downloads (~0-700KB) depending on zoom delta
To measure performance in your app:
// Memory usage
val memoryInfo = ActivityManager.MemoryInfo()
activityManager.getMemoryInfo(memoryInfo)
// Cache statistics
val cacheSize = tileCache.size()
val cacheHits = tileCache.hitCount()
val cacheMisses = tileCache.missCount()
// Rendering performance
Choreographer.getInstance().postFrameCallback { frameTimeNanos ->
// Measure frame time
}For optimal performance:
- Lifecycle integration: Register OpenMapView with lifecycle observer (see Lifecycle Management)
- Memory limits: Monitor memory usage if displaying multiple maps simultaneously
- Cache clearing: Provide UI to clear cache if storage becomes an issue
- Network awareness: Consider pausing downloads on metered connections
- Error handling: Handle network failures gracefully