如何让Kotlin Multiplatform的Ktor Client支持自定义逻辑缓存
Hey there! I’ve dealt with exactly this issue when building a KMP app with Ktor Client—those internal HttpCache classes can be really frustrating when you need custom logic. Here’s how I built a fully custom caching system that doesn’t rely on response headers:
1. Start with a Cross-Platform Cache Interface
First, define a generic cache interface to abstract storage logic across platforms. This keeps your caching rules consistent while letting each platform use its native storage:
// commonMain interface CustomHttpCache { suspend fun get(key: String): ByteArray? suspend fun put(key: String, value: ByteArray, expiresIn: Long? = null) suspend fun invalidate(key: String) suspend fun clear() }
2. Implement Platform-Specific Cache Storage
Use expect/actual to create platform-native implementations. For example:
JVM (with Caffeine)
// jvmMain class JvmCustomHttpCache : CustomHttpCache { private val cache = Caffeine.newBuilder() .expireAfterWrite(1, TimeUnit.HOURS) .build<String, ByteArray>() override suspend fun get(key: String): ByteArray? = cache.getIfPresent(key) override suspend fun put(key: String, value: ByteArray, expiresIn: Long?) { val expiry = expiresIn ?: 3600000 // Default 1 hour cache.put(key, value) // Add custom expiry logic here if needed } override suspend fun invalidate(key: String) = cache.invalidate(key) override suspend fun clear() = cache.invalidateAll() }
iOS (with NSCache)
// iosMain class IosCustomHttpCache : CustomHttpCache { private val cache = NSCache<NSString, NSData>() override suspend fun get(key: String): ByteArray? { return cache.objectForKey(key as NSString)?.bytes?.let { ptr -> ByteArray(ptr.toLong()) { index -> (ptr + index).toByte() } } } override suspend fun put(key: String, value: ByteArray, expiresIn: Long?) { cache.setObject(NSData(bytes = value, length = value.size), forKey = key as NSString) // Add custom expiry cleanup logic here if needed } override suspend fun invalidate(key: String) = cache.removeObjectForKey(key as NSString) override suspend fun clear() = cache.removeAllObjects() }
Platform Factory
Add a factory to get the right cache instance for each platform:
// commonMain expect fun createCustomHttpCache(): CustomHttpCache // jvmMain actual fun createCustomHttpCache(): CustomHttpCache = JvmCustomHttpCache() // iosMain actual fun createCustomHttpCache(): CustomHttpCache = IosCustomHttpCache()
3. Build a Custom Ktor Client Plugin (Interceptor)
Create a Ktor plugin to inject your caching logic into the request/response pipeline. This lets you control exactly when to read from cache and when to store responses:
// commonMain class CustomCachePlugin(private val cache: CustomHttpCache) { companion object Plugin : HttpClientPlugin<CustomCachePlugin, CustomCachePlugin> { override val key: AttributeKey<CustomCachePlugin> = AttributeKey("CustomCachePlugin") override fun prepare(block: CustomCachePlugin.() -> Unit): CustomCachePlugin { return CustomCachePlugin(createCustomHttpCache()).apply(block) } override fun install(plugin: CustomCachePlugin, scope: HttpClient) { // Intercept request to check cache first scope.requestPipeline.intercept(HttpRequestPipeline.Before) { context -> val cacheKey = generateCacheKey(context.request) if (shouldUseCache(context.request)) { plugin.cache.get(cacheKey)?.let { cachedBytes -> // Return cached response instead of hitting the network val cachedResponse = HttpResponse( call = context.call, status = HttpStatusCode.OK, content = ByteReadChannel(cachedBytes), headers = Headers.Empty ) proceedWith(cachedResponse) } } } // Intercept response to store in cache if needed scope.responsePipeline.intercept(HttpResponsePipeline.After) { context -> val cacheKey = generateCacheKey(context.request) if (shouldCacheResponse(context.response)) { val responseBytes = context.response.content.readRemaining().readBytes() // Preserve content for downstream interceptors context.response.content = ByteReadChannel(responseBytes) val expiresIn = getCustomExpiry(context.request) plugin.cache.put(cacheKey, responseBytes, expiresIn) } } } // Generate a unique cache key (customize based on your needs) private fun generateCacheKey(request: HttpRequest): String { return "${request.method.value}-${request.url}-${request.body.hashCode()}" } // Custom rule: when to use cached data private fun shouldUseCache(request: HttpRequest): Boolean { // Example: Only cache GET requests to specific endpoints return request.method == HttpMethod.Get && request.url.path.contains("/api/cacheable") } // Custom rule: when to store the response private fun shouldCacheResponse(response: HttpResponse): Boolean { // Example: Only cache successful responses return response.status.isSuccess() } // Custom expiry logic (pull from request params, config, etc.) private fun getCustomExpiry(request: HttpRequest): Long? { return request.url.parameters["expires_in"]?.toLong() ?: 3600000 } } }
4. Install the Plugin in Your HttpClient
Add the custom cache plugin to your Ktor client setup:
// commonMain val httpClient = HttpClient(OkHttp) { install(CustomCachePlugin) { // Override cache instance if needed cache = createCustomHttpCache() } // Add other plugins (logging, content negotiation, etc.) }
Key Considerations
- Cache Key Uniqueness: Make sure your
generateCacheKeymethod accounts for all request attributes that affect the response (method, URL, request body, headers, etc.). - Response Content Preservation: When reading the response body for caching, reset the content channel so downstream interceptors can still access it (like we did with
context.response.content = ByteReadChannel(responseBytes)). - Cache Invalidation: Add methods to invalidate specific keys (e.g., after a POST/PUT updates data) by calling
cache.invalidate(cacheKey)from your business logic.
内容的提问来源于stack exchange,提问作者Randheer

