サービス特性にあった検索システムの設計戦略

こんにちは!研究開発部ソフトウェアエンジニアの林田千瑛(@chie8842)です。あまりたくさん飲めないけど日本酒が好きです。 クックパッドが提供するサービスの検索や推薦機能の構築・改善を行っています。

本稿では、クックパッド本体の検索改善や推薦システム構築の傍らで、新規サービスであるクックパッドマート向けの検索システムをつくったので、その際の設計や精度改善の工夫について書きます。

新規サービスクックパッドマートと検索

クックパッドマートは、生鮮食品に特化したECサービスで、ステーションと呼ばれる場所に購入した食品を届けてくれるという特徴をもっています。2018年夏にサービス開始して以来順調にユーザ数を伸ばしています。中でも商品検索機能は、クックパッドマートの追加機能として9月にリリースしました。

検索システムの要件

プロダクトチームの当初の要件は以下のとおりでした。

  • まずは 1ヶ月で リリースしたい
  • 最初は商品検索機能を提供したいが、その後GISデータを用いた食品を受け取るステーション検索などが必要になる可能性がある
  • 商品検索は、UI/UXの要件上、絞り込み検索などではなく単純なキーワード検索がしたい
  • 商品インデックスのリアルタイム更新はあまり重要でない

また、データを眺めたり実際にサービスを使ったりする中で以下のようなことを予想できました。

  • インデックスサイズについて

    • 現状のインデックスサイズはそれほど大きくない(現在1G程度)
    • サービスの成長率が高く、将来的に商品数が増えることでインデックス化するデータは格段に増える可能性はある。しかし、配送機能をもつというサービスの特性上、配送エリアごとにインデックスを分けるといったことでインデックスサイズの上限は抑えられる
  • 検索精度のチューニングについて

    • 検索の使われ方と商品数を考慮すると、適合率よりユーザの目的にヒットしうる商品の再現率を高めることを重要視した方がよさそう
    • サービス側のキャンペーンなどの施策の追加が想定される。そのため今後インデックスのスキーマ変更やクエリのチューニングを行いやすいようにしたい
  • プロダクトチームの体制について

    • プロダクトチームはスピードを重視しており、メンテナンスコストは低い方がいい

上記を考慮して、最初のリリースに向けては以下の設計方針ですすめることにしました。

  • 検索インデックスやクエリは一旦今ある情報をもとに設計して一部のユーザにリリースし、実際の使い勝手を見ながらチューニングしつつ利用者を広げる(オフラインテストなどはあまりかっちりやらない)
  • 基盤設計は慎重に行い、今後のシステムのスケールやメンテナンスを行いやすいようにする

検索基盤の設計

検索サーバにはElasticsearchを利用します。 クックパッドはインフラ環境にAWSを利用しており、その上にElasticsearchサーバをデプロイする方法としては以下の3つが考えられます。

  1. Amazon Elasticsearch Service(以降AES)を利用する方法
  2. EC2上に構築する方法
  3. ECSクラスタ上に構築する方法

1.のAmazon Elasticsearch Serviceを利用するか2.3.の方法で自前でElasticsearchを構築するかという判断がまずあります。

AESはクックパッドの他のサービスでも一部取り入れられているという背景もあり、最初はAESを使うのがよいのではないかという声がありました。しかし、マネージドサービスは、システム管理の負荷を軽減できるというメリットがある一方で、そのマネージドサービスが提供する機能は大なり小なり限定されることを考慮する必要があります。よってマネージドサービスで提供される機能がサービスが必要とする機能範囲をカバーできるかどうかを見定める必要があります。

