How the Trending Feature Fetches and Caches Trending Repositories in GSYGitHubAppCompose

The trending feature implements a cache-first, network-refresh pattern using Kotlin Flow, first emitting cached Room database entries via TrendingDao, then fetching fresh data from a custom GitHub trending endpoint and updating the cache.

The GSYGitHubAppCompose project demonstrates modern Android architecture by separating data concerns into distinct layers. This article examines how the TrendingRepository orchestrates local persistence and remote API calls to deliver trending repositories with minimal latency and maximum freshness.

The Three-Step Cache-First Architecture

The trending data flow follows a predictable cache-first, network-refresh sequence implemented as a cold Kotlin Flow. This approach ensures users see content immediately while guaranteeing data stays current.

The flow executes these steps in TrendingRepository.kt:

  1. Query Local Storage – The repository first calls trendingDao.getAllTrendingRepos() to retrieve any persisted TrendingEntity rows from Room.
  2. Emit Cached Snapshot – If the database contains data, entities convert to TrendingRepoModel objects via TrendingEntity.toTrendingRepoModel() and emit as a RepositoryResult marked with DataSource.CACHE.
  3. Refresh from Network – The repository queries GitHubApiService.getTrendingRepos(), clears old cache entries with trendingDao.clearTrendingRepos(), inserts fresh data, and emits a second RepositoryResult marked with DataSource.NETWORK.

Implementation in TrendingRepository.kt

All coordination logic lives in TrendingRepository.kt within the data module. The getTrendingRepositories() function returns a Flow<RepositoryResult<List<TrendingRepoModel>>> that consumers collect to receive sequential data updates.

fun getTrendingRepositories(
    since: String,
    languageType: String?
): Flow<RepositoryResult<List<TrendingRepoModel>>> = flow {
    // 1️⃣ Load cached data immediately
    val cached = trendingDao.getAllTrendingRepos()
    val dbEmpty = cached.isEmpty()
    if (!dbEmpty) {
        emit(
            RepositoryResult(
                data = Result.success(cached.map { it.toTrendingRepoModel() }),
                dataSource = DataSource.CACHE,
                isDbEmpty = false
            )
        )
    }

    // 2️⃣ Fetch fresh data from remote service
    try {
        val response = apiService.getTrendingRepos(since, languageType)

        // 3️⃣ Atomically replace cache contents
        trendingDao.clearTrendingRepos()
        trendingDao.insertAll(response.map { it.toTrendingEntity() })

        // 4️⃣ Emit network result
        emit(
            RepositoryResult(
                data = Result.success(response),
                dataSource = DataSource.NETWORK,
                isDbEmpty = dbEmpty
            )
        )
    } catch (e: Exception) {
        emit(RepositoryResult(Result.failure(e), DataSource.NETWORK, isDbEmpty = dbEmpty))
    }
}

The cold flow behavior guarantees no database queries or network requests execute until a consumer begins collection. This prevents unnecessary resource consumption when the trending screen is not active.

Data Layer Components

TrendingDao.kt (Room Persistence)

The TrendingDao interface in core/database/src/main/java/com/shuyu/gsygithubappcompose/core/database/dao/TrendingDao.kt defines the storage contract:

  • getAllTrendingRepos() – Retrieves all cached trending entities
  • clearTrendingRepos() – Deletes existing rows before inserting fresh data
  • insertAll() – Persists new network responses as TrendingEntity objects

GitHubApiService.kt (Network Layer)

Remote data originates from a custom trending aggregation endpoint rather than the standard GitHub API. The GitHubApiService defines the Retrofit call at core/network/src/main/java/com/shuyu/gsygithubappcompose/core/network/api/GitHubApiService.kt:

@GET("https://guoshuyu.cn/github/trend/list")
suspend fun getTrendingRepos(
    @Query("since") since: String,
    @Query("languageType") languageType: String?
): List<TrendingRepoModel>

DataMappers.kt (Type Conversion)

