Persistencia en Android


Los tipos de persistencia en Android, por lo general, puede dividirse en tres grupos:

  • Preferencias compartidas: conjunto de pares clave-valor. Pueden ser privadas (únicamente accesibles desde la aplicación) o compartidas con otras aplicaciones.
  • Ficheros: una aplicación Android puede almacenar y consultar datos tanto de la carpeta privada de la aplicación como de la tarjeta SD.
  • Base de datos: cada aplicación puede disponer de una base de datos local en formato SQLite en la que realizar operaciones SQL como si de un SGDB normal y corriente se tratara.

A continuación pasaremos a explicar en qué consisten y cómo utilizar estos tres tipos de persistencia.

Estructura interna de la aplicación

Cada aplicación que se instala en el dispositivo crea una carpeta con el nombre de su paquete principal dentro de la ruta /data/data de la memoria interna del dispositivo. Dentro de esa carpeta privada (que no puede ser accedida por ningún otro proceso salvo que se disponga de un terminal de desarrollo o se “rootee” el dispositivo) se aloja una estructura en la que, entre otros, se encuentran las siguientes carpetas:

  • /shared_prefs: contiene un conjunto de ficheros XML que se corresponden a los nombres de las preferencias compartidas. Almacena un mapa con pares clave-valor.
  • /files: contiene los archivos del sistema, que pueden ser binarios o de texto.
  • /databases: contiene las bases de datos SQLite asociadas a la aplicación.
  • /cache: contiene los datos temporales de la aplicación

Directorio interno de la aplicación

A continuación veremos con un poco más de detalle el significado de estos directorios y la relación con los medios de persistencia que Android ofrece.

Preferencias compartidas

Las preferencias compartidas, accesibles mediante la clase SharedPreferences, es una forma de almacenar pequeños fragmentos de información en forma de pares clave-valor. Para el usuario es completamente transparente, aunque de forma interna se almacena como un fichero XML cuyo contenido es un mapa con estas configuraciones.

La forma de uso de las preferencias compartidas es simple. Para instanciar el objeto habrá que invocar el método getSharedPreferences() del contexto pasándole como parámetro el nombre de las preferencias compartidas (que se corresponderá con el nombre del fichero XML en el que se almacenarán de forma interna) y el modo de acceso, que será:

  • MODE_PRIVATE: sólo la aplicación puede acceder al archivo de preferencias.
  • MODE_WORLD_READABLE: otras aplicaciones pueden leer el archivo de preferencias, pero no modificarlas.
  • MODE_WORLD_WRITEABLE: otras aplicaciones pueden leer y escribir el archivo de preferencias.
  • MODE_MULTI_PROCESS: varios procesos pueden acceder simultáneamente al fichero de preferencias.

	private static final String SHARED_PREFERENCES_KEY = "ActivitySharedPreferences_data";
	//[...]
	SharedPreferences sPreferences = getSharedPreferences(SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE);

Para escribir un par clave-valor en las preferencias compartidas necesitaremos un objeto de la clase Editor, que se obtendrá invocando el método edit(). Hecho esto, usando los métodos putXXXX() del editor podremos añadir (o sustituir en el caso de que ya existan) valores en este fichero especial. Para aplicar los cambios, se invocarán los métodos commit() o apply().


	String contenidoTexto 	= txtTexto.getText().toString();
	Editor editor 	= sPreferences.edit();

	// Insertamos el valor de contenidoTexto como cadena de texto y comprometemos los cambios
	editor.putString("contenidoTexto", contenidoTexto);
	editor.commit();

La lectura es aún más sencilla. Basta con invocar el método getXXXXX() pasándole como parámetros la clave del elemento a recuperar y el valor por defecto que se obtendrá en el caso de que la clave no se encuentre.


	contenidoTexto = sPreferences.getString("contenidoTexto", "[vacío]");

Un ejemplo del fichero XML interno resultante (cuya acceso no está disponible de forma pública) sería el siguiente:


	<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
	<map>
		<string name="contenidoTexto">texto de prueba</string>
		<int name="colorTexto" value="-14116982" />
		<int name="colorFondo" value="-16777096" />
	</map>

Ficheros

Con los ficheros tenemos un caso parecido al anterior: existen ficheros privados (que se almacenan en la carpeta files/) y ficheros públicos (que se almacenan en la tarjeta externa SD).

