LoginSignup
9
17

More than 3 years have passed since last update.

DateFormatterを完全に理解した記事を書いてみました

Posted at

あるいはDateの時差の変換でハマった話しです。
海外対応を実装した方がターゲット層です。

概要

今回はiOSのDateDateFormatterの仕様について話して行きたいと思います。
今週これだけで1, 2日ハマってしまいましたので備忘録も込めてまとめます。

ハマったことについて

例えば次のようなコードがあったとします。
簡単に言えばDateのExtensionを作って日付の処理について簡単に扱う為に作った関数です。

DateConvert.swift

extension Date {
    var japaneseDeviceDateFormatter: DateFormatter {
        let dateFormatter = DateFormatter()
        dateFormatter.locale = Locale(identifier: "en_US_POSIX")
        dateFormatter.timeZone = TimeZone(identifier: "Asia/Tokyo")
        dateFormatter.calendar = Calendar(identifier: .gregorian)
        return dateFormatter
    }

    var deviceDateFormatter: DateFormatter {
        let dateFormatter = DateFormatter()
        dateFormatter.locale = Locale(identifier: "en_US_POSIX")
        dateFormatter.timeZone = .current
        dateFormatter.calendar = Calendar(identifier: .gregorian)
        return dateFormatter
    }

    func getSampleLocalDate() -> String {
        let localDate = Date()
        let df = localDate.deviceDateFormatter
        df.dateFormat = "yyyyMMddHHmm"
        let testableDateString = df.string(from: localDate)
        return testableDateString
    }
}

関数の書き方とか確かにそういうところもツッコミたいかもしれませんが、
そこはグッと抑えてください。

テストの仕方

昔のシミュレータはタイムゾーンを変更できた記憶があるのですが、今のシミュレータはできないみたいです。
出来る方はこっそり教えてください。

実機で海外の時間に変更したい場合は
設定 > 一般 > 日付と時刻
自動設定をオフにすると各国の時間帯に変更することができます。

IMG_6853.PNG

これで各国の時間でデバッグすることができます。

テストコードを走らせる場合は実機をMacに繋げてXcodeでテストスキームで実機でビルドしないと駄目な気がします。

それではテストしていきます。

テストする仕様について

  1. 今回は海外在住のユーザーが日本のコンテンツを日本時刻で見たいというニーズに合わせて、海外時刻を日本時刻に変換する処理をテストしたいと思います。
  2. 海外時刻を関数に渡す時にはDateが使えず"201905180110"というような形の文字列しか引数に渡すことができません。(例の文字列は2019年05月18日1:10)

つまり、海外の日付の文字列から日本時刻の文字列ひいてはDateを取得する関数が欲しいです

テスト失敗例その1

こちらは自分でテストを書いておきながらハマってしまったコードの例です。

DateSampleTests.swift
import XCTest
@testable import DateSample

class DateSampleTests: XCTestCase {

    func testBatPractice() {
        let localDate = Date()
        let localDateFormatter = localDate.deviceDateFormatter
        localDateFormatter.dateFormat = "yyyyMMddHHmm"
        guard let currentLocalDate = localDateFormatter.date(from: localDate.getSampleLocalDate()) else { return }
        let currentDateString = localDateFormatter.string(from: currentLocalDate)
        print("currentLocalDate:", currentLocalDate.description, ", currentDateString:", currentDateString)

        let jpDateFormatter = localDate.japaneseDeviceDateFormatter
        let jpDateString = jpDateFormatter.string(from: currentLocalDate)
        guard let jpDate = jpDateFormatter.date(from: jpDateString) else { return }
        print("jpDate:", jpDate.description, ", jpDateString:", jpDateString)
        XCTAssertNotEqual(currentDateString, jpDateString)
    }
}

これはテストが失敗します。

正解のテストコード

一見うまいこと行くように思われますが

DateSampleTests.swift
        let jpDateFormatter = localDate.japaneseDeviceDateFormatter
        let jpDateString = jpDateFormatter.string(from: currentLocalDate) // ""
}

このステップで落ちてしまいます。

jpDateStringが生成されなくて落ちてしまうみたいです。

jpDate: 1999-12-31 15:00:00 +0000 , jpDateString: 
Printing description of jpDateString:
""

正しくは次のコードになります。

DateSampleTests.swift
import XCTest
@testable import DateSample

class DateSampleTests: XCTestCase {

    override func setUp() {
        // Put setup code here. This method is called before the invocation of each test method in the class.
    }

    func testConvertToJapanese() {
        let localDate = Date()
        let localDateFormatter = localDate.deviceDateFormatter
        localDateFormatter.dateFormat = "yyyyMMddHHmm"
        guard let currentLocalDate = localDateFormatter.date(from: localDate.getSampleLocalDate()) else { return }
        let currentDateString = localDateFormatter.string(from: currentLocalDate)
        print("currentDate:", currentLocalDate.description, ", currentDateString:", currentDateString)

        let jpDataFormatter = localDate.japaneseDeviceDateFormatter
        jpDataFormatter.dateFormat = "yyyyMMddHHmm" // NEW
        let jsDateString = jpDataFormatter.string(from: currentLocalDate)
        guard let jpDate = jpDataFormatter.date(from: jsDateString) else { return }
        print("jpDate:", jpDate.description, ", jpDateString:", jsDateString)
        XCTAssertNotEqual(currentDateString, jsDateString)
    }
}

