Calamity
Calamity

Reputation: 1030

In-memory caching on repository level for Kotlin Flows on Android

Suppose you have a list of users downloaded from a remote data source in your Android application, and for some reason you do not have a local DB. This list of users is then used throughout your entire application in multiple ViewModels to make other network requests, so you would surely like to have it cached for as long as the app lives and re-fetch it only on demand. This necessarily means you want to cache it inside the Data Layer, which is a Repository in my case, to then get it from your ViewModels.
It is easy to do in a state holder like a ViewModel - just make a StateFlow or whatever. But what if we want a Flow of List<User> (that is cached in RAM after every API request) available inside a repository to then collect from it from the UI Layer? What is the most testable, stable and right way of achieving this?
My initial idea led to this:

class UsersRepository @Inject constructor(
    private val usersApi: UsersApi,
    private val handler: ResponseHandler
) {

    private val _usersFlow = MutableStateFlow<Resource<List<UserResponse>>>(Resource.Empty)
    val usersFlow = _usersFlow.asStateFlow()

    suspend fun fetchUserList() = withContext(Dispatchers.IO) {
        _usersFlow.emit(Resource.Loading)
        _usersFlow.emit(
            handler {
                usersApi.getUsers()
            }
        )
    }
}

Where ResponseHandler is:

class ResponseHandler {
    suspend operator fun <T> invoke(block: suspend () -> T) = try {
        Resource.Success(block())
    } catch (e: Exception) {
        Log.e(javaClass.name, e.toString())
        val errorCode = when (e) {
            is HttpException -> e.code()
            is SocketTimeoutException -> ErrorCodes.SocketTimeOut.code
            is UnknownHostException -> ErrorCodes.UnknownHost.code
            else -> Int.MAX_VALUE
        }
        Resource.Error(getErrorMessage(errorCode))
    }
}

But while researching I found random guy on the internet telling that it is wrong:

Currently StateFlow is hot in nature so it’s not recommended to use in repository. For cold and reactive stream, you can use flow, channelFlow or callbackFlow in repository.

Is he right? If he is, how exactly do cold flows help in this situation, and how do we properly manage them?

If it helps, my UI Layer is written solely with Jetpack Compose

Upvotes: 10

Views: 7773

Answers (3)

pie
pie

Reputation: 163

Which option did you choose? I also encountered this situation. I use a third-party API that I cannot control. And, for example, I request a list of available devices. The API sends me a list of devices, but does not indicate their status (online, offline). You can make a request to the API for each device (by id). In this case, I get the device state. I decided to make the second request only when the user performs some action with the device (for example, turns it on). As a result, I get a situation where I have a list of devices without a state, and one device with a state. Accordingly, I need to update the list and notify the Ui layer about the change (and, in a good way, block the ability to act on the device). If we implement the cache simply as a list, as is done in the documentation, then when replacing a device in the list, we will not receive the changed data until we explicitly request it from the data layer. At the moment, I keep all the data on the ViewModel layer. For this reason, I have one ViewModel instance for several screens.

Upvotes: 0

Vince
Vince

Reputation: 3084

In the official "Guide to app architecture" from Google for Android:

About the source of true: ✅ The repository can contain an in-memory-cache.

The source of truth can be a data source—for example, the database—or even an in-memory cache that the repository might contain. Repositories combine different data sources and solve any potential conflicts between the data sources to update the single source of truth regularly or due to a user input event.

About the lifecycle: ✅ You can scope an instance of your repository to the Application class (but take care).

If a class contains in-memory data—for example, a cache—you might want to reuse the same instance of that class for a specific period of time. This is also referred to as the lifecycle of the class instance.

If the class's responsibility is crucial for the whole application, you can scope an instance of that class to the Application class. This makes it so the instance follows the application's lifecycle.

About the implementation: I recommend you to check the link directly.

class NewsRepository(
  private val newsRemoteDataSource: NewsRemoteDataSource
) {
    // Mutex to make writes to cached values thread-safe.
    private val latestNewsMutex = Mutex()

    // Cache of the latest news got from the network.
    private var latestNews: List<ArticleHeadline> = emptyList()

    suspend fun getLatestNews(refresh: Boolean = false): List<ArticleHeadline> {
        if (refresh || latestNews.isEmpty()) {
            val networkResult = newsRemoteDataSource.fetchLatestNews()
            // Thread-safe write to latestNews
            latestNewsMutex.withLock {
                this.latestNews = networkResult
            }
        }

        return latestNewsMutex.withLock { this.latestNews }
    }
}

You should read the following page, I think it will answer a lot of your questions : https://developer.android.com/topic/architecture/data-layer

Upvotes: 6

Jakoss
Jakoss

Reputation: 5255

To make this work as a cache you will have to use this repository as a singleton. This effectively create a huge memory leak since you have no control over this memory. You cannot free it, you cannot bypass cache if you want (i mean you can, but it requires additional code outside the flow), you don't have any control over eviction. It's very dumb cache which acts like a memory leak. Not worth it.

Cold flow don't "help" in caching per se. They just give you control over each request that comes from the client. There you can check some outside memory cache if the entry is cached. If yes - is it correct or should be evicted? If it is evicted you can just a normal request. And all this is a single flow that gets disposed right after, so no memory leaks. The only part that have to be singleton is the cache. Although you can implement it as disk cache, it will be faster than network anyway

Upvotes: 1

Related Questions