しめ鯖日記

swift, iPhoneアプリ開発, ruby on rails等のTipsや入門記事書いてます

【Swift】Dateの日付のみを扱う方法を考える

iOSアプリ開発ではDate型があるのですが、これは日付型のみを扱う事ができません。
そのため「RealmなどのDBから特定日付のレコードを取り出したい」という時に開始日と終了日を指定する必要があって結構めんどくさいです。

class History: Object {
    @objc dynamic var id = 0
    @objc dynamic var date = Date()
}

let realm = try! Realm()
// startDateとendDateで比較するのでめんどくさい
let history = realm.objects(History.self).filter("date >= %@ && date < %@", startDate, endDate).first

「dateカラムには常にその日の0:00のデータを入れるというルールにする」って方針もあるのですが、これだと別の国に移動してタイムゾーンがずれた時などにデータがおかしくなる可能性があります。

let history = History()
if let date = Calendar.current.dateComponents([.calendar, .year, .month, .day], from: Date()).date {
    history.date = date
}

今回はそういった問題の対策として、Dateを文字列として扱う事で時間情報を省けないかを検討してみます。

Dateを文字列として保存する実装

上で挙げたHistoryオブジェクトのdateカラムを文字列化する場合、下のような実装ができるかと思います。
dateのゲッターとセッターを提供して、その内部ではdateStrカラムからのデータ取得・データの保存を行います。

class History: Object {
    @objc dynamic var id = 0
    @objc dynamic var dateStr = "20180101"
    var date: Date? {
        get {
            let formatter = DateFormatter()
            formatter.dateFormat = "yyyyMMdd"
            return formatter.date(from: dateStr)
        }
        set {
            if let date = newValue {
                let formatter = DateFormatter()
                formatter.dateFormat = "yyyyMMdd"
                dateStr = formatter.string(from: date)
            }
        }
    }
}

日付のセットは下のように行います。

let history = History()
history.date = Date()
print(history.date)

特定の日付のレコードの取得は下のように行います。

let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd"
print(try? Realm().objects(History.self).filter("dateStr = %@", formatter.string(from: Date())).first)

RealmではStringで比較演算子を使う事ができないので、下のように比較する必要があります。

let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd"
var startDateComponent = Calendar.current.dateComponents([.calendar, .year, .month, .day], from: Date())
startDateComponent.day = (startDateComponent.day ?? 0) - 10
let endDateComponent = Calendar.current.dateComponents([.calendar, .year, .month, .day], from: Date())
if let startDate = startDateComponent.date, let endDate = endDateComponent.date {
    print(try? Realm().objects(History.self).filter { $0.dateStr >= formatter.string(from: startDate) && $0.dateStr < formatter.string(from: endDate) }.count)
}

ここまでで取得・保存の実装を見てきましたが少し冗長な書き方になってしまいそうです。
実装のシンプルさという面ではDateを文字列として扱うのはあまり良くないかもしれません。

Dateを文字列として扱う場合のパフォーマンス

次に文字列として扱った場合のパフォーマンスを見てみようと思います。
最初にdateのセットを見ていきます。

下のように100回セットした時の時間を計測します。
端末はiPhone5を利用しました。

let dates: [Date] = (0...100).compactMap {
    var component = Calendar.current.dateComponents([.calendar, .year, .month, .day], from: Date())
    component.day = (component.day ?? 0) - $0
    return component.date
}
let t = Date().timeIntervalSince1970
let history = History()
dates.forEach { date in
    history.date = date
}
print(Date().timeIntervalSince1970 - t)

結果は下のとおりです。
100回で0.1秒なので負荷を気にしなくて良いレベルかと思います。

0.108951091766357 // 1回目
0.0738258361816406 // 2回目
0.066342830657959 // 3回目

次は日付の取得を見ていきます。

let histories: [History] = (0...100).compactMap {
    var component = Calendar.current.dateComponents([.calendar, .year, .month, .day], from: Date())
    component.day = (component.day ?? 0) - $0
    let history = History()
    history.date = component.date
    return history
}
let t = Date().timeIntervalSince1970
let history = History()
histories.forEach { history in
    _ = history.date
}
print(Date().timeIntervalSince1970 - t)

結果は下の通りです。
こちらもセットとほぼ同じくらいの速度でした。

0.078549861907959
0.0887308120727539
0.0748848915100098

次にRealmでの検索を見ていきます。
レコードは事前に10000件登録してあります。

let dates: [Date] = (0...100).compactMap {
    var component = Calendar.current.dateComponents([.calendar, .year, .month, .day], from: Date())
    component.day = (component.day ?? 0) - $0
    return component.date
}
let t = Date().timeIntervalSince1970
dates.forEach { date in
    let formatter = DateFormatter()
    formatter.dateFormat = "yyyyMMdd"
    let realm = try? Realm()
    _ = realm?.objects(History.self).filter("dateStr = %@", formatter.string(from: date))
}
print(Date().timeIntervalSince1970 - t)

