これは株式会社TimeTree Advent Calendar 2023の7日目の記事です。
TimeTreeのiOSエンジニア、@gonseeです。
TimeTreeはカレンダーシェアアプリなので、日々カレンダーにまつわる開発を行っています。カレンダーは我々の生活にあまりにも溶け込んだ存在なので、それをアプリで実装することはそれほど難しくないんじゃないか、と思うかもしれません。しかし実際に開発してみるとすごく複雑で、知っておかないと思わぬバグを仕込んでしまうことになります。そんな、エンジニア視点で「怖い」話をお届けしていきます。今回のテーマは「タイムゾーン」と「サマータイム」です。
同じ内容をTimeTreeラヂオでも話しているのでよかったら聞いてみてください。こちらの記事ではコードを交えてより技術的な話をしたいと思います。コードはSwiftによるiOSアプリを前提としています。
世界が平面だったらいいのに
そう思ったことはありませんか?だとしたら同じ苦労をしてきた同志ですね!
残念ながら地球は球体のようで、同じ瞬間でも場所によって太陽の位置が変わってきます。「午前9時」がどこでも朝であるためには場所によって時計をずらす必要があり、そのためにどの地域が今何時なのかを決めているのが「タイムゾーン」です。
日本にはタイムゾーンがひとつしかないので、日本向けのサービス開発であればあまり意識しなくても困ることは少ないかもしれません。TimeTreeはグローバルに展開していることもあり、タイムゾーンの考慮は避けて通れません。
9時間のずれ
時刻まわりの不具合であるあるなのが9時間ずれているという問題です。カレンダーサービスに限らずほとんどどのサービスでもプログラムで時刻を扱うことがあると思うので、これは多くのエンジニアがピンとくるかもしれません。日本標準時とUTCの時差が9時間なので、おそらくこれはタイムゾーンの扱いに問題があるなと推測できます。
実際にこういった不具合が過去にありました。
TimeTreeでは終日予定の日付をUTCの0:00として扱う仕様があり、ここでよくタイムゾーン関連の問題が起きがちです。
他によくある問題として、日本時間では問題ないのにアメリカ時間だとバグるみたいなことがあります。日付をUTCで扱わなければならないのに端末のローカルタイムゾーンで扱ってしまったとき、日本時間はUTC+9なので日付部分だけ見れば一致していて問題ないですが、ニューヨーク時間だとUTC-5なので前日になってしまう、というような問題です。
こういった問題をできるだけ避けるためには、コードの中で TimeZone.current
を暗黙的に使うのを避け、 TimeZone
を外から与えられる(dependency injectionできる)ようにすることが大事です。その上でユニットテストを書き、 Asia/Tokyo
だけでなく America/New_York
などでもテストするようにしています。
1日が24時間じゃない
ある時刻から1日後の時刻を出したいときにどのようにしたらいいでしょうか?
var dateFormatter = DateFormatter()
dateFormatter.locale = .init(identifier: "ja_JP")
dateFormatter.dateStyle = .short
dateFormatter.timeStyle = .short
// 現在時刻
let date1 = Date()
print(dateFormatter.string(from: date1))
// 1日後の時刻
let date2 = date1.addingTimeInterval(24 * 60 * 60)
print(dateFormatter.string(from: date2))
上記のコードでは現在時刻に24時間分の秒数を足しています。これを実行すると以下のように出力されます。
2023/12/06 16:28
2023/12/07 16:28
一見正しいように見えますし、多くの場合はこれで問題なく動作すると思います。
ではこれが「ニューヨーク時間 2023/11/04 12:00」の1日後だったらどうでしょうか?
let timeZone = TimeZone(identifier: "America/New_York")!
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = timeZone
var dateFormatter = DateFormatter()
dateFormatter.timeZone = timeZone
dateFormatter.locale = .init(identifier: "ja_JP")
dateFormatter.dateStyle = .short
dateFormatter.timeStyle = .short
var dateComponents = DateComponents()
dateComponents.year = 2023
dateComponents.month = 11
dateComponents.day = 4
dateComponents.hour = 12
// ニューヨーク時間 2023/11/04 12:00
let date1 = calendar.date(from: dateComponents)!
print(dateFormatter.string(from: date1))
// 1日後の時刻?
let date2 = date1.addingTimeInterval(24 * 60 * 60)
print(dateFormatter.string(from: date2))
上記を実行すると以下のように出力されます。
2023/11/04 12:00
2023/11/05 11:00
1時間ずれてしまっています!何が起きたのでしょうか。
これが悪名高きサマータイムの罠です。ニューヨークが属するEST(米国東部標準時)では、2023年11月5日 2:00にサマータイムが終了し、時計が1時間戻されます。なので1日後の12:00は25時間後になるということです。逆にサマータイムが開始されるタイミングでは時計が1時間進められ、1日が23時間になります。
このような場合はタイムゾーンを考慮した正しい日付計算をする必要があります。Swiftでは Foundation.Calendar
の日付計算メソッドを使うのが定石になります。
// 1日後の時刻
let date2 = calendar.date(byAdding: .day, value: 1, to: date1)!
print(dateFormatter.string(from: date2))
例えば上記のように書くと正しい結果が得られます。
2023/11/04 12:00
2023/11/05 12:00
政治によって時間が変わる!?
サマータイムが実施されるのかされないのか、されるとして何月何日の何時に切り替わるのかは地域によって違っていて、同じ地域でも導入したり廃止したりしています。つまり時間というものは政治によって変わってしまうということです。
以前TimeTreeのiOSアプリでレバノン、ベイルート時間(Asia/Beirut)だとクラッシュするという不具合が起きたことがあります。これはこのタイムゾーンのサマータイムの切り替え時刻が0:00だったため、切り替えのタイミングで1:00に時計が進み、その日の0:00という時刻が存在しないという状況で発生する不具合でした。1日の始まりが「0:00」とは限らないんです。
また、メキシコのチワワ州にお住まいのユーザーさんから時間がずれているというお問合せをいただいたことがあります。この地域では2022年10月にサマータイムが廃止されていました。このような変更はTime Zone Databaseに反映され、それがiOSなどシステムに反映されることで正しく動作するようになるのですが、ユーザーさん側のシステムアップデートまでにどうしてもタイムラグが発生してしまいます。このときはiOSのバージョンごとに挙動を確認してiOS 16.1.1でサマータイム廃止が反映されていることを確認し、OSアップデートを実施いただくようにご案内しました。
終わりに
タイムゾーンとサマータイムにまつわる、知らないとハマりがちなポイントをご紹介しました。「こわっ」と思った方はぜひ今後の開発に活かしていただければ幸いです。
こちらを読んでTimeTreeに少しでも興味を持っていただけたなら、ぜひ以下のページもチェックしていただければと思います。カジュアル面談などお気軽にお申し込みください!