Todd Kerpelman
Developer Advocate
FirebaseDevelopers チャンネル ファンの皆さんなら、Remote Config からアプリに値を読み込む最善策を紹介した動画が公開されたことをご存じかもしれません。




ですが、動画を見るより記事を読みたい方もいるでしょう。そこで、この内容を便利なブログ形式でもご紹介します。

すでに Remote Config を使ったことがある方なら、アプリでの Remote Config の実装は主に次の 3 つのステップで行われることをご存じでしょう。
  1. Remote Config のさまざまな既定値を設定します。これは、コードで、またはローカルの plist や xml ファイルから値を取り込むことで、端末上でローカルに設定されます。
  2. 次に、fetch() を呼び出して新しい値をクラウドからダウンロードします。
  3. 最後に activateFetched() を呼び出し、最初に設定された既定値に対してダウンロードした値を適用します。
この時点で、Remote Config に特定の値を問い合わせると、クラウドからアップデートされた値か、ローカルに設定されている既定値のどちらかが取得されます。これはすばらしい動作です。アプリの すべての 値を Remote Config 経由で取得できるようになるからです。既定値とは異なる値のみがダウンロードされるため、ネットワークの呼び出しは適切かつ最小限に抑えることができます。しかも、アプリのあらゆる動作を後からクラウド経由で変更できるという柔軟性が得られます。

とても簡単だと思いませんか。基本的には、そのとおりです。問題なのは、先ほどのステップ 2 です。ここではネットワークの呼び出しが必要になりますが、現実の世界では、トンネル、エレベーター、砂漠などのネットワーク接続が不安定な場所でアプリを使う人々がいます。そのため、ネットワークの呼び出しが完了するまでにどのくらい時間がかかるかは予想できません。つまり、ユーザーがアプリを使い始める前にこのプロセスが完了することは保証されません。

これを踏まえて、次の 3 つの戦略が考えられます。

戦略 1: 反映して更新

これはもっとも単純な戦略です。そのため、サンプルアプリやチュートリアルでは、一般的にこの方法が使われています。この考え方では、クラウドから新しい値をダウンロードし終えたタイミングで、完了ハンドラを使って activateFetched() を呼び出し、それをすぐに反映させてから、アプリをアップデートして処理を続けます。


この戦略が優れているのは、ユーザーがすぐにアプリを使い始めることができる点です。しかし、アプリを使用している最中にボタンのテキストや UI 配置などの重要な値が変更されるのは違和感があるでしょう。ユーザー エクスペリエンスの低下につながるかもしれません。そのため、多くのデベロッパーは次のようなソリューションを選ぶ傾向にあります。

戦略 2: ロード中画面の追加

よく使われる戦略として、ユーザーがアプリを開始した際にロード中画面を表示する方法があります。先ほどと同じように、完了ハンドラで activateFetched を呼んで値を即座に適用しますが、アプリのインターフェースを更新するのではなく、ロード中画面を閉じてメイン コンテンツに遷移します。ロード中画面が終わってアプリに入ると、すべてがうまくいっているように見えます。ほとんどの場合、クラウドから最新の値がダウンロードされ、アプリがそれを使う準備も完了していることが保証されます。



しかし、この方式の明らかに大きな欠点は、ロード中画面が追加されたことです。開発にかかる時間や手間もさることながら、ユーザーがアプリに入る際の壁になります。しかし、すでにアプリにロード中画面があり、開始時に他の作業やネットワークの呼び出しを行っているのであれば、これは優れた戦略となるでしょう。その場合は、他の初期化作業と合わせて Remote Config の値を取得しましょう。

しかし、アプリにまだロード中画面がない場合は、別の戦略を試してみることをお勧めします。たとえば、次のようなものです。

戦略 3: 値を読み込み、次回に反映

これはあまり直感的ではないかもしれませんが、ぜひお読みください。ユーザーがアプリを開始した際、即座に activateFetched() を呼び出します。すると、クラウドから取得した前回の値が適用されます。ユーザーはすぐにアプリを使い始めることができます。
その間に、非同期に fetch() を呼び出してクラウドから新しい値を取得します。完了ハンドラでは、何もしません。そもそも、完了ハンドラを追加する必要すらありません。クラウドから取得した値は、ユーザーが次にアプリを起動して activateFetched を呼び出すまで、端末のローカルに保存されたままになります。



これは、ユーザーがすぐにアプリを使い始めることができる優れた戦略で、アプリのインターフェースが突然変わったりするような奇妙な動作も起こりません。明らかな欠点は、ユーザーがアップデートされた Remote Config の値を確認できるまで 2 セッションが必要になることです。この動作がアプリにふさわしいかどうかは、ご自身で判断してください。
タワー ディフェンス ゲームの設定値の微調整に Remote Config を使うような場合は、おそらく問題ないでしょう。しかし、日ごとにメッセージを送信したり、日付に固有のコンテンツを提供したりする場合(アプリのスキンを休日用に変更するなど)にはふさわしくないかもしれません。

戦略 3.5: 戦略 2 と 3、または 1 と 3 の中間

Remote Config が優れている点の 1 つは、最後に fetch() の呼び出しが成功したのはいつなのかがわかることです。そのため、Remote Config データの最終取得日時を最初に確認するという中間戦略をとることができます。それが最近(たとえば、48 時間以内など)であれば、そのまま先に進み、「前回取得した値を適用して、次回のために値を取得する」戦略をとることができます。それ以外の場合は、最初の 2 つの戦略のうちどちらかを実行します。



キャッシュについて

ここまで読み込み戦略の話をしてきたので、関連するキャッシュの話題に進みましょう。Remote Config の値は、fetch() を呼び出すたびに即座に取得されるのではなく、12 時間キャッシュされます。この点を勘違いしているデベロッパーもいるようです。また、キャッシュ時間を短くすることはできますが、ネットワークの呼び出しを頻繁に実行しすぎると、クライアントまたは Remote Config サービスによる制限がアプリに適用されることもあります。

では、そもそもなぜこのようなキャッシュや制限の仕組みが存在しているのでしょうか。

その理由の 1 つは、たとえ何百万というユーザーが使うアプリがあっても、確実にこの無償サービスを維持できるようにするためです。適切に動作するキャッシュの仕組みが追加されているので、アプリがどんなに有名になったとしても、このサービスを無償で利用できることが保証されています。

