Skip to content

Conversión de Unidades en Swift: Sistema Métrico e Imperial con Detección de Locale y Persistencia Thread-Safe

Introducción

Tu app almacena una carrera como 5,000 metros. Un usuario en Berlín ve "5.0 km". Un usuario en Texas ve "3.1 mi". Los mismos datos, diferente presentación —y si lo haces mal, tu app de fitness se ve rota.

Apple provee Measurement y MeasurementFormatter para el manejo completo de unidades, pero cuando tu app solo necesita dos tipos de medición (distancia y peso) con una preferencia que el usuario puede alternar y que se sincroniza con widgets, esas APIs son más pesadas de lo necesario. Un enum respaldado por String con algunos métodos de conversión te da todo lo que necesitas en menos de 100 líneas.

Este artículo construye un UnitSystem probado en producción que:

  • Detecta automáticamente el sistema métrico o imperial según la región del dispositivo
  • Convierte entre unidades de almacenamiento (metros, kilogramos) y unidades de presentación (km/mi, kg/lbs)
  • Persiste la elección del usuario mediante UserDefaults de App Group con un patrón thread-safe de primer acceso
  • Se integra directamente con SwiftUI usando @AppStorage y un picker segmentado

Prerrequisitos

  • Swift 5.9+ / Xcode 15+
  • Un App Group configurado en los entitlements de tu target (para sincronización con widgets)

Definiendo el Enum UnitSystem

El tipo central es un enum respaldado por String. Usar String como raw value significa que la conformidad con Codable es automática —sin codificación personalizada— y UserDefaults puede almacenarlo directamente:

swift
import Foundation

public enum UnitSystem: String, Codable, CaseIterable, Sendable {
    case metric   // km, kg
    case imperial // millas, lbs

    public var displayName: String {
        switch self {
        case .metric:   "Métrico (km, kg)"
        case .imperial: "Imperial (mi, lbs)"
        }
    }
}

Vale la pena destacar tres conformidades:

  • CaseIterable — proporciona UnitSystem.allCases para construir pickers sin tener que escribir las opciones a mano
  • Sendable — marca el tipo como seguro para pasar entre límites de concurrencia (requerido para la concurrencia estricta de Swift 6)
  • Codable — codificación/decodificación JSON gratuita gracias al raw value de tipo String

Conversiones de Distancia

Cada distancia se almacena en metros. El enum convierte hacia y desde la unidad de presentación del usuario:

swift
extension UnitSystem {
    public var distanceUnit: String {
        switch self {
        case .metric:   "km"
        case .imperial: "mi"
        }
    }

    public var distanceUnitLong: String {
        switch self {
        case .metric:   "kilómetros"
        case .imperial: "millas"
        }
    }

    /// Convierte metros a la unidad de presentación (km o millas)
    public func displayDistance(meters: Double) -> Double {
        switch self {
        case .metric:   meters / 1_000.0
        case .imperial: meters / 1_609.344
        }
    }

    /// Convierte la unidad de presentación de vuelta a metros para almacenamiento
    public func metersFromDisplay(_ value: Double) -> Double {
        switch self {
        case .metric:   value * 1_000.0
        case .imperial: value * 1_609.344
        }
    }
}

El factor de conversión 1,609.344 es el número exacto de metros en una milla internacional. Usar una sola constante para ambas direcciones (multiplicar en un sentido, dividir en el otro) elimina la deriva entre las conversiones directa e inversa.

Conversiones de Peso

El mismo patrón, con kilogramos como unidad de almacenamiento:

swift
extension UnitSystem {
    public var weightUnit: String {
        switch self {
        case .metric:   "kg"
        case .imperial: "lbs"
        }
    }

    public var weightUnitLong: String {
        switch self {
        case .metric:   "kilogramos"
        case .imperial: "libras"
        }
    }

    /// Convierte kilogramos a la unidad de presentación (kg o lbs)
    public func displayWeight(kilograms: Double) -> Double {
        switch self {
        case .metric:   kilograms
        case .imperial: kilograms * 2.20462
        }
    }

    /// Convierte la unidad de presentación de vuelta a kilogramos para almacenamiento
    public func kilogramsFromDisplay(_ value: Double) -> Double {
        switch self {
        case .metric:   value
        case .imperial: value / 2.20462
        }
    }
}