今回の要件にAESが合致するかどうかは検証を行い、結果的に利用しないことに決めました。 ボトルネックとなったのは以下の点です。

  • 既存インデックス上のAnalyzerの設定変更ができない(設定の異なる新規インデックスを作成した上でaliasを切り替えることで代用はできるがオペレーションが煩雑化する1
  • ユーザ辞書やシノニム(同義語)辞書をファイルで指定できない
  • AESはサポートされるプラグインが限られており、中でも日本語のTokenizerとしてkuromoji_tokenizerは利用できるが、素のElasticsearchであれば利用できるNEologd, UniDicといった別の辞書を用いるTokenizerを提供するプラグインが利用できない2
  • 出力できるログが限定的である。(エラーログとスローログしか取得できない)

クックパッドマートの商品検索は、例えば「じゃがいも」と入力したら「メークイン」や「ジャガイモ」でマッチする商品も検索結果に出したいといったように、日本語の辞書やシノニムといったAnalyzerのチューニングが検索精度に大きく影響するタイプです。 日本語を使わない場合やTwitterやInstagramで見られるようなシノニムの重要度が高くないタイプのキーワード検索、geolocation検索であればAESで提供される機能で十分な場合があると思いますが、今回の要件には合致しないという判断になりました。

さらに残る 2.のEC2と3.のECSについて検討を行いました。Elasticsearchはインデックスを保存するという意味である種のDBの機能を持ちますが、DBは基本的にECSなどのコンテナオーケストレーション環境にデプロイする例はあまりないと思います。なぜかというと、Elasticsearchやその他のDBはクラスタネットワーク内でノードディスカバリを行い、データの永続化やレプリケーションを行うことでデータの可用性とスケーラビリティ担保するクラスタ構成機能を持ちますが、エフェメラルな環境としてアプリのデプロイを行うことを目的として発展したコンテナ環境はこうしたことを前提として作られていないからです。(できないとは言っていないです。)

上記を考慮すると、基本的にはデプロイ先としてEC2を選ぶべきです。しかし、今回のケースは、以下のことがわかっていました。

  • (将来を考慮しても)1サーバ上でデータを持てるくらいインデックスデータが小さく保てる
  • インデックスのリアルタイムな更新が必要ないため、障害時のデータ保証を考慮したデータの永続化が比較的簡素で済む

この場合、複数台によるクラスタを組まずとも、Elasticsearchを単なる1ノードの検索アプリケーションと見てデータは外部ストレージ上に永続化することで、コンテナオーケストレーション環境上で他のRailsなどのアプリケーションと同じように可用性とリクエストに対するスケーラビリティを担保したデプロイができます。

EC2上にデプロイすることになると、物理サーバによるクラスタのメンテナンスコストがかかってしまうため、S3上でインデックスデータを管理し、ECS上にデプロイすることにしました。

f:id:chie8842:20191118113708p:plain

クックパッドマートは、他機能もECS上で動作しています。検索サーバも管理を同じ環境にすることでDockerのデプロイに慣れているメンバであれば検索サーバのデプロイ学習コストを小さくとどめることができました。

精度のチューニング

上述したとおり、今回の検索サーバは、エリア内の生鮮食品の検索という特性からそもそも適合するアイテム数が少なく、それらをすべて検索結果に出すことが大切になります。一方で明らかに関係のないアイテムが混じりすぎるのも検索体験的によくありません。今回のキーワード検索においてこのバランスを保つために開発当初から今までに行った主な改善を書いておきます。

商品検索時に利用するテキストの選択

検索のインデックスはサービスのデータベース上にある商品データから作成し、それに対して検索クエリを実行します。検索対象とすべきテキストデータとしては、主に以下がありました。

  • 商品タイトル
  • 商品の説明
  • カテゴリ情報
  • ショップの名前

当初は重みをつけた上で、上記のデータをすべて使うことを考えましたが、現在は商品検索には商品タイトル、カテゴリ情報、ショップ名を利用しています。 理由は以下のとおりです。

商品の説明を利用しないことにした理由

「商品の説明」のデータでは、例えば野菜の商品の説明で、「豚肉と炒めるとおいしいです」といった文章が出てくることがあります。こうした場合、「豚肉」と検索したときにこの野菜の商品がヒットしてしまいます。このような要因によって検索体験が下がる影響が大きいだろうという判断をしました。

カテゴリ情報を利用する理由

クックパッドマートには、「とり皮」、「砂肝」など、焼き鳥の串の商品があります。(ちなみにおいしかったので買ってみてください。) しかし商品タイトルのみに対して検索を行う場合、「鶏肉」というクエリに対してこれらの焼き鳥はヒットしません。カテゴリ情報を利用すると、これらの焼き鳥の商品に対して、「鶏肉」や「肉」といったテキスト情報を利用することができるようになり、「鶏肉」の検索結果に焼き鳥の商品をヒットさせることができるようになりました。

ショップの名前を利用する理由

ショップの名前はリリース時には商品検索のためのインデックスとしては入れていませんでした。しかしながらリリース後のユーザのクエリをみると、ショップ名で商品を検索しているユーザが一定数いることが判明しました。そこでショップ名からも商品を検索できるようにしました。

Analyzerのチューニング

検索結果の再現率を上げるためには、Analyzerのチューニングも重要です。チューニングでは、主に以下のことを行いました。

Tokenizerにkuromoji-ipadic-neologdとNGram Tokenizerを併用する

Elasticsearchにおける日本語に特化したTokenizerには、Elasticsearch本体に組み込まれているkuromoji_tokenizer(辞書はIPADic)の他に、形態素解析エンジンには同じkuromojiを利用してNEologd、UniDicといった別の辞書を利用するものや、最近作られたSudachiという形態素解析エンジンを利用するものがあります。また、辞書にない単語をとってくる方法として、NGram Tokenizerなどがあります。今回は、語彙数が多いkuromoji_ipadic_neologd TokenizerとNGram Tokenizerを重み付けして併用することで、できるだけ多く適合するアイテムをヒットさせることを目指しました。 なお、辞書としてUniDicを利用した方が細かい単位で単語を取得できる可能性もありますが、今回のようなヒットするアイテム数が少ないケースでは、検索結果のランク順くらいしか違いがでない、かつランク順がそれほど重要でないだろうと予想されるため試していません。

シノニムの重要性

上で例を出したとおり、じゃがいもを購入したいとき、検索結果には「メークイン」や「男爵いも」も出した方がいいでしょう。 このようにクックパッドマートでは、シノニムを考慮した検索が重要でした。 クックパッド本体で利用している辞書資源から必要なデータを取得できたので、最初のリリース後にシノニム情報を追加しました。

チューニング結果

クックパッドマートの検索精度は、リリース後にプロダクト側からの意見をヒアリングしつつ改善しました。 サービス自体がまだ若く、A/Bテストを行うような基盤はないため、別期間での比較になってしまいますが、リリース直後(チューニング前)では検索画面から目的の商品に出会えた確率(検索画面から商品ページへの遷移率と検索画面からカートイン率の合計)が 61% だったのが、チューニング後には 84% まで増加することができました。

今後の課題

検索システムは一度作って終わりというわけにはいきません。今後もユーザやプロダクトの成長に合わせて精度の改善を行う必要があります。また、今後は今の商品検索以外の検索機能が必要となる可能性もあります。 サービスの成長にあわせて検索の精度改善や機能追加を進めていけるとよいと思います。

最後に

検索に限らず、よいシステムをつくるには必要な機能を決めるためにUI・UXを考えるデザイナ、プロダクトオーナの協力が大切です。今回の検索システム構築においては、ryo-katsumaをはじめとしたクックパッドマートのプロダクトチームが明確な検索ストーリーを提示してくれ、また積極的に検索システムを使って実際に使って課題や要望を伝えてくれたため、スムーズに要件を固めて改善につなげることができました。 また、検索精度のチューニングについては、同じ研究開発部の@takahi-iが親身にレビューをしてくれました。

このようにサービスのドメイン知識と技術知識をもつメンバ同士で連携できたことで、スピード感をもってよい検索システムを作ることができたと思います。

クックパッド採用説明会「クックパッドはサービスの作り手を採用したいんです。」を開催しました!

こんにちは、メディアプロダクト開発部の長田(おさだ)です。

クックパッドは、エンジニア、デザイナーを絶賛大募集しています。先日「クックパッドはサービスの作り手を採用したいんです。」というイベントを開催したのでその時の様子をお伝えします。
https://cookpad.connpass.com/event/149581/

開始

まずはクックパッドの紹介から始まりました。

f:id:osadake212:20191108125332j:plain

ご存知ない方もいらっしゃるかと思うのですが、クックパッドではレシピサービス以外にも、スマートキッチンサービスの OiCyクッキング Live 配信が視聴できる cookpadLiveおいしい食べ方を学習できる たべドリ生鮮食品 EC プラットフォームの cookpad mart料理が楽しくなるマルシェアプリ komerco など、たくさんのサービスを開発しています。

また、レシピサービスは全世界に展開しており、2019年10月末時点で73カ国/地域、30言語に対応しています。全世界の月間利用者数は1億人近く、投稿されているレシピ数は590万品を突破しています。

完成されたサービスに見えるとよく言われるクックパッドですが、実はやりたいことの1%もできていないのが現状です。
「毎日の料理を楽しくする」ことで世の中をよくしていきたい我々は、レシピサービスにとどまることなく、 いくつもの新しい機能や新規事業を立ち上げ拡大している最中です。

このイベントは、そんなクックパッドで一緒にサービスを作ってくれる仲間を探すために開催されました。
発表のセクションでは、クックパッドのサービス開発の様子をクックパッドマート、 cookpadLive の開発を通して紹介しました。

発表① クックパッドマート ディレクターのいない◯◯な開発スタイル

クックパッドマートからは、長野 佳子(@naganyo)・米田 哲丈(@tyoneda)による、開発スタイルについての発表を行いました。

f:id:osadake212:20191108125342j:plain

クックパッドマートでは「なにをつくるか」をどのように決めるのか、またそれをどのように実現しているのかについて紹介しました。

「なにをつくるか」は KGI ブレークダウンで決まるのがベースになります。
さらにそれだけではなく、サービスに対する「気づき」を得る機会を増やし様々な観点からサービスを俯瞰することで、なにをつくるかが日々生まれています。
生まれてきた「なにをつくるか」は事業的な観点から絞り込まれ、開発・リリースされていきます。

また、「どうやってつくるか」についてはディレクターがいないので、全員で起案し、デザインも開発も同時に進めています。
アイデアの可視化、開発しながら機能をブラッシュアップ、リリース後にチューニングするなど、職種にとらわれない「サービスの作り手」が集まっています。

詳しくはこちらの資料をご覧ください。
https://speakerdeck.com/tyoneda/20191030-kutukupatudomato-deirekutafalseinaioonakai-fa-sutairu

発表② cookpadLive 短期間で行うサービス開発術

cookpadLive からは、若月 啓聡(@puzzeljp)・長田 卓哉(osadake212)による、こちらも開発スタイルについての発表を行いました。

f:id:osadake212:20191108125351j:plain

cookpadLive では、短期間でサービスを実現するために、デザイナー・エンジニアがどのような動きをしているのかをそれぞれの視点で工夫していることを紹介しました。

デザイナーはビジネス要件(納期)も考慮しながらエンジニアがより高速に開発ができる取り組みをしています。
取り外し可能なデザインを作成することで、サービスの価値を損なわないことを担保しながら、エンジニアの実装の都合に合わせて柔軟にデザインを変更することが可能になります。

エンジニアは、設計や実装だけではなく役割を越えて、アイデアだし・仕様・画面遷移の検討・オペレーションの検討を行い、サービス開発に積極的に関わっています。
職種にとらわれず、全員がサービスの成長に対してできることを実行しながら開発を進めています。

詳しくはこちらの資料をご覧ください。
https://speakerdeck.com/osadake212/cookpadlive-duan-qi-jian-dexing-usabisukai-fa-shu

Q&Aパネルディスカッション

このセクションでは、イベント開始前に受け付けていた質問や、発表を聞いて気になった質問に対して社員が答える形式で、サービス開発や社内の様子等についてディスカッションしていきました。

f:id:osadake212:20191107175329j:plain

ありがたいことに、タイムテーブルの40分では答えきれないくらいの質問をいただきました。
質問内容は、クックパッドマート・cookpadLive のサービスについて、クックパッド全体のサービス開発チームの雰囲気について、入社してからの働き方についてなど、サービス開発だけではなく幅広くご質問をいただきました。

個別面談・懇親会

f:id:osadake212:20191108125633j:plain

「イベントでいきなり個別面談ってなに?」となりそうですが、本イベントでは希望者の方に、エンジニア採用責任者の一人である勝間(買物事業部 副部長)、デザイナー採用責任者の一人である倉光(デザイン戦略部 部長)と個別に一対一でお話ができる場を設けました。
一人10分弱の面談枠をそれぞれ4つ用意していたのですが、いったい何人の方に個別面談していただけるのだろうか・・・と不安だったのですが、なんと全ての枠が埋まり8名の方と面談することができました。

懇親会では、弊社のサービス開発に関わっているエンジニア、デザイナー6名がそれぞれテーブルにわかれ、ざっくばらんに参加者とお話させていただきました。
私、長田のテーブルでは、クックパッドの開発基盤の話から、サービスのグロースの話、チーム・プロジェクトマネジメントの話など、本当にたくさんのことについて話すことができ、弊社の様子をより伝えることができたのではないかと感じます。

まとめ

冒頭でも触れたように、クックパッドでは一緒にサービスを作ってくれる仲間を募集しています。
現在募集中のポジションはこちらからご確認いただけます。
https://info.cookpad.com/careers/jobs/

「まずは話だけでも聞いてみようか」というのも大歓迎なので気軽にお声がけいただけると幸いです。
recruit@cookpad.com

Firebase In-App MessagingのUIをカスタマイズして運用する

Komerco事業部エンジニアの岸本(@_sgr_ksmt)です。
昨年Cloud Firestoreのrulesのテストを全てローカルエミュレータを使うように書き換えた話を書いてからだいぶ間が空いてしまいましたが投稿します。

今回はFirebase In-App Messagingを利用する際にカスタムUIを適応して運用している話をしたいと思います。

f:id:sgrksmt:20191025105713p:plain:w300

In-App Messaging

Firebase In-App Messaging(以下FIAMと呼びます)は、指定した条件で絞り込んだアクティブユーザーに対して、
メッセージやボタンのアクションを設定し、アプリ内で表示するためのFirebaseの一つの機能です。
表示形式としてはいわゆる「ポップアップ」「画面上部のバナー」といった形式で表示することが可能です。

ユーザーに出すための条件にアプリのターゲット、バージョン、オーディエンス、ユーザープロパティといった情報を活用することができるので、特定のユーザーにプロモーションを行うことが容易にできます。

また、メッセージを表示するUIはSDK側が標準で提供してくれるので、クライアント側はSDKをインストールするだけで実装完了になります。 また、FIAMの設定では文字の色、ボタンのアクションなど、ある程度カスタマイズすることも可能になっています。

f:id:sgrksmt:20191025105732p:plain:w600

標準で使う場合の難点

しかし、標準のまま活用するとUIに関して次のような問題点がでてきます。

  • 文字の大きさ、フォントを変更することができない
  • 文字や画像の並びを変更することができない
  • 一部の表示形式だとボタンの背景色を変更できない
  • ボタンの大きさを変更することができない


表示用UIを標準で用意してくれているのは大変うれしいのですが、どうしてもサービスのUIと比べると浮いてしまうことと、カスタマイズ可能な範囲が狭いというのが難点になってきます。
もしかしたら読者の中にはその標準UIが微妙でFIAMの使用を断念してしまった方も居るのではないでしょうか。 というわけで、次項からカスタムUIを適応していく方法を紹介します。 (※ちなみにKomercoはiOSアプリのみ配信している関係で以降の内容はiOSでのカスタムUIの適応の話になります。

カスタムUIを適応する

公式のドキュメントにあるこちらの内容を紐解きながら解説していきます。

Podfileの編集

Podfileを開いて、Firebase/InAppMessagingDisplayFirebase/InAppMessagingに変更してインストールし直します。

- pod 'Firebase/InAppMessagingDisplay'
+ pod `Firebase/InAppMessaging'


CustomMessageDisplayComponentクラスの作成

次に、InAppMessagingDisplayプロトコルに適合したCustomMessageDisplayComponentクラスを作成します。
displayMessage(_:displayDelegate:)メソッドを実装し、引数で渡ってくるmessageのデータを判別し「Card」「Modal」「Banner」「Image Only」それぞれ表示するUIを出し分けるようにします。 以下はCardタイプの場合に、サービス内で使用しているPopupクラスを活用して表示する例を示しています。

import Firebase
import Foundation

private enum IAMDisplay {
    case unknown
    case card(InAppMessagingCardDisplay)
    case modal(InAppMessagingModalDisplay)
    case banner(InAppMessagingBannerDisplay)
    case imageOnly(InAppMessagingImageOnlyDisplay)

    init(_ messageForDisplay: InAppMessagingDisplayMessage) {
        switch messageForDisplay.type {
        case .card:
            self = (messageForDisplay as? InAppMessagingCardDisplay).map { .card($0) } ?? .unknown
        case .modal:
            self = (messageForDisplay as? InAppMessagingModalDisplay).map { .modal($0) } ?? .unknown
        case .banner:
            self = (messageForDisplay as? InAppMessagingBannerDisplay).map { .banner($0) } ?? .unknown
        case .imageOnly:
            self = (messageForDisplay as? InAppMessagingImageOnlyDisplay).map { .imageOnly($0) } ?? .unknown
        @unknown default:
            self = .unknown
        }
    }
}

final class CustomMessageDisplayComponent: InAppMessagingDisplay {
    func displayMessage(_ messageForDisplay: InAppMessagingDisplayMessage, displayDelegate: InAppMessagingDisplayDelegate) {
        DispatchQueue.main.async {
            displayDelegate.impressionDetected?(for: messageForDisplay) // ★
            switch IAMDisplay(messageForDisplay) {
            case let .card(card):
                Popup.show(
                    title: card.title,
                    body: card.body,
                    image: URL(string: card.portraitImageData.imageURL),
                    primaryButton: card.primaryActionButton.buttonText,
                    secondaryButton: card.secondaryActionButton?.buttonText,
                    buttonActionHandler: { button in
                        switch button {
                            case .primary:
                                print(card.primaryActionURL)
                                // URLを開く処理
                            case .secondary:
                                print(card.secondaryActionURL)
                                // URLを開く処理
                        }
                    }   
                )
            case let .modal(modal):
                // Modalタイプの場合の表示実装
            }
        }
    }
}



messageForDisplay.type を見ることでどの表示形式か判定できるのでそれを活用し、更にそれぞれの表示形式で扱うクラスにダウンキャストして使用します。 各種表示形式でアクセスすることが出来る情報(プロパティは次のようになっています)

f:id:sgrksmt:20191025105720p:plain:w600


また、カスタムUIを表示する際はで示しているdisplayDelegate.impressionDetected?(for:)メソッドを呼び出す必要があります。 例で示しているPopupクラスはUI含めてご自身で実装してください。

messageDisplayComponentを指定する

CustomMessageDisplayComponentを実装できたら、In-App Messagingに適応します。 次のコードをFirebaseApp.configure()の呼び出し以降で設定します。可能であればこの呼出の直後に次のコードを書くと良いでしょう。

InAppMessaging.inAppMessaging().messageDisplayComponent = CustomMessageDisplayComponent()


これで、In-App Messagingの配信をアプリが受け取った際にカスタムUIで表示することが可能になります。

カスタムUIを使う際のルールを決めておく

Komercoでは、カスタムUI側でフォントの色などを指定して運用するようにしたため、 FIAMでのメッセージ配信の設定画面では色に関する設定はしない(無視する)ようにしています。

f:id:sgrksmt:20191025105735p:plain:w600

Before/After

ここまで実装ができると、同じ設定でも変更前後でこのようにUIが変わります。

Before After
f:id:sgrksmt:20191025105659p:plain:w300 f:id:sgrksmt:20191025105650p:plain:w300

デバッグがしやすくなるTips

ちょっとしたTipsですが、FIAMは指定したアナリティクスイベントを発火させないと表示されないですが、次のようにコードでメッセージ配信設定したアナリティクスイベントの名前を指定してあげると即座に表示させることができます。

InAppMessaging.inAppMessaging().triggerEvent("show_product_detail")



- 参考: In-App-Messagingのキャンペーンを手動で呼び出せるようになった

メッセージ表示やボタンタップ時のイベントをアナリティクスに別途送りたい

カスタムUIでメッセージ表示をしたり、ボタンを押した時に別途アナリティクスイベントを収集する場合は、messageForDisplay 変数からキャンペーン名を取得することができるため、これを活用するとどのキャンペーンでのイベント発火だったのか判断することができます。

f:id:sgrksmt:20191025105741p:plain

let campaignName = messageForDisplay.campaignInfo.campaignName // 設定したキャンペーン名が取得できる
Logger.postLog(.showInAppMessaging(campaignName: campaignName))


注意点

displayDelegate.impressionDetectedの呼び忘れに注意

displayDelegate.impressionDetected?(for:)メソッドを呼び忘れすと、SDK側でユーザーが見たかどうかの集計が行われないため、条件に設定しているアナリティクスイベントが発火するたび何度もユーザーに表示されてしまいます。

ポップアップの表示制御が必要なら別途実装する

もしカスタムUIで表示するポップアップのクラスをサービス内の別の場所で使用していたり、FIAMの制御外でも非同期通信を経て何かしら表示する可能性があったりする場合は自信で重複して表示されないように制御ロジックを実装しておきましょう。

func displayMessage(_ messageForDisplay: InAppMessagingDisplayMessage, displayDelegate: InAppMessagingDisplayDelegate) {
    DispatchQueue.main.async {
        if Popup.isAlreadyShown { return }
        // カスタムUI表示処理を続行
    }
}


カスタムUIを適応すると標準UIは使えなくなる

この方法でカスタムUIを適応した場合、標準UIを呼び出すことはできなくなります。 例えば、Card、Modalタイプであれば用意したUIを使い、Banner、Image Onlyタイプであれば標準のUIを呼び出す、といったことは不可能です。
もし標準のUIを実装したい場合は、「SDK側」のソースコードを参考に作成するか、そのタイプの使用を諦めるのも一つの手になります。

KomercoではBannerタイプ、Image Onlyタイプの配信は行わない事にしたので表示実装はしていません。

まとめ

カスタムUIを適応してあげることで、よりサービスに馴染んだ形でメッセージ配信を行うことができるのでよりプロモーションに活かせるようになると思います。
標準UIを敬遠して使ってなかった方、そこがネックでFirebaseを使っているにも関わらず自前でポップアップの配信機能を実装をしていた方、これを機にカスタムUIを適応してFIAMを使ってみてはいかがでしょうか。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/