LoginSignup
0
2

[Swift] 1秒を正確に刻む

Last updated at Posted at 2024-02-11

Swiftのインターバルタイマーの精度は高い

毎秒インベントを欲しい場合、次のAPIを使用すると思いますが、どちらのAPIも、正確にタイマー処理が呼ばれることが分かりました。その精度は誤差ほぼ1ミリ秒未満です。

Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true, block: timerEvent)
// OR
Timer.publish(every: 1.0, on: .main, in: .common)

精度は、タイマー処理内で 現在時刻と秒未満のナノ秒を求めることで分かります。
秒未満のナノ秒は、$10^9$で割って秒に変換します。

let nanosecond = Calendar.current.component(.nanosecond, from: Date.now)
let lessThanSeconds = Double(nanosecond) / 1e9

正確に1秒を刻むということは、この秒未満の値が、毎回、ほぼ同じ値であることを意味します。
以下のコードで確認できます。

for Playground
import Foundation
let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true, block: { _ in
    let now = Date.now
    let nanosecond = Calendar.current.component(.nanosecond, from: now)
    let lessThanSeconds = Double(nanosecond) / 1e9
    print(now.formatted(date: .omitted, time: .standard), lessThanSeconds)
})
//RunLoop.current.run()
for SwiftUI(平均値あり)
import SwiftUI

struct ContentView: View {
    let timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()

    @State var currentTime = Date.now
    @State var lessThanSeconds = 0.0
    @State var avarage = 0.0
    @State var count = 0
    @State var sum = 0.0

    var body: some View {
        VStack {
            HStack {
                Text("現在時刻: ")
                Text(currentTime.formatted(date: .omitted, time: .standard))
            }
            HStack {
                Text("秒未満: ")
                Text("\(lessThanSeconds)")
            }
            HStack {
                Text("平均: ")
                Text("\(avarage)")
            }
            HStack {
                Text("平均差: ")
                Text("\(abs(lessThanSeconds - avarage))")
            }
        }
        .frame(width: 200)
        .padding()
        .onReceive(timer) { _ in
            currentTime = Date.now
            lessThanSeconds = Double(Calendar.current.component(.nanosecond, from: currentTime)) / 1e9
            sum += lessThanSeconds; count += 1
            avarage = sum / Double(count)            
        }
    }
}

毎秒、秒未満の値の平均値を取ると、毎回の差がほぼ1ミリ秒しか無いことが分かります。以外なほど正確です。正直、これほど精度が高いとは思っていませんでした。

"every hour" & "on the hour" の "秒"版

実は、ここからが、この記事の本当の目的です。

("on the hour")"正時"とは、1時0分0秒, 2時0分0秒, 3時0分0秒 の様に、"分"と"秒"がちょうど 0 の時刻を指しますが、これの"秒"版、a時b分0.000秒、a時b分1.000秒、a時b分2.000秒、a時b分3.000秒、・・・の様に、"ちょうどの秒"にイベントが欲しい場合を考えます。
(ここでは、"正時"ならぬ"正秒"と呼ぶことにします)

前項で説明した通り、1秒(毎秒)はほぼ正確に刻むことが分かりましたので、このタイマーイベントの開始を限りなく、 x.000秒に起動できればよい ということになります。

そこで、次の処理を考えました。

現在時刻(a)を秒未満のナノ秒まで求め、"次の秒"の開始(b)までの残り時間を求める。その時間だけ待ってからタイマーを起動する。

  • (a) hh:mm:ss.zzzzzz
  • (b) hh:mm:(ss + 1).000000
  • (c) "次の秒"の開始までの残り時間 = (b)-(a)

以下のコードで実装できます。

let nowInterval = Date.now.timeIntervalSinceReferenceDate //(a)
let nextSecond = floor(nowInterval + 1) //(b)
let remainSecond = nextSecond - nowInterval //(c)
Thread.sleep(forTimeInterval: remainSecond) //wait
//start timer ((c)秒待てタイマーを起動する)
let currentTime = Date.now
let lessThanSeconds = Double(Calendar.current.component(.nanosecond, from: currentTime)) / 1e9
print(currentTime.formatted(date: .omitted, time: .standard), lessThanSeconds)

このコードを何回か実行してみると、最後のprintの結果は、毎回ほぼ5ミリ秒以内の値となります。つまり、毎回(毎秒) hh:mm:ss.005xxxという結果であり、ピッタリx.000秒とはいきませんが、x.005秒(5ミリ秒)程度なら十分な精度だと思います。

前出のSwiftUIのコードに組み込みます。

SwiftUI
import Combine

struct ContentView: View {
    //let timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
    let timer = startTimer()

