Layout Fetcher and Caching
The Compose Remote Layout Router uses the LayoutFetcher
interface to retrieve layout JSON from
various sources. This component is crucial for performance and offline functionality.
LayoutFetcher Interface
All layout fetchers implement this interface:
interface LayoutFetcher {
suspend fun fetchLayout(url: String): Result<String>
fun fetchLayoutAsFlow(url: String): Flow<ResultLayout<String>>
}
This interface defines two methods:
fetchLayout
: Simple suspend function that returns a ResultfetchLayoutAsFlow
: Returns a Flow with loading states, useful for UI feedback
Built-in Fetchers
KtorHttpLayoutFetcher
The primary fetcher uses Ktor HTTP Client to retrieve layouts from remote endpoints:
// Create with default settings
val fetcher = KtorHttpLayoutFetcher()
// Create with custom Ktor client
val client = HttpClient {
install(Logging) {
level = LogLevel.HEADERS
}
install(HttpCache) // Built-in HTTP caching
install(HttpTimeout) {
requestTimeoutMillis = 10000 // 10 seconds
}
}
val customFetcher = KtorHttpLayoutFetcher(client)
The default implementation includes:
- HTTP request logging
- Basic HTTP caching
- Standard error handling
CachedKtorLayoutFetcher
This wrapper adds in-memory caching capabilities to any LayoutFetcher
:
// Create with default settings
val cachedFetcher = ktorFetcher.cached()
// Create with custom settings
val customCachedFetcher = ktorFetcher.cached(
maxCacheSize = 100, // Cache up to 100 layouts
cacheTtlMillis = 15.minutes.inWholeMilliseconds // Cache for 15 minutes
)
Caching Configuration
Cache Size
Control how many layouts are stored in memory:
// Small cache for memory-constrained devices
val smallCache = fetcher.cached(maxCacheSize = 20)
// Large cache for complex apps with many screens
val largeCache = fetcher.cached(maxCacheSize = 200)
Cache TTL (Time-To-Live)
Control how long layouts are considered valid:
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.days
// Short TTL for frequently changing content
val shortTtlCache = fetcher.cached(cacheTtlMillis = 5.minutes.inWholeMilliseconds)
// Medium TTL for normal content
val mediumTtlCache = fetcher.cached(cacheTtlMillis = 1.hours.inWholeMilliseconds)
// Long TTL for static content
val longTtlCache = fetcher.cached(cacheTtlMillis = 1.days.inWholeMilliseconds)
Cache Management
The cache can be manually managed:
// Clear the entire cache
cachedFetcher.clearCache()
// Remove a specific URL from cache
cachedFetcher.invalidateCache("https://api.example.com/layouts/home")
Use cases for manual cache management:
- When a user logs out (clear all)
- After submitting a form that changes data (invalidate specific URLs)
- After app updates (clear all)
- When forcing a refresh (invalidate specific URL)
Cache Implementation Details
The CachedKtorLayoutFetcher
provides:
- Thread-safe operations using Mutex
- LRU eviction policy (Least Recently Used)
- TTL-based expiration (Time To Live)
- Transparent operation - falls back to network when cache misses
The cache flow works as follows:
- Check if URL exists in cache and is not expired
- If found and valid, return cached layout
- If not found or expired, fetch from network
- If network fetch succeeds, store in cache
- Return the layout
Combined HTTP and Memory Caching
For optimal performance, use both HTTP caching and memory caching:
// Create HTTP client with caching
val client = HttpClient {
install(HttpCache) // HTTP-level caching
}
// Create fetcher with HTTP cache
val httpCachedFetcher = KtorHttpLayoutFetcher(client)
// Add memory caching layer
val fullyCachedFetcher = httpCachedFetcher.cached()
This provides multiple caching layers:
- HTTP Cache: Handles conditional requests, ETags, etc.
- Memory Cache: Provides fastest access without network
Sample Usage Scenarios
Basic Network with Caching
val router = ResultRouterFactory().createRouter(
scope = coroutineScope,
fetcher = KtorHttpLayoutFetcher().cached(),
baseUrl = "https://api.example.com/layouts"
)
Different Cache Settings for Different Content
// Create different caches for different content types
val staticFetcher = KtorHttpLayoutFetcher().cached(
cacheTtlMillis = 24.hours.inWholeMilliseconds
)
val dynamicFetcher = KtorHttpLayoutFetcher().cached(
cacheTtlMillis = 5.minutes.inWholeMilliseconds
)
// Select fetcher based on URL
val compositeFetcher = object : LayoutFetcher {
override suspend fun fetchLayout(url: String): Result<String> {
return if (url.contains("/static/")) {
staticFetcher.fetchLayout(url)
} else {
dynamicFetcher.fetchLayout(url)
}
}
override fun fetchLayoutAsFlow(url: String): Flow<ResultLayout<String>> {
return if (url.contains("/static/")) {
staticFetcher.fetchLayoutAsFlow(url)
} else {
dynamicFetcher.fetchLayoutAsFlow(url)
}
}
}
val router = ResultRouterFactory().createRouter(
scope = coroutineScope,
fetcher = compositeFetcher,
baseUrl = "https://api.example.com/layouts"
)
Cache Refresh on User Action
@Composable
fun ProductScreen(productId: String, fetcher: CachedKtorLayoutFetcher) {
val router = rememberRemoteRouter()
// Function to refresh product data
val refreshProduct = {
// Invalidate all related product caches
fetcher.invalidateCache("https://api.example.com/layouts/product/$productId")
fetcher.invalidateCache("https://api.example.com/layouts/product/$productId/reviews")
// Reload current screen
router.reload()
}
// Pull-to-refresh implementation
SwipeRefresh(
state = rememberSwipeRefreshState(isRefreshing = false),
onRefresh = { refreshProduct() }
) {
ComposeRemoteRouter(
initialPath = "/product/$productId",
router = router
) { renderEvent ->
// Render content
}
}
// Additional refresh button
FloatingActionButton(onClick = { refreshProduct() }) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
}
Best Practices
1. Always Use Caching
// Don't do this in production
val fetcher = KtorHttpLayoutFetcher() // No caching
// Do this instead
val cachedFetcher = KtorHttpLayoutFetcher().cached()
2. Tailor Cache TTL to Content Type
// Static content (rarely changes)
val staticFetcher = KtorHttpLayoutFetcher().cached(
cacheTtlMillis = 24.hours.inWholeMilliseconds
)
// Dynamic content (changes frequently)
val dynamicFetcher = KtorHttpLayoutFetcher().cached(
cacheTtlMillis = 5.minutes.inWholeMilliseconds
)
3. Handle Network Errors Gracefully
ComposeRemoteRouter(
initialPath = "/home",
router = router,
) { renderEvent ->
when (renderEvent) {
is RenderEvent.Loading -> {
LoadingIndicator()
}
is RenderEvent.Failure -> {
if (renderEvent.error is IOException) {
// Network error - show offline message with retry button
OfflineMessage(
onRetry = { router.reload() }
)
} else {
// Other error
ErrorMessage(renderEvent.error.message ?: "Unknown error")
}
}
is RenderEvent.RenderedLayout -> {
DynamicLayout(
component = renderEvent.component,
bindValue = renderEvent.bindsValue,
onClickHandler = renderEvent.clickEvent
)
}
}
}
4. Clear Cache When Appropriate
// When user logs out
fun logout() {
// Clear all user data
userPreferences.clear()
// Clear layout cache to avoid showing personalized content
cachedFetcher.clearCache()
// Navigate to login
router.clearHistory()
router.pushPath("/login")
}
5. Monitor Cache Performance
// Add cache hit/miss monitoring
var cacheHits = 0
var cacheMisses = 0
val monitoredFetcher = object : LayoutFetcher {
override suspend fun fetchLayout(url: String): Result<String> {
val startTime = System.currentTimeMillis()
val result = cachedFetcher.fetchLayout(url)
val duration = System.currentTimeMillis() - startTime
// Guess if it was a cache hit based on response time
if (duration < 50) {
cacheHits++
} else {
cacheMisses++
}
// Log some stats
println("Cache hit rate: ${cacheHits.toFloat() / (cacheHits + cacheMisses)}")
return result
}
override fun fetchLayoutAsFlow(url: String): Flow<ResultLayout<String>> {
return cachedFetcher.fetchLayoutAsFlow(url)
}
}