さらに、この仕組みは質の悪いコードからサービスを守る方法としても有効です。中には、意図せず fetch() を何度も呼び出すようなデベロッパーもいます(もちろん、皆さんのことではありません)。あるデベロッパーが意図せず DDoS を行ったとしても、サービス全体が影響を受けないようにする必要があります。クライアント側のライブラリでキャッシュされた値を提供したり、頻繁に行われるネットワークの呼び出しを制限したりすれば、安全にサービスを維持できます。

これは、ユーザーにとってもメリットになります。優れたキャッシュ戦略があれば、アプリから不要なネットワークの呼び出しを何度も行ってユーザーの電池やデータプランを使い果たすようなことはなくなります。そのため、優れたキャッシュ戦略は常に実装すべきものです。

もちろん、Remote Config の実装の開発やテストの際に、この動作を不便に感じることがあるでしょう。そのため、デベロッパー モードをオンにしてローカルでの制限動作を無視できるようになっています。しかし実際には、たとえユーザーが毎日アプリを使ったとしても、通常、こういったキャッシュ動作はメリットだけをもたらします。

しかし、もっと頻繁に値をプッシュしたい場合はどうすればよいでしょうか。たとえば、Remote Config で「時間ごとのメッセージ」のような機能を提供したいような場合です。率直に言えば、これは Remote Config サービスが適切なソリューションではないことの表れかもしれません。タイムリーに何かを行いたい場合は、Realtime Database の利用を検討するとよいでしょう。

一方で、頻度の少ない Remote Config アップデートを大至急適用したい場合もあるかもしれません。前回の変更で意図せずにアプリ内の秩序を乱してしまった場合や、ただちに新しい値を全員に適用したい場合などが考えられます。キャッシュを回避してクライアントに値の取得を強制させるには、どうすればよいでしょうか。

考えられるソリューションの 1 つは、Firebase Cloud Messaging を使うことです。FCM を使うと、すべての端末にデータのみの通知を送り、保留されている緊急アップデートについて知らせることができます。この通知を受信したアプリは、なんらかのフラグをローカルに保存します。次にユーザーがアプリを起動した際に、そのフラグを確認し、それが true であれば、即時性が保証されるようにキャッシュ時間 0 で Remote Config の値を取得します。完了時には、忘れずにフラグをクリアして通常のキャッシュ動作に戻すようにしてください。

受信した通知に対してクライアントを応答させ、即座にスリープを解除して Remote Config サービスから新しい値を取得させたくなるかもしれませんが、インストールされたすべてのアプリが一斉にこれを行うと、サーバー側の制限の対象になる可能性が高くなります。そのため、このような動作をさせるのではなく、前述の戦略を使用します。そうすることで、すべてのユーザーがアプリを開くまでの期間、ネットワークの呼び出しを分散させることができます。

いかがでしょうか。今回は、Remote Config から値を読み込む際のちょっとしたテクニックについて紹介しました。なにか参考になりましたか?YouTube 動画のコメントでぜひ共有してください。または、Firebase Talk グループでどのように Remote Config を実装しているかをお知らせください。


Posted by Khanh LeViet - Developer Relations Team

Posted by Yuichi Araki - Developer Relations Team


まず、注文管理を Google Payments Center から Google Play Developer Console に移動し、いくつかの機能を改善しています。課金設定は、引き続き payments.google.com からも利用できますが、Developer Console からもアクセスできるようになります。新機能には適切なアクセス制御を設定できるため、必要なツールへのアクセス権のみをユーザーに与えることができます。



Google Play Developer Console の新しい注文管理タブ

今まで Google Payments Center で行っていたタスクは、Developer Console から実行できます。さらに、いくつかの改善も行っています。
  • 一括返金: 複数の注文を選択して同時に返金できるようになり、個々に返金する必要はなくなります。
  • 定期購入のキャンセル: 注文管理タブから、直接(別の UI を開かずに)返金と定期購入の取り消しを行うことができます。
  • パーミッション: Developer Console に「注文の管理」という新しいユーザー アクセス パーミッションを追加しました。このパーミッションを持つユーザーは、注文の検索、返金、定期購入のキャンセルが可能です。その他の機能は読み取り専用となり、売上レポートは表示されません(売上データは、「売上レポートの表示」パーミッションを持つユーザーのみ参照できます)。Developer Console からアクセスする場合、課金設定にアクセスできるのはアカウント所有者のみです。

注文管理の Developer Console への移行

Developer Console から注文管理にアクセスできるようになり、1 月 23 日以降、Payments Center からは注文管理にアクセスできなくなりました。Developer Console アカウントに新しいユーザーを追加する方法を次に示します。
  1. Google Payments Center にログインし、すべての既存ユーザーを確認します。
  2. Developer Console にログインし、Developer Console の注文管理にアクセスする必要があるすべてのユーザーに以下のいずれかまたは両方のパーミッションを追加します。
    1. 売上レポートの表示: 売上レポートへのアクセスと参照に必要なアクセス権を与えます。
    2. 注文の管理: 注文の参照や返金に必要なアクセス権を与えますが、売上の集計や統計の表示、販売および支払レポートのダウンロードはできません。
  3. 注文管理を行う新しい場所をユーザーに知らせます。


Posted by Takeshi Hagikura - Developer Relations Team

そのような要素を念頭に置き、「継続利用率」に着目しました。アプリのダウンロード後もアプリの確認によるセキュリティ チェックが定期的に行われていれば、その端末は利用継続中と見なされます。そうでない場合、DOI の可能性があると見なされます。特定の日にアプリをダウンロードし、継続利用中のすべての端末の割合がアプリの継続利用率となります。継続利用は端末の健全性を示す強力なインジケーターなので、私たちはエコシステムの継続利用率が最大化されるよう努めています。

そこで、端末の継続利用率はすべてのアプリで同じになるはずという前提のもと、アプリの DOI スコアラーを利用します。あるアプリの継続利用率が平均よりも低い標準偏差を示す場合、DOI スコアラーはそのアプリにフラグを立てます。平均から標準偏差の数値を計算する一般的な方法に、Z スコアがあります。Z スコアの方程式を次に示します。