  (中略

    private static func startTimer() -> Publishers.Autoconnect<Timer.TimerPublisher> {
        let offset = 0.18
        let nowInterval = Date.now.timeIntervalSinceReferenceDate
        let nextSecond = floor(nowInterval + 1)
        var remainSecond = nextSecond - nowInterval
        if remainSecond > offset { remainSecond -= offset }
        Thread.sleep(forTimeInterval: remainSecond)
        let timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
        //print(nowInterval, remainSecond)
        return timer
    }
}

Timer.publish(every:on:in:).autoconnect()のオーバーヘッドが大きいためか、200ミリ秒以上の誤差(x.200秒)となっていました。プレビューだと、30ミリ秒程度の誤差(x.030秒)。
そこで、オーバーヘッド分として0.18秒を加味するようにoffsetを定義してみました。

例えば、時計アプリを作成する場合、1秒間に何回も描画することは無駄であるため、今回の"正秒"イベント処理を使い、数十ミリ秒程度の精度を期待したい(毎秒x.030秒に描画する)。
上記のコードでは、オフセット0.18秒とすることで、ほぼこの精度となった。

いや 待てよ、このオフセット値を自動計算できないか?

ダミーのタイマーイベントを開始して、イベント発生までの遅延時間を実測することで、このオフセット値を自動計算できるはずだ。

オーバーヘッドの自動計算

Timer.publish(every:on:in:).autoconnect()のオーバーヘッドを計測するため、ダミーのタイマーイベントを起動し、最初のイベントが起動されたときの時刻の差からオーバーヘッドを計算します。
そして次に、"次の秒"の開始までの残り時間に、求めたオーバーヘッドを加味した時間だけ待って、本物のインターバルタイマを起動します。

import SwiftUI

struct ContentView: View {
    enum TimerMode {
        case dummy(Date) //遅延時間を計測するためのダミーイベント
        case genuine //本物のタイマーイベント
        case initial //(初期値)これもダミー
    }

    @State var timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
    @State var timerMode = TimerMode.initial
    @State var currentTime = Date.now
    @State var lessThanSeconds = 0.0
    @State var avarage = 0.0
    @State var count = 0
    @State var sum = 0.0

    var body: some View {
        VStack {
            HStack {
                Text("現在時刻: ")
                Text(currentTime.formatted(date: .omitted, time: .standard))
            }
            HStack {
                Text("秒未満: ")
                Text("\(lessThanSeconds)")
            }
            HStack {
                Text("平均差: ")
                Text("\(abs(lessThanSeconds - avarage))")
            }
        }
        .frame(width: 200)
        .padding()
        .onAppear {
            //ダミーイベントを起動
            timerMode = .dummy(Date.now)
            timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
        }
        .onReceive(timer) { _ in
            switch timerMode {
                case .initial: timer.upstream.connect().cancel() //捨て
                case .dummy(let startDateTime):
                    timer.upstream.connect().cancel()
                    let delaySeconds = -startDateTime.timeIntervalSinceNow - 1.0 //オーバーヘッド実測値
                    
                    let nowInterval = Date.now.timeIntervalSinceReferenceDate
                    let nextSecond = floor(nowInterval + 1) //"次の秒"
                    let remainSecond = nextSecond - nowInterval - delaySeconds //オーバーヘッドを加味
                    Thread.sleep(forTimeInterval: remainSecond)

                    //本物のタイマーイベントを起動
                    timerMode = .genuine
                    timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
                    //print(Date.now.formatted(date: .omitted, time: .standard), delaySeconds)
                    
                case .genuine:
                    //本物のタイマーイベント
                    currentTime = Date.now
                    lessThanSeconds = Double(Calendar.current.component(.nanosecond, from: currentTime)) / 1e9
                    sum += lessThanSeconds; count += 1
                    avarage = sum / Double(count)
                    //print(currentTime.formatted(date: .omitted, time: .standard), lessThanSeconds)
            }
        }
    }
}

この結果、数ミリ秒〜20ミリ秒程度の範囲となり、期待する精度に収めることができました。(ただし、逆にプレビューの場合の精度は悪くなりました。)

以下は、上記コードの実行結果の動画。

画面録画したタイミングは、何故か、1ミリ秒未満と特に精度が高かった。

プレビューやPlaygroundだと、何故かこの精度にはなりません。
ビルドしたMacAppを録画しました。iOSアプリでもOK。

(上記コードでは省略したが、記録した動画においては、"数字"のみ等幅フォントを使用している。)

おわりに

今回の試みのきっかけは、偶然 目にした「Swiftで単純な時計を作る」記事が0.1秒のインターバルタイマを使っていたことである。

PCが秒を刻む中、1秒のインターバルタイマだと、最大で限りなく1秒遅れの表示となる可能性があるため、1秒間に10回描画すれば安全だという発想である。確かにそうすれば、最大でも0.1秒遅れの表示となり、人間の感覚であれば、ほぼリアルタイムであると言ってもよいとは思うが、CPUリソースを無駄に消費するので、エコでは無い。

1秒のインターバルタイマを使い、かつ、"正秒"にイベントが発生するなら、表示遅れの問題も解消される。そんな発想から考えた内容である。

なお、説明が下手で、うまく伝わるか不安が残るが、何かの参考になれば幸いです。

以上

0
2
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
0
2