Firebaseを利用したプッシュ通知のカスタムデータの解釈(その2)

こちらの記事の続きです。

前回は、Firebaseを利用してプッシュ通知を実装する場合、どのような形式でデータが送られてくるのか、またその内容はどこで受け取れるのか、という内容を書きました。
今回は、受け取ったデータをどういった構造体に変換して保持したのか、について書きたいと思います。 この方針が必ず正しいという訳ではなく、あくまでひとつの考え方として紹介します。

前回の記事にも書いた通り、実現したいことは、
受信したプッシュ通知をタップした時に任意の画面へ遷移させること です。
そのため、遷移先によって必要な情報が異なります。 つまり、プッシュ通知のペイロードには様々な値が含まれ、それらはほぼ全てnullableになるということです。

仮に、それらをひとつのstructで管理しようとした場合、
nullableの各値を利用する際に毎回オプショナルバインディングを行う必要があり、処理が非常に煩雑になってしまいます。

そこで今回は、遷移先ごとに異なるstructを用意し、必要な値は必ず存在するように しました。
存在すべきはずの値が含まれていなければエラーを起こし、エラーが起きた場合は処理をスキップしてどこにも遷移しないようにします。

具体的に以下で説明していきます。

前提

検索ができるECアプリを例に考えてみることにします。
通知をタップすると商品検索結果一覧、商品詳細の画面へ遷移する場合があるとしましょう。それぞれ、遷移に必要な値は以下の通りです。

  • 商品検索結果一覧 への遷移に必要な値
    • 検索キーワード
    • 並び順
  • 商品詳細 への遷移に必要な値
    • 商品ID

そして、プッシュ通知のペイロード形式は以下を想定します。 遷移先を識別するための値 transition_type は必ず存在し、それ以外は遷移先によって含まれたり含まれなかったりします。

{
  "transition_type" : "{遷移先を識別するの値}", // -> search_results_list or product_detail が入る
  "search_keyword" : "{検索キーワード}", // nullable
  "order" : "{並び順}", // nullable
  "product_id" : "{商品ID}" // nullable
}

遷移先を扱う構造体の実装手順

遷移先を扱うための構造体は、こちらのソースコードに載せていますが、以下3つの手順で実現しました。

  • A. プッシュ通知に含まれるペイロードをデコードするためのstructを用意する
  • B. 商品検索結果一覧 商品詳細 用のstructを用意する
  • C. transition_type によって生成するstructを出し分け、enumでそれらを管理する

AはAPIをから得たJSONをパースするときと同じような一般的な処理ですが、B・Cは 遷移先ごとに異なるstructを用意し、必要な値は必ず存在するようにするために必要な処理です。

A. プッシュ通知に含まれるペイロードをデコードするためのstructを用意する

まずはペイロードのデコードです。
データをデコードするための一般的な実装で、ペイロードを扱うモデルであるPayloadDataEntityをDecodableに準拠させます。 また、ペイロードはdictionaryの形式で送られてくるので、そのdictionaryからモデルを生成できるようにします。

対象コード

struct RemoteNotification {
  struct PayloadDataEntity: Decodable {
      enum CodingKeys: String, CodingKey {
          case transitionType = "transition_type"
          case searchKeyword = "post_id"
          case order
          case productID = "product_id"
      }

      let transitionType: String
      let searchKeyword: String?
      let order: String?
      let productID: String?

      init(dictionary: [String: AnyObject]) throws {
          self = try JSONDecoder().decode(PayloadDataEntity.self, from: JSONSerialization.data(withJSONObject: dictionary))
      }
  }  
}

B. 商品検索結果一覧 商品詳細 用のstructを用意する

各遷移時に必ず必要な値がnullableにならないよう、遷移先ごとにstructを用意し、必要な値が必ず存在することを保証できるようにします。

対象コード

struct RemoteNotification {
  struct PayloadDataEntity: Decodable {..省略..}

  struct SearchResultsListEntity {
      let searchKeyword: String
      let order: String
  }

  struct ProductDetailEntity {
      let productID: String
  }
}

C. transition_type によって生成するstructを出し分け、enumでそれらを管理する

