Jetpack Compose Navigation, Ktor, and Koin DI Unlocking MAD Skills

Nimit Raja
ProAndroidDev
Published in
7 min readApr 13, 2024

--

In this blog post, We’ll delve into the powerful trio of Jetpack Compose, Ktor, and Koin, exploring how they synergize to streamline and enhance modern Android app development. Here’s what we’ll cover:

  1. Introduction to Jetpack Compose: We’ll integrate of Jetpack Compose, Google’s declarative UI toolkit for building native Android app. Link
  2. Getting Started with Ktor: Next, we’ll dive into Ktor, a lightweight web framework for building asynchronous servers and clients in Kotlin. We’ll explore its intuitive API, asynchronous programming model, and seamless integration with other Kotlin libraries. Link
  3. Understanding Koin for Dependency Injection: Dependency injection is crucial for building scalable and maintainable Android apps. We’ll introduce Koin, a pragmatic lightweight dependency injection framework for Kotlin. We’ll cover its core concepts, such as modules, components, and scoped dependencies, and demonstrate how it simplifies dependency management in our projects. Link
  4. Integration and Interoperability: We’ll explore how Jetpack Compose, Ktor, and Koin can seamlessly integrate with each other. We’ll discuss best practices for integrating Ktor APIs into Jetpack Compose apps and leveraging Koin for dependency injection in both UI and backend layers.
  5. Building a Sample Application: To tie everything together, we’ll walk through the development of a sample Android application using Jetpack Compose for the UI, Ktor for networking, and Koin for dependency injection. We’ll demonstrate how these technologies work in harmony to create a robust and efficient mobile app.

By the end of this blog post, you’ll have a comprehensive understanding of how to leverage Jetpack Compose, Ktor, and Koin to build modern, efficient, and maintainable Android applications. Let’s dive in!

Before delving into our exploration of Compose Multiplatform for networking, I’d like to briefly revisit my previous blog post (Part 2). In that piece, I covered the intricacies of dependency injection with Ktor. If you haven’t had the chance to read it yet, you can find it here. Understanding the fundamentals of dependency injection will provide valuable context for our discussion ahead.”. this article is for KMP but here we are focusing only for Android !!

for Jetpack compose navigation see article

https://medium.com/proandroiddev/jetpack-compose-navigation-with-mvvm-dependency-injection-koin-ceee45658c86

Lets start quick implementation

declare dependencies inside build.gradle