結果は下の通りです。
日付のセットより少し重いですが気にするほどではなさそうです。

0.303496122360229
0.253895998001099
0.246631860733032

最後に複数データの取得を試してみました。

let dates: [Date] = (0...100).compactMap {
    var component = Calendar.current.dateComponents([.calendar, .year, .month, .day], from: Date())
    component.day = (component.day ?? 0) - $0
    return component.date
}
let t = Date().timeIntervalSince1970
dates.forEach { date in
    let formatter = DateFormatter()
    formatter.dateFormat = "yyyyMMdd"
    var startDateComponent = Calendar.current.dateComponents([.calendar, .year, .month, .day], from: date)
    startDateComponent.day = (startDateComponent.day ?? 0) - 10
    let endDateComponent = Calendar.current.dateComponents([.calendar, .year, .month, .day], from: date)
    if let startDate = startDateComponent.date, let endDate = endDateComponent.date {
        let realm = try? Realm()
        _ = realm?.objects(History.self).filter { $0.dateStr >= formatter.string(from: startDate) && $0.dateStr <= formatter.string(from: endDate) }.count
    }
}
print(Date().timeIntervalSince1970 - t)

これは全レコードを毎回インスタンス化するという事もあって非常に遅かったです。
パフォーメンスの面で言うと文字列でDateを扱うのは難しそうです。

96.0780339241028
92.7221720218658
99.2258989810944

DateをIntとして保存する実装

Dateを文字列で扱うのは、複数データ取得時のパフォーマンス面で厳しそうでした。
代わりにIntとして扱う実装を試してみます。

Intとして扱う場合、Historyテーブルは下のような形の実装が考えられます。

class History: Object {
    @objc dynamic var id = 0
    @objc dynamic var dateValue = 20180101
    var date: Date? {
        get {
            let year = dateValue / 10000
            let month = (dateValue % 10000) / 100
            let day = (dateValue % 100)
            let dateComponent = DateComponents(calendar: Calendar.current, year: year, month: month, day: day)
            return dateComponent.date
        }
        set {
            if let date = newValue {
                let year = Calendar.current.component(.year, from: date)
                let month = Calendar.current.component(.month, from: date)
                let day = Calendar.current.component(.day, from: date)
                dateValue = year * 10000 + month * 100 + day
            }
        }
    }
}

文字列の時にパフォーマンスが悪かった「複数データ取得」の測定をしてみました。

let dates: [Date] = (0...100).compactMap {
    var component = Calendar.current.dateComponents([.calendar, .year, .month, .day], from: Date())
    component.day = (component.day ?? 0) - $0
    return component.date
}
let t = Date().timeIntervalSince1970
dates.forEach { date in
    var startDateComponent = Calendar.current.dateComponents([.calendar, .year, .month, .day], from: Date())
    startDateComponent.day = (startDateComponent.day ?? 0) - 10
    let endDateComponent = Calendar.current.dateComponents([.calendar, .year, .month, .day], from: Date())
    if let startDate = startDateComponent.date, let endDate = endDateComponent.date {
        let startDateValue = Calendar.current.component(.year, from: startDate) * 10000 + Calendar.current.component(.month, from: startDate) * 100 + Calendar.current.component(.day, from: startDate)
        let endDateValue = Calendar.current.component(.year, from: endDate) * 10000 + Calendar.current.component(.month, from: endDate) * 100 + Calendar.current.component(.day, from: endDate)
         
         let realm = try? Realm()
         _ = realm?.objects(History.self).filter("dateValue >= %d and dateValue <= %d", startDateValue, endDateValue).count
    }
}
print(Date().timeIntervalSince1970 - t)

測定結果は下の通りです。
この速度なら採用しても問題なさそうです。

0.374364852905273
0.377358913421631
0.369530916213989

実装においての注意

この実装ですが、和暦の対策が必要です。
和暦対策をしないと、ユーザーの端末設定によってはyearが平成の年数になったりして期待した動きをしない事があります。

常に西暦の年数を取りたい場合は下のような実装をします。
もし毎回初期化する事でパフォーマンスが悪い場合はstatic変数としてCalendarを保持するのもいいかもしれません。

Calendar(identifier: .gregorian).component(.year, from: Date())

まとめ

Dateの日付のみを扱う方法について調べましたが、もし実装するとしたらIntで保持するのが良さそうです。
処理は冗長になりがちなので、その辺りをうまく共通化するともっと便利になりそうです。