ここで、アプリの継続利用率の Z スコアを DOI スコアと呼びます。Z スコアが -3.7 より低い場合、DOI スコアはそのアプリが統計的に著しく低い継続利用率であるという指標になります。つまり、帰無仮説を真とする場合、Z スコアの大きさがそこまで大きくなる確率は 0.01% 以下しかありません。この場合の帰無仮説とは、アプリが低い継続利用率を示すのは、アプリの動作とは関係なく偶然であるということです。
これによって、極端な値(継続利用率が低くダウンロード数が多い)を示すアプリが DOI リストのトップに現れることになります。そこから DOI スコアと他の情報を組み合わせて、アプリを PHA に分類するかどうかを判断します。次に、アプリの確認を使用してインストール済みのアプリを削除し、今後はそのアプリがインストールされないようにします。

同じ端末にダウンロードされた通常のアプリと DOI アプリの違い


実際の結果
DOI スコアを使うと、3 つの有名な不正ソフトウェアである HummingbadGhost PushGooligan を含むアプリを他の手段に比べて多く判別できました。各マルウェアの動作は異なるにもかかわらず、DOI スコアラーはこの 3 つの不正ソフトウェアに関連する 25,000 以上のアプリを判別しています。これらのマルウェアが Android の機能を損ない、無視できない数のユーザーがファクトリー リセットを行ったり、端末の利用をやめたりしているためです。このアプローチは、PHA を検出し、それが広まる前にブロックするもう 1 つの有望な手段となっています。DOI スコアラーがなければ、これらのアプリの大半が人手によるレビューをすり抜けていたはずです。
DOI スコアラーを含む Android の不正ソフトウェア対策機能は、Android のユーザーやデベロッパーを保護するための何層にもわたる取り組みの 1 つです。Android のセキュリティや透過性に関する取り組みの概要については、こちらのページをご覧ください。



Posted by Yuichi Araki - Developer Relations Team
Share on Twitter Share on Facebook

Share on Twitter Share on Facebook


なお、今回のイベントに通訳は付きません。Emily Schechter の講演は英語であることを予めご了承の上ご参加下さい。

イベント概要

名称:Chrome Tech Talk Night #9
日時:2017 年 2 月 9 日(木) 19:00 - 21:00 (受付 18:30 〜 19:30)
   ※ 終了後、懇親会(軽食付き)を行う予定です。
場所:グーグル株式会社 六本木オフィス
   東京都港区六本木 6-10-1 六本木ヒルズ 森タワー
会費:無料
定員:100 名
主催:グーグル株式会社
ハッシュタグ: #chromejp

申し込み方法

参加を希望される方は以下のフォームよりお申込みをお願いします。
https://goo.gl/forms/tMDQvaYAq6W7hyha2

プログラム

18:30 - 19:00 受付開始
19:00 - 21:00 セッション
21:00 - 22:00 懇親会

内容は変更になる可能性がございます。予めご了承ください。
皆様のご参加をお待ちしております。

Posted by Eiji Kitamura - Developer Relations Team
Share on Twitter Share on Facebook

Cloudflare の Accelerated Mobile Links を使うと、コンテンツを見つけた方法によらずアプリを高速化できるので、この問題の解決につながります。Accelerated Mobile Links を有効にすると、Cloudflare のお客様のサイト上で、AMP 版が利用できるコンテンツへのリンクを自動的に識別します。モバイル端末でリンクをクリックすると、ほぼ瞬時にAMP コンテンツが読み込まれます。





動作を確認するには、モバイル端末でこの投稿を表示し、以下のいずれかのリンクをクリックしてみてください。





ユーザー エンゲージメントの向上

Accelerated Mobile Links のメリットの 1 つは、AMP コンテンツが、コンテンツにリンクしているサイトのビューアーに直接読み込まれることです。そのため、閲覧者が AMP コンテンツを読み終えてビューアーを閉じると、リンク元のソースに戻ることができます。このように、Cloudflare のお客様のサイトはネイティブ モバイルアプリに近くなり、それによってユーザー エンゲージメントも向上します。

ブランドに合わせたユーザー エクスペリエンスを提供したい大規模なサイトオーナーに対しては、サイトオーナーのドメインに一致するようにビューアーのドメインをカスタマイズできる機能を提供する予定です。訪問者を Google のドメインにリダイレクトせずに AMP コンテンツを表示するシームレスな操作が初めて実現しました。Accelerated Mobile Links ビューアーのカスタマイズに興味を持っている大規模なサイトオーナーの皆様は、Cloudflare チームにご連絡ください。

AMP の革新

AMP の初代チャンピオンは Google ですが、AMP 関連技術はオープンです。私たちは、Cloudflare の Accelerated Mobile Links や独自の AMP キャッシュの開発にあたり、Google チームと密接に連携してきました。Google で AMP プロジェクトのテクニカル リードを務める Malte Ubl 氏は、私たちとの協業について次のように話しています。

「AMP キャッシュ ソリューションに関する Cloudflare との協業は、最高にシームレスなオープンソース開発でした。Cloudflare はプロジェクトの恒常的な貢献者になり、AMP のすべてのユーザーのためにコードベースを改善してくれました。ソフトウェア プロジェクトにとって、特定のキャッシュのサポートを多くのキャッシュのサポートへと進化させることは、常に大きな進展です。これを実現した Cloudflare のエレガントなソリューションは実に見事です」

現在、Cloudflare は Google 以外で唯一互換性がある AMP キャッシュを開発しており、Google と同じパフォーマンスやセキュリティを提供しています。

私たちは、サイトオーナーやエンドユーザーの懸念に対処するために、オープンソース精神に則ってプロジェクトの開発を進めています。とりわけ、AMP に関して寄せられている懸念に対処するために、以下の機能の開発を行っています。 

  • サイトオーナーのオリジナル ドメインを使って AMP コンテンツを簡単に共有する仕組み 
  • PC から AMP 版にアクセスした訪問者を自動的にオリジナル版のコンテンツにリダイレクト 
  • AMP 版コンテンツにリダイレクトしたくないエンドユーザーがオプトアウトできる仕組み 
  • サイトオーナーが AMP ビューアーにブランドを表示し、それを自社ドメインから提供する機能 

Cloudflare は AMP プロジェクトに貢献しています。Accelerated Mobile Links は、私たちがリリースする最初の AMP 機能ですが、今後数か月でさらに実績を重ねる予定です。現在のところ、すべての Cloudflare のお客様が無料で Accelerated Mobile Links を利用できます。この機能は、Cloudflare Performance ダッシュボードから有効化できます。モバイルウェブのさらなる高速化を実現する AMP 機能にご期待ください。


Posted by Yoshifumi Yamaguchi - Developer Relations Team
Share on Twitter Share on Facebook




