The Skeleton of Android Project Structure

Building an Android app can feel like trying to assemble a puzzle where the pieces are constantly updating. To get a clean, modern start, you’ll want to follow the Recommended App Architecture (MVVM) and use Jetpack Compose.

Here is your skeleton for a robust Android project.


1. Installation & Environment Setup:

Before writing code, ensure your workstation is ready:

  1. Download Android Studio: Install the latest stable version (e.g., Ladybug or newer).

  2. JDK: Android Studio comes bundled with a compatible JDK (usually Java 17 or 21), which is recommended for modern Gradle builds.

  3. SDK Platforms: Open the SDK Manager and install the latest API level (e.g., API 34 or 35).

  4. Emulators: Create a Virtual Device (AVD) via the Device Manager for testing.

2. Project Folder Structure:

A clean project separates concerns. The structure below follows the Feature-based layer approach:

Plaintext

app/
├── libs/                         # External .jar or .aar files
├── src/
│   ├── main/
│   │   ├── java/com/example/app/  # Source code
│   │   │   ├── data/             # Repositories, APIs, Room DB
│   │   │   ├── di/               # Dependency Injection (Hilt/Koin)
│   │   │   ├── ui/               # UI Layer (Compose Screens)
│   │   │   │   ├── theme/        # Color, Type, Shape definitions
│   │   │   │   └── main/         # Specific feature (Main Screen)
│   │   │   └── util/             # Helpers and Extensions
│   │   ├── res/                  # Resources (XML, Drawables, Strings)
│   │   └── AndroidManifest.xml   # App manifest
│   └── test/                     # Local Unit Tests
└── build.gradle.kts              # Module-level build script

3. Key Configuration Files:

Modern Android projects use Kotlin DSL (.gradle.kts) and Version Catalogs for dependency management.

Root build.gradle.kts

This file defines the plugins used across all modules.

Kotlin

plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.kotlin.android) apply false
    alias(libs.plugins.hilt.android) apply false
}

Module app/build.gradle.kts

This is where you configure your app's "identity."

Kotlin

android {
    namespace = "com.example.myapp"
    compileSdk = 34

    defaultConfig {
        applicationId = "com.example.myapp"
        minSdk = 24
        targetSdk = 34
        versionCode = 1
        versionName = "1.0"
    }

    buildFeatures {
        compose = true
    }
    
    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.1"
    }
}

dependencies {
    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.compose.ui)
    implementation(libs.hilt.android)
    // Add other libraries here
}

4. The "Skeleton" Components:

To make this a working skeleton, every app needs these three pillars:

Component Responsibility
MainActivity.kt The entry point that hosts your Compose UI.
NavGraph.kt Handles transitions between screens.
AppModule.kt Provides dependencies (Retrofit, Database) via Hilt.

Example Entry Point (MainActivity.kt)

Kotlin

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyApplicationTheme {
                Surface(color = MaterialTheme.colorScheme.background) {
                    AppNavigation() // Your NavHost goes here
                }
            }
        }
    }
}

5. Development Workflow:

  1. Sync Gradle: Always hit the "Elephant" icon after changing config files.

  2. Linting: Run ./gradlew lint to check for structural issues.

  3. Build: Use Build > Make Project to generate necessary Hilt/Room code

Smart move—managing dependencies in a central location saves you from "version mismatch hell" later on.

The libs.versions.toml file lives in your gradle folder. It allows you to update a version number once and have it apply across your entire project.

The gradle/libs.versions.toml Skeleton

Ini, TOML

[versions]
# Core
agp = "8.2.2"
kotlin = "1.9.22"
coreKtx = "1.12.0"
lifecycleRuntimeKtx = "2.7.0"

# UI & Compose
activityCompose = "1.8.2"
composeBom = "2023.10.01"

# Architecture & DI
hilt = "2.50"
navigationCompose = "2.7.6"
retrofit = "2.9.0"

[libraries]
# AndroidX & Compose
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }

# Navigation & DI
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
hilt-android = { group = "google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }

# Networking
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }

How to use this in your code

Once you've defined the catalog, your app/build.gradle.kts becomes much cleaner. Instead of hardcoded strings, you use the type-safe accessors:

Kotlin

dependencies {
    // Accessing via the alias defined in the TOML
    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.material3)
    implementation(libs.hilt.android)
    kapt(libs.hilt.compiler) // For Hilt code generation
    
    // Using the BOM (Bill of Materials) for Compose versions
    implementation(platform(libs.androidx.compose.bom))
}

Pro-Tip: The "Sync" Rule

