iOS 10 by Tutorials 笔记(十)

Chapter 10: Measurements and Units

在日常的 iOS 开发中,我们经常要面对不同单位之间的转换。比如英美国家的英制到公制的换算,以及其他度量单位间的换算。你自己可能也写了一些库来做这些事情,不过这始终不是一件省心的事情。

幸运的是 iOS 10 在 Foundation framework 中提供了系统级的支持,它让我们从繁琐的单位转换中彻底解脱,并且使用强类型防止犯错,甚至你可以定义自己的计量单位。

此外 iOS 10 还提供了 Date intervals,即从一个时间点到另一个时间点的时间间隔,Date intervals 配合 date interval formatter 让你不用去考虑跨时区的问题,生活会更轻松。

Measurement and Unit

开始前,先来学习两个新的类型 MeasurementUnit

Measurement 结构体是一个模型类(model type),它里面包含了一堆与 Unit 相关的 Double 值,它有两个属性:

  • value 是一个 Double
  • unit 表示一个 Unit

Unit(NSUnit)是一个抽象类,为那些『计量单位对象』声明了一个可编程的接口,它只有一个属性

  • symbol 表示一个 Sring

这种简单的结构也有好处,给定一个 Measurement(模型类),你马上就能知道它的 unit(计量单位)是什么,然后使用附带的 symbol 来把该计量单位格式化处理并加以显示。

但系统真正的力量源泉来自于以下两点:

  • 苹果已经在 Foundation 中定义好了很多 Unit 的子类(计量单位)
  • 我们可以使用泛型将一个特定的 Measurement 和一个给定的 Unit 关联起来

Unit subclasses

Dimension 作为 Unit 的子类,表示 units 有一个度量,类似于长度、温度。Dimension 包含一个基本的 unit 和一个转换器可以进行单位转换。

Dimension 是一个抽象类,意味着你需要通过子类来定义自己的计量单位,Foundation 中已经提供了大量可供使用的计量单位。例如,有一个 UnitLength 类,它是 Dimension 的子类,用来表示尺寸或长度。基本单位是:米。

UnitLength 有超过 20 个类型变量,每一个都配备一个转换器,可以执行和长度单位(米)之间的转换。比如 UnitLength.meters 就什么也不做,因为该长度单位已经是米了。UnitLength.miles 的转换器已经知道一米约合 0.000621371 英里。

Units and generics

当你创建一个 Measurement 时,会给它一个值和一个 unit。而 unit 会成为 measurement 上的一个泛型约束。要知道一个 measurement 关联了一个特定的 Unit 意味着 Swift 不会让一个 measurements 表示两种单位。

I want to ride my bicycle

铁人三项是一项有趣的运动,它包括

  • 25km 的自行车骑行
  • 3 海里的游泳
  • 半程马拉松

本章我们先用此项运动来举例,研究下如何使用 Foundation 新提供的类,来创建一些表示长度的 measurements。

本节代码全部在 playground 上完成:

let cycleRide = Measurement(value: 25,  
  unit: UnitLength.kilometers)
let swim = Measurement(value: 3,  
  unit: UnitLength.nauticalMiles)

上面的代码定义了自行车和游泳的 Measurement 类型,具体的 unit 是系统 Foundation 提供的。

那么半程马拉松呢?先别急,全程马拉松是 26 英里 385 码,这个数字来自于 1908 的伦敦奥运会。那么你知道这个距离具体是多少?和米之间的单位转换又是多少?至少在新系统下不再需要我们操心啦~

创建一个关于马拉松的 measurement:

 let marathon = Measurement(value: 26, unit: UnitLength.miles)
  + Measurement(value: 385, unit: UnitLength.yards)

虽然 miles 和 yards 单位不同,但封装在 Measurement 中是可以相加的。原因在于他们的基本单位 units 都是 UnitLength(长度单位),而最终的相加结果(marathon)是建立在 base unit(米)上的。

可以想象成两个不同的长度单位相加,首先全部转换成基本单位(米),然后再相加。

我们可以通过 symbol 来输出下单位结果:

