- Ruby / Rails以外の開発一般
Android開発者が Kotlin Multiplatform Mobile (KMM) による開発を通して iOS のバックグラウンド処理を学んだ話
前回 Kotlin Multiplatform Mobile (KMM) Beta で気になったことの調査まとめでAndroid用に作ったファイルダウンロードライブラリをKMMライブラリ化出来ないか実験しました。
その際に iOS のバックグラウンド処理周りについてあまり詳しくなかったため調査したのですが、軽く検索した程度だと自分の知りたい内容があまり出てこなかったため調査結果を記事にまとめてみます。
はじめに
自分は普段 Android メインの開発をしていますが、ほとんどのアプリ開発案件は Android/iOS 両対応のため当然 iOS との仕様のすり合わせなどで iOS 開発者の話を聞くことも多くなります。
その際によく耳にする話として以下のようなものがありました。
- iOS は Android と違い、Activity単位のような破棄/復帰はなく、プロセスが死んだら復帰時は常に最初から
- 別プロセスに ActivityStack のようなものが保存されていたりもしない
- アプリをバックグラウンドに回した状態で処理を継続するとプロセスがすぐ死ぬ
もちろんバックグラウンド処理を長時間行うための手法もいくつかあるわけですが、「バックグラウンドで処理するとすぐ死ぬ」という話を頻繁に聞いていたためどんだけ死にやすいんだこいつ・・・iOS ゴミじゃん、と常々思っていました。
しかしKMMでコルーチンを利用したバックグラウンド処理を書く際に色々疑問点が増えてきたためまじめに調査することにしました。
結論を先に書くと iOS 用語で言う「サスペンド」という状態を正しく理解出来ていませんでした。
Android 開発者と iOS 開発者が口頭で「バックグラウンド」という用語を使って話しているだけだと、この点はかなり認識にズレが出る気がします。
ついでにぶっちゃけると、Android 開発者も以降で説明するプロセス周りの内容を正しく理解していない人が結構いると思っていて、iOS 開発者の人も同様に正しく理解している人は案外少ないのではないかと疑っています。
ちなみにこの記事内で「バックグラウンド」と言ったら「アプリのバックグラウンド/フォアグラウンド」の話となります。
端末HOMEや別アプリを表示した際にアプリは「バックグラウンド」になります。
Android のバックグラウンド処理とプロセス破棄について軽くおさらい
Android は Activity やプロセスの復帰処理やバックグラウンド時の制限事項などについて語り始めると長くなりますが、「バックグラウンド処理とプロセス破棄」のみに関して言えば難しい説明はそんなにありません。
- アプリで処理を継続中にバックグラウンドにしても処理はそのまま継続する
- メモリが不足した場合はプロセスの重要度に応じてプロセスが LowMemoryKiller に殺されることがある
- この挙動でプロセスが殺されても onSaveInstanceState などを利用してデータの保存/復元を正しく行えば状態の復帰が可能、何故かというとOSが管理する別プロセスにデータが保存されるため(現状1MBまでしか保存出来ません、おそらくこの仕様は TransactionTooLargeException の説明にしか記載がない)
- onSaveInstanceState による状態の保存/復元と同じ挙動について、最近は Jetpack Compose や ViewModel を利用した場合も簡単に実装するための手段が用意されていたりする
- 上記によるデータの保存処理は該当の処理が呼び出されたタイミングで行われるため、フォアグラウンドの状態でいきなり殺されたりバックグラウンドになった後に進行した処理内容の保存は出来ない
例えばあり得ないほど高い解像度のJPEGとかを BitmapRegionDecoder で読み込んだりするとアプリがフォアグラウンドの状態でもプロセスは殺されます。
onTrimMemory を監視すると殺される様子が分かりやすいです。
アプリがバックグラウンドになるとプロセスの重要度が下がるため殺されやすくはなりますが、プロセスが殺されるかどうかはそれだけでは決まらず、上記の通りフォアグラウンドであろうと殺されるときは殺されます。
iOS のバックグラウンド処理について
Apple のドキュメントは(自分の中で)非常に分かりづらいことで有名なのですが、動作確認しつつ頑張ってまとめると以下のような感じでした。
- アプリで処理を継続中にバックグラウンドにすると、5秒経っても処理が終わらないとプロセスが殺される
- バックグラウンドに入ったタイミングは applicationDidEnterBackground で検知出来る
- beginBackgroundTask を実行することで処理時間を30秒とかに伸ばせる
- 具体的な残り時間は backgroundTimeRemaining で確認出来る
- 処理が終わったら endBackgroundTask を実行する必要がある
- 時間内に適切に処理を終了すればサスペンド状態に以降し、メモリ(アプリ内の状態)は保持される
- 通信処理などは URLSession を使うことで、通信処理中にバックグラウンドになっても適切にサスペンド状態に移行する
- プロセスは殺されないが、サスペンド中はもちろんダウンロード処理は停止する
- URLSessionDownloadTask を利用するとアプリとは別のプロセスでダウンロード処理が行われるため、アプリがサスペンド状態になってもダウンロードを継続することが出来る
- Background Tasks を利用すると定期的にアプリを再開して一定時間処理を行うことが出来る
- サスペンド状態のアプリはメモリ不足時に破棄される事がある
- 音楽再生など、いくつかの決められた処理はバックグラウンド処理時間を延長することが出来る
- 過去にこれを悪用し、Facebookアプリが無音再生で長時間のバックグラウンド処理を実現したことがある
Android と比較すると「メモリ不足にならずとも一定時間経過でプロセスが殺される」という点が最も大きな違いです。
これが最初に書いた「バックグラウンドで処理するとすぐ死ぬ」という話の部分ですね。
ただし、適切に処理を中断すればサスペンド状態になり、いきなりプロセス(メモリ)が破棄されたりはしないようです。調査前はここを正しく理解できていませんでした。
iOS のアプリのライフサイクルを見ると、Background と Suspended は違う状態であり、サスペンド状態になると手動でフォアグラウンドに戻したり、Background Tasks の機能などで再開されたりしない限り処理が停止した状態になるという部分も Android のバックグラウンド状態とはイメージが違う点です。
iOS でのコルーチン利用について
今回調査を開始した発端となったKMMにおける iOS でのコルーチン利用についてですが、当然ではありますがバックグラウンドに入ったタイミングで適切にコルーチンのキャンセルをして処理を止めれば正常にサスペンド状態に移行します。
それ以外にも、例えば backgroundTimeRemaining がなくなるタイミングで delay 中になるようにしてもサスペンド状態に移行し、フォアグラウンド復帰後 delay していたところから処理が再開することが確認できました。
KMM によるコルーチンベースのファイルダウンロード処理について
iOS のファイルダウンロードは基本的に前述の URLSessionDownloadTask を使ってやれ、というのが iOS 界隈の常識のようですが、以下のような制限があり処理の自由度が低いのが難点に思えました。
- resumeData の管理が別プロセス任せなため扱いづらい
- 取得した resumeData をプロセス破棄後も維持したい場合は自前でファイルに保存しておく必要がある
- キャンセル直後は2倍容量を食う?その場合、残り容量が少ないと正しくキャンセルが出来ない
- resumeData の具体的な仕様が公開されていないため、ワンタイムURLなどの再開に対応出来ない
Android の処理をコルーチンベースで既に作ってあった上、URLSessionDownloadTask を利用するとなると共通コードがほとんど無くなりそうでKMMライブラリ化する価値も無くなりそうだったため、コルーチンベースのままKMMライブラリ化した場合どんな感じになりそうか検討&実験してみました。
Android に関しては基本的に以下のような仕様想定です。
- ダウンロードはアプリのプロセスが生きている限り継続する
- 長時間ダウンロードしたい場合はプロセスを殺されにくくするためにフォアグラウンドサービスを起動することが可能
- Android 13 から通知権限が必要になり使いにくくなってしまいましたが・・・
なるべく iOS も上記に合わせたいわけですが、iOS 独自の制限のため完全な共通化は不可能です。ただ iOS の機能が許す限り頑張るとおそらく以下のような形にまでは持っていけそうです。
- バックグラウンドになった場合、beginBackgroundTask により延長した時間分ダウンロード処理が継続し、時間切れになりそうになったらサスペンド状態に以降する
- フォアグラウンドに復帰したらダウンロード処理を再開する
- バックグラウンド状態が長時間続く場合、Background Tasks によりたまに再開して一定時間ダウンロード処理を進める
メモリ不足によりプロセスが破棄された場合に状態を失うのは Android/iOS 共通です。
iOS の方はやはり処理継続時間の制限が強いですが、30秒以内にダウンロードが終わるケースがほとんどの場合や、サスペンド状態からの復帰が出来れば問題ない場合は十分使えそうな感じです。
大容量ファイルまたは大量のファイルをバックグラウンドで長時間ダウンロードしたい場合は使いづらいですが、夜中に充電しながらなどの制限の下であれば Background Tasks の機能によりある程度は要求を満たせます。
というわけで、すべての条件で使えるものではないですが、一応コルーチンベースでKMMライブラリ化する価値はありそうでした。
本当は長時間ダウンロード時も Android/iOS 共通動作で使えるのが理想ではあります。
ただし、Android でコルーチンベースのファイルダウンロードライブラリを作ろうと思った発端は Flutter 案件で flutter_downloader を触った際に使いづらさを感じ、特にエラー処理周りが思うように実装できなかったのが原因なのですが、長時間ダウンロードの問題を解消しようとするとおそらく flutter_downloader と似たものが出来上がってしまいます。
flutter_downloader は Android では WorkManager、iOS では URLSessionDownloadTask を利用しています。
URLSessionDownloadTask については前述の問題点があり、WorkManager はそれ自体の利用に SQLite データベースを使用してストレージ容量を消費しており、容量不足エラーを適切に管理しづらいどころか現状だとクラッシュすることもあります。
(flutter_downloader 内でも独自に SQLite を利用していたりもする)
もちろん自作ライブラリの方は Android 専用なので Flutter 案件で flutter_downloader から差し替えるのは簡単ではありませんが、せめて Android 単体プロジェクトの場合は自作ライブラリを使いたいという気持ちで作成しました。
自作ライブラリに関しては申し訳ないのですが現状は一般公開できる状態になっていないため、どんな感じのものなのか興味ある人向けに以下にREADMEだけ公開させていただきます。
このままKMMライブラリ化した場合、前述のバックグラウンド時の処理時間制限以外は iOS も同じ挙動になる想定です。
※良いものが出来上がったら一般公開したいなーと思いつつ、今どきならマルチプラットフォーム化したいよね、でも現状理想の仕様の実現がOS制限で難しいよね、でも flutter_downloader と同じもの作っても意味ないよね、みたいな状況で若干停滞しています
最後に
自分にとっては「食った嫌い」な iOS なので中途半端な知識が多かったのですが、やはり実際にコードを書いたり実験したりしつつ調査しないと詳しい仕様とかは理解しづらいですね・・・。
今回は頑張って実験しつつ調査を行ったわけですが、この記事の内容以外にも同様に Android/iOS 開発者の会話が噛み合わないケースも多いと思うので、出来れば壮大な比較表みたいな wiki が欲しいところです。
(どなたか偉い人、お願いします!)
まあ今回の調査でも改めて思いましたが、比較がしづらい一番の原因は Apple のドキュメントのせいだと思ってるので、まずは Apple さん、まともなドキュメントの整備をお願いします!