📘

Androidで「1秒後に実行する」時に出てくるHandlerは何をしているのか?

に公開

Androidアプリ開発では、例えば 1秒(1000ミリ秒)後に処理を実行したい場合、次のような方法を使うことがあります。

kotlin
Handler(Looper.getMainLooper()).postDelayed({
    Log.d("hogehuga", "1秒後に実行されました")
}, 1000)

このコードがどのように動作しているのかは、Javaでの記述を見るとより理解しやすいと思います。

java
new Handler(Looper.getMainLooper()).postDelayed(new Runnable() {
    @Override
    public void run() {
        Log.d("hogehuga", "1秒後に実行されました");
    }
}, 1000);

他の言語の経験がある人にとっては、1秒待機するだけでなぜこれだけ複雑な記述が必要なのか? Handler とは?Looper.getMainLooper() とは?という気持ちになるかもしれません。これは、Android のスレッドやメッセージループの仕組みを踏まえて考えると、より腑に落ちると思います。

そこで今回は、Androidの内部でどのような流れになっているのかを解説していきます。Androidを支える技術〈I〉 を参考しているため、一部古い箇所があるかもしれません。

(近年では Handler を直接使うよりも、Kotlin Coroutines を用いて待機処理を書く方法が推奨されており、より安全とされています。)
*おまけ: 現代の開発では Handler よりも Kotlin Coroutines で書くべき理由

UIスレッドとは?

Android において、例えば「TextViewのテキストを変更する」「Toastを表示する」といったGUI関連の処理を行う時は、UIスレッド(メインスレッドとも呼ばれる)で実行する必要があります。

ここで、スレッドはプログラム内の処理を実行する1つの場所のようなもので、UIスレッドは基本的に同時に1つの処理しか実行できません。そのため、以下のようなコードを書くと、1秒間待機するどころか1秒間アプリが何も操作できない状態(= フリーズ)してしまいます。

kotlin
runBlocking {
    println("A")
    delay(1000)
    println("B")
}

そのため、時間のかかる処理を実行する時は、別のスレッドに分けた上で実行することが重要になります。

メッセージループ

Android のUIスレッドは、Looper と呼ばれる仕組みを使ってメッセージループを回しています。
メッセージループとは、キューに溜まった処理(Runnable)をひたすら順番に取り出して実行していく仕組みのことです。while文のようなループをイメージしてもらえれば OK です。

flow.png

Android ではLooperがこのループを回していて、アプリが動作している間、UIスレッド上で延々に繰り返されています。

そこに Handler を使って「○○ミリ秒(ms) 後にこの処理を実行して」と命令すると、その処理がMessageQueueにタイムスタンプ付きで登録され、順番が来たら実行される という流れになります。

Handler

まず最初に、Handler のpostDelayedを呼ぶときには、Runnableオブジェクトを引数に渡します。Kotlinの場合は、ここをラムダ式で書くことができます。

java
new Handler(Looper.getMainLooper()).postDelayed(new Runnable() {
    @Override
    public void run() {
        Log.d("hogehuga", "1秒後に実行されました");
    }
}, 1000);

その後、Handler はRunnableオブジェクトをMessageに詰め込み、「何ミリ秒後に実行するか」を示すタイムスタンプを付与します。
今回の例では、1000ミリ秒に実行すべきメッセージとして、このRunnableを保持したメッセージがMessageQueueに登録されます。

handler.png

https://developer.android.com/reference/android/os/Handler

Looper

LooperはAndroidにおけるメッセージループを提供するクラスです。
MessageQueueはその名の通りキュー(Queue)の構造を持ち、メッセージを順番に管理する役割を担っています。

MessageQueuenext()を呼び出すと、今回の例ではMessage1が先に積まれていても、発火するまでの時間の短いMessage2が優先的に取り出されます。

looper.png

Looper自身はスレッドを生成せず、呼ばれたスレッドでメッセージループを回します。
AndroidのUIスレッドにおけるLooperは、AOSP (Android Open Source Project) のコードを見るとActivityThreadの中で以下のように呼ばれています。[1]

ActivityThread.java
public static void main(String[] args) {
    // ~略~
    Looper.prepareMainLooper();
    // ~略~
    if (sMainThreadHandler == null) {
        sMainThreadHandler = thread.getHandler();
    }

    if (false) {
        Looper.myLooper().setMessageLogging(new
                LogPrinter(Log.DEBUG, "ActivityThread"));
    }
    // ~略~
    Looper.loop();
    throw new RuntimeException("Main thread loop unexpectedly exited");
}

