Consuming web-based JSON APIs is quite different on Android than in web development.
In this article, I will guide you through setting up an MVVM (Model-View-ViewModel) architecture using Jetpack Compose and Kotlin in Android Studio.
Permissions
To make the app able to connect to the internet, you will have to add the user-permission to the Android manifest
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application>
...
</application>
</manifest>
Gradle Script
To make it easier for us to perform HTTP requests, use JSON data in our app, and render images, add the following dependencies to your build.gradle.kts (Module :app)
...
dependencies {
// ViewModel
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
// Network calls
implementation("com.squareup.retrofit2:retrofit:2.9.0")
// JSON to Kotlin object mapping
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
// Image loader
implementation("io.coil-kt:coil-compose:2.6.0")
....
}
Sync the Gradle build by clicking on "Sync now". This will download the dependencies.
Data Class
A class to store the data from the API
When fetching data from the web API, the data must be placed in objects.
Add a Data Class Kotlin file appropriately named after the data you will get from the API.
package no.haxor.jsonapi
data class ArticleThumbnail(
val title: String,
val image: String
)
The attributes of the ArticleThumbnail class match 1:1 with the names of the values in the JSON received from the API. In this example, I only use two of the attributes coming from the JSON API.
Model / API Service
To fetch the data and store it in memory, you have to create a Model acting as an API service. This Model will be used to get the data from the API.
package no.haxor.jsonapi
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.GET
private val retrofit = Retrofit.Builder()
.baseUrl("https://haxor.no/actions/haxor/")
.addConverterFactory(GsonConverterFactory.create())
.build()
val thumbnailApiService= retrofit.create(ThumbnailApiService::class.java)
interface ThumbnailApiService {
@GET("articles/all")
suspend fun getThumbnails(): List<ArticleThumbnail>
}
The getThumbnails() function in the ThumbnailApiInterface will automagically transform the list of articles in the JSON response from the API, to a list of ArticlesThumbnail objects.
By creating this abstraction in this Model, we can focus on storing the data in memory in the ViewModel, and presenting the data in the View.
ViewModel
Connecting the view to the model
Add a Kotlin class contaning the ViewModel.
package no.haxor.myrecipeapp
import android.util.Log
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.compose.runtime.State // Manually import this!
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
class ThumbnailViewModel: ViewModel() {
private val _thumbnailState = mutableStateOf(ThumbnailState())
val thumbnailState : State<ThumbnailState> = _thumbnailState
init {
fetchThumbnails()
}
private fun fetchThumbnails(){
viewModelScope.launch { // Async scope.
try {
val response = thumbnailApiService.getThumbnails()
_thumbnailState .value = _thumbnailState .value.copy(
loading = false,
list = response,
error = null
)
} catch (e: Exception){
_thumbnailState .value = _thumbnailState .value.copy(
loading = false,
error = "Error fetching thumbnails. ${e.message}"
)
Log.i("info", e.message.toString())
}
}
}
data class ThumbnailState(
val loading: Boolean = true,
val list: List<ArticleThumbnail> = emptyList(),
val error: String? = null
)
}
On lines 10 and 11, we create a private value to store the ViewModel's state and a public value accessible outside this class.
When the class is instantiated, it fetches data from the API. It stores it in a private variable, along with other related states, to indicate whether the data is being loaded and to track any errors.
By using the ViewModel, data from the API will remain in memory even if you suspend the app or rotate your device.
View
Displaying the data
Finally, you can use the ViewModel in a View using Jetpack Compose.
Create the Kotlin file containing the View.
package no.haxor.jsonapi
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
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.layout.ContentScale
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.rememberAsyncImagePainter
@Composable
fun ThumbnailView(padding: PaddingValues){
val thumbnailViewModel: ThumbnailViewModel = viewModel()
val viewState by thumbnailViewModel.thumbnailState
Box(modifier = Modifier.fillMaxSize().padding(padding)){
when{
viewState.loading -> {
CircularProgressIndicator(Modifier.align(Alignment.Center))
}
viewState.error != null -> {
Text(viewState.error.toString())
}
else -> {
ThumbnailListing(viewState.list)
}
}
}
}
@Composable
fun ThumbnailListing(thumbnails: List<ArticleThumbnail>){
LazyVerticalGrid(
columns = GridCells.Fixed(2)
) {
items(thumbnails){
thumbnail -> ThumbnailItem(thumbnail = thumbnail)
}
}
}
@Composable
fun ThumbnailItem(thumbnail: ArticleThumbnail){
Column(
modifier = Modifier.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = rememberAsyncImagePainter(thumbnail.image),
contentDescription = null,
modifier = Modifier.fillMaxWidth()
.aspectRatio(3f / 2f)
.clip(RoundedCornerShape(12.dp)),
contentScale = ContentScale.Crop
)
Text(
text = thumbnail.title,
color = Color.Black,
style = TextStyle(fontWeight = FontWeight.Medium),
modifier = Modifier.padding(2.dp).wrapContentSize(),
textAlign = TextAlign.Center,
)
}
}
Finally, we can implement the View in the Main Activity.
package no.haxor.jsonapi
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Scaffold
import androidx.compose.ui.Modifier
import no.haxor.jsonapi.ui.theme.JsonApiTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
JsonApiTheme{
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
ThumbnailView(
padding = innerPadding
)
}
}
}
}
}
The PoC in all its glory
Congratulations, you have now fetched some data from a JSON API and used it in an Android app using the MVVM architecture, and Jetpack Compose. 🎉