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
UserDefaultsde App Group com um padrao thread-safe de primeiro acesso - Se integra diretamente com SwiftUI usando
@AppStoragee 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:
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-- forneceUnitSystem.allCasespara construir pickers sem precisar hardcodar as opcoesSendable-- 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 valueString
Conversoes de Distancia
Toda distancia e armazenada em metros. O enum converte de e para a unidade de exibicao do usuario:
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:
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:
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:
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:
- Primeira leitura -- o caminho rapido. Se a chave ja existe (99% dos acessos apos o primeiro lancamento), retorna imediatamente.
- 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.
- 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:
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:
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:
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:
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.0sempre 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:
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 --
displayDistanceemetersFromDisplayusam o mesmo fator - Persistencia thread-safe atraves de um padrao compare-and-set garante valores padrao consistentes sem locks
- Integracao com SwiftUI via
@AppStorageeCaseIterabletorna 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.

