LoginSignup
46
37

More than 1 year has passed since last update.

Android に処理を完遂させる

Posted at

はじめに

以前書いた記事で「Android アプリは OS の都合で勝手に終了する」ということを書きました。

今回はその話を少し掘り下げて、「どんなときにアプリが終了するのか」「ではどうやって処理を完遂させれば良いのか」を書いてみようと思います。

先に結論

長いので先に結論だけ言うと「ちゃんと Service を使おう!怖くないよ!」という話です。

非同期処理で処理を完遂できるのか?

皆さんは「時間のかかる連続した処理」を Android アプリに実装したことはあるでしょうか?
Android アプリ開発者の皆さんの多くは「ある」と回答されるのではないかと思います。

Android アプリの開発では頻繁に非同期処理の実装が求められます。Android では UI を操作するためにメインスレッドが使われるため、UI 関連の処理をブロックする可能性がある「時間のかかる処理」は別のスレッドで実行する必要があるためです。代表的な非同期処理(別スレッドで実行する処理)としてはネットワーク通信が挙げられますが、ローカルストレージの読み書きもそれなりに時間がかかるので非同期化することはありますし、時間のかかる計算をさせたい場合もそれ用のスレッドで実行することはあるでしょう。

でも、そういう非同期処理で「時間のかかる連続した処理」を完遂させることは可能なのでしょうか?

たとえば「重要なデータをサーバに送信する。通信に失敗した場合は成功するまで繰り返す」という処理があったとして、この処理は通信環境が劣悪な場合はかなり時間がかかる可能性があります。
あるいは「ある科学技術計算を実行して結果をローカルストレージに保存する。計算には数分かかる」という場合もあるでしょう。

これらの処理は「最後まで完遂する」ことが重要になります。
でも「完遂」する前にアプリが終了してしまったら、処理途中の非同期処理はどうなってしまうのでしょうか?
そもそも Android アプリで時間のかかる処理を「完遂」させるなんて可能なのでしょうか…?

そもそもアプリの終わりとは?

皆さんはアプリを開発する際に「アプリの終了」を意識しているでしょうか?

え?Activity が破棄されたときじゃない?

なるほど、アプリで生成したすべての Activity が破棄されたタイミングで「アプリが終了した」と考えるのも有りだと思います。
ではちょっとした実験をしてみましょう。

Activity が破棄されたら処理は終わるのか?

Activity が破棄されたタイミングですべての処理が終わるのかを見るために以下の単純なコードを実行してみます。

MainActivity.kt
class MainActivity : AppCompatActivity() {
    private val tag = this::class.simpleName

    private val handler = Handler(Looper.getMainLooper())
    private var count = 0

    private val task = object : Runnable {
        override fun run() {
            Log.d(tag, "count=$count")
            count++
            handler.postDelayed(this, 1000)
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        task.run()
    }

    override fun onPause() {
        super.onPause()
        Log.d(tag, "onPause:")
    }

    override fun onStop() {
        Log.d(tag, "onStop:")
        super.onStop()
    }

    override fun onDestroy() {
        Log.d(tag, "onDestroy:")
        super.onDestroy()
    }
}

このプログラムは Handler を使って1秒間隔で整数をカウントアップしログに出力するという単純なものです。これを適当な実機で実行すると以下のようにログ出力されていきます。

D/MainActivity: count=0
D/MainActivity: count=1
D/MainActivity: count=2

では Activity を破棄するためにバックキーを押してみましょう。

D/MainActivity: count=6
D/MainActivity: count=7
D/MainActivity: onPause:     ← バックキー押下
D/MainActivity: onStop:      ← Activity停止
D/MainActivity: onDestroy:   ← Activity破棄
D/MainActivity: count=8      ← 動き続けている
D/MainActivity: count=9
D/MainActivity: count=10

見ての通り、Activity が破棄された後もカウントアップが続いています。アプリ内の全 Activity が破棄されたからといって、すぐにアプリのプロセスが終了するわけではないということが分かります。

では Activity が破棄されてもアプリはずっと生き続けるのか?

そんなはずはありません。
先ほど試したプログラムも、バックキーを押して Activity を破棄させた後に他のアプリを使っていたりすると、不意に以下のようなログが出力されます。

D/MainActivity: count=30
D/MainActivity: count=31
D/MainActivity: count=32
I/ActivityManager: Force stopping com.ishihatta.terminationtest appid=10080 user=0: from pid 744
I/ActivityManager: Killing 9768:com.ishihatta.terminationtest/u0a80 (adj 903): stop com.ishihatta.terminationtest

見ての通り、アプリのプロセスがいきなり kill されています。当然それ以降はカウントアップのログは出力されません。
Android アプリの終了は、このように唐突に起こります。

スレッドを使っている場合はどうか?

ぐぬぬ、これまたとんでもなくヘンテコな挙動だな…。
あ、でもスレッド作って処理させている場合はどうなるんだ?

そうですね、当然の疑問だと思います。
冒頭にも書きましたが Android アプリの開発ではスレッドの利用が欠かせません。非同期処理はアプリ開発に欠かせない技術です。
非同期で実行する処理は、それなりに時間がかかる処理であることが前提となります。ですので非同期処理が実行されている最中に Activity が破棄されることも十分起こり得ることです。そのとき、つまり実行中の非同期処理が残っているような状況では、さすがにいきなり kill されるということはないのでは…?なんて思っちゃったりしてもおかしくないですよね!

というわけで実験してみましょう。以下のプログラムを実行してみます。

MainActivity.kt
class MainActivity : AppCompatActivity() {
    private val tag = this::class.simpleName