dependencies {

implementation("androidx.activity:activity-compose:1.8.2")
implementation(platform("androidx.compose:compose-bom:2023.08.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
androidTestImplementation(platform("androidx.compose:compose-bom:2023.08.00"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")

val lifecycle_version ="2.6.2"
val coroutine_version="1.7.3"
val koin_version="3.4.0"

implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")

//Koin
implementation ("io.insert-koin:koin-android:$koin_version")
implementation ("io.insert-koin:koin-androidx-compose:$koin_version")


//Lifecycle
implementation("androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version")
implementation ("androidx.lifecycle:lifecycle-extensions:2.2.0")
implementation( "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version")
implementation ("androidx.activity:activity-ktx:1.8.2")

//Coroutines
implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutine_version")
implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutine_version")
implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:$coroutine_version")


//Compose Navigation
implementation ("androidx.navigation:navigation-compose:2.7.7")

//Koil Image Dependency
implementation("io.coil-kt:coil-compose:2.4.0")

//Ktor & kotlin Serialization
implementation("io.ktor:ktor-client-android:2.3.10")
implementation("io.ktor:ktor-client-serialization:2.3.10")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1")
implementation("io.ktor:ktor-client-logging-jvm:2.3.10")
implementation("io.ktor:ktor-client-content-negotiation:2.3.10")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.10")
}

for Ktor integration with Koin, lets break down step by step

val networkModule = module {
single {
HttpClient {
install(ContentNegotiation) {
json(json = Json { ignoreUnknownKeys = true }, contentType = ContentType.Any)
}
install(Logging) {
logger = object : Logger {
override fun log(message: String) {
Log.v("Logger Ktor =>", message)
}

}
level = LogLevel.ALL
}
install(ResponseObserver) {
onResponse { response ->
Log.d("HTTP status:", "${response.status.value}")
}
}
install(DefaultRequest) {
header(HttpHeaders.ContentType, ContentType.Application.Json)
}
}
}
}
val networkModule = module {
  • This line defines a Koin module named networkModule. Modules in Koin are used to organize and configure dependencies.
single {
HttpClient {

this declares a singleton dependency using Koin’s single function. It creates an instance of the HttpClient class provided by Ktor.

 install(ContentNegotiation) {
json(json = Json { ignoreUnknownKeys = true }, contentType = ContentType.Any)
}

This block installs the ContentNegotiation feature in the HTTP client, allowing it to automatically serialize and deserialize JSON data. It configures JSON serialization with ignoreUnknownKeys set to true, meaning that the JSON parser will ignore any unknown keys encountered during deserialization.

install(Logging) {
logger = object : Logger {
override fun log(message: String) {
Log.v("Logger Ktor =>", message)
}
}
level = LogLevel.ALL
}
  • This block installs the Logging feature in the HTTP client, enabling logging of HTTP requests and responses. It configures a custom logger that logs messages to Android's Logcat with a verbose level (Log.v). The logging level is set to LogLevel.ALL, which logs all messages.
install(ResponseObserver) {
onResponse { response ->
Log.d("HTTP status:", "${response.status.value}")
}
}

This block installs the ResponseObserver feature in the HTTP client, allowing it to observe and log HTTP responses. It defines a callback function that logs the HTTP status code of the response

            install(DefaultRequest) {
header(HttpHeaders.ContentType, ContentType.Application.Json)
}
  • This block installs the DefaultRequest feature in the HTTP client, setting default request headers. It adds a header specifying that the content type of requests should be JSON.

for DI modules

val networkModule = module {
single {
HttpClient {
install(ContentNegotiation) {
json(json = Json { ignoreUnknownKeys = true }, contentType = ContentType.Any)
}
install(Logging) {
logger = object : Logger {
override fun log(message: String) {
Log.v("Logger Ktor =>", message)
}

}
level = LogLevel.ALL
}
install(ResponseObserver) {
onResponse { response ->
Log.d("HTTP status:", "${response.status.value}")
}
}
install(DefaultRequest) {
header(HttpHeaders.ContentType, ContentType.Application.Json)
}
}
}
}

val apiServiceModule= module {
factory { ApiService(get()) }
}

val repositoryModule = module {
factory { Repository(get()) }
}

val viewModelModule= module {
viewModel{ MainViewModel(get(),get()) }
}

val remoteDataSourceModule= module {
factory { RemoteDataSource(get()) }
}

Application class

startKoin {
androidContext(this@MyApplication)
androidLogger()
modules(networkModule,
apiServiceModule, remoteDataSourceModule, repositoryModule, viewModelModule)
}

ApiService class

class RemoteDataSource(private val apiService: ApiService) {

suspend fun getReceipes() = apiService.getReceipes()
suspend fun getReceipesDetail(id:Int?) = apiService.getReceipeDetails(id)
}

class ApiService(private val httpClient: HttpClient) {

val recipes="recipes/"
suspend fun getReceipes(): Receipes = httpClient.get("${Constants.BASE_URL}$recipes").body<Receipes>()
suspend fun getReceipeDetails(id:Int?): Receipes.Recipe = httpClient.get("${Constants.BASE_URL}$recipes$id").body<Receipes.Recipe>()
}

This ApiService class acts as a client for making HTTP requests to the remote server. It uses the get() function provided by the HttpClient to execute the request. The response body is deserialized into a Receipes object using Kotlinx.serialization's body() function.

Repository class

class Repository(private val remoteDataSource: RemoteDataSource) {

suspend fun getReceipes(context: Context): Flow<UiState<Receipes?>> {
return toResultFlow(context){
remoteDataSource.getReceipes()
}
}

suspend fun getReceipesDetail(context: Context,id:Int?): Flow<UiState<Receipes.Recipe?>> {
return toResultFlow(context){
remoteDataSource.getReceipesDetail(id)
}
}

}

this Repository class acts as an intermediary between the UI layer and the remote data source, providing a clean API for fetching recipes data while encapsulating the details of how the data is fetched and processed.

Common BaseviewModel for Api requests

open class BaseViewModel(application: Application) : AndroidViewModel(application) {
protected val context
get() = getApplication<Application>()

suspend fun <T> fetchData(uiStateFlow: MutableStateFlow<UiState<T?>>, apiCall: suspend () -> Flow<UiState<T?>>) {
uiStateFlow.value = UiState.Loading
try {
apiCall().collect {
uiStateFlow.value = it
}
} catch (e: Exception) {
uiStateFlow.value = UiState.Error(e.message?:"")
}
}
}

extend this BaseviewModel into your any ViewModel class

class MainViewModel(private val repository: Repository, application: Application): BaseViewModel(application) {

val _uiStateReceipeList = MutableStateFlow<UiState<Receipes?>>(UiState.Loading)
val uiStateReceipeList: StateFlow<UiState<Receipes?>> = _uiStateReceipeList

val _uiStateReceipeDetail = MutableStateFlow<UiState<Receipes.Recipe?>>(UiState.Loading)
val uiStateReceipeDetail: StateFlow<UiState<Receipes.Recipe?>> = _uiStateReceipeDetail


fun getReceipesList() = viewModelScope.launch {
fetchData(_uiStateReceipeList) { repository.getReceipes(context) }
}

fun getReceipeDetail(id: Int?) = viewModelScope.launch {
fetchData(_uiStateReceipeDetail,) { repository.getReceipesDetail(context, id) }
}
}

Generic function for handle response

function provides a convenient way to handle data fetching operations asynchronously and emit different states (loading, success, error) as the result of the operation. It encapsulates common error handling and internet connectivity checks.

fun <T> toResultFlow(context: Context, call: suspend () -> T?) : Flow<UiState<T?>> {
return flow<UiState<T?>> {
if(Utils.hasInternetConnection(context)) {
emit(UiState.Loading)
val c = call.invoke()
c.let { response ->
try {
emit(UiState.Success(response))
} catch (e: Exception) {
emit(UiState.Error(e.toString()))
}
}
}else{
emit(UiState.Error(Constants.API_INTERNET_MESSAGE))
}
}.flowOn(Dispatchers.IO)
}

Finally call your viewmodel function inside composable functions

@Composable
fun RecipesScreen(navigation: NavController, mainViewModel: MainViewModel) {
Scaffold(
topBar = {
CustomToolbarScreen(navController = navigation, title = "Home", false)
}
)
{ innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.padding(10.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
//add your code
LaunchedEffect(key1 = Unit) {
getReceipesListAPI(mainViewModel)
}
val state = mainViewModel.uiStateReceipeList.collectAsState()
when (state.value) {
is UiState.Success -> {
ProgressLoader(isLoading = false)
(state.value as UiState.Success<Receipes>).data?.let {
it.recipes?.let { it1 ->
RecipeList(recipes = it1) { recipe ->
// Handle recipe click here
navigation.navigate(Routes.getSecondScreenPath(recipe.id))
}
}
}
}

is UiState.Loading -> {
ProgressLoader(isLoading = true)
}

is UiState.Error -> {
ProgressLoader(isLoading = false)
//Handle Error
SimpleAlertDialog(message = ((state.value as UiState.Error<Receipes>).message))
}
}
}
}


}

@Composable
fun RecipeListCard(recipe: Receipes.Recipe, onRecipeClick: (Receipes.Recipe) -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.clickable { onRecipeClick(recipe) },
shape = RoundedCornerShape(10),
elevation = CardDefaults.cardElevation(
defaultElevation = 4.dp
)
) {
Row(
modifier = Modifier.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = rememberAsyncImagePainter(recipe.image),
contentDescription = null,
modifier = Modifier
.size(100.dp)
.clip(RoundedCornerShape(10.dp)),
contentScale = ContentScale.Crop
)
Column(
modifier = Modifier
.padding(start = 8.dp)
.weight(1f)
) {
Text(
text = recipe.name ?: "",
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
Text(
text = "Prep Time: ${recipe.prepTimeMinutes} mins",
fontSize = 14.sp,
color = Color.Black
)
Text(
text = "Cook Time: ${recipe.cookTimeMinutes} mins",
fontSize = 14.sp,
color = Color.Black
)
Text(
text = "Servings: ${recipe.servings}",
fontSize = 14.sp,
color = Color.Black
)
}
}
}
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RecipeList(recipes: List<Receipes.Recipe>, onRecipeClick: (Receipes.Recipe) -> Unit) {
LazyColumn {
items(recipes) { recipe ->
RecipeListCard(recipe = recipe, onRecipeClick = onRecipeClick)
}
}
}

Full Implementation on GitHub

Certainly! Here are some suggestions for sentences to include after wrapping up:

  1. Thank you for taking the time to read article. I hope you found it informative and useful for your Android development projects.”
  2. If you have any questions, feedback, or topics you’d like us to cover in future articles, please don’t hesitate to reach out to us.”
  3. Stay tuned for more insightful content on modern Android development techniques and best practices.”
  4. “Don’t forget to follow me for updates on upcoming articles and other interesting content.”
  5. We appreciate your support and look forward to sharing more knowledge with you in the future. Happy coding !!”

Nimit Raja — LinkedIn

--

--

Sr Android Engineer, Jetpack Compose, KMP, Dart, Tech Writer, Open Source Contributor