画面の左側にアプリのリストが表示されます。任意のアプリをクリックすると、アプリの広告ユニットが表示されます。

広告ユニットをアーカイブするには、広告ユニットの横にあるボックスを選択し、[ARCHIVE] をクリックします。





広告をアーカイブすると、ユーザーに広告が表示されなくなり、収益も生まれなくなります。この処理は永続的なものであり、アーカイブを取り消すことはできません。この点は重要なので、覚えておいてください。

次の投稿までの間、TwitterLinkedInGoogle+ でお届けする AdMob の情報をご覧ください。


Posted by Rikako Katayama - AdMob Team
Share on Twitter Share on Facebook

このデータは API からも利用できます。最新の ドキュメントをご覧いただければ、この API を簡単に試してみることができます。次に示す簡単な HTML リクエストでは、Google 検索と Google マップで実行された、ビジネスリスティングの検索結果の数を取得します。

次の例では、利用者がある営業拠点・店舗までの運転ルートをリクエストした際の分析結果です。




この新機能によって、Google My Business API ユーザーは、顧客が Google のサービスを使って、どのように営業拠点・店舗を検索しているのか、さらにはその場所を見つけた後にどのようなアクションをとっているのかを理解した上で、ユーザーのアクションを引き起こす最適なビジネスリスティングを得ることができます。このような分析は、Google マイビジネス ウェブやモバイルでも利用できるため、場所を問わずに重要なトレンドを把握することができます。

Google My Business API の詳細や利用申請については、デベロッパー ページをご覧ください。質問やフィードバックがある方は、Google My Business API フォーラムで API チームにご連絡ください。

 
Posted by 丸山 智康 (Tomoyasu Maruyama) - Google Maps Solution Architect, Google Maps
Share on Twitter Share on Facebook

ウェブ全体の平均で、イメージはページの全データ量の 64% を占めています。つまり、効果的に最適化するには、イメージを対象にするとよいということになります。

イメージの最適化は、通信に必要なデータ量を削減する効果的な方法です。Google AMP Cache は、PageSpeed モジュールChrome データ圧縮で利用されているイメージ最適化スタックを使っています(この変換を行うために、Google AMP Cache は「Cache-Control: no-transform」ヘッダーを無視している点に注意してください)。サーバーに PageSpeed をインストールすると、サイトは元のイメージに対して同じ最適化を行うことができます。

以下に、私たちが行った最適化の概要を示します。

1)見えない、または見えにくいデータを削除する
サムネイルや位置情報メタデータなど、ユーザーに見えないイメージデータを削除します。画質とカラーサンプルが必要以上に高い JPEG イメージでは、そのレベルを下げます。厳密に言えば、JPEG 画質を 85、カラーサンプルを 4:2:0(4 ピクセルごとに 1 つのカラーサンプル)に減らします。JPEG 圧縮イメージでは、これより画質が高い、またはカラーサンプルが多い場合、データ量は増えますが目視ではほとんど違いがわかりません。

データ量を減らしたイメージデータは大幅に圧縮されます。以上の最適化によって、ユーザーの見た目には影響を与えずに 40% 以上のデータ量を削減できることがわかりました。

2)イメージを WebP 形式に変換する
特定の画像形式では、モバイルと親和性が高まります。WebP がサポートされているブラウザの場合、JPEG を WebP に変換します。この変換によって、画質を変えずにさらに 25% 以上のデータ量を削減できます。

3)srcset を追加する
「srcset」が含まれていない場合は追加します。これは、「src」属性が存在し、「srcset」属性が存在しない「amp-img」タグに適用されます。この操作では、「amp-img」タグを拡張するとともに、イメージを複数のサイズに変更しています。これによって、画面が小さい端末でさらにデータ量が削減されます。

4)状況によって画質の低いイメージを利用する
ユーザーが望む場合や、ネットワークが非常に遅い場合には、(後述の AMP Lite の一環として)JPEG イメージの画質を下げます。たとえば、データセーバーをオンにしている Chrome ユーザーに対して、JPEG イメージの画質を 50 に下げます。この変換によって、さらに JPEG イメージのデータ量を 40% 以上削減できます。

次の例は、最適化前(左)と最適化後(右)のイメージを示しています。元のイメージは 241,260 バイトでしたが、1、2、4 の最適化を行った後は、25,760 バイトになっています。最適化後もイメージはほとんど同じに見えますが、データ量の 89% が削減されています。



低速ネットワーク向けの AMP Lite


世界では、多くのユーザーが低速な接続や RAM の少ない端末でインターネットにアクセスしています。しかし、AMP ページの中には、このように帯域幅が厳しく制限されたユーザー向けに最適化されていないものもありました。そのため、Google は AMP Lite を立ち上げ、このようなユーザーのためにさらに AMP ページのデータ量を削減することにしました。

AMP Lite では、イメージに対して前述のすべての最適化を行います。特に、画質レベルは常に低いものを使用します(上記の 4 つ目の項目を参照)。

さらに、amp-font タグを使って外部フォントを最適化し、外部フォントがキャッシュ済みかどうかにかかわらずページが即座に表示されるように、フォント読み込みタイムアウトを 0 秒に設定します。

AMP Lite は、ベトナムやマレーシアなど、いくつかの国の帯域幅が限られたユーザーや、全世界の RAM が少ない端末所有者のために提供されています。イメージによっては、これらの最適化によって細部が変わってしまう場合もある点に注意してください。ただし、広告など、ページの他の部分には影響しません。

* * *

上記のすべての最適化を組み合わせると、合計 45% のデータ量を削減できます。
私たちは、今まで以上に高速な AMP を提供するために、ユーザーがさらにデータを効率的に利用できるようにしたいと考えています。


Posted by Yoshifumi Yamaguchi - Developer Relations Team
Share on Twitter Share on Facebook


作業やフィードバックをいただいた AMP 開発コミュニティの方々に感謝いたします。いつものように、問題や機能リクエストがありましたら遠慮なくお知らせください。


Posted by Yoshifumi Yamaguchi - Developer Relations Team
Share on Twitter Share on Facebook



Jen Looper
Developer Advocate at ProgressSW