    override fun onCreate(state: Bundle?) {
        super.onCreate(state)
        setContentView(R.layout.activity_main)

        thread {
            var count = 0
            while (true) {
                Log.d(tag, "count=$count")
                count++
                Thread.sleep(1000)
            }
        }
    }
}

このプログラムはさっき試したカウントアッププログラムの動作を別スレッドで行うよう書き換えたものです。
このアプリを実機で実行し、先のプログラムのときと同様にバックキーを押して AActivity を破棄させ、その後他のアプリを適当に使っていたときのログが以下の通りです。

D/MainActivity: count=0
D/MainActivity: count=1
D/MainActivity: count=2
≪中略≫
D/MainActivity: count=5
D/MainActivity: count=6
D/MainActivity: onPause:     ← バックキー押下
D/MainActivity: onStop:      ← Activity停止
D/MainActivity: onDestroy:   ← Activity破棄
D/MainActivity: count=7      ← 別スレッドが動き続けている
D/MainActivity: count=8
D/MainActivity: count=9
≪中略≫
D/MainActivity: count=64
D/MainActivity: count=65
D/MainActivity: count=66
I/ActivityManager: Force stopping com.ishihatta.terminationtest appid=10080 user=0: from pid 744
I/ActivityManager: Killing 9768:com.ishihatta.terminationtest/u0a80 (adj 903): stop com.ishihatta.terminationtest

やっぱり kill されます。
別スレッドが残っていても、そんなことは関係なく、いきなり kill されてしまうのです。

全 Activity が破棄されたら kill されるということなのか?

む、むむぅ…。ということはアプリ内の全 Activity が破棄されたらそのうち kill されちゃうってことなのか…?

では Activity を破棄するのではなくバックグラウンドにしただけだったらどうでしょうか?
同じアプリを使って試してみましょう。今回はバックキーを押す代わりにホームキーを押してみます。バックキーが押された場合はデフォルトでは Activity を終了する扱いになりますがホームキーの場合は Activity は終了扱いにはならずバックグラウンド状態になります。

D/MainActivity: count=5
D/MainActivity: count=6
D/MainActivity: onPause:    ← ホームキー押下、バックグラウンド状態に遷移
D/MainActivity: onStop:     ← Activity停止
D/MainActivity: count=7
D/MainActivity: count=8
≪中略≫
D/MainActivity: count=24
D/MainActivity: count=25
I/ActivityManager: Force stopping com.ishihatta.terminationtest appid=10080 user=0: from pid 744
I/ActivityManager: Killing 14446:com.ishihatta.terminationtest/u0a80 (adj 700): stop com.ishihatta.terminationtest
I/ActivityManager:   Force finishing activity ActivityRecord{361b1ab u0 com.ishihatta.terminationtest/.MainActivity t173}

Activity#onDestroy() が呼ばれていません。たしかに Activity は破棄されていないようです。
でもアプリのプロセスは kill されています。

Android では一つのアプリがデバイスのリソースをずっと掴んでいるような状況を避けるため、わりと頻繁にアプリプロセスの強制終了(kill)が行われます。
Activity が残っているかどうかに関係なく、画面表示されていないアプリ(=フォアグラウンドな Activity や Service がないアプリ)のプロセスは、いつ kill されてもおかしくないということです( Service については後述します)。

Out of memory とどう違う?

なんだかよく分からなくなってきたぞ…。
でも「他のアプリを使っていると落ちる」っていうのは、普通にメモリ不足なんじゃない?

これは半分正解と言えなくもないです。でも PC 用のデスクトップアプリやサーバアプリで起こる Out of memory 例外とは異なります。

確かに PC 向けのデスクトップアプリやサーバアプリなどもメモリ不足でプロセスが落ちることがありますよね。そのこと自体は Android アプリでも起こり得ますが、先ほどまで見てきた「不意に kill される」現象はそれとは違うものです。

デスクトップアプリやサーバアプリで発生する「メモリ不足で落ちる」という現象は、通常、以下のような処理の中で発生します。