Para acceder a los archivos alojados en la tarjeta interna, usaremos los métodos openFileInput() y openFileOutput(). La escritura sería de la siguiente forma:


	// Declaramos una constante con el nombre del fichero
	private static final String CONFIG_FILENAME = "config.txt";
	[...]

	try {
		// Abrimos un fichero privado asociado con el paquete del contexto de la aplicacion
		// en modo de escritura.
		// El fichero estará en este caso en /data/data/org.danigarcia.android.examples.almacenamiento/files/config.txt
		FileOutputStream streamFichero = openFileOutput(CONFIG_FILENAME, Activity.MODE_PRIVATE);

		// Usamos un OutputStreamWriter para transformar una cadena de texto en un array de bytes
		// y almacenarlos en el archivo.
		OutputStreamWriter writer = new OutputStreamWriter(streamFichero);

		// Escribimos la cadena "Hola, mundo." y un retorno de carro.
		writer.write("Hola, mundo." + System.getProperty("line.separator"));

		// Forzamos la escritura y cerramos el OutputStreamWriter
		writer.flush();
		writer.close();
	}
	catch(IOException e) {
		Log.e(this.getClass().getName(), "escribirConfiguracion()", e);
	}

La lectura se realizaría de forma similar:


	try {
		// Abrimos un fichero privado asociado con el paquete del contexto de la aplicación
		// en modo de solo lectura
		FileInputStream inputStream = openFileInput(CONFIG_FILENAME);

		// Construye un objeto que transformara el fichero en un flujo de caracteres, es decir,
		// convertira el array de bytes en una cadena de texto.
		InputStreamReader inputStreamReader = new InputStreamReader(inputStream);

		// En lugar de utilizar directamente el InputStreamReader, creamos a partir de el
		// un BufferedReader, que encapsula el InputStreamReader y proporciona ya un buffer
		// para los datos de entrada. De este modo nos despreocupamos de mantener el buffer.
		BufferedReader bufferedReader = new BufferedReader(inputStreamReader);

		// Leemos linea a linea
		String configLine = bufferedReader.readLine();
		while(configLine != null)
		{
			procesarLinea(configLine);					// Usamos la linea de texto para lo que queramos
			configLine = bufferedReader.readLine();		// Leemos la siguiente linea
		}
	} catch(IOException e) {
			Log.e(this.getClass().getName(), "leer()", e);
	}

Para leer y escribir en un fichero almacenado en la tarjeta SD necesitaremos añadir los permisos adecuados en el manifiesto, que serán los siguientes:


	<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
	<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

A continuación realizaremos la escritura en el fichero de forma parecida a como hicimos en el ejemplo anterior:


	// Obtenemos la ruta de la tarjeta SD
	File sdCardPath = Environment.getExternalStorageDirectory();

	// Creamos el fichero pasándole la ruta y el nombre del fichero
	// En este caso, será /mnt/sdcard/sdcard/config.txt
	File ficheroExt = new File(sdCardPath.getAbsolutePath(), CONFIG_FILENAME);

	// Abrimos el flujo de salida del fichero para escribir en él.
	// El booleano que se pasa como segundo parámetro indicará si queremos añadir nuevo texto (true) o no (false).
	FileOutputStream streamFicheroExt = new FileOutputStream(ficheroExt, false);

	// Obtenemos un OutputStreamWriter para transformar el texto en un array de bytes
	OutputStreamWriter writerExt = new OutputStreamWriter(streamFicheroExt);

	// Escribimos la cadena "Hola, mundo." y un retorno de carro.
	writerExt.write("Hola, mundo." + System.getProperty("line.separator"));

	// Forzamos la escritura y cerramos el OutputStreamWriter
	writerExt.flush();
	writerExt.close();

La lectura se realizará de una forma muy similar


	// Obtenemos la ruta de la tarjeta SD
	File sdCardPath = Environment.getExternalStorageDirectory();

	// Creamos el fichero pasándole la ruta y el nombre del fichero
	// En este caso, será /mnt/sdcard/sdcard/config.txt
	File ficheroExt = new File(sdCardPath.getAbsolutePath(), CONFIG_FILENAME);

	try {
		// Abrimos un fichero privado asociado con el paquete del contexto de la aplicación
		// en modo de solo lectura
		FileInputStream inputStreamExt = openFileInput(ficheroExt);

		// Construye un objeto que transformara el fichero en un flujo de caracteres, es decir,
		// convertira el array de bytes en una cadena de texto.
		InputStreamReader inputStreamReaderExt = new InputStreamReader(inputStreamExt);

		// En lugar de utilizar directamente el InputStreamReader, creamos a partir de el
		// un BufferedReader, que encapsula el InputStreamReader y proporciona ya un buffer
		// para los datos de entrada. De este modo nos despreocupamos de mantener el buffer.
		BufferedReader bufferedReaderExt = new BufferedReader(inputStreamReaderExt);

		// Leemos linea a linea
		String configLine = bufferedReaderExt.readLine();
		while(configLine != null)
		{
			procesarLinea(configLine);						// Usamos la linea de texto para lo que queramos
			configLine = bufferedReaderExt.readLine();		// Leemos la siguiente linea
		}
	} catch(IOException e) {
			Log.e(this.getClass().getName(), "leer()", e);
	}

