概要
時間という概念は、概念と呼ばれるものの中で最も日常に溶け込んだ存在です。
本投稿では、まずはじめにSwiftでの正しい日付・時刻の扱い方を知るために、その仕組みとなっている時間・時刻の定義についてまとめていき、その後Date
を扱うときのアンチパターンとベストプラクティスについて考え、その対応について紹介していきたいと思います。
GMT / UTC / JST / UNIX TIMEについて
SwiftでDate
を扱う前に時刻とはなにかについて知る必要があります。ここでは世界で主に使われる時刻の定義の種類について簡単におさらいをします。
GMT
GMT(グリニッジ平均時): UTCが現れる以前まで世界共通時として扱われていたものであり、経度0からの平均太陽時を指します。現在ではUTCと同義で扱われることが多いですが、厳密には異なりGMTはうるう秒
が考慮されないためUTC
とは100年でおよそ18秒のズレが生じます。
UTC
UTC(協定世界時): UTCは世界共通の時刻であり、各地域の標準時はこのUTC
を基準として算出されています。UTCは原子時をもとに時刻を進めていますが、原子時を人が扱う現実の時間に変換(整数として扱う)するとほんの僅かな誤差が生じます。この誤差の影響が1秒以上になってしまっては問題が生じるため、1秒以上の誤差が生じないように人工的に原子時にうるう秒
を考慮したものがUTC
です。
JST
JST(日本標準時): JSTは日本における時間の標準化を図っており、UTC+9時間と定義されています。 よってUTC
で1時のものはJST
だと10時となります。
UNIX TIME
UNIXタイム: PCなどはインターネットが繋がっていない状態でも時間は進みます。これは1970年1月1日0時0分0秒(UNIXエポック)からの経過時間を算出することで実現しています。UNIXタイムは本当の時刻経過を表しておらず、うるう秒も考慮されませんが多くのシステムでは運用的な問題が少ないため実質的な時刻として扱われています。
タイムゾーン
時刻は世界中あらゆる場所で使用されます。もし参照する地域によって、時刻がばらばらになってしまっては時刻
自体が意味のないものとなってしまいます。そこで標準時を基準に、その地域が標準時からどれだけ遅れているか、または、進んでいるかを加えることによって時刻の共通化を図るものがタイムゾーンです。これにより時差の概念が生じます。
Swiftで日付を扱うときのアンチパターン集
ここではSwiftでの日付処理におけるアンチパターンを紹介するとともに次のセクションでは、Swiftでの正しい日付処理の仕方を紹介します。
1. Formatterでロケール・タイムゾーンを設定しない
例えばインドのように公用語が母国語ではないケースや、「言語」は英語を設定しているが「地域」は日本など必ずしも現在設定している地域(ロケール)が日付の表示したい言語とは限りません。これについては現在の地域を使用する方法やpreferredLanguages
を使用する方法もありますが、明示的に指定することでUXの向上や正しいローカライズ結果を得ることができます。
2. 日付文字列へ変換時にローカライズされるべき箇所をハードコードしている
ダメな例:
formatter.dateFormat = "yyyy年MM月dd日"
print(formatter.string(from: Date())) // 2023年7月23日
この場合年
や月
、日
などローカライズされるべき文字が混合しており、今後ローカライズの対応を検討するときにLocalizable.strings
に国ごとのパターンを個別に追加する未来が見えるためアンチパターンとなります。
正しい例:
formatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "ydMMM", options: 0, locale: Locale(identifier: "ja_JP"))
print(formatter.string(from: Date())) // 2023年7月23日
年・月・日
はどこから現れたんだろうと思う方もいらっしゃるかもしれませんが、これはテンプレートとロケールを組み合わせることでその地域に合わせた形式での日付の出力が可能となります。
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 = "2023/8/12"
formatter.dateFormat = "yyyy/MM/dd"
formatter.locale = Locale(identifier: "ja_JP")
let date = formatter.date(from: now)
これを実行してみると通常の設定ではdate
は正しく2023/8/12
と取得されます。
しかし、例えばiPhoneの設定から日付を西暦ではなく「和暦」にしてみるとどうでしょう?
すると4011/8/12
と取得されてしまいます。
これは和暦を設定したために本来は令和5年
が2023年という解釈ですがこれが、令和2023年は西暦何年か?
という解釈になってしまい、その結果西暦4011年
となります。このようにformatterに対して本体設定が解釈に影響を及ぼすコードであることからアンチパターンと言えます。
Dateの王道 👑
王道1. styleを検討すべし
DateFormatter
にはdateStyle
とtimeStyle
があり、それぞれ日付と時間の出力形式をロケールによりローカライズしたフォーマットとして扱ってくれるプロパティがあります。
日付の出力の仕様がこの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)) // 令和5年7月23日日曜日 11時54分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)) //2023年7月23日
使用例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" // 2023/1/1
case time = "Hms" // 12:39:22
case full = "yMdkHms" // 2023/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)) // 2023/7/23 17:24:21
テンプレート文字の種類と意味一覧はこちらから確認できます: DateFieldSymbolTable
王道3: 上記でカバーできないパターンのみdateFormat
に頼るべし
style
とtemplate
は便利であるものの当然全てのパターンを網羅できるものではありません。例えば時刻をあと○分
と表示したいという場合にはカバーできません。そういった場合には素直にdateFormat
を使用します。
また、日本語の場合時
や分
などのローカライズされるべき文字がありますが、多くの場合は記号区切りです。
つまり○時○分
のような記号と一対一で結びつかないローカライズを期待する文字は世界共通ではないのでRFC3339では非推奨とされています。そのためテンプレートなどを使用した場合においても10:30
のような表記になります。よって、日本語であと○分
と表示したい場合には、Localizable.strings(ja)からあとmm分
をdateFormat
に与えるのが良いということになります。(Localizable.strings(en)の場合はmm minutes remaining
)。
王道4. StringからDateに変換するときは常にen_US_POSIX
を指定すべし
次のコードを見てください。これはDate
をyyyy/MM/dd
と表示したい場合の例です。
let f = DateFormatter()
f.dateFormat = "yyyy/MM/dd"
let now = Date()
print(f.string(from: now))
結果例:
本体設定が西暦(グレゴリアン)の場合 -> 2023/8/12
本体設定が和暦の場合 -> 0005/8/12
本体設定がタイ仏暦の場合 -> 2566/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_POSIX
はja_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())) // 令和5年7月23日(日)
// 平成
print(f.string(from: -(year * 10))) // 平成25年7月25日(木)
// 昭和(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日後 |
---|---|---|---|---|---|
日本 | 一昨日 | 昨日 | 今日 | 明日 | 明後日 |
アメリカ | Jul 22, 2023 | Yesterday | Today | Tomorrow | Jul 25, 2023 |
日付の期間を表示する
日付の期間を表示するには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: 明後日)) // 2023年7月23日(日)~25日(火)
秒数を時刻形式に変換
例えば任意の秒数を○時間○分○秒
と出力したいときがあります。これを自前で実装しようとなると、仮に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になるときの振る舞いも指定することができます。
まとめ
- 日付処理や文字列からの変換は本当にロジックを書く必要があるかを検討する
- 日付・時刻のみの出力には
style
・template
を活用する -
String
からDate
に変換する時は常にen_US_POSIX
を使用する
おまけ
DateFormatter
のインスタンス生成はコストが高いので繰り返し使用が想定される場合はキャッシュしインスタンスを使いまわすことをおすすめします (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