https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/app/ActivityThread.java

ここで登場する主なメソッドはLooper.prepareMainLooper()Looper.loop()の2つです。

Looper.prepareMainLooper()を呼び出した後、同じスレッド内で Handler のインスタンスを生成すると、その Handler はメインのLooperとして紐づけられ、メッセージキューを利用できるようになります。

実際に、thread.getHandler()を見ると、mHの中に Handler のインスタンスが生成されて、格納されているように見えます。

ActivityThread.java
if (sMainThreadHandler == null) {
    sMainThreadHandler = thread.getHandler();
}
ActivityThread.java
final H mH = new H();
// ~略~
public Handler getHandler() {
    return mH;
}

Looper.loop()では終了メッセージが届くまで無限ループを続けます。
MessageQueueにメッセージが投入されると、loop()を呼び出したスレッドと同じスレッド内で処理が実行されます。

https://developer.android.com/reference/android/os/Looper

https://learn.microsoft.com/en-us/dotnet/api/android.os.looper.preparemainlooper?view=net-android-36.0

おわりに

今回の記事では、UIスレッドとメッセージループの仕組みについて、その中心となる Handler にフォーカスして解説しました。Handler や Android 内部のより詳しい仕組みについては、Androidを支える技術〈I〉に分かりやすく解説されています。興味を持った方はぜひ手に取って読んでみてください!

もし間違っているところがありましたら、コメントにて教えていただけると大変助かります。

おまけ: 現代の開発では Handler よりも Kotlin Coroutines で書くべき理由

ここまで解説してきた Handler には、1つ大きな問題があります。それは「Android のライフサイクルを認識できない」という点です。

例えば、次のように「5秒後にTextViewのテキストを"Done"に変更する」コードを考えてみます。

Handlerを使った方法
val handler = Handler(Looper.getMainLooper())
val button = view.findViewById<Button>(R.id.button)

button.setOnClickListener {
    handler.postDelayed({
        // View破棄後に呼ばれると requireView() がクラッシュ
        val textView = requireView().findViewById<TextView>(R.id.message)
        textView.text = "Done"
    }, 5000)
}

このとき、5秒の待機中にユーザーの操作によって、バックグラウンドに入った、画面が回転した ...等で Activity が非表示・破棄されても、Runnableは依然としてキューに残り続けます。
その結果、IllegalStateExceptionが起きてしまったり、Activity がガーベジコレクションで解放されるまで参照され続けて、メモリリークの原因となる可能性があります。

そのため、今までは 自力でスレッドの管理をする といった回避策が必要でした。

Kotlin Coroutines はこの問題の解決策となります。特定のスレッドに縛られることなく処理を記述でき、UIスレッドをブロックしません。また、lifecycleScope を利用することで、Activity のライフサイクルに応じて Coroutine を自動キャンセルできるため、画面回転や Back 操作後に古い処理が残ってしまうリスクを防げます。

Coroutinesを使った方法
val button = view.findViewById<Button>(R.id.button)
val textView = view.findViewById<TextView>(R.id.message)

button.setOnClickListener {
    lifecycleScope.launch {
        delay(5000)
        textView.text = "Done"
    }
}

バックグラウンドに入った場合も含めて処理を制御したい場合は、repeatOnLifecycleを併用することで解決できます。

以下のコードでは、アプリが表に出ている(フォアグラウンド)時だけ、5秒待機が動作します。onStopが呼ばれてバックグラウンドに移動した時点で自動的にキャンセルされ、再びonStartに入ってフォアグラウンドに戻ると再起動されます。

Coroutinesを使った方法(フォアグラウンド時のみ)
button.setOnClickListener {
    lifecycleScope.launch {
        repeatOnLifecycle(Lifecycle.State.STARTED) {
            delay(5000)
            textView.text = "Done"
        }
    }
}

おまけ2: Androidのトーストにも使われているHandler

Androidで見かけるトーストも内部ではHandlerが使われています。

adb logcat を見ると、X(旧Twitter)のアプリを閉じた直後に、トーストを消すコールバックが実行されて警告が出ることがあります。スタックトレースを追うと、Handler.dispatchMessage が呼ばれ、その後LooperからActivityThreadに渡されている様子が確認できて面白いですね。

参考文献

https://gihyo.jp/book/2017/978-4-7741-8759-4

https://medium.com/@raya.wahyu.anggara/android-thread-43b30b61dee6

脚注
  1. if (false) { ... } ってなんだ...... ↩︎

Discussion