Kotlin

В 2018 году произошли серьезные изменения мира Android, особенно касательно Android Networking. Многие перешли от использования RxJava к Kotlin Coroutines, для обработки многопоточности в Android.

Поговорим о том, как выполнить вызов Android Networking API с помощью Retrofit2 и Kotlin Coroutines, и сделаем сетевой вызов API TMDB для получения популярных фильмов.


Вкратце об Android Networking

В сущности, android networking, как и любой другой networking, работает следующим образом:

  • Request — Выполнение HTTP-запроса к URL-адресу (конечной точке — endpoint) с соответствующими заголовками. При необходимости используется ключ авторизации.
  • Response — Запрос возвращает ответ, содержащий error или success. В случае успешного завершения запроса, ответ содержит содержимое endpoint (обычно в формате JSON)
  • Parse & Store — Парсинг JSON, получение необходимых значений и размещение их в классе данных.

В Android используются:

  • Okhttp — Для создания HTTP-запроса с соответствующими заголовками.
  • Retrofit — Для создания запроса
  • Moshi / GSON — Для парсинга данных JSON
  • Kotlin Coroutines — Для создания сетевых запросов, не блокирующих основной поток.
  • Picasso / Glide —Для скачивания и установки изображения в ImageView.

Начнем

В API TMDb (The Movie Database) находится список всех популярных, недавно вышедших, а также готовящихся к выпуску фильмов и сериалов. Это один из самых популярных API для различных экспериментов.

Для создания запросов в API TMDB требуется ключ API. Для этого нужно:

Скрытие ключа API в Version Control (Не обязательное, но рекомендуемое действие)

После получения ключа API выполните следующие действия, чтобы скрыть его в VCS.

  • Добавьте ключ в local.properties, находящийся в корневой папке (root folder).
  • Получите доступ к ключу в build.gradle с помощью программы.
  • Ключ будет доступен в программе через BuildConfig.
//In local.properties
tmdb_api_key = "xxxxxxxxxxxxxxxxxxxxxxxxxx"

//In build.gradle (Module: app)
buildTypes.each {
        Properties properties = new Properties()
        properties.load(project.rootProject.file("local.properties").newDataInputStream())
        def tmdbApiKey = properties.getProperty("tmdb_api_key", "")

        it.buildConfigField 'String', "TMDB_API_KEY", tmdbApiKey
        
        it.resValue 'string', "api_key", tmdbApiKey

}

//In your Constants File
var tmdbApiKey = BuildConfig.TMDB_API_KEY

Установка проекта

Для установки проекта добавляем необходимые зависимости в build.gradle (Module: app):

