«

»

mar 22 2012

Persistencia de aplicaciones con ADA Framework


Hace un tiempo os hablamos de la salida de una nueva librería para manejar la persistencia en nuestras aplicaciones Android, llamada ADA Framework y cuyo propósito principal es simplificar el trabajo con la capa de acceso a datos. ADA Framework tiene un concepto parecido a otros ORM conocidos del mundo Java como puede ser Hibernate pero consiguen aportar un concepto propio, desde mi punto de vista acertado y que le puede permitir buscarse un hueco dentro de las librerías de persistencia para android.

Para aquellos que hayáis trabajado con algún motor de persistencia para Java os sonará el concepto de DAO (Data Access Object). Un DAO no es más que un componente que nos proporciona los mecanismos para comunicarnos con el almacenamiento de datos. Normalmente los DAO contienen dos tipos de métodos:

  • De consulta: nos permiten recuperar un objeto o una lista de objetos de la base de datos aplicando patrones de búsqueda, ordenación, etc.
  • De escritura: nos permiten guardar, actualizar o borrar un objeto en la base de datos

ADA Framework nos aporta el concepto de ObjectSet. En el sentido estricto, un ObjectSet es un DAO, sin embargo lo que lo hace especial es que, como su nombre indica, representa un conjunto de objetos y además implementa la interfaz List, proporcionando los métodos de las listas (get, size, remove, etc.).

La diferencia del ObjectSet con los DAO tradicionales es que en los segundos encontramos métodos para recuperar una lista de objetos mientras que con ObjectSet lo que tenemos son métodos para “rellenar” el propio ObjectSet con los objetos de la base de datos. Además, como acabamos de comentar tenemos todos métodos de una lista. Este pequeño matiz nos ayudará a entender mejor cómo funciona la librería.

¡Manos a la obra!

Una vez introducido el concepto de ADA Framework pongámonos manos a la obra para completar un pequeño ejercicio que mostrará el uso de esta librería. En nuestro ejemplo trabajaremos con Productos y Categorías, de forma que un producto pueda, opcionalmente, pertenecer a una categoría. El modelo de nuestra base de datos es el siguiente:

Diagrama Base de Datos

Como veis es un diseño bastante sencillo con el que veremos los conceptos básicos del uso de la librería.

Paso 1 – Preparar el proyecto

Lo primero es preparar el proyecto para trabajar con ADA Framework, para ello sólo tenemos que ir a la página oficial y descargar la librería. A continuación copiamos el JAR al directorio “libs” de nuestro proyecto (lo creamos si no existe) y lo añadimos al classpath, por ejemplo pulsando sobre el fichero JAR con el botón derecho y eligiendo “Build Path -> Add to Build Path”.

Con esto ya tenemos lo necesario para utilizar ADA Framework en el proyecto. Fácil, ¿verdad?

Paso 2 – Clases del Modelo

Ahora vamos a crear nuestras clases del modelo. Las clases del modelo son simples clases Java con atributos a las que añadiremos las anotaciones y con las que indicaremos las propiedades de los campos de la tabla.

Clase Category

package es.androcode.adafw.products.model;

import com.desandroid.framework.ada.Entity;
import com.desandroid.framework.ada.annotations.Table;
import com.desandroid.framework.ada.annotations.TableField;

@Table(name = "category")
public class Category extends Entity {

    @TableField(name = "name", datatype = DATATYPE_TEXT, required = true)
    private String name;
    @TableField(name = "description", datatype = DATATYPE_TEXT, required = false)
    private String description;

    public Category() {
        super();
    }

    public Category(String name, String description) {
        super();
        this.name = name;
        this.description = description;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getDescription() {
        return description;
    }

    public void setDescription(String description) {
        this.description = description;
    }

}

Como vemos resulta muy sencillo “convertir” una clase normal en una clase de modelo. Los tres requisitos que hay que cumplir son los siguientes:

  1. Hacer que la clase extienda de Entity
  2. Añadir la anotación @Table al principio de la clase
  3. Anotar cada atributo que queramos hacer persistente con @TableField

Como podemos observar, en la anotación @Table hemos añadido el nombre de la tabla mientras que en los campos hemos especificado el nombre que tendrá la columna, el tipo de campo y si es obligatorio o no. Además disponemos de otros atributos que puedes consultar en la API oficial. Se recomienda al menos especificar el nombre y por supuesto el tipo de campo que puede ser uno de los siguientes (más información):

  • DATATYPE_BOOLEAN
  • DATATYPE_INTEGER
  • DATATYPE_LONG
  • DATATYPE_DOUBLE
  • DATATYPE_REAL
  • DATATYPE_TEXT
  • DATATYPE_DATE
  • DATATYPE_BLOB
  • DATATYPE_ENTITY
  • DATATYPE_ENTITY_REFERENCE

Además se han creado dos constructores, uno vacío y otro que toma los atributos para facilitar la creación de objetos de esta clase.

Clase Product

package es.androcode.adafw.products.model;

import com.desandroid.framework.ada.Entity;
import com.desandroid.framework.ada.annotations.Table;
import com.desandroid.framework.ada.annotations.TableField;

@Table(name = "product")
public class Product extends Entity {

    @TableField(name = "name", datatype = DATATYPE_TEXT, required = true)
    private String name;
    @TableField(name = "category", datatype = DATATYPE_ENTITY_REFERENCE, required = false)
    private Category category;
    @TableField(name = "quantity_per_unit", datatype = DATATYPE_INTEGER, required = true)
    private int quantityPerUnit;
    @TableField(name = "unit_price", datatype = DATATYPE_DOUBLE, required = true)
    private double unitPrice;
    @TableField(name = "units_in_stock", datatype = DATATYPE_INTEGER, required = true)
    private int unitsInStock;

    public Product() {
        super();
    }

    public Product(String name, Category category, int quantityPerUnit,
            double unitPrice, int unitsInStock) {
        super();
        this.name = name;
        this.category = category;
        this.quantityPerUnit = quantityPerUnit;
        this.unitPrice = unitPrice;
        this.unitsInStock = unitsInStock;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Category getCategory() {
        return category;
    }

    public void setCategory(Category category) {
        this.category = category;
    }

    public int getQuantityPerUnit() {
        return quantityPerUnit;
    }

    public void setQuantityPerUnit(int quantityPerUnit) {
        this.quantityPerUnit = quantityPerUnit;
    }

    public double getUnitPrice() {
        return unitPrice;
    }

    public void setUnitPrice(double unitPrice) {
        this.unitPrice = unitPrice;
    }

    public int getUnitsInStock() {
        return unitsInStock;
    }

    public void setUnitsInStock(int unitsInStock) {
        this.unitsInStock = unitsInStock;
    }

}

La clase Product tampoco tiene mayor dificultad. Lo más destacable es el atributo category que es del tipo Category y que hemos marcado como DATATYPE_ENTITY_REFERENCE al ser una referencia a otra entidad. Además es el único atributo no requerido ya que en nuestro ejemplo hemos optado por que haya productos sin categoría.

Paso 3 – Clase de contexto

Lo siguiente es crear una clase que represente al contexto de nuestros objetos. Esta clase se encargará de crear las tablas de forma automática y proporcionarnos acceso a los DAO de la aplicación. En nuestro caso la clase sería de la siguiente forma:

package es.androcode.adafw.products.db;

import android.content.Context;

import com.desandroid.framework.ada.ObjectContext;
import com.desandroid.framework.ada.ObjectSet;

import es.androcode.adafw.products.model.Category;
import es.androcode.adafw.products.model.Product;

public class ApplicationDataContext extends ObjectContext {

    public ObjectSet<Category> categoryDao;
    public ObjectSet<Product> productDao;

    public ApplicationDataContext(Context pContext) throws Exception {
        super(pContext);
        this.categoryDao = new ObjectSet<Category>(Category.class, this);
        this.productDao = new ObjectSet<Product>(Product.class, this);
    }

}

Como vemos es muy sencillo crear una clase de contexto. Los pasos a seguir son:

