APIs e Microsserviços

23 out, 2017

RXJava + Kotlin + Retrofit + Star Wars API

Publicidade

Olá povo!

Há algum tempo venho estudando dois tópicos relacionados a Android que vem me deixando bem empolgado: Kotlin e RX Java.

Kotlin é uma liguagem dinâmica para JVM desenvolvida pela JetBrains que traz diversas features não existentes no Java. E o RX Java, bem a grosso modo, é uma biblioteca nos ajuda a trabalhar com sequência de dados que podem estar em threads separadas de uma maneira bem mais simples.

O intuito deste artigo não é ensinar a dar os primeiros passos com RX ou Kotlin, mas sim, documentar e compartilhar o que eu aprendi ao tentar implementar um exemplo “simples” com esse conjunto de linguagem+biblioteca+api.

Nesse artigo, vou mostrar:

  • Como acessar a Star Wars API utilizando a biblioteca Retrofit
  • Fazer as requisições utilizando RX Java + Retrofit
  • E todo o código é escrito em Kotlin

Configuração do projeto

A primeira coisa que você deve fazer é instalar o plugin do Kotlin no Android Studio. Você pode seguir esse tutorial: https://blog.jetbrains.com/kotlin/2013/08/working-with-kotlin-in-android-studio/

A versão atual no momento da escrita desse artigo é a 1.1.0-release-Studio2.3-1.

  • Instalado o plugin, crie um novo projeto no Android Studio.
  • Deixe o build.gradle do seu projeto como a seguir:
buildscript {
    ext.kotlin_version = '1.1.0'
    ext.appcompat_version = '25.1.0'
    ext.retrofit_version = '2.2.0'
 
    repositories {
        jcenter()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:2.3.0'
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }
}
 
allprojects {
    repositories {
        jcenter()
    }
}

O que temos de diferente aqui é que estamos criando algumas variáveis com as versões do Kotlin e da biblioteca de compatibilidade. E na seção de dependências adicionamos o plugin do Kotlin para o Gradle.

Vá agora até o build.gradle do módulo, faça as seguintes alterações:

apply plugin: 'com.android.application'
apply plugin: "kotlin-android"
 
android {
...
}
 
dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    // Dependência da linguagem Kotlin
    compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
    // AppCompat
    compile "com.android.support:appcompat-v7:$appcompat_version"
    // RXJava
    compile 'io.reactivex:rxjava:1.2.5'
    // RXAndroid para termos acesso a main thread do Android 
    compile 'io.reactivex:rxandroid:1.2.1'
    // Retrofit
    compile "com.squareup.retrofit2:retrofit:$retrofit_version"
    // Adapter do Retrofit para retornar objetos observáveis
    compile "com.squareup.retrofit2:adapter-rxjava:$retrofit_version"
    // Converter do Retrofit para utilizar o Gson para tratar a resposta do servidor
    compile "com.squareup.retrofit2:converter-gson:$retrofit_version"
    // Interceptor para visualizar os logs das requisições do Retrofit
    compile 'com.squareup.okhttp3:logging-interceptor:3.6.0'
    ...
}

Aplicamos o plugin do Kotlin e adicionamos as dependências que utilizaremos no projeto. A motivação de cada uma está comentada acima.

A API do Star Wars

Vamos utilizar nesse exemplo dois endpoints da API do Star Wars: films e people.
Se fizermos uma requisição para http://swapi.co/api/films, o resultado será o JSON com a lista dos filmes de Star Wars.

{
    "count": 7, 
    "next": null, 
    "previous": null, 
    "results": [
        {
            "characters": [
                "http://swapi.co/api/people/1/",
                ...
            ],
            "created": "2014-12-10T14:23:31.880000Z",
            "director": "George Lucas",
            "edited": "2014-12-12T11:24:39.858000Z",
            "episode_id": 4,
            "opening_crawl": "It is a period of civil war..",
            "planets": [
                "http://swapi.co/api/planets/1/",
                ...
            ],
            "producer": "Gary Kurtz, Rick McCallum",
            "release_date": "1977-05-25",
            "species": [
                "http://swapi.co/api/species/1/",
                ...
            ],
            "starships": [
                "http://swapi.co/api/starships/2/",
                ...
            ],
            "title": "A New Hope",
            "url": "http://swapi.co/api/films/1/",
            "vehicles": [
                "http://swapi.co/api/vehicles/4/",
                ...
            ]
        },
        // Aqui viriam os demais filmes...
    ]
}