Además de escribir en el almacenamiento interno y externo, es posible hacer uso de otras dos rutas predefinidas: la caché de la aplicación, pensada para almacenar datos temporales y que se corresponderían primero con el directorio /cache, que se accedería de la siguiente forma:


	// Se correspondera con el directorio /data/data/org.danigarcia.android.examples.almacenamiento/cache/config.txt567822742.tmp
	File ficheroCache = File.createTempFile(CONFIG_FILENAME, null, getCacheDir());

El segundo será el directorio público de la aplicación asociado a un tipo determinado de dato. Así, si quisiésemos almacenar fotos, podríamos hacer que éstas se almacenaran en el directorio de la carpeta pública destinada a ello por defecto.


	// Se correspondera con el directorio /mnt/sdcard/DCIM
	File ficheroExtRel = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM), CONFIG_FILENAME);

Los valores para estos directorios son los siguientes:

  • Enviroment.DIRECTORY_PICTURES: Directorio público de imágenes
  • Enviroment.DIRECTORY_DCIM: Directorio público de fotografías
  • Enviroment.DIRECTORY_ALARMS: Directorio público de alarmas
  • Enviroment.DIRECTORY_DOWNLOADS: Directorio público de descargas
  • Enviroment.DIRECTORY_MOVIES: Directorio público de películas
  • Enviroment.DIRECTORY_MUSIC: Directorio público de música
  • Enviroment.DIRECTORY_NOTIFICATIONS: Directorio público de sonidos de notificación
  • Enviroment.DIRECTORY_PODCASTS: Directorio público de podcasts
  • Enviroment.DIRECTORY_RINGTONES: Directorio público de tonos de llamada

Base de datos

Android hace uso de la biblioteca SQLite para aquellas aplicaciones que necesiten una base de datos. Cada propia aplicación corre su propia base de datos, evitando problemas de concurrencia, corrupción de datos, etc.

Las bases de datos, al igual que los ficheros internos y las preferencias compartidas, se almacenan en el directorio interno de la aplicación, concretamente en el subdirectorio /databases.

Para usar una base de datos, en primer lugar deberemos crearla. Lo haremos creando una nueva clase que herede de la clase SQLiteOpenHelper, que implementará por defecto dos nuevos métodos: onCreate() y onUpdate(). El primero de estos métodos se ejecutará una sola vez si la base de datos no existe, y se utilizará para crear la estructura de la base de datos (tablas) y una carga de datos inicial (si procede).

El segundo método se ejecutará si la versión de la base de datos aumenta desde el último acceso (es decir, si el desarrollador modifica la base de datos, debe modificar el valor de la versión para que SQLiteOpenHelper ejecute el método onUpdate(), realizando operaciones como por ejemplo añadir un nuevo campo a una tabla).

Por lo tanto, definimos la clase, creamos un constructor que invoque al constructor padre y rellenamos los métodos onCreate() y onUpdate() inicializando la base de datos (por ejemplo, creando una tabla “configuracion” con tres campos: clave [text], valor [text] y fechaModif [datetime].


	public class SQLConfigManager extends SQLiteOpenHelper {

		private static final String SQL_DROP_CONFIG   = "drop table if exists configuracion";
		private static final String SQL_CREATE_CONFIG = "create table configuracion(" +
														"clave text primary key," +
														"valor text," +
														"fechaModif datetime)";
		// Constructor por defecto
		public SQLConfigManager(Context context, String nombre, CursorFactory factory, int version)
		{
			super(context, nombre, factory, version);
		}

		@Override
		public void onCreate(SQLiteDatabase db) {
			// Al instalar la base de datos, creamos la tabla "configuracion"
			db.execSQL(SQL_CREATE_CONFIG);
			Log.d(TAG, "onCreate()");
		}

		@Override
		public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
			// En caso de actualizar la base de datos, eliminamos la tabla y la volvemos a crear.
			db.execSQL(SQL_DROP_CONFIG);
			db.execSQL(SQL_CREATE_CONFIG);
		}
	}

