You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何让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 generateCacheKey method 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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.14 09:17:01