Se utilizarmos http://swapi.co/api/films/1 ele trará apenas o primeiro filme. Percebam que os campos “characters”, “planets”, “species”, “starships” e “vehicles” retornam um array de strings, onde cada string representa o endereço para aquela determinada informação. Sendo assim, se acessarmos a URL http://swapi.co/api/people/1/, teremos o resultado abaixo:

{
    "name": "Luke Skywalker", 
    "height": "172", 
    "mass": "77", 
    "hair_color": "blond", 
    "skin_color": "fair", 
    "eye_color": "blue", 
    "birth_year": "19BBY", 
    "gender": "male", 
    "homeworld": "http://swapi.co/api/planets/1/", 
    "films": [
        "http://swapi.co/api/films/6/", 
        "http://swapi.co/api/films/3/", 
        "http://swapi.co/api/films/2/", 
        "http://swapi.co/api/films/1/", 
        "http://swapi.co/api/films/7/"
    ], 
    "species": [
        "http://swapi.co/api/species/1/"
    ], 
    "vehicles": [
        "http://swapi.co/api/vehicles/14/", 
        "http://swapi.co/api/vehicles/30/"
    ], 
    "starships": [
        "http://swapi.co/api/starships/12/", 
        "http://swapi.co/api/starships/22/"
    ], 
    "created": "2014-12-09T13:50:51.644000Z", 
    "edited": "2014-12-20T21:17:56.891000Z", 
    "url": "http://swapi.co/api/people/1/"
}

Note que temos uma referência cruzada aqui. O filme possui a lista de personagens e o personagem possui uma lista dos filmes (no campo “films”) em que ele participou.

Entendida a API, vamos começar a brincar com ela!

Definindo as classes de modelo

Um dos recursos que eu gosto bastante do Kotlin é a possibilidade de criar data classes, que são os nosso famosos POJOs. É possível criar várias classes públicas no mesmo arquivo e no Kotlin temos o conceito de propriedade, ou seja, não é preciso definir os gets e sets (embora você possa customiza-los).

Crie o arquivo DataClassesWeb.kt (ou o nome que preferir) que conterá as classes que representarão o retorno das requisições que faremos a API.

package br.com.nglauber.starwarsrx.model.api
 
import com.google.gson.annotations.SerializedName
 
data class FilmResult(val results : List<Film>)
 
data class Film (val title : String,
                 @SerializedName("episode_id")
                 val episodeId : Int,
                 @SerializedName("characters")
                 val personUrls : List<String>)
 
data class Person(val name : String,
                  val gender : String)

A classe FilmResult representará o retorno da chamada que faremos a lista de filmes. Ela possui a propriedade results que é uma lista de Film. A classe Film, por sua vez, possui o título, o id do episódio e a lista das URLs para obtermos as informações dos personagens. Por fim, a classe Person possui o nome e o gênero do personagem.

Agora crie mais um arquivo chamado DataClasses.kt com as classes “de negócio” da nossa aplicação.

ackage br.com.nglauber.starwarsrx.model
 
data class Movie (val title : String,
                  val episodeId : Int,
                  val characters : MutableList<Character>)
 
data class Character(val name : String,
                     val gender : String){
 
    override fun toString(): String {
        return "${name} / ${gender}"
    }
}

Como podemos observar, essas classes são bem parecidas, mas preferi separar as classes de retorno de API, das que serão utilizadas na UI.

Definindo as chamadas à API com Retrofit

Nesse exemplo, vamos utilizar apenas dois endpoints da API do Star Wars: o que retorna a listagem de filmes; e o que obtém o personagem pelo seu id. Sendo assim, crie o arquivo StarWarsApiDef.kt e deixe-o como a seguir:

package br.com.nglauber.starwarsrx.model.api
 
import retrofit2.http.GET
import retrofit2.http.Path
import rx.Observable
 
interface StarWarsApiDef {
  @GET("films")
  fun listMovies() : Observable<FilmResult>
 
  @GET("people/{personId}")
  fun loadPerson(@Path("personId") personId : String) : Observable<Person> 
}

Os métodos seguem o que está especificado na API do Star Wars. Para obtermos a lista de filmes, realizamos uma requisição do tipo GET, para o endpoint “films” que retorna um objeto (FilmResult) que possui uma lista de filmes (Film). E para obter um personagem específico, utilizamos o endpoint “people/id_do_personagem”.

