Difficulty: Beginner
Ktor is an excellent Http client for android it has many features such as web sockets, monitoring progress when uploading a file and the ability to use it in KMM projects across different operating system like Android and IOS.
In this article we will create a simple app that makes an API request using FakeApi and displays the list of posts returned by the API in a Recycler-View.
Important note: the best practice is to use a dependency injection framework like Dagger Hilt and provide your Ktor instance that way, but for simplicity in this tutorial we are not going to use any dependency injection framework.
Setup
Add the internet permisson in you AndroidManifest.xml file
<uses-permission android:name="android.permission.INTERNET" />
add the Ktor dependencies in your module’s build.gradle file, you can find the latest version here.
- Compose
- XML
val ktor_version = "2.2.3"
implementation ("io.ktor:ktor-client-core:$ktor_version")
implementation ("io.ktor:ktor-client-android:$ktor_version")
implementation ("io.ktor:ktor-client-serialization:$ktor_version")
implementation ("io.ktor:ktor-client-logging:$ktor_version")
implementation ("io.ktor:ktor-client-content-negotiation:$ktor_version")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
val lifecycle_version = "2.8.5"
// ViewModel
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version")
// ViewModel utilities for Compose
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version")
// for loading the image (optional)
implementation("io.coil-kt:coil-compose:2.7.0")
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.0"
plugins {
id 'org.jetbrains.kotlin.plugin.serialization'
}
//for viewModels and viewModelScope
implementation 'androidx.activity:activity-ktx:1.6.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1'
def ktor_version = "2.2.3"
implementation "io.ktor:ktor-client-core:$ktor_version"
implementation "io.ktor:ktor-client-android:$ktor_version"
implementation "io.ktor:ktor-client-serialization:$ktor_version"
implementation "io.ktor:ktor-client-logging:$ktor_version"
implementation "io.ktor:ktor-client-content-negotiation:$ktor_version"
implementation "io.ktor:ktor-serialization-kotlinx-json:$ktor_version"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.0"
plugins {
id 'org.jetbrains.kotlin.plugin.serialization' version '1.8.0'
}
Designing our UI
- Compose
- XML
@Composable
fun ScreenOneContent(modifier: Modifier = Modifier) {
val postsViewModel: PostsViewModel = viewModel()
LazyColumn(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), modifier = Modifier.fillMaxSize()) {
item {
if (postsViewModel.postsList.isEmpty()) {
CircularProgressIndicator()
}
}
items(postsViewModel.postsList){ post ->
PostItem(modifier = Modifier.fillMaxWidth(), title = post.title, content = post.content, picture = post.picture)
}
}
}
@Composable
fun PostItem(modifier: Modifier = Modifier, title: String, content: String , picture: String) {
Card (modifier = Modifier) {
Column(modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(title, fontSize = 24.sp, fontWeight = FontWeight.Bold)
Text(content, fontSize = 16.sp)
}
}
}
In our main activity add a recyclerview like below.
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.posts.MainActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_posts"
android:layout_width="0dp"
android:layout_height="0dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ProgressBar
android:id="@+id/pb_posts"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.5" />
</androidx.constraintlayout.widget.ConstraintLayout>
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:backgroundTint="#45F0F4C3"
app:cardCornerRadius="8dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingBottom="16dp">
<ImageView
android:id="@+id/iv_picture"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="32dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@tools:sample/avatars" />
<TextView
android:id="@+id/tv_username"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginEnd="7dp"
app:layout_constraintEnd_toEndOf="@+id/iv_picture"
app:layout_constraintTop_toBottomOf="@+id/iv_picture"
tools:text="user" />
<TextView
android:id="@+id/tv_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginTop="13dp"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
app:layout_constraintEnd_toEndOf="@+id/tv_username"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tv_username"
tools:text="Let me explain" />
<TextView
android:id="@+id/tv_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginTop="17dp"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
app:layout_constraintEnd_toEndOf="@+id/tv_title"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tv_title"
tools:text="But I must explain to you how all this mistaken idea of denouncing pleasure and praising pain was born and I will give you a complete account of the system, and expound the actual teachings of the great explorer of the truth, the master-builder of human happiness. No one rejects," />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
In this example we are going to use the end point ‘/posts’ in our ‘model’ package Lets create a Data class to represent the JSON that we will receive from the server, notice that we annotate the class with ‘@Serializable’ this annotation allows Ktor to map the JSON key value pairs with our Post class.
Pro tip: you can use the Json To Kotlin class plugin in Android Studio to automatically create a data class for you
@Serializable
data class Post(
val id: String,
val title: String,
val content: String,
val slug: String,
val picture: String,
val user: String
)
{"id":"1","title":"Let me explain","content":"But I must explain to you how all this mistaken idea of denouncing pleasure and praising pain was born and I will give you a complete account of the system, and expound the actual teachings of the great explorer of the truth, the master-builder of human happiness. No one rejects,","slug":"let-me-explain","picture":"https:\/\/fakeimg.pl\/350x200\/?text=FreeFakeAPI","user":"\/api\/users\/1","_links":{"self":{"href":"\/api\/posts\/1"},"modify":{"href":"\/api\/posts\/1"},"delete":{"href":"\/api\/posts\/1"}}}
- Compose
- XML
Nothing To Do
Now lets create an Adapter class for our recyclerview items, create a package ‘ui’ inside ‘ui’ create another package lets call it ‘posts’ for our main screen inside it create the PostAdapter.
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.example.ktorsampleapp.databinding.PostItemBinding
import com.example.ktorsampleapp.model.remote.Post
/**
* Created by Taha Ben Ashur (https://github.com/tahaak67) on 07,Feb,2023
*/
class PostAdapter : ListAdapter<Post, RecyclerView.ViewHolder>(DIFF_CALLBACK){
companion object{
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<Post>() {
override fun areItemsTheSame(oldItem: Post, newItem: Post): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Post, newItem: Post): Boolean {
return oldItem.title == newItem.title || oldItem.content == newItem.content ||
oldItem.picture == newItem.picture
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int):
RecyclerView.ViewHolder {
val binding: PostItemBinding =
PostItemBinding.inflate(LayoutInflater.from(parent.context),parent, false)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (holder is ViewHolder){
val item = getItem(position)
holder.bind(item)
}
}
inner class ViewHolder(val itemBinding: PostItemBinding):
RecyclerView.ViewHolder(itemBinding.root) {
fun bind(item: Post){
itemBinding.apply {
tvTitle.text = item.title
tvContent.text = item.content
}
}
}
}
Creating the Ktor Client
In our ‘model’ package create a new Kotlin object lets call it “Provider”, this object will hold a reference to our Ktor client instance.
import io.ktor.client.*
import io.ktor.client.engine.android.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
/**
* Created by Taha Ben Ashur (https://github.com/tahaak67) on 07,Feb,2023
*/
object Provider {
val client: HttpClient = HttpClient(Android) {
//to map json objects returned from the api to a kotlin data class
install(ContentNegotiation) {
json(Json {
//ignores json keys we have not included in our data class
ignoreUnknownKeys = true
})
}
//a logger to see logging information about every request we make using the client
install(Logging) {
level = LogLevel.ALL
}
}
}
In our ‘model’ package create a new interface, this interface will include all the functions needed to communicate with our API, this can be called “PostsService” or “PostsApi”.
interface PostsApi {
suspend fun getPosts(): List<Post>
}
Next we will need a place to store the base url to our api and our route, for this i will create a Kotlin object in the ‘model’ package and call it ‘EndPoints’
object EndPoints {
private const val BASE_URL = "https://freefakeapi.io/api/"
const val POSTS = "$BASE_URL/posts"
}
Lets create an implementation for our PostApi interface, this will have the actual logic that uses the client to make the GET request.
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
/**
* Created by Taha Ben Ashur (https://github.com/tahaak67) on 04,Feb,2023
*/
class PostsApiImpl(
private val client: HttpClient
): PostsApi {
override suspend fun getPosts(): List<Post> {
return try {
client.get {
url(Routes.POSTS)
}.body()
} catch (e: RedirectResponseException) {
Log.e("PostsApi", "3XX Error: ${e.message}")
emptyList()
} catch (e: ClientRequestException) {
Log.e("PostsApi", "4XX Error: ${e.message}")
emptyList()
} catch (e: ServerResponseException) {
Log.e("PostsApi", "5XX Error: ${e.message}")
emptyList()
} catch (e: Exception) {
Log.e("PostsApi", "Error: ${e.message}")
emptyList()
}
}
}
Creating the ViewModel
Now that we implemented our API service we need to make the request somewhere the best place to do this is inside a viewModel class, in our ‘posts’ package lets create a new class and call it PostsViewModel make sure the class inherits ViewModel from ‘androidx.lifecycle’.
import androidx.lifecycle.ViewModel
class PostViewModel: ViewModel() {
}
- Compose
- XML
Next create an instance of the PostsApiImpl and make the api call in the init block of the viewModel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.democlass.data.PostItem
import io.ktor.client.call.body
import io.ktor.client.request.get
import kotlinx.coroutines.launch
/* Created by Taha https://github.com/tahaak67/ at 5/9/2024 */
class PostsViewModel: ViewModel() {
private var postsApi: PostsApi = PostsApiImpl(Provider.client)
var postsList by mutableStateOf(emptyList<PostItem>())
private set
init {
viewModelScope.launch {
postsList = postsApi.getPosts()
}
}
}
Next create an instance of the PostsApiImpl and create a function that calls getPosts() and stores the results in a LiveData object.
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.ktorsampleapp.model.remote.Post
import com.example.ktorsampleapp.model.remote.PostsApi
import com.example.ktorsampleapp.model.remote.PostsApiImpl
import com.example.ktorsampleapp.utils.Provider
import kotlinx.coroutines.launch
/**
* Created by Taha Ben Ashur (https://github.com/tahaak67) on 07,Feb,2023
*/
class PostViewModel: ViewModel() {
private var postsApi: PostsApi = PostsApiImpl(Provider.client)
private val _posts: MutableLiveData<List<Post>> = MutableLiveData()
val posts: LiveData<List<Post>> get() = _posts
init {
loadPostsFromApi()
}
fun loadPostsFromApi(){
viewModelScope.launch {
_posts.value = postsApi.getPosts()
}
}
}
Using the View Model
- Compose
- XML
Call the viewmodel using the compose viewModel() function like below
@Composable
fun ScreenOneContent(modifier: Modifier = Modifier) {
val postsViewModel: PostsViewModel = viewModel()
LazyColumn(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), modifier = Modifier.fillMaxSize()) {
item {
if (postsViewModel.postsList.isEmpty()) {
// if list is empty show a progress indicator
CircularProgressIndicator()
}
}
items(postsViewModel.postsList){ post ->
PostItem(modifier = Modifier.fillMaxWidth(), title = post.title, content = post.content, picture = post.picture)
}
}
}
Finally we use the view model in our activity and observe changes to our live data
import android.os.Bundle
import android.view.View
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import com.example.ktorsampleapp.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val viewModel: PostViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
val adapter = PostAdapter()
binding.rvPosts.adapter = adapter
//start observing our data
viewModel.posts.observe(this) { posts ->
if (posts.isEmpty()) {
//show a progress bar if the list is empty
binding.pbPosts.visibility = View.VISIBLE
} else {
//otherwise hide the progress bar
binding.pbPosts.visibility = View.GONE
adapter.submitList(posts)
}
}
}
}
Results
When we lunch our app we should see the following:
Extra: Displaying images using coil
Lets use the Coil library to load the images into our image views, first add the coil dependency to build.gradle(module), find the latest version here.
- Compose
- XML
you can already find the dependency in the first section above to use it we just need to call Async image in our ‘PostItem’ composable like below:
AsyncImage(model = picture, contentDescription = "", modifier = Modifier.size(150.dp))
implementation 'io.coil-kt:coil:2.2.2'
in our PostAdapter in the bind function add the following line
ivPicture.load(item.picture)
And now our posts have pictures!
as always the source code is available on Github. Compose version Here.
[…] this example i am going to use the project from my article Making API request using Ktor client in Android as a starter project for this tutorial please bare in mind that this tutorial is not intended for […]