Busqueda de peliculas REST (Versión Xtend)

En nuestro segundo ejemplo vamos a trabajar en ambos lados
  • en el cliente Android armamos una UI que permite ingresar el título de una película
  • y construiremos un componente Grails en el servidor que hará la búsqueda de las películas que contengan ese texto en el título

El servicio

Aquellos que vieron la tecnología Grails conocerán el ejemplo del alquiler de películas, donde
  • el usuario escribe parte del título de una película
  • y como el campo tiene autocompletado automático, le van apareciendo las primeras n películas cuyo título contiene el texto que escribió
Esta funcionalidad está implementada con Ajax, que dispara los pedidos en forma asincrónica desde el cliente (en el browser) hacia el servidor. El pedido se hace por http y la respuesta vuelve en formato JSON.

No obstante, el output que tiene el servidor está pensado para el control autocomplete de jQuery-UI:

  1. [{
  2. "label": "Jardines en otoño",
  3. "value": "Jardines en otoño",
  4. "id": 36
  5. }, {
  6. "label": "El jardinero",
  7. "value": "El jardinero",
  8. "id": 40
  9. }, {
  10. "label": "Michael Jackson : 30 Aniversario MSG",
  11. "value": "Michael Jackson : 30 Aniversario MSG",
  12. "id": 55
  13. },

Vamos a crear un nuevo método que devuelva una representación más cercana al objeto película y a su género:

  1. [{
  2. "titulo": "Jardines en otoño",
  3. "actores": "Séverin Blanchet, Pascal Vincent, Muriel Motte, Michel Piccoli, Lily Lavina",
  4. "genero": {
  5. "id": 1,
  6. "descripcion": "Drama"
  7. }
  8. }, {
  9. "titulo": "El jardinero",
  10. "actores": "Daniel Auteuil, Jean Pierre Darroussin, Fanny Cottençon, Alexia Barlier, Hiam Abbass",
  11. "genero": {
  12. "id": 2,
  13. "descripcion": "Comedia"
  14. }
  15. }, {
  16. "titulo": "Michael Jackson : 30 Aniversario MSG",
  17. "actores": "Michael Jackson",
  18. "genero": {
  19. "id": 3,
  20. "descripcion": "Musical"
  21. }
  22. }, {

La implementación es sencilla, el home aplica el filtro y devuelve una colección de películas, mientras que el controller adapta la lista de películas y pasa un mapa que termina transformándose en un DTO de facto:

class PeliculasController {


def repoPeliculas = HomePeliculas.instance


def getPeliculas(String tituloContiene) {

render repoPeliculas.getPeliculas(tituloContiene, 10).collect { pelicula ->

[titulo: pelicula.titulo,

actores: pelicula.actores,

genero: [

id: pelicula.genero.id,

descripcion: pelicula.genero.descripcion

]

]

} as JSON

}


DTO

¿Por qué directamente no pasar el objeto película?
  • el campo sinopsis (el resumen de una película) es largo. Si estamos transmitiendo varias películas, esto tiene un costo de pasarlo por la red y luego de recibirlo en el cliente. Además ¿qué pasa si el cliente sólo quiere conocer el título, los actores y el género y si un objeto película tiene muchos atributos más? Hay una tensión entre armar un servicio general para todos los que necesiten usarlo y la especificidad para aprovechar al máximo el ancho de banda.
  • el otro tema asociado es que el objeto género necesita un id y una descripción, y no matchea exactamente con la salida por default:
  1. "genero": {
  2. "class": "ar.edu.videoclub.domain.Genero",
  3. "descripcion": "Drama"
  4. },

Por eso aparece como respuesta una abstracción: el DTO o Data Transfer Object, que es el objeto que modela la transferencia entre el cliente y el servidor:
  • como el DTO se concentra en el envío de información de un nodo a otro, no suele tener comportamiento
  • introduce redundancia de conceptos (Pelicula y PeliculaDTO apuntan a representar la misma entidad)
  • la ventaja de tecnologías como Grails es que el mapa reemplaza la necesidad de generar una clase adicional ad-hoc (que sólo se usa en el contexto de un request), aunque hay que pensar en técnicas de reutilización si necesitamos transferir la misma información en más de un lugar
  • por otra parte, cuando trabajamos con sistemas distribuidos necesariamente aparecen redundancias: tanto en el cliente como en el servidor tengo películas, pero no necesariamente coinciden

Cliente

Diseño de la vista

En una primera iteración podemos pensar la vista lo más simple posible:
  • un campo editable para ingresar el título de la película
  • un botón para disparar la consulta
  • y un listView para mostrar los resultados
Para una segunda iteración vamos a incorporar un checkbox, que nos permitirá disparar la búsqueda a medida que escribamos en el campo editable.

Cómo se dispara la consulta

Definimos que la activity implemente la interfaz OnClickListener y el método onClick(View v) dispara la búsqueda en un método específico llamado buscarPeliculas():

override onClick(View v) {

    buscarPeliculas

}


def void buscarPeliculas() {

    // Esta URL apunta a la solución en Grails con Homes hechos en Xtend

    val API_URL = "http://10.0.2.2:8080/videoclub-ui-grails-homes-xtend"

    val restAdapter = new RestAdapter.Builder().setEndpoint(API_URL).build

    val PeliculasService peliculasService = restAdapter.create(PeliculasService)


    // Invocamos al servicio acá

    peliculasService.getPeliculas(tituloContiene,

        new Callback<List<Pelicula>>() {

            override failure(RetrofitError e) {

                ...

        }


        override success(List<Pelicula> peliculas, Response response) {

            mostrarPeliculas(peliculas)

        }


    })

}


En la interfaz PeliculasService indicamos que 
  • la búsqueda se hace mediante un método GET
  • y le pasamos como parámetro el valor que tiene el EditText

interface PeliculasService {

    @GET("/peliculas/{tituloContiene}")

    def void getPeliculas(@Path("tituloContiene") String tituloContiene,

                          Callback<List<Pelicula>> callback)

 

}

Pero  ¿cuándo le pasamos el valor del EditText? Al invocar el servicio:

    peliculasService.getPeliculas(tituloContiene,

        new Callback<List<Pelicula>>() { ...


Claro, tituloContiene no es una variable, sino un método definido en la Activity, y devuelve el valor del EditText:

def tituloContiene() {

    val campoBusqueda = findViewById(R.id.tituloContiene) as EditText

    campoBusqueda.text.toString

}

Y lo que nos devuelve es un JSON que encaja perfectamente en nuestra definición de una lista de películas:

 JSON
 
  1. [{
  2. "titulo": "Jardines en otoño",
  3. "actores": "Séverin Blanchet, ...",
  4. "genero": {
  5. "id": 1,
  6. "descripcion": "Drama"
  7. }
  8. },
 objeto de dominio Película en Xtend

@Accessors

class Pelicula {

    String titulo

    String actores

    Genero genero


 objeto de dominio Género en Xtend 

@Accessors

class Genero {

String descripcion


Cómo llenar el ListView de películas

Ahora nos concentramos en el método mostrarPeliculas(peliculas) que se envía cuando el pedido asincrónico del service se completa exitosamente. Primero definimos un row en el directorio res/layout:

<?xml version="1.0" encoding="utf-8"?>

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"

   android:layout_width="match_parent"

   android:layout_height="match_parent"

   android:orientation="vertical" >


   <TextView

       android:id="@+id/txtTitulo"

       android:layout_width="wrap_content"

       android:layout_height="wrap_content"

       android:textAppearance="?android:attr/textAppearanceMedium" />


   <TextView

       android:id="@+id/txtGenero"

       android:layout_width="wrap_content"

       android:layout_height="wrap_content"

       android:text="?android:attr/textAppearanceSmall"

       android:textAppearance="?android:attr/textAppearanceSmall" />


</LinearLayout>


El título de cada película se va a visualizar más grande que el género al que pertenece (por eso Medium Appearance vs. Small). Ahora necesitamos llenar los valores del ListView definiendo un Adapter [1], pero vamos a tratar de no repetir tantas veces la misma idea...
  • tenemos que recibir el contexto y los elementos de la lista
  • también tenemos comportamiento default para getCount(), getItem(), getItemId()
  • y además vamos a proveer métodos default para
    • generar cada fila de la list view
    • y adaptar un valor String a un TextView
El lector puede ver la implementación de la clase AbstractListAdapter.

Y ahora es mucho más simple definir nuestra PeliculaAdapter:

class PeliculaAdapter extends AbstractListAdapter<Pelicula> {


    ...

    override getView(int position, View convertView, ViewGroup parent) {

        val pelicula = getItem(position) as Pelicula

        val row = generateRow(R.layout.pelicula_row, parent)

        setColumnTextView(row, R.id.txtTitulo, pelicula.titulo)

        setColumnTextView(row, R.id.txtGenero, pelicula.descripcionGenero)

        row

    }


Así se llama al Adapter desde la Activity:

def void mostrarPeliculas(List<Pelicula> peliculas) {

    findViewById(R.id.lvPeliculas) as ListView => [

        adapter = new PeliculaAdapter(this as Context, peliculas)

        choiceMode = ListView.CHOICE_MODE_SINGLE

    ]

}

Un último agregado

Y finalmente vamos a incorporarle el checkbox a la vista, 
  • si el checkbox está activado esto disparará la búsqueda automáticamente cada vez que el usuario escriba algo (y siempre que haya al menos dos caracteres en el texto)
    • no tiene sentido entonces mostrar un botón para disparar la búsqueda (se invisibiliza)
  • si el checkbox está desactivado hay que mostrar el botón y permitir que se dispare manualmente la búsqueda
Primero vamos a la vista (res/layout/activity_peliculas.xml) e incorporamos el checkbox. Luego modificamos el método onCreate de nuestra Activity principal para que se comporte como describimos recién:

override onCreate(Bundle savedInstanceState) {

    super.onCreate(savedInstanceState)

    contentView = R.layout.activity_peliculas

    // Comportamiento del checkbox que indica si se busca a medida que se escribe

    val chkBuscar = findViewById(R.id.chkBuscarOnline) as CheckBox

    chkBuscar.onClickListener = [ View v |

        val btnBuscar = findViewById(R.id.btnBuscar) as ImageButton

        btnBuscar.visibility = if (chkBuscar.checked) View.INVISIBLE else  View.VISIBLE

    ]

    // Comportamiento del título de búsqueda

    val tituloContiene = findViewById(R.id.tituloContiene) as EditText

    tituloContiene.addTextChangedListener([ Editable editable |

        if (chkBuscar.checked && editable.length >= MIN_BUSQUEDA_PELICULAS) {

            buscarPeliculas

        } ] as BaseTextWatcher)


    ...

}


La clase BaseTextWatcher define el comportamiento default vacío para un EditText que recibe eventos de usuario (no es relevante).

Ver una película

Cuando el usuario selecciona una película, disparamos un Intent que muestra sus datos.
Para ver cómo se implementa el lector puede ver
  • el método onItemClick en PeliculasActivity
  • y la activity DetallePelicula

Arquitectura general


Comments