This guide encompasses best practices and recommended architecture for building robust, high-quality apps
βββ common
βββ data
| βββ config
| βββ datasources
| βββ models
| βββ repositories
βββ di
βββ domain
| βββ entities
| βββ repositories
| βββ usecases
βββ MainApplication.kt
βββ MyActivity.kt
βββ ui
βββ modules
| βββ photos
βββ theme
- Material3 : Compose Material You Design Components library.
- Hilt : A fast dependency injector for Android and Java.
- Retrofit2 : A type-safe HTTP client for Android and Java.
- Coil_Compose : An image loading library for Android backed by Kotlin Coroutines.
There are 3 main modules to help separate the code. They are Data, Domain, and Presentaion.
-
Data contains Local Storage, APIs, Data objects (Request/Response object, DB objects), and the repository implementation.
-
Domain contains UseCases, Domain Objects/Models, and Repository Interfaces
-
Presentaion contains UI, View Objects, Widgets, etc. Can be split into separate modules itself if needed. For example, we could have a module called Device handling things like camera, location, etc.
- Bridge between Data layer and Domain layer
- Connects to data sources and returns mapped data
- Data sources include DB and Api
interface PhotoRemoteDataSource {
@GET("?key=${AppConfig.API_KEY}")
suspend fun getPhotos(
@Query("q") query: String?,
@Query("page") page: Int
): PhotosResponse
}
class PhotoRepositoryImpl @Inject constructor(
private val remoteDataSource: PhotoRemoteDataSource
) : PhotoRepository {
override fun getPhoto(query: String?, page: Int): Flow<Resources<Photos>> {
return flow {
try {
val response = remoteDataSource.getPhotos(query, page)
emit(Resources.Success(data = response.toEntity()))
} catch (e: Exception) {
emit(Resources.Error("Failed to fetch images"))
}
}
}
}
- Responsible for connecting to repository to retrieve necessary data. returns a Flow.
- This is where the business logic takes place.
- Returns data downstream.
- Single use.
- Lives in Domain (No Platform dependencies. Very testable).
class GetPhotoUseCase @Inject constructor(
private val repository: PhotoRepository
) {
suspend operator fun invoke(query: String?, page: Int): Flow<Resources<Photos>> {
return if (query.isNullOrEmpty()) {
flow {
emit(Resources.Success(data = null))
}
} else {
repository.getPhoto(query, page)
}
}
}
- Organizes data and holds View state.
- Talks to use cases.
class PhotoViewModel @Inject constructor(private val getPhotoUseCase: GetPhotoUseCase) :
ViewModel() {
private val queryFlow = MutableStateFlow("")
private val refreshTrigger = MutableSharedFlow<Unit>(replay = 1).apply { tryEmit(Unit) }
private val loadMoreTrigger = MutableSharedFlow<Unit>(replay = 1).apply { tryEmit(Unit) }
private var currentPage = 1
private val appendPhotos = mutableListOf<Hits>()
val state: StateFlow<SearchState?> = combine(
queryFlow,
refreshTrigger,
loadMoreTrigger,
) { query, _, _ -> query }
.debounce(350)
.flatMapLatest {
getPhotoUseCase.invoke(it, currentPage).onEach { resource ->
if (resource is Resources.Success) {
if (currentPage == 1) {
appendPhotos.clear()
}
val hits = resource.data?.hits ?: emptyList()
appendPhotos.addAll(hits)
}
}
}
.map { resource ->
when (resource) {
is Resources.Loading -> SearchState.Loading
is Resources.Success -> SearchState.Success(data = Photos(
total = resource.data?.total ?: 0,
totalHits = resource.data?.totalHits ?: 0,
hits = appendPhotos.toList()
))
is Resources.Error -> SearchState.Error(resource.message.toString())
}
}
.stateIn(viewModelScope, SharingStarted.Lazily, SearchState.Idle)
fun onIntent(event: PhotoIntent) {
when (event) {
is PhotoIntent.SearchPhotosWithoutQuery -> queryFlow.value = ""
is PhotoIntent.SearchPhotos -> {
currentPage = 1
queryFlow.value = event.q
}
is PhotoIntent.RefreshPhotos -> {
currentPage = 1
refreshTrigger.tryEmit(Unit)
}
is PhotoIntent.LoadMorePhotos -> {
currentPage += 1
loadMoreTrigger.tryEmit(Unit)
}
}
}
init {
onIntent(PhotoIntent.SearchPhotosWithoutQuery)
}
}
- View,updates UI
fun PhotoListScreen(viewModel: PhotoViewModel = hiltViewModel()) {
val state = viewModel.state.collectAsState()
val listState = rememberLazyListState() // Remember the scroll state
var query by remember { mutableStateOf("") }
val focusManager = LocalFocusManager.current
Scaffold(topBar = {
TopAppBar(colors = topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.primary,
), title = { Text("Android Clean Architecture") })
}, content = { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
horizontalAlignment = Alignment.CenterHorizontally, // Horizontally centers children
) {
TextField(
value = query,
onValueChange = {
query = it
viewModel.onIntent(PhotoIntent.SearchPhotos(it, 1))
},
label = { Text("Enter text") },
placeholder = { Text("Type something...") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
}
),
)
Spacer(modifier = Modifier.height(0.dp))
when (state.value) {
is SearchState.Loading -> CircularProgressIndicator()
is SearchState.Success -> {
val hits = (state.value as SearchState.Success).data.hits
println("PhotoListScreen_value: ${(state.value as SearchState.Success).data.totalHits} - ${hits.size}")
if (hits.isNullOrEmpty()) {
Text(text = "No photo found")
} else {
focusManager.clearFocus()
SwipeRefresh(state = SwipeRefreshState(isRefreshing = false),
onRefresh = { viewModel.onIntent(PhotoIntent.RefreshPhotos(query)) }) {
LazyColumn(
state = listState, // Use the remembered scroll state
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(items = hits, key = { it.id }) {
PhotoRow(it)
}
if (hits.size < (state.value as SearchState.Success).data.totalHits) {
item {
LaunchedEffect(Unit) {
viewModel.onIntent(PhotoIntent.LoadMorePhotos(query))
}
CircularProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.wrapContentWidth(Alignment.CenterHorizontally)
)
}
}
}
}
}
}
is SearchState.Error -> Text(text = "Error")
is SearchState.Idle -> Text(text = "Idle")
else -> Text(text = "null")
}
}
})
}
@Composable
fun PhotoRow(hit: Hits) {
Card(
shape = RoundedCornerShape(8.dp), // Set the border radius here
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
modifier = Modifier
.padding(
start = 16.dp, end = 16.dp, top = 16.dp, bottom = 0.dp
) // Set left and right margins
.fillMaxWidth()
.clickable {},
) {
Row(modifier = Modifier.padding(16.dp)) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current).data(hit.previewURL)
.crossfade(true) // Optional crossfade animation
.placeholder(R.drawable.placeholder_image) // Default image while loading
.error(R.drawable.placeholder_image) // Image if there's an error
.size(Size.ORIGINAL) // Optionally specify size to preload at
.memoryCachePolicy(CachePolicy.ENABLED) // Enable memory caching
.build(),
contentDescription = "Preview image of ${hit.user}",
contentScale = ContentScale.FillBounds,
modifier = Modifier
.height(100.dp)
.width(100.dp)
.clip(RoundedCornerShape(50.dp))
)
Column(modifier = Modifier.padding(start = 10.dp)) {
Text(text = hit.user, fontWeight = FontWeight.Bold)
Text(text = "ThαΊ»: ${hit.tags}", maxLines = 1)
Text(text = "Lượt thΓch: ${hit.likes}")
Text(text = "BΓ¬nh luαΊn: ${hit.comments}")
}
}
}
}
Default Search | Search keyword (ex: flo) |
---|---|