  1. アプリケーションが OS に対してメモリの割り当てを要求する。
  2. OS が要求された量のメモリを確保しようとして失敗し、アプリケーションに「失敗しちゃった☆」という情報を返す。
  3. 「失敗しちゃった☆」を受け取ったアプリケーションはその後の処理を継続できずやむなく終了する。

それに対してこれまで見てきた現象は以下のようなものです。

  1. OS (Android framework) がアプリの状態を監視し、適当なタイミングでアプリのプロセスを kill する。

つまりアプリがメモリ割り当てを要求していなくても OS の都合だけでサクッと kill されてしまうということです。そしてその「OS の都合」というのはメモリ不足だけではなく、CPU 時間やバッテリー消費を含めた端末リソース全体を最適化するという観点での判定になります。

つまりどういうこと…?

以上のことをまとめると以下のようになると思います。

  • 全 Activity が破棄されても即座にメインスレッドやサブスレッドが終了したりはしない。当然プロセスも残る。
  • でもしばらくすると、いきなりプロセスが kill される。そのタイミングはコントロールできない。
  • Activity が破棄されていない状態でも、Activity をバックグラウンド状態にしておくと、いきなりプロセスが kill されることがある。

以上から、以下のことが導き出されると思います。

「それなりに時間のかかる処理は、いつ kill されるか分からない前提で(いつ kill されても大丈夫なように)実装する必要がある」

そ、そんなアホな!!

昔ながらの、デスクトップアプリやサーバアプリを開発してきたプログラマからすると、このことは非常に取っつきにくい概念だと思います。そういうプログラマにとって、アプリケーションのプロセスが(ユーザ操作もなく)勝手に kill されるなんてことは滅多にないことです。でも Android では頻繁に起こります。アプリに不具合があるわけでもないのに、です。それが Android の世界なんです。

でもそれじゃあ、Activity が破棄されたりバックグラウンド状態に遷移したりしても処理を継続したい場合はどうすればいいの?

そういうときは Service を使いましょう。

Android における Service とは

サービスって常駐アプリケーションのことでしょ?
単に処理を持続させたいだけなのに、それはちょっと大げさなんじゃないかな…。

Windows や Linux の世界で「サービス」というと、多くの場合、OS 起動時に自動的に起動してバックグラウンドで動作する常駐型アプリケーションのことを指すと思います。それ自体が一つのアプリケーションなわけですから、単にアプリ内の一つの処理を継続させるためだけに使うのは、少し大げさなように思えるかも知れません。

ですが Android における Service は、そこまで大げさなものではありません。Android の Service は、通常、OS 起動時に自動的に起動することはありません(OS 起動時に自動実行する Service を作ることも可能です)。また Windows や Linux のサービスが「アプリケーションの一種」であるのに対して、Android の Service は「Activity と対等だけど画面がないもの」となります。

なぜ Activity と対等と言えるのかというと、画面の有無以外は性質がとても似ているからです。

  • Activity も Service も Context のサブクラスです。
  • Activity も Service も AndroidManifest.xml に登録する必要があります。
  • Context#startActivity() で Activity を起動できるのと同じように、Context#startService()(またはContext#startForegroundService())で Service を起動できます。
  • Service も Activity と同じように Intent を受け取る形で起動します。
  • Activity も Service も、一つのアプリ内に複数あって構いません。
  • Activity だけのアプリ、Service だけのアプリ、Activity と Service の両方あるアプリ、どれも作成可能です。
  • アプリ内のすべての Activity と Service で同じメインスレッドが使われます。

そのため Android アプリの開発では「画面を追加する」のと同じ感覚で「サービスを追加する」ことができます。また好きなタイミングで画面遷移できるのと同じように、好きなタイミングでサービスを起動できます。Android における Service は、あくまでアプリのいち部品に過ぎないんです。
基本的に、画面に依存せず(あるいは複数の画面にまたがって)時間のかかる処理をさせたい場合、その処理をまるっと Service 側で行うようにします。

ではこれまで見てきた「1秒間隔でカウントアップする」という処理を Service に移してみましょう。1

MainService.kt
class MainService : Service() {
    companion object {
        private const val NOTIFICATION_CHANNEL_ID = "MainChannel"
        private const val NOTIFICATION_ID = 1
    }