TIP

El caso métrico devuelve el valor sin cambios —sin cálculos. Este es un beneficio sutil de almacenar en métrico: el sistema de unidades más usado en el mundo no incurre en ningún costo de conversión.

Detección de Locale

Solo tres países usan oficialmente el sistema imperial: Estados Unidos, Liberia y Myanmar. Todos los demás usan el métrico. La detección es directa:

swift
extension UnitSystem {
    /// Detecta el sistema de unidades preferido según la región del dispositivo.
    public static func fromLocale() -> UnitSystem {
        let regionCode = Locale.current.region?.identifier ?? ""
        let imperialRegions = ["US", "LR", "MM"]
        return imperialRegions.contains(regionCode) ? .imperial : .metric
    }
}

Locale.current.region?.identifier devuelve el código ISO 3166-1 alfa-2 de la configuración regional del usuario. Esto es más confiable que verificar Locale.current.usesMetricSystem en casos donde quieres control por app en lugar de delegar al formateador del sistema.

Importante

Locale.current refleja la configuración del dispositivo al momento de acceso, no al momento del lanzamiento de la app. Si un usuario cambia su región en Configuración y vuelve a tu app, fromLocale() detectará la nueva región. Esto generalmente es lo deseado, pero ten en cuenta que puede cambiar a mitad de sesión.

Persistencia Thread-Safe

La preferencia se almacena en UserDefaults de App Group para que se sincronice entre la app principal y los widgets. La propiedad estática current implementa un patrón de comparar-y-asignar para un primer acceso seguro:

swift
public extension UnitSystem {
    static var current: UnitSystem {
        get {
            // Ruta rápida: ya está almacenado
            if let rawValue = SharedDefaults.appGroup
                .string(forKey: SharedDefaults.Keys.Units.system),
               let system = UnitSystem(rawValue: rawValue)
            {
                return system
            }

            // Primer acceso: detectar desde el locale
            let detected = fromLocale()

            // Solo persistir si no fue asignado por un acceso concurrente
            if SharedDefaults.appGroup
                .string(forKey: SharedDefaults.Keys.Units.system) == nil
            {
                SharedDefaults.appGroup.set(
                    detected.rawValue,
                    forKey: SharedDefaults.Keys.Units.system
                )
            }

            // Devolver el valor almacenado (puede haber sido asignado por otro hilo)
            if let storedRaw = SharedDefaults.appGroup
                .string(forKey: SharedDefaults.Keys.Units.system),
               let stored = UnitSystem(rawValue: storedRaw)
            {
                return stored
            }

            return detected
        }
        set {
            SharedDefaults.appGroup.set(
                newValue.rawValue,
                forKey: SharedDefaults.Keys.Units.system
            )
        }
    }
}

La razón de las tres lecturas:

  1. Primera lectura — la ruta rápida. Si la clave ya existe (99% de los accesos después del primer lanzamiento), retorna inmediatamente.
  2. Detección + escritura condicional — en el primer acceso, detecta desde el locale y persiste solo si la clave sigue siendo nil. Esto evita sobrescribir un valor que un hilo concurrente pueda haber establecido.
  3. Lectura final — relee el valor almacenado para garantizar que devolvemos lo que realmente se persistió, no solo lo que detectamos. Si otro hilo ganó la carrera, usamos su valor.

Este patrón de tres lecturas evita locks y al mismo tiempo asegura que todos los hilos converjan en el mismo valor después del primer acceso.

TIP

UserDefaults es thread-safe para operaciones individuales de lectura/escritura. El patrón de comparar-y-asignar aquí no busca proteger a UserDefaults en sí —busca asegurar que el valor por defecto detectado del locale se escriba solo una vez, incluso cuando múltiples hilos disparan el primer acceso simultáneamente.

Configuración de SharedDefaults

El wrapper SharedDefaults proporciona un punto de acceso centralizado para UserDefaults del App Group:

swift
public enum SharedDefaults {
    public static var appGroup: UserDefaults {
        UserDefaults(suiteName: "group.com.yourapp") ?? .standard
    }

    public enum Keys {
        public enum Units {
            public static let system = "dev.yourapp.units.system"
        }
    }
}