NativeScript、Firebase、Angular 2 という強力な組み合わせを活用すれば、アプリの構築を一気に加速させることができます。クリスマス休暇の期間中は、アプリの開発をスピードアップさせて、プレゼントを贈りたいという家族のニーズに応えなければならないため、特にその点が重要になります。ちょうどよいことに、Angular 2 ベースの NativeScript アプリで Firebase を使う方法のデモを皆さんにプレゼントすることができます(以下をご覧ください 🎁)。これは、Eddy Verbruggen 氏による有名な NativeScript-Firebase プラグインのいくつかのコンポーネントを使ったものです。


このチュートリアルでは、よく使われている次の 4 つの Firebase 機能を NativeScript アプリで使用する方法を説明します。4 つの機能には、ログインとユーザー登録の機能を提供する Authentication、リアルタイムにアップデートされるデータ ストレージの Realtime Database、リモートでアプリを変更できる Remote Config、写真を保存する Storage があります。説明するにあたって、以前に Ionic で書いた Giftler アプリを書き直すことにしました。

実際のプロジェクトで作業に着手する前に、ドキュメントに目を通し、以下の前提事項について確認することをお勧めします。



依存モジュールのインストール

Giftler は、認証が必要な NativeScript アプリのサンプルとして作成しました。このアプリでは、ユーザーがクリスマス休暇に受け取りたいギフトを、写真や説明とともに一覧表示できます。現時点で、このアプリには iOS 版と Android 版があり、以下の機能があります。


それでは、Giftler のソースコードをフォークしましょう。これは、問題なく動作している完全版のアプリです。アプリをクローンできたら、アプリを作成した際にダウンロードした、現在の Firebase 関連のアプリのファイルを置き換えます。



これらのファイルは、アプリで Firebase を初期化し、関連する外部サービスに接続するために必要です。

次に、アプリのルートにある package.json を確認します。このファイルには、アプリで使用するプラグインが含まれています。ここでは、NativeScript に関連するプラグインに注目してください。

"nativescript-angular":"1.2.0",
"nativescript-camera": "^0.0.8",
"nativescript-iqkeyboardmanager": "^1.0.1",
"nativescript-plugin-firebase": "^3.8.4",
"nativescript-theme-core": "^1.0.2",


NativeScript-Angular プラグインは、Angular と NativeScript を統合するプラグインです。Camera プラグインを使うと、少しカメラを管理しやすくなります。IQKeyboardManager は iOS 専用のプラグインで、厄介な iOS のキーボードを管理してくれます。Theme プラグインは、スキンを完全に自作しなくてもデフォルトのスタイルをアプリに追加できるすばらしいプラグインです。最後に、このアプリでもっとも重要なのが Firebase プラグインです。

以上の依存モジュールを配置して、プラグインをインストールする準備ができたら、iOS と Android のそれぞれのコードが格納された platforms フォルダを作成し、Firebase プラグインやその他の npm ベースのプラグインを初期化して、アプリを構築できるようにします。NativeScript CLI を使ってクローンしたアプリのルートに移動し、tns run ios または tns run android を実行します。これによってプラグインをビルドするルーチンが開始されます。中でも、Firebase プラグインのさまざまなパーツのインストールが始まることが確認できるはずです。インストール スクリプトが実行されると、さまざまな Firebase サービスを統合するためのコンポーネントをインストールするかどうかの確認が表示されます。ここでは、Messaging とソーシャル認証を除くすべてを選択します。ここでの大きな特徴は、firebase.nativescript.json ファイルがアプリのルートにインストールされることです。そのため、今後プラグインの新しいパーツをインストールしたい場合、このファイルを編集するだけでプラグインを再インストールできます。


ここで、tns livesync ios --watch または tns livesync android --watch を実行し、エミュレータでアプリが起動して変更が監視されることを確認すると、アプリが実行されて新しくログインする準備ができていることがわかります。ただし、ログインを行う前に、Firebase コンソールの [Authentication] タブからメール / パスワードによるログインを有効にして、Firebase がこのタイプのログインを処理できるようにする必要があります。


この裏で何が起こっているかを確認するために、少しばかり内部の仕組みを見てみましょう。Firebase にログインするには、インストールした Firebase サービスを初期化しておく必要があります。app/main.ts には、いくつか興味深い点があります。

// this import should be first in order to load some required settings (like globals and reflect-metadata)
 import { platformNativeScriptDynamic } from "nativescript-angular/platform";

 import { AppModule } from "./app.module";
 import { BackendService } from "./services/backend.service"; 
 

 import firebase = require("nativescript-plugin-firebase"); 


 firebase.init({
   //persist should be set to false as otherwise numbers aren't returned during 
 livesync
  persist: false,
  storageBucket: 'gs://giftler-f48c4.appspot.com',
  onAuthStateChanged: (data: any) => {
    console.log(JSON.stringify(data))
    if (data.loggedIn) {
      BackendService.token = data.user.uid;
    }
    else {
      BackendService.token = "";
    }
   }
 }).then(
   function (instance) {
     console.log("firebase.init done");
   },
   function (error) {
     console.log("firebase.init error: " + error);
   }
   );
 platformNativeScriptDynamic().bootstrapModule(AppModule); 


最初に、プラグインから firebase をインポートし、.init() を呼び出します。次に storageBucket プロパティを編集し、Firebase コンソールの [Storage] タブに表示されている値を貼り付けます。

これで、お使いの Firebase アカウントに合わせてアプリがカスタマイズされ、アプリで新しいユーザーの登録とログインが可能になるはずです。必要に応じて、app/login/login.component.ts ファイルの user.email と password 変数を編集して、デフォルトのログイン認証情報を [email protected] からご自身のログインとパスワードに変更することもできます。

iOS と Android のログイン画面
注: iOS では、Xcode シミュレータを使うとすぐにアプリをエミュレートできます。ただし Android では、アプリをエミュレータにインストールするために、Google Services の有効化など、追加でいくつかの手順が必要になる場合があります。

コードの構造と認証

Angular 2 のデザイン パターンでは、コードをモジュール化する必要があります。そのため、以下のコード構造に従うものとします。

—login
  1. login.component.ts
  2. login.html
  3. login.module.ts
  4. login.routes.ts
—list …

—list-detail …

—models

  1. gift.model.ts
  2. user.model.ts
  3. index.ts
—services
  1. backend.service.ts
  2. firebase.service.ts
  3. utils.service.ts
  4. index.ts
app.component.ts

app.css

app.module.ts

app.routes.ts

auth-guard.service.ts

main.ts

Firebase の認証が Angular 2 の auth-guard.service と連携する方法に注目してください。前述のように、アプリの app/main.ts で Firebase が初期化されます。その際に、onAuthStateChanged 関数が呼び出されます。