transitionTypeによって、SearchResultsListEntity・ProductDetailEntity どちらの構造体を生成するか決めます。また、それぞれに必要な値が不足している場合はエラーを起こすようにします。

そして、生成したentityを詰めるためのenumである NotificationType に変換しておきます。 Swiftのenumは、Associated Valuesを使う事でcaseの値に付加情報を保持できるようになるため、その仕組みを利用します。
利用する際のコードを後述しますが、enumに変換することでその後の処理がシンプルに書きやすくなります。

対象コード

struct RemoteNotification {
  (..上部省略..)

  // ペイロードに含まれる transition_type をこのenumに変換してから扱います
  enum TransitionType: String {
      case searchResultsList = "search_results_list"
      case productDetail = "product_detail"
  }

  // entityを詰めるためのenum
  enum NotificationType {
      case searchResultsList(SearchResultsListEntity)
      case productDetail(ProductDetailEntity)
  }

  enum NotificationError: Error {
      case invalidType
      case invalidData
  }

  // データを各entityを含んだNotificationTypeに変換する
  static func convert(from data: [String: AnyObject]) throws -> NotificationType {
      let data = try PayloadDataEntity(dictionary: data)
      // 先にtransition_typeをTransitionTypeに変換し扱いやすくする
      guard let transitionType = TransitionType(rawValue: data.transitionType) else {
        throw NotificationError.invalidType // 未知のタイプの場合はエラーを起こす
      }
      switch transitionType {
      case .searchResultsList:
          guard let searchKeyword = data.searchKeyword, let order = data.order else {
            throw NotificationError.invalidData // パラメータの場合はエラーを起こす
          }
          let entity = SearchResultsListEntity(searchKeyword: searchKeyword, order: order)
          return .searchResultsList(entity)
      case .productDetail:
          guard let productID = data.productID else {
            throw NotificationError.invalidData // パラメータの場合はエラーを起こす
          }
          let entity = ProductDetailEntity(productID: productID)
          return .productDetail(entity)
      }
  }  
}

利用するときは...

これらを実際に利用する際は、以下のように呼び出す事ができます。
NotificationTypeとしてenumで管理することでswitch文が利用できるため、実装の見通しがよくなります。

class PushNotificationRouter {
  // 画面遷移する関数の例(notificationPayloadにプッシュ通知のペイロードが渡ってくる想定)
  static func openTargetViewIfPossible(with notificationPayload: [AnyHashable: Any], navigationController: UINavigationController) {
      guard let data = notificationPayload as? [String: AnyObject] else { return }

      do {
          let notificationType: RemoteNotification.NotificationType = try RemoteNotification.convert(from: data)
          let viewController: UIViewController
          switch notificationType { // notificationType によって遷移先を変える
          case .searchResultsList(let entity):
              viewController = SearchResultsListViewController(keyword: entity.searchKeyword, order: entity.order)
          case .productDetail(let entity):
              viewController = ProductDetailViewController(id: entity.postID)
          }
          navigationController.pushViewController(viewController, animated: true)
      } catch {
          // エラー時の処理
          print(error)
          return
      }
  }  
}

前の記事で説明したように、例えばアプリが未起動状態でプッシュ通知をタップして開いた場合に遷移させたければ、AppDelegateを以下のように記述すれば遷移できるようになります。(処理がちょっと雑ですが...)

class AppDelegate: UIResponder, UIApplicationDelegate {
  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
      if let data = launchOptions?[.remoteNotification] as? [AnyHashable: Any], let rootViewController = window?.rootViewController {
        let navigationController = UINavigationController(rootViewController: rootViewController)
        PushNotificationRouter.openTargetViewIfPossible(with: data, navigationController: navigationController)
      }
      return true
  }
}

まとめ

ということで、通知タップ時の遷移先を管理するための構造体について紹介しました。

遷移先ごとに異なるstructを用意し、必要な値は必ず存在するようにすること、そしてそれらをenumとそのAssociated Valuesで管理することで、利用する際にオプショナル・バインディングの記述量も少なくて済みスッキリ見通しが良くなります。

他にも良い方法はありそうですが、ひとまず私なりのやり方をブログに書き起こしてみました。
同じような課題の解決策を探している人の参考になればいいなと思います😊

ではでは〜👋👋