iOSとAndroidで共通の共有リンク1本と、ボタン別のクリック計測
iPhoneはApp Store、AndroidはGoogle Playへ振り分け、LINEやXのプレビューにはOGカードを返し、どの共有ボタンから来たかを記録するPHPのスマートリンクの設計。
アプリの中から「友だちに教える」で共有すると、文面にはリンクが必要です。でも受け取った相手がiPhoneなのか、Androidなのか、PCなのかは分かりません。この記事では、そのために使っている「スマートリンク」の設計を紹介します。アプリごとに自分のサーバー上のURLを1本用意し、iPhoneならApp Store、AndroidならGoogle Playへ送り、それ以外には2つのボタンがあるページを見せ、どの共有ボタンから来たクリックかを記録します。共用サーバー上の、アプリ一覧とランディングページのHTMLを含めても300行に満たないPHPファイル1本です。
ここで説明するのはコードが実際にやっていることです。クリック数は載せていません。仕組みとエッジケース、そして出てきた数字を信用する前に何を確かめるべきかを書きます。
なぜストアURL2本ではなく1本なのか
共有機能の最初のバージョンは、たいていこうなっています。
今日の運勢ランキングが出ました! #今日の運勢
https://apps.apple.com/app/id…
https://play.google.com/store/apps/details?id=…
これには問題が3つあります。
- カードになるのは最初のURLだけ。 XもLINEも、プレビューを作るのは最初のリンクだけです。Google PlayのURLはカードの下に長いままむき出しで残ります。LINEのトークでこれが届くと、正直かなり見栄えが悪いです。
- 文字数を食い、スパムっぽく見えます。
- 数えられない。 Firebase Analyticsのようなアプリ内SDKが見えるのは、すでにインストールした人だけです。共有を見てリンクを押した人こそ数えたいのに、アプリ内の計測からは見えません。
そこで共有文には、どのボタンから出たかを示すタグを付けた、自分のドメインのリンクを1本だけ入れることにしました。
https://example.com/app/<app-id>/?s=card
Flutterアプリ側は1行のヘルパーです。共有リンクに apps.apple.com や play.google.com が絶対に入らないことを、ユニットテストで確認しています。
static const _base = 'https://example.com/app/<app-id>/';
static String url(String src) => '$_base?s=$src';
ハッシュタグはリンクの前に置きます。プレビューカードは最初のURLから作られるので、リンクを最後に置けば、カードにタグが押し出されません。占い系なら「#今日の運勢」のようなタグが流入の入口になるので、カードに隠れないことは地味に重要です。
?s= で流入元を区別する
共有ボタンごとに違う値を渡します。私たちのアプリでは、たとえば rank(ランキング画面)、card(個別の結果カード)、result、saved(履歴から)、score(ゲームオーバー画面)、board、photo、code、invite を使っています。
変わり種は img です。2つのアプリは、共有画像の下部に透かしのような形でスマートリンクを文字として印字しています(https:// は省略)。s=img のクリックは、誰かが画像を見てURLを手で打ったかコピーしたということです。タップできるリンクとは別の経路で、こうしておかなければ見えないままでした。
サーバー側では、保存してもURLに入れても安全な形に正規化します。
$src = preg_replace('/[^a-z0-9_]/', '', strtolower((string)($_GET['s'] ?? '')));
if ($src === '') $src = 'unknown';
(string) は ?s[]=x への対策です。[a-z0-9_] 以外の文字は捨てます。タグがない場合は unknown にして、消えるのではなく集計に出てくるようにしています。
リクエストの流れ
アプリごとのフォルダには、アプリIDを設定して共通ファイルを読み込むだけの2行の index.php があり、それ以外はすべて1つのファイルにまとめています。アプリ一覧には、アプリごとにApp Storeの数値ID(なければ null)、Playのパッケージ名(なければ null)、ランディングページの言語、名前、説明文、OG画像を持たせています。現在は10本が登録されていて、両方のストアにあるのが6本、Androidのみが4本です。その4本のうち1本はiOS版のリリース待ちで、IDは後から入れます。
未登録のアプリIDには、プレーンテキストの404を返します。これは運用上の制約になります。共有リンクを出すアプリのバージョンをリリースする前に、サーバー側の登録をデプロイしておく必要があるということです。実際、あるアプリでは共有URLの定数の横に、そのためのTODOコメントが残っています。
流れは次のとおりです。
- クライアントの判定:まずクローラーかどうか、次にプラットフォーム
- ストアURLの組み立て:キャンペーンタグ付き
- クリックの記録:人間だけ。記録の失敗でリダイレクトを止めない
- 人間には302でリダイレクト:そのプラットフォームのストアにアプリがある場合
- それ以外はランディングページ:クローラー、PC、判定できない端末、Androidのみのアプリを開いたiPhone
クローラーにはリダイレクトではなくHTML
function isCrawler(string $ua): bool {
if ($ua === '') return true; // UA なしは人間として扱わない
return (bool)preg_match('~(bot|crawler|spider|preview|facebookexternalhit|twitterbot|slackbot|'
. 'discordbot|whatsapp|telegrambot|line\-?spider|linebot|skypeuripreview|embedly|'
. 'quora link preview|pinterest|redditbot|applebot|vkshare|googlebot|bingbot|yeti|daum|'
. 'petalbot|headlesschrome)~i', $ua);
}
プレビュー用のクローラーに302を返すと、カードはストアのページから作られ、タイトルも画像もストアのものになります。そこでクローラーには、アプリ一覧の内容で og:title、og:description、og:image、twitter:card を埋めたランディングページを返します。リダイレクトはせず、記録もしません。プレビュー取得のたびにクリック数が膨らむことはありません。
プラットフォームの判定
function platform(string $ua): string {
if (preg_match('~(iphone|ipad|ipod)~i', $ua)) return 'ios';
if (preg_match('~macintosh~i', $ua) && preg_match('~mobile/~i', $ua)) return 'ios'; // Mac を名乗る iPad
if (preg_match('~android~i', $ua)) return 'android';
return 'other';
}
iPadOS 13以降のiPadはMacを名乗ることがあります。2行目は、それでもUAに Mobile/ が残っているケースを拾います。判定から漏れたものはランディングページになりますが、ボタンを1回多く押すだけで、ちゃんと機能します。
キャンペーンタグ付きのストアURL
$play = 'https://play.google.com/store/apps/details?id=' . rawurlencode($pkg)
. '&referrer=' . rawurlencode('utm_source=sns_share&utm_medium=app&utm_campaign=' . $src);
$ios = 'https://apps.apple.com/app/id' . rawurlencode($iosId)
. '?ct=' . rawurlencode('sns_share_' . $src) . '&mt=8';
Playは referrer をインストールリファラーに引き継ぎます。App Storeの ct は、プロバイダートークン(pt)が付いていないと無視されることがあります。コードのコメントにもそう明記してあり、だから実際に判断に使うのは自前のクリックログです。ストア側の計測はおまけと考えています。
記録がリダイレクトを壊さないように
if (!$isCrawler) {
try {
if (!is_file($dbFile)) throw new RuntimeException('DB 設定がない');
require_once $dbFile;
// 記録: アプリ, src, platform, リファラー(255文字まで), UA(255文字まで), IPのハッシュ, UTC時刻
} catch (Throwable $e) {
// 無視する。数え漏れのほうが、訪問者を失うよりましだから
}
}
大事な点が2つあります。
- 存在しないファイルを
requireすると致命的エラーになり、catch (Throwable)では捕まりません。だから先にis_fileで確認します。 - INSERTはプリペアドステートメントで行い、失敗はすべて握りつぶします。リダイレクトは必ず実行されます。
IPアドレスそのものは保存しません。CDNのクライアントIPヘッダー、X-Forwarded-For、REMOTE_ADDR のうち最初に見つかったものの先頭のアドレスを取り、固定のソルトを付けたMD5だけを保存します。ソース別のおおよそのユニーク数を出すにはこれで十分です。
ランディングページは絶対にキャッシュさせない
全員が同じURLを開きますが、応答はUser-Agentによって変わります。クローラーが受け取ったHTMLが共有キャッシュに残ると、次に来たiPhoneの人はストアへ行けず、ランディングページを見ることになります。コード内のメモには、このホストのLiteSpeedが Vary: User-Agent を無視し、URLだけをキャッシュキーにしていた(2026年11月3日に確認)と記録されています。そこでページには次のヘッダーを付けています。
header('Vary: User-Agent, Accept-Encoding');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
リダイレクトは301ではなく302です。301はデフォルトでキャッシュされ得るもので、実際にホストのCDNがキャッシュした301を返し続けたことがあります。
コードが扱っているエッジケース
| 訪問者 | 動作 | 記録 |
|---|---|---|
| iPhone、App Storeにあるアプリ | ct 付きでApp Storeへ302 |
する(ios) |
| Android | referrer 付きでPlayへ302 |
する(android) |
| iPhone、Androidのみのアプリ | Playボタンだけのランディングページ(使えないApp Storeリンクは出さない) | する(ios) |
| PCのブラウザ | ボタン2つのランディングページ | する(other) |
| プレビュー用クローラー(X、LINE、Slackなど) | OGタグ入りのランディングページ、リダイレクトなし | しない |
| User-Agentが空 | クローラー扱い、ランディングページ | しない |
| 未登録のアプリID | プレーンテキストの404 | しない |
| DB停止、設定ファイルなし | それでもリダイレクトする | しない(黙って) |
アプリ内ブラウザに特別な処理はありません。 iPhoneのLINE、Instagram、Xのアプリ内ブラウザはiPhoneのUAを送るので、Safariと同じようにApp Storeへの302を受け取ります。その後App Storeアプリが開くのか、Webページが表示されるのかは、それぞれのアプリ内ブラウザ次第です。
数字を信じる前に確かめること
コードは単純ですが、穴は前提の中にあります。このクリック数をもとに報告書を書くなら、まず次のことを確認します。
- クローラー判定の誤検知。
daum、pinterest、previewのような短いトークンは、UAのどこにあっても一致します。あるアプリのアプリ内ブラウザがUAに自分の名前を足していると、そのアプリから来た本物の人がクローラー扱いになり、ランディングページを見せられ、数えられません。記録したUAをグループ化して、抜けているアプリがないか確認します。 otherに落ちるiPad。otherのうちUAにMacintoshを含む行を数えます。本物のMacか、Mobile/を送らなかったiPadのどちらかです。- CDNがどのIPヘッダーを付けるか。 コードはCDNのクライアントIPヘッダーを最初に信用し、次に
X-Forwarded-Forを見ます。CDNが付けないヘッダーは、クライアントが自分で送れます。おおよそのユニーク数には十分ですが、何かの証明にはなりません。 - ハッシュは仮名化であって匿名化ではない。 固定ソルト付きのIPv4のMD5は、2^32通りを総当たりすれば元に戻せます。もっと強いプライバシーが必要なら、毎日入れ替える秘密鍵を使ったHMACにします。それでも1日の中での重複除去はできます。
- 302そのものがキャッシュされていないか ― 確認しました(2026-11-09)。 コードがno-storeを付けるのはランディングページだけで、リダイレクトの分岐はその前に終了します。そこで同じテスト用URLをiPhone → Android → iPhoneの順に取得しました。3回とも正しいストアへの302で、
x-hcdn-cache-status: MISS、Cache-Control: no-cache, no-store, must-revalidate, max-age=0が付いていました。コードでは送っていないヘッダーをサーバー側がPHPの応答に付けていたので、この環境では302はキャッシュされません。それでもホストの既定値に頼らないよう、リダイレクトの分岐にも明示的に付ける予定です。
「ソース別・日別のクリック数と、おおよそのユニーク数」を出すクエリはこうなります。
SELECT DATE(clicked_at) AS day, src, platform,
COUNT(*) AS clicks, COUNT(DISTINCT ip_hash) AS uniq
FROM clicks
WHERE app_id = ? AND clicked_at >= UTC_TIMESTAMP() - INTERVAL 30 DAY
GROUP BY day, src, platform
ORDER BY day DESC, clicks DESC;
実際に役立つのは比較です。同じアプリの rank と card、あるいは img とテキストリンク系のソースを比べると、どの共有ボタンが本当に人を連れてくるのかが分かります。そもそもこのリンクを作ったのは、それを知るためでした。
よくある質問
共有文にApp StoreとGoogle Playの両方のURLを入れてはだめ?
XやLINEはプレビューカードを最初のURLにしか作らないので、2つ目のストアURLは長いURLがむき出しで残り、文字数を食ってスパムっぽく見えます。アプリを持っていない人のクリックも数えられません。自分のサーバーのリンク1本にすれば両方解決します。
スマートリンクはTwitterbotなどのクローラーもストアにリダイレクトすべき?
いいえ。プレビュー用のクローラーに302を返すと、カードがストアのページで作られ、自分の文言と画像が消えます。クローラーにはOGタグ入りのHTMLを返し、リダイレクトは人間だけにします。
Vary: User-AgentでキャッシュがUAごとの応答を混ぜるのを防げる?
私たちが確認したLiteSpeed環境では防げませんでした。キャッシュキーがURLだけだったからです。全員が同じURLで違う応答を受け取るので、ランディングページにはCache-Control: no-storeを付け、リダイレクトは301ではなく302にしています。