RevComm Tech Blog

コミュニケーションを再発明し 人が人を想う社会を創る

iOSアプリのバックグラウンド状態を考慮した実装における注意点

モバイルエンジニアの長尾です。 最近は暑くてすっかり外に出なくなってメタボ体型になってしまったので、定期的に運動する方法としてジムに通うようになりました。やっぱり運動はいいっすね。

はじめに

みなさんは、iOSアプリは、ユーザーには見えていないけれどもアプリが動いている状態があることをご存じですか? iOS アプリはバックグラウンド状態でのアプリの動作は厳しく制限されていて、ユーザーに見えない状態において、アプリはほとんど動かせません。しかし、いくつかのケースにおいては、バックグラウンド状態でもアプリを動作させることができます。

例えば、ミュージックプレイヤーのようなアプリは、アプリがユーザーから見えていなくとも、音楽を流し続けることができています。これはアプリがバックグラウンド状態になっていても、動作し続けているからこそ実現できています。

弊社で提供している MiiTel Phone Mobile は、電話アプリです。 電話という機能は、iPhone がロックされている状態でも、着信できる必要があります。この機能を実現するために、バックグラウンド状態でも動作するように設計しています。

本稿では、MiiTel Phone Mobile において遭遇したバックグラウンドでのアプリの動作ケースにおいて利用した API を2つご紹介し、それぞれの API における実装上の注意点をまとめています。

バックグラウンドでの着信時の動作

電話アプリの特徴として、電話がかかってきた際に、アプリを使用していなくても、アプリを起動させる必要がある点が挙げられます。ここで言う「アプリを起動する」とは、アプリが前面に立ち上がることだけを指しているのではなく、アプリのプロセスが動き始める、という意味も含みます。電話がかかってきた際は、少なくともサーバーと通信ができる程度にはアプリが動作する必要があります。

このようなケースにおいては、Voice-over-IP (VoIP) push notifications を用います。 VoIP push notifications を使用して、VoIP プッシュ通知を受信することでアプリを起動することができます。 ただし、VoIP プッシュ通知を受信する際は、所定の手順を実行しないと、OSがアプリを強制終了します。 もし所定の手順を実行しないようなアプリであれば、VoIP push notifications でアプリを起動させることができなくなることもあります。

ここでいう所定の手順とは以下の通りです。

  1. pushRegistry() メソッドの中で、reportNewIncomingCall() をコールすること
  2. reportNewIncomingCall()completion() クロージャ内で pushRegistry() メソッドの completion() をコールすること

WWDCでもかなり強い口調で言及されている*1 *2 ので、OSによって強制終了させられないようなコードにしておく必要があります。 pushRegistry() メソッドは、PKPushRegistryDelegate に定義されているデリゲートメソッドで、VoIP プッシュ通知を受信した際にOSからコールされるメソッドです。 reportNewIncomingCall() メソッドは、CXProviderクラスに定義されたメソッドで、画面に着信画面を表示するためのメソッドです。reportNewIncomingCall() メソッドをコールすること自体に制約はありません。 所定の手順を満たそうとすると、VoIP プッシュ通知を受信した際に必ず着信画面が表示されることになります。

コード例を例示しておくので、参考にしてください。(WWDC のセッションで表示されていたコード *3を参考にしています)

let provider = CXProvider(configuration: providerConfiguration)

func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) {
    if type != .voIP { return }
    guard let handle = payload.dictionaryPayload[“handle”] as? String else { return }
    let callUpdate = CXCallUpdate()
    callUpdate.remoteHandler = CXHandle(type: .phoneNumber, value: handle)
    let callUUID = UUID()
   // 1. pushRegistry(_:,didReceiveIncomingPushWith:,for:,completion:)メソッド内で、reportNewIncomingCallをコール
    provider.reportNewIncomingCall(with: callUUID, update: callUpdate) { _ in
        // 2. reportNewIncomingCall(with:update:completion:)のクロージャ内でpushRegistry(_:,didReceiveIncomingPushWith:,for:,completion:)メソッドのcompletionをコール
        completion()
    }
    establishConnection(for: callUUID)
}

もし、先ほどの所定の手順を実行しなければ、以下のようなエラー文がコンソールに出力されます。ログに現れている通り、アプリは強制終了させられます。

2022-08-25 14:36:30.016132+0900 AppName[4899:1149881] Apps receving VoIP pushes must post an incoming call (via CallKit or IncomingCallNotifications) in the same run loop as   pushRegistry:didReceiveIncomingPushWithPayload:forType:[withCompletionHandler:] without delay.
2022-08-25 14:36:30.016292+0900 AppName[4899:1149881] *** Assertion failure in -[PKPushRegistry _terminateAppIfThereAreUnhandledVoIPPushes], /Library/Caches/com.apple.xbs/Sources/PushKit/PushKit-37/PKPushRegistry.m:343
2022-08-25 14:36:30.017563+0900 AppName[4899:1149881] *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Killing app because it never posted an incoming call to the system after receiving a PushKit VoIP push callback.'
*** First throw call stack:
(0x1b0851654 0x1b0573bcc 0x1b07546ec 0x1b0b9a16c 0x1c4a47ab8 0x107d2b730 0x107d3a488 0x1c4a46a70 0x107d2a338 0x107d2b730 0x107d39710 0x1b07cf6bc 0x1b07ca590 0x1b07c9ba8 0x1ba940344 0x1b49053e4 0x102def5b4 0x1b06518f0)

