从 180 cm 到 5′ 11″:Swift Measurement 全解析

发表于

在日常生活里,人们经常需要在各种度量衡单位之间转换。对开发者而言,实现这类功能看似简单:写几行公式、做几次 switch,似乎就能完成任务。但当你希望同时支持数十种单位、无缝国际化,并且还要考虑格式化、精度、舍入等细节时,工作量立刻飙升,且枯燥到足以让人怀疑人生。好消息是——从 iOS 10 起,苹果在 Foundation 中加入了完善的 Measurement API,帮我们把这一切“苦力活”都完成了。本文将带你系统地了解它的用法与实践。

初识 Measurement

Measurement 是一个用来表示“带单位数值”的结构体,内部封装了:

Swift
value: Double  // 数值
unit : Unit    // 单位

Measurement 提供了类型安全的单位换算能力。一旦实例化,它便是不可变的(遵循 struct 的值类型语义)。

Swift
// 只有数值
let height: Double = 180

// 同时包含数值与单位
let heightMeasurement = Measurement(value: 180, unit: UnitLength.centimeters)

// 也可使用泛型写法明确单位类型
let heightMeasurement = Measurement<UnitLength>(value: 180, unit: UnitLength.centimeters)

相比仅用 Double 来存储数值,Measurement 不仅在语义上更加清晰,还内置了便捷的转换功能:

Swift
// 直接转换为米
let meters = heightMeasurement.converted(to: .meters).value
print(meters)  // 1.8

目前,Foundation 为 Measurement 内置了涵盖长度、质量、持续时间、加速度、面积、温度等数十种 单位类别,足以覆盖绝大多数日常开发场景。

数学运算

Measurement 的一个便利之处在于,只要它们属于同一单位类别(UnitType),你就可以直接对它们进行数学运算,例如加法、减法、比较,甚至可以用它们来定义数值区间。

Swift
let heightMeasurement = Measurement<UnitLength>(value: 180, unit: .centimeters)

// 除法 (与标量运算)
let half = heightMeasurement / 2
print(half.value)  // 90.0
print(half)        // "90.0 cm"

// 加法:不同单位亦可相加,结果会自动转换为该类别的基准单位
let h180cm = Measurement<UnitLength>(value: 180, unit: .centimeters)
let h1m    = Measurement<UnitLength>(value: 1,   unit: .meters)
let totalHeight = h180cm + h1m // 结果为 2.8 m (因为 UnitLength 的基准单位是米)
print(totalHeight)             // "2.8 m"

// 比较:自动处理单位转换
h180cm < h1m  // false (180 cm 不小于 1 m)

// 区间:同样支持,单位会被考虑在内
let range = h1m ... h180cm // 区间从 1 米 到 1.8 米
range.contains(Measurement(value: 6, unit: .feet)) // false (约 1.83 米,不在区间内)

这种设计无需手动进行单位换算,并且运算过程中单位信息始终得以保留,从而显著提升了代码的可读性与安全性。

提示:当对两个属于同一类别的 Measurement 实例执行 +- 运算时,结果会自动转换为该 UnitType 的基准单位(base unit)。我们将在稍后详细讨论基准单位的概念。

格式化

Measurement 实例转换为用户友好的、本地化的文本是常见需求。Measurement 提供了强大的 formatted() 方法来实现这一点。

最简单的用法是直接调用 formatted(),它会根据当前的 Locale 设置自动选择合适的单位和格式:

Swift
let h180cm = Measurement<UnitLength>(value: 180, unit: UnitLength.centimeters)

h180cm.formatted()

这段代码的输出会因用户的区域设置而异:

  • 在美国 (en-US) → 5.9 ft (自动转换为英尺)
  • 在中国 (zh-CN) → 1.8 m (自动转换为米)

当你需要更精细地控制输出格式时,可以为 formatted() 提供一个 MeasurementFormatStyle

Swift
let str = h180cm.formatted(
    .measurement(
        width: .wide,                // 单位长度:wide / abbreviated / narrow
        usage: .asProvided,          // 场景:.asProvided / .person / .road / …
        numberFormatStyle: .number
            .precision(.fractionLength(2)) // 数值格式
    )
)
print(str)  // "180.00 centimeters"
参数说明
width指定单位文本的详细程度。wide → “centimeters”,abbreviated → “ cm”,narrow → “cm” 。
usage提示格式化器该数值的使用场景,以便系统能自动选择最恰当的单位并进行智能舍入。例如,.personHeight 在英制地区可能会将 180 cm 显示为 “5 ft 11 in”,而 .road 则可能将较小的单位聚合成更常用的道路距离单位
numberFormatStyle用于精细控制数值部分的显示方式,它本身就是一个强大的 FormatStyle,可以设置小数位数、分组、科学计数法等。

通过这些选项,格式化过程不仅会根据 Localeusage 智能地转换单位,还会翻译单位名称(例如 centimeters vs 厘米),从而提供真正贴合用户区域与习惯的展示。

在 SwiftUI 中优雅展示

利用 Text 视图接受 FormatStyle 的初始化器,你可以轻松地将 Measurement 格式化并显示出来,并且它会自动响应环境中的 Locale 设置:

Swift
struct MeasurementDisplay: View {
    let h180cm = Measurement<UnitLength>(value: 180, unit: UnitLength.centimeters)

    var body: some View {
        VStack(alignment: .leading) {
            // 示例 1: 在美国英语环境下,使用 .person 场景,缩写单位
            Text(h180cm, format: .measurement(width: .abbreviated, usage: .person))
                .environment(\.locale, .init(identifier: "en_US"))
            // 换成 .personHeight 显示 5 ft, 11 in

            // 示例 3: 在中文环境下,使用 .person 场景,缩写单位
            Text(h180cm, format: .measurement(width: .abbreviated, usage: .personHeight)) // 或者 .person
                .environment(\.locale, .init(identifier: "zh_CN"))

            // 示例 4: 在法语环境下,使用 .road 场景,完整单位名
            Text(h180cm, format: .measurement(width: .wide, usage: .road))
                .environment(\.locale, .init(identifier: "fr_FR"))
            // 输出: "2 mètres" (针对 road 场景,1.8m 被智能舍入为更常用的 2m)
        }
    }
}

image-20250430102805983

如果你需要更精细的控制,比如为数值和单位应用不同的样式(颜色、字体、粗细等),你可以先将 Measurement 格式化为 AttributedString,然后修改其特定部分的属性:

Swift
struct AttributedMeasurementView: View {
    let weight = Measurement(value: 100, unit: UnitMass.kilograms) // 100 kg

    var styledWeightAttributedString: AttributedString {
        // 使用 .attributed 将 Measurement 格式化为 AttributedString
        var str = weight.formatted(
            .measurement(
                width: .abbreviated,
                usage: .asProvided,
                numberFormatStyle: .number)
                .attributed
        )
        // 精准控制 value 和 unit 的样式
        let _ = str.transformingAttributes(\.measurement) { info in
            switch info.value {
                case .value:
                    str[info.range].font = .title
                case .unit:
                    str[info.range].foregroundColor = .orange
                    str[info.range].font = .italic(.body)()
                default:
                    break
            }
        }
        return str
    }

    var body: some View {
        Text(styledWeightAttributedString)
            .foregroundStyle(.blue.gradient)
    }
}

image-20250430103548462

若想更全面地了解 AttributedString 的强大功能及其在 SwiftUI 中的应用,推荐阅读:

新瓶装旧酒:与 NSMeasurement 的桥接

如果你用过 NSMeasurement,可能会疑惑:Measurement 是全新的 Swift 实现吗?其实它们本质上是同一套底层逻辑的不同包装:

Swift
public struct Measurement<UnitType>: ReferenceConvertible, Comparable, Equatable
    where UnitType: Unit { }

通过 ReferenceConvertibleMeasurement(值类型)可与 NSMeasurement(引用类型)互转。

Swift
let h1m  = Measurement(value: 1, unit: UnitLength.meters)
let ns   = h1m as NSMeasurement        // Swift → Obj-C
print(ns.doubleValue)                  // 1

let nsCM = NSMeasurement(doubleValue: 100, unit: UnitLength.centimeters)
let cm   = nsCM as Measurement         // Obj-C → Swift
print(cm.unit)                         // cm

尽管 Swift Foundation 正在进行纯 Swift 重构,但 Measurement 的重写工作尚未完成。这意味着其功能当前仅能在苹果平台上有效运行。为了代码兼容性,非苹果平台提供了 Stub 实现,确保包含 Measurement 的代码可以编译成功。然而,请务必注意:这些 Stub 不具备实际的单位转换和格式化功能。我们期待未来能迎来真正跨平台的 Measurement 支持。

在现有类别中新增单位

有时,我们需要的单位属于一个已有的类别(比如质量 UnitMass、长度 UnitLength),但系统并未内置该特定单位。我们可以通过 Swift 的 extension 机制,方便地为这些现有类别补充我们需要的单位。

以下是如何为 UnitMass 添加中国大陆常用的“市斤”(等于 0.5 千克)的示例:

Swift
extension UnitMass {
    /// 添加中国特有的质量单位:市斤 (jin)
    /// 1 市斤 = 0.5 千克 (kilograms)
    static var jin: UnitMass {
        // 1. 定义单位符号 (Symbol)
        // 使用 NSLocalizedString 是一种好习惯,便于未来对符号本身进行基础本地化
        let symbol = NSLocalizedString("", comment: "Symbol for the Chinese unit of mass 'jin'")

        // 2. 定义转换器 (Converter)
        // 指明该单位与此类别的基准单位 (Base Unit) 的换算关系
        // UnitMass 的基准单位是 kilograms (千克)
        // coefficient: 0.5 表示 1 jin = 0.5 kilograms
        let converter = UnitConverterLinear(coefficient: 0.5)

        // 3. 使用符号和转换器创建并返回新的 UnitMass 实例
        return UnitMass(symbol: symbol, converter: converter)
    }
}

let weight = Measurement(value: 1, unit: UnitMass.kilograms)
print(weight.converted(to: .jin).value) // 2.0
  • symbol: 定义单位的文本表示符号。
    • 推荐使用 NSLocalizedString,这允许你为不同语言提供该符号的基础翻译(例如,如果需要在英文环境下显示 “Jin” 而不是 “斤”)。
    • 局限性: 请注意,通过这种方式添加的自定义单位符号,目前无法像系统内置单位那样,根据 formatted() 方法中的 width 参数(如 .wide, .abbreviated, .narrow)自动展示不同长度的本地化形式。它通常只会显示你提供的这个 symbol
  • converter: 这是核心部分,用于定义新单位与**该类别基准单位(Base Unit)**之间的数学关系。
    • 对于大多数物理量,使用 UnitConverterLinear 即可,它表示一个简单的线性关系。
    • coefficient 参数指定了 “1 个新单位等于多少个基准单位”。在我们的例子中,UnitMass 的基准单位是 kilograms,所以 coefficient: 0.5 意味着 1 jin = 0.5 kilograms

进阶提示:对于单位之间的转换不仅需要系数,还需要一个偏移量( 例如温度单位 ,它们的转换公式是 y = ax + b 的形式),Foundation 提供了另一个专门的转换器:UnitConverterTemperature。它用于处理了这种带有偏移量的线性转换。

自定义全新单位类别

如果系统预设的单位类别,甚至在现有类别上扩展单位,仍然无法满足你的特定需求(比如需要定义一种全新的物理量或者非标准的度量体系),那么我们还可以创建全新的单位类别。

要做到这一点,了解 Foundation 中单位类型的继承结构会很有帮助:

  • Unit: 所有单位的基础类。
  • Dimension: 可度量单位的抽象基类,继承自 Unit。它专门用于表示那些可以通过线性转换(乘以系数)相互关联的单位(如长度、质量、时间等)。这是实现单位换算的核心。
  • UnitLength, UnitMass, UnitDuration 等: Foundation 预设的具体单位类别,它们都继承自 Dimension,并定义了各自领域内的具体单位(如米、千克、秒)及其与基准单位的关系。

因此,创建自定义单位类别的关键就是:声明一个继承自 Dimension 的新类

下面我们以中国传统的质量单位(斤、两、钱)为例,演示如何自定义一个完整的 UnitType:

Swift
/// 中国重量单位:斤、两、钱
final class UnitChineseMass: Dimension, @unchecked Sendable {
    // 定义单位:斤(基准单位)、两、钱
    static let jin = UnitChineseMass(symbol: "", converter: UnitConverterLinear(coefficient: 1))       // 以“斤”作为基准 (coefficient: 1)
    static let liang = UnitChineseMass(symbol: "", converter: UnitConverterLinear(coefficient: 0.1))   // 1 两 = 0.1 斤
    static let qian = UnitChineseMass(symbol: "", converter: UnitConverterLinear(coefficient: 0.01))   // 1 钱 = 0.01 斤

    // 指定这个类别的基准单位,所有转换都将基于它
    // 这里我们选择“斤”作为基准单位
    override class func baseUnit() -> UnitChineseMass {
        return .jin
    }
}

let weight = Measurement(value: 1, unit: UnitChineseMass.jin)
weight.converted(to: .liang).value // 10
weight.converted(to: .qian).value  // 100

自定义的单位类别同样支持 Measurement 的所有运算和格式化特性(但格式化的本地化符号可能需要额外处理)。

善用 Foundation,事半功倍

正如我们所见,Measurement API 体现了 Foundation 框架多年积累的智慧:设计精良、考虑周全且内建国际化支持。这提醒我们,在着手实现许多看似基础的功能之前,养成先查阅 Foundation 文档的习惯,往往能带来惊喜。你很可能会找到一个官方提供、性能更佳、久经考验的解决方案。选择利用这些现成的“轮子”,不仅能显著提升开发效率,也能让代码更加稳健,让我们能真正专注于应用的核心逻辑。毕竟,Less code, more life!

"加入我们的 Discord 社区,与超过 2000 名苹果生态的中文开发者一起交流!"

每周精选 Swift 与 SwiftUI 精华!