onAuthStateChanged: (data: any) => { 
    console.log(JSON.stringify(data))
    if (data.loggedIn) {
      BackendService.token = data.user.uid;
    }
    else {
      BackendService.token = "";
    }
  }

アプリが起動する際に、Firebase から返されるデータを文字列化したものをコンソールで確認してください。そのユーザーに loggedIn フラグが立っている場合、Firebase から送り返された userId を token として設定します。NativeScript アプリケーション設定モジュールを使って、この userId を保存するかつ、これから作成するデータと関連付けます。このモジュールは、localStorage のような機能を持つモジュールです。このトークンとトークンを使う認証テストは app/services/backend.service.ts ファイルで管理されており、app/auth-guard.service.ts ファイルで利用できます。auth-guard ファイルは、アプリのログインおよびログアウト状態を適切に管理する方法を提供しています。

AuthGuard クラスは、Angular Router モジュールの CanActivate インターフェースを実装しています。

export class AuthGuard implements CanActivate {
  constructor(private router:Router) { } 

  canActivate() {
    if (BackendService.isLoggedIn()) {
      return true;
    }
    else {
      this.router.navigate(["/login"]);
      return false;
    }
  }


基本的には、前述のログイン ルーチンでトークンが設定され、かつ BackendService.isLoggedIn 関数が true を返す場合、アプリでデフォルトのルートであるウィッシュリストへのナビゲーションが許可されます。しかし、それ以外の場合、ユーザーはログイン画面に戻されます。

const listRoutes:Routes = [
  { path: "", component:ListComponent, canActivate: [AuthGuard] },
];

これで、Firebase を使う NativeScript アプリを初期化できました。次は、アプリにデータを読み込み、Firebase のすばらしいリアルタイム性を活用して、アップデートされるデータベースの内容を監視する方法について学んでゆきましょう。

リストの作成とダブルチェック

ウィッシュリストのベースとなっている app/list/list.html から説明します。ここには、テキスト項目と空のリストが表示されているはずです。では、サンタさんに欲しいものを伝えてみましょう。項目がデータベースに送信され、リアルタイムにリストに追加されます。これをどうやって実現しているかを見てみましょう。

app/list/list.component.ts では、最初にギフトのリストを保持するための監視可能オブジェクトを設定しています。 public gifts$: Observable; 次に、コンポーネントの初期化の際に、データベースからリストを読み込みます。

ngOnInit(){
  this.gifts$ = this.firebaseService.getMyWishList();
}


おもしろいのは firebaseService ファイルです。この関数では、リスナーを追加して Firebase Database の Gifts コレクションの変更を監視する監視可能オブジェクト rxjs を返しています。この方法に注目してください。

getMyWishList():Observable {
   return new Observable((observer: any) => { 
       let path = 'Gifts';
         let onValueEvent = (snapshot: any) => {
           this.ngZone.run(() => {
             let results = this.handleSnapshot(snapshot.value);
             console.log(JSON.stringify(results))
              observer.next(results);
           });
         };
         firebase.addValueEventListener(onValueEvent, `/${path}`);
     }).share(); 
   }


このクエリの結果は、次に示す handleSnapshot 関数で処理されます。この関数は、ユーザーがフィルタリングしたデータを _allItems 配列に設定しています。

handleSnapshot(data: any) {
    //empty array, then refill and filter 
    this._allItems = [];
    if (data) {
      for (let id in data) {
        let result = (Object).assign({id: id}, data[id]);
        if(BackendService.token === result.UID){
          this._allItems.push(result);
        }
      }
      this.publishUpdates();
    }
    return this._allItems;
 }


最後に、publishUpdates が呼び出されます。この関数は、新しい項目が先頭に表示されるように、日付でデータを並び替えています。

publishUpdates() {
    // here, we sort must emit a *new* value (immutability!)
    this._allItems.sort(function(a, b){
         if(a.date < b.date) return -1;
         if(a.date > b.date) return 1;
       return 0;
    })
    this.items.next([...this._allItems]);
  }


監視可能オブジェクト $gifts にデータが設定されると、要素の編集や削除を行うたびにリスナーが呼ばれ、フロントエンドが適切にアップデートされます。getMyWishList メソッド内の onValueEvent 関数で ngZone が使われていることに注意してください。これによって、非同期に行われるデータのアップデートに応じて UI が適切にアップデートされます。NativeScript アプリの ngZone の概要については、こちらをご覧ください。

メッセージのリモート設定


もう 1 つの優れた Firebase サービスに「Remote Config」があります。これは、Firebase のバックエンドからアプリをアップデートできる機能です。Remote Config を使うと、アプリの機能のオン、オフを切り替えたり、UI を変更したりできます。ここでは、サンタさんからメッセージを送る機能を追加してみましょう。

app/list/list.html には、次のメッセージ ボックスがあります。 <Label class="gold card" textWrap="true" [text]="message$ | async"></Label> 監視可能オブジェクト message$ は、データリストとほぼ同じ方法で組み込まれていますが、ここではアプリが新しく初期化されるたびに変更が適用されます。

ngOnInit(){
  this.message$ = this.firebaseService.getMyMessage();
}


秘密は、次に示すサービスレイヤ(app/services/firebase.service.ts)にあります。
getMyMessage():Observable{
    return new Observable((observer:any) => {
      firebase.getRemoteConfig({ 
      developerMode: false,
      cacheExpirationSeconds: 300,
      properties: [{
      key: "message",
      default:"Happy Holidays!"
    }]
  }).then(
        function (result) {
          console.log("Fetched at " + result.lastFetch + (result.throttled ? " (throttled)" : "")); 
          for (let entry in result.properties)
            {
              observer.next(result.properties[entry]);
            }
          }
       );
     }).share();
 }



好きなだけ新しいメッセージを発行可能

注: アプリから必要のない高頻度で Remote Config の値を取得すると、Remote Config の利用が制限されてしまう場合がありますので、慎重に開発してください。

写真を撮る



このプロジェクトのおもしろいところは、選んだプレゼントの写真を撮って Firebase Storage に格納できる点でしょう。前述のように、この部分には Camera プラグインを使いました。これでハードウェアの管理が少し楽になります。最初に、app/list-detail/list-detail.component.ts の ngOnInit() メソッドでパーミッション セットを取得し、アプリがカメラデバイスにアクセスできるようにします。

ngOnInit() {
   camera.requestPermissions();
   ...
  }

ユーザーが詳細画面の [Photo] ボタンをクリックすると、イベント チェーンが始まります。最初に呼ばれるのは次のコードです。

takePhoto() {
  let options = {
            width:300,
            height:300,
            keepAspectRatio: true,
            saveToGallery: true
         };
     camera.takePicture(options)
          .then(imageAsset => {
              imageSource.fromAsset(imageAsset).then(res => {
                  this.image = res;
                  //save the source image to a file, then send that file path to firebase
                  this.saveToFile(this.image);
              })
          }).catch(function (err) {
              console.log("Error -> " + err.message);
          });
}


カメラで写真が撮影され、その写真が imageAsset として保存されて画面に表示されます。このイメージはファイルとしてローカルに保存されます。その際に、日付のタイムスタンプに従った名前が付けられます。また、今後の利用に備えて、パスも保存されます。

saveToFile(res){
  let imgsrc = res;
        this.imagePath =
this.utilsService.documentsPath(`photo-${Date.now()}.png`);
        imgsrc.saveToFile(this.imagePath, enums.ImageFormat.png); 
}


[Save] ボタンが押されると、イメージはローカルパス経由で Firebase に送信され、ストレージ モジュール内に保存されます。アプリに返される Firebase のフルパスは、/Gifts データベース コレクションに保存されます。

editGift(id: string){
  if(this.image){
    //upload the file, then save all
    this.firebaseService.uploadFile(this.imagePath).then((uploadedFile: any) => 
{
          this.uploadedImageName = uploadedFile.name;
          //get downloadURL and store it as a full path;
this.firebaseService.getDownloadUrl(this.uploadedImageName).then((downloadUrl: string) => {
this.firebaseService.editGift(id,this.description,downloadUrl).then((result:any) => { 
               alert(result)
             }, (error: any) => {
                 alert(error);
             });
           })
          }, (error: any) => {
            alert('File upload error: ' + error);
          });
        }
        else {
          //just edit the description
          this.firebaseService.editDescription(id,this.description).then((result:any) => {
               alert(result)
            }, (error: any) => {
               alert(error);
            });
          }
        }


このイベント チェーンは一見複雑そうですが、最終的に実行されるのは、Firebase サービス ファイルの中の数行です。

uploadFile(localPath: string, file?: any): Promise {
      let filename = this.utils.getFilename(localPath);
      let remotePath = `${filename}`;
      return firebase.uploadFile({
        remoteFullPath: remotePath,
        localFullPath: localPath,
        onProgress: function(status) {
            console.log("Uploaded fraction: " + status.fractionCompleted);
            console.log("Percentage complete: " + status.percentageCompleted);
        }
      });
  }
  getDownloadUrl(remoteFilePath: string): Promise {
      return firebase.getDownloadUrl({
     remoteFullPath: remoteFilePath})
      .then(
        function (url:string) {
          return url;
        },
        function (errorMessage:any) {
          console.log(errorMessage);
        });
}
editGift(id:string, description: string, imagepath: string){
    this.publishUpdates();
    return firebase.update("/Gifts/"+id+"",{
        description: description,
        imagepath: imagepath})
      .then(
        function (result:any) {
          return 'You have successfully edited this gift!';
        },
        function (errorMessage:any) {
          console.log(errorMessage);
        });
  }


最終的な結果では、ウィッシュリストに表示するギフトの写真と説明の両方がうまく取得できています。これでもうサンタさんは、カイリーのどのアイライナーを買えばいいのかわからないという言い訳はできません。強力な NativeScript と Angular を組み合わせれば、数分で iOS と Android のネイティブ アプリを作ることができます。これに Firebase を追加すれば、アプリのユーザー、イメージ、データを保存する強力な機能を利用でき、さらに端末間でリアルタイムにデータをアップデートすることも可能になります。すばらしいと思いませんか。このアプリは、次のようになります。



ここまで、充実したウィッシュリスト管理アプリの作成に向けて順調に進んできました。しかし、サンタさんに願いごとを伝える最高の方法はまだ模索中と言えるでしょう。次のステップとしては、Mailgun のメールとの統合やプッシュ通知の利用が考えられます。それまでの間、すばらしいクリスマス休暇をお過ごしください。皆さんが Firebase を利用してすてきな NativeScript アプリを作れるようにお祈りします。

NativeScript についてさらに詳しく学びたい方は、http://www.nativescript.org をご覧ください。質問がある方は、こちらから NativeScript の Slack チャンネルに参加してください。

Posted by Khanh LeViet - Developer Relations Team
Share on Twitter Share on Facebook


デバイスのセットアップとローカルでの書き込み

これを実現するために、Intel EdisonをLinux ベースのコンピューティング プラットフォームとして使い、これに Sparkfun の Edison Blocks を加えました。なお、デバイスの作成には Intel EdisonBase BlockBattery BlockHardwareパックがそれぞれ 1 つずつ必要です。



Edison の開発は Intel XDK IDE を使えば簡単です。JavaScript でシンプルな Node.js アプリケーションを作っていきます。今回、3 つのライブラリ(データベース接続には Firebase、Wi-Fiネットワークの取得には wireless-tools/iwlist、デバイスのMAC 取得には macaddress)を使用しました。実装手順はそれぞれのリンク先のページを参照してください。

ステップ 1: デバイスの MAC アドレスを取得して Firebase に接続する
function initialize() {
    macaddress.one('wlan0', function (err, mac) {
        mac_address = mac;
        if (mac === null) {
            console.log('exiting due to null mac Address');
            process.exit(1);
        }
        firebase.initializeApp({
            serviceAccount: '/node_app_slot/<service-account-key>.json',
            databaseURL: 'https://<project-id>.firebaseio.com/'
        });
        var db = firebase.database();
        ref_samples = db.ref('/samples');
        locationSample();
    });
}


上記のコードに含まれている 2 つのプレースホルダーは次のとおりです。