  1. Extender de la clase ObjectContext
  2. Crear el constructor que reciba el objeto Context y llamar a super con el parámetro
  3. Crear los objetos ObjectSet para cada DAO
Importante: Los atributos DAO deben ser public.

Ahora sólo nos queda usar el ObjectContext y los ObjectSet para trabajar con nuestra capa de acceso a datos.

Paso 4 – Uso

Para finalizar veremos cómo podemos interactuar con los datos. Como ejemplo veremos un par de activities, una que muestra una lista de Productos y otra que crea o edita una Categoría. Al final de la entrada encontrarás un enlace al proyecto completo que permite añadir, editar y eliminar Productos y Categorías así como asociar Categorías a Productos.

Código de ejemplo: El proyecto no representa un ejemplo sobre cómo crear actividades, listas y formularios de creación. Se ha optado por simplificar el código y centrarnos únicamente en el uso de la librería, por ejemplo no se han hecho uso de Fragments ni se ha optimizado. Utiliza el código para ver su funcionamiento y no para poner una aplicación en producción.

Mostrar la lista de Productos

El resultado será algo parecido a lo siguiente:

Lista de productos

Para este ejemplo utilizaremos una actividad que extienda de ListActivity y así ahorrarnos tener que definir el layout. Además se ha definido un Adapter dentro de la clase que rellenará la lista con los productos de la base de datos. El layout que utilizaremos para cada producto de la lista es el siguiente:

<?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/name"
        android:layout_height="wrap_content"
        android:layout_width="wrap_content"
        android:layout_margin="5dp"
        style="@android:style/TextAppearance.DeviceDefault.Medium"/>

    <TextView
        android:id="@+id/category"
        android:layout_height="wrap_content"
        android:layout_width="wrap_content"
        android:layout_margin="5dp"
        style="@android:style/TextAppearance.DeviceDefault.Small"/>

</LinearLayout>

El código de la clase ProductsActivity que muestra la lista de productos es el siguiente:

package es.androcode.adafw.products;

import android.app.ListActivity;
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;
import android.view.ContextMenu;
import android.view.ContextMenu.ContextMenuInfo;
import android.view.LayoutInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.BaseAdapter;
import android.widget.ListView;
import android.widget.TextView;

import com.desandroid.framework.ada.Entity;

import es.androcode.adafw.products.db.ApplicationDataContext;
import es.androcode.adafw.products.model.Product;

/**
 * Actividad que muestra la lista de Productos
 * @author fedeproex
 *
 */
public class ProductsActivity extends ListActivity {

    /**
     * Activity DataContext.
     */
    private ApplicationDataContext appDataContext;
    private MyAdapter adapter;
    protected LayoutInflater inflater;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        inflater = (LayoutInflater) getSystemService(LAYOUT_INFLATER_SERVICE);
        try {
            // Creamos un objeto ApplicationDataContext
            appDataContext = new ApplicationDataContext(this);
            // Rellenamos el DAO de categorías ordenándolo por la columna name
            appDataContext.productDao.fill("name");
            // Creamos el adapter que utilizará el DAO
            adapter = new MyAdapter();
            setListAdapter(adapter);

        } catch (Exception e) {
            Log.e("Androcode", "Error creando vista", e);
        }
    }

    /**
     * Adapter que utiliza el DAO para rellenar la lista
     *
     */
    class MyAdapter extends BaseAdapter {

        @Override
        public int getCount() {
            return appDataContext.productDao.size();
        }

        @Override
        public Object getItem(int position) {
            if (position < getCount()) {
                return appDataContext.productDao.get(position);
            }
            return null;
        }

        @Override
        public long getItemId(int position) {
            Product product = (Product) getItem(position);
            return product.getID();
        }