中でも、2. の手順は、見落としがちなので注意が必要です。 また、2. の手順は pushRegistry() メソッド内でなるべく早く実行することをお勧めします。サーバーとの通信の確立等やらないといけないことも色々あると思いますが、b)の手順を行っておいてからでも遅くはないはずです。

フォアグラウンド状態での動作をバックグラウンド状態でも続けたい時の動作

アプリを使用中に、通信が長くなってしまうと、ユーザーが痺れを切らして、スマホをロックしてしまうケースはよくあることだと思います。こういったユースケースでは、フォアグラウンド状態の時にはじまった通信がバックグラウンド状態になっても継続し、通信が完了するまで動作し続けることが望ましいです。 そのようなケースで使うのが、Background Task Completion です。

Background Task Completion の使い方は、以下の通りです。

  1. UIApplication.beginBackgroundTask をコールして、OSに対して Background Task Completion が開始されたことを通知する
  2. タスクが完了した時点で、UIApplication.endbackgroundTask をコールし、Background Task Completion を完了させても良いことをOSに対して通知する
  3. 必要であれば、定められた時間内に、タスクが完了できなかった場合、エラー通知などタスクが完了できなかった時の後始末を行うコードを、beginBackgroundTask メソッドの expirationHandler 引数へ定義しておく

使用例を示します。こちらも WWDC のセッションで表示されていたコード *4 を参考にしています

func send(_ message: Message) {
    let sendOperation = SendOperation(message: message)
    var identifier: UIBackgroundTaskIdentifier!
    // ここからバックグラウンドになっても継続したい処理が開始されることをOSへ通知する
    identifier = UIApplication.shared.beginBackgroundTask(withName: “sendOperationTask”,expirationHandler: {
        sendOperation.cancel()
        postUserNotification(“Message not sent, please resend”)
        // バックグラウンドでの動作が継続できる時間を過ぎてしまった時に呼ばれる
    })
    sendOperation.completionBlock = {
        // バックグラウンドでも継続したい処理が完了したことをOSに通知する
        UIApplication.shared.endBackgroundTask(identifier)
    }
    operationQueue.addOperation(sendOperation)
}

ここで大事なことは、beginBackgroundTask をコールした後、必ず endBackgroundTask をコールすることが必要ということです。 beginBackgroundTask をコールしたにもかかわらず、endBackgroundTask をコールしないとどうなるでしょう? 答えは簡単で、OS によってアプリが Terminated されます。 ちゃんと endBackgroundTask をコールできていないと、以下のようなメッセージが出るので、気をつけましょう。

[BackgroundTask] Background Task 145 ("sendOperationTask"), was created over 30 seconds ago. In applications running in the background, this creates a risk of termination. Remember to call UIApplication.endBackgroundTask(_:) for your task in a timely manner to avoid this.

まとめ

本稿においては、バックグラウンドでの動作ケースにおいて利用したAPIを2つ挙げ、それぞれのAPIにおける実装上の注意点をご紹介させていただきました。本稿では取り上げませんでしたが、バックグラウンド中に少しだけアプリを起動して、情報をリフレッシュしておくと言った使い方のできるAPIも用意されています。興味がある方は「Background Task App Refresh *5」で調べてみてください。
本稿がご参考になれば幸いです。

最後に

RevComm は 9月10日(土)〜12日(月) に開催される iOSDC Japan 2022 *6 にシルバースポンサーとして協賛します。 RevComm では自社プロダクトの通話用アプリ「MiiTel Phone Mobile」を開発しており、モバイルアプリエンジニアを募集しています。

hrmos.co

iOSDC トークンはこちら! #RevCommでMiiTelを一緒に作りませんか

*1:Advances in App Background Execution - WWDC19 - Videos - Apple Developer 9:40から

*2:And new this year, it's very important that you know that you must report incoming calls with CallKit in the didReceiveIncomingPush callback or your app will be terminated. And, if you repeatedly do this, or if you repeatedly fail not to report an incoming call, the system may stop launching your app for VoIP pushes altogether.

*3:Advances in App Background Execution - WWDC19 - Videos - Apple Developer 10:04から

*4:Advances in App Background Execution - WWDC19 - Videos - Apple Developer 7:34から

*5:https://developer.apple.com/documentation/backgroundtasks/bgapprefreshtask

*6:https://iosdc.jp/2022/