marathon.unit.symbol      //"m"   米  
swim.unit.symbol          //"NM"  海里  
cycleRide.unit.symbol     //"km"  千米  

下面来就得到了半马的距离,进而计算出整个铁人三项的距离:

let run = marathon / 2  
let triathlon = cycleRide + swim + run

//result:
//triathlon 51653.442 m

美国人看不懂公制 😂,我们再转换成英制

triathlon.converted(to: .miles)  

除了数学运算,我们还可以比较两个相同类型的单元

cycleRide > run //结果为 true  

Uranium Fever

这节我们来捣鼓一下物理小课题,先复习下爱因斯坦的质能方程:

能量 = 质量 x 光速的平方,能量的单位是『焦耳』;质量是『千克』;光度是『米/秒』。质量转换成能量本质是核裂变。核电站的工作方式有写类似下面的步骤:

  • 铀-235受到中子撞击
  • 铀-235 吸收中子变成铀-236
  • 然后,它会分解成 krypton-92,barium-141 和三个中子
  • 这些分解出来的中子又会去撞击更多的铀-235

我们还是在 playground 来演示整个反应过程:

首先定义一个单位来处理原子质量,一个原子质量约等于一个质子或中子的质量,近似于 1.661e-27 千克,这是非常小的一个数值。

既然涉及到质量,就先来定义一个 UnitMass 实例:

let amus = UnitMass(symbol: "amu",  
    converter: UnitConverterLinear(coefficient: 1.661e-27))

这是你自定义的第一个计量单位,稍后我们再来关注具体的转换细节。当前要理解你根据基本单位创建了一个新的计量单位

UnitMass 的基本单位是千克

基于新定义的计量单位,我们来添加相关元素的质量:

let u235 = Measurement(value: 235.043924, unit: amus)  
let neutron = Measurement(value: 1.008665, unit: amus)  
let massBefore = u235 + neutron  

继续添加核裂变后的产物

let kr92 = Measurement(value: 91.926156, unit: amus)  
let ba141 = Measurement(value: 140.914411, unit: amus)  
let massAfter = kr92 + ba141 + (3 * neutron)  

根据质能守恒定律,massAfter 要比 massBefore 轻,这是因为一部分质量转换成能量了。

下一步使用 来找出具体转换了多少能量:

func emc2(mass: Measurement<UnitMass>) -> Measurement<UnitEnergy> {  
  let speedOfLight = Measurement(value: 299792458,
    unit: UnitSpeed.metersPerSecond)
  let energy = mass.converted(to: .kilograms).value *
    pow(speedOfLight.value, 2)
  return Measurement(value: energy, unit: UnitEnergy.joules)
}

注意在计算公式时,使用了 value,因为质量和速度是不同的计量单位,他们直接的运算 Foundation 里还未做定义,比如你不能通过 UnitLength / UnitDuration 得到一个 UnitSpeed

计算一个铀-235 原子裂变所释放的能量:

let massDifference = massBefore - massAfter  
let energy = emc2(mass: massDifference)  

要计算给定质量的铀物质能释放多少能量,就需要先找出给定质量的铀物质包含多少原子

func atoms(atomicMass: Double, substanceMass: Measurement<UnitMass>) ->  
Double {  
  let grams = substanceMass.converted(to: .grams)
  let moles = grams.value / atomicMass
  let avogadro = 6.0221409e+23
  return moles * avogadro
}

上面的函数中用到了一个特殊的常数:阿伏伽德罗数,它定义了一摩尔中的原子数。最后我们计算了 1 克铀元素所包含的原子数。

通过下面的换算,我们得到了 1 磅铀物质所包含的原子数量,接着与单个原子所释放的能量相乘,就得到了最终释放的总能量

let numberOfAtoms = atoms(atomicMass: u235.value, substanceMass:  
Measurement(value: 1, unit: .pounds))  
let energyPerPound = energy * numberOfAtoms  

这个数字并不怎么直观,我们来做个转换。美国每户家庭平均每年消耗 11700(千瓦时)的电力

let kwh = energyPerPound.converted(to: .kilowattHours)  
kwh.value / 11700  

这个数字最后接近 766,也就是说一磅的铀物质能够为 766 个美国家庭提供一年的电力

