7.6 KiB
7.6 KiB
Native Android Reference (Kotlin + Jetpack Compose)
Project Structure
app/
├── src/
│ ├── main/
│ │ ├── AndroidManifest.xml
│ │ ├── java/com.example.app/
│ │ │ ├── MyApp.kt # Application class, Hilt entry point
│ │ │ ├── MainActivity.kt # Single activity, NavHost host
│ │ │ ├── ui/
│ │ │ │ ├── theme/ # MaterialTheme, Color, Type, Shape
│ │ │ │ ├── components/ # Shared design system composables
│ │ │ │ └── feature/
│ │ │ │ ├── home/
│ │ │ │ │ ├── HomeScreen.kt
│ │ │ │ │ ├── HomeViewModel.kt
│ │ │ │ │ └── HomeUiState.kt
│ │ │ ├── domain/
│ │ │ │ ├── model/ # Domain models (pure Kotlin, no Android deps)
│ │ │ │ ├── repository/ # Interfaces only
│ │ │ │ └── usecase/ # One class per use case
│ │ │ ├── data/
│ │ │ │ ├── remote/ # Retrofit services, DTOs, mappers
│ │ │ │ ├── local/ # Room DB, DAOs, entities
│ │ │ │ └── repository/ # Repository implementations
│ │ │ └── di/ # Hilt modules
│ └── test/ # Unit tests
│ └── androidTest/ # Instrumented tests
├── build.gradle.kts
└── proguard-rules.pro
ViewModel Pattern
// UiState — sealed class for exhaustive when()
sealed class HomeUiState {
object Loading : HomeUiState()
data class Success(val items: List<Item>) : HomeUiState()
data class Error(val message: String) : HomeUiState()
}
// UiEvent — one-shot events (navigation, snackbars)
sealed class HomeUiEvent {
data class NavigateTo(val route: String) : HomeUiEvent()
data class ShowSnackbar(val message: String) : HomeUiEvent()
}
@HiltViewModel
class HomeViewModel @Inject constructor(
private val getItemsUseCase: GetItemsUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow<HomeUiState>(HomeUiState.Loading)
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
private val _uiEvent = Channel<HomeUiEvent>()
val uiEvent = _uiEvent.receiveAsFlow()
init { loadItems() }
fun loadItems() {
viewModelScope.launch {
_uiState.value = HomeUiState.Loading
getItemsUseCase()
.onSuccess { _uiState.value = HomeUiState.Success(it) }
.onFailure { _uiState.value = HomeUiState.Error(it.message ?: "Unknown error") }
}
}
}
Repository Pattern
// Interface in domain layer
interface ItemRepository {
fun observeItems(): Flow<List<Item>>
suspend fun refreshItems(): Result<Unit>
suspend fun getItemById(id: String): Result<Item>
}
// Implementation in data layer
class ItemRepositoryImpl @Inject constructor(
private val remoteSource: ItemRemoteDataSource,
private val localSource: ItemLocalDataSource,
private val mapper: ItemMapper
) : ItemRepository {
override fun observeItems(): Flow<List<Item>> =
localSource.observeAll().map { mapper.toDomain(it) }
override suspend fun refreshItems(): Result<Unit> = runCatching {
val dto = remoteSource.fetchItems()
localSource.insertAll(mapper.toEntity(dto))
}
override suspend fun getItemById(id: String): Result<Item> = runCatching {
// Example implementation fetching from local cache
val entity = localSource.getById(id) ?: throw Exception("Item not found")
mapper.toDomain(entity)
}
}
Compose Screen
@Composable
fun HomeScreen(
viewModel: HomeViewModel = hiltViewModel(),
onNavigate: (String) -> Unit
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
// One-shot event handling
LaunchedEffect(Unit) {
viewModel.uiEvent.collect { event ->
when (event) {
is HomeUiEvent.NavigateTo -> onNavigate(event.route)
is HomeUiEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.message)
}
}
}
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { padding ->
when (val state = uiState) {
is HomeUiState.Loading -> LoadingContent()
is HomeUiState.Success -> HomeContent(state.items, Modifier.padding(padding))
is HomeUiState.Error -> ErrorContent(state.message, onRetry = viewModel::loadItems)
}
}
}
Room Database
@Entity(tableName = "items")
data class ItemEntity(
@PrimaryKey val id: String,
val title: String,
val updatedAt: Long = System.currentTimeMillis()
)
@Dao
interface ItemDao {
@Query("SELECT * FROM items ORDER BY updatedAt DESC")
fun observeAll(): Flow<List<ItemEntity>>
@Upsert
suspend fun upsertAll(items: List<ItemEntity>)
@Query("DELETE FROM items")
suspend fun deleteAll()
}
@Database(entities = [ItemEntity::class], version = 1, exportSchema = true)
abstract class AppDatabase : RoomDatabase() {
abstract fun itemDao(): ItemDao
}
Hilt DI Setup
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides @Singleton
fun provideRetrofit(): Retrofit = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.client(buildOkHttpClient())
.build()
}
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds @Singleton
abstract fun bindItemRepository(impl: ItemRepositoryImpl): ItemRepository
}
Key Dependencies (libs.versions.toml)
[versions]
kotlin = "2.0.0"
compose-bom = "2024.06.00"
hilt = "2.51"
room = "2.6.1"
retrofit = "2.11.0"
coroutines = "1.8.1"
lifecycle = "2.8.2"
[libraries]
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
Testing Setup
// ViewModel unit test
@OptIn(ExperimentalCoroutinesApi::class)
class HomeViewModelTest {
@get:Rule val mainDispatcherRule = MainDispatcherRule()
private val getItemsUseCase = mockk<GetItemsUseCase>()
private lateinit var viewModel: HomeViewModel
@BeforeEach
fun setup() { viewModel = HomeViewModel(getItemsUseCase) }
@Test
fun `loadItems emits Success when use case succeeds`() = runTest {
val items = listOf(Item("1", "Test"))
coEvery { getItemsUseCase() } returns Result.success(items)
viewModel.uiState.test {
skipItems(1) // Loading
assertThat(awaitItem()).isEqualTo(HomeUiState.Success(items))
}
}
}