🤖

iPhone, Apple Watch, Androidで長時間のバックグラウンド処理を実装した話

2023/12/03に公開

はじめに

こんにちは。Sun* でモバイルエンジニアをやっている@haruka0640です。
この記事はSun* Advent Calendar 2023の3日目の記事です。

今年、iPhone, Apple Watch, Androidの3種類のOSにおいて、端末のセンサーを使った長時間のロギングが必要なアプリを開発する機会がありましたので、そちらについて書こうと思っています。

結論を最初に書いてしまうと、Androidが圧倒的に楽でした。
Apple製品では、基本的に長時間処理を行う手段は提供されていません。今回はなんとかワークアラウンドを見つけたものの、審査に出したらリジェクトされるかもしれないし、今後OSの仕様が変わったらもう使えなくなるかもしれません。反面、Androidはあえて記事に書くこともないくらいシンプルな正攻法で済みました。
なのでAndroidで作るのがお勧めなのですが、それでもiPhoneやApple Watchで長時間処理を作らざるを得なくなった人のためにこの記事を残しておきます。

前提

今回作ったアプリの要件

  • ユーザーがアプリから計測開始をリクエストすると、ロギング処理(センサー値の記録)を開始する
  • ユーザーがアプリから計測終了をリクエストすると、ロギング処理を停止し、それまでに記録したセンサー値をサーバーに送信する
  • 対象のデバイスはiPhone, Apple Watch, Android
  • 最大12時間の計測に耐えられる

制限事項

  • iOS16, 17でテストずみ
  • 審査通過実績は、TestFlight配布のみ(ストア版申請が通過するかどうかは未検証)

実現方法

iPhone

iOSでのBackgroundタスクの実行時間は厳しく制限されており、普通にバックグラウンド処理をしているとアプリは容赦なくSuspendされ、処理が停止してしまいます。
試行錯誤した結果、以下のワークアラウンドによってこの制限を掻い潜ることができることがわかりました。

ワークアラウンド1: 位置情報トラッキングする

  • iOSでは数分程度のバックグラウンドタスクを行うためのAPIは用意されているが、数時間規模での使用は想定されていない。ただし、「音楽再生」「通話」「位置情報トラッキング」などの一部のアプリは数時間の実行が許されているとのこと(※Stack Overflowなどを見る限り、以前はこのことが公式ドキュメントに明記されていたようだが、現在はリンクぎれになっている。おそらく現在、「長時間実行が可能なアプリの種類」について明記されている公式ドキュメントは存在しない。このワークアラウンドがいつまで使えるのかは不明。)

  • App Storeを調査したところ、位置情報トラッキングを行うことで長時間実行している前例があった(似たようなことをしているアプリはみな位置情報許可をリクエストしてきた)ので、本アプリも位置情報トラッキングを使用することを決定

  • ロギング処理の起動とともに位置情報サービスを立ち上げ、ロギングの終了時にサービスも終了する

  • allowsBackgroundLocationUpdatesをtrueに、pausesLocationUpdatesAutomaticallyをfalseにしないと途中で止まるので注意(*1)

let manager = CLLocationManager()
manager.allowsBackgroundLocationUpdates = true
manager.pausesLocationUpdatesAutomatically = false

ワークアラウンド2: UIBackgroundTaskを使う

  • 上記の方法で位置情報トラッキングをしても、バックグラウンドタスクがないと、30分ほどすると処理が止まってしまう
  • 対策として、UIBackgroundTaskを使う。UIBackgroundTaskは数分するとキルされてしまうため、対策として、Timerを使って60秒ごとに新しいTaskを立ち上げ直し、古いTaskを削除することで常にTaskが起動している状態をキープする。
// 長時間のバックグラウンドタスク実行を可能にするため、
// 一定時間おきにタスクを立ち上げ直すためのクラス
final class LongTaskManager {
  static let shared = LongTaskManager()
  private var timer: Timer?
  // swiftlint:disable all
  private var backgroundTaskID: UIBackgroundTaskIdentifier = UIBackgroundTaskIdentifier(rawValue: 0)
  private var oldBackgroundTaskID: UIBackgroundTaskIdentifier = UIBackgroundTaskIdentifier(rawValue: 0)
  // swiftlint:enable all
  
  func start(interval: Double = 60) {
      timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true, block: { _ in
                  self.oldBackgroundTaskID = self.backgroundTaskID

                  // 新しいタスクを登録
                  self.backgroundTaskID = UIApplication.shared.beginBackgroundTask { [weak self] in
                      guard let backgroundTaskID = self?.backgroundTaskID else { return }
                      UIApplication.shared.endBackgroundTask(backgroundTaskID)
                      self?.backgroundTaskID = UIBackgroundTaskIdentifier.invalid
                  }
                  // 前のタスクを削除
          UIApplication.shared.endBackgroundTask(self.oldBackgroundTaskID)
      })
  }

  func end() {
      timer?.invalidate()
      timer = nil
      UIApplication.shared.endBackgroundTask(backgroundTaskID)
      UIApplication.shared.endBackgroundTask(oldBackgroundTaskID)
  }
}

Apple Watch

WatchOSでもやはり、バックグラウンドタスクは普通にやっていると数分で止まってしまいます。しかしワークアウトアプリは長時間実行が許されている(*2)ので、ワークアウトアプリとして振る舞うことで、iPhoneよりは正攻法で実現できました。

ワークアラウンド: HKWorkOutSessionを起動する

  • ロギングを開始する前にHKWorkOutSessionを起動しておき、ユーザーがロギングを終了したらHKWorkOutSessionも終了する
  • HKWorkOutSessionを起動すると、Apple Watchのworkoutアプリに影響が出る(数時間ロギングすると、その時間分運動していることになる)。そのため、HKWorkoutSessionをスタートした後に、即時pauseすることでこれを回避
let workoutConfiguration = HKWorkoutConfiguration()
let session = try HKWorkoutSession(healthStore: healthStore, configuration: workoutConfiguration)
session.startActivity(with: nil)
// HACK: Activityアプリへの影響を防止するために、即時にワークアウトを停止する
session.pause()

余談: iPhoneへのファイル転送について

長時間実行処理とは関係ないですが、Watchからのファイル転送でも苦労したので、書いておきます。

  • Apple Watchで計測したログファイルは、最終的にサーバーに送る必要がある
  • ログファイルはサイズが大きく、Watch本体の通信性能には期待できないため、今回はWatchから一度iPhoneを経由し、iPhoneからサーバーに送る方法を取った
  • Watch -> iPhoneのファイル転送は、transferFile(_:metadata:)で実現できる。しかし、短時間の計測データなら問題なく転送ができるが、長時間実行するとiPhone側のファイル受け取りコールバック(session(_:didReceive:))が呼ばれず、ファイルが消滅するという問題が発生
  • 調査した結果、転送中にiPhoneアプリが終了 or 起動しているがSuspend状態である とiPhone側でファイルが受け取れないということが発覚
  • 最終的に、ロギング中だけでなく、ファイルの転送中にも長時間実行処理と同じワークアラウンドを行い、アプリがSuspend状態にならないようにすることでファイルの消滅を回避した

Androidにおける実装

Androidは長時間実行用の手段を公式が用意してくれているので、iPhone, Apple Watchのように抜け道を探す必要はなく、非常にシンプルに実装ができました。

1. フォアグラウンドサービスを使う

  • バックグラウンドサービスには実行制限があるので、今回はフォアグラウンドサービスを使用
  • 特別なことはしていないので、説明やサンプルコードは詳しくは公式ドキュメント参照

2. wake lockを取得する

  • wake lockとは、Androidの画面が消えた状態でもCPUをオンのままにするための機能
  • こちらも特別なことはしていないので、詳しくは公式ドキュメント参照

結論

冒頭にも書きましたが、長時間実行処理をやりたいならAndroidの方がずっと楽だというのが、今回身に染みて感じたことです。
実際、本アプリではApple Watch/iPhone版の開発〜テストには数ヶ月必要でしたが、全く同じ機能を持つAndroid版は約1ヶ月で終わりました。
もちろんAndroidの方が開発が短く済んだことには、一度Watch/iPhone版を作ったことで知見がたまってたとか、Watchがない分処理が単純になっていたからとか、色々な理由があり、工数だけで労力が単純比較できるわけではありませんが、開発中に発生した問題が段違いに少なかったです。
何より、ワークアラウンドではなく正式な方法で実装ができたので、今後の保守やストア審査に懸念がないというのが健全でいいですね。

参考文献

*1 Apple Developer | Handling location updates in the background
*2 Apple Developer | Using extended runtime sessions, Apple Developer | Enabling Background Sessions

Sun* Advent Calendar 2023の次の記事は、azukiさんが執筆します!
お楽しみに!

Discussion