// NEWの部分が修正コードになります。

dateFormatterを日本用に新しく生成した場合は最初dateFormatを指定しないとDateからStringに日本時刻で変換できない仕様だということが分かりました。

当然と言えば当然なのですが焦っているときにExtensionで作られているクラスのテストでは思いつきません。

もしかすると既に気づいている人もいるかもしれませんが、海外用のDateFormatterと日本用のDateFormatterの関数が既にあったので僕は勘違いしてしまいました。

実はこんな回りくどい処理をする必要がないのです。

そもそも文字列などからDateのインスタンスを生成した場合はUTC基準なのです。

【Swift】Dateの王道 【日付】

(この記事によると既に一番上の参考コードはバットプラクティスのオンパレードですがそこはスルーで)

スクリーンショット 2019-05-18 15.05.04.png

ということはDateを作成した時点で既にTimeZoneの概念が入っているのでTimeZoneを変更するだけでいいのです。

正解のテストコード (その2)

以上をもって上のテストコードをもっとスマートに書くなら例えば次のように変更すればいいのです。

DateSampleTests.swift
import XCTest
@testable import DateSample

class DateSampleTests: XCTestCase {

   func testConvertToJapaneseBestPractice() {
        let localDate = Date()
        let localDateFormatter = localDate.deviceDateFormatter
        localDateFormatter.dateFormat = "yyyyMMddHHmm"
        guard let currentLocalDate = localDateFormatter.date(from: localDate.getSampleLocalDate()) else { return }
        let currentDateString = localDateFormatter.string(from: currentLocalDate)
        print("currentDate:", currentLocalDate.description, ", currentDateString:", currentDateString)

        localDateFormatter.timeZone = TimeZone(identifier: "Asia/Tokyo") // TimeZone を変える
        let jsDateString = localDateFormatter.string(from: currentLocalDate)
        guard let jpDate = localDateFormatter.date(from: jsDateString) else { return }
        print("jpDate:", jpDate.description, ", jpDateString:", jsDateString)
        XCTAssertNotEqual(currentDateString, jsDateString)
    }
}

// TimeZone を変えるのように既に作成したlocalDateFormatterのTimeZoneを日本時刻に変更すればわざわざ日本用のDateFormatterのインスタンスを作り直す必要がないのです。

言われて見れば「確かに!」と思う内容なのですが今まで使いまわしてタイムゾーンを変更する機能を作ったことがありませんでしたので拍子抜けしてしまいました。

そもそも海外の時刻を文字列で持ってそれを日本の時刻の文字列に変換する意味ってないやん、というツッコミもあるかもしれませんが仮にそういう境遇に陥ってしまった場合は今日の記事が参考になるかと思います。

1週間前にこの記事を読んでいたらこの1週間は徹夜する必要がなかったぐらいです。

DateFormatter について

元々はNSDateFormatterですがSwiftの登場によってDateFormatterに呼び方が変わりました。
Date()クラスを修飾したいに使うクラスになります。

初期宣言

Sample.swift
let dateFormatter = DateFormatter()

だいたいみんなが実装に困る部分の設定は

  • calendar
  • locale
  • timezone

かなと思います。

calendar

設定は一般 > 言語と地域 > 暦法から変更できます。

IMG_6854.PNG

Left align Right align
gregorian グレゴリオ歴
japanese 和暦
chinese 中国暦
indian インド暦

みたいな感じになります。

iOS アプリ開発 全アプリ共通でやっておきたいUIまわりのテストが詳しいです。

和暦は24時間対応の際にiPadとかでバグの温床になるケースが多いのでgregorianを指定することが多いです。
japaneseの方が設定は簡単ですが長期的運用を考えた場合はこちらがオススメです。

Sample.swift
        let dateFormatter = DateFormatter()
        dateFormatter.calendar = Calendar(identifier: .gregorian)

locale

文字列 から 日付 へ変換する際にどのロケールで変換するのかっていう設定で使います。

  • ja_JP
  • ja
  • en_US_POSIX

とかのオプションがあります。

表についてはこちらのページが一覧になります。

一般的にはja_JPen_US_POSIXで設定することが多いです。

Sample.swift
        let dateFormatter = DateFormatter()
        dateFormatter.locale = Locale(identifier: "ja_JP")

timezone

これが今回の記事のメインテーマになったプロパティです。

iPhoneの実機の表示時刻で各国の時間に合わせる時に使うオプションになります。

表については

こちらが参考になります。

海外対応や時差の計算で使うと思います。

Sample.swift
        let dateFormatter = DateFormatter()
        dateFormatter.timeZone = TimeZone(identifier: "GMT") // 標準時間
        dateFormatter.timeZone = TimeZone(identifier: "Asia/Tokyo") // 日本時間
        dateFormatter.timeZone = TimeZone(identifier: "Asia/Seoul") // ソウル時間
        dateFormatter.timeZone = .current // 実機での設定時間

とりあえずここまでの情報や参考ページを読み込めばDateとDateFormatterとの関係性について深く理解できると思います。

9
17
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
17