Skip to content

Commit 526dcc3

Browse files
committed
Implement prefetching and caching
1 parent e511336 commit 526dcc3

File tree

9 files changed

+760
-38
lines changed

9 files changed

+760
-38
lines changed

docs/PERFORMANCE.md

Lines changed: 386 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,386 @@
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)

openmapview/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ dependencies {
4747
implementation("androidx.core:core-ktx:1.15.0")
4848
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
4949
implementation("io.ktor:ktor-client-android:2.3.7")
50+
implementation("com.jakewharton:disklrucache:2.0.2")
5051

5152
// Unit testing
5253
testImplementation("junit:junit:4.13.2")

0 commit comments

Comments
 (0)