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
UserDefaultsde App Group con un patrón thread-safe de primer acceso - Se integra directamente con SwiftUI usando
@AppStoragey 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:
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— proporcionaUnitSystem.allCasespara construir pickers sin tener que escribir las opciones a manoSendable— 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 tipoString
Conversiones de Distancia
Cada distancia se almacena en metros. El enum convierte hacia y desde la unidad de presentación del usuario:
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:
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:
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:
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:
- Primera lectura — la ruta rápida. Si la clave ya existe (99% de los accesos después del primer lanzamiento), retorna inmediatamente.
- 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.
- 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:
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:
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:
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:
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.0siempre 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:
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 —
displayDistanceymetersFromDisplayusan 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
@AppStorageyCaseIterablehace 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.