        @Override
        public View getView(int position, View convertView, ViewGroup parent) {
            View itemView = convertView;
            if (itemView == null) {
                itemView = inflater.inflate(R.layout.product, null);
            }
            Product product = (Product) getItem(position);
            ((TextView) itemView.findViewById(R.id.name)).setText(product.getName());
            if (product.getCategory() == null) {
                ((TextView) itemView.findViewById(R.id.category)).setText(R.string.no_category);
            } else {
                ((TextView) itemView.findViewById(R.id.category)).setText(product.getCategory().getName());
            }
            return itemView;
        }

    }
}

Como puede verse, en el método onCreate creamos un nuevo objecto de ApplicationDataContext, rellenamos el DAO con todos los productos de la base de datos ordenados por la columna “name” y creamos el adapter que manejará la lista.

En el adapter, utilizamos los métodos del ObjectSet para saber cuantos elementos tenemos y poder acceder a cada uno de los elementos. Como dijimos al principio del tutorial, un ObjectSet extiende de List, por lo que a parte de los métodos DAO disponemos de los elementos de la lista como size, get o iterator.

Aquí únicamente hemos utilizado el método fill pasando como argumento el nombre de la columna con la que ordenar los resultados. Sin embargo, este método tiene muchas variantes, algunas de ellas son:

  • fill() -> Recupera todos los objetos de la base de datos
  • fill(Integer pLimit) -> Recupera un número determinado de objetos, cantidad especificada por el parámetro pLimit
  • fill(Integer pOffset, Integer pLimit) -> Igual que el anterior pero empezando en la fila indicada por el parámetro pOffset
  • fill(String pWherePattern, String[] pWhereValues, String pOrderBy) -> Recupera los objetos que cumplan los criterios de búsqueda establecidos por los parámetros pWherePatternpWhereValues

Como siempre puedes encontrar más información en la API de la librería.

Crear una nueva categoría

La actividad utilizará un formulario diseñado en XML para modificar o crear una nueva categoría.

Editar categoría

Primero comprobará si le ha llegado por parámetros el identificador de la categoría. Si nos ha llegado es que vamos a editar dicha categoría, sino es que estamos creando una nueva.

Veamos el código del layout:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent" >

    <LinearLayout
        android:id="@+id/buttons"
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:layout_alignParentBottom="true"
        android:orientation="horizontal" >
        <Button
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:layout_margin="4dp"
            android:padding="4dp"
            android:onClick="save"
            android:text="@android:string/ok" />
        <Button
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:layout_margin="4dp"
            android:padding="4dp"
            android:onClick="cancel"
            android:text="@android:string/cancel" />
    </LinearLayout>

    <LinearLayout
	    android:layout_width="fill_parent"
	    android:layout_height="fill_parent"
	    android:layout_above="@+id/buttons"
	    android:orientation="vertical" >

	    <TextView
	        android:id="@+id/edit_title"
	        android:layout_width="fill_parent"
	        android:layout_height="wrap_content"
	        android:gravity="center"
	        android:layout_margin="4dp"
	        style="@android:style/TextAppearance.DeviceDefault.Large"
	        android:text="@string/new_category" />

	    <TextView
	        android:layout_width="wrap_content"
	        android:layout_height="wrap_content"
	        android:text="@string/name" />

	    <EditText
	        android:id="@+id/name"
	        android:layout_width="fill_parent"
	        android:layout_height="wrap_content"
	        android:layout_margin="5dp"
	        android:inputType="text" />

	    <TextView
	        android:layout_width="wrap_content"
	        android:layout_height="wrap_content"
	        android:layout_marginTop="4dp"
	        android:text="@string/description" />

	    <EditText
	        android:id="@+id/description"
	        android:layout_width="fill_parent"
	        android:layout_height="wrap_content"
	        android:layout_margin="5dp"
	        android:inputType="textMultiLine" />

	</LinearLayout>

</RelativeLayout>

No hay nada especial en el código anterior. Simplemente mostramos un par de botones para aceptar o cancelar los cambios y un par de campos con sus etiquetas para rellenar el nombre y la descripción de la categoría. Además tenemos un campo con id edit_title que mostrará “Nueva Categoría” o “Editar Categoría” cuando corresponda.

Método onCreate

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.edit_category);
        name = (EditText) findViewById(R.id.name);
        description = (EditText) findViewById(R.id.description);

        long idCategory = getIntent().getLongExtra(ID_CATEGORY, 0);
        category = queryCategory(idCategory);
        if (category == null) {
            finish();
        } else {
            name.setText(category.getName());
            description.setText(category.getDescription());
            setResult(RESULT_CANCELED);
        }
    }
  • Se carga el layout de la vista
  • Se recuperan los campos del formulario (name y description)
  • Se comprueba si nos ha llegado el identificador de cateogría
  • Se llama al método queryCategory, descrito más adelante
  • Si no ha habido errores, se rellenan los campos con los valores de la categoría
  • Se pone el resultado a RESULT_CANCELED. Esto se hace porque hay otra actividad que está a la espera del resultado, así que pondremos el resultado a cancelado y sólo en el caso de que todo termine correctamente pondremos el resultado a RESULT_OK