Every time you modify the .toml file or a .gradle.kts file, you must click "Sync Now" in the yellow bar at the top of Android Studio. If you don't, the IDE won't recognize the new libraries.

To keep your app from becoming a "giant single file" mess, you need a Navigation Graph. This acts as the central traffic controller, defining which screens exist and how to move between them.

Here is the boilerplate for a clean, Compose-based navigation setup.

1. Define Your Routes:

In modern Compose, we use a simple object or sealed class to define routes. This prevents typos when navigating.

Kotlin

sealed class Screen(val route: String) {
    object Home : Screen("home_screen")
    object Details : Screen("details_screen/{itemId}") {
        fun createRoute(itemId: String) = "details_screen/$itemId"
    }
}

2. The NavGraph Skeleton:

Create a file named NavGraph.kt. This is where you map your routes to actual UI Composables.

Kotlin

@Composable
fun AppNavigation(
    navController: NavHostController = rememberNavController(),
    startDestination: String = Screen.Home.route
) {
    NavHost(
        navController = navController,
        startDestination = startDestination
    ) {
        // Home Screen
        composable(route = Screen.Home.route) {
            HomeScreen(onNavigateToDetails = { id ->
                navController.navigate(Screen.Details.createRoute(id))
            })
        }

        // Details Screen with Arguments
        composable(
            route = Screen.Details.route,
            arguments = listOf(navArgument("itemId") { type = NavType.StringType })
        ) { backStackEntry ->
            val itemId = backStackEntry.arguments?.getString("itemId")
            DetailsScreen(itemId = itemId, onBack = {
                navController.popBackStack()
            })
        }
    }
}

3. Connecting to the UI:

Inside your screens, you just trigger the callbacks. This keeps your UI components "dumb" and easy to test because they don't need to know about the navController directly.

Kotlin

@Composable
fun HomeScreen(onNavigateToDetails: (String) -> Unit) {
    Column {
        Text("Main Dashboard")
        Button(onClick = { onNavigateToDetails("123") }) {
            Text("Go to Item 123")
        }
    }
}

4. Final Integration:

Now, go back to your MainActivity.kt and drop the AppNavigation() component inside your theme block.

Kotlin

setContent {
    MyApplicationTheme {
        AppNavigation() // Everything starts here!
    }
}

Why this structure?

  • Decoupled: Your screens don't "own" the navigation logic; the NavGraph does.

  • Argument Passing: It handles dynamic data (like IDs) safely.

  • Backstack Management: Android’s "Back" button behavior is handled automatically.

In modern Android development, the ViewModel is the "brain" of your UI. It holds your data, handles business logic, and—most importantly—survives configuration changes (like rotating your phone).

Using StateFlow is the current gold standard for pushing data from the ViewModel to your Compose screens.

1. The ViewModel Skeleton:

This pattern uses a UiState data class. It ensures your UI only has to listen to one object to know exactly what to display (the data, a loading spinner, or an error).

Kotlin

// 1. Define what your screen can look like
data class HomeUiState(
    val isLoading: Boolean = false,
    val items: List<String> = emptyList(),
    val errorMessage: String? = null
)

@HiltViewModel
class HomeViewModel @Inject constructor(
    private val repository: MyRepository // Injected via Hilt
) : ViewModel() {

    // 2. The internal mutable state (private)
    private val _uiState = MutableStateFlow(HomeUiState())
    
    // 3. The external immutable state (public for the UI)
    val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()

    init {
        fetchDashboardData()
    }

    fun fetchDashboardData() {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }
            
            try {
                val result = repository.getData()
                _uiState.update { it.copy(isLoading = false, items = result) }
            } catch (e: Exception) {
                _uiState.update { it.copy(isLoading = false, errorMessage = e.message) }
            }
        }
    }
}

2. Consuming State in Compose:

To connect your UI to this ViewModel, you use collectAsStateWithLifecycle(). This is the most efficient way to observe data because it automatically stops listening when the app goes into the background, saving battery.

Kotlin

@Composable
fun HomeScreen(
    viewModel: HomeViewModel = hiltViewModel() // Hilt injects the VM automatically
) {
    // Collect the state safely
    val state by viewModel.uiState.collectAsStateWithLifecycle()

    Box(modifier = Modifier.fillMaxSize()) {
        if (state.isLoading) {
            CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
        }

        if (state.items.isNotEmpty()) {
            LazyColumn {
                items(state.items) { item ->
                    Text(text = item)
                }
            }
        }

        state.errorMessage?.let { error ->
            Text(text = "Error: $error", color = Color.Red)
        }
    }
}

