📱

Androidアプリでローカルプッシュを定期的に実行する方法

に公開

SUZURI・minne事業部モバイルチームのtepiです。この記事は、「GMOペパボ minne Advent Calendar 2025(🎅会場)」の5日目の記事です。

ユーザーをサービスにアクセスさせる頻度を上げる一つの方法としてプッシュ通知がありますが、どのように実装されているでしょうか?サービスのサーバーで対象者を抽出し送信しているでしょうか?Firebase等のサードパーティのサービスの管理画面から設定を行って送信しているでしょうか?

結果的に効果がないプッシュ通知のためにサーバーにて実装を行ってしまうと実装のコストはもちろんのこと、サーバーのコストもかかり無駄になってしまうことがありますし、Firebase等の外部サービスで送る場合には事前に契機となる情報を送っておいたり、送信者を手動で抽出したりする手間があったりします(もちろんそのようなコストや手間が発生しないよう実装されている優秀なサービスもたくさんあるでしょう)。

そこで方法の一つとして、Androidデバイス内でローカルプッシュ通知を送ることで、サーバーコストを下げたり、実装をモバイルチームだけで行えるようにし、そういった課題に対処できないかと考えました。

ローカルプッシュとは

この記事ではローカルプッシュを、サーバーからの通知に従ってプッシュ通知を端末上で表示するのではなく、端末上のバックグラウンドで動作したプロセスから表示するもの、として書いていきます。

実装は、Jetpack Libraryの一つであるworkライブラリを使ってバックグラウンドでWorkerを動かし、Workerにプッシュ通知を表示させます。

バックグラウンドの動作

Jetpack Libraryの一つであるandroidx.work:work-runtime-ktxにある、WorkManagerWorkerを使ってバックグラウンドで動作するプロセスを作成できます。

Workerの実装

Workerはandroidx.work.Workerを継承したクラスを作成し、継承したdoWorkメソッド内でプッシュ通知の表示を実装します。プッシュ通知の実装はドキュメントにもあるような通常の実装と同様にNotificationManagerを利用して表示を行います。

まとめるとこのような形になります。

class LocalPushWorker constructor(
    context: Context,
    workerParameters: WorkerParameters,
) : Worker(context, workerParameters) {
    private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

    override fun doWork(): Result {
        createNotification()

        return Result.success()
    }

    private fun createNotification() {
        createChannel()

        val notification = NotificationCompat.Builder(applicationContext, "channelId")
            .setContentTitle("プッシュ通知のタイトル!")
            .setContentText("プッシュ通知の内容です!!")
            .setSmallIcon(R.mipmap.ic_launcher)
            .build()

        notificationManager.notify(1, notification)
    }

    private fun createChannel() {
        val channel = NotificationChannel("channelId", "チャンネル名", NotificationManager.IMPORTANCE_DEFAULT)
            .apply {
                description = "チャンネルの説明"
            }
        notificationManager.createNotificationChannel(channel)
    }
}

非同期処理を行うWorker

上記の実装では非同期なAPIの実行などは行っていませんが、行いたい場合はWorkerではなくCoroutineWorkerを継承するとdoWorkがsuspendメソッドになり、非同期処理が実行できるようになります。

class LocalPushWorker constructor(
    context: Context,
    workerParameters: WorkerParameters,
) : CoroutineWorker(context, workerParameters) {
    override suspend fun doWork(): Result {
        //実装...
    }
}

WorkerのDI対応

また、WorkerはHiltにも対応しています。実装はドキュメントにある通りに行います。

ドキュメントではkaptで記載されていますが、kaptをkspに変更するだけでkspでのビルドも可能でした。

ただ、サンプルアプリでは実装できたのですが、minneのソース上に実装する際はUnable to read Kotlin metadata due to unsupported metadata kind: null.というエラーが出てしまいビルドすることができませんでした。Kotlin、KSP、ライブラリ関連の問題と認識していますが、minneはkaptとkspが混在していたり、Gradleがpluginsではなくapply pluginの形式で書かれている箇所があったりと、Gradle関連は整備が進んでおらず色々と試してみましたが解決しなかったため、minne上で試すことができませんでした。

Workerの起動

WorkerWorkManagerにキューイングすることでキューイング時に指定された情報を使って実行されます。キューイングする箇所は好みの箇所で構いませんが、自分がサンプルとして実行する際はApplicationクラスのonCreateで行い、起動時に必ずWorkerが起動するよう実装しました。

val pushNotificationWorkRequest: WorkRequest = OneTimeWorkRequestBuilder<LocalPushWorker>().build()
WorkManager.getInstance(applicationContext)
    .enqueue(pushNotificationWorkRequest)

