Edited at

【Swift】Dateの王道 【日付】


概要

時間という概念は、概念と呼ばれるものの中で最も日常に溶け込んだ存在です。

本投稿では、まずはじめにSwiftでの正しい日付・時刻の扱い方を知るために、その仕組みとなっている時間・時刻の定義についてまとめていき、 その後Dateを扱うときのアンチパターンとベストプラクティスについて考え、その対応について紹介していきたいと思います。


GMT・UTC・JST・UNIXタイム

SwiftでDateを扱う前に時刻とはなにかについて知る必要があります。ここでは世界で主に使われる時刻の定義の種類について簡単におさらいをします。


  • UTC(協定世界時): UTCは世界共通の時刻であり、各地域の標準時はこのUTCを基準として算出されています。UTCは原子時をもとに時刻を進めていますが、原子時を人が扱う現実の時間に変換(整数として扱う)するとほんの僅かな誤差が生じます。この誤差の影響が1秒以上になってしまっては問題が生じるため、1秒以上の誤差が生じないように人工的に原子時にうるう秒を考慮したものがUTCです。



  • GMT(グリニッジ平均時): UTCが現れる以前まで世界共通時として扱われていたものであり、経度0からの平均太陽時を指します。現在ではUTCと同義で扱われることが多いですが、厳密には異なりGMTはうるう秒が考慮されないためUTCとは100年でおよそ18秒のズレが生じます。



  • JST(日本標準時): JSTは日本における時間の標準化を図っており、UTC+9時間と定義されています。 よってUTCで1時のものはJSTだと10時となります。



  • UNIXタイム: PCなどはインターネットが繋がっていない状態でも時間は進みます。これは1970年1月1日0時0分0秒(UNIXエポック)からの経過時間を算出することで実現しています。UNIXタイムは本当の時刻経過を表しておらず、うるう秒も考慮されませんが多くのシステムでは運用的な問題が少ないため実質的な時刻として扱われています。


タイムゾーン

時刻は世界中あらゆる場所で使用されます。もし参照する地域によって、時刻がばらばらになってしまっては時刻自体が意味のないものとなってしまいます。そこで標準時を基準に、その地域が標準時からどれだけ遅れているか、または、進んでいるかを加えることによって時刻の共通化を図るものがタイムゾーンです。これにより時差の概念が生じます。


Swiftで日付を扱うときのアンチパターン集 :no_entry:

ここではSwiftでの日付処理におけるアンチパターンを紹介するとともに次のセクションでは、Swiftでの正しい日付処理の仕方を紹介します。


1. Formatterでロケール・タイムゾーンを設定しない

例えばインドのように公用語が母国語ではないケースや、「言語」は英語を設定しているが「地域」は日本など必ずしも現在設定している地域(ロケール)が日付の表示したい言語とは限りません。これについては現在の地域を使用する方法やprefferedLanguagesを使用する方法もありますが、明示的に指定することでUXの向上や正しいローカライズ結果を得ることができます。


2. 日付文字列へ変換時にローカライズされるべき箇所をハードコードしている

ダメな例:

formatter.dateFormat = "yyyy年MM月dd日"

print(formatter.string(from: Date())) // 2017年8月12日

この場合などローカライズされるべき文字が混合しており、今後ローカライズの対応を検討するときにLocalizable.stringsに国ごとのパターンを個別に追加する未来が見えるためアンチパターンとなります。

正しい例:

formatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "ydMMM", options: 0, locale: Locale(identifier: "ja_JP"))

print(formatter.string(from: Date())) // 2017年8月12日

年・月・日はどこから現れた!?と思う方もいらっしゃるかもしれませんが、これはテンプレートとロケールを組み合わせることでその地域に合わせた形式での日付の出力が可能となります。


3. 曜日の算出を試みる

ダメな例:

let weeks = ["日","月","火","水","木","金","土"]

// 曜日の算出
let week = weeks[currentIndex] // 日

正しい例:

let formatter = DateFormatter()

formatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "EEEEE", options: 0, locale: Locale.current)
print(formatter.string(from: Date())) // 日

これはDateFormatterのパターンを使用することで解決できます。パターン一覧はこちら


4. StringからDate変換時にen_US_POSIX以外をロケールを指定する

let now = "2017/8/12"

formatter.dateFormat = "yyyy/MM/dd"
formatter.locale = Locale(identifier: "ja_JP")
let date = formatter.date(from: now)

これを実行してみると通常の設定ではdateは正しく2017/8/12と取得されます。

しかし、例えばiPhoneの設定から日付を西暦ではなく「和暦」にしてみるとどうでしょう?

すると4005/8/12と取得されてしまいます。これは和暦を設定したために平成2017年と解釈されyyyyにより 西暦変換すると平成29年現在が2017年であることから平成2017年西暦4005年となります。このようにformatterに対して本体設定が解釈に影響を及ぼすコードであることからアンチパターンと言えます。


Dateの王道 👑


王道1. styleを検討すべし

