Android Kotlin Fundamentals Course 코드랩 하면서 노트. Android Kotlin Fundamentals: 8.1 Getting data from the internet 부터.

dep versions

Just in case.

buildscript {
  ext {
    // Versions for all the dependencies we plan to use. It's particularly useful for kotlin and
    // navigation where the versions of the plugin needs to be the same as the version of the
    // library defined in the app Gradle file

    version_android_gradle_plugin = '4.0.1'
    version_core = "1.3.1"
    version_constraint_layout = "1.1.3"
    version_glide = "4.8.0"
    version_kotlin = "1.3.72"
    version_lifecycle = "2.2.0"
    version_moshi = "1.8.0"
    version_navigation = "1.0.0"
    version_retrofit = "2.9.0"
    version_recyclerview = "1.0.0"
  }
}

Retrofit: A type-safe HTTP client

// build.gradle (project)
buildscript {
  ext {
    //...
    version_retrofit = "2.9.0"
  }
}
// build.gradle (module: app)
dependencies {
  // ...
  implementation "com.squareup.retrofit2:retrofit:$version_retrofit"
  implementation "com.squareup.retrofit2:converter-scalars:$version_retrofit"
}

android {
  // ...

  compileOptions {
    sourceCompatibility JavaVersion.VERSION_1_8
    targetCompatibility JavaVersion.VERSION_1_8
  }

  kotlinOptions {
    jvmTarget = JavaVersion.VERSION_1_8.toString()
  }
}

Then, create MarsApiService.kt.

package com.example.android.marsrealestate.network

import retrofit2.Call
import retrofit2.Retrofit
import retrofit2.converter.scalars.ScalarsConverterFactory
import retrofit2.http.GET

// private const val BASE_URL = "https://android-kotlin-fun-mars-server.appspot.com/"

private val retrofit = Retrofit.Builder()
  .addConverterFactory(ScalarsConverterFactory.create())
  .baseUrl(BASE_URL)
  .build()

interface MarsApiService {
  @GET("realestate")
  fun getProperties():
      Call<String>
}

object MarsApi {
  val retrofitService: MarsApiService by lazy {
    retrofit.create(MarsApiService::class.java)
  }
}

Call the web service in OverviewViewModel.kt.

class OverviewViewModel : ViewModel() {
  // ...
  private fun getMarsRealEstateProperties() {
    MarsApi.retrofitService.getProperties().enqueue(
      object : Callback<String> {
        override fun onFailure(call: Call<String>, t: Throwable) {
          _response.value = "Failure: " + t.message
        }

        override fun onResponse(call: Call<String>, response: Response<String>) {
          _response.value = response.body()
        }
      }
    )
  }
}

Add INTERNET permission in app/manifests/AndroidManifest.xml.

<manifest ...>
  <uses-permission android:name="android.permission.INTERNET" />
</manifest>

Moshi: JSON parser library

// build.gradle (project)
buildscript {
  ext {
    //...
    version_moshi = "1.8.0"
  }
}
// build.gradle (module: app)
dependencies {
  // ...
  implementation "com.squareup.moshi:moshi-kotlin:$version_moshi"
  // change retrofit2's scalars converter
  implementation "com.squareup.retrofit2:converter-moshi:$version_retrofit"
}

Then, sync.

Create the property data class.

// MarsProperty.kt
data class MarsProperty(
  val id: String,
  // val img_src: String,
  // to remap the name
  @Json(name = "img_src") val imgSrcUrl: String,
  val type: String,
  val price: Double
)

Update MarsApiService.kt with new converter.

/// MarsApiService.kt

private val moshi = Moshi.Builder()
  .add(KotlinJsonAdapterFactory())
  .build()

private val retrofit = Retrofit.Builder()
  .addConverterFactory(MoshiConverterFactory.create(moshi))
  .baseUrl(BASE_URL)
  .build()

interface MarsApiService {
  @GET("realestate")
  fun getProperties():
    Call<List<MarsProperty>>
}

Update the ViewModel.

