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秒を刻む
ということは、この秒未満の値が、毎回、ほぼ同じ値
であることを意味します。
以下のコードで確認できます。
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()
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のコードに組み込みます。
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秒のインターバルタイマを使い、かつ、"正秒"にイベントが発生するなら、表示遅れの問題も解消される。そんな発想から考えた内容である。
なお、説明が下手で、うまく伝わるか不安が残るが、何かの参考になれば幸いです。
以上