【Swift】Dateの王道 【日付】

  • 83
    Like
  • 0
    Comment

概要

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

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

GMTとUTCとJSTとUNIXタイム

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

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

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

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

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

タイムゾーン

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

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

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

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

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

2. DateをStringで出力時にテンプレート/スタイル指定を使わない

ダメな例:

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と取得されます。しかし、例えば日付の設定を「和暦」にしてみるとどうでしょう?
すると4005/8/12と取得されてしまいます。これは和暦を設定したために平成2017年と解釈されyyyyにより 西暦変換すると平成29年現在が2017年であることから平成2017年西暦4005年となります。このようにフォーマッタに対して本体設定が解釈に影響を及ぼすコードであることからアンチパターンと言えます。

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は拡張のためにあるが使用されていない引数
        // localeは省略できないがほとんどの場合currentを指定する
        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は便利であるものの当然全てのパターンを網羅できるものではありません。例えば時刻をあと○分と表示したいという場合にはテキストのローカライズに加えて時刻表記のローカライズを考えなければなりませんがDateFormatterではまかなえません。そういった場合には素直にdateFormatを使用します。
また、日本語の場合などのローカライズされるべき文字がありますが、多くの場合は記号区切りです。
つまり○時○分のような記号と一対一で結びつかないローカライズを期待する文字は世界共通ではないのでRFC3339では非推奨とされています。そのためテンプレートなどを使用した場合においても10:30のような表記になります。よって、日本語であと○分と表示したい場合には、Localizable.strings(ja)からあとmm分dateFormatに与えるのが良いということになります。(Localizable.strings(en)の場合はmm minutes remaining)。ただしこれはローカライズされる文字を含んでいる場合のみであり、基本的な日付の出力はstyleテンプレートを活用するのが良いでしょう。

王道4. StringからDateに変換するときのLocaleには常に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 タイ仏暦 {...}
...

:scream:
これはローカライズする地域が増えるのに比例して肥大化していきそうな未来が見えるのでアンチパターンです。

これを解決するためにフォーマッタのローケールには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の底力編

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

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

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

// 平成(現在)
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:
  • 文字列からDateに変換する時Localeは常にen_US_POSIXを使用する :flag_us:
  • DateFormatterのインスタンス生成はコストが高いので繰り返し使用が想定される間は1つのインスタンスをキャッシュする :comet:

参考

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