class OverviewViewModel : ViewModel() {
  // ...
  private fun getMarsRealEstateProperties() {
    MarsApi.retrofitService.getProperties().enqueue(
      object : Callback<List<MarsProperty>> {
        override fun onFailure(call: Call<List<MarsProperty>>, t: Throwable) {
          _response.value = "Failure: " + t.message
        }

        override fun onResponse(call: Call<List<MarsProperty>>, response: Response<List<MarsProperty>>) {
          _response.value =
            "Success: ${response.body()?.size} Mars properties retrieved"
        }
      }
    )
  }
}

It is a good idea to use coroutines for asynchronous.

// MarsApiService.kt
// Change the function with suspend
interface MarsApiService {
  @GET("realestate")
  suspend fun getProperties():
    List<MarsProperty>
}

Then, replace the ViewModel like the code below using try {}, catch {}, and viewModelScope.

// OverviewViewModel.kt
private fun getMarsRealEstateProperties() {
  viewModelScope.launch {
    try {
      val listResult = MarsApi.retrofitService.getProperties()
      _response.value = "Success: ${listResult.size} Mars properties retrieved"
    } catch (e: Exception) {
      _response.value = "Failure: ${e.message}"
    }
  }
}

Glide: Media mgmt and loading framework

// build.gradle (project)
buildscript {
  ext {
    //...
    version_glide = "4.8.0"
  }
}
// build.gradle (module: app)
dependencies {
  // ...
  implementation "com.github.bumptech.glide:glide:$version_glide"
}

Update the view model.

// OverviewViewModel.kt
private val _property = MutableLiveData<MarsProperty>()

val property: LiveData<MarsProperty>
  get() = _property

private fun getMarsRealEstateProperties() {
  viewModelScope.launch {
    try {
      val listResult = MarsApi.retrofitService.getProperties()
      if (listResult.size > 0) {
          _property.value = listResult[0]
      }
    } catch (e: Exception) {
      _response.value = "Failure: ${e.message}"
    }
  }
}

Add BindingAdapters.kt.

// BindingAdapters.kt
@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {
  imgUrl?.let {
    val imgUri = imgUrl.toUri().buildUpon().scheme("https").build()
    Glide.with(imgView.context)
      .load(imgUri)
      .into(imgView)
  }
}

Update gridview_item.xml.

<layout ...>
  <ImageView
      android:id="@+id/mars_image"
      ...
      app:imageUrl="@{viewModel.property.imgSrcUrl}" />

  <data>
    <variable
      name="viewModel"
      type="com.example.android.marsrealestate.overview.OverviewViewModel" />
  </data>
</layout>

Change the layout from FragmentOverviewBinding to GridViewItemBinding in the fragment to quick test.

// val binding = FragmentOverviewBinding.inflate(inflater)
val binding = GridViewItemBinding.inflate(inflater)

Add loading and broken image

Add files in res/drawable. Here, ic_broken_image.xml and loading_animation.xml.

Update BindingAdapters.kt and reflect this file in bindImage().

// BindingAdapter.kt
@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {
  imgUrl?.let {
    val imgUri = imgUrl.toUri().buildUpon().scheme("https").build()
    Glide.with(imgView.context)
      .load(imgUri)
      .apply(RequestOptions()
        .placeholder(R.drawable.loading_animation)
        .error(R.drawable.ic_broken_image))
      .into(imgView)
  }
}

Update RecyclerView with loaded data

// OverviewViewModel.kt
private val _properties = MutableLiveData<List<MarsProperty>>()
val properties: LiveData<List<MarsProperty>>
  get() = _properties

// ...
private fun getMarsRealEstateProperties() {
  viewModelScope.launch {
    try {
      _properties.value = MarsApi.retrofitService.getProperties()
      _response.value = "Success: Mars properties retrieved"
    } catch (e: Exception) {
      _response.value = "Failure: ${e.message}"
    }
  }
}

Update gridview_item.xml for the each property.

<layout ...>
  <ImageView
    android:id="@+id/mars_image"
    ...
    app:imageUrl="@{property.imgSrcUrl}" />

  <data>
    <variable
      name="property"
      type="com.example.android.marsrealestate.network.MarsProperty" />
  </data>
</layout>

Change the layout back to FragmentOverviewBinding in the fragment.

val binding = FragmentOverviewBinding.inflate(inflater)
// val binding = GridViewItemBinding.inflate(inflater)

Replace <TextView> to <RecyclerView> in fragment_overview.xml.