この例では、OneTimeWorkRequestBuilderを使ってWorkRequestを作成しています。OneTimeWorkRequestBuilderはその名の通り、1度だけWorkerを実行してくれるようWorkRequestを作成してくれます。上記の実装だと(Applicationクラスに実装しているので)アプリの起動直後にLocalPushWorkerdoWorkが開始され、プッシュ通知が表示されます。

Workerを定期的に実行する

では、元々の目的である、定期的にプッシュ通知を表示したい場合はどうすればよいでしょう?

その場合は、OneTimeWorkRequestBuilderの代わりにPeriodicWorkRequestBuilderを使います。

val pushNotificationWorkRequest: PeriodicWorkRequest = PeriodicWorkRequestBuilder<LocalPushWorker>(
    repeatInterval = 1, repeatIntervalTimeUnit = TimeUnit.HOURS,
    flexTimeInterval = 15, flexTimeIntervalUnit = TimeUnit.MINUTES
)
    .setNextScheduleTimeOverride(15.minutes.inWholeMilliseconds)
    .build()
WorkManager.getInstance(applicationContext)
    .enqueueUniquePeriodicWork("id", ExistingPeriodicWorkPolicy.UPDATE, pushNotificationWorkRequest)

変更点その1:PeriodicWorkRequestBuilder

AndroidではDozeというアプリのバックグラウンド処理やネットワークの処理を管理し電池消費を管理をしてくれる仕組みがあり、その制約に則って定期実行の設定ができます。PeriodicWorkRequestBuilderの初期化時の引数は以下の通りです。

引数名 設定内容
repeatInterval どれくらいの頻度で実行したいか。
最短は15分(PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLISの値)。
repeatIntervalTimeUnit repeatIntervalの単位(例:分単位ならTimeUnit.MINUTESを指定する)。
flexTimeInterval どの程度指定されたインターバルからズレてもいいか。
最短は5分(PeriodicWorkRequest.MIN_PERIODIC_FLEX_MILLISの値)。
flexTimeIntervalUnit flexTimeIntervalの単位(repeatIntervalTimeUnitと同様)。

詳しくはドキュメントの図を見ていただくのが一番わかりやすいかと思いますが、PeriodicWorkRequestは定期的に実行される間隔とその定期実行されるタイミングに対してどの程度ズレが発生してもよいかを指定します。残念ながら間隔やズレは記載の通りの制約がありますが、その範囲内であれば自由に設定できます。

例えば、上記のようにrepeatIntervalが15分、flexTimeIntervalが5分の場合、実行開始から10分後〜20分後の間(=15分後の±5分)にWorkerが実行されます。

加えて、setNextScheduleTimeOverrideを指定することで、キューイングしてから最初の1回目をいつにするかを指定できます。

例えば、Workerを毎日22時に実行したい場合は、setNextScheduleTimeOverrideに次の22時までの時間を指定し、repeatIntervalが1日分となるように指定します。ただし、setNextScheduleTimeOverriderepeatIntervalと同じ制約で動いているようで、15分未満を指定した場合は即時になります。

変更点その2:enqueueUniquePeriodicWork

キューイングするだけであればOneTimeWorkRequestBuilderと同様にenqueueメソッドでも構いませんが、setNextScheduleTimeOverrideを利用する場合はメソッドのドキュメントにも記載の通りExistingPeriodicWorkPolicy.UPDATEを指定する必要があるため、引数として受け取れるようメソッドをenqueueUniquePeriodicWorkに変更します。

ExistingPeriodicWorkPolicy.UPDATEはすでにキューイング済みのWorkerが重なった場合のIntervalを変更するかどうかの設定のようです。setNextScheduleTimeOverrideではライブラリが勝手に再キューイングするため更新できるようにしておく必要があるのだと思われます。

忘れずに

以上で実装は完了です。ここまで来て私のようにプッシュ通知が表示されない方。必ずアプリのプッシュ通知の設定がオンかどうかを確認しましょう。多分オフになってます。

まとめ

元はローカルプッシュの実装をしたくて始めたのですが、結果的にはWorkerの実装の良い勉強になりました。
特にPeriodicWorkRequestの動きはドキュメントで読むより試してみるとわかりやすいところもあり、試せて良かったです。

なお、minneでは毎年数回「モバイルチーム合宿」を実施し、普段の業務ではなかなか時間を取りづらい実験時間を半日設けて実施しています。今回のローカルプッシュもその一環で試したものでした。もしよければ5月に実施した際の様子はテックブログにありますので読んでみてください。

引き続きこういった会に限らず自分の案を試しつつ知識の向上に役立てていきたいです。今年もあと3週間ほどとなりましたが、走りきって楽しいクリスマス→年末を迎えられるよう皆さんがんばりましょうね!

明日の 「GMOペパボ minne Advent Calendar 2025(🎅会場)」はリューちゃんさんの予定です!お楽しみに!

GMOペパボ株式会社

Discussion