Percebam que estamos retornando um Observable<FilmResult> e um Observable<Person>. A classe Observable é um dos principais componentes do RXJava (senão o principal). Se você não está familiarizado com esses conceitos, sugiro assistir as palestras do Ubiratan Soares que é uma verdadeira aula sobre o assunto (veja os links no final do artigo).

Definida a classe com os endpoints, vamos criar a implementação que utilizará esses endpoints. Crie o arquivo StarWarsApi.kt e deixe-o como a seguir.

package br.com.nglauber.starwarsrx.model.api
 
import br.com.nglauber.starwarsrx.model.Character
import br.com.nglauber.starwarsrx.model.Movie
import com.google.gson.GsonBuilder
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory
import retrofit2.converter.gson.GsonConverterFactory
import rx.Observable
import java.util.*
 
class StarWarsApi {
  val service: StarWarsApiDef
 
  init {
      val logging = HttpLoggingInterceptor()
      logging.level = HttpLoggingInterceptor.Level.BODY
 
      val httpClient = OkHttpClient.Builder()
      httpClient.addInterceptor(logging)
 
      val gson = GsonBuilder().setLenient().create()
 
      val retrofit = Retrofit.Builder()
            .baseUrl("http://swapi.co/api/")
            .addCallAdapterFactory(RxJavaCallAdapterFactory.create())
            .addConverterFactory(GsonConverterFactory.create(gson))
            .client(httpClient.build())
            .build()
 
      service = retrofit.create<StarWarsApiDef>(StarWarsApiDef::class.java)
  }
 
  fun loadMovies(): Observable<Movie>? {
    return service.listMovies()
            .flatMap { filmResults -> Observable.from(filmResults.results) }
            .map { film ->
                Movie(film.title, film.episodeId, ArrayList<Character>())
            }
    }
}

Essa classe possui um atributo chamado service do tipo StarWarsApiDef (que criamos anteriormente). Dentro do bloco init{} fazemos a inicialização e configuração do serviço do Retrofit. Adicionamos o HttpLoggingInterceptor ao OkHttpClient para podermos visualizar no Logcat as requisições e as respostas feitas pelo retrofit. Instanciamos o GsonBuilder para que o JSON retornado seja tratado pela biblioteca Gson. Utilizamos o RxJavaCallAdapterFactory para o Retrofit retornar o resultado em forma de objetos observáveis. Por fim, utilizamos o Retrofit.Builder para criar a instância do serviço. O operador flatMap permite iterar sobre um Observable e retornar um novo Observable. É isso que estamos fazendo no método loadMovies. Estamos chamando o método listMovies() do nosso serviço que retorna um Observable de FilmResult, então utilizamos o operador flatMap para obter o FilmResult e geramos um novo Observable de Film com os filmes por meio do método Observable.from(). Em seguida, iterarmos por cada filme (Film) da lista (que é um Observable de Film) e o transformamos em um Observable de Movie, que é o tipo de retorno do método.

Chamando o serviço na Activity

Vamos ver como acessar o nosso serviço na interface gráfica. Se você ainda não converteu sua activity para Kotlin, faça isso acessando o menu “Code > Convert Java File to Kotlin file”. E deixe sua activity como a seguir.

class MainActivity : AppCompatActivity() {
 
    lateinit var listView : ListView
    lateinit var movieAdapter : ArrayAdapter<String>
    var movies = mutableListOf<String>()
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        listView = ListView(this)
        setContentView(listView)
        movieAdapter = ArrayAdapter(this, 
                android.R.layout.simple_list_item_1, movies)
        listView.adapter = movieAdapter
 
        val api = StarWarsApi()
        api.loadMovies()
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .subscribe ({ movie ->
                movies.add("${movie.title} -- ${movie.episodeId}")
            }, { e ->
                e.printStackTrace()
            },{
                movieAdapter.notifyDataSetChanged()
            })
    }
}

Perceba que estamos invocando o método loadMovies() da nossa API. Como vimos anteriormente, esse método retorna um Observable, ou seja, um observável. Nossa tela observará esse objeto, então ela será um Observer. Estamos dizendo que queremos que esse objeto seja criado em background na thread de I/O usando o método subscribeOn(Schedulers.io()). Ele será criado em background, mas queremos observá-lo na main thread do Android, o que nos permitirá atualizar a tela. Ao chamarmos o método subscribe, temos 3 expressões lambda: onNext, que é chamado a cada novo objeto Movie retornado; onError disparado se algum erro ocorrer; e o onCompleted quando a sequência de objetos termina.
No onNext estamos adicionando os filmes na lista (em formato de string para simplificar) e no onCompleted estamos atualizando o adapter para exibir a listagem na tela.

