Skip to content

Conversao de Unidades em Swift: Metrico e Imperial com Deteccao de Locale e Persistencia Thread-Safe

Introducao

Seu app armazena uma corrida como 5.000 metros. Um usuario em Berlim ve "5,0 km". Um usuario no Texas ve "3,1 mi". Mesmos dados, apresentacao diferente -- e se voce errar, seu app de fitness parece estar quebrado.

A Apple oferece Measurement e MeasurementFormatter para manipulacao robusta de unidades, mas quando seu app precisa de apenas dois tipos de medida (distancia e peso) com uma preferencia alternavel pelo usuario que sincroniza com widgets, essas APIs sao mais pesadas do que o necessario. Um enum com raw value String e alguns metodos de conversao entrega tudo o que voce precisa em menos de 100 linhas.

Este artigo constroi um UnitSystem testado em producao que:

  • Detecta automaticamente metrico ou imperial pela regiao do dispositivo
  • Converte entre unidades de armazenamento (metros, quilogramas) e unidades de exibicao (km/mi, kg/lbs)
  • Persiste a escolha do usuario via UserDefaults de App Group com um padrao thread-safe de primeiro acesso
  • Se integra diretamente com SwiftUI usando @AppStorage e um picker segmentado

Pre-requisitos

  • Swift 5.9+ / Xcode 15+
  • Um App Group configurado nos entitlements do seu target (para sincronizacao com widgets)

Definindo o Enum UnitSystem

O tipo central e um enum com raw value String. Usar String como raw value significa que a conformidade com Codable e automatica -- sem encoding customizado -- e o UserDefaults pode armazena-lo diretamente:

swift
import Foundation

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

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

Tres conformidades merecem destaque:

  • CaseIterable -- fornece UnitSystem.allCases para construir pickers sem precisar hardcodar as opcoes
  • Sendable -- marca o tipo como seguro para passar entre fronteiras de concorrencia (obrigatorio para strict concurrency no Swift 6)
  • Codable -- encoding/decoding JSON gratuito a partir do raw value String

Conversoes de Distancia

Toda distancia e armazenada em metros. O enum converte de e para a unidade de exibicao do usuario:

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

    public var distanceUnitLong: String {
        switch self {
        case .metric:   "quilometros"
        case .imperial: "milhas"
        }
    }

    /// Converte metros para a unidade de exibicao (km ou milhas)
    public func displayDistance(meters: Double) -> Double {
        switch self {
        case .metric:   meters / 1_000.0
        case .imperial: meters / 1_609.344
        }
    }

    /// Converte a unidade de exibicao de volta para metros para armazenamento
    public func metersFromDisplay(_ value: Double) -> Double {
        switch self {
        case .metric:   value * 1_000.0
        case .imperial: value * 1_609.344
        }
    }
}

O fator de conversao 1.609,344 e o numero exato de metros em uma milha internacional. Usar uma unica constante para ambas as direcoes (multiplicar na ida, dividir na volta) elimina divergencias entre as conversoes direta e inversa.

Conversoes de Peso

Mesmo padrao, com quilogramas como unidade de armazenamento:

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

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

    /// Converte quilogramas para a unidade de exibicao (kg ou lbs)
    public func displayWeight(kilograms: Double) -> Double {
        switch self {
        case .metric:   kilograms
        case .imperial: kilograms * 2.20462
        }
    }

    /// Converte a unidade de exibicao de volta para quilogramas para armazenamento
    public func kilogramsFromDisplay(_ value: Double) -> Double {
        switch self {
        case .metric:   value
        case .imperial: value / 2.20462
        }
    }
}

TIP

O caso metrico retorna o valor inalterado -- nenhuma operacao matematica. Esse e um beneficio sutil de armazenar em metrico: o sistema de unidades mais usado no mundo nao tem nenhum custo de conversao.

Deteccao de Locale