Bidirectional mapping between network models and database entities occurs in data/src/main/java/com/shuyu/gsygithubappcompose/data/repository/mapper/DataMappers.kt:

  • TrendingEntity.toTrendingRepoModel() – Converts database rows to display models for UI consumption
  • TrendingRepoModel.toTrendingEntity() – Transforms API responses into storable entities

RepositoryResult.kt (Source Tracking)

The RepositoryResult wrapper class tracks metadata about each emission:

  • dataSource – Enum indicating CACHE or NETWORK origin
  • isDbEmpty – Boolean flag signaling whether the local database was initially empty
  • dataResult<T> containing the actual payload or exception

This metadata enables the UI layer to render appropriate loading indicators or error states based on data provenance.

ViewModel Collection Pattern

The UI layer typically consumes the trending flow within a ViewModel using viewModelScope:

class TrendingViewModel @Inject constructor(
    private val trendingRepository: TrendingRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow<TrendingUiState>(TrendingUiState.Loading)
    val uiState: StateFlow<TrendingUiState> = _uiState

    fun loadTrending(since: String = "daily", language: String? = null) {
        viewModelScope.launch {
            trendingRepository.getTrendingRepositories(since, language)
                .collect { result ->
                    when (result.dataSource) {
                        DataSource.CACHE -> {
                            // Show cached data immediately
                            _uiState.value = TrendingUiState.Success(result.data.getOrThrow())
                        }
                        DataSource.NETWORK -> {
                            // Update with fresh data
                            _uiState.value = TrendingUiState.Success(result.data.getOrThrow())
                        }
                    }
                }
        }
    }
}

Compose UI Integration

The composable screen observes the StateFlow and reacts to emissions:

@Composable
fun TrendingScreen(viewModel: TrendingViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsState()

    when (uiState) {
        is TrendingUiState.Loading -> CircularProgressIndicator()
        is TrendingUiState.Success -> TrendingList((uiState as TrendingUiState.Success).repos)
        is TrendingUiState.Error -> Text("Error loading trending repos")
    }
}

Manual Cache Management

For scenarios requiring explicit cache invalidation, developers can access the DAO directly:

suspend fun clearTrendingCache(trendingDao: TrendingDao) {
    trendingDao.clearTrendingRepos()
}

Summary

  • Cache-first architecture ensures immediate UI rendering by querying TrendingDao before network requests
  • Dual emission strategy provides both cached and network data through a single Kotlin Flow collection
  • Atomic cache replacement clears old entries via clearTrendingRepos() before inserting fresh data to prevent stale content accumulation
  • Source transparency via RepositoryResult allows the UI to distinguish between cached and live data for appropriate visual feedback
  • Cold flow implementation prevents unnecessary database and network operations when no observers are active

Frequently Asked Questions

When GitHubApiService.getTrendingRepos() throws an exception, the flow catches the error and emits a RepositoryResult containing the failure in the Result.failure() wrapper with DataSource.NETWORK specified. If cached data was already emitted, the user retains stale content; if the database was empty, the UI receives an empty error state signified by isDbEmpty = true.

Why does the repository use a custom endpoint instead of GitHub's official API?

The https://guoshuyu.cn/github/trend/list endpoint aggregates and processes GitHub's trending data independently, providing a curated list that handles rate limiting and trending calculation logic server-side. This approach reduces client complexity and avoids GitHub API authentication requirements for public trending data.

Can I modify the cache expiration behavior?

The current implementation in TrendingRepository.kt does not implement time-based expiration; it performs a complete replacement on every network fetch. To add expiration logic, you would need to store timestamps in TrendingEntity and modify the repository to conditionally skip network requests based on age calculations before calling getTrendingRepos().

What is the benefit of using Kotlin Flow instead of LiveData for this feature?

Kotlin Flow provides superior compositional capabilities and lifecycle awareness through coroutines. The cold flow nature ensures database queries only execute during active collection, and the sequential emission pattern (cache then network) maps naturally to Flow's reactive stream model, whereas LiveData would require manual orchestration to achieve the same dual-emission behavior.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →