[iOS 10 day by day] Day 7:单位换算

1,038 阅读7分钟

本文介绍了 iOS 10 新出的 Measurement API,主要用于诸如英里/公里、角度/弧度之类的单位转换。

《iOS 10 day by day》是 shinobicontrols 公司编写的系列博客,介绍开发者需要了解的 iOS 10 新特性,每周更新。本系列翻译(文集地址)已取得官方授权。目录点此。仓薯翻译,欢迎指正:)

Shinobicontrols 为 iOS 和 Android 开发者提供高性能、响应式的 UI 控件 SDK,尤其是图表方面的控件。 官网 : shinobicontrols.com twitter : @shinobicontrols

本文翻译时参考了 simpletonking同一篇译文,在此感谢。Thank you simpletonking!

本周我们来看看新的 Measurement API,这是 Foundation 框架新增的一部分。这套 API 从表面上看平凡无奇,就是提供了一套单位换算的方法,例如公里与英里互相换算。

不过,仔细想想,我们确实在单位换算上浪费了太多时间。比如,你有一个角度值,但是用来旋转 view 的 API 只接受弧度值。或者,可能你的 app 里距离的计量单位是英里,但是要为了习惯用公里的用户转换成公里去显示。

在 iOS 10 之前,你可能已经自己写了一些单位换算的方法,或者用了第三方库。现在苹果提供了新的 API ,能解决大部分问题了。我们来看看它具体能做什么吧。

基本概念

本文使用 Swift 3 编写,在 Xcode 8 GM build 上编译运行。

我们要介绍一个单位空间(dimension)的概念,用 Measurement 的 model 类型把数值保存为某个单位空间的数据。所谓“单位空间”就是一组能互相转换的单位,比如克可以转换成千克,千克也能转换回来。每个单位空间都有一个基本单位,其他单位都用这个基本单位来表示(比如,容积的基本单位是升,而 1 毫升就是 0.001 升)。

建立度量值

先从简单的开始,假设我们有一品脱牛奶,想知道一品脱是多少公升。代码如下:

let milk = Measurement(value: 1, unit: UnitVolume.imperialPints)
milk.converted(to: .liters)
// 输出 0.568261 L

很简单吧!定义出了某个单位的度量值之后,就只能把它换算为同一个单位空间的其他单位。converted 这一步会自动进行类型检查,milk 变量的类型是 Measurement,换算成的单位也要属于 UnitVolume 这个单位空间。只能在同一个单位空间之内互相换算,这种限制是显而易见的——不然,把公升换算成英里怎么换算?

运算符

Measurement API 支持对度量值使用运算符。

如果我们想要 5 品脱的牛奶,就可以写:

let fivePints = milk * 5

这样会创建一个新的度量值,接下来我们就可以把它换算成另一个单位:

fivePints.converted(to: .cups)
// 输出 11.8387708333333 cup

可以注意到,把代码放在 Playground 里,或者把度量值打印出来,末尾会自动带上它的单位。

当然,能用的运算符不只有乘号。还有其他的几种,比如双等号 ==

let kms = Measurement(value: 5, unit: UnitLength.kilometers)
let meters = Measurement(value: 5000, unit: UnitLength.meters)

kms == meters // true

以及加号:

kms + meters // 10000.0 m

Formatter

前面提到过,做本地化的时候我们经常需要为不同的 locale 显示不同的单位。

除了新的 Measurement API,苹果还提供了 MeasurementFormatter,它能把度量值转化成格式化字符串。

默认情况下,measurement formatter 使用的是用户当前的 locale。下面我们手动更改这一点,更改前后分别打印同一段两个城市之间的距离,看看输出有什么变化:

let newcastleToLondon = Measurement(value: 248, unit: UnitLength.miles)

let formatter = MeasurementFormatter()
formatter.locale = Locale(identifier: "fr")
formatter.string(from: newcastleToLondon) // 输出 399,166 km

formatter.locale = Locale(identifier: "en_GB")
formatter.string(from: newcastleToLondon) // 输出 248 mi

好棒!不费吹灰之力就完成了。

项目

我们已经大略看了一下 API 的基本用法,下面来上手玩一下吧。

我们来做一个小风车,转动速度与风力强度成比例,而风力强度可以用一个滑动条来调节。

风车就是一个简单的 UIView 子类。把它添加到 UIViewController 的 view 上,再加一些其他的基本 UI 控件:一个调节风速的滑动条,还有一个用 米/秒 和 英里/小时 两种单位显示风速的 label。如果想看完整的 playground,欢迎从 GitHub 下载。

我们把目光集中在用到 Measurement API 的部分上:首先是拖动滑动条的时候,在 label 上显示风速:

func handleWindSpeedChange(slider: UISlider) {
    let windSpeed = Measurement(value: Double(slider.value), unit: UnitSpeed.metersPerSecond)

    let milesPerHour = windSpeed.converted(to: .milesPerHour)

    windSpeedLabel.text = "Wind speed: \(windSpeed) (\(milesPerHour))"
}

label 的显示就像下面这样:

未经格式化的 label

哇哦!只是一个简单的 demo 而已,不需要弄得这么精确。有时候小数点后的位数显示得太多,都看不到后面的单位 m/s 了。要解决这个问题,我们可以使用上面提过的 MeasurementFormatter

let windSpeed = Measurement(value: Double(slider.value), unit: UnitSpeed.metersPerSecond)

let measurementFormatter: MeasurementFormatter = {
    let formatter = MeasurementFormatter()
    formatter.unitOptions = .providedUnit
    let numberFormatter = NumberFormatter()
    numberFormatter.minimumIntegerDigits = 1
    numberFormatter.minimumFractionDigits = 1
    numberFormatter.maximumFractionDigits = 1
    formatter.numberFormatter = numberFormatter

    return formatter
}()

let metersPerSecond = measurementFormatter.string(from: windSpeed)
let milesPerHour = measurementFormatter.string(from: windSpeed.converted(to: .milesPerHour))

windSpeedLabel.text = "Wind speed: \(metersPerSecond) (\(milesPerHour))"

创建 formatter 的时候,我们要指定它使用 providedUnit。这是为了防止 formatter 忽略我们指定的单位,用它自己认为合适的格式输出。对我们现在的情况来说,如果不设这个 option,formatter 会对两个结果都用 英里/小时 为单位输出。

MeasurementFormatter 本身包含另一个 formatter(有点像嵌套的 formatter!),内层的 formatter 是用来格式化数字部分的。我们要求数字显示为一位(且仅一位)小数。

最后,我们使用构造好的 formatter,将 米/秒 和 英里/每小时 的两种风速度量分别格式化为 string。

格式化后的 label

为了有一点视觉反馈,我们希望叶片转动的速度能随着风速改变(要注意,下面用到的这些数值只是为了展示用的,跟风力的物理学基础没有任何关系)。

显示动画的是 TurbineView,不过我们要把每秒钟叶片转动多少角度的数值传给它。你可能会想到去定义一个属性,类似这样:

/// 叶片每秒转动的角度,用弧度的形式表示
public var bladeRotationPerSecond: Double

这样定义没什么问题,也跟苹果官方的 API 保持一致,角度是用弧度表示的。不过,怎么防止使用者无意中传了角度值而不是弧度值呢?你可能会说:“他们应该好好看看文档”。这句话有一定道理,不过万一这个属性没有文档呢?并且,因为我们平常更习惯用角度值,所以这也是个容易不小心犯下的错误。

那我们能怎么利用上 Measurement 框架,只约束使用者传过来的是表示角度的值,具体单位不限呢?这样使用者想传弧度或者角度都可以,反正都会自动转化成我们需要的单位。听起来很棒,我们试试吧:

// TurbineView 的属性
public var bladeRotationPerSecond: Measurement = Measurement(value: 0, unit: UnitAngle.degrees) {
    didSet {
        rotate()
    }
}

在 viewController 里,我们可以用下面这段代码来计算一秒旋转多少角度。

func calculateTurbineRotation() {
    // 假设滑动条拖到最快时,转速达到每秒 1 圈
    let ratio = windSpeedSlider.value / windSpeedSlider.maximumValue

    let fullRotation = Measurement(value: 360, unit: UnitAngle.degrees)

    let rotationAnglePerSecond = fullRotation * Double(ratio)

    turbine.bladeRotationPerSecond = rotationAnglePerSecond
}

参数想传角度或者弧度都可以——我选择了角度。然后根据当前风速来计算旋转角度(如果滑动条的 value 为 0,ratio 就是 0 / 40 = 0;拖到最快的一端,滑动条的 value 是 40,ratio 就是 40 / 40 = 1),用到了乘法操作符,非常方便。

下面来欣赏我们美丽的风车吧:

根据风速旋转的风车

扩展阅读

本文中我们用到了苹果提供的几组单位,不过这些只是冰山一角;苹果一共提供了 170 多种不同的单位。你需要用的单位大概率就在其中,不过,如果真的没有,也可以自己创建一个。想知道怎么创建(还有其他内容!),请看这部 WWDC 视频

原文地址:iOS 10 Day by Day :: Day 7 :: Measurement

原作者:Sam Burnstone @sam_burnstone

ShinobiControls 官网:ShinobiControls.com twitter : @shinobicontrols

文集地址:iOS 10 day by day 仓薯翻译

译者:戴仓薯,本文翻译时参考了 simpletonking同一篇译文,非常感谢~