✍️

コードで確認するタイマーの基本的な使い方

2022/06/01に公開

初心者がタイマーをアプリに利用しようと考えている際に求めていることは、一定の時間が経過したらなにかしらの処理を行い、それを繰り返す動作だと思います。SwiftではTimerクラスがその役割を担えます。

実際に使ってみるとわかりますが、Timerクラスは実行ループというものを理解していないと考えていた通りに動きません。そこで本記事では、Timerクラスについての基本的な使い方と合わせて、Run Loops(実行ループ)についても初心者の目線でまとめました。

間違いがあれば指摘していただけるとありがたいです。

タイマーの起動について

scheduledTimer

タイマーを生成し、デフォルト形式で現在の実行ループに追加するTimerクラスのクラスメソッドです。わかりやすく言えば、このクラスメソッドのみでタイマーとしての機能が利用できます。

定義

一例として、上記メソッドの定義は次の通りです。

class func scheduledTimer(withTimeInterval interval: TimeInterval, 
                  repeats: Bool, 
                    block: @escaping (Timer) -> Void) -> Timer

Creates a timer and schedules it on the current run loop in the default mode.

使い方

var timer = Timer
    .scheduledTimer(
        withTimeInterval: 1.0,
        repeats: true
    ) { _ in
        print("実行しました")
    }

このようにTimerクラスのクラスメソッドであるscheduledTimer()を呼ぶことで、タイマーが実行できます。インスタンスの生成だけではタイマーは機能せず、run loops(実行ループ)に追加されることが必要です。後述しますが、タイマーの本質はこの実行ループになります。

scheduledTimer()はその両方を担っているので、上記のコードだけでタイマーを使い始められるのでとても簡単です。ちなみに引数のパターンはいくつかあり、クロージャではなくセレクターを指定するメソッドなど、用途によって使い分けられます。

またタイマーをストップする場合には、インスタンスメソッドであるinvalidateメソッドを呼びます。

var timer = Timer
    .scheduledTimer(
        withTimeInterval: 1.0,
        repeats: true
    ) { _ in
        print("実行しました")
    }

DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
    timer.invalidate()
}

タイマーが動いているかどうかはインスタンスメソッドであるisValidメソッドで確認できます。

var timer = Timer
    .scheduledTimer(
        withTimeInterval: 1.0,
        repeats: true
    ) { _ in
        print("実行しました")
    }

DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
    timer.invalidate()
    timer.isValid //invalidateで破棄されているためfalse
}

いずれの場合もデバッグエリアはおそらくこのような形になります。

実行しました
実行しました
実行しました
実行しました
実行しました
実行しました
実行しました
実行しました
実行しました
実行しました
実行しました
//10秒経過する前後でinvalidateが実行されて終了

ちなみに、上記は変数にインスタンスを代入していますが、Timerクラスを利用する上で必ずしも変数を利用しなければならないわけではありません。後述するように実行ループに追加されると強参照で保持されるので、コード上でインスタンスを保持しなくても機能します。その場合、クロージャやセレクター内部で条件を記述して自身を参照する形でinvalidateメソッドを呼び出す必要があります。

実行ループによる強参照について

参照に関してはドキュメントに明確に書かれているので、まずは確認しておきます。

Timers work in conjunction with run loops. Run loops maintain strong references to their timers, so you don’t have to maintain your own strong reference to a timer after you have added it to a run loop.

Timerクラスが実行される際はrun loops、いわゆる実行ループと呼ばれるシステムに結びついており、実行ループがTimerクラスへの強参照を保持しています。したがって、一度実行ループに結びつけてしまえば、コード上であえて強参照を設ける必要はないといったことが書かれています。そのため、上述したように変数を利用せずに次のような書き方でタイマーを利用することができます。

変数に代入しない場合

Timer
    .scheduledTimer(
        withTimeInterval: 10.0,
        repeats: true
    ) { _ in
        print("実行しました")
	timer.invalidate()
	timer.isValid 
    }

実行ループに関してここではiOSの基本的なシステムの一部で、アプリでも起動時からそのループするシステムが走っていると理解しています。そのループしているシステム上にTimerクラスを載せることで時間の処理が始まるといった感じではないでしょうか。