  1. service-account-key は Firebase コンソールで作成するプライベート キーです。コンソールの左上部にある歯車アイコンから「settings」を選択して、Generate New Private Key をクリックします。このキーを Edison の /node_app_slot/ ディレクトリに入れます。詳しくは、Firebase のドキュメントをご覧ください。
  2. データベース URLの中の project-id は、Google プロジェクトを Firebase と連携させた後、Firebase コンソールのデータベース ページに表示されます。


ステップ 2: 10秒毎に Wi-Fiネットワークをスキャンしてローカルに書き込む
function locationSample() {
    var t = new Date();
    iwlist.scan('wlan0', function(err, networks) {
        if(err === null) {
            ref_samples.push({
                mac: mac_address,
                t_usec: t.getTime(),
                t_locale_string: t.toLocaleString(),
                networks: networks,
            });
        } else {
            console.log(err);
        }        
    });
    setTimeout(locationSample, 10000);
}


クラウドへの書き込み

上記の locationSample() は、検出可能なWi-Fi ネットワークを Firebase のデータベースに書き込む関数で、データベースはネットワーク接続時にクラウドと同期します。

補足説明: Firebase へのアクセス権と認証の設定では、デバイスを「サーバー」として設定しました。この設定方法については Firebase ウェブサイトをご覧ください。今回、認証情報の保存に関しデバイスのセキュリティは十分であると仮定しましたが、皆さんが実装する際にこの点が該当しない場合は、クライアント JavaScript SDK の設定手順に従ってください。

データベースではワークロード管理に 3 つのキュー(Wi-Fi サンプル キュー、ジオロケーション結果キュー、視覚化データ キュー)を使用します。ワークフローは次のとおりです。デバイスからのサンプルがサンプル キューに入り、これを使ってジオロケーションが生成され、生成されたジオロケーションはジオロケーション キューに入れられます。ジオロケーションは取得されて表示用にフォーマット化され、デバイスによって整理された後、出力が視覚化バケットに保管され、フロントエンドのウェブサイトがこれを使用するという流れです。

以下に、Firebase Database コンソール上に表示されるサンプル、ジオロケーション、デバイスによって書き込まれた視覚化データの例を示します。

データを Google App Engine で処理する

サンプルデータの処理の実行には、長期実行中の Google App Engine Backend Module と Java Client for Google Maps Services のカスタム版を使用しました。

補足説明: Firebase と App Engine を併用する場合は、必ず手動スケーリングを使用してください。Firebase はバックグラウンド スレッドを使って変更をリッスンしていて、App Engine は手動でスケーリングされたバックエンド インスタンスに対する長期のバックグラウンド スレッドしか許可しません。

Java Client for Google Maps Services は Google Maps API を利用する際に必要な大量の通信コードを扱い、エラー処理について公開されているベスト プラクティスおよびレート制限に従ったリトライ方針に従います。次に示す GeolocateWifiSample() 関数は、イベント リスナーとしてFirebase に登録されています。この関数はデバイスから報告される各ネットワークをループし、それをジオロケーション リクエストに組み込みます。

private void GeolocateWifiSample(DataSnapshot sample,  Firebase db_geolocations, Firebase db_errors) {
    // initalize the context and request
    GeoApiContext context = new GeoApiContext(new GaeRequestHandler()).setApiKey("");
    GeolocationApiRequest request = GeolocationApi.newRequest(context)
            .ConsiderIp(false);
    // for every network that was reported in this sample...
    for (DataSnapshot wap : sample.child("networks").getChildren()) {
        // extract the network data from the database so it’s easier to work with
        String wapMac = wap.child("address").getValue(String.class);
        int wapSignalToNoise = wap.child("quality").getValue(int.class);
        int wapStrength = wap.child("signal").getValue(int.class);
        // include this network in our request
        request.AddWifiAccessPoint(new WifiAccessPoint.WifiAccessPointBuilder()
                .MacAddress(wapMac)
                .SignalStrength(wapStrength)
                .SignalToNoiseRatio(wapSignalToNoise)
                .createWifiAccessPoint());
    }
    ...
    try {
        // call the api
        GeolocationResult result = request.CreatePayload().await();
        ...
        // write results to the database and remove the original sample
    } catch (final NotFoundException e) {
        ...
    } catch (final Throwable e) {
        ...
    }
}


GeolocateWifiSample() 関数をイベント ハンドラーとして登録します。ジオロケーションの結果を処理して視覚化データを生成するそれ以外のリスナーも、同じようなパターンで作成します。
ChildEventListener samplesListener = new ChildEventListener() {
    @Override
    public void onChildAdded(DataSnapshot dataSnapshot, String previousChildName) {
        // geolocate and write to new location
        GeolocateWifiSample(dataSnapshot, db_geolocations, db_errors);
    }
    ...
};
db_samples.addChildEventListener(samplesListener);


データの視覚化

デバイスの位置の視覚化には、Google App Engine を使います。保存されたデータを Firebase から渡して、Google Maps JavaScript API を使って結果表示用のシンプルなウェブページを作成しました。index.html ページの中身は空の <div> とID “map” です。この <div> を初期化して Google Map オブジェクトを入れました。また、イベントハンドラーの “child_added” と “child_removed” を追加して、時間の経過に伴ってデータが変化したときにマップを更新するようにしています。

function initMap() {
    // attach listeners
    firebase.database().ref('/visualization').on('child_added', function(data) {
        ...
        data.ref.on('child_added', function(vizData) {
            circles[vizData.key]= new CircleRoyale(map,
                                vizData.val().lat,
                                vizData.val().lng,
                                vizData.val().accuracy,
                                color);
          set_latest_position(data.key, vizData.val().lat, vizData.val().lng);
        });
        data.ref.on('child_removed', function(data) {
            circles[data.key].removeFromMap();
        });
    });
    // create the map
    map = new google.maps.Map(document.getElementById('map'), {
      center: get_next_device(),
      zoom: 20,
      scaleControl: true,
    });
    ...
}


API は位置だけでなく精度表示も返すため、精度のコンポーネントを示すカスタム マーカー(半径内の範囲が脈動する)を作成しました。

2 個のデバイス(赤と青)と判明した位置の直近 5 か所

次に進むには

この記事では、インターネットに接続されたデバイス(ロボットからウェアラブル端末まで)を追跡するために、Google Maps Geolocation API を使った IoT デバイスの構築方法を紹介しました。App Engine の処理モジュールを拡張し、他の Google Maps API を使ってルート案内標高プレイスタイムゾーン情報などの地図データを提供することもできます。皆さん、ぜひ作成してみてください。

さらに、Firebase の代わりに Google Cloud Platform を使って同様のソリューションを実現することも可能です。手順はこちらの記事をご覧ください。




Posted by 丸山 智康 (Tomoyasu Maruyama) - Google Maps Solution Architect, Google Maps
Share on Twitter Share on Facebook