Método queryCategory

    private Category queryCategory(long idCategory) {
        Category category = null;
        ApplicationDataContext dataContext = getApplicationDataContext();
        if (dataContext != null) {
            if (idCategory == 0) {
                // Es una categoría nueva, creamos el objeto
                category = new Category("", "");
                category.setStatus(Entity.STATUS_NEW);
            } else {
                // Recuperamos la categoría
                try {
                    category = dataContext.categoryDao.getElementByID(idCategory);
                    category.setStatus(Entity.STATUS_UPDATED);
                } catch (Exception e) {
                    Log.e("Androcode", "Error recuperando categoria: " + e.getMessage());
                }
                ((TextView) findViewById(R.id.edit_title)).setText(R.string.edit_category);
            }
        }
        return category;
    }

Se ha optado por utilizar un método auxiliar que se encarga de devolver la categoría en el caso de que el identificador sea válido o de crear una nueva categoría si el identificador es 0.

Como vemos resulta muy sencillo recuperar un objeto a partir de su identificador. Basta con llamar al método getElementByID del DAO de la clase Category. Además hemos marcado el estado de la instancia como nueva (Entity.STATUS_NEW) o editada (Entity.STATUS_UPDATED). Esto es necesario y obligatorio. De esta forma, cuando le digamos al DAO que guarde el objeto sabrá qué tiene que hacer. Los otros estados son STATUS_DELETED y STATUS_NOTHING.

Método getApplicationDataContext

    private ApplicationDataContext getApplicationDataContext() {
        if (appDataContext == null) {
            try {
                appDataContext = new ApplicationDataContext(this);
            } catch (Exception e) {
                Log.e("Androcode", "Error inicializando ApplicationDataContext: " + e.getMessage());
            }
        }
        return appDataContext;
    }

Simplemente es un método de utilidad que crea un nuevo objeto ApplicationDataContext si no se ha hecho ya. Por último veamos los métodos save y cancel que se ejecutan al pulsar los botones de aceptar y cancelar respectivamente.

Métodos cancel y save

    public void cancel(View view) {
        finish();
    }
    public void save(View view) {
        String newName = name.getText().toString();
        if (newName.trim().length() > 0) {
            // Guardamos los nuevos datos
            category.setName(newName);
            category.setDescription(description.getText().toString());
            // Salvamos
            ApplicationDataContext dataContext = getApplicationDataContext();
            if (dataContext != null) {
                try {
                    dataContext.categoryDao.add(category);
                    dataContext.categoryDao.save();
                    setResult(RESULT_OK);
                } catch (Exception e) {
                    Log.e("Androcode", "Error guardando categoria: " + e.getMessage());
                }
            }
            finish();
        } else {
            Toast.makeText(this, R.string.error_empty_name, Toast.LENGTH_SHORT).show();
        }
    }

El método cancel únicamente finaliza la actividad. En el método save lo primero que hace es comprobar que el nombre no esté vacío, en dicho caso muestra al usuario un mensaje de error (definido en el fichero strings.xml). Si no está vacío, rellenamos el objeto categoría con los nuevos datos, añadimos el objeto al ObjectSet y ejecutamos el método save del ObjectSet. Por último, si todo ha ido bien, ponemos el resultado a RESULT_OK.

El método save de ObjectSet tiene muchas variantes que puedes consultar en su API. Una de ellas toma como argumento el objeto en sí mismo, por lo que en lugar de escribir:

dataContext.categoryDao.add(category);
dataContext.categoryDao.save();

Podríamos haber puesto:

dataContext.categoryDao.save(category);

Y hubiera tenido el mismo resultado.

Atento: hemos cambiado el estado de la entidad a STATUS_NEW o STATUS_UPDATED. Si no haces esto, la llamada al método save del ObjectSet no hará nada.

Para más información:

Eso es todo. Como veis resulta muy sencillo adaptar nuestros proyectos para que trabajen con esta librería y lo que es mejor, podemos olvidarnos del código SQL. ADA Framework es una librería que aunque debe mejorar en algunos aspectos resulta extremadamente útil. Además es innegable que va por el buen camino y que puede llegar a convertirse en una herramienta a tener en cuenta para nuestros proyectos android.

Espero que os haya gustado el artículo y os haya sido de utilidad. El proyecto lo tenéis disponible en Google Code:

Acerca del autor

FedeProEx

Ingeniero Informático en la Universidad de Sevilla, programador Java y amante del Heavy Metal. Soy desarrollador android fuera del horario de trabajo con algunas aplicaciones en el market como Tiempo AEMET o aconTags

  • Victor Manuel Agudelo

    Hola, no se si han visto esta libreria:
    https://github.com/javipacheco/Android-DataFramework/wiki

    La uso en todos mis proyectos por que me ha funcionado de maravilla.

    • FedeProEx | Redactor

      Por supuesto!, tenemos varias entradas dedicadas a esa fantástica librería:

      http://androcode.es/tag/android-dataframework/

      Saludos!

      • Victor Manuel Agudelo

        Hola, es que soy nuevo por aquí y aun no he llegado al final de las entradas.

        Muy buen sitio para los que programamos en android, felicitaciones.

  • David Díaz

    Muy buenas,

    Antes de nada queria agradeceros los magnificos articulos y el “compartir conocimiento”.

    Al respecto de la puntualización que haceis sobre la libreria referente a la inclusión de getters y setters, tiene sentido desde le punto de vista formal pero hay una contraindicación explicita de google donde se recomienda el uso del atributos publicos para mejorar el rendimiento:
    http://developer.android.com/guide/practices/design/performance.html#internal_get_set

    Un saludo

    • FedeProEx | Redactor

      Buenas,

      Gracias por los ánimos. Tienes razón en que el acceso directo es más rápido, pero si te fijas bien, en el artículo que enlazas se desaconseja el uso de getter y setter dentro de la propia clase. De todas formas es más un formalismo y en las últimas versiones ya está solucionado.

      Saludos!

  • borlbes

    Un tutorial fantástico, una sola duda, ¿Hay alguna forma de usar una de estas librerias con un content provider ?. O si quieres un content provider lo haces aparte ? Eso seria una buena practica, poner el content provider aparte o existen mejores soluciones ?

    Merci !!

  • borlbes

    Por cierto la aplicacion extra, cuando añades un producto se cuelga al elegir una categoria para el producto

  • Pingback: Persistencia de aplicaciones con ADA Framework – Parte 2 | Androcode()

  • soyfelix

    Muchas gracias por el artículo, ¿es posible almacenar en una única tabla un objeto que tiene un atributo ArrayList? Gracias

  • Pingback: ADA Framework Tournament | Androcode()

  • Juan

    Buenas me parece un frame work muy interesante, pero no puedo bajarme el codigo fuente para hacer el testeo de la aplicacion para entenderla mejor, soy principiante y me cuesta.

  • Jorge Betancourt

    Hola me parece una librería excelente yo ya la he implementado en mis proyectos, lo unico que no se como hacer es insertar el dato de fecha cuando tengo un campo de tipo DATATYPE_DATE y ahora ya hay el DATATYPE_DATE_BINARY. Espero y me puedas ayudar con esta inquietud.
    Gracias

  • http://gravatar.com/zentech948 Martin

    Buenísimo el tutorial, muy claro y simple. Gracias!

  • Sergio

    El articulo esta bonito pero es un rollo poder descargar el codigo fuente del articulo. Si no es mucha molestia poder facilitar el codigo fuente