sexta-feira, 3 de março de 2017

RXJava + Kotlin + Retrofit + Star Wars API



Olá povo,

Há algum tenho 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 post não é como dar os primeiros passos com RX ou Kotlin, e sim documentar e compartilhar o que eu aprendi ao tentar implementar um exemplo "simples" com esse conjunto de linguagem+biblioteca+api.

Nesse post 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

Primeira coisa que você deve fazer é instalar o plugin do Kotlin no Android Studio. Você pode seguir esse tutorial aqui:
https://blog.jetbrains.com/kotlin/2013/08/working-with-kotlin-in-android-studio/
A versão atual no momento da escrita desse post é 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 filme 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.
package 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 post).

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 post, 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.

4br4ç05,
nglauber

Referências

Site oficial do Kotlin
https://kotlinlang.org/

Site oficial do RXJava
https://github.com/ReactiveX/RxJava

StarWars API
https://swapi.co

Apresentações Ubiratan Soares
Programação Reativa Funcional com RxJava
Vídeo: https://www.youtube.com/watch?v=0FpphC6hL5I
Slides: https://speakerdeck.com/ubiratansoares/rxjava-for-android
Refactoring for RxJava
Vídeo: https://www.youtube.com/watch?v=391H38-7JYk
Sllides: https://speakerdeck.com/ubiratansoares/refactoring-for-rxjava

Dan Lew cache in RX
http://blog.danlew.net/2015/06/22/loading-data-from-multiple-sources-with-rxjava/

Livro de RX
http://www.oreilly.com/programming/free/rxjava-for-android-app-development.csp

Livro de Kotlin
https://antonioleiva.com/kotlin/

6 comentários:

Unknown disse...

Fala Glauber, tudo bem? Cara, parabéns pelos seus conteúdos, sou fã do seu trabalho!
Mas no momento, a minha dúvida é outra, estou fazendo o seu curso do YouTube e vi num vídeo que você tinha ou tem um Asus.
Eu tenho um Asus Zenfone 2 e tenho 2 computadores, um windows e um mac.
No windows eu consigo compilar tranquilo direto no celular, mais no mac não vai de jeito nenhum.
Já tentei procurar drivers no site da Asus mais não acho nada pra mac.
Vi que você usa o Genymotion, mais quando queria compilar direto no aparelho, como fazia?
Se puder me ajudar agradeço muito!

Abraços

Nelson Glauber disse...

Oi Everton,

Que bom que está gostando do conteúdo do blog ;)
Em relação a sua dúvida, eu realmente não sei :( Nunca tive problema de conexão com nenhum aparelho no Mac. Realmente teria que pesquisar.
Eu uso o Genymotion, o emulador nativo ou aparelho. Depende da necessidade, mas não precisa fazer nada no seu projeto para rodar no aparelho. O Android Studio deveria detectar cada aparelho/emulador automaticamente.

[]'s
nglauber

Unknown disse...

Consegui Glauber, obrigado pela atenção :)

Bruno disse...

Show de bola Glauber como sempre seus posts são de primeira.
Abraços.

Unknown disse...

Opa cara ótimo artigo, porem estou tendo dificuldade pois estou usando o retrofit2, apenas no retorno dos filmes.

Nelson Glauber disse...

Oi John,

Acho que respondi sua dúvida no comentário do vídeo no YouTube, mas estou deixando aqui também as mudanças nesse código para RX2.
https://github.com/nglauber/playground/commit/236481ad42e4d612c1b5e694ef17862241d28b66

4br4ç05,
nglauber