ユニバーサルリンク — シームレスなWebからアプリへのディープリンク
ユニバーサルリンクは、WebサイトのURLをアプリで直接開く機能です。ユーザーがアプリをインストールしていない場合、URLはブラウザで開きます。
ユニバーサルリンクのセットアップ
ユニバーサルリンクには2つの要素が必要です。
apple-app-site-association ファイル をドメイン上に配置
Entitlements設定 をアプリで設定
// ステップ1:apple-app-site-associationファイルを作成
// 配置先: https://yourdomain.com/.well-known/apple-app-site-association
{
"applinks" : {
"apps" : [],
"details" : [
{
"appID" : "com.example.myapp" ,
"paths" : [
"/products/*" ,
"/users/*" ,
"/articles/*"
]
}
]
},
"webcredentials" : {
"apps" : [ "com.example.myapp" ]
}
}
// ステップ2:Rork Maxでentitlementsを設定
// プロンプトで以下をリクエスト:
// "ユニバーサルリンク向けのassociated domainsを追加してください:
// yourdomain.com, www.yourdomain.com"
// ステップ3:SwiftUIでユニバーサルリンクを処理
class UniversalLinkHandler {
func parseUniversalLink ( _ url: URL) -> DeepLinkScheme ? {
// 末尾のスラッシュを削除して一貫したパースを実現
var urlPath = url.path
while urlPath. hasSuffix ( "/" ) {
urlPath. removeLast ()
}
// ルート・パターンにマッチさせる
if urlPath. starts ( with : "/products/" ) {
let productSlug = String (urlPath. dropFirst ( "/products/" . count ))
return . native (. product ( id : productSlug))
}
if urlPath. starts ( with : "/users/" ) {
let username = String (urlPath. dropFirst ( "/users/" . count ))
return . native (. user ( username : username))
}
if urlPath. starts ( with : "/articles/" ) {
let articleSlug = String (urlPath. dropFirst ( "/articles/" . count ))
// スラッグから記事IDを取得する必要があるかもしれません
return . native (. article ( slug : articleSlug))
}
return nil
}
}
⚠️ ユニバーサルリンクはAppleとドメイン間の暗号化されたハンドシェイクで検証されます。テストには以下が必要です。
ドメインはHTTPSである必要があります
apple-app-site-associationファイルは/.well-known/apple-app-site-associationで公開アクセス可能である必要があります
associated domains entitlementを持つ適切なプロビジョニング・プロファイルが必要です
開発中は、xcode-select --installでユニバーサルリンク検証を無効化し、App Clipsを無効にした実機でテストしてください。
Web限定コンテンツの優雅な処理
すべてのユニバーサルリンクがアプリで開く必要はありません。ブラウザ体験を保持したい場合もあります。
class UniversalLinkRouter {
let deepLinkHandler = UniversalLinkHandler ()
func handleUniversalLink ( _ url: URL) {
// 一部のパスは常にブラウザで開く
let webOnlyPaths = [ "/legal/" , "/privacy/" , "/terms/" ]
let pathMatches = webOnlyPaths. contains { path in
url.path. starts ( with : path)
}
if pathMatches {
// ブラウザで開く
UIApplication.shared. open (url)
return
}
// アプリ内でルーティング試行
if let deepLink = deepLinkHandler. parseUniversalLink (url) {
navigateTo (deepLink)
} else {
// ブラウザにフォールバック
UIApplication.shared. open (url)
}
}
private func navigateTo ( _ deepLink: DeepLinkScheme) {
// ナビゲーション実装
}
}
アプリリンク — Androidのディープリンク
AndroidアプリではApp Linksがユニバーサルリンクと同じ検証メカニズムを提供します。
// Rork MaxアプリでAndroidを対象とする場合、以下の設定をリクエスト:
// "Androidでのアプリリンク設定を追加してください。以下のドメイン向け:
// yourdomain.com, www.yourdomain.com
// Asset linkは: https://yourdomain.com/.well-known/assetlinks.json"
// assetlinks.json設定:
[
{
"relation" : [ "delegate_permission/common.handle_all_urls" ],
"target" : {
"namespace" : "android_app" ,
"package_name" : "com.example.myapp" ,
"sha256_cert_fingerprints" : [
"AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"
]
}
}
]
遅延ディープリンク — グロースの究極ツール
遅延ディープリンクにより、ユーザーがアプリをインストールしていなくても、ディープリンクが機能します。ユーザーがストアからアプリをインストールして初めて起動すると、意図した画面にルーティングされます。
💡 遅延ディープリンクは紹介プログラムとウイルス・キャンペーンに変革をもたらします。ユーザーは特定の商品へのリンクを共有でき、友人はインストール後、その商品に直接ランディングします。サーバー・ロジック不要です。
帰属追跡を伴う遅延ディープリンク実装
// 帰属追跡を伴う遅延ディープリンク・サービス
class DeferredDeepLinkService {
private let apiBaseURL = URL ( string : "https://api.yourdomain.com" ) !
private let deviceIdentifier = UIDevice.current.identifierForVendor ? .uuidString ?? UUID ().uuidString
// 最初のアプリ起動時に遅延ディープリンクを保存
func fetchDeferredDeepLink () async throws -> DeferredDeepLinkData ? {
var request = URLRequest ( url : apiBaseURL. appendingPathComponent ( "/deferred-links/fetch" ))
request.httpMethod = "POST"
let payload = DeferredLinkRequest (
deviceId : deviceIdentifier,
appVersion : Bundle.main.appVersion,
osVersion : UIDevice.current.systemVersion,
isFirstLaunch : ! UserDefaults.standard. bool ( forKey : "hasLaunchedBefore" )
)
request. setValue ( "application/json" , forHTTPHeaderField : "Content-Type" )
request.httpBody = try JSONEncoder (). encode (payload)
let (data, _ ) = try await URLSession.shared. data ( for : request)
let decoder = JSONDecoder ()
let response = try decoder. decode (DeferredLinkResponse. self , from : data)
return response.deepLinkData
}
// 帰属データをログ
func logAttribution ( source : String , campaign : String , medium : String ) async throws {
var request = URLRequest ( url : apiBaseURL. appendingPathComponent ( "/attribution" ))
request.httpMethod = "POST"
let attribution = AttributionEvent (
deviceId : deviceIdentifier,
source : source,
campaign : campaign,
medium : medium,
timestamp : Date ()
)
request. setValue ( "application/json" , forHTTPHeaderField : "Content-Type" )
request.httpBody = try JSONEncoder (). encode (attribution)
_ = try await URLSession.shared. data ( for : request)
}
}
struct DeferredLinkRequest : Codable {
let deviceId: String
let appVersion: String
let osVersion: String
let isFirstLaunch: Bool
}
struct DeferredDeepLinkData : Codable {
let deepLink: String
let source: String // "referral", "ad_campaign"など
let campaign: String
let userId: String ? // リンクを共有したユーザーのID
}
struct AttributionEvent : Codable {
let deviceId: String
let source: String
let campaign: String
let medium: String
let timestamp: Date
}
起動時の遅延ディープリンク処理
class AppStartupManager {
@StateObject private var deferredLinkService = DeferredDeepLinkService ()
func setupAppLaunchFlow () async {
// アプリ起動をマーク
if ! UserDefaults.standard. bool ( forKey : "hasLaunchedBefore" ) {
UserDefaults.standard. set ( true , forKey : "hasLaunchedBefore" )
// 最初の起動時に遅延ディープリンクを取得
do {
if let deferredLink = try await deferredLinkService. fetchDeferredDeepLink () {
// 帰属をログ
try await deferredLinkService. logAttribution (
source : deferredLink.source,
campaign : deferredLink.campaign,
medium : "deferred_deep_link"
)
// ディープリンクにルーティング
handleDeepLinkString (deferredLink.deepLink)
// オプション:紹介関係を保存
if let referrerId = deferredLink.userId {
UserDefaults.standard. set (referrerId, forKey : "referredBy" )
}
}
} catch {
// エラーをログしても、アプリ起動をブロックしない
print ( "遅延ディープリンク取得失敗: \( error ) " )
}
}
}
private func handleDeepLinkString ( _ deepLinkString: String ) {
guard let url = URL ( string : deepLinkString) else { return }
if let scheme = DeepLinkScheme. parse (url) {
// 適切な画面にナビゲート
navigateToDeepLink (scheme)
}
}
}
帰属追跡とモバイル計測
帰属インフラストラクチャの構築
class AttributionTracker {
struct Campaign {
let id: String
let source: String // "organic", "paid_social", "email", "referral"
let medium: String // "cpc", "display", "email", "organic"
let name: String
let content: String ?
}
enum EventType : String , Codable {
case appInstall = "app_install"
case appOpen = "app_open"
case productView = "product_view"
case addToCart = "add_to_cart"
case purchase = "purchase"
case share = "share"
case signup = "signup"
}
private let apiURL = URL ( string : "https://api.yourdomain.com" ) !
// 帰属データを伴うコンバージョン・イベントをログ
func logEvent (
_ eventType: EventType,
userId : String ? ,
metadata : [ String : Any ] ? = nil ,
campaign : Campaign ? = nil
) async throws {
var request = URLRequest ( url : apiURL. appendingPathComponent ( "/events" ))
request.httpMethod = "POST"
let event = ConversionEvent (
eventId : UUID ().uuidString,
timestamp : Date (),
eventType : eventType. rawValue ,
userId : userId,
deviceId : UIDevice.current.identifierForVendor ? .uuidString ?? "" ,
campaign : campaign != nil ? CampaignData (
id : campaign ! .id,
source : campaign ! .source,
medium : campaign ! .medium,
name : campaign ! .name,
content : campaign ! .content
) : nil ,
metadata : metadata
)
request. setValue ( "application/json" , forHTTPHeaderField : "Content-Type" )
request.httpBody = try JSONEncoder (). encode (event)
_ = try await URLSession.shared. data ( for : request)
}
// 紹介登録を追跡
func trackReferral (
referrerId : String ,
refereeId : String ,
reward : Double
) async throws {
var request = URLRequest ( url : apiURL. appendingPathComponent ( "/referrals" ))
request.httpMethod = "POST"
let referral = ReferralEvent (
referrerId : referrerId,
refereeId : refereeId,
timestamp : Date (),
reward : reward
)
request. setValue ( "application/json" , forHTTPHeaderField : "Content-Type" )
request.httpBody = try JSONEncoder (). encode (referral)
_ = try await URLSession.shared. data ( for : request)
}
}
struct ConversionEvent : Codable {
let eventId: String
let timestamp: Date
let eventType: String
let userId: String ?
let deviceId: String
let campaign: CampaignData ?
let metadata: [ String : AnyCodable] ?
}
struct CampaignData : Codable {
let id: String
let source: String
let medium: String
let name: String
let content: String ?
}
struct ReferralEvent : Codable {
let referrerId: String
let refereeId: String
let timestamp: Date
let reward: Double
}
UTMパラメーター・パース
class UTMParameterParser {
struct UTMParameters : Codable {
let source: String ? // utm_source
let medium: String ? // utm_medium
let campaign: String ? // utm_campaign
let content: String ? // utm_content
let term: String ? // utm_term
}
static func extractUTM ( from url: URL) -> UTMParameters {
let components = URLComponents ( url : url, resolvingAgainstBaseURL : true )
let queryItems = components ? .queryItems ?? []
return UTMParameters (
source : queryItems. first ( where : { $0 .name == "utm_source" }) ? . value ,
medium : queryItems. first ( where : { $0 .name == "utm_medium" }) ? . value ,
campaign : queryItems. first ( where : { $0 .name == "utm_campaign" }) ? . value ,
content : queryItems. first ( where : { $0 .name == "utm_content" }) ? . value ,
term : queryItems. first ( where : { $0 .name == "utm_term" }) ? . value
)
}
static func createDeepLinkWithUTM (
baseDeepLink : String ,
utm : UTMParameters
) -> String ? {
guard var components = URLComponents ( string : baseDeepLink) else {
return nil
}
var queryItems = components.queryItems ?? []
if let source = utm.source {
queryItems. append ( URLQueryItem ( name : "utm_source" , value : source))
}
if let medium = utm.medium {
queryItems. append ( URLQueryItem ( name : "utm_medium" , value : medium))
}
if let campaign = utm.campaign {
queryItems. append ( URLQueryItem ( name : "utm_campaign" , value : campaign))
}
if let content = utm.content {
queryItems. append ( URLQueryItem ( name : "utm_content" , value : content))
}
if let term = utm.term {
queryItems. append ( URLQueryItem ( name : "utm_term" , value : term))
}
components.queryItems = queryItems
return components. url ? .absoluteString
}
}
ディープリンクを使った紹介プログラムの構築
紹介リンク生成
class ReferralProgram {
let baseDeepLinkURL = "myapp://invite"
func generateReferralLink ( for userId: String , withReward reward: Double ) -> URL ? {
let referralCode = generateCode ( userId : userId, reward : reward)
var components = URLComponents ( string : baseDeepLinkURL)
components ? .queryItems = [
URLQueryItem ( name : "code" , value : referralCode),
URLQueryItem ( name : "referrer_id" , value : userId)
]
return components ? . url
}
// ユニバーサルリンク版も生成
func generateUniversalReferralLink ( for userId: String , withReward reward: Double ) -> URL ? {
let referralCode = generateCode ( userId : userId, reward : reward)
var components = URLComponents ( string : "https://myapp.com/invite" )
components ? .queryItems = [
URLQueryItem ( name : "code" , value : referralCode),
URLQueryItem ( name : "referrer_id" , value : userId)
]
return components ? . url
}
private func generateCode ( userId : String , reward : Double ) -> String {
let timestamp = Int ( Date ().timeIntervalSince1970)
let data = " \( userId ) : \( timestamp ) : \( reward ) "
return data. base64Encoded ()
}
}
紹介の追跡とリワード
class ReferralRewardService {
private let apiURL = URL ( string : "https://api.yourdomain.com" ) !
// 紹介コードを検証し両者にリワード
func processReferral (
code : String ,
refereeId : String
) async throws -> ReferralResult {
var request = URLRequest ( url : apiURL. appendingPathComponent ( "/referrals/validate" ))
request.httpMethod = "POST"
let payload = ReferralValidation ( code : code, refereeId : refereeId)
request. setValue ( "application/json" , forHTTPHeaderField : "Content-Type" )
request.httpBody = try JSONEncoder (). encode (payload)
let (data, response) = try await URLSession.shared. data ( for : request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw ReferralError.invalidCode
}
let result = try JSONDecoder (). decode (ReferralResult. self , from : data)
return result
}
}
struct ReferralValidation : Codable {
let code: String
let refereeId: String
}
struct ReferralResult : Codable {
let referrerId: String
let refereeReward: Double
let referrerReward: Double
let referrerBalance: Double
}
enum ReferralError : Error {
case invalidCode
case alreadyUsed
case expired
}
ディープリンク・テストとQA
包括的なテスト戦略
class DeepLinkTestSuite {
let app = XCUIApplication ()
// 基本的なディープリンク処理をテスト
func testProductDeepLink () throws {
let deepLink = "myapp://product?id=12345"
guard let url = URL ( string : deepLink) else {
XCTFail ( "無効なURL" )
return
}
app. open (url)
app. wait ( for : .runningForeground, timeout : 5 )
// 商品画面が表示されることを確認
XCTAssertTrue (app.staticTexts[ "product-title" ].exists)
XCTAssertTrue (app.staticTexts[ "product-id-12345" ].exists)
}
// ユニバーサルリンク・フォールバックをテスト
func testUniversalLinkFallback () {
let universalLink = "https://myapp.com/products/slug"
guard let url = URL ( string : universalLink) else {
XCTFail ( "無効なURL" )
return
}
// アプリがインストールされていない場合はブラウザで開く
// アプリがインストールされている場合はアプリで開く
let canOpenURL = UIApplication.shared. canOpenURL (url)
print ( "URLを開いた: \( canOpenURL ) " )
}
// 最初の起動で遅延ディープリンクをテスト
func testDeferredDeepLink () async throws {
// 最初のアプリ起動をシミュレート
UserDefaults.standard. removeObject ( forKey : "hasLaunchedBefore" )
let mockDeferredLink = DeferredDeepLinkData (
deepLink : "myapp://product?id=99999" ,
source : "referral" ,
campaign : "invite_friends" ,
userId : "user123"
)
// APIレスポンスをモック
// アプリが正しい画面にナビゲートすることを確認
}
// 紹介リンク生成と検証をテスト
func testReferralFlow () async throws {
let referralProgram = ReferralProgram ()
let userId = "user123"
let reward = 10.0
// 紹介リンク生成
let referralLink = referralProgram. generateReferralLink ( for : userId, withReward : reward)
XCTAssertNotNil (referralLink)
// リンク形式を確認
let urlString = referralLink ? .absoluteString ?? ""
XCTAssertTrue (urlString. contains ( "code=" ))
XCTAssertTrue (urlString. contains ( "referrer_id= \( userId ) " ))
}
}
ディープリンク・デバッグ・ツール
class DeepLinkDebugger {
// デバッグ用にすべてのディープリンク・ナビゲーションをログ
func logDeepLink ( _ url: URL, isSuccessful : Bool ) {
let log = DeepLinkLog (
url : url.absoluteString,
timestamp : Date (),
isSuccessful : isSuccessful,
screenRoute : nil
)
// 検査用にUserDefaultsに保存
var logs = UserDefaults.standard. array ( forKey : "deep_link_logs" ) as? [ String ] ?? []
logs. append (log. description )
UserDefaults.standard. set (logs, forKey : "deep_link_logs" )
// コンソール・デバッグ用に出力
print ( "🔗 ディープリンク: \( url. absoluteString ) - \( isSuccessful ? "✓" : "✗" ) " )
}
// 最近のディープリンクを表示するデバッグUI
func presentDebugMenu () {
let logs = UserDefaults.standard. array ( forKey : "deep_link_logs" ) as? [ String ] ?? []
print ( "最近のディープリンク:" )
logs. forEach { print ( " \( $0 ) " ) }
}
}
struct DeepLinkLog {
let url: String
let timestamp: Date
let isSuccessful: Bool
let screenRoute: String ?
var description: String {
"[ \( timestamp. formatted () ) ] \( url ) - \( isSuccessful ? "成功" : "失敗" ) "
}
}
本番ベスト・プラクティス
⚠️ **ディープリンクのセキュリティに関する考慮事項:**
- ディープリンクURLを常に検証して、リダイレクト攻撃を防止
- 紹介コード用の暗号的署名を使用
- URLパラメーターに機密データを公開しない(代わりに暗号化トークンを使用)
- 遅延ディープリンク・リクエストをレート制限して悪用を防止
- 詐欺検出用にすべてのディープリンク・ナビゲーションをログ
ディープリンクのセキュア化
class SecureDeepLinkValidator {
private let validHosts = [ "myapp.com" , "www.myapp.com" ]
func validateDeepLink ( _ url: URL) -> Bool {
// Webリンクはhttpsのみ受け入れ
if let scheme = url.scheme, scheme != "https" {
return false
}
// ホストを検証
if let host = url.host {
guard validHosts. contains (host) else { return false }
}
// URL構造を検証
let components = URLComponents ( url : url, resolvingAgainstBaseURL : true )
guard let path = components ? .path else { return false }
// 有効なパスをホワイトリスト
let validPaths = [ "/products" , "/users" , "/articles" , "/invite" ]
let isValidPath = validPaths. contains { path. starts ( with : $0 ) }
return isValidPath
}
// 紹介コードを暗号的に検証
func validateReferralCode ( _ code: String ) -> Bool {
// HMAC署名を検証
guard let decodedCode = Data ( base64Encoded : code) else { return false }
let signature = decodedCode. prefix ( 32 )
let payload = decodedCode. suffix ( from : 32 )
let expectedSignature = HMAC (
key : "your-secret-key" . data ( using : . utf8 ) ! ,
data : payload,
algorithm : .sha256
).digest
return signature == expectedSignature
}
}
個人開発12年で得た「公式には書かれていない」運用知見
ここからは、Apple/Google 公式ドキュメントや SDK の README には書かれていないが、5,000万 DL 規模のアプリ運用で実際にぶつかった落とし穴と、その回避策をまとめます。Rork Max のコード生成は基本実装としては優秀ですが、収益化とグロース観点で見ると、生成直後のコードのままだと取りこぼす数値がいくつもあります。
知見1: AASA キャッシュは「24 時間」よりずっと長い
Apple の公式ドキュメントは Universal Links のサーバー側ファイル(apple-app-site-association)について「24時間キャッシュされる」と説明していますが、現実には iOS 端末によっては 2〜3 週間 古いキャッシュを掴み続けるケースを観測しました。私が運用している壁紙アプリのリリースでは、ドメイン移転後の AASA 反映を待つあいだに、新規ユーザーの 約 4.2% が Universal Link を踏んでも Safari にフォールバックしていました。
対応として、移行期は「旧ドメインの AASA に新旧両方の appID を併記する」「アプリ側で application(_:continue:restorationHandler:) を叩くたびに 404 リダイレクトを検知して SFSafariViewController をフォールバック起動する」の二重防御を入れています。Rork Max の初期生成コードはここまでカバーしてくれないので、必ず手動で continueUserActivity に検証ロジックを足してください。
// Rork Max 生成コード + フォールバック検出
func application ( _ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler : @escaping ([UIUserActivityRestoring] ? ) -> Void ) -> Bool {
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL else { return false }
// ① AASA ステータスを軽量 HEAD でセルフチェック(ユーザーには非可視)
Task. detached ( priority : .background) {
let head = URLRequest ( url : URL ( string : "https://yourdomain.com/.well-known/apple-app-site-association" ) ! ,
cachePolicy : .reloadIgnoringLocalCacheData)
if let ( _ , response) = try? await URLSession.shared. data ( for : head),
let http = response as? HTTPURLResponse, http.statusCode != 200 {
Analytics. logEvent ( "aasa_stale" , parameters : [ "url" : url.absoluteString])
}
}
return handleDeepLink (url)
}
aasa_stale イベントを Firebase Analytics に流しておくと、ドメイン移転や CDN 設定ミスを 24 時間以内に検知できます。私の運用では Crashlytics と組み合わせて、AASA 取得失敗が DAU の 1% を超えたら Slack に通知が飛ぶようにしてあります。
知見2: 遅延ディープリンクの「クリップボード経由」は iOS 14 以降で許可率が劇的に変わる
遅延ディープリンク(Deferred Deep Linking)の古典的な実装はクリップボード経由ですが、iOS 14 で「ペーストを許可しますか?」のダイアログが導入されてから、私のアプリ群では クリップボード経由の attribution 成功率が 87.3% → 21.4% まで落ちました。
代替手段として現実的なのは以下の 3 つです。
Apple Search Ads Attribution API (最も正確だが Apple Search Ads 経由のみ)
指紋認証ベース(IP + デバイス特性 + 言語 + タイムゾーン + 起動時刻の組み合わせ)の確率的マッチング ─ 業界標準で精度 70〜85%
インストール直後のディープリンク要求 API (Branch.io / Adjust の SDK が内部実装)
私は AdMob 経由の流入が中心なので、Google Play Install Referrer API(Android)と SKAdNetwork 4.0(iOS)の Postback を主軸に据え、クリップボード経由はあくまでフォールバックとして 1 度だけ試行する設計に変更しました。これで attribution 取りこぼしは DAU の 3.8% → 1.1% に改善しました。
知見3: ユニバーサルリンクが Slack/LINE/Instagram で動かない問題
これは公式ドキュメントが明確に説明していない最大の地雷です。Slack や LINE や Instagram のアプリ内ブラウザは WKWebView ベースの独自実装 で、ユニバーサルリンクを意図的に無視します(自社アプリへの離脱を防ぐため)。
私が運用している引き寄せ系アプリで、Instagram ストーリーズの「リンクスタンプ」経由の流入は、当初 Universal Link でディープリンクされる前提でしたが、実測すると 97.6% が in-app browser に留まったまま でした。
対策としては「ランディングページ側で window.location.replace('myapp://...') を JavaScript で発火させる」「同時に App Store / Play Store の URL も表示してユーザー自身に選ばせる」の二段構えが現実的です。次のような HTML を Rork Max の Web ランディングに足しています。
< script >
( function () {
const isInAppBrowser = /FBAN | FBAV | Instagram | Line | Twitter | MicroMessenger | WeChat/ i . test (navigator.userAgent);
const fallback = setTimeout (() => {
window.location.href = "https://apps.apple.com/app/idXXXXXXXX" ;
}, 1500 );
// ユニバーサルリンクを試行(in-app browser でも一応叩いておく)
window.location.href = "https://yourdomain.com/open?path=" + encodeURIComponent (window.location.pathname);
// in-app browser の場合は明示的にカスタムスキームも試す
if (isInAppBrowser) {
setTimeout (() => {
window.location.href = "myapp://" + window.location.pathname. replace ( / ^ \/ / , "" );
}, 100 );
}
})();
</ script >
この対策を入れる前と後で、Instagram 流入のアプリ起動率は 2.4% → 18.7% に上がりました。広告予算が同じでもインストール CPI が下がる効果は大きく、AdMob リターゲティング含めた月次広告効率が 11% 改善 しました。
知見4: Android App Links の verified status が突然 false になる
Google Play Console の Digital Asset Links は CI/CD で keytool 経由の SHA256 fingerprint が変わると無音で失効します。私は Rork Max が Android ビルドの署名鍵をローテートしたタイミングで、App Links が 1 週間まったく動かない事故を起こしました(GSC 経由で気づくまで時間がかかった)。
対策として、CI のリリース後フックで次のコマンドを叩いて検証しています。
# .github/workflows/post-release-android.yml
curl -s "https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://yourdomain.com&relation=delegate_permission/common.handle_all_urls" \
| jq '.statements[] | select(.target.androidApp.packageName == "com.yourapp")'
返り値が空なら Slack に通知。これで App Links 失効を 1 時間以内に検知できるようになりました。
帰属追跡の現実 ─ 12 年間のアプリ運用で見えてきたこと
ここからは、ディープリンクを「収益」につなげる最終ステップとして、帰属追跡 (Attribution) を実用範囲で扱う方法をまとめます。
私が採用している attribution の優先順位
公式ドキュメントは「最も正確な方法から順に試せ」としか書きませんが、実運用ではコストと精度のトレードオフがあります。私は次の優先順位で実装しています。
優先 方法 精度 実装コスト 適用範囲
1 Apple Search Ads Attribution API / Google Play Install Referrer 99% 中 iOS Search Ads / Google Play
2 SKAdNetwork 4.0 Postback 85〜95% 高 iOS 14.5+ 広告全般
3 指紋認証マッチング(Branch.io 等の SDK) 70〜85% 低(SDK 依存) クロスプラットフォーム
4 UTM パラメータ + クリップボード 20〜40% 低 Web → App の最終手段
私の壁紙アプリ群(累計 5,000万 DL)では、AdMob の自社プロモ枠経由のインストールが収益の柱になっており、Install Referrer + SKAdNetwork の 2 軸だけで attribution の 約 89% をカバーできています。Branch.io のような有料 SDK を入れる ROI は、月間アクティブ広告予算が 50 万円を超えるまでは出にくいというのが私の体感です。
Rork Max 生成コードでありがちな取りこぼし
Rork Max は SKAdNetwork のスキャフォールドコードまでは生成しますが、conversion value の更新ロジック はテンプレートで止まることが多いです。これを放置すると、Apple からの postback で受け取れる情報が「インストールしたかどうか」だけになり、LTV 推定がほぼ不可能になります。
私の運用では、初回起動から 24 時間以内の「課金ファネル進捗」を 6 ビットの conversion value にエンコードしています。
enum OnboardingMilestone : Int {
case launched = 0
case viewedPaywall = 1
case startedTrial = 2
case completedOnboarding = 3
case firstSession2Min = 4
case secondDayReturn = 5
}
func updateConversionValue ( for milestone: OnboardingMilestone) {
// SKAdNetwork 4.0 の coarse-grained value(low/medium/high)にもマッピング
let coarse: SKAdNetwork.CoarseConversionValue = {
switch milestone {
case .launched, .viewedPaywall : return .low
case .startedTrial, .completedOnboarding : return .medium
case .firstSession2Min, .secondDayReturn : return .high
}
}()
SKAdNetwork. updatePostbackConversionValue (milestone. rawValue ,
coarseValue : coarse,
lockWindow : false ) { error in
if let error { Crashlytics. crashlytics (). record ( error : error) }
}
}
このマイルストーン設計に切り替えたあと、Apple Search Ads の最適化アルゴリズムが拾える信号が増えて、Cost per Trial が 23% 低下 しました。Rork Max 生成コードのまま放置していると、ここの数字を取り逃します。
私が現場で使っているチェックリスト
最後に、リリース前の最終確認に使っているチェックリストを共有します。Rork Max でディープリンクを実装するときは、生成完了後にこのリストを全て通してから TestFlight / Play Console 内部テストに上げています。
✅ AASA / assetlinks.json が HTTP 200 で返ってくる(HTTPS 必須、HTTP リダイレクト不可)
✅ AASA のキャッシュコントロールヘッダが Cache-Control: public, max-age=3600 以下になっている
✅ Universal Link を Notes アプリ / Mail から踏んだときと、Safari から踏んだときの両方でアプリが開く
✅ Android App Links が adb shell pm get-app-links com.yourapp で verified 状態
✅ クリップボード経由の遅延ディープリンクが iOS 14+ の「ペースト許可」UI を出している
✅ Slack / LINE / Instagram in-app browser からの起動経路を別途用意した
✅ SKAdNetwork conversion value が onboarding ファネル全段で発火する
✅ Firebase Analytics に deep_link_received / aasa_stale / attribution_source の 3 イベントが流れている
✅ 紹介プログラムのリワード付与に べき等性チェック が入っている(同じ referralId で 2 回叩いても重複しない)
✅ 本番ドメインの ATS 設定が NSExceptionDomains の例外なしで通る
この 10 項目を満たすまでは、私は AdMob のキャンペーン予算を上げません。逆に言えば、ここまで詰めてからスケールさせると、AdMob 単体の月収を安定して伸ばす土台になります。
全体を振り返って
ディープリンクとユニバーサルリンクは現代的なアプリ・グロースに必須です。Rork Maxがネイティブ・Swiftコードを生成することで、以下のシステムを実装できます。
ユーザーを正確にルーティング して期待するコンテンツに案内
帰属を正確に追跡 して全マーケティング・チャネルでROI測定
遅延ディープリンク で紹介プログラムとウイルス・キャンペーンを有効化
Web とネイティブ・アプリをまたぐ シームレスな体験 を提供
適切なディープリンク・インフラに投資することで、以下のリターンが得られます。
ユーザー・エンゲージメントとリテンション向上
マーケティング帰属とROI追跡の改善
ユーザー紹介とグロース・メカニズムのビルトイン
WebとネイティブのクロスプラットフォームX一貫性
基本的なディープリンク実装から始め、アプリが成長するにしたがってユニバーサルリンク、遅延ディープリンク、帰属追跡を段階的に追加してください。このガイドの例を使えば、Rork Maxでグロース重視のアプリを構築するのに必要なすべてを持っています。