<androidx.recyclerview.widget.RecyclerView
  android:id="@+id/photos_grid"
  android:layout_width="0dp"
  android:layout_height="0dp"
  android:padding="6dp"
  android:clipToPadding="false"
  app:layoutManager=
      "androidx.recyclerview.widget.GridLayoutManager"
  app:layout_constraintBottom_toBottomOf="parent"
  app:layout_constraintLeft_toLeftOf="parent"
  app:layout_constraintRight_toRightOf="parent"
  app:layout_constraintTop_toTopOf="parent"
  app:spanCount="2"
  tools:itemCount="16"
  tools:listitem="@layout/grid_view_item" />

Then, Add the photo grid adapter.

// PhotoGridAdapter.kt
class PhotoGridAdapter : ListAdapter<MarsProperty, PhotoGridAdapter.MarsPropertyViewHolder>(DiffCallback) {

  override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MarsPropertyViewHolder {
    return MarsPropertyViewHolder(GridViewItemBinding.inflate(LayoutInflater.from(parent.context)))
  }

  override fun onBindViewHolder(holder: MarsPropertyViewHolder, position: Int) {
    val marsProperty = getItem(position)
    holder.bind(marsProperty)
  }

  companion object DiffCallback : DiffUtil.ItemCallback<MarsProperty>() {
    override fun areItemsTheSame(oldItem: MarsProperty, newItem: MarsProperty): Boolean {
      return oldItem == newItem
    }

    override fun areContentsTheSame(oldItem: MarsProperty, newItem: MarsProperty): Boolean {
      return oldItem.id == newItem.id
    }
  }

  class MarsPropertyViewHolder(private var binding: GridViewItemBinding) : RecyclerView.ViewHolder(binding.root) {
    fun bind(marsProperty: MarsProperty) {
      binding.property = marsProperty
      binding.executePendingBindings()
    }
  }
}

Update the Binding adapter.

// BindingAdapters.kt
@BindingAdapter("listData")
fun bindRecyclerView(recyclerView: RecyclerView, data: List<MarsProperty>?) {
  val adapter = recyclerView.adapter as PhotoGridAdapter
  adapter.submitList(data)
}

Update the fragment.

<!-- fragment_overview.xml -->
<RecyclerView ...
  app:listData="@{viewModel.properties}" />

Add the adapter into the fragment.

binding.photosGrid.adapter = PhotoGridAdapter()
// Note: photos_grid is a name of the recycler view

Add error handling in RecyclerView

Add status in the view model.

enum class MarsApiStatus { LOADING, ERROR, DONE }

class OverviewViewModel : ViewModel() {

  // ...

  private val _status = MutableLiveData<MarsApiStatus>()
  val status: LiveData<MarsApiStatus>
    get() = _status

  private fun getMarsRealEstateProperties() {
    viewModelScope.launch {
      _status.value = MarsApiStatus.LOADING
      try {
        _properties.value = MarsApi.retrofitService.getProperties()
        _status.value = MarsApiStatus.DONE

      } catch (e: Exception) {
        _status.value = MarsApiStatus.ERROR
        _properties.value = ArrayList()
      }
    }
  }
}

Update BindingAdapters.kt.

// BindingAdapters.kt

@BindingAdapter("marsApiStatus")
fun bindStatus(statusImageView: ImageView, status: MarsApiStatus?) {
  when (status) {
    MarsApiStatus.LOADING -> {
      statusImageView.visibility = View.VISIBLE
      statusImageView.setImageResource(R.drawable.loading_animation)
    }
    MarsApiStatus.ERROR -> {
      statusImageView.visibility = View.VISIBLE
      statusImageView.setImageResource(R.drawable.ic_connection_error)
    }
    MarsApiStatus.DONE -> {
      statusImageView.visibility = View.GONE
    }
  }
}

Then, add the image view for the status.

<!-- fragment_overview.xml -->
<ImageView
   android:id="@+id/status_image"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:marsApiStatus="@{viewModel.status}" />

다음 챕터: Android Kotlin Fundamentals and detail views with internet data

색상을 바꿔요

눈에 편한 색상을 골라보세요 :)

Darkreader 플러그인으로 선택한 색상이 제대로 표시되지 않을 수 있습니다.