Execute a aplicação e você deverá ver a lista de filmes.

Mas cada filme não deveria ter os seus respectivos personagens?

Sim. Mas para uma tela de listagem isso demora um bocado, pois cada filme tem vários personagens. Então seria melhor na tela de detalhe exibir os personagens. Mas fiquei curioso em saber como fazer isso com RX e resolvi fazer o teste. Vamos voltar ao arquivo StarWarsApi.kt e adicione o seguinte método.

fun loadMoviesFull(): Observable<Movie> {
  return service.listMovies()
      .flatMap { filmResults -> Observable.from(filmResults.results) }
      .flatMap { film ->
          Observable.zip(
              Observable.just(Movie(film.title, film.episodeId, ArrayList<Character>())),
              Observable.from(film.personUrls)
                  .flatMap { personUrl ->
                      service.loadPerson(Uri.parse(personUrl).lastPathSegment)
                  }
                  .map { person ->
                      Character(person!!.name, person.gender)
                  }
                  .toList(), 
                  { movie, characters ->
                      movie.characters.addAll(characters)
                      movie
                  })
      }
}

Olha que loucura isso!

Fazemos a requisição da lista de filmes, e para cada filme temos que pegar a lista de URLs dos personagens e apenas quando cada objeto filme estiver completo, é passamos para o próximo. Para fazer isso, utilizamos o operador zip(), pois ele junta o resultado de dois Observables e retorna um novo Observable.
Podemos testar isso agora na nossa Activity.

api.loadMoviesFull()
        .subscribeOn(Schedulers.io())
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe ({
            movie ->
            movies.add("${movie.title} -- ${movie.episodeId}\n ${movie.characters.toString() }")
        }, {
            e ->
            e.printStackTrace()
        },{
            movieAdapter?.notifyDataSetChanged()
        })

Na listagem deve aparecer o filme e os respectivos personagens. Essa requisição deve demorar vários segundos.

Fazendo cache

Alguns personagens aparecem em vários filmes. Por isso, seria interessante fazermos cache dos dados desses personagens para não fazermos requisições desnecessárias. Vamos fazer um pequeno ajuste no StarWarsApi.kt.

var peopleCache = mutableMapOf<String, Person>()
 
fun loadMoviesFull(): Observable<Movie> {
  return service.listMovies()
      .flatMap { filmResults -> Observable.from(filmResults.results) }
      .flatMap { film ->
          val movieObj = Movie(film.title, film.episodeId, ArrayList<Character>())
          Observable.zip(
              Observable.just(movieObj),
              Observable.from(film.personUrls)
                  .flatMap { personUrl ->
                      Observable.concat(
                          getCache(personUrl),
                          service.loadPerson(Uri.parse(personUrl).lastPathSegment)
                              .doOnNext { person ->
                                  peopleCache.put(personUrl, person)
                              }
                          ).first()
                  }
                  .map { person ->
                      Character(person!!.name, person.gender)
                  }.toList(), 
              { movie, characters ->
                  movie.characters.addAll(characters)
                  movie
              })
      }
}
 
private fun getCache(personUrl : String) : Observable<Person?>? {
    return Observable.from(peopleCache.keys)
        .filter { key ->
            key == personUrl
        }
        .map { key ->
            peopleCache[key]
        }
}

O atributo peopleCache armazena as instâncias de Person. Então na hora que estamos varrendo a lista de personagens utilizamos o operador concat().first() para pegar o primeiro objeto do cache (se existir) ou da API. Quando buscamos da API, adicionamos o objeto no cache, isso é feito no método doOnNext(). Agora estamos fazendo o cache em memória. Mas poderíamos (e deveríamos) fazer em disco.

Como comentei anteriormente, a implementação sem cache demora bastante (uns 30 segundos), mas essa implementação com cache foi bem melhor. Mesmo assim, acho que não seria legal esperar esse tempo todo para trazer a listagem. Seria melhor exibir a listagem de personagens na tela de detalhe de um único filme. Entretanto, foi interessante para explorar o potencial do RX com múltiplas requisições.

Ao terminar de escrever o artigo, notei que tinha muita informação, então resolvi fazer um vídeo mostrado passo a passo a construção do exemplo e tentando explicar melhor a implementação. Espero que gostem

Qualquer dúvida, deixem seus comentários.

Abraços.