// build.gradle(Module: app)
dependencies {

    def moshiVersion="1.8.0"
    def retrofit2_version = "2.5.0"
    def okhttp3_version = "3.12.0"
    def kotlinCoroutineVersion = "1.0.1"
    def picassoVersion = "2.71828"

     
    //Moshi
    implementation "com.squareup.moshi:moshi-kotlin:$moshiVersion"
    kapt "com.squareup.moshi:moshi-kotlin-codegen:$moshiVersion"

    //Retrofit2
    implementation "com.squareup.retrofit2:retrofit:$retrofit2_version"
    implementation "com.squareup.retrofit2:converter-moshi:$retrofit2_version"
    implementation "com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter:0.9.2"

    //Okhttp3
    implementation "com.squareup.okhttp3:okhttp:$okhttp3_version"
    implementation 'com.squareup.okhttp3:logging-interceptor:3.11.0'
    
     //Picasso for Image Loading
    implementation ("com.squareup.picasso:picasso:$picassoVersion"){
        exclude group: "com.android.support"
    }

    //Kotlin Coroutines
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlinCoroutineVersion"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinCoroutineVersion"

Создание сервиса TmdbAPI

//ApiFactory to create TMDB Api
object Apifactory{
  
    //Creating Auth Interceptor to add api_key query in front of all the requests.
    private val authInterceptor = Interceptor {chain->
            val newUrl = chain.request().url()
                    .newBuilder()
                    .addQueryParameter("api_key", AppConstants.tmdbApiKey)
                    .build()

            val newRequest = chain.request()
                    .newBuilder()
                    .url(newUrl)
                    .build()

            chain.proceed(newRequest)
        }
  
   //OkhttpClient for building http request url
    private val tmdbClient = OkHttpClient().newBuilder()
                                .addInterceptor(authInterceptor)
                                .build()


  
    fun retrofit() : Retrofit = Retrofit.Builder()
                .client(tmdbClient)
                .baseUrl("https://api.themoviedb.org/3/")
                .addConverterFactory(MoshiConverterFactory.create())
                .addCallAdapterFactory(CoroutineCallAdapterFactory())
                .build()   

  
   val tmdbApi : TmdbApi = retrofit().create(TmdbApi::class.java)

}

Рассмотрим наши действия в ApiFactory.kt.

  • Сначала создаем Network Interceptor, чтобы добавить api_key во все запросы в качестве authInterceptor.
  • Затем создаем сетевого клиента с помощью OkHttp и добавляем authInterceptor.
  • Теперь соединяем все вместе, чтобы создать строителя (builder) и обработчика (handler) HTTP-запроса с помощью Retrofit. Здесь добавляем ранее созданного сетевого клиента, базовый URL, а также конвертер и адаптер.
    MoshiConverter участвует в парсинге JSON и конвертирует ответ JSON в класс данных Kotlin, при необходимости используя селективный парсинг.
    CoroutineCallAdaptor — это CallAdapter.Factory Retrofit2 для значения Deferred Kotlin coroutine.
  • Наконец создаем tmdbApi, передав ссылку на класс TmdbApi (Создание с следующем пункте) к ранее созданному классу retrofit.

Поиск по API Tmdb

Получаем следующий ответ для endpoint /movie/popular. Ответ возвращает результаты — массив объекта movie. Именно он нам и нужен.

{
  "page": 1,
  "total_results": 19848,
  "total_pages": 993,
  "results": [
    {
      "vote_count": 2109,
      "id": 297802,
      "video": false,
      "vote_average": 6.9,
      "title": "Aquaman",
      "popularity": 497.334,
      "poster_path": "/5Kg76ldv7VxeX9YlcQXiowHgdX6.jpg",
      "original_language": "en",
      "original_title": "Aquaman",
      "genre_ids": [
        28,
        14,
        878,
        12
      ],
      "backdrop_path": "/5A2bMlLfJrAfX9bqAibOL2gCruF.jpg",
      "adult": false,
      "overview": "Arthur Curry learns that he is the heir to the underwater kingdom of Atlantis, and must step forward to lead his people and be a hero to the world.",
      "release_date": "2018-12-07"
    },
    {
      "vote_count": 625,
      "id": 424783,
      "video": false,
      "vote_average": 6.6,
      "title": "Bumblebee",
      "popularity": 316.098,
      "poster_path": "/fw02ONlDhrYjTSZV8XO6hhU3ds3.jpg",
      "original_language": "en",
      "original_title": "Bumblebee",
      "genre_ids": [
        28,
        12,
        878
      ],
      "backdrop_path": "/8bZ7guF94ZyCzi7MLHzXz6E5Lv8.jpg",
      "adult": false,
      "overview": "On the run in the year 1987, Bumblebee finds refuge in a junkyard in a small Californian beach town. Charlie, on the cusp of turning 18 and trying to find her place in the world, discovers Bumblebee, battle-scarred and broken.  When Charlie revives him, she quickly learns this is no ordinary yellow VW bug.",
      "release_date": "2018-12-15"
    }
  ]
}

Теперь создаем класс данных Movie и класс MovieResponse в соответствии с json.

// Data Model for TMDB Movie item
data class TmdbMovie(
    val id: Int,
    val vote_average: Double,
    val title: String,
    val overview: String,
    val adult: Boolean
)

// Data Model for the Response returned from the TMDB Api
data class TmdbMovieResponse(
    val results: List<TmdbMovie>
)

//A retrofit Network Interface for the Api
interface TmdbApi{
    @GET("movie/popular")
    fun getPopularMovie(): Deferred<Response<TmdbMovieResponse>>
}

Интерфейс TmdbApi

После создания класса данных, мы создаем интерфейс TmdbApi. Ссылка на него находится в строителе retrofit. В интерфейс TmdbApi добавляем требуемые вызовы API и при необходимости параметры запроса. К примеру, чтобы получить фильм по id нужно добавить следующий метод в интерфейс:

interface TmdbApi{
    @GET("movie/popular")
    fun getPopularMovies() : Deferred<Response<TmdbMovieResponse>>
    @GET("movie/{id}")      
    fun getMovieById(@Path("id") id:Int): Deferred<Response<Movie>>
}

Выполнение сетевого вызова

Для получения необходимых данных выполняем сетевой вызов в DataRepository или в ViewModel или прямо в Activity.

Sealed Result Class

Класс, который используется для обработки ответа сети. Он может иметь значение Success с необходимыми данными или Error с исключением.

sealed class Result<out T: Any> {
    data class Success<out T : Any>(val data: T) : Result<T>()
    data class Error(val exception: Exception) : Result<Nothing>()
}

Построение BaseRepository для обработки safeApiCall

open class BaseRepository{

    suspend fun <T : Any> safeApiCall(call: suspend () -> Response<T>, errorMessage: String): T? {

        val result : Result<T> = safeApiResult(call,errorMessage)
        var data : T? = null

        when(result) {
            is Result.Success ->
                data = result.data
            is Result.Error -> {
                Log.d("1.DataRepository", "$errorMessage & Exception - ${result.exception}")
            }
        }


        return data

    }

    private suspend fun <T: Any> safeApiResult(call: suspend ()-> Response<T>, errorMessage: String) : Result<T>{
        val response = call.invoke()
        if(response.isSuccessful) return Result.Success(response.body()!!)

        return Result.Error(IOException("Error Occurred during getting safe Api result, Custom ERROR - $errorMessage"))
    }
}

Построение MovieRepository

class MovieRepository(private val api : TmdbApi) : BaseRepository() {
  
    fun getPopularMovies() : MutableList<TmdbMovie>?{
      
      //safeApiCall is defined in BaseRepository.kt (https://gist.github.com/navi25/67176730f5595b3f1fb5095062a92f15)
      val movieResponse = safeApiCall(
           call = {api.getPopularMovie().await()},
           errorMessage = "Error Fetching Popular Movies"
      )
      
      return movieResponse?.results.toMutableList();
    
    }

}

Создание View Model для получения данных

class TmdbViewModel : ViewModel(){
  
    private val parentJob = Job()

    private val coroutineContext: CoroutineContext
        get() = parentJob + Dispatchers.Default

    private val scope = CoroutineScope(coroutineContext)

    private val repository : MovieRepository = MovieRepository(ApiFactory.tmdbApi)
    

    val popularMoviesLiveData = MutableLiveData<MutableList<ParentShowList>>()

    fun fetchMovies(){
        scope.launch {
            val popularMovies = repository.getPopularMovies()
            popularMoviesLiveData.postValue(popularMovies)
        }
    }


    fun cancelAllRequests() = coroutineContext.cancel()

}

Использование ViewModel в Activity для обновления UI

class MovieActivity : AppCompatActivity(){
    
    private lateinit var tmdbViewModel: TmdbViewModel
  
     override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_movie)
       
        tmdbViewModel = ViewModelProviders.of(this).get(TmdbViewModel::class.java)
       
        tmdbViewModel.fetchMovies()
       
        tmdbViewModel.popularMovies.observe(this, Observer {
            //TODO - Your Update UI Logic
        })     
     }
}

Мы рассмотрели базовый полнофункциональный вызов API для Android. Больше примеров вы найдете здесь.

Удачного программирования!


Перевод статьи Navendra Jha: Android Networking in 2019 — Retrofit with Kotlin’s Coroutines