SQLite proporciona un conjunto muy limitado de tipos de dato: enteros, cadenas, reales y blobs (además del valor NULL). Por lo tanto, el resto de datos, incluyendo fechas, tendrán que ser “emulados” de alguna forma. La fecha la podemos codificar como una cadena de texto con el formato “YYYY-MM-DD HH:MM:SS.SSS” (“1970-01-01 00:00:00”), como un entero (número de segundos desde 1970-01-01 00:00:00) o como un número real (días desde 4714-11-24 A.C.). Más información sobre los tipos de dato de SQLite, aquí.

Creada nuestra base de datos, codificaremos un método que, a partir de la clave, recupere el valor correspondiente (la típica búsqueda por ID).

En primer lugar añadimos un par de constantes de tipo cadena a nuestra clase para facilitar posibles modificaciones de nuestro código.


	public static final String SQL_TABLA_CONFIG = "configuracion";
	public static final String SQL_COLUMNA_CLAVE = "clave";
	public static final String SQL_COLUMNA_VALOR = "valor";
	public static final String SQL_COLUMNA_FECHA = "fechaModif";

A continuación, comenzamos a codificar nuestro método de consulta.


	public String recuperarValor(String campoClave)
	{
		String resultado = null;
		Cursor cSelect	 = null;

		// Creamos un array de Strings a partir de la clave para poder usarla
		// como filtro
		String[] filtroSelect = {campoClave};
		String[] filtroColumnas = {SQL_COLUMNA_VALOR};

		// Obtenemos la base de datos en modo lectura
		SQLiteDatabase db = getReadableDatabase();
	}

Como podemos observar, declaramos una cadena para el resultado, un cursor para iterar sobre los resultados y dos arrays de cadenas de texto: uno contendrá las columnas que queremos recuperar (si es null recuperará todas) y otro, los filtros a aplicar en la cláusula WHERE (si es null recuperará todas las filas).

Realizamos una select sobre la tabla configuracion, con los siguientes parametros:

  • SQL_TABLA_CONFIG: tabla sobre la que se realiza la consulta (configuracion)
  • filtroColumnas: array con los nombres de las columnas a recuperar (valor)
  • “clave=?”: filtro where
  • filtroSelect: parametros que seran sustituidos en el filtro anterior
  • null: clausula group by
  • null: clausula having
  • null: clausula order by
</span>
<pre>
	cSelect = db.query(SQL_TABLA_CONFIG, filtroColumnas, "clave=?", filtroSelect, null, null, null);

Así, si quisiéramos ejecutar la siguiente consulta:


	select nombre, apellidos from empleados
		where estadoCivil = 'S' and edad > 38
		order by apellidos

Los valores para la llamada serían los siguientes:


	String[] filtroSelect = {"nombre", "apellidos"};
	String[] filtroColumnas = {"S", "38"};

	cSelect = db.query("empleados", filtroColumnas, "estadoCivil=? and edad > ?", filtroSelect, null, null, "apellidos");

Podríamos realizar la misma llamada de forma equivalente ejecutando la select directamente, del siguiente modo:


	String SQL_SELECT_VALOR = "select valor from configuracion " +
										  " where clave=?";
	cSelect = db.rawQuery(SQL_SELECT_VALOR, filtroSelect);

Comprobamos ahora si se ha recuperado un sólo registro (lo esperado). En ese caso, almacenamos el valor de la primera (y única) columna recuperada en la cadena de texto. El método getString(0) hace precisamente eso: obtiene una cadena de texto de la columna 0 (primer elemento) de la fila apuntada por el cursor.


	// Si se recupera un solo registro, la consulta es la esperada
	if(cSelect.getCount() == 1)
	{
		// Recuperamos el campos "valor" correspondiente al elemento "clave".
		cSelect.moveToFirst();
		resultado = cSelect.getString(0);
	}

	return resultado;

A continuación crearemos un método que nos permita insertar nuevos elementos (o modificarlos si ya existen). Crearemos un método insertar(clave, valor) que realice una operación de uno de estos dos tipos:


	delete from configuracion where clave = 'contenidoTexto';
	insert into configuracion(clave, valor, fechaModif) values ('contenidoTexto', 'Texto de prueba', date(now));

O bien


	update configuracion set valor = 'Texto de prueba', fechaModif=date(now) where clave = 'contenidoTexto'