    private val tag = this::class.simpleName

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        // 通知チャンネルの作成
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
            if (notificationManager.getNotificationChannel(NOTIFICATION_CHANNEL_ID) == null) {
                val channel = NotificationChannel(
                        NOTIFICATION_CHANNEL_ID,
                        "バックグラウンド処理",
                        NotificationManager.IMPORTANCE_HIGH).apply {
                    description = "バックグラウンド処理の通知です"
                }
                notificationManager.createNotificationChannel(channel)
            }
        }
        // 通知の作成
        val notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
                .setContentTitle("バックグラウンド処理")
                .setContentText("処理中です")
                .setSmallIcon(R.mipmap.ic_launcher)
                .setPriority(NotificationCompat.PRIORITY_HIGH)
                .build()
        // フォアグラウンドサービスにする
        startForeground(NOTIFICATION_ID, notification)

        var count = 0

        // カウントアップ処理開始
        thread {
            while (true) {
                Log.d(tag, "count=$count")
                count++
                Thread.sleep(1000)
            }
        }
        return START_NOT_STICKY
    }

    override fun onBind(intent: Intent?): IBinder? {
        throw UnsupportedOperationException()
    }

    override fun onDestroy() {
        Log.d(tag, "onDestroy:")
        super.onDestroy()
    }
}
MainApplication.kt
class MainApplication : Application() {
    override fun onCreate() {
        super.onCreate()

        // サービス起動
        val serviceIntent = Intent(this, MainService::class.java)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            startForegroundService(serviceIntent)
        } else {
            startService(serviceIntent)
        }
    }
}

このアプリを起動するとこれまでのアプリと同様1秒間隔でログ出力されますが、Activity を閉じて他のアプリをたくさんいじったりしても、そう簡単にはログ出力は止まりません。Activity が破棄された後も Service が生き続けていますからアプリのプロセスも kill されないわけです。

Service も「絶対に落ちない」というわけではない

なるほど、Service を使えばプロセスを維持できるのか。
あれ、でもさっき「Android は一つのアプリがデバイスのリソースをずっと掴んでいるような状況を避ける」みたいなこと言ってたけど、その話と矛盾しない?

そこに気付いたあなたは鋭い!
そうなんですよ、実は Service も、ずっと維持されることは保証されていないんです。具体的には、システム全体のメモリが枯渇してくると、OS (Android framework) が Service のプロセスを強制的に kill することがあるんです。そのため Service を使う際には強制終了させられてしまうことを念頭に設計する必要があります。

強制終了することを念頭に設計って、、、そんなんどうすりゃいいのよ…

そう思いますよね。
でも一応、リカバリー方法は提供されています。それは、Service#onStartCommand() の戻り値として START_STICKY または START_REDELIVER_INTENT を返すようにすることです。これらの値を戻り値として返すと、その Service のプロセスが強制終了してしまった場合、メモリの枯渇状態が改善されたタイミングで OS (Android framework) によってその Service が自動的に再生成され、新しいプロセスで再実行されるんです。

な、なんじゃそりゃあ!そんなのアリかよっ!?

お気持ちは分かります。ウルトラCと言っても良いかも知れません。でもこんな方法が「正当なやり方」なんです。というわけでさっきのプログラムを書き替えてみましょう

MainService.kt
class MainService : Service() {
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        // ≪中略≫
        val sharedPreferences = getSharedPreferences("MainService", Context.MODE_PRIVATE)
        var count = if (intent == null) {
            // 状態の復元
            sharedPreferences.getInt("count", 0)
        } else {
            0
        }

        // カウントアップ処理開始
        thread {
            while (true) {
                Log.d(tag, "count=$count")
                count++
                // 状態の保存
                sharedPreferences.edit { putInt("count", count) }
                Thread.sleep(1000)
            }
        }
        return START_STICKY
    }
}

ともかくこの方法を使うことで処理を継続することは可能になります。当然ですがこの方法を使うには、いつプロセスが kill されても大丈夫なように、そして新しいプロセスで再実行されても処理の続きができるように、状態をローカルストレージに保存するなどして永続化しておく必要があります。

め、、、めんどくさい、、、、

そうですね。
でもこれは、そういうものだと思ってもらうしかないです。

ただ現実的なことを言うと、最近の端末はメモリが潤沢ですので、Service が短時間で強制終了させられることはあまりないと思います。ですので「絶対に落ちちゃいけない処理」でない限りは、そこまでやる必要はないかも知れません。

Recents Screen で終了させられるのでは?

でもさ、Android には動作中アプリの一覧を表示する画面があるじゃん?
あの画面でアプリを終了できるよね?そのときってどんな扱いになるの?

おっと、それって ↓ こんな画面のことでしょうか?
RecentScreen.png
Android 端末の画面下部にある ■ ボタンを押したり画面下部から上方向にスワイプしたりすると出てくる、最近使った Activity やタスクの一覧が表示されるあの画面は Recents Screen と呼ばれます。

ちなみにこの画面で表示されるものはあくまで「最近使われたタスクの一覧」であって「動作中アプリの一覧」ではありません。この画面に表示されていなくてもプロセスが残っているアプリはあり得ますし、逆にこの画面に表示されているのに kill 済みだったりもします。

さて、この画面でアプリを「終了」させられると思っている人もそれなりにいると思います。では実際はどうなのか試してみましょう。
これまで試してきたプログラム(1秒間隔でカウントアップするプログラム)のうち、Service を使わないものを実行させ、Recents Screen で「終了」させる操作をしてみてそのときのログを確認してみます。

D/MainActivity: count=10
D/MainActivity: count=11
≪Recents Screenで「消す」操作を実行≫
I/ActivityManager: Killing 3458:com.ishihatta.terminationtest/u0a87 (adj 900): remove task

kill されていますね。「終了」の操作をすることで、確かにアプリは終了するようです。
では次に Service を使ったプログラムで同じことを試してみます。

D/MainService: count=5
D/MainService: count=6
≪Recents Screenで「消す」操作を実行≫
D/MainService: count=7
D/MainService: count=8
D/MainService: count=9

「終了」の操作をしても kill されず、処理が続いていることが分かります。

このことからも分かるように、Recents Screen での「終了」操作は必ずしも「アプリの終了」を行うものではありません。ではこの操作は一体何なのかと言うと、「タスクの終了」をする操作だったりします。
タスクというのは複数の Activity をまとめたものです。アプリによっては複数の Activity を生成して画面遷移を実現することがありますよね。Android はそれら複数の Activity を、ひとまとめにしたタスクという単位で管理しています。Recents Screen に表示されているのは「アプリの一覧」ではなく「タスクの一覧」だったりします。ですからこの画面で「終了」できるのは、アプリではなくタスクなんです。
そして、そのタスクはさっきも書いた通り「Activity をまとめたもの」です。ですから「タスクの終了」は「Activity の終了」を意味します。

つまり、Recents Screen で「終了」の操作をすると、対象の Activity が終了することになります。その結果、Activity だけで構成されるアプリは終了することになります。
ですが、終了するのはあくまで Activity だけです。Service は終了しません。そのため Service が動いているアプリは、この操作で終了(kill)させられることはないんです。

この観点からも、なるべく確実に完遂させたい処理は Service で実装した方が良いことが分かります。2

まとめ

以上のことをまとめると以下のようになると思います。

  • Android で処理を完遂するには工夫が必要だゾ!
  • 短時間(数秒程度)で終わる処理や、失敗しても問題にならない処理は、単純に非同期処理として実装しても問題ないと思われ。
  • それ以上に時間がかかり、かつ、失敗が(あまり)許されない処理は、Service として実装するのが無難だゾ!

プログラムコードに書かれた処理を CPU に完遂させるなんてことは「コンピュータ」にとってはごく当たり前なことのはずですが、Android では必ずしも当たり前ではないということですね。昔ながらのプログラマとしては少し気持ち悪さも残りますが、こういう OS の仕組みや挙動を把握しておけば、きっとエンジニアとしての強みになると思います。


  1. 実際は WorkManager を使うことの方が多いと思いますがここでは「そもそも Service とは?」を理解するためあえて Service クラスを継承する例を挙げています。また WorkManager を使う場合でも Service の性質を理解していた方が良いだろうと思っています。それと、本当は Android 8 で導入されたバックグラウンド実行制限の話やフォアグラウンドサービスの話も書こうと思っていたのですが、さすがに長くなってしまうので端折りました。 

  2. でも Foreground service については通知領域から終了させることはできちゃったりします。なので Service も「絶対死なない」ってわけではないです。 

46
37
2

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
46
37