Skip to content

Swift 中的单位转换:感知地区设置的公制与英制系统及 Thread-Safe 持久化

引言

你的应用将一次跑步记录存储为 5,000 米。柏林的用户看到的是"5.0 km",而德州的用户看到的是"3.1 mi"。同样的数据,不同的展示——如果处理不当,你的健身应用看起来就像是出了 bug。

Apple 提供了 MeasurementMeasurementFormatter 来处理丰富的单位操作,但当你的应用只需要两种度量类型(距离和重量),加上一个可由用户切换、且需要同步到 widget 的偏好设置时,这些 API 就显得过于笨重了。一个 String 支撑的 enum 加上几个转换方法,不到 100 行代码就能满足你的全部需求。

本文将构建一个经过生产环境验证的 UnitSystem,它能够:

  • 根据设备地区自动检测公制或英制
  • 在存储单位(米、千克)和显示单位(km/mi、kg/lbs)之间相互转换
  • 通过 App Group UserDefaults 持久化用户的选择,并实现 thread-safe 的首次访问模式
  • 直接接入 SwiftUI,支持 @AppStorage 和分段式 picker

前置要求

  • Swift 5.9+ / Xcode 15+
  • 在目标的 entitlements 中配置好 App Group(用于 widget 同步)

定义 UnitSystem Enum

核心类型是一个以 String 为原始值的 enum。使用 String 作为原始值意味着 Codable 一致性是自动的——无需自定义编码——并且 UserDefaults 可以直接存储它:

swift
import Foundation

public enum UnitSystem: String, Codable, CaseIterable, Sendable {
    case metric   // km, kg
    case imperial // 英里, 磅

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

有三个协议遵循值得注意:

  • CaseIterable — 提供 UnitSystem.allCases,无需硬编码选项即可构建 picker
  • Sendable — 将该类型标记为可安全跨并发边界传递(Swift 6 严格并发模式的要求)
  • Codable — 基于 String 原始值,自动获得 JSON 编解码能力

距离转换

所有距离以为单位存储。该 enum 负责在用户的显示单位之间进行转换:

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

    public var distanceUnitLong: String {
        switch self {
        case .metric:   "kilometers"
        case .imperial: "miles"
        }
    }

    /// 将米转换为显示单位(km 或英里)
    public func displayDistance(meters: Double) -> Double {
        switch self {
        case .metric:   meters / 1_000.0
        case .imperial: meters / 1_609.344
        }
    }

    /// 将显示单位转换回米以便存储
    public func metersFromDisplay(_ value: Double) -> Double {
        switch self {
        case .metric:   value * 1_000.0
        case .imperial: value * 1_609.344
        }
    }
}

转换系数 1,609.344 是一国际英里精确对应的米数。正反两个方向使用同一个常量(正向乘,反向除),可以消除正向转换和逆向转换之间的漂移误差。

重量转换

同样的模式,以千克作为存储单位:

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

    public var weightUnitLong: String {
        switch self {
        case .metric:   "kilograms"
        case .imperial: "pounds"
        }
    }

    /// 将千克转换为显示单位(kg 或磅)
    public func displayWeight(kilograms: Double) -> Double {
        switch self {
        case .metric:   kilograms
        case .imperial: kilograms * 2.20462
        }
    }

    /// 将显示单位转换回千克以便存储
    public func kilogramsFromDisplay(_ value: Double) -> Double {
        switch self {
        case .metric:   value
        case .imperial: value / 2.20462
        }
    }
}

TIP

公制的情况下直接返回原值——完全不需要运算。这就是以公制存储的一个隐含好处:全球使用最广泛的单位制,转换成本为零。

地区检测

全球只有三个国家官方使用英制:美国、利比里亚和缅甸。其他所有国家都使用公制。检测逻辑非常直接:

swift
extension UnitSystem {
    /// 根据设备的地区设置检测首选单位制。
    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 返回用户地区设置对应的 ISO 3166-1 alpha-2 代码。相比检查 Locale.current.usesMetricSystem,这种方式在你希望进行应用内控制而非完全依赖系统格式化器时更为可靠。

注意

Locale.current 反映的是访问时的设备设置,而非应用启动时的设置。如果用户在系统设置中更改了地区并返回你的应用,fromLocale() 会获取到新的地区。这通常是期望的行为,但需要注意它可能在会话中途发生变化。

Thread-Safe 持久化

偏好设置存储在 App Group UserDefaults 中,以便在主应用和 widget 之间同步。current 静态属性实现了一个 compare-and-set 模式,确保首次访问的安全性:

swift
public extension UnitSystem {
    static var current: UnitSystem {
        get {
            // 快速路径:已有存储值
            if let rawValue = SharedDefaults.appGroup
                .string(forKey: SharedDefaults.Keys.Units.system),
               let system = UnitSystem(rawValue: rawValue)
            {
                return system
            }

            // 首次访问:从地区设置检测
            let detected = fromLocale()

            // 仅在并发访问未抢先设置时才持久化
            if SharedDefaults.appGroup
                .string(forKey: SharedDefaults.Keys.Units.system) == nil
            {
                SharedDefaults.appGroup.set(
                    detected.rawValue,
                    forKey: SharedDefaults.Keys.Units.system
                )
            }

            // 返回存储的值(可能已被另一个线程设置)
            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
            )
        }
    }
}

为什么需要三次读取:

  1. 第一次读取 — 快速路径。如果 key 已经存在(首次启动后 99% 的访问都是如此),立即返回。
  2. 检测 + 条件写入 — 首次访问时,从地区设置检测并仅在 key 仍为 nil 时才持久化。这防止覆盖可能刚刚被并发线程设置的值。
  3. 最终读取 — 重新读取存储的值,以确保返回的是实际持久化的值,而不仅仅是我们检测到的值。如果另一个线程赢得了竞争,我们就使用它们的值。

这种三次读取模式在不使用锁的情况下,确保所有线程在首次访问后收敛到相同的值。

TIP

UserDefaults 对于单个读写操作是 thread-safe 的。这里的 compare-and-set 并不是为了保护 UserDefaults 本身——而是为了确保基于地区检测的默认值只被写入一次,即使多个线程同时触发首次访问也是如此。

SharedDefaults 设置

SharedDefaults 封装提供了对 App Group UserDefaults 的集中访问入口:

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"
        }
    }
}

如果 App Group 不可访问(例如在未配置 entitlements 的测试目标中运行),它会回退到 UserDefaults.standard。这避免了崩溃,同时功能有明确的降级:偏好设置不会同步到 widget,但应用仍然可以正常工作。

SwiftUI 集成

设置 Picker

@AppStorage 直接从相同的 UserDefaults key 读取数据,因此在 picker 中的更改会即时传播到每个读取 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("Unit System", selection: unitSystemBinding) {
            ForEach(UnitSystem.allCases, id: \.self) { system in
                Text(system.displayName).tag(system)
            }
        }
        .pickerStyle(.segmented)
    }
}

Binding 封装是必要的,因为 @AppStorage 存储的是原始 String,而不是 UnitSystem enum 本身。binding 在两者之间进行转换。

显示转换后的值

在视图层,始终将存储单位(公制)转换为显示单位:

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)
}

// 使用示例
formatDistance(5000)    // "5.0 km" 或 "3.1 mi"
formatWeight(90)       // "90 kg" 或 "198 lbs"

接收用户输入

当用户以其偏好的单位输入值时,在保存前将其转换回公制:

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

    if let displayDistance = Double(distanceText), displayDistance > 0 {
        let meters = system.metersFromDisplay(displayDistance)
        // 将 `meters` 存储到数据库
    }

    if let displayWeight = Double(weightText), displayWeight >= 0 {
        let kilograms = system.kilogramsFromDisplay(displayWeight)
        // 将 `kilograms` 存储到数据库
    }
}

以公制存储的设计模式

这个工具背后的设计原则值得明确指出:

始终以公制存储数值。仅在显示层进行转换。

这意味着:

  • 数据库中的值与单位无关。 距离 5000.0 始终表示 5,000 米,无论用户输入时的偏好是什么。
  • 更改偏好设置无需数据迁移。 如果用户从英制切换到公制,数据不需要重新计算——只有显示方式改变。
  • 聚合计算结果正确。 将不同用户的距离求和(或同一用户在更改偏好前后的距离求和)能得到正确的结果,因为所有数据都使用同一单位。
  • 往返精度得以保持。 使用相同的系数进行"显示 -> 米 -> 显示"的转换,会得到原始值(在浮点精度范围内)。

注意

如果你以用户的显示单位存储数值,就会在数据和偏好设置之间创建依赖关系。更改偏好设置将需要重新计算每一个已存储的值——对于任何具有一定规模的数据集来说,这都是一场迁移噩梦。

测试

该 enum 的简洁性使得测试非常直接。需要覆盖转换、往返一致性和边界情况:

swift
import XCTest

final class UnitSystemTests: XCTestCase {

    // MARK: - 距离

    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 // 马拉松距离,单位:米
            let display = system.displayDistance(meters: original)
            let backToMeters = system.metersFromDisplay(display)
            XCTAssertEqual(backToMeters, original, accuracy: 0.001)
        }
    }

    // MARK: - 重量

    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 // 千克
            let display = system.displayWeight(kilograms: original)
            let backToKg = system.kilogramsFromDisplay(display)
            XCTAssertEqual(backToKg, original, accuracy: 0.001)
        }
    }

    // MARK: - 边界情况

    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: - 标签

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

TIP

往返测试使用了 accuracy 参数,因为浮点除法后再乘法可能会引入微小误差。0.001(千分之一米或千分之一千克)的精度对于任何实际测量来说绰绰有余。

何时应该使用 Apple 的 Measurement 框架

这种轻量级方案在以下场景下效果很好:

  • 你只有固定的几种度量类型(本文中是距离 + 重量)
  • 你需要存储在 UserDefaults 中的用户可切换偏好
  • 你需要通过 App Groups 进行 widget 同步
  • 你希望对格式化有明确的控制

在以下场景下,考虑使用 Apple 的 Measurement<UnitLength>MeasurementFormatter

  • 你需要处理多种单位类型(温度、速度、体积、压力等)
  • 你需要基于地区的自动格式化,而不想自己构建格式字符串
  • 你需要自然的量级转换(例如,自动将"1,200 m"显示为"1.2 km")

两种方案都遵循相同的原则:以规范单位存储,在显示时转换。

结语

一个以 String 为原始值的 enum,不到 100 行代码就能提供一个完整的单位转换系统:

  • 地区检测 通过检查三个地区代码,在首次启动时分配正确的默认值
  • 双向转换 方法保持了 API 的对称性——displayDistancemetersFromDisplay 使用相同的系数
  • Thread-safe 持久化 通过 compare-and-set 模式,在不使用锁的情况下确保一致的默认值
  • SwiftUI 集成 通过 @AppStorageCaseIterable,使设置界面的实现变得轻而易举

关键的架构决策是以公制存储的模式。通过保持数据库与单位无关,你将数据与展示解耦,避免了用户更改偏好设置时的迁移难题。