Measure for MeasurementFormatter

MeasurementFormatter 是 Formatter 的子类,就像 DateFormatter 和 NumberFormatter 一样。它私下会完成许多工作以便于将 measurements 展示给用户,比如自动根据用户的区域来设置首选单位。

It’s getting hot in here

打开 playground,创建一个愉快的北欧夏日,摄氏 24 度:

let temperature = Measurement(value: 24, unit: UnitTemperature.celsius)  

然后创建一个 measurement formatter,将温度作为参数传入

let formatter = MeasurementFormatter()  
formatter.string(from: temperature)  

playgrounds 的位置信息默认设置为 US,所以会变成只有美国人才能理解的计量单位,因此修正为更加通用的公制单位:

formatter.locale = Locale(identifier: "en_GB")  
formatter.string(from: temperature)  

Measurement formatter 有一个 UnitOptions 属性,它指明了该如何格式化 unit 单元。它包含三个选项,其中一个就和温度相关。

formatter.unitOptions = .temperatureWithoutUnit  
formatter.string(from: temperature)  

其实就是告诉 formatter 跳过指示温度刻度的字母(° 或 F),它还有个副作用是阻止温度单位的本地化,这有时会导致混乱,所以我们再次设置本地信息

formatter.locale = Locale(identifier: "en_US")  
formatter.string(from: temperature)  

第二个 UnitOptions 选项 .providedUnit 告诉 formatter 不要修改我们传入的 units

formatter.unitOptions = .providedUnit  
formatter.string(from: temperature)  

这次 formatter 就再次使用摄氏度了,即使区域是 US

第三个 UnitOptions 选项与温度不相关,下面会学习

I would walk 500 miles

还记得上面我们把『英里』、『码』、『海里』、『千米』几个单位相加会自动转换为『米』,以米做单位的结果是相当大的,不那么一目了然,你需要确定更适合的单位。

let run = Measurement(value: 20000, unit: UnitLength.meters)  
formatter.string(from: run)  

上面的代码应该输出 20000 m,但是别忘了 playground 默认的本地格式是 US,输出的会是英里数,所以再用上面提到的 UnitOptions 改造一番:

formatter.unitOptions = [.naturalScale, .providedUnit]  
formatter.string(from: run)  

这次我们用到了 UnitOptions 第三个属性 .naturalScale,它会自动将当前单位自动转换成更合适的单位。比如上面代码输出 20km,可读性更好。再如下面的代码:

let speck = Measurement(value: 0.0002, unit: UnitLength.meters)  
formatter.string(from: speck)  

这次它的结果是 0.2 mm

UnitStyle option 用于指示最后的输出单位是全称还是缩写,比如下面的单位是长度的全称 kilometers

formatter.unitStyle = .long  
formatter.string(from: run)  

默认是 .medium,不过还没有公开的 Api 能够为自定义的单位设置 UnitStyle option

最后关于 measurement formatter,可以定制的部分是其数字的展现形式。你可以先创建一个 number formatter,然后传递给一个 measurement formatter 去组织数据展示:

let numberFormatter = NumberFormatter()  
numberFormatter.numberStyle = .spellOut  
formatter.numberFormatter = numberFormatter  
formatter.string(from: run)  

这里我们使用了 .spellOut,意味着将结果用英语写出来:twenty kilometers

(Custom) Dimension

所有计量单位的基类都是 Unit,它是一个抽象类,它存在唯一的子类 Dimension,也是一个抽象类。Foundation 提供了很多 Dimension 的子类,每一个都代表了特定量化的东西,就像长度、面积、时间等。Dimension 提供了一个类方法 baseUnit() 返回一个基本单位(unit)实例。

每个计量实例都有一个标识和一个转换器(针对基本单位)再次强调 Foundation 已经定义好了一些 Dimension 子类。可能有些难以理解,画个图吧

要实例化一个 Dimension 或它的子类,你需要传递一个标识(标识)和一个转换器(converter)。转换器是 UnitConverter 的子类,UnitConverter 也是个抽象类,具体工作要靠子类完成,其中一个子类是 UnitConverterLinear,允许不同单位间的线性转换。