Si el App Group no es accesible (por ejemplo, al ejecutar en un target de pruebas sin entitlements), recurre a UserDefaults.standard. Esto previene crashes mientras degrada de manera clara: la preferencia no se sincronizará con widgets, pero la app sigue funcionando.

Integración con SwiftUI

Picker de Configuración

@AppStorage lee directamente de la misma clave de UserDefaults, así que los cambios en el picker se propagan instantáneamente a cada vista que lea UnitSystem.current:

swift
import SwiftUI

struct AppPreferencesView: View {
    @AppStorage(
        SharedDefaults.Keys.Units.system,
        store: SharedDefaults.appGroup
    )
    private var unitSystemRaw = UnitSystem.fromLocale().rawValue

    private var unitSystemBinding: Binding<UnitSystem> {
        Binding(
            get: { UnitSystem(rawValue: unitSystemRaw) ?? .metric },
            set: { unitSystemRaw = $0.rawValue }
        )
    }

    var body: some View {
        Picker("Sistema de unidades", selection: unitSystemBinding) {
            ForEach(UnitSystem.allCases, id: \.self) { system in
                Text(system.displayName).tag(system)
            }
        }
        .pickerStyle(.segmented)
    }
}

El wrapper Binding es necesario porque @AppStorage almacena el String crudo, no el enum UnitSystem directamente. El binding traduce entre ambos.

Mostrando Valores Convertidos

En la capa de vista, siempre convierte de unidades de almacenamiento (métricas) a unidades de presentación:

swift
func formatDistance(_ meters: Double) -> String {
    let system = UnitSystem.current
    let value = system.displayDistance(meters: meters)
    return String(format: "%.1f %@", value, system.distanceUnit)
}

func formatWeight(_ kilograms: Double) -> String {
    let system = UnitSystem.current
    let value = system.displayWeight(kilograms: kilograms)
    return String(format: "%.0f %@", value, system.weightUnit)
}

// Uso
formatDistance(5000)    // "5.0 km" o "3.1 mi"
formatWeight(90)       // "90 kg" o "198 lbs"

Aceptando Entrada del Usuario

Cuando el usuario escribe un valor en su unidad preferida, conviértelo de vuelta a métrico antes de guardar:

swift
func saveExercise(distanceText: String, weightText: String) {
    let system = UnitSystem.current

    if let displayDistance = Double(distanceText), displayDistance > 0 {
        let meters = system.metersFromDisplay(displayDistance)
        // Almacenar `meters` en tu base de datos
    }

    if let displayWeight = Double(weightText), displayWeight >= 0 {
        let kilograms = system.kilogramsFromDisplay(displayWeight)
        // Almacenar `kilograms` en tu base de datos
    }
}

El Patrón de Almacenar en Métrico

Vale la pena mencionar explícitamente el principio de diseño detrás de esta utilidad:

Siempre almacena los valores en métrico. Convierte solo en la capa de presentación.

Esto significa:

  • Los valores en la base de datos son agnósticos a la unidad. Una distancia de 5000.0 siempre significa 5,000 metros, sin importar cuál era la preferencia del usuario cuando la ingresó.
  • Cambiar la preferencia no requiere migración de datos. Si un usuario cambia de imperial a métrico, sus datos no necesitan ser recalculados —solo cambia la presentación.
  • Las agregaciones funcionan correctamente. Sumar distancias de diferentes usuarios (o del mismo usuario que cambió su preferencia) da resultados correctos porque todo está en la misma unidad.
  • Se preserva la precisión en ida y vuelta. Convertir presentación -> metros -> presentación usando el mismo factor produce el valor original (dentro de la precisión de punto flotante).

Importante

Si almacenas los valores en la unidad de presentación del usuario, creas una dependencia entre los datos y la preferencia. Cambiar la preferencia requeriría recalcular cada valor almacenado —una pesadilla de migración para cualquier conjunto de datos de tamaño no trivial.

Pruebas

La simplicidad del enum hace que las pruebas sean directas. Cubre conversiones, ida y vuelta, y casos límite:

swift
import XCTest

final class UnitSystemTests: XCTestCase {

    // MARK: - Distancia

