Cómo optimicé una app JavaFX al límite
Técnicas reales de profiling, lazy loading y gestión de memoria aplicadas en WalletApp: una aplicación de escritorio para gestión de finanzas personales construida con JavaFX y SQLite.
TL;DR: Pasé de 4 segundos de arranque y 180 MB de RAM en idle a 1.2 segundos y 95 MB aplicando lazy loading de vistas FXML, pool de conexiones SQLite, caché de consultas frecuentes y virtualización de listas.
// 01. El problema
WalletApp empezó como un proyecto de aprendizaje: una aplicación JavaFX para llevar el control de mis finanzas personales. Varias cuentas, categorías, gráficos de evolución mensual. Funcionaba, pero la experiencia era horrible.
En la versión inicial, al arrancar la aplicación cargaba todas las vistas FXML de golpe, abría la conexión a SQLite, ejecutaba 12 queries de inicialización y calculaba los gráficos. Todo en el hilo principal. El resultado:
// 02. Profiling primero, optimizar después
El error clásico es optimizar a ciegas. Yo empecé conectando VisualVM (incluido con el JDK) y grabando un perfil de arranque. La sorpresa: el 68% del tiempo de arranque lo consumían dos cosas:
- Carga de todos los FXML en el constructor de
MainController - Una query JOIN sin índice en la tabla de transacciones
Sin profiling habría empezado optimizando lo que me parecía lento intuitivamente (los gráficos con JavaFX Charts). Habría perdido tiempo en el 32% del problema.
// 03. Lazy loading de vistas FXML
La primera versión cargaba los 5 paneles (Dashboard, Cuentas, Transacciones, Categorías, Estadísticas) al arrancar, aunque el usuario solo vea el Dashboard. La solución es cargarlos bajo demanda:
// ANTES: todo cargado en el constructor
public class MainController {
@FXML private Parent dashboardPane;
@FXML private Parent accountsPane;
@FXML private Parent transactionsPane;
// 5 paneles cargados siempre
public void initialize() {
loadAllPanes(); // 4 segundos aquí
}
}
// DESPUÉS: lazy loading por demanda
public class MainController {
private final Map<String, Parent> paneCache = new HashMap<>();
private Parent getPane(String name) throws IOException {
return paneCache.computeIfAbsent(name, k -> {
try {
return FXMLLoader.load(
getClass().getResource("/fxml/" + k + ".fxml")
);
} catch (IOException e) {
throw new RuntimeException(e);
}
});
}
public void showDashboard() {
contentArea.setCenter(getPane("Dashboard"));
}
}
Resultado: el arranque baja de 4.1s a 1.8s. Solo se carga el Dashboard en el arranque. El resto se carga la primera vez que el usuario navega a esa sección y queda cacheado en paneCache.
// 04. Índices en SQLite y pool de conexiones
La query lenta era un JOIN entre transacciones y categorias filtrado por rango de fechas. Sin índice, SQLite hacía un full scan de toda la tabla en cada cambio de mes.
-- Índice compuesto por fecha y cuenta CREATE INDEX IF NOT EXISTS idx_trans_fecha_cuenta ON transacciones(fecha, cuenta_id); -- Índice para filtros por categoría CREATE INDEX IF NOT EXISTS idx_trans_categoria ON transacciones(categoria_id);
La query de dashboard pasó de ~280ms a ~12ms. Para el pool de conexiones, en lugar de abrir y cerrar SQLite en cada operación, mantengo una conexión persistente con WAL mode activado:
public class DatabaseManager {
private static Connection connection;
public static Connection getConnection() throws SQLException {
if (connection == null || connection.isClosed()) {
connection = DriverManager.getConnection("jdbc:sqlite:wallet.db");
// WAL mode: lecturas y escrituras concurrentes
connection.createStatement().execute("PRAGMA journal_mode=WAL");
connection.createStatement().execute("PRAGMA cache_size=10000");
}
return connection;
}
}
// 05. Caché de consultas frecuentes
El balance total y el resumen del mes actual se recalculaban en cada render. Como son datos que no cambian hasta que el usuario añade una transacción, los cacheo con invalidación explícita:
public class CacheService {
private static final Map<String, Object> cache = new ConcurrentHashMap<>();
@SuppressWarnings("unchecked")
public static <T> T get(String key, Supplier<T> loader) {
return (T) cache.computeIfAbsent(key, k -> loader.get());
}
// Llamar al añadir/editar/borrar transacción
public static void invalidate(String... keys) {
Arrays.stream(keys).forEach(cache::remove);
}
}
// 06. Virtualización de listas
Con 500+ transacciones, el ListView de JavaFX renderizaba todos los items aunque solo se vieran 15 en pantalla. JavaFX ya tiene virtualización built-in en ListView y TableView, pero yo estaba usando un VBox con un bucle. Error clásico.
Migrar a TableView con ObservableList y paginación redujo el cambio de pestaña de ~340ms a ~18ms con 1000 registros.
// 07. Resultados finales
// 08. Lo que me llevé
- Mide antes de optimizar. VisualVM reveló que estaba mirando en el lugar equivocado.
- Los índices en SQLite son gratis. Una línea de SQL puede eliminar segundos de latencia.
- JavaFX tiene buenas primitivas.
TableView,ListViewyTreeViewtienen virtualización built-in. Úsalas. - El lazy loading no es solo para web. Cargar bajo demanda es un patrón universal.
El código de WalletApp está en GitHub si quieres ver la implementación completa.