Usaremos un flag llamado UPDATE para seleccionar un método de inserción u otro. Claro que primero deberemos comprobar si el registro ya existía en la base de datos.


	// Insertamos un elemento de configuracion.
	// Equivaldra a ejecutar la siguiente sentencia:
	// insert into configuracion (clave, valor, fechaModif) values campoClave, campoValor, date(now)
	public void insertar(String campoClave, String campoValor)
	{
		// Creamos un array de Strings a partir de la clave para poder usarla
		// como filtro
		String[] arrayClave = {campoClave};

		// Obtenemos la base de datos en modo lectura y escritura
		SQLiteDatabase db = getWritableDatabase();
	}

 

Vemos que hemos abierto la base de datos con el método getWritableDatabase(), lo que nos proporciona permisos tanto de lectura como de escritura. Lo que deberemos hacer ahora será crear un objeto en el que almacenar el registro que queremos insertar. Esto lo haremos mediante la clase ContentValues.


		// Creamos una nueva fila, a la que le añadimos pares clave-valor
		// con el nombre de la columna y su contenido.
		ContentValues row = new ContentValues();
		row.put(SQL_COLUMNA_CLAVE, campoClave);
		row.put(SQL_COLUMNA_VALOR, campoValor);
		row.put(SQL_COLUMNA_FECHA, "date(now)");

 

A continuación comprobaremos si el registro ya existía en base de datos. Para hacer esto podemos hacer dos cosas: una SELECT filtrando por el campo clave y contando los registros del cursor, tal y como hicimos antes, o realizar directamente un SELECT COUNT(*), también con el mismo filtro. Usaremos el segundo método, ya que el primero ya lo hemos visto antes.


		String SQL_SELECT_COUNT = "select count(*) from configuracion" +
												" where clave=?";

		// Creamos un cursor y lanzamos una SELECT a la base de datos para comprobar
		// el numero de registros que existen con la clave que vamos a insertar
		Cursor cCount = db.rawQuery(SQL_SELECT_COUNT, arrayClave);
		cCount.moveToFirst();
		int count = cCount.getInt(0);
		cCount.close();

 

Si la clave no existía previamente en la base de datos, haremos directamente la inserción.


		// Si la clave no existia previamente en la base de datos
		if(count == 0)
			db.insert(SQL_TABLA_CONFIG, null, row);

 

La inserción necesita de tres argumentos: la tabla, un parámetro opcional utilizado si se desea insertar un registro nulo (que normalmente será null) y el objeto ContentValues con los elementos a insertar.

En caso de que el registro ya existiese, podemos hacer dos cosas: un update:


	else
	{
		// Si el flag UPDATE es true, actualizamos directamente la fila
		if(UPDATE)
			db.update(SQL_TABLA_CONFIG, row, SQL_COLUMNA_CLAVE + "=?", arrayClave);
	}

 

…o una eliminiación seguida de una inserción.


	// Si el flag UPDATE es false, eliminamos el registro cuya clave coincide con
	// la que queremos insertar e insertamos el nuevo registro.
		else
		{
			db.delete(SQL_TABLA_CONFIG, SQL_COLUMNA_CLAVE + "=?", arrayClave);
			db.insert(SQL_TABLA_CONFIG, null, row);
		}

 

Con esto, nuestra clase SQLConfigManager estaría completa, permitiéndonos de paso ver un ejemplo de inserción, modificación, eliminación y consulta.

Para utilizar la clase, haríamos lo siguiente. Primero deberíamos instanciar la clase en el método onCreate() de la actividad que vaya a hacer uso de ella. En esta llamada se usará el contexto, el nombre (físico) de la base de datos, un CursorFactory (opcional) y el número de versión de la base de datos.


	sqlManager = new SQLConfigManager(this, SQLConfigManager.SQL_TABLA_CONFIG, null, 1);

 

Una inserción se efectuaría de la siguiente forma:


	sqlManager.insertar("colorTexto", tvColorTexto.getText().toString());

 

Mientras que la siguiente sentencia ejecutaría una consulta:


	String hexColor = sqlManager.recuperarValor("colorTexto");

El código completo del ejemplo puede obtenerse aquí, incluyendo la clase DialogColorPicker descrita en el artículo anterior. Si lo que deseas es ver la aplicación en funcionamiento, aquí puedes obtener el fichero .apk.

Responder

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión / Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión / Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión / Cambiar )

Google+ photo

Estás comentando usando tu cuenta de Google+. Cerrar sesión / Cambiar )

Conectando a %s