流しそうめんに、そうめん入れたら流しそうめんになるみたいな...いや、例えが下手くそか。

ループしている一般のシステムに載せていることで強参照が保持されているわけですが、逆に一般のシステムである以上インスタンスが解放されるようなものではないため、都合よく参照がなくなることはありません。アプリをバックグラウンドにしても強参照のためにループし続けてメモリを圧迫するということも考えられます。そのため、参照は考慮しなくてよいものの、インスタンスの破棄に関しては理解しておく必要があります。

実行ループに関するドキュメント
https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/Multithreading/RunLoopManagement/RunLoopManagement.html#//apple_ref/doc/uid/10000057i-CH16-SW1

タイマーを止めるには .invalidate()

run loopsからの強参照含めた破棄になり、結果としてタイマーが止まります。
そのためのメソッドがTimerクラスのインスタンスメソッドである.invalidate()です。
実は実行順序の項ですでに利用しています。

var timerRunCount = 0
var timer = Timer
    .scheduledTimer(withTimeInterval: 1.0,
                    repeats: true) {
        _ in
        print("実行ループ")
        timerRunCount += 1
        
        if timerRunCount == 5  {
            timer.invalidate()
        } else {
            print("timerRunCount: \(timerRunCount)")
        }
    }

これを実行してみると、次のように確かにcountが5になる時点でループが終了しています。

実行ループ
timerRunCount: 1
実行ループ
timerRunCount: 2
実行ループ
timerRunCount: 3
実行ループ
timerRunCount: 4
実行ループ
//終了

ちなみにTimerクラスをより効果的に扱うには、run loopへの理解が必要で、そのためにはThreading Programming Guideを読んでくれとドキュメントには書かれています。

なかなかパッとすぐに読んで理解できる英文量ではないですね...
https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/Multithreading/RunLoopManagement/RunLoopManagement.html#//apple_ref/doc/uid/10000057i-CH16-SW1

タイマーとして実行される順序について

あまり意味がないのですが、次のコードの実行順序について記載しておきます。

var timerRunCount = 0
var timer = Timer
    .scheduledTimer(withTimeInterval: 1.0,
                    repeats: true) {
        _ in
        print("実行ループ")
        timerRunCount += 1
    }

//カウントが1だったらTimerを破棄する
timerRunCount == 1 ? timer.invalidate() : print("timerRunCount: \(timerRunCount)")

これは結果としてデバッグエリアには次のように表示されます。

timerRunCount: 0
実行ループ
実行ループ
実行ループ
実行ループ
実行ループ
.
.
.
実行ループ

といった形で永遠にループすることになります。

timerは、引数withIntervalで指定された1.0秒間隔で実行されています。そして完了時にクロージャ(trailing closure)が呼ばれ、まずprint文が実行されます。続いて、変数のtimerRunCountがインクリメント、1が加算されるという処理が行われます。しかし、これらが実行されるのは1.0秒後です。コードの処理として実際に先に行われるのは、クロージャを抜けた先にある三項演算子によるtimerRunCountの分岐処理です。その後、1.0秒経った時点でtimerに渡されているクロージャが実行されて、晴れて無限ループに入ってデバッグエリアの表示になるわけです。

ちなみに初回のクロージャの実行に関しては即時に行われるのか、withIntervalの秒間が空いてから行われるのかどっちだっけ?とたまにわからなくなることがあったのですが、次のように考えると私は腑に落ちました。

スケジュールした時点で実行されて、それからwithIntervalに設定した秒間を置いて実行されると考えてもおかしくないように感じてしまいます。仮にここでwithInterbalを3.0、repeatfalseにし他として、Timerクラスのスケジュール時に即クロージャが実行されるしたらどうなるでしょうか。repeatfalseである以上、繰り返しはないのですからTimerクラスのwithIntervalの意味もなくなってしまいます。だから使い方の観点からみると、初回の起動もwithInterval後であるのが必要不可欠というわけです。

もし本来の処理を正しく実行したければ、たとえば次のようなコードが一例です。