Apenas tres paises usam oficialmente o sistema imperial: Estados Unidos, Liberia e Mianmar. Todos os demais usam o metrico. A deteccao e direta:

swift
extension UnitSystem {
    /// Detecta o sistema de unidades preferido a partir da regiao do 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 retorna o codigo ISO 3166-1 alpha-2 da configuracao de regiao do usuario. Isso e mais confiavel do que verificar Locale.current.usesMetricSystem nos casos em que voce quer controle por app em vez de delegar ao formatador do sistema.

Importante

Locale.current reflete as configuracoes do dispositivo no momento do acesso, nao no momento do lancamento do app. Se um usuario alterar a regiao nos Ajustes e voltar ao seu app, fromLocale() vai captar a nova regiao. Isso geralmente e desejavel, mas esteja ciente de que pode mudar no meio de uma sessao.

Persistencia Thread-Safe

A preferencia e armazenada no UserDefaults do App Group para sincronizar entre o app principal e os widgets. A propriedade estatica current implementa um padrao compare-and-set para acesso seguro na primeira utilizacao:

swift
public extension UnitSystem {
    static var current: UnitSystem {
        get {
            // Caminho rapido: ja esta armazenado
            if let rawValue = SharedDefaults.appGroup
                .string(forKey: SharedDefaults.Keys.Units.system),
               let system = UnitSystem(rawValue: rawValue)
            {
                return system
            }

            // Primeiro acesso: detectar pelo locale
            let detected = fromLocale()

            // Persistir apenas se nao foi definido por acesso concorrente
            if SharedDefaults.appGroup
                .string(forKey: SharedDefaults.Keys.Units.system) == nil
            {
                SharedDefaults.appGroup.set(
                    detected.rawValue,
                    forKey: SharedDefaults.Keys.Units.system
                )
            }

            // Retornar o valor armazenado (pode ter sido definido por outra thread)
            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
            )
        }
    }
}

Eis por que existem tres leituras:

  1. Primeira leitura -- o caminho rapido. Se a chave ja existe (99% dos acessos apos o primeiro lancamento), retorna imediatamente.
  2. Deteccao + escrita condicional -- no primeiro acesso, detecta pelo locale e persiste somente se a chave ainda for nil. Isso impede a sobrescrita de um valor que uma thread concorrente pode ter acabado de definir.
  3. Leitura final -- rele o valor armazenado para garantir que retornamos o que realmente foi persistido, nao apenas o que detectamos. Se outra thread venceu a corrida, usamos o valor dela.

Esse padrao de tres leituras evita locks e ao mesmo tempo garante que todas as threads convergam para o mesmo valor apos o primeiro acesso.

TIP

UserDefaults e thread-safe para operacoes individuais de leitura/escrita. O compare-and-set aqui nao e para proteger o UserDefaults em si -- e para garantir que o valor padrao detectado pelo locale seja escrito apenas uma vez, mesmo quando multiplas threads disparam o primeiro acesso simultaneamente.

Configuracao do SharedDefaults

O wrapper SharedDefaults fornece um ponto de acesso centralizado para o UserDefaults do 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"
        }
    }
}

Se o App Group nao estiver acessivel (por exemplo, ao rodar em um target de teste sem entitlements), ele faz fallback para UserDefaults.standard. Isso evita crashes enquanto degrada de forma clara: a preferencia nao sincroniza com widgets, mas o app continua funcionando.

Integracao com SwiftUI

Picker de Configuracoes

@AppStorage le diretamente da mesma chave de UserDefaults, entao mudancas no picker se propagam instantaneamente para toda view que le 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)
    }
}

O wrapper Binding e necessario porque @AppStorage armazena a String bruta, nao o enum UnitSystem diretamente. O binding faz a traducao entre os dois.

Exibindo Valores Convertidos

Na camada de view, sempre converta das unidades de armazenamento (metrico) para as unidades de exibicao:

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" ou "3,1 mi"
formatWeight(90)       // "90 kg" ou "198 lbs"

