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.
Before writing code, ensure your workstation is ready:
Download Android Studio: Install the latest stable version (e.g., Ladybug or newer).
JDK: Android Studio comes bundled with a compatible JDK (usually Java 17 or 21), which is recommended for modern Gradle builds.
SDK Platforms: Open the SDK Manager and install the latest API level (e.g., API 34 or 35).
Emulators: Create a Virtual Device (AVD) via the Device Manager for testing.
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
Modern Android projects use Kotlin DSL (.gradle.kts) and Version Catalogs for dependency management.
build.gradle.ktsThis 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
}
app/build.gradle.ktsThis 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
}
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. |
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
}
}
}
}
}
Sync Gradle: Always hit the "Elephant" icon after changing config files.
Linting: Run ./gradlew lint to check for structural issues.
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.
gradle/libs.versions.toml SkeletonIni, 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" }
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))
}
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.
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"
}
}
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()
})
}
}
}
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")
}
}
}
Now, go back to your MainActivity.kt and drop the AppNavigation() component inside your theme block.
Kotlin
setContent {
MyApplicationTheme {
AppNavigation() // Everything starts here!
}
}
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.
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) }
}
}
}
}
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)
}
}
}
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.
First, define your network endpoints using Retrofit annotations.
Kotlin
interface ApiService {
@GET("items")
suspend fun getItems(): List<String>
}
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()
}
}
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)
}
}
Now that all pieces are connected, here is how a single request travels through your skeleton:
UI: Calls viewModel.fetchDashboardData().
ViewModel: Launches a coroutine and calls repository.getData().
Repository: Calls apiService.getItems().
Retrofit: Executes the network request and returns the result.
ViewModel: Updates the UiState with the new data.
Compose: Observes the UiState change and automatically updates the screen.
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.
A modern Android template leveraging Jetpack Compose, MVVM Architecture, and Clean Architecture principles. This skeleton is designed for scalability, testability, and maintainability.
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)
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
Clone the repository:
Bash
git clone https://github.com/yourusername/your-repo-name.git
Open in Android Studio:
File > Open > Select the cloned folder.
Sync Gradle:
Wait for the project to index and click the "Sync Project with Gradle Files" icon.
Run the app:
Select an emulator or a physical device and press Shift + F10.
Define a new route in Screen.kt.
Create a new Composable screen in the ui package.
Create a ViewModel extending androidx.lifecycle.ViewModel.
Add the screen to NavGraph.kt.
Modify the gradle/libs.versions.toml file to add new libraries. This ensures version consistency across the entire project.
Unit Tests: Located in src/test/java. Run with ./gradlew test.
UI Tests: Located in src/androidTest/java. Run with ./gradlew connectedAndroidTest.
This project is licensed under the MIT License - see the "www.google.com/search?q=LICENSE" as-an-example file for details.
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:
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.
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.
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)
)
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.
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.
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.
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>)
}
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.
Don't let hackers steal your API keys.
Add the secrets-gradle-plugin to your root build.gradle.kts.
Place your key in local.properties: API_KEY=your_secret_value_here.
Access it in code: BuildConfig.API_KEY.
Peer Note: Make sure
local.propertiesis in your.gitignore. If you push this file to GitHub, your secrets are public property!
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")
}
}
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
With these additions, your data flow now looks like this:
UI calls the ViewModel.
ViewModel asks the Repository.
Repository fetches from Retrofit (Network) and saves it to Room (Local).
Repository returns the Room data as a Flow back to the UI.
You have a modern, scalable, and secure Android project structure.