3. Why this setup works:

  • Unidirectional Data Flow (UDF): Data flows down (State), and events flow up (Functions). This makes debugging a breeze.

  • Thread Safety: viewModelScope ensures that if the user leaves the screen, any background tasks (like API calls) are cancelled immediately.

  • Type Safety: By using a UiState data class, you avoid having 10 different variables for loading, error, and data.

To wrap up the skeleton, we need the Data Layer. This is where your app talks to the outside world (APIs) or a local database.

We’ll use Retrofit for networking and the Repository Pattern to mediate between the data source and the ViewModel.

1. The API Interface:

First, define your network endpoints using Retrofit annotations.

Kotlin

interface ApiService {
    @GET("items")
    suspend fun getItems(): List<String>
}

2. The Repository:

The Repository is the "Single Source of Truth." The ViewModel shouldn't care if the data comes from the internet or a local cache; it just asks the Repository.

Kotlin

class MyRepository @Inject constructor(
    private val apiService: ApiService
) {
    suspend fun getData(): List<String> {
        // Here you could add logic to check a local database first
        return apiService.getItems()
    }
}

3. Dependency Injection (Hilt Module):

Hilt needs to know how to create the ApiService and Repository. We do this in a NetworkModule.

Kotlin

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides
    @Singleton
    fun provideRetrofit(): Retrofit {
        return Retrofit.Builder()
            .baseUrl("https://api.example.com/")
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }

    @Provides
    @Singleton
    fun provideApiService(retrofit: Retrofit): ApiService {
        return retrofit.create(ApiService.class.java)
    }
}

4. The Complete Data Flow:

Now that all pieces are connected, here is how a single request travels through your skeleton:

  1. UI: Calls viewModel.fetchDashboardData().

  2. ViewModel: Launches a coroutine and calls repository.getData().

  3. Repository: Calls apiService.getItems().

  4. Retrofit: Executes the network request and returns the result.

  5. ViewModel: Updates the UiState with the new data.

  6. Compose: Observes the UiState change and automatically updates the screen.

Summary of Your Skeleton

You now have a production-ready foundation:

  • Build System: libs.versions.toml for easy updates.

  • Navigation: Typed routes with NavHost.

  • UI Layer: Jetpack Compose for modern, reactive interfaces.

  • State Management: ViewModels with StateFlow.

  • Data Layer: Hilt, Retrofit, and the Repository pattern.

A professional README.md is the "front door" of your project. It helps other developers (or your future self) understand how to build, run, and contribute to the code without digging through every folder.

Here is a comprehensive template tailored to the skeleton we just built.

📋 Android Project Skeleton:

A modern Android template leveraging Jetpack Compose, MVVM Architecture, and Clean Architecture principles. This skeleton is designed for scalability, testability, and maintainability.

🚀 Tech Stack:

  • Language: https://kotlinlang.org/

  • UI: https://developer.android.com/compose (Declarative UI)

  • Architecture: MVVM (Model-View-ViewModel) + Repository Pattern

  • Dependency Injection: https://developer.android.com/training/dependency-injection/hilt-android

  • Networking: https://square.github.io/retrofit/ + OkHttp

  • Async/Threading: https://kotlinlang.org/docs/coroutines-overview.html#what-s-next & Flow

  • Navigation: Jetpack Compose Navigation

  • Dependency Management: Gradle Version Catalogs (libs.versions.toml)

🏗 Project Structure:

Plaintext

app/
├── src/main/java/com/example/app/
│   ├── data/           # Remote & Local data sources, Repositories
│   ├── di/             # Hilt Modules (Network, Database, etc.)
│   ├── ui/             # UI Layer (Screens, ViewModels, Theme)
│   │   ├── components/ # Reusable UI atoms
│   │   ├── navigation/ # NavGraph and Route definitions
│   │   └── theme/      # Design system (Color, Type, Shape)
│   └── util/           # Extension functions and helpers
└── build.gradle.kts    # Module-level configuration

🛠 Installation & Setup:

  1. Clone the repository:

    Bash
    git clone https://github.com/yourusername/your-repo-name.git
    
  2. Open in Android Studio:

    • File > Open > Select the cloned folder.

  3. Sync Gradle:

    • Wait for the project to index and click the "Sync Project with Gradle Files" icon.

  4. Run the app:

    • Select an emulator or a physical device and press Shift + F10.

🚦 Getting Started:

Adding a New Screen

  1. Define a new route in Screen.kt.

  2. Create a new Composable screen in the ui package.

  3. Create a ViewModel extending androidx.lifecycle.ViewModel.

  4. Add the screen to NavGraph.kt.

Adding Dependencies

