Android ofrece compatibilidad integrada con SQLite, una base de datos SQL eficiente. Sigue estas prácticas recomendadas para optimizar el rendimiento de tu app y asegurarte de que se mantenga rápida y predecible a medida que crezcan tus datos. Si usas estas prácticas recomendadas, también reduces la posibilidad de encontrar problemas de rendimiento que son difíciles de reproducir y solucionar.
Para lograr un rendimiento más rápido, sigue estos principios:
Lee menos filas y columnas: Optimiza tus consultas para recuperar solo los datos necesarios. Minimiza la cantidad de datos que se leen en la base de datos, ya que la recuperación de datos en exceso puede afectar el rendimiento.
Envía el trabajo al motor SQLite: Realiza las operaciones de procesamiento, filtrado y ordenamiento dentro de las consultas en SQL. El uso del motor de consultas de SQLite puede mejorar significativamente el rendimiento.
Modifica el esquema de la base de datos: Diseña el esquema de tu base de datos para ayudar a SQLite a crear planes de consulta y representaciones de datos eficientes. Indexa las tablas correctamente y optimiza sus estructuras para mejorar el rendimiento.
Además, con las herramientas de solución de problemas disponibles, puedes medir el rendimiento de la base de datos SQLite para identificar las áreas que requieren optimización.
Te recomendamos usar la biblioteca Room de Jetpack.
Cómo configurar la base de datos para mejorar el rendimiento
Sigue los pasos de esta sección para configurar tu base de datos y obtener un rendimiento óptimo en SQLite.
Habilita el almacenamiento de registros de escritura anticipada
Para implementar mutaciones, SQLite las adjunta a un registro, que ocasionalmente compacta en la base de datos. Esto se denomina almacenamiento de registros de escritura anticipada (WAL).
Habilita WAL, a menos que uses ATTACH
DATABASE
.
Disminuye la rigurosidad del modo de sincronización
Cuando usas WAL, de forma predeterminada, cada confirmación emite un objeto fsync
para garantizar que los datos lleguen al disco. Esto mejora la durabilidad de los datos, pero ralentiza las confirmaciones.
SQLite tiene la opción de controlar el modo síncrono. Si habilitas WAL, establece el modo síncrono en NORMAL
:
Kotlin
db.execSQL("PRAGMA synchronous = NORMAL")
Java
db.execSQL("PRAGMA synchronous = NORMAL");
Con esta configuración, se puede mostrar una confirmación antes de que los datos se almacenen en un disco. Si un dispositivo se apaga, por ejemplo, debido a un corte de energía o un error irrecuperable del kernel, es posible que se pierdan los datos confirmados. Sin embargo, gracias al almacenamiento de registros, la base de datos no se daña.
Si solo tu app falla, tus datos aún llegarán al disco. Para la mayoría de las apps, este parámetro de configuración mejora el rendimiento sin implicar un costo material.
Cómo definir esquemas de tabla eficientes
Para optimizar el rendimiento y minimizar el consumo de datos, define un esquema de tabla eficiente. SQLite construye datos y planes de consultas eficientes, lo que lleva a una recuperación de datos más rápida. En esta sección, se proporcionan prácticas recomendadas para crear esquemas de tablas.
Considera INTEGER PRIMARY KEY
En este ejemplo, define y completa una tabla de la siguiente manera:
CREATE TABLE Customers(
id INTEGER,
name TEXT,
city TEXT
);
INSERT INTO Customers Values(456, 'John Lennon', 'Liverpool, England');
INSERT INTO Customers Values(123, 'Michael Jackson', 'Gary, IN');
INSERT INTO Customers Values(789, 'Dolly Parton', 'Sevier County, TN');
El resultado de la tabla es el siguiente:
rowid | id | name | city |
---|---|---|---|
1 | 456 | John Lennon | Liverpool, England |
2 | 123 | Michael Jackson | Gary, IN |
3 | 789 | Dolly Parton | Sevier County, TN |
La columna rowid
es un índice que conserva el orden de inserción. Las consultas que filtran por rowid
se implementan como una búsqueda rápida del árbol B, pero las consultas que filtran por id
implican un análisis lento de la tabla.
Si planeas realizar búsquedas por id
, puedes evitar almacenar la columna rowid
para lograr tener menos datos en el almacenamiento y una base de datos generalmente más rápida:
CREATE TABLE Customers(
id INTEGER PRIMARY KEY,
name TEXT,
city TEXT
);
Ahora, la tabla se ve de la siguiente manera:
id | name | city |
---|---|---|
123 | Michael Jackson | Gary, IN |
456 | John Lennon | Liverpool, England |
789 | Dolly Parton | Sevier County, TN |
Dado que no necesitas almacenar la columna rowid
, las consultas por id
son rápidas. Ten en cuenta que la tabla ahora se ordena según el id
, en lugar de hacerlo según el orden de inserción.
Acelera las consultas con índices
SQLite usa
índices
para acelerar las consultas. Cuando se filtra (WHERE
), se ordena (ORDER BY
) o se agrega (GROUP BY
) una columna, si la tabla tiene un índice para la columna, se acelera la consulta.
En el ejemplo anterior, filtrar por city
requiere analizar toda la tabla:
SELECT id, name
WHERE city = 'London, England';
Si tienes una app con muchas consultas de ciudades, puedes acelerar esas consultas con un índice:
CREATE INDEX city_index ON Customers(city);
Un índice se implementa como una tabla adicional, que se ordena por la columna de índice y se asigna a rowid
:
city | rowid |
---|---|
Gary, IN | 2 |
Liverpool, England | 1 |
Sevier County, TN | 3 |
Ten en cuenta que el costo de almacenamiento de la columna city
ahora es el doble, ya que está presente en la tabla original y en el índice. Dado que usas el índice, el costo de almacenamiento adicional vale la pena, ya que las consultas son más rápidas.
Sin embargo, no mantengas un índice que no utilices, para evitar pagar el costo de almacenamiento sin obtener una ganancia de rendimiento de las consultas.
Crea índices de varias columnas
Si tus consultas combinan varias columnas, puedes crear varias columnas índices para acelerar por completo la consulta. También puedes usar un índice en una columna externa y permitir que la búsqueda interna se realice de manera lineal.
Por ejemplo, dada la siguiente consulta:
SELECT id, name
WHERE city = 'London, England'
ORDER BY city, name
Puedes acelerar la consulta con un índice de varias columnas que esté en el mismo orden que se especifica en la consulta:
CREATE INDEX city_name_index ON Customers(city, name);
Sin embargo, si solo tienes un índice de city
, el orden externo se acelerará, mientras que el orden interno requerirá una búsqueda lineal.
Esto también funciona con consultas de prefijo. Por ejemplo, un índice ON Customers (city, name)
también acelera el filtrado, el ordenamiento y la agrupación por city
, ya que la tabla de índices de un índice de varias columnas se ordena según los índices dados en el orden determinado.
Considera WITHOUT ROWID
De forma predeterminada, SQLite crea una columna rowid
para tu tabla, en la que rowid
es un INTEGER PRIMARY KEY AUTOINCREMENT
implícito. Si ya tienes una columna que es INTEGER PRIMARY KEY
, esta se convierte en un alias de rowid
.
Para las tablas que tienen una clave primaria distinta de INTEGER
o un compuesto de columnas, considera WITHOUT
ROWID
.
Almacena los datos pequeños como un BLOB
y los datos grandes como un archivo
Si deseas asociar datos grandes con una fila, como la miniatura de una imagen o una foto de un contacto, puedes almacenar los datos en una columna BLOB
o en un archivo, y, luego, almacenar la ruta de acceso al archivo en la columna.
Los archivos suelen redondearse en incrementos de hasta 4 KB. Para archivos muy pequeños, en los que el error de redondeo es significativo, es más eficiente almacenarlos en la base de datos como un BLOB
. SQLite minimiza las llamadas al sistema de archivos y es más rápido que el sistema de archivos subyacente en algunos casos.
Cómo mejorar el rendimiento de las consultas
Sigue estas prácticas recomendadas para mejorar el rendimiento de las consultas en SQLite minimizando los tiempos de respuesta y maximizando la eficiencia del procesamiento.
Lee solo las filas que necesitas
Los filtros te permiten acotar los resultados a través de la especificación de ciertos criterios, como el período, la ubicación o el nombre. Los límites te permiten controlar la cantidad de resultados que ves:
Kotlin
db.rawQuery(""" SELECT name FROM Customers LIMIT 10; """.trimIndent(), null ).use { cursor -> while (cursor.moveToNext()) { ... } }
Java
try (Cursor cursor = db.rawQuery(""" SELECT name FROM Customers LIMIT 10; """, null)) { while (cursor.moveToNext()) { ... } }
Lee solo las columnas que necesitas
Evita seleccionar columnas innecesarias, ya que pueden ralentizar tus consultas y desperdiciar recursos. En cambio, solo selecciona las columnas que se usan.
En el siguiente ejemplo, se seleccionan id
, name
y phone
:
Kotlin
// This is not the most efficient way of doing this. // See the following example for a better approach. db.rawQuery( """ SELECT id, name, phone FROM customers; """.trimIndent(), null ).use { cursor -> while (cursor.moveToNext()) { val name = cursor.getString(1) // ... } }
Java
// This is not the most efficient way of doing this. // See the following example for a better approach. try (Cursor cursor = db.rawQuery(""" SELECT id, name, phone FROM customers; """, null)) { while (cursor.moveToNext()) { String name = cursor.getString(1); ... } }
Sin embargo, solo necesitas la columna name
:
Kotlin
db.rawQuery(""" SELECT name FROM Customers; """.trimIndent(), null ).use { cursor -> while (cursor.moveToNext()) { val name = cursor.getString(0) ... } }
Java
try (Cursor cursor = db.rawQuery(""" SELECT name FROM Customers; """, null)) { while (cursor.moveToNext()) { String name = cursor.getString(0); ... } }
Parametriza consultas con SQL Cards, no con concatenación de cadenas
Tu cadena de consulta puede incluir un parámetro que solo se conoce en el tiempo de ejecución, como de la siguiente manera:
Kotlin
fun getNameById(id: Long): String? db.rawQuery( "SELECT name FROM customers WHERE id=$id", null ).use { cursor -> return if (cursor.moveToFirst()) { cursor.getString(0) } else { null } } }
Java
@Nullable public String getNameById(long id) { try (Cursor cursor = db.rawQuery( "SELECT name FROM customers WHERE id=" + id, null)) { if (cursor.moveToFirst()) { return cursor.getString(0); } else { return null; } } }
En el código anterior, cada consulta construye una cadena diferente y, por lo tanto, no se beneficia de la caché de sentencias. Cada llamada requiere que SQLite se compile
antes de que pueda ejecutarse. En su lugar, puedes reemplazar el argumento id
con una
parámetro y
vincula el valor con selectionArgs
:
Kotlin
fun getNameById(id: Long): String? { db.rawQuery( """ SELECT name FROM customers WHERE id=? """.trimIndent(), arrayOf(id.toString()) ).use { cursor -> return if (cursor.moveToFirst()) { cursor.getString(0) } else { null } } }
Java
@Nullable public String getNameById(long id) { try (Cursor cursor = db.rawQuery(""" SELECT name FROM customers WHERE id=? """, new String[] {String.valueOf(id)})) { if (cursor.moveToFirst()) { return cursor.getString(0); } else { return null; } } }
Ahora la consulta se puede compilar una vez y almacenar en caché. La consulta compilada se reutiliza entre diferentes invocaciones de getNameById(long)
.
Itera en SQL, no en el código
Usar una sola consulta que devuelva todos los resultados segmentados, en lugar de una consulta programática repetir indefinidamente las consultas en SQL para devolver resultados individuales. La programática es alrededor de 1,000 veces más lento que una sola consulta en SQL.
Usa DISTINCT
para valores únicos
Usar la palabra clave DISTINCT
puede mejorar el rendimiento de tus consultas, ya que reduce la cantidad de datos que se deben procesar. Por ejemplo, si quieres mostrar solo los valores únicos de una columna, usa DISTINCT
:
Kotlin
db.rawQuery(""" SELECT DISTINCT name FROM Customers; """.trimIndent(), null ).use { cursor -> while (cursor.moveToNext()) { // Only iterate over distinct names in Kotlin ... } }
Java
try (Cursor cursor = db.rawQuery(""" SELECT DISTINCT name FROM Customers; """, null)) { while (cursor.moveToNext()) { // Only iterate over distinct names in Java ... } }
Usa funciones de agregación siempre que sea posible
Usa funciones de agregación para obtener resultados agregados sin datos de filas. Por ejemplo, el siguiente código verifica si hay al menos una fila que coincida:
Kotlin
// This is not the most efficient way of doing this. // See the following example for a better approach. db.rawQuery(""" SELECT id, name FROM Customers WHERE city = 'Paris'; """.trimIndent(), null ).use { cursor -> if (cursor.moveToFirst()) { // At least one customer from Paris ... } else { // No customers from Paris ... }
Java
// This is not the most efficient way of doing this. // See the following example for a better approach. try (Cursor cursor = db.rawQuery(""" SELECT id, name FROM Customers WHERE city = 'Paris'; """, null)) { if (cursor.moveToFirst()) { // At least one customer from Paris ... } else { // No customers from Paris ... } }
Para recuperar solo la primera fila, puedes usar EXISTS()
para mostrar 0
si no existe una fila coincidente y 1
si una o más filas coinciden:
Kotlin
db.rawQuery(""" SELECT EXISTS ( SELECT null FROM Customers WHERE city = 'Paris'; ); """.trimIndent(), null ).use { cursor -> if (cursor.moveToFirst() && cursor.getInt(0) == 1) { // At least one customer from Paris ... } else { // No customers from Paris ... } }
Java
try (Cursor cursor = db.rawQuery(""" SELECT EXISTS ( SELECT null FROM Customers WHERE city = 'Paris' ); """, null)) { if (cursor.moveToFirst() && cursor.getInt(0) == 1) { // At least one customer from Paris ... } else { // No customers from Paris ... } }
Usa funciones de agregación de SQLite en el código de tu app:
COUNT
: Cuenta cuántas filas hay en una columna.SUM
: Suma todos los valores numéricos de una columna.MIN
oMAX
: Determinan el valor más bajo o más alto. Funcionan con columnas numéricas, tipos deDATE
y tipos de texto.AVG
: Encuentra el valor numérico promedio.GROUP_CONCAT
: Concatena cadenas con un separador opcional.
Usa COUNT()
en lugar de Cursor.getCount()
En el siguiente ejemplo, la función Cursor.getCount()
lee todas las filas de la base de datos y muestra todos los valores de fila:
Kotlin
// This is not the most efficient way of doing this. // See the following example for a better approach. db.rawQuery(""" SELECT id FROM Customers; """.trimIndent(), null ).use { cursor -> val count = cursor.getCount() }
Java
// This is not the most efficient way of doing this. // See the following example for a better approach. try (Cursor cursor = db.rawQuery(""" SELECT id FROM Customers; """, null)) { int count = cursor.getCount(); ... }
Sin embargo, cuando usas COUNT()
, la base de datos solo muestra el recuento:
Kotlin
db.rawQuery(""" SELECT COUNT(*) FROM Customers; """.trimIndent(), null ).use { cursor -> cursor.moveToFirst() val count = cursor.getInt(0) }
Java
try (Cursor cursor = db.rawQuery(""" SELECT COUNT(*) FROM Customers; """, null)) { cursor.moveToFirst(); int count = cursor.getInt(0); ... }
Consultas de Nest en lugar de código
SQL es componible y admite subconsultas, uniones y restricciones de claves externas. Puedes usar el resultado de una consulta en otra sin revisar el código de la app. Esto reduce la necesidad de copiar datos de SQLite y permite que el motor de base de datos optimice tu consulta.
En el siguiente ejemplo, puedes ejecutar una consulta para averiguar qué ciudad tiene la mayor cantidad de clientes y, luego, utilizar el resultado en otra consulta para encontrar todos los clientes de esa ciudad:
Kotlin
// This is not the most efficient way of doing this. // See the following example for a better approach. db.rawQuery(""" SELECT city FROM Customers GROUP BY city ORDER BY COUNT(*) DESC LIMIT 1; """.trimIndent(), null ).use { cursor -> if (cursor.moveToFirst()) { val topCity = cursor.getString(0) db.rawQuery(""" SELECT name, city FROM Customers WHERE city = ?; """.trimIndent(), arrayOf(topCity)).use { innerCursor -> while (innerCursor.moveToNext()) { ... } } } }
Java
// This is not the most efficient way of doing this. // See the following example for a better approach. try (Cursor cursor = db.rawQuery(""" SELECT city FROM Customers GROUP BY city ORDER BY COUNT(*) DESC LIMIT 1; """, null)) { if (cursor.moveToFirst()) { String topCity = cursor.getString(0); try (Cursor innerCursor = db.rawQuery(""" SELECT name, city FROM Customers WHERE city = ?; """, new String[] {topCity})) { while (innerCursor.moveToNext()) { ... } } } }
Para obtener el resultado en la mitad del tiempo del ejemplo anterior, usa una sola consulta en SQL con sentencias anidadas:
Kotlin
db.rawQuery(""" SELECT name, city FROM Customers WHERE city IN ( SELECT city FROM Customers GROUP BY city ORDER BY COUNT (*) DESC LIMIT 1; ); """.trimIndent(), null ).use { cursor -> if (cursor.moveToNext()) { ... } }
Java
try (Cursor cursor = db.rawQuery(""" SELECT name, city FROM Customers WHERE city IN ( SELECT city FROM Customers GROUP BY city ORDER BY COUNT(*) DESC LIMIT 1 ); """, null)) { while(cursor.moveToNext()) { ... } }
Comprueba la unicidad en SQL
Si no se debe insertar una fila a menos que el valor de una columna en particular sea único en la tabla, podría ser más eficiente aplicar esa unicidad como una restricción de columna.
En el siguiente ejemplo, se ejecuta una consulta para validar la fila que se insertará y otra para insertarla:
Kotlin
// This is not the most efficient way of doing this. // See the following example for a better approach. db.rawQuery( """ SELECT EXISTS ( SELECT null FROM customers WHERE username = ? ); """.trimIndent(), arrayOf(customer.username) ).use { cursor -> if (cursor.moveToFirst() && cursor.getInt(0) == 1) { throw AddCustomerException(customer) } } db.execSQL( "INSERT INTO customers VALUES (?, ?, ?)", arrayOf( customer.id.toString(), customer.name, customer.username ) )
Java
// This is not the most efficient way of doing this. // See the following example for a better approach. try (Cursor cursor = db.rawQuery(""" SELECT EXISTS ( SELECT null FROM customers WHERE username = ? ); """, new String[] { customer.username })) { if (cursor.moveToFirst() && cursor.getInt(0) == 1) { throw new AddCustomerException(customer); } } db.execSQL( "INSERT INTO customers VALUES (?, ?, ?)", new String[] { String.valueOf(customer.id), customer.name, customer.username, });
En lugar de verificar la restricción de unicidad en Kotlin o Java, puedes verificarla en SQL cuando defines la tabla:
CREATE TABLE Customers(
id INTEGER PRIMARY KEY,
name TEXT,
username TEXT UNIQUE
);
SQLite hace lo siguiente:
CREATE TABLE Customers(...);
CREATE UNIQUE INDEX CustomersUsername ON Customers(username);
Ahora, puedes insertar una fila y permitir que SQLite verifique la restricción:
Kotlin
try { db.execSql( "INSERT INTO Customers VALUES (?, ?, ?)", arrayOf(customer.id.toString(), customer.name, customer.username) ) } catch(e: SQLiteConstraintException) { throw AddCustomerException(customer, e) }
Java
try { db.execSQL( "INSERT INTO Customers VALUES (?, ?, ?)", new String[] { String.valueOf(customer.id), customer.name, customer.username, }); } catch (SQLiteConstraintException e) { throw new AddCustomerException(customer, e); }
SQLite admite índices únicos con varias columnas:
CREATE TABLE table(...);
CREATE UNIQUE INDEX unique_table ON table(column1, column2, ...);
SQLite valida las restricciones más rápido y con menos sobrecarga que el código Kotlin o Java. Una práctica recomendada consiste en usar SQLite en lugar del código de la app.
Agrupa varias inserciones en una sola transacción
Una transacción confirma varias operaciones, lo que mejora no solo la eficiencia, sino también la precisión. Para mejorar la coherencia de los datos y acelerar el rendimiento, puedes realizar inserciones por lotes:
Kotlin
db.beginTransaction() try { customers.forEach { customer -> db.execSql( "INSERT INTO Customers VALUES (?, ?, ...)", arrayOf(customer.id.toString(), customer.name, ...) ) } } finally { db.endTransaction() }
Java
db.beginTransaction(); try { for (customer : Customers) { db.execSQL( "INSERT INTO Customers VALUES (?, ?, ...)", new String[] { String.valueOf(customer.id), customer.name, ... }); } } finally { db.endTransaction() }
Usa herramientas para solucionar problemas
SQLite proporciona las siguientes herramientas de solución de problemas para ayudarte a medir el rendimiento.
Usa el prompt interactivo de SQLite
Ejecuta SQLite en tu máquina para ejecutar consultas y aprender.
Las diferentes versiones de la plataforma de Android usan distintas revisiones de SQLite. Para usar el mismo motor que tiene un dispositivo con Android, usa adb shell
y ejecuta sqlite3
en tu dispositivo de destino.
Puedes pedirle a SQLite que programe consultas:
sqlite> .timer on
sqlite> SELECT ...
Run Time: real ... user ... sys ...
EXPLAIN QUERY PLAN
Puedes pedirle a SQLite que explique cómo responderá una consulta usando EXPLAIN QUERY PLAN
:
sqlite> EXPLAIN QUERY PLAN
SELECT id, name
FROM Customers
WHERE city = 'Paris';
QUERY PLAN
`--SCAN Customers
El ejemplo anterior requiere un análisis completo de la tabla sin un índice para encontrar todos los clientes de París. Esto se denomina complejidad lineal. SQLite necesita leer todas las filas y solo mantener las filas que coincidan con los clientes de París. Para solucionar esto, puedes agregar un índice:
sqlite> CREATE INDEX Idx1 ON Customers(city);
sqlite> EXPLAIN QUERY PLAN
SELECT id, name
FROM Customers
WHERE city = 'Paris';
QUERY PLAN
`--SEARCH test USING INDEX Idx1 (city=?
Si usas la shell interactiva, puedes pedirle a SQLite que siempre explique los planes de consulta:
sqlite> .eqp on
Para obtener más información, ve a Planificación de consultas.
SQLite Analyzer
SQLite ofrece el
sqlite3_analyzer
de línea de comandos (CLI) para volcar información adicional que puede usarse
y solucionar problemas de rendimiento. Visita la página de descarga de SQLite para instalarla.
Puedes usar adb pull
para descargar un archivo de base de datos de un dispositivo de destino en tu estación de trabajo para su análisis:
adb pull /data/data/<app_package_name>/databases/<db_name>.db
SQLite Browser
También puedes instalar la herramienta de GUI SQLite Browser desde la página de descargas de SQLite.
Registro de Android
Android calcula el tiempo de las consultas de SQLite y las registra por ti:
# Enable query time logging
$ adb shell setprop log.tag.SQLiteTime VERBOSE
# Disable query time logging
$ adb shell setprop log.tag.SQLiteTime ERROR
```### Perfetto tracing
### Perfetto tracing {:#perfetto-tracing}
When [configuring Perfetto](https://perfetto.dev/docs/concepts/config), you may
add the following to include tracks for individual queries:
```protobuf
data_sources {
config {
name: "linux.ftrace"
ftrace_config {
atrace_categories: "database"
}
}
}
Recomendaciones para ti
- Nota: El texto del vínculo se muestra cuando JavaScript está desactivado
- Cómo ejecutar comparativas en la integración continua
- Fotogramas congelados
- Cómo crear y medir perfiles de Baseline sin macrocomparativas