    func testMetricDistanceConversion() {
        let system = UnitSystem.metric
        XCTAssertEqual(system.displayDistance(meters: 1_000), 1.0)
        XCTAssertEqual(system.displayDistance(meters: 5_000), 5.0)
    }

    func testImperialDistanceConversion() {
        let system = UnitSystem.imperial
        let miles = system.displayDistance(meters: 1_609.344)
        XCTAssertEqual(miles, 1.0, accuracy: 0.0001)
    }

    func testDistanceRoundTrip() {
        for system in UnitSystem.allCases {
            let original = 42_195.0 // Maratón en metros
            let display = system.displayDistance(meters: original)
            let backToMeters = system.metersFromDisplay(display)
            XCTAssertEqual(backToMeters, original, accuracy: 0.001)
        }
    }

    // MARK: - Peso

    func testImperialWeightConversion() {
        let system = UnitSystem.imperial
        let lbs = system.displayWeight(kilograms: 1.0)
        XCTAssertEqual(lbs, 2.20462, accuracy: 0.0001)
    }

    func testWeightRoundTrip() {
        for system in UnitSystem.allCases {
            let original = 100.0 // kg
            let display = system.displayWeight(kilograms: original)
            let backToKg = system.kilogramsFromDisplay(display)
            XCTAssertEqual(backToKg, original, accuracy: 0.001)
        }
    }

    // MARK: - Casos Límite

    func testZeroValues() {
        for system in UnitSystem.allCases {
            XCTAssertEqual(system.displayDistance(meters: 0), 0)
            XCTAssertEqual(system.displayWeight(kilograms: 0), 0)
        }
    }

    // MARK: - Codable

    func testCodableRoundTrip() throws {
        let original = UnitSystem.imperial
        let data = try JSONEncoder().encode(original)
        let decoded = try JSONDecoder().decode(UnitSystem.self, from: data)
        XCTAssertEqual(decoded, original)
    }

    // MARK: - Etiquetas

    func testUnitLabels() {
        XCTAssertEqual(UnitSystem.metric.distanceUnit, "km")
        XCTAssertEqual(UnitSystem.imperial.distanceUnit, "mi")
        XCTAssertEqual(UnitSystem.metric.weightUnit, "kg")
        XCTAssertEqual(UnitSystem.imperial.weightUnit, "lbs")
    }
}

TIP

Las pruebas de ida y vuelta usan accuracy porque la división de punto flotante seguida de una multiplicación puede introducir errores mínimos. Una precisión de 0.001 (una milésima de metro o kilogramo) es más que suficiente para cualquier medición del mundo real.

Cuándo Usar el Framework Measurement de Apple

Este enfoque liviano funciona bien cuando:

  • Tienes un conjunto fijo de tipos de medición (distancia + peso, en este caso)
  • Quieres una preferencia que el usuario pueda alternar almacenada en UserDefaults
  • Necesitas sincronización con widgets mediante App Groups
  • Prefieres control explícito sobre el formato

Considera usar Measurement<UnitLength> y MeasurementFormatter de Apple cuando:

  • Manejas muchos tipos de unidades (temperatura, velocidad, volumen, presión, etc.)
  • Necesitas formato automático basado en locale sin construir tus propias cadenas de formato
  • Quieres conversión automática de escala (por ejemplo, mostrar automáticamente "1.2 km" en lugar de "1,200 m")

Ambos enfoques siguen el mismo principio: almacenar en una unidad canónica, convertir al momento de presentar.

Conclusión

Un enum respaldado por String te da un sistema completo de conversión de unidades en menos de 100 líneas:

  • Detección de locale asigna el valor predeterminado correcto en el primer lanzamiento verificando tres códigos de región
  • Métodos de conversión bidireccional mantienen la API simétrica —displayDistance y metersFromDisplay usan el mismo factor
  • Persistencia thread-safe mediante un patrón de comparar-y-asignar asegura valores por defecto consistentes sin locks
  • Integración con SwiftUI mediante @AppStorage y CaseIterable hace que la UI de configuración sea trivial

La decisión arquitectónica clave es el patrón de almacenar en métrico. Al mantener tu base de datos agnóstica a la unidad, desacoplas los datos de la presentación y evitas dolores de cabeza con migraciones cuando los usuarios cambian su preferencia.