所谓线性转换也很简单,无非就是:

y = kx + c  

k 是倍增因子,而 c 代表常数

Chain of fools

貌似古老的英国人民看到什么觉得顺眼就拿来做单位了,其中有个英制单位叫 chain,它等于 20.1168 米,所以我们为这个 chain 在 iOS 里创建一个单位。

let chains = UnitLength(symbol: "ch",  
  converter: UnitConverterLinear(coefficient: 20.1168))

有点类似于之前创建的 amus,想象一下,你要到处去使用这个 chain,因此我们添加到全局的 UnitLength 扩展中

extension UnitLength {  
  class var chains: UnitLength {
    return UnitLength(symbol: "ch",
      converter: UnitConverterLinear(coefficient: 20.1168))
  } 
}

然后可以像正常的单位去使用它

let cricketPitch = Measurement(value: 1, unit: UnitLength.chains)  

它的行为和内置的米、千米等单位没什么区别

cricketPitch.converted(to: .baseUnit())    // 20.1168 m  
cricketPitch.converted(to: .furlongs)      // 0.1 fur  
(80 * cricketPitch).converted(to: .miles)  // 1.00000248549095 mi

我们可以看到 chain 和各种单位直接的转换

弗隆(furlong):英国长度单位,相当于 1/8 英里或 201.167 米

总结:自定义一个单位,你需要做的就是提供一个类变量,设置 symbol 和转换器。下一节来看个更复杂的例子。

Turning it up to 11

这一节来自定义分贝 decibels(dB)单位,它有两个含义:

  1. 表示功率量之比的一种单位,等于功率强度之比的常用对数的10倍;
  2. 表示场量之比的一种单位,等于场强幅值之比的常用对数的20倍;

因为增长是对数级变化的,之前的线性公式就不适用了,相应的线性转换器也不再适用。

所以先自定义一个对数增长的转换器,它也是 UnitConverter 的子类:

// 1 注意必须实现 NSCopying
class UnitConverterLogarithmic: UnitConverter, NSCopying {  
  // 2 需要两个参数:系数和 log(Base)
  let coefficient: Double
  let logBase: Double
  // 3 初始化
  init(coefficient: Double, logBase: Double) {
    self.coefficient = coefficient
    self.logBase = logBase
  }
// 4 实现 NSCopying 协议
  func copy(with zone: NSZone? = nil) -> Any {
return self  
  } 
}

然后必须重写两个转换方法,分别是将自定义单位转换成基本单位,以及将基本单位转换为自定义单位:

override func baseUnitValue(fromValue value: Double) -> Double {  
  return coefficient * log(value) / log(logBase)
}

override func value(fromBaseUnitValue baseUnitValue: Double) -> Double {  
  return exp(baseUnitValue * log(logBase) / coefficient)
}

转换器类搞定了,下面来使用它来创建自定义的分贝单位:

class UnitRatio: Dimension {  
  class var decibels: UnitRatio {
    return UnitRatio(symbol: "dB",
      converter: UnitConverterLinear(coefficient: 1))
    }
  override class func baseUnit() -> UnitRatio {
    return UnitRatio.decibels
  }
}

我们自定义的分贝也需要一个基本单位,如同长度单位的米,重量单位的千克,这里通过 baseUnit() 方法指定了 decibels 作为基准单位。所以初始化 decibels 时转换器传入了一个线性转换器。

class UnitRatio: Dimension {  
  class var decibels: UnitRatio {
    return UnitRatio(symbol: "dB",
      converter: UnitConverterLinear(coefficient: 1))
    }
  override class func baseUnit() -> UnitRatio {
    return UnitRatio.decibels
  }
}

有了基准单位,现在可以添加分贝的两种比率,转换器对应着它们和基准单位之间的转换:

// 振幅比
class var amplitudeRatio: UnitRatio {  
  return UnitRatio(symbol: "", converter:
    UnitConverterLogarithmic(coefficient: 20, logBase: 10))
}
// 功率比
class var powerRatio: UnitRatio {  
  return UnitRatio(symbol: "", converter:
    UnitConverterLogarithmic(coefficient: 10, logBase: 10))
}

