|
| 1 | +# Performance Optimization |
| 2 | + |
| 3 | +This document describes the performance optimization strategies implemented in OpenMapView to provide smooth map interaction while minimizing memory usage and network requests. |
| 4 | + |
| 5 | +## Overview |
| 6 | + |
| 7 | +OpenMapView employs a multi-layered approach to performance optimization: |
| 8 | + |
| 9 | +1. **Memory-efficient bitmap decoding** - Reduces memory footprint per tile |
| 10 | +2. **Two-level caching system** - Fast memory cache with persistent disk fallback |
| 11 | +3. **Intelligent tile prefetching** - Anticipates user panning for smoother experience |
| 12 | +4. **Optimized rendering** - Minimizes unnecessary redraws and allocations |
| 13 | + |
| 14 | +## Memory Optimization |
| 15 | + |
| 16 | +### RGB_565 Bitmap Format |
| 17 | + |
| 18 | +Map tiles are decoded using the `RGB_565` bitmap configuration instead of the default `ARGB_8888`: |
| 19 | + |
| 20 | +```kotlin |
| 21 | +val options = BitmapFactory.Options().apply { |
| 22 | + inPreferredConfig = Bitmap.Config.RGB_565 |
| 23 | + inScaled = false |
| 24 | + inDither = false |
| 25 | + inPreferQualityOverSpeed = false |
| 26 | +} |
| 27 | +``` |
| 28 | + |
| 29 | +**Benefits:** |
| 30 | +- 50% memory reduction: 2 bytes per pixel vs 4 bytes for ARGB_8888 |
| 31 | +- Sufficient quality for map tiles (OSM tiles don't use transparency) |
| 32 | +- Allows more tiles to be cached in memory |
| 33 | +- Reduces garbage collection pressure |
| 34 | + |
| 35 | +**Example:** A single 256x256 tile uses 128KB instead of 256KB. |
| 36 | + |
| 37 | +### Dynamic Memory Allocation |
| 38 | + |
| 39 | +The memory cache size is calculated dynamically based on available heap: |
| 40 | + |
| 41 | +```kotlin |
| 42 | +private val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt() |
| 43 | +private val cacheSize = maxMemory / 8 // Use 1/8 of available heap |
| 44 | +``` |
| 45 | + |
| 46 | +This ensures the cache adapts to different device capabilities while avoiding OutOfMemoryErrors. |
| 47 | + |
| 48 | +## Two-Level Caching System |
| 49 | + |
| 50 | +### Architecture |
| 51 | + |
| 52 | +OpenMapView implements a two-tier cache hierarchy: |
| 53 | + |
| 54 | +``` |
| 55 | +┌─────────────────────────────────────────┐ |
| 56 | +│ Tile Request │ |
| 57 | +└─────────────────┬───────────────────────┘ |
| 58 | + │ |
| 59 | + v |
| 60 | +┌─────────────────────────────────────────┐ |
| 61 | +│ Level 1: Memory Cache (LRU) │ |
| 62 | +│ - Fast access (nanoseconds) │ |
| 63 | +│ - ~1/8 of heap memory │ |
| 64 | +│ - Automatically sized │ |
| 65 | +└─────────────────┬───────────────────────┘ |
| 66 | + │ Cache miss |
| 67 | + v |
| 68 | +┌─────────────────────────────────────────┐ |
| 69 | +│ Level 2: Disk Cache (LRU) │ |
| 70 | +│ - Persistent across app restarts │ |
| 71 | +│ - 50MB maximum size │ |
| 72 | +│ - PNG compressed tiles │ |
| 73 | +└─────────────────┬───────────────────────┘ |
| 74 | + │ Cache miss |
| 75 | + v |
| 76 | +┌─────────────────────────────────────────┐ |
| 77 | +│ Level 3: Network Download │ |
| 78 | +│ - OpenStreetMap tile servers │ |
| 79 | +│ - HTTP with Ktor client │ |
| 80 | +└─────────────────────────────────────────┘ |
| 81 | +``` |
| 82 | + |
| 83 | +### Memory Cache (Level 1) |
| 84 | + |
| 85 | +Implemented using Android's `LruCache`: |
| 86 | + |
| 87 | +- **Access time:** Nanoseconds (in-memory lookup) |
| 88 | +- **Size:** Dynamically calculated as 1/8 of max heap |
| 89 | +- **Eviction:** Least Recently Used (LRU) algorithm |
| 90 | +- **Lifecycle:** Cleared when map view is destroyed |
| 91 | + |
| 92 | +When a tile is evicted from memory, it's automatically written to disk: |
| 93 | + |
| 94 | +```kotlin |
| 95 | +override fun entryRemoved( |
| 96 | + evicted: Boolean, |
| 97 | + key: TileCoordinate, |
| 98 | + oldValue: Bitmap, |
| 99 | + newValue: Bitmap?, |
| 100 | +) { |
| 101 | + if (evicted && diskCache != null) { |
| 102 | + CoroutineScope(Dispatchers.IO).launch { |
| 103 | + diskCache?.put(key, oldValue) |
| 104 | + } |
| 105 | + } |
| 106 | +} |
| 107 | +``` |
| 108 | + |
| 109 | +### Disk Cache (Level 2) |
| 110 | + |
| 111 | +Implemented using Jake Wharton's DiskLruCache library: |
| 112 | + |
| 113 | +- **Access time:** Milliseconds (disk I/O) |
| 114 | +- **Size:** 50MB maximum |
| 115 | +- **Format:** PNG compressed images |
| 116 | +- **Location:** App cache directory (`context.cacheDir/tiles`) |
| 117 | +- **Persistence:** Survives app restarts |
| 118 | +- **Eviction:** LRU with size limit |
| 119 | + |
| 120 | +**Benefits:** |
| 121 | +- Reduces network requests on app restart |
| 122 | +- Tiles for frequently visited areas persist |
| 123 | +- System can clear cache when storage is low |
| 124 | + |
| 125 | +### Cache Promotion |
| 126 | + |
| 127 | +When a tile is found in disk cache, it's promoted back to memory cache: |
| 128 | + |
| 129 | +```kotlin |
| 130 | +val diskBitmap = diskCache?.get(tile) |
| 131 | +if (diskBitmap != null) { |
| 132 | + // Promote to memory for faster subsequent access |
| 133 | + memoryCache.put(tile, diskBitmap) |
| 134 | + return diskBitmap |
| 135 | +} |
| 136 | +``` |
| 137 | + |
| 138 | +This ensures frequently accessed tiles migrate to faster storage. |
| 139 | + |
| 140 | +## Intelligent Tile Prefetching |
| 141 | + |
| 142 | +### Strategy |
| 143 | + |
| 144 | +OpenMapView prefetches tiles adjacent to the visible viewport to enable smooth panning: |
| 145 | + |
| 146 | +``` |
| 147 | +┌───────────────────────────────────┐ |
| 148 | +│ Prefetch Buffer (512px / 2 tiles)│ |
| 149 | +│ ┌─────────────────────────────┐ │ |
| 150 | +│ │ │ │ |
| 151 | +│ │ Visible Viewport │ │ |
| 152 | +│ │ │ │ |
| 153 | +│ │ │ │ |
| 154 | +│ └─────────────────────────────┘ │ |
| 155 | +│ │ |
| 156 | +└───────────────────────────────────┘ |
| 157 | +``` |
| 158 | + |
| 159 | +### Implementation |
| 160 | + |
| 161 | +Prefetching is triggered only when the viewport changes: |
| 162 | + |
| 163 | +```kotlin |
| 164 | +if (lastDrawnTiles != visibleTiles.toSet()) { |
| 165 | + prefetchAdjacentTiles(visibleTiles) |
| 166 | + lastDrawnTiles = visibleTiles.toMutableSet() |
| 167 | +} |
| 168 | +``` |
| 169 | + |
| 170 | +**Why this matters:** |
| 171 | +- Prevents continuous downloads during smooth panning |
| 172 | +- Reduces redundant network requests |
| 173 | +- Avoids unnecessary cache lookups |
| 174 | + |
| 175 | +### Low-Priority Downloads |
| 176 | + |
| 177 | +Prefetched tiles are marked as low-priority and don't trigger redraws: |
| 178 | + |
| 179 | +```kotlin |
| 180 | +downloadTile(tile, lowPriority = true) |
| 181 | +``` |
| 182 | + |
| 183 | +In the download handler: |
| 184 | + |
| 185 | +```kotlin |
| 186 | +if (!lowPriority) { |
| 187 | + launch(Dispatchers.Main) { |
| 188 | + onTileLoadedCallback?.invoke() // Trigger redraw |
| 189 | + } |
| 190 | +} |
| 191 | +``` |
| 192 | + |
| 193 | +This ensures: |
| 194 | +- Visible tiles load first and trigger immediate redraws |
| 195 | +- Background prefetch doesn't cause UI jank |
| 196 | +- Main thread is not overwhelmed with invalidate() calls |
| 197 | + |
| 198 | +### Prefetch Buffer Size |
| 199 | + |
| 200 | +The current implementation uses a 2-tile (512px) buffer around the visible area: |
| 201 | + |
| 202 | +```kotlin |
| 203 | +val prefetchTiles = ViewportCalculator.getVisibleTiles( |
| 204 | + center, |
| 205 | + zoom.toInt(), |
| 206 | + viewWidth + 512, // Add 2 tiles horizontally |
| 207 | + viewHeight + 512, // Add 2 tiles vertically |
| 208 | + panOffsetX, |
| 209 | + panOffsetY, |
| 210 | +) |
| 211 | +``` |
| 212 | + |
| 213 | +**Rationale:** |
| 214 | +- Balances network usage vs user experience |
| 215 | +- Most panning gestures stay within 2-tile range |
| 216 | +- Prevents excessive bandwidth consumption |
| 217 | +- Keeps memory footprint reasonable |
| 218 | + |
| 219 | +## Rendering Optimizations |
| 220 | + |
| 221 | +### Minimal Invalidations |
| 222 | + |
| 223 | +The view only invalidates when necessary: |
| 224 | + |
| 225 | +1. **User interactions:** Pan, zoom, marker changes |
| 226 | +2. **Tile downloads complete:** Only for visible (high-priority) tiles |
| 227 | +3. **Explicit API calls:** setCenter(), setZoom(), etc. |
| 228 | + |
| 229 | +Prefetch downloads don't trigger redraws, reducing rendering overhead. |
| 230 | + |
| 231 | +### Efficient Canvas Operations |
| 232 | + |
| 233 | +- Tiles are drawn directly with `canvas.drawBitmap()` without intermediate transformations |
| 234 | +- Placeholder rectangles use simple fill and stroke operations |
| 235 | +- Marker rendering uses pre-cached bitmaps from `MarkerIconFactory` |
| 236 | + |
| 237 | +### Download Deduplication |
| 238 | + |
| 239 | +A set tracks tiles currently being downloaded to prevent duplicate requests: |
| 240 | + |
| 241 | +```kotlin |
| 242 | +if (!downloadingTiles.contains(tile)) { |
| 243 | + downloadingTiles.add(tile) |
| 244 | + downloadTile(tile) |
| 245 | +} |
| 246 | +``` |
| 247 | + |
| 248 | +This prevents the same tile from being downloaded multiple times during rapid view changes. |
| 249 | + |
| 250 | +## Network Optimizations |
| 251 | + |
| 252 | +### HTTP Client Configuration |
| 253 | + |
| 254 | +Ktor client is configured with reasonable timeouts: |
| 255 | + |
| 256 | +```kotlin |
| 257 | +HttpClient(Android) { |
| 258 | + engine { |
| 259 | + connectTimeout = 10_000 // 10 seconds |
| 260 | + socketTimeout = 10_000 // 10 seconds |
| 261 | + } |
| 262 | +} |
| 263 | +``` |
| 264 | + |
| 265 | +### User-Agent Header |
| 266 | + |
| 267 | +All requests include a user-agent header as required by OSM tile usage policy: |
| 268 | + |
| 269 | +```kotlin |
| 270 | +header("User-Agent", "OpenMapView/0.1.0 (https://github.com/afarber/OpenMapView)") |
| 271 | +``` |
| 272 | + |
| 273 | +### Coroutine-Based Downloads |
| 274 | + |
| 275 | +Tile downloads use Kotlin coroutines for efficient async I/O: |
| 276 | + |
| 277 | +```kotlin |
| 278 | +scope.launch(Dispatchers.IO) { |
| 279 | + val bitmap = tileDownloader.downloadTile(url) |
| 280 | + // ... |
| 281 | +} |
| 282 | +``` |
| 283 | + |
| 284 | +**Benefits:** |
| 285 | +- Non-blocking tile downloads |
| 286 | +- Efficient thread pool management |
| 287 | +- Easy cancellation on view destruction |
| 288 | + |
| 289 | +## Memory Leak Prevention |
| 290 | + |
| 291 | +### Lifecycle Management |
| 292 | + |
| 293 | +MapController properly cleans up resources on destruction: |
| 294 | + |
| 295 | +```kotlin |
| 296 | +fun onDestroy() { |
| 297 | + scope.cancel() // Cancel all coroutines |
| 298 | + tileDownloader.close() // Close HTTP client |
| 299 | + tileCache.close() // Close disk cache |
| 300 | + MarkerIconFactory.clearCache() // Clear marker icon cache |
| 301 | +} |
| 302 | +``` |
| 303 | + |
| 304 | +This is called from `OpenMapView.onDestroy(owner: LifecycleOwner)` lifecycle callback. |
| 305 | + |
| 306 | +### Bitmap Management |
| 307 | + |
| 308 | +- Bitmaps are stored in caches, not leaked through closures |
| 309 | +- Old bitmaps are evicted by LRU policy |
| 310 | +- No bitmap references retained after view destruction |
| 311 | + |
| 312 | +## Performance Metrics |
| 313 | + |
| 314 | +### Memory Usage |
| 315 | + |
| 316 | +Typical memory footprint for a visible viewport at zoom level 10: |
| 317 | + |
| 318 | +- **Visible tiles:** ~12 tiles = 1.5MB (with RGB_565) |
| 319 | +- **Prefetch buffer:** ~20 additional tiles = 2.5MB |
| 320 | +- **Total active memory:** ~4MB for tiles |
| 321 | +- **Memory cache capacity:** ~32-64MB depending on device |
| 322 | + |
| 323 | +### Cache Hit Rates |
| 324 | + |
| 325 | +Expected cache hit rates with typical usage: |
| 326 | + |
| 327 | +- **Memory cache:** 80-90% for panning within same area |
| 328 | +- **Disk cache:** 60-70% on app restart |
| 329 | +- **Network downloads:** 10-20% for new areas |
| 330 | + |
| 331 | +### Network Usage |
| 332 | + |
| 333 | +With prefetching enabled: |
| 334 | + |
| 335 | +- **Initial view:** ~12 tile downloads (~400KB) |
| 336 | +- **Pan gesture:** 0-4 tile downloads (~0-150KB) from cache |
| 337 | +- **Zoom change:** 0-20 tile downloads (~0-700KB) depending on zoom delta |
| 338 | + |
| 339 | +## Future Optimizations |
| 340 | + |
| 341 | +Potential areas for further improvement: |
| 342 | + |
| 343 | +1. **Bitmap pooling** - Reuse bitmap objects to reduce allocations |
| 344 | +2. **Tile compression** - Store tiles in WebP format in disk cache |
| 345 | +3. **Vector tiles** - Support vector-based rendering for reduced bandwidth |
| 346 | +4. **Adaptive prefetch** - Adjust buffer size based on panning velocity |
| 347 | +5. **Render throttling** - Limit draw calls during rapid gestures |
| 348 | +6. **Tile source selection** - Support multiple tile providers with fallback |
| 349 | +7. **Offline mode** - Bundle common area tiles in APK |
| 350 | + |
| 351 | +## Benchmarking |
| 352 | + |
| 353 | +To measure performance in your app: |
| 354 | + |
| 355 | +```kotlin |
| 356 | +// Memory usage |
| 357 | +val memoryInfo = ActivityManager.MemoryInfo() |
| 358 | +activityManager.getMemoryInfo(memoryInfo) |
| 359 | + |
| 360 | +// Cache statistics |
| 361 | +val cacheSize = tileCache.size() |
| 362 | +val cacheHits = tileCache.hitCount() |
| 363 | +val cacheMisses = tileCache.missCount() |
| 364 | + |
| 365 | +// Rendering performance |
| 366 | +Choreographer.getInstance().postFrameCallback { frameTimeNanos -> |
| 367 | + // Measure frame time |
| 368 | +} |
| 369 | +``` |
| 370 | + |
| 371 | +## Best Practices |
| 372 | + |
| 373 | +For optimal performance in your app: |
| 374 | + |
| 375 | +1. **Lifecycle integration:** Always call lifecycle methods (onResume, onPause, onDestroy) |
| 376 | +2. **Memory limits:** Monitor memory usage if displaying multiple maps simultaneously |
| 377 | +3. **Cache clearing:** Provide UI to clear cache if storage becomes an issue |
| 378 | +4. **Network awareness:** Consider pausing downloads on metered connections |
| 379 | +5. **Error handling:** Handle network failures gracefully |
| 380 | + |
| 381 | +## References |
| 382 | + |
| 383 | +- [Android Bitmap Best Practices](https://developer.android.com/topic/performance/graphics) |
| 384 | +- [OSM Tile Usage Policy](https://operations.osmfoundation.org/policies/tiles/) |
| 385 | +- [DiskLruCache](https://github.com/JakeWharton/DiskLruCache) |
| 386 | +- [Kotlin Coroutines](https://kotlinlang.org/docs/coroutines-guide.html) |
0 commit comments