DateFormatterにはdateStyletimeStyleがあり、それぞれ日付と時間の出力形式をロケールによりローカライズしたフォーマットとして扱ってくれるプロパティがあります。

日付の出力の仕様がこのstyleの指定で満たされる場合は、個別にyyyy年MM月dd日のような固定フォーマットを与えるべきではありません。

使い方:

let f = DateFormatter()

f.timeStyle = .full
f.dateStyle = .full
f.locale = Locale(identifier: "ja_JP")
let now = Date()
print(f.string(from: now)) // 平成29年8月13日日曜日 16時29分05秒 日本標準時

ja_JPにおけるスタイルチートシート:

dateStyle
出力
timeStyle
出力

.full
2017年8月13日日曜日
.full
16時29分05秒 日本標準時

.long
2017年8月13日
.long
16:35:54 JST

.medium
2017/08/13
.medium
16:36:46

.short
2017/08/13
.short
16:37

.none
(出力なし)
.none
(出力なし)

使用例1(日付だけ出力):

let f = DateFormatter()

f.dateStyle = .long
f.timeStyle = .none
let now = Date()
print(f.string(from: now)) //2017年8月13日

使用例2(時刻だけ出力):

let f = DateFormatter()

f.dateStyle = .none
f.timeStyle = .medium
let now = Date()
print(f.string(from: now)) //16:36:46


王道2. テンプレートを検討すべし

DateFormatter.dateFormat(fromTemplate:options:locale)を使用することでスタイルよりも自由度が高い出力形式の指定が行えます。

使用例:

extension DateFormatter {

// テンプレートの定義(例)
enum Template: String {
case date = "yMd" // 2017/1/1
case time = "Hms" // 12:39:22
case full = "yMdkHms" // 2017/1/1 12:39:22
case onlyHour = "k" // 17時
case era = "GG" // "西暦" (default) or "平成" (本体設定で和暦を指定している場合)
case weekDay = "EEEE" // 日曜日
}

func setTemplate(_ template: Template) {
// optionsは拡張用の引数だが使用されていないため常に0
dateFormat = DateFormatter.dateFormat(fromTemplate: template.rawValue, options: 0, locale: .current)
}
}

// テンプレートから時刻を表示
let f = DateFormatter()
f.setTemplate(.full)
let now = Date()
print(f.string(from: now)) // 2017/8/13 17:24:21

テンプレート文字の種類と意味一覧はこちらから確認できます: DateFieldSymbolTable


王道3: 上記でカバーできないパターンのみdateFormatに頼るべし

styletemplateは便利であるものの当然全てのパターンを網羅できるものではありません。例えば時刻をあと○分と表示したいという場合にはカバーできません。そういった場合には素直にdateFormatを使用します。

また、日本語の場合などのローカライズされるべき文字がありますが、多くの場合は記号区切りです。

つまり○時○分のような記号と一対一で結びつかないローカライズを期待する文字は世界共通ではないのでRFC3339では非推奨とされています。そのためテンプレートなどを使用した場合においても10:30のような表記になります。よって、日本語であと○分と表示したい場合には、Localizable.strings(ja)からあとmm分dateFormatに与えるのが良いということになります。(Localizable.strings(en)の場合はmm minutes remaining)。


王道4. StringからDateに変換するときは常にen_US_POSIXを指定すべし

次のコードを見てください。これはDateyyyy/MM/ddと表示したい場合の例です。

let f = DateFormatter()

f.dateFormat = "yyyy/MM/dd"
let now = Date()
print(f.string(from: now))

結果:

本体設定が西暦(グレゴリアン)の場合 -> 2017/8/12

本体設定が和暦の場合 -> 0029/8/12

本体設定がタイ仏暦の場合 -> 2560/8/12

このようにある設定では正常に動いていても設定を変更した途端にこのコードは破綻してしまいます。これはdateFormatが現在の時刻表示設定を用いてフォーマットの解釈を行うためです。つまり任意のフォーマットを指定する場合、本来全ての設定を網羅した処理が必要となってしまいます。

if グレゴリアン {...}

else if 和暦 {...}
else if タイ仏暦 {...}
...

これはロジックを複雑化させメンテナンス性を下げるコードにシフトしていく未来が見えるためアンチパターンです。

これを解決するためにen_US_POSIXを使用します。 よく生物の学名がラテン語で表記されているのを見ると思いますが、これはラテン語がこれ以上言語として仕様変更がないとみなされているため採択されたものです。つまり、あとから変更されないことが保証されているため、膨大な生物の名前を一意に与えたいときに便利だから採択されたというわけです。 

このラテン語の例は今回のen_US_POSIXを使う理由と同じです。en_US_POSIXは仕様変更されないことが保証されている唯一のロケールであるため、RFC3339に準拠した日付文字列(例: yyyy-MM-dd'T'HH:mm:ss'Z')をDateに変換できる保証があります。en_US_POSIXja_JPなどのロケールとは違い当然、和暦やタイ仏暦などもありません。つまり、いかなる本体設定をしようともRFC3339のフォーマットの日付文字列を正しく解釈することができるということです。そのため一度en_US_POSIXで解釈したDateオブジェクトから任意の出力形式に変換することによって設定やLocaleに依存しない日付処理を実現することができます。