Modify the gradle/libs.versions.toml file to add new libraries. This ensures version consistency across the entire project.

🧪 Testing:

  • Unit Tests: Located in src/test/java. Run with ./gradlew test.

  • UI Tests: Located in src/androidTest/java. Run with ./gradlew connectedAndroidTest.

📄 License:

This project is licensed under the MIT License - see the "www.google.com/search?q=LICENSE" as-an-example file for details.

Pro-Tip

Keep your API keys out of this file! Use a local.properties file or a secrets plugin to manage sensitive data.

While you have a solid foundation, a "complete" production-ready skeleton usually requires a few more specialized pieces to handle the messy reality of mobile development (like losing internet or keeping secrets).

Here is what is left to turn this from a skeleton into a professional-grade template:

1. Local Database (Room):

Most apps shouldn't rely 100% on the network. Room allows you to cache data so the app works offline.

  • Entity: Defines your database table.

  • DAO (Data Access Object): The queries (SQL) to read/write data.

  • Database: The main access point for the connection.

2. Secrets Management:

You should never hardcode API keys in your code or your README.md.

  • The Fix: Use the Secrets Gradle Plugin. It moves your keys into local.properties (which is git-ignored) and exposes them as variables in your code.

3. Image Loading (Coil):

Modern apps are visual. Since Compose doesn't have a native "URL to Image" component, you need Coil. It handles caching, memory management, and placeholder crossfades automatically.

Kotlin

AsyncImage(
    model = "https://example.com/image.jpg",
    contentDescription = "Translated description",
    placeholder = painterResource(R.drawable.loading_img)
)

4. GitHub Actions (CI/CD):

To ensure your code doesn't break when you (or others) make changes, you need a Continuous Integration pipeline.

  • Create a .github/workflows/android.yml file.

  • It will automatically run ./gradlew test and build your APK every time you push code.

5. ProGuard/R8 Rules:

When you release your app, you need to "obfuscate" the code to make it harder to reverse-engineer and to reduce the file size. You’ll need a proguard-rules.pro file specifically configured for libraries like Retrofit and Hilt.

6. Testing Strategy:

A complete skeleton includes placeholders for:

  • Unit Tests: Testing your ViewModels and Repositories (JUnit/MockK).

  • UI Tests: Testing your Compose screens (Espresso/Compose Test Library).

Buckle up—we're going to turn this skeleton into a tank. To cover everything else, we'll implement Offline Caching, Image Loading, Secrets Management, and CI/CD Automation.

1. Local Persistence (Room Database):

To make your app work offline, the Repository will now save API results into Room and read from it when the network is down.

The Entity & DAO:

Kotlin

@Entity(tableName = "items")
data class ItemEntity(
    @PrimaryKey val id: String,
    val name: String
)

@Dao
interface ItemDao {
    @Query("SELECT * FROM items")
    fun getAllItems(): Flow<List<ItemEntity>>

    @Upsert
    suspend fun insertItems(items: List<ItemEntity>)
}

2. Image Loading with Coil:

Add the dependency to your libs.versions.toml: coil-compose = "2.5.0"

In your Compose code, use AsyncImage. It handles memory caching (not re-downloading the same image) and disk caching (keeping images for offline use) automatically.

3. Secrets Management (API Keys):

Don't let hackers steal your API keys.

  1. Add the secrets-gradle-plugin to your root build.gradle.kts.

  2. Place your key in local.properties: API_KEY=your_secret_value_here.

  3. Access it in code: BuildConfig.API_KEY.

Peer Note: Make sure local.properties is in your .gitignore. If you push this file to GitHub, your secrets are public property!

4. ProGuard/R8 (Shrinking the App):

In your app/build.gradle.kts, ensure isMinifyEnabled = true for release builds. This removes unused code and makes your APK significantly smaller.

Kotlin

buildTypes {
    release {
        isMinifyEnabled = true
        isShrinkResources = true
        proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
    }
}

5. CI/CD with GitHub Actions:

Create .github/workflows/main.yml. This script runs every time you push code to ensure nothing is broken.

YAML

name: Android CI

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: set up JDK
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
      - name: Run Tests
        run: ./gradlew test
      - name: Build APK
        run: ./gradlew assembleDebug

6. The Final Architecture Diagram:

With these additions, your data flow now looks like this:

  1. UI calls the ViewModel.

  2. ViewModel asks the Repository.

  3. Repository fetches from Retrofit (Network) and saves it to Room (Local).

  4. Repository returns the Room data as a Flow back to the UI.

Your Skeleton is Complete! 🏆

You have a modern, scalable, and secure Android project structure.