尝试将功率比值为 2 的单位数值转换成基准单位 .decibels

let doubleVolume = Measurement(value: 2, unit: UnitRatio.powerRatio)  
doubleVolume.converted(to: .decibels)  // 3.01029995663981 db  

结果约等于 3 分贝,接着我们把功率比调整到 1.1

let upTo11 = Measurement(value: 1.1, unit: UnitRatio.powerRatio)  
upTo11.converted(to: .decibels)    // 0.413926851582251 db  

结果约为 0.4 分贝

24 Hours From Tulsa

自定义完计量单位,我们来学习下 iOS 新提供的、用于处理时间间隔的 DateIntervalDateIntervalFormatter

一个 DateInterval 通常包含一个开始时间点和一个持续时间,用于表示一段特定的时间。你可以用两种方式创建时间间隔:

  1. 一个起始时间节点和一段持续时间
  2. 一个起始时间和一个结束时间

还是打开 playground,添加如下代码:创建了今天,明天,后天等日期,然后用上面提到的两种方式创建了相同的时间间隔

let today = Date()  
let twentyFourHours: TimeInterval = 60 * 60 * 24  
let tomorrow = today + twentyFourHours  
let overmorrow = tomorrow + twentyFourHours

let next24Hours = DateInterval(start: today, duration: twentyFourHours)  
let nowTillThen = DateInterval(start: today, end: tomorrow)  

可以测试一发,是否相等

next24Hours == nowTillThen  

48 小时的时间间隔也很容易搞定

let next48Hours = DateInterval(start: today, end: overmorrow)  
next48Hours > next24Hours  //true  

继续比较

let allTomorrow = DateInterval(start: tomorrow, end: overmorrow)  
allTomorrow > next24Hours //true  
allTomorrow > next48Hours //true  

关于比较有两条规则:

  1. 如果开始时间一样,持续时间长的大
  2. 如果开始时间不一样,开始时间晚的大

而且比较了开始时间就不会比较持续时间了

DateInterval 还有很多有用的方法

// 1 当前日历
let calendar = Calendar.current  
var components = calendar.dateComponents([.year, .weekOfYear],  
  from: Date())
// 2 周一早上八点
components.weekday = 2  
components.hour = 8  
let startOfWeek = calendar.date(from: components)!  
// 3 周五下午五点
components.weekday = 6  
components.hour = 17  
let endOfWeek = calendar.date(from: components)!  
// 4 工作周
let workingWeek = DateInterval(start: startOfWeek,  
  end: endOfWeek)

如果我们有两周的假期,将假期开始的时间调整到 13 点(下午 1 点),持续两周,最后设置假期的持续时间

components.hour = 13  
let startOfHoliday = calendar.date(from: components)!  
let endOfHoliday = calendar.date(byAdding: .day,  
  value: 14, to: startOfHoliday)!
let holiday = DateInterval(start: startOfHoliday,  
  end: endOfHoliday)

因为上面设置 workingWeek 时已经调整 components.weekday 到周五了,所以假期起始时间是周五下午 1 点

有了工作日(workingWeek)和假期(holiday)这两个持续时间段,我们就能做各种逻辑运算

判断假期的开始时间是否落在工作日

workingWeek.contains(startOfHoliday) //true  

假期是否和工作日有交集

workingWeek.intersects(holiday) //true  

度假时错过的工作时间

let freedom = workingWeek.intersection(with: holiday)  

错过的工作时间看起来有点不错,但还是不直观

我们用 DateIntervalFormatter 格式化输出结果,dateStyle 设为 none 去掉年月日等信息,只保留小时、分钟等信息:

let formatter = DateIntervalFormatter()  
formatter.dateStyle = .none  
formatter.string(from: freedom!)  

这次结果就简单多了:"1:00 - 5:00 PM"

这一章虽然没有为一般用户带来激动人心的新特性,但为开发者省了不少心,所以值得好好学学。


-EOF-

chengway

认清生活真相之后依然热爱它!

Subscribe to Talk is cheap, Show me the world!

Get the latest posts delivered right to your inbox.

or subscribe via RSS with Feedly!