Formatterの底力編


和暦の元号名は自動で取得できる

「令和」の表記はmacOS Mojave(10.14.5)またはiOS(12.3)以降のバージョンが必要です。

let day: Double = 60 * 60 * 24

let year: Double = day * 365
let f = DateFormatter()

// 日本語表示
f.locale = Locale(identifier: "ja_JP")

// 和暦表示
f.calendar = Calendar(identifier: .japanese)

// 元号名をつけて出力
f.dateFormat = DateFormatter.dateFormat(fromTemplate: "GydMMMEEE", options: 0, locale: Locale(identifier: "ja_JP"))

// 令和(現在)
print(f.string(from: Date())) // 令和1年5月30日(日)

// 平成
print(f.string(from: Date())) // 平成29年8月13日(日)

// 昭和(30年前)
print(f.string(from: -(year * 30))) // 昭和15年1月9日(火)

// 大正(50年前)
print(f.string(from: -(year * 50))) // 大正9年1月14日(水)

// 明治(70年前)
print(f.string(from: -(year * 70))) // 明治33年1月18日(木)

// 安政(110年前)
print(f.string(from: -(year * 110))) // 安政7年1月28日(土)

// 応仁(502年前) 応仁の乱の時代
print(f.string(from: -(year * 502))) // 応仁3年4月23日(日)

// 天禄(1000年前)
print(f.string(from: -(year * 1000))) // 天禄1年8月26日(金)

// 大化(1326年前) 限界...これ以上昔にすると...
print(f.string(from: -(year * 1326))) // 大化0年11月15日(月)


当日を「今日」、前日を「昨日」などと表示する

let f = DateFormatter()

f.dateStyle = .medium
f.timeStyle = .none
f.doesRelativeDateFormatting = true
let now = Date()
print(f.string(from: now)) // 今日

doesRelativeDateFormatting = trueの時の出力対応表:


2日前
1日前
当日
1日後
2日後

日本
一昨日
昨日
今日
明日
明後日

アメリカ
Jun 1, 2017
Yesterday
Today
Tomorrow
Jun 5, 2017


日付の期間を表示する

日付の期間を表示するにはDateIntervalFormatterを使用します。

let f = DateIntervalFormatter()

f.dateTemplate = "ydMMMEEE"
f.locale = Locale(identifier: "ja_JP")
let 今日 = Date()
let 明後日 = Date(timeIntervalSinceNow: 60 * 60 * 48)
print(f.string(from: 今日, to: 明後日)) // 2017年8月13日(日)~15日(火)


秒数を時刻形式に変換

例えば任意の秒数を○時間○分○秒と出力したいときがあります。これを自前で実装しようとなると、仮に3601秒の場合1時間1秒と表示するなどを読み落としたり、hh:mm:ssの形式の場合にはhh以外の1桁は0で埋めて1:00:01とするなど複雑なコードが生まれる未来が見えます。

こういった場合はDateComponentsFormatterを使うことにより簡単に解決することができます。

let formatter = DateComponentsFormatter()

formatter.unitsStyle = .positional
formatter.allowedUnits = [.hour, .minute, .second]
print(formatter.string(from: 3601)) // 1:00:01

DateComponentsのunitsStyle一覧:

unitsStyle
出力

.abbreviated
1時間1秒

.brief
1時間1秒

.full
1時間 1秒

.positional
1:00:01

.short
1時間 1秒

.spellOut
一時間 一秒

また、DateComponentsFormatterでは時間以外にもallowedUnitsを指定することで日時に変換することもでき、さらにzeroFormattingBehaviorを指定することである単位で値が0になるときの振る舞いも指定することができます。


まとめ


  • 日付処理や文字列からの変換は本当にロジックを書く必要があるかを検討する :point_up:

  • 日付・時刻のみの出力にはstyletemplateを活用する :timer:


  • StringからDateに変換する時は常にen_US_POSIXを使用する :flag_us:


おまけ

DateFormatterのインスタンス生成はコストが高いので繰り返し使用が想定される場合はキャッシュしインスタンスを使いまわすことをおすすめします :comet: (e.g. tableView(_:cellForRowAt:)などでDateFormatterをインスタンス化するのはNG)


参考

http://qiita.com/gonsee/items/d3fb641914d2ca45e858

http://qiita.com/cotrpepe/items/a3e36fa70afb418c1bbe

http://techlife.cookpad.com/entry/internationalization-and-localization-of-ios-apps

http://www.unicode.org/reports/tr35/tr35-25.html#Date_Format_Patterns

https://developer.apple.com/documentation/foundation/date

https://developer.apple.com/documentation/foundation/dateformatter

https://developer.apple.com/library/content/qa/qa1480/_index.html