Recebendo Entrada do Usuario

Quando o usuario digita um valor na unidade preferida, converta de volta para metrico antes de salvar:

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

    if let displayDistance = Double(distanceText), displayDistance > 0 {
        let meters = system.metersFromDisplay(displayDistance)
        // Armazene `meters` no seu banco de dados
    }

    if let displayWeight = Double(weightText), displayWeight >= 0 {
        let kilograms = system.kilogramsFromDisplay(displayWeight)
        // Armazene `kilograms` no seu banco de dados
    }
}

O Padrao Armazene-em-Metrico

O principio de design por tras desta utilidade merece ser destacado explicitamente:

Sempre armazene valores em metrico. Converta apenas na camada de exibicao.

Isso significa:

  • Valores no banco de dados sao agnosticos a unidade. Uma distancia de 5000.0 sempre significa 5.000 metros, independente de qual era a preferencia do usuario quando ele inseriu o valor.
  • Mudar a preferencia nao exige migracao de dados. Se um usuario trocar de imperial para metrico, os dados dele nao precisam ser recalculados -- apenas a exibicao muda.
  • Agregacoes funcionam corretamente. Somar distancias de usuarios diferentes (ou do mesmo usuario que mudou a preferencia) produz resultados corretos porque tudo esta na mesma unidade.
  • A precisao de ida e volta e preservada. Converter exibicao -> metros -> exibicao usando o mesmo fator produz o valor original (dentro da precisao de ponto flutuante).

Importante

Se voce armazenar valores na unidade de exibicao do usuario, voce cria uma dependencia entre os dados e a preferencia. Mudar a preferencia exigiria recalcular cada valor armazenado -- um pesadelo de migracao para qualquer conjunto de dados de tamanho nao-trivial.

Testes

A simplicidade do enum torna os testes diretos. Cubra conversoes, ida e volta (round trips) e casos extremos:

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 // Maratona em 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 Extremos

    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: - Labels

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

TIP

Os testes de ida e volta usam accuracy porque divisao de ponto flutuante seguida de multiplicacao pode introduzir erros minusculos. Uma precisao de 0.001 (um milesimo de metro ou quilograma) e mais do que suficiente para qualquer medicao do mundo real.

Quando Usar o Framework Measurement da Apple

Esta abordagem leve funciona bem quando:

  • Voce tem um conjunto fixo de tipos de medida (distancia + peso, neste caso)
  • Voce quer uma preferencia alternavel pelo usuario armazenada no UserDefaults
  • Voce precisa de sincronizacao com widgets via App Groups
  • Voce prefere controle explicito sobre a formatacao

Considere Measurement<UnitLength> e MeasurementFormatter da Apple quando:

  • Voce lida com muitos tipos de unidade (temperatura, velocidade, volume, pressao, etc.)
  • Voce precisa de formatacao automatica baseada em locale sem construir suas proprias format strings
  • Voce quer conversao automatica de escala (por exemplo, exibir automaticamente "1,2 km" em vez de "1.200 m")

Ambas as abordagens seguem o mesmo principio: armazene em uma unidade canonica, converta no momento da exibicao.

Conclusao

Um enum com raw value String oferece um sistema completo de conversao de unidades em menos de 100 linhas:

  • Deteccao de locale atribui o padrao correto no primeiro lancamento verificando tres codigos de regiao
  • Metodos de conversao bidirecional mantem a API simetrica -- displayDistance e metersFromDisplay usam o mesmo fator
  • Persistencia thread-safe atraves de um padrao compare-and-set garante valores padrao consistentes sem locks
  • Integracao com SwiftUI via @AppStorage e CaseIterable torna a UI de configuracoes trivial

A decisao arquitetural-chave e o padrao armazene-em-metrico. Ao manter seu banco de dados agnostico a unidade, voce desacopla dados de apresentacao e evita dores de cabeca com migracao quando usuarios mudam sua preferencia.