var timerRunCount = 0

var timer = Timer
    .scheduledTimer(withTimeInterval: 1.0,
                    repeats: true) {
        _ in
        print("実行ループ")
        timerRunCount += 1
        timerRunCount == 10 ? timer.invalidate() : print("timerRunCount: \(timerRunCount)")
   }
実行ループ
timerRunCount: 1
実行ループ
timerRunCount: 2
...()
実行ループ
timerRunCount: 8
実行ループ
timerRunCount: 9
実行ループ

同一の変数に対して異なるインスタンスを代入し実行ループに追加した場合

私はこの辺り理解せずに使ってしまっていて、タイマーの挙動がわからなくなっていました。題だけ読んでもわかりづらいとは思いますが、下記の記事がとても整理されていてわかりやすいです。

https://qiita.com/KikurageChan/items/5b33f95cbec9e0d8a05f#オブジェクトを保持する

要点は、同一の変数に対して重ねてTimerを作りスケジュールした場合、いずれのTimerも実行ループで保持されてしまうため機能するということになります。後から重ねた方だけが活きるというわけではないので、注意が必要です。これは適切にinvalidateしてタイマーを破棄しなければならないということでもあります。

上記のQiitaの記事がとても整理されているので、二番煎じではあるのですが試してみました。

import Dispatch

var timer = Timer
    .scheduledTimer(
        withTimeInterval: 1.0,
        repeats: true
    ) { _ in
        print("1秒後に実行しました")
    }

timer = Timer
    .scheduledTimer(
        withTimeInterval: 2.0,
        repeats: true
    ) { _ in
        print("2秒後に実行しました")
    }

DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
    while timer.isValid {
        print("before: \(timer.isValid)")
        timer.invalidate()
        print("after: \(timer.isValid)")
    }
}

これを実行するとどうなるか

1秒後に実行しました
1秒後に実行しました
2秒後に実行しました 
1秒後に実行しました //被せても実行ループに追加されているためタイマーは生きている
1秒後に実行しました 
2秒後に実行しました
1秒後に実行しました
before: true    //timerのインスタンスが確認できる
after: false      //timer.invalidate()で破棄されたのでfalse
1秒後に実行しました //timerは被ってきたものが反映される
1秒後に実行しました  //invalidateで実行ループからも破棄される
...                          //結果、先に実行ループに追加されていたタイマーだけが残る
...
1秒後に実行しました  

といったようになります。
ちなみにここでは while timer.isValid { }としているので、変数timerがインスタンスを抱えている以上、処理が繰り返されてinvalidateされるはずですが、デバッグエリアの通り破棄されているのは一つのタイマーだけです。当たり前ですが、配列のような型ではないので被せてしまったら失われるわけです。一方で、実行ループに追加されたタイマーは動き続けます。while文でインスタンスがなくなるまでとするのも多分意味がない。そして、おそらく初心者のうちは変数に被せることは行わない方がよいのだと思います。

実行ループに重複がないか確認した上での処理を行う

上記のように変数に対してTimerクラスの重ねがけをし、実行ループにてどちらも機能してしまう以上、作る側でそれをコントロールできるのが良さそうです。その場合、例えばインスタンスメソッドであるisValidを利用して次のような記述もありうるのではないかと考えます。

var timer = Timer
    .scheduledTimer(
        withTimeInterval: 1.0,
        repeats: true
    ) { _ in
        print("実行しました")
    }

//変数timerがtrueでなかったら処理を行う
if  !timer.isValid {
        //変数にTimerクラスのインスタンスを代入して実行ループに追加
    timer = Timer
        .scheduledTimer(
            withTimeInterval: 1.0,
            repeats: true
        ) { _ in
            print("タイマーを再度実行しました")
        }
}

DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
    timer.invalidate()
    timer.isValid
}

上記の場合は設けられているので重ねがけが起こりません。

Run Loops(実行ループ)とは

https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/Multithreading/RunLoopManagement/RunLoopManagement.html#//apple_ref/doc/uid/10000057i-CH16-SW1

今後整理して記事にします

Discussion