A simple app showcasing how Ktor can be leveraged for Network calls. In this project, we build a nice Comments App, that fetches data from a free API Faker "{JSON} Placeholder".
A lightweight and flexible framework for building asynchronous client and server-side applications in Kotlin
- Kotlin and Coroutines - Ktor is built from the ground up using Kotlin and Coroutines, as such it is provides a "Kotlin-first" approach and support for asynchronous tasks.
- Lightweight and flexible - Ktor allows you to only use what you need and to structure your application the way you need.
- Built and backed by JetBrains - the very creator of Kotlin, IntelliJ IDEA and more
- Multiplatform support - Ktor is a multiplatform library, allowing you to share network code between different platforms (Android, iOS, JVM, JS, Native).
While getting data from the internet using Ktor is the primary focus for this project, we will use other libraries including
- Koin - for dependency injection
- Kotlinx-serialization - core library for serializing and deserializing Kotlin objects
- kotlinx-serialization-json - provides JSON Format specific support . For entity serialization
- Kotlinx-coroutines
- Content-negotiation - needed for Ktor http requests and kotlinx-seialization to work together seamlessly
- lifecycle
- Logback - a logging framework that enables logging for Ktor
- Add the following in
libs.versions.tomlfile
[versions]
# ... Rest of the code
kotlin = "2.0.0"
ktor = "2.3.12"
kotlinx-serialization = "2.0.0"
kotlinx-serialization-json = "1.7.1"
logbackClassic = "1.3.12"
lifecycle = "2.8.4"
koin = "3.5.6"
koinCompose = "3.4.6"
kotlinxCoroutines = "1.9.0-RC"[libraries]
# ... Rest of the code
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } # core engine. Not necessary
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor"} # engine that handles network requests on Android
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json"}
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-logging = {module = "io.ktor:ktor-client-logging", version.ref = "ktor"}
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logbackClassic" }
lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
compose-lifecycle = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle"}
#dependency injection
koin-core = { module ="io.insert-koin:koin-core", version.ref = "koin"}
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin"}
koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koinCompose"}[Plugins]
# ... Rest of the code
kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinx-serialization" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }# Note: Bundles help us provide related dependencies all at once.
[bundles]
koin = ["koin-core", "koin-android", "koin-androidx-compose"]
ktor = ["ktor-client-core", "ktor-client-android", "kotlinx-serialization-json", "ktor-client-content-negotiation",
"ktor-serialization-kotlinx-json","ktor-client-logging", "logback-classic"]
coroutines = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"]- Add the following in
build.gradle.ktsproject-level file
plugins {
// ... Rest of the code
alias(libs.plugins.kotlinxSerialization) apply false
alias(libs.plugins.compose.compiler) apply false
}
- Add the following in
build.gradle.ktsapp-level file
plugins {
// ... Rest of the code
alias(libs.plugins.kotlinxSerialization)
alias(libs.plugins.compose.compiler)
}
dependencies {
// ... Rest of the code
implementation(libs.bundles.ktor)
implementation(libs.lifecycle.viewmodel.compose)
implementation(libs.compose.lifecycle)
implementation(libs.bundles.koin)
implementation(libs.bundles.coroutines)
}- Be sure to
syncyour project after adding dependencies. While at it you're likely to run into anissuelike this one here⬇️
x files found with path 'META-INF/INDEX.LIST' . Adding a packaging block may help, please refer to https://developer.android.com/reference/tools/gradle-api/8.5/com/android/build/api/dsl/Packaging for more information
- Add the line
excludes +="META-INF/INDEX.LIST"to the packaging block in yourbuild.gradle.ktsapp-level file
android {
// ... Rest of the code
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
excludes +="META-INF/INDEX.LIST" // Add this line
}
}
}Now you need an entity class to hold your data. For our demo project we are getting comments data from a free API Faker {JSON} Placeholder. Here is the endpoint: https://jsonplaceholder.typicode.com/comments
Our model class will be as follows. Create it in a package called models
@Serializable
data class Comment(
val body: String,
val email: String,
val id: Int,
val name: String,
val postId: Int
)Notice the @Serializable annotation - it helps us serialize our entity object and represnt it in other formats such as JSON
- Create a package called
network. Inside it, create a file calledCommentsAPIService.kt - You need a
http clientfor your API service to help you get data from the internet. Inside yourCommentsAPIService.ktfile create an instance of the client like this
val httpClient = HttpClient {
install(ContentNegotiation) {
json(
Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
}
)
}
install(Logging){
logger = Logger.DEFAULT
level = LogLevel.HEADERS
}
}
- Notice we are configuring our client to use
ContentNegotiationandLogging. Moreconfigurationscan be done but for brevity we will keep these two. Now we need anAPIService. InsideCommentsAPIService.kt, below thehttp clientcreate an API Service class as follows
class CommentsAPIService {
suspend fun getComments(): List<Comment> {
return httpClient
.get("https://jsonplaceholder.typicode.com/comments")
.body<List<Comment>>()
}
}
- Note: We configured our client to use
Logging. Since we are usingLogbackwe complete theconfigurationby creating anxmlfile. In yoursrc/main/resourcesdirectory, create alogback.xmlfile to configureLogbackas follows:-
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="DEBUG">
<appender-ref ref="CONSOLE" />
</root>
</configuration>- We use the
Repository patternto keep things clean and organized. Besides, therepository patternhelps to abstract the data access layer, providing a clean separation between the application's business logic and how it interacts with data sources. - First, we need a custom
NetworkResultgeneric sealed class to hold the network responses. Create the sealedNetworkResultclass inside thenetworkpackage
sealed class NetworkResult<out T> {
data class Success<out T>(val data: T) : NetworkResult<T>()
data class Error(val message: String) : NetworkResult<Nothing>()
}- Now Create a
repositorypackage, inside the package create therepository interfaceand theimplementation classas follows
interface CommentsRepository {
suspend fun getComments(): NetworkResult<List<Comment>>
}
class CommentsRepositoryImpl(
private val apiService: CommentsAPIService,
private val dispatcher: CoroutineDispatcher
): CommentsRepository {
override suspend fun getComments(): NetworkResult<List<Comment>> {
return withContext(dispatcher){
try{
val response = apiService.getComments()
NetworkResult.Success(response)
}catch (e: Exception){
NetworkResult.Error(e.message ?: "Something went wrong")
}
}
}
}- We parse an
apiServiceto help us get data and thecoroutineDispatcherto help us with switching tasks to background threads.
- Create a new package
viewmodel - Inside create a file named
CommentsViewModelwith this code
data class CommentsUIState(
val comments: List<Comment> = emptyList(),
val error: String? = null,
val isLoading: Boolean = false
)
class CommentsViewModel(
private val repository: CommentsRepository
): ViewModel() {
private val _commentsUIState = MutableStateFlow(CommentsUIState())
val commentsUIState: StateFlow<CommentsUIState> = _commentsUIState.asStateFlow()
init {
getComments()
}
private fun getComments(){
_commentsUIState.value = _commentsUIState.value.copy(isLoading = true, comments = emptyList())
viewModelScope.launch {
val result = repository.getComments()
when(result){
is NetworkResult.Success -> {
_commentsUIState.update {
it.copy(
comments = result.data,
isLoading = false,
)
}
}
is NetworkResult.Error -> {
_commentsUIState.update {
it.copy(
isLoading = false,
error = result.message
)
}
}
}
}
}
}Notice we used flows and UIState.
- Create a new package called
di. Inside create a new fileAppModulesand declare your dependencies as follows
val appModules = module {
//API Service dependency
single <CommentsAPIService> { CommentsAPIService() }
//coroutine dispatcher dependency
single { Dispatchers.IO }
//repository dependency
single <CommentsRepository>{
CommentsRepositoryImpl(
apiService = get(),
dispatcher = get()
)
}
//viewmodel dependency
single { CommentsViewModel(repository = get()) }
}- Next, create an
Applicationclass that extends theApplication class. We startKoinfrom this class
import android.app.Application
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
class KtorInActionApplication: Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@KtorInActionApplication)
modules(appModules)
}
}
}- Next, in the manifest file, add the Application class inside the application tags
- ALso, explicitly declare your network permissions. Without this your app wont be able to get data from the internet.
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!--You need to explittly include permisssions for internet-->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<application
android:name=".KtorInActionApplication"
<!-- Rest of the code -->
>
<!-- Rest of the code -->
</application>- Now, we need the UI from where we will be displaying our comments. Here, we display specific UI for different UIStates as shown in the code below.
Notice we use viewModel from
koinViewModel– since we havedependency injectionset up already withkoin
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CommentsListScreen() {
val viewModel: CommentsViewModel = koinViewModel()
val commentsUIState by viewModel.commentsUIState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = "Comments") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
)
)
},
content = { paddingValues ->
Column(
Modifier
.fillMaxSize()
.padding(paddingValues),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
AnimatedVisibility(visible = commentsUIState.comments.isNotEmpty()) {
LazyColumn {
items(items = commentsUIState.comments) { comment ->
CommentItem(comment = comment)
}
}
}
AnimatedVisibility(visible = commentsUIState.isLoading) {
CircularProgressIndicator()
}
AnimatedVisibility(visible = commentsUIState.error != null) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = "Error",
tint = Color.Red,
modifier = Modifier
.size(60.dp)
.padding(bottom = 10.dp)
)
Text(
text = "There Was An Error! Try Again...",
color = Color.Red,
fontSize = 24.sp
)
}
}
}
}
)
}
@Composable
fun CommentItem(comment: Comment) {
Card(
modifier = Modifier
.padding(top = 10.dp, start = 10.dp, end = 10.dp)
.fillMaxWidth(),
elevation = CardDefaults.cardElevation(4.dp)
) {
Column(
modifier = Modifier
.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 8.dp)
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center
) {
Text(
text = comment.email.first().uppercaseChar().toString(),
color = MaterialTheme.colorScheme.onPrimary,
fontWeight = FontWeight.Bold,
fontSize = 24.sp
)
}
Spacer(modifier = Modifier.width(8.dp))
Column {
Text(
text = comment.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = comment.email,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
Text(
text = comment.body,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 8.dp)
)
}
}
}- Now we need to use the
CommentsListScreen.ktin ourMainActivity.kt. Your newMainActivity.ktfile should be as follows
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.samueljuma.ktorinaction.screens.CommentsListScreen
import com.samueljuma.ktorinaction.ui.theme.KtorInActionTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
KtorInActionTheme {
CommentsListScreen()
}
}
}
}- At this point, all we need to run the project and see what we have created
- You should get a nice Comments App like this one here
![]() |
![]() |
![]() |
![]() |
|---|
Copyright 2024 Samuel Juma
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
