Construir BorealisClima requirió una arquitectura Android nativa que obtuviera el pronóstico de Open-Meteo, lo mantuviera disponible sin conexión y puntuara los deportes con reglas deterministas, todo en el dispositivo. En este artículo desgloso cada componente técnico y las decisiones arquitectónicas clave.
🎯 Desafío arquitectónico: convertir el pronóstico de Open-Meteo en recomendaciones deportivas útiles en menos de 2 segundos, funcionando también sin conexión y sin enviar datos a ningún servidor propio.
Visión General de la Arquitectura
BorealisClima es una app Android nativa (Kotlin + Jetpack Compose) construida sobre una arquitectura limpia por capas: MVVM con Hilt para la inyección de dependencias, Room para la persistencia local, Retrofit para el acceso a la API meteorológica y un motor de reglas en Kotlin puro para el scoring deportivo. Todo el procesamiento ocurre en el dispositivo, con énfasis en la velocidad de respuesta y la disponibilidad offline.
Capas de la Arquitectura
Integración con Open-Meteo
BorealisClima obtiene el pronóstico de Open-Meteo, una API abierta y gratuita que no necesita clave. Con una sola petición se traen todas las variables que necesita el motor de reglas, para el momento actual y para las próximas 72 horas.
- Sin registro ni clave
- Cobertura global
- Pronóstico horario a 7 días
- Bien documentada y estable
- Respuesta JSON compacta
- Temperatura y sensación térmica
- Viento y ráfagas
- Índice UV
- Humedad relativa
- Precipitación y probabilidad
- Sin cuentas de usuario
- Sin analytics remoto
- Sin claves que filtrar
- Ubicación a nivel de ciudad
- Todo el histórico vive en Room
Cliente de Open-Meteo con Retrofit
// Servicio Retrofit: una sola llamada trae todo lo necesario
interface OpenMeteoService {
@GET("v1/forecast")
suspend fun getForecast(
@Query("latitude") lat: Double,
@Query("longitude") lon: Double,
@Query("current") current: String =
"temperature_2m,relative_humidity_2m,apparent_temperature," +
"precipitation,wind_speed_10m,wind_gusts_10m,uv_index",
@Query("hourly") hourly: String =
"temperature_2m,precipitation_probability,wind_speed_10m,uv_index",
@Query("forecast_days") days: Int = 3,
@Query("timezone") timezone: String = "auto"
): ForecastResponse
}
// DTO -> modelo de dominio
data class WeatherConditions(
val temperature: Double,
val apparentTemperature: Double,
val humidity: Int,
val windSpeed: Double,
val windGusts: Double,
val uvIndex: Double,
val precipitation: Double,
val fetchedAt: Instant
)
class WeatherRepository(
private val api: OpenMeteoService,
private val cache: WeatherCache
) {
// Devuelve datos frescos si hay red; si no, el último pronóstico guardado
suspend fun getConditions(lat: Double, lon: Double): Result<WeatherConditions> =
runCatching {
val response = api.getForecast(lat, lon)
response.toDomain().also { cache.save(lat, lon, it) }
}.recoverCatching { error ->
cache.lastKnown(lat, lon) ?: throw error
}
}
Algoritmos de Scoring Deportivo
El corazón de BorealisClima es un motor de reglas en Kotlin puro que convierte los datos meteorológicos brutos en recomendaciones deportivas específicas. No hay machine learning ni TensorFlow Lite: son funciones de puntuación deterministas que se ejecutan íntegramente en el dispositivo. Cada deporte define su propio conjunto de rangos ideales y pesos.
Motor de Reglas Multifactorial (Kotlin puro)
// Reglas por deporte: rangos ideales y pesos. Datos, no modelo entrenado.
data class SportRule(
val temperatureOptimal: ClosedRange<Double>,
val temperatureWeight: Double,
val windSpeedMax: Double,
val windWeight: Double,
val humidityOptimal: IntRange,
val humidityWeight: Double,
val uvIndexMax: Double,
val uvWeight: Double,
val precipitationTolerance: Double,
val precipitationWeight: Double
)
val SPORT_RULES: Map<Sport, SportRule> = mapOf(
Sport.RUNNING to SportRule(
temperatureOptimal = 15.0..22.0, temperatureWeight = 0.35,
windSpeedMax = 15.0, windWeight = 0.20,
humidityOptimal = 30..60, humidityWeight = 0.15,
uvIndexMax = 6.0, uvWeight = 0.20,
precipitationTolerance = 0.1, precipitationWeight = 0.10
),
Sport.CYCLING to SportRule(
temperatureOptimal = 18.0..28.0, temperatureWeight = 0.30,
windSpeedMax = 25.0, windWeight = 0.35, // más sensible al viento
humidityOptimal = 40..70, humidityWeight = 0.10,
uvIndexMax = 8.0, uvWeight = 0.15,
precipitationTolerance = 0.0, precipitationWeight = 0.10 // cero lluvia
),
Sport.GOLF to SportRule(
temperatureOptimal = 16.0..30.0, temperatureWeight = 0.25,
windSpeedMax = 20.0, windWeight = 0.30,
humidityOptimal = 40..80, humidityWeight = 0.05,
uvIndexMax = 9.0, uvWeight = 0.25,
precipitationTolerance = 0.2, precipitationWeight = 0.15
)
// ... hasta 27 deportes
)
object SportsScoringEngine {
// Función pura: mismas entradas, misma salida. Sin estado, sin red.
fun score(sport: Sport, weather: WeatherConditions): Double {
val rule = SPORT_RULES.getValue(sport)
var total = 100.0
total *= factor(temperatureScore(weather.temperature, rule.temperatureOptimal), rule.temperatureWeight)
total *= factor(windScore(weather.windSpeed, rule.windSpeedMax), rule.windWeight)
total *= factor(humidityScore(weather.humidity, rule.humidityOptimal), rule.humidityWeight)
total *= factor(uvScore(weather.uvIndex, rule.uvIndexMax), rule.uvWeight)
if (weather.precipitation > rule.precipitationTolerance) {
val penalty = min(weather.precipitation / rule.precipitationTolerance.coerceAtLeast(0.01), 1.0)
total *= (1 - penalty * rule.precipitationWeight)
}
return total.coerceIn(0.0, 100.0)
}
private fun factor(subScore: Double, weight: Double) = subScore * weight + (1 - weight)
private fun temperatureScore(temp: Double, optimal: ClosedRange<Double>): Double {
if (temp in optimal) return 1.0
val deviation = if (temp < optimal.start) optimal.start - temp else temp - optimal.endInclusive
return (1.0 - deviation * 0.08).coerceAtLeast(0.0) // -8% por grado fuera de rango
}
private fun windScore(windSpeed: Double, maxWind: Double): Double {
if (windSpeed <= maxWind) return 1.0
val excess = windSpeed - maxWind
return (1.0 - (excess / 10.0).pow(1.5)).coerceAtLeast(0.0) // penalización exponencial
}
}
Caché Offline con Room
Para que la app responda rápido y funcione sin conexión, el pronóstico se guarda en Room en cuanto llega. Una capa en memoria evita releer la base de datos en consultas seguidas.
Flujo de Datos
La pantalla pide el pronóstico para una ubicación
Se comprueba la copia en memoria (válida 10 minutos)
Se comprueba el último pronóstico guardado (válido 30 minutos)
Una petición a la API si no hay copia fresca
Se escribe en Room y en memoria, y se calculan los scores
Se entrega el pronóstico con las recomendaciones por deporte
Implementación de la caché
@Entity(tableName = "weather_cache")
data class CachedForecast(
@PrimaryKey val cacheKey: String, // lat/lon redondeadas
val payload: WeatherConditions, // vía TypeConverter a JSON
val savedAt: Long
)
@Dao
interface WeatherDao {
@Query("SELECT * FROM weather_cache WHERE cacheKey = :key LIMIT 1")
suspend fun find(key: String): CachedForecast?
@Upsert
suspend fun upsert(entry: CachedForecast)
@Query("DELETE FROM weather_cache WHERE savedAt < :threshold")
suspend fun purgeOlderThan(threshold: Long)
}
class WeatherCache(private val dao: WeatherDao) {
private val memory = LruCache<String, CachedForecast>(50)
private val memoryTtl = 10.minutes
private val roomTtl = 30.minutes
suspend fun get(lat: Double, lon: Double): WeatherConditions? {
val key = keyOf(lat, lon)
val now = System.currentTimeMillis()
memory.get(key)?.let { if (now - it.savedAt < memoryTtl.inWholeMilliseconds) return it.payload }
val stored = dao.find(key) ?: return null
if (now - stored.savedAt < roomTtl.inWholeMilliseconds) {
memory.put(key, stored) // se sube a memoria
return stored.payload
}
return null // hay algo, pero está caducado
}
suspend fun save(lat: Double, lon: Double, conditions: WeatherConditions) {
val entry = CachedForecast(keyOf(lat, lon), conditions, System.currentTimeMillis())
memory.put(entry.cacheKey, entry)
dao.upsert(entry)
}
// Última copia sin importar la antigüedad: se usa cuando no hay red
suspend fun lastKnown(lat: Double, lon: Double): WeatherConditions? =
dao.find(keyOf(lat, lon))?.payload
private fun keyOf(lat: Double, lon: Double) =
"%.2f_%.2f".format(lat, lon) // agrupa ubicaciones cercanas
}
Manejo de Estados Offline
BorealisClima tiene que seguir siendo útil sin conexión, mostrando el último pronóstico guardado y dejando claro cuándo se descargó.
⚠️ Desafío de conectividad: mucha gente consulta el tiempo justo antes de salir al monte o a la playa, donde la cobertura es mala. La app no puede quedarse en blanco en ese momento.
enum class Source { NETWORK, CACHE_FRESH, CACHE_STALE }
data class ForecastResult(
val conditions: WeatherConditions,
val source: Source,
val ageMinutes: Long
)
class GetForecastUseCase(
private val repository: WeatherRepository,
private val cache: WeatherCache
) {
suspend operator fun invoke(lat: Double, lon: Double): ForecastResult {
// 1. Copia fresca en memoria o en Room
cache.get(lat, lon)?.let {
return ForecastResult(it, Source.CACHE_FRESH, ageIn(it))
}
// 2. Pedir a Open-Meteo; el repositorio ya cae en la última copia si no hay red
return repository.getConditions(lat, lon).fold(
onSuccess = { fresh ->
val fromNetwork = ageIn(fresh) < 1
ForecastResult(
fresh,
if (fromNetwork) Source.NETWORK else Source.CACHE_STALE,
ageIn(fresh)
)
},
onFailure = { error ->
// 3. Sin red y sin copia fresca: se muestra la caducada, avisando de su antigüedad
val stale = cache.lastKnown(lat, lon) ?: throw error
ForecastResult(stale, Source.CACHE_STALE, ageIn(stale))
}
)
}
private fun ageIn(c: WeatherConditions): Long =
Duration.between(c.fetchedAt, Instant.now()).toMinutes()
}
Optimización de Performance
Para mantener la app fluida, la optimización se centra en lo que más cuesta: la petición de red y el cálculo de los 27 scores.
Técnicas de optimización aplicadas
- Una sola petición: Open-Meteo devuelve las variables actuales y las 72 h en una llamada
- Caché primero: memoria y Room antes de tocar la red
- Scoring en paralelo: los 27 deportes se puntúan con coroutines en
Dispatchers.Default - Precarga: al abrir la app se lanza la consulta de la última ubicación
- Recomposición mínima: estado inmutable y
StateFlowpara no redibujar de más en Compose
class GetRecommendationsUseCase(
private val getForecast: GetForecastUseCase
) {
suspend operator fun invoke(
lat: Double,
lon: Double,
sports: List<Sport>
): List<SportRecommendation> = coroutineScope {
// 1. Pronóstico (red o caché) una sola vez
val forecast = getForecast(lat, lon)
// 2. Puntuar los deportes en paralelo; score() es una función pura
sports.map { sport ->
async(Dispatchers.Default) {
val score = SportsScoringEngine.score(sport, forecast.conditions)
SportRecommendation(
sport = sport,
score = score,
source = forecast.source,
tips = buildTips(sport, score, forecast.conditions)
)
}
}.awaitAll().sortedByDescending { it.score }
}
}
Métricas Locales de Depuración
BorealisClima no envía analítica a ningún servidor. Durante el desarrollo registro unas métricas en memoria, solo en compilaciones de depuración, para detectar regresiones de rendimiento antes de publicar.
📊 Qué se mide en local: latencia de la petición a Open-Meteo, aciertos de caché frente a llamadas de red, errores de red y tiempo de cálculo de los 27 scores. Nada de esto sale del dispositivo ni se guarda de forma persistente.
// Solo debug: contadores en memoria, sin red, sin persistencia
object DebugMetrics {
private data class Stat(var count: Long = 0, var totalMs: Long = 0) {
val avgMs get() = if (count == 0L) 0 else totalMs / count
}
private val timings = ConcurrentHashMap<String, Stat>()
private val counters = ConcurrentHashMap<String, Long>()
inline fun <T> time(label: String, block: () -> T): T {
if (!BuildConfig.DEBUG) return block()
val start = SystemClock.elapsedRealtime()
return block().also { record(label, SystemClock.elapsedRealtime() - start) }
}
fun record(label: String, elapsedMs: Long) {
if (!BuildConfig.DEBUG) return
timings.getOrPut(label) { Stat() }.apply { count++; totalMs += elapsedMs }
}
fun increment(label: String) {
if (!BuildConfig.DEBUG) return
counters.merge(label, 1L, Long::plus)
}
// Volcado a Logcat bajo demanda; nunca se sube a ningún sitio
fun dump() = buildString {
timings.forEach { (k, v) -> appendLine("$k: ${v.avgMs} ms (n=${v.count})") }
counters.forEach { (k, v) -> appendLine("$k: $v") }
}
}
// Uso
val forecast = DebugMetrics.time("open_meteo_request") { repository.getConditions(lat, lon) }
DebugMetrics.increment(if (servedFromCache) "cache_hit" else "network_call")
Lecciones Aprendidas y Mejores Prácticas
Tras varios meses desarrollando y refinando la arquitectura de BorealisClima, estas son las lecciones que más me han servido:
🎯 Decisiones Arquitectónicas Clave
- ✅ Una API abierta y sin clave simplifica todo: con Open-Meteo no hay secretos que gestionar ni cuotas que vigilar
- ✅ La caché en Room salva la UX: sin ella, la app sería inservible sin cobertura
- ✅ Enfoque offline-first: el pronóstico útil tiene que estar antes de tocar la red
- ✅ Reglas específicas por deporte: un único conjunto de umbrales no sirve para actividades tan distintas
- ✅ Métricas locales desde el día uno: sin analítica remota, pero con datos suficientes para optimizar
⚠️ Errores a Evitar
- ❌ No cachear el último pronóstico: si la petición falla, el usuario se queda sin nada que mirar
- ❌ Caché demasiado agresiva: un dato muy viejo puede ser peor que avisar de que no hay dato
- ❌ Reglas demasiado complejas: la simplicidad predecible gana a la sofisticación impredecible
- ❌ Ignorar los casos límite: ubicaciones remotas, zonas horarias y condiciones extremas
¿Construyendo una app móvil con datos en tiempo real?
Si estás desarrollando una app Android que consume una API y necesita funcionar bien sin conexión, puedo ayudarte a diseñar una arquitectura limpia y sólida.
🏗️ Consultoría de Arquitectura