LINEヤフー Tech Blog

LINEヤフー株式会社のサービスを支える、技術・開発文化を発信しています。

LINE iOS におけるアプリ内通知の OS 標準化

こんにちは。iOS エンジニアのもとにしです。iOS 版 LINE のバージョン 15.14.0 において、アプリ内通知をシステム通知で表示するようアップデートしました。

変更前変更後
アップデート前に LINE アプリ内に UIView で表示される通知バナーアップデート後の LINE アプリ内に表示されるシステム通知バナー

このように、これまでカスタムビューで表示していた通知が、iOS 標準の通知バナーで表示できるようになりました。

表示を変更するだけのシンプルな作業に思えましたが、開発を進めていく中で、想像以上に考慮すべきポイントが多いことに気づきました。この記事では、これまでの iOS や LINE アプリでの制限をふり返りつつ、移行の過程で向き合ったことや工夫した点を紹介します。

なぜこれまでカスタムビューが使われていたのか?

昔は OS による制限があった

iOS 9 まで、OS の制限により、アプリがフォアグラウンド状態のときに OS 標準のシステム通知を表示することはできませんでした。LINE アプリでは、フォアグラウンドでも通知バナーを表示するために、UIView で実装された独自バナーによりユーザー通知を表示していました(以降「カスタム通知」と呼びます)。カスタム通知の短所として、iOS の通知センターに表示されなかったり、下の画像のように他のアプリの通知とビューが被ってしまったりといった問題がありました。

LINE アプリ内でシステム通知の背後に重なり視認性が低下したカスタム通知バナー

iOS 10 でフォアグラウンドのシステム通知が解禁された

iOS 10 で登場した UserNotifications Framework により、ユーザー通知まわりの機能が刷新されました。それまでローカル通知に使われていた UILocalNotification が非推奨となり、以下の流れでローカル通知を登録できるようになりました。(事前に UNUserNotificationCenter.current().requestAuthorization() で通知の許可を取る必要があります。)

let content = UNMutableNotificationContent()
content.title = "Sample Title"
content.subtitle = "Sample Subtitle"
content.body = "Sample Body"

let request = UNNotificationRequest(
    identifier: UUID().uuidString,
    content: content,
    trigger: nil
)

UNUserNotificationCenter.current().add(request)

最小コードで表示したローカルシステム通知バナー

さらに、フォアグラウンド状態でもシステム通知を表示することができるようになりました。以下のように UNUserNotificationCenterDelegate の関数を実装することで表示できます。

func userNotificationCenter(
    _ center: UNUserNotificationCenter,
    willPresent notification: UNNotification,
    withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
    completionHandler([.banner, .sound, .badge])
}

LINE アプリでシステム通知への移行が遅れた理由

LINE アプリでは iOS 9 以前から、バックグラウンド時にはサーバーからのプッシュ通知を表示していました。ですので、UNUserNotificationCenterDelegate を実装するだけで、フォアグラウンド時にもプッシュ通知が表示され、システム通知への移行が完了するはずでした。しかし、LINE アプリでは、iOS 10 以降もカスタム通知が利用されてきました。いくつかの LINE 特有の問題により、プッシュ通知をフォアグラウンドで利用できなかったのです。

まず、これまで LINE ではアプリがフォアグラウンド状態のときにプッシュ通知を送っていませんでした。これはサーバーの負荷軽減のためであり、システム通知をフォアグラウンドでも表示するためにプッシュ通知を送ってもらうのは難しい状況でした。

また、フォアグラウンド状態でプッシュ通知を表示すると、別経路で更新されるトークリスト画面との整合性が取れなくなる可能性があるといった課題もありました。トークリストは Core Data で管理されており、プッシュ通知とは別の方法で同期されています。そのため、たとえばネットワークの問題などで、プッシュ通知が成功したのにトークリストの同期に失敗した場合、通知バナーが表示されるがトークリストにメッセージは来ていないという状態になってしまい、ユーザー体験を損なう懸念がありました。

以上の理由により、フォアグラウンドのカスタム通知をシステム通知に置き換えるには、ローカルでシステム通知を登録する処理を新たに加える必要があり、実装コストが大きかったのです。

今回は、遅ればせながら LINE アプリのカスタム通知をシステム通知に置き換える実装を行いました。基本的にはカスタム通知を表示している箇所をUNUserNotificationCenter.current().add(request) に置き換えるだけでしたが、その過程で工夫したこと、苦労したことを共有します。

プッシュ通知に挙動を合わせる

ローカル通知をシステム通知として表示すると、プッシュ通知と同じように表示されるため、ユーザーからは区別ができません。そのため、ローカル通知はプッシュ通知と同じ挙動をすることが求められます。もし、ローカル通知とプッシュ通知で通知バナーをタップしたときの遷移先が異なると、ユーザーを困惑させてしまいます。

LINE アプリでローカル通知を実装するにあたり、以下のように、システム通知の各要素についてプッシュ通知と違う挙動をしないように注意しました。

通知をタップした時のアクション

プッシュ通知・ローカル通知に関わらず、システム通知をタップしたとき、UNUserNotificationCenterDelegate の以下の関数が呼ばれます。

func userNotificationCenter(
    _ center: UNUserNotificationCenter,
    didReceive response: UNNotificationResponse,
    withCompletionHandler completionHandler: @escaping () -> Void
)

LINE のプッシュ通知では、通知バナーをタップしたときに該当のトークルームを開く際に利用しています。プッシュ通知では、ペイロードに設定した情報が UNNotificationContent.userInfo に辞書形式で格納されます。たとえば、ペイロードに以下のように talkroom-id を指定した場合、userInfo["talkroom-id"]"123abc" となります。上記の関数内ではこのように userInfo からトークルームに関する情報を取得し、遷移先を判断しています。

{
    "talkroom-id": "123abc"
}

今回ローカル通知のタップアクションを開発するにあたって、通知を登録する際、userInfo がプッシュ通知と同じ構造になるように各種情報を格納しました。

let content = UNMutableNotificationContent()
content.userInfo = ["talkroom-id": "123abc"]

これにより、通知バナーをタップした時に、プッシュ通知と全く同じように情報を取得できます。プッシュ通知で利用していたタップ処理を使いまわすことができるので、プッシュ通知とローカル通知で挙動の差異が起きづらい設計になります。また、追加実装を最小限にできるという利点もあります。

通知バナーのコンテキストメニュー

UNNotificationCategory を利用することで、通知バナーを長押しして表示されるコンテキストメニューを実装できます。LINE アプリでは、メッセージのコンテキストメニューから、返信や通知オフのアクションを実行できるようになっています。

LINE アプリの通知バナー長押しで表示される返信・通知オフアクション付きコンテキストメニュー

利用するには、まずアクションを持つカテゴリを登録する必要があります。これは毎回のアプリ起動時に行う必要があります。

let replyAction = UNNotificationAction(identifier: "reply", title: "Reply")

let messageCategory = UNNotificationCategory(
    identifier: "message_category",
    actions: [replyAction],
    intentIdentifiers: []
)

UNUserNotificationCenter.current().setNotificationCategories([messageCategory])

そして、通知にカテゴリを設定すれば、対応するアクションがコンテキストメニューに表示されるようになります。プッシュ通知の場合、ペイロードの category から設定できます。ローカル通知の場合は、UNNotificationContent.categoryIdentifier にカテゴリを設定できます。

UNNotificationCategory はプッシュ通知とローカル通知で共通です。LINE アプリではすでにプッシュ通知でカテゴリを利用していたので、今回の追加実装はほとんどありませんでした。プッシュ通知と同じ規則でローカル通知にカテゴリを設定するだけで、プッシュ通知・ローカル通知で同じコンテキストメニューを表示できます。

通知のグループ化

iOS 12 以降、iOS の通知センターやロック画面において通知をグループ化できます。LINE でも、通知をトークルーム単位で表示するために利用されています。

通知センター上でトークルーム単位にまとめられた通知

プッシュ通知においては、ペイロードにおける thread-id キーにスレッドIDを設定することができ、値ごとに通知がグループ化されます。

この値はローカル通知と共通です。UNNotificationContent.threadIdentifier にスレッドIDを設定すると、プッシュ通知・ローカル通知に関わらず、通知がグループ化されます。サーバー側とローカル側で指定するスレッドIDが異なると、違うグループとして表示されてしまうので、注意が必要です。

ユーザーアイコンの表示

iOS 15 以降で利用できる Communication Notifications 機能では、INSendMessageIntent を利用することで、iOS に通知の情報を提供できます。これにより、通知バナーにユーザーアイコンやグループアイコンを表示できたり、他のアプリでも共有シート上に共有先としてユーザーが表示されたりするようになります。LINE のプッシュ通知でも、友だちやグループのアイコンを表示するために用いています。

(利用するには、ターゲット設定の Capability から Communication Notifications を追加する必要があります。)

プッシュ通知・ローカル通知に関わらず、同じ方法でユーザーアイコンを設定することができます。INPerson に画像を指定し、INSendMessageIntent に渡すことで実現できます。

let sender = INPerson(
    personHandle: INPersonHandle(value: "Dog", type: .unknown),
    nameComponents: nil,
    displayName: "Dog",
    image: INImage(named: "sender_icon"), // 画像を指定
    contactIdentifier: nil,
    customIdentifier: "user_001",
    isContactSuggestion: false,
    suggestionType: .socialProfile
)

let intent = INSendMessageIntent(
    recipients: nil,
    outgoingMessageType: .outgoingMessageText,
    content: "Hello!",
    speakableGroupName: INSpeakableString(spokenPhrase: "Music Group"),
    conversationIdentifier: "123abc",
    serviceName: "LINE",
    sender: sender, // 画像つき sender を指定
    attachments: nil
)

グループアイコンは以下のように設定できます。

let groupImage = INImage(named: "group_icon")
intent.setImage(groupImage, forParameterNamed: \.speakableGroupName)

最後に、INSendMessageIntent から作成した INInteraction をシステムに提供し、UNNotificationContent を更新することで完了です。

let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .incoming
try? interaction.donate()

let updatedContent = try? content.updating(from: intent)

Communication Notifications で LINE のグループアイコンが表示された通知

INSendMessageIntent の初期化は、プッシュ通知とローカル通知で共通化することが望ましいです。これは、実装を重複させないという理由だけではありません。サーバーとローカルにおいて異なるロジックを用いて INSendMessageIntent を作成した場合、共有シート上で同じユーザーが重複して表示される可能性があるからです。

以上のように、「タップアクション」「コンテキストメニュー」「グループ化」「ユーザーアイコン」の要素について、プッシュ通知とローカル通知で一貫した動作をするために注意を払う必要があります。

特定の条件で通知を表示させない

実は LINE では、フォアグラウンド時にメッセージを受信しても通知バナーが表示されないケースがあります。例えば、画面下部にタブバーが表示されていたり、トークルームが開かれている場合です。タブバーにはバッジで新規メッセージ数が表示されるため、通知が重複して煩わしくならないように通知バナーを表示していません。逆に、ユーザーのプロフィール画面や LINE の設定画面が開かれているときには通知が表示されます。これはカスタム通知を利用していた頃からの仕様で、今回実装したシステム通知でもこの挙動を引き継ぎました。

通知バナーが表示される例通知バナーが表示されない例
LINE アプリ内で 通知が表示されるケース(プロフィール画面)LINE アプリ内で 通知が表示されないケース(トークリスト)

Core Data の更新を待つ

LINE アプリで新しい友だちからメッセージを受け取ったとき、通知バナーに友だちの名前を表示できないケースがありました。これは下図のように、友だちの情報がまだ Core Data に保存されていないときに、通知のコンテンツに利用する友だちの名前を取得しようとして発生していました。

友だちの名前が表示できない場合のシーケンス図

そのため、保存されていない友だちからのメッセージについては、下図のように、Core Data の更新を一定時間待ってから通知を登録するようにしました。

改善後のシーケンス図

実装によっては Core Data の更新と通知の登録が別のスレッドで行われる可能性があるため、タイミングの競合(timing issue)に気をつける必要があります。

メッセージを受信した順番に通知を表示する

受信したメッセージがバラバラの順番で通知されると不便ですよね。例えば、画像メッセージのサムネイルの読み込みに時間がかかった場合、後続のメッセージが先に通知バナーとして表示され、画像メッセージが遅れて表示されます。このような場合、通知センターに表示されるメッセージの順番がバラバラになってしまいます。

これを防止するために、今回は以下のように actor を用いた排他制御を行い、画像の読み込みから通知の登録が完了するまでの処理を直列で 1 つずつ行うようにしました。

actor UserNotificationQueue {
    private var messages: [Message] = []
    private var processingTask: Task<Void, any Error>?

    // メッセージをキューに入れる
    func enqueue(_ message: Message) {
        messages.append(message)
        processNext()
    }

    // 1つずつ通知を登録する
    private func processNext() {
        // Task実行中またはmessagesが空ならreturn
        guard processingTask == nil, let message = messages.first else { return }

        processingTask = Task {
            // ここで画像を読み込み、requestを作成する
            let request = await createRequest(from: message)

            // requestを登録
            try? await UNUserNotificationCenter.current().add(request)

            messages.removeFirst()
            processingTask = nil
            processNext()
        }
    }
}

チャットアプリなど、通知の順番が重要なアプリでは、このように通知の登録が前後しないよう管理する必要があります。

通信状況が悪いときは Attachment を諦める

ローカル通知に UNNotificationContent.attachments を指定することでサムネイルを表示できます。LINE アプリでは、画像やスタンプを表示するために利用しました。以前のカスタム通知ではサムネイルを表示していなかったので、画像を取得するところから追加実装しました。

LINE アプリ内で UNNotificationAttachment により写真のサムネイルが表示された通知バナー

通信状況が悪いときには、attachment として設定するサムネイルの取得に時間がかかり、通知の表示が遅延する可能性があります。また、上述のように、メッセージを受信した順序を保つための仕組みにより、サムネイルの取得は後続のメッセージも待たせることになります。これを回避するために、以下の処理を加えました。

  • サムネイルの読み込みにタイムアウトを設定する
  • キューに入っている通知オブジェクトが上限を超えると、サムネイルの取得をせずに通知を表示する

プッシュ通知にサムネイルを表示する場合も同様の方法となりますが、Notification Service Extension で実行するため、OS 側から設定されるタイムアウトがあり、開発者側で意識する必要はありません。一方ローカル通知では、自前でタイムアウトや制限を設け、通知が遅延しないように配慮する必要があります。

Attachment の追加に失敗したとき

ローカル通知に attachment として設定できる画像には、拡張子やファイルサイズなど、さまざまな制約があります。設定した画像が基準を満たしていなかったり、ファイルが壊れている場合、画像が通知バナーに表示されないどころか、ローカル通知自体の登録に失敗し、通知が表示されません。そのため、attachment が原因で通知の登録に失敗した際は、attachment を削除してリトライすることが望ましいです。

LINE アプリのローカル通知では、UNUserNotificationCenter.current().add(request) が失敗したときに返される UNError を確認し、UNError.code.attachmentInvalidFileSize など attachment 関連のエラーであった場合には、attachment を削除して再度通知を登録し直すようにしました。

移行に伴うユーザーへの影響と対応

システム通知への移行により、iOS 側の通知設定の影響を受けるようになったため、一部のユーザーで通知の挙動が変わる可能性がありました。例えば、OS 設定で通知を拒否している場合、これまではフォアグラウンド時にカスタム通知が表示されていましたが、今回の変更により通知が表示されなくなってしまいます。

このような挙動の変化に対して、ユーザーの混乱を避けるため、LINE 公式ヘルプページの文言を修正するなど、関連チームと連携して対応しました。

おわりに

今回は、LINE iOS アプリのカスタム通知をシステム通知に置き換えた流れについて紹介しました。

実装を通じて学んだこと

実装を始める前は UNUserNotificationCenter.current().add(request) を呼ぶだけでいいのだろうと思っていましたが、開発していく中で、注意しなければならない点がたくさん見つかりました。

  • プッシュ通知との一貫性を保つための設計
  • Attachment の取得失敗やタイムアウトへの対応
  • 通知の順序制御
  • Core Data との同期タイミングの調整

システム通知への移行は一見シンプルに見えますが、既存のプッシュ通知との統合や、ユーザー体験の維持には細やかな配慮が必要だと実感しました。

移行によって実現できたこと

この移行により、フォアグラウンド時にメッセージを受信した際、iOS ユーザーに見慣れたシステム通知で表示され、通知センターにも表示できるようになりました。また、これまでのカスタム通知で対応していなかった画像やスタンプのサムネイルの表示や、コンテキストメニューからの返信や通知オフのアクションもできるようになり、さらに便利になりました。

皆様に快適に使っていただけたら幸いです。