ZhgChg.Li

iOS Deferred Deep Link|Swiftで実現する遅延深度連結の完全ガイド

iOSアプリの遅延深度連結をSwiftで実装し、ユーザーの離脱を防ぐシームレスな遷移を実現。全シナリオ対応の転送フロー設計でコンバージョン率を最大化します。

iOS Deferred Deep Link|Swiftで実現する遅延深度連結の完全ガイド
本記事は AI による翻訳です。お気づきの点があればお知らせください。

あらゆるシーンに対応し、中断しないApp遷移フローを自分で作る

[2022/07/22] iOS 16の今後の変更点の更新

iOS ≥ 16 から、ユーザーが自発的にペースト操作を行わない限り、Appが自らクリップボードを読み取ると確認ダイアログが表示され、ユーザーが許可を押さないとクリップボードの情報を取得できません。

iOS 16におけるUIPasteBoardのプライバシー変更

UIPasteBoardのiOS 16におけるプライバシー変更

[2020/07/02] 更新

無関係

卒業して兵役を終えてからほぼ3年間、平凡に働いてきましたが、成長は鈍化し、快適ゾーンに入り始めていました。幸いにも思い切って退職を決意し、リセットして再出発しました。

自分の人生設計者になるを読んで人生設計を見直す中で、仕事や人生を振り返りました。技術力はあまり高くありませんが、Mediumで皆と共有することで「フロー状態」に入り、多くのエネルギーを得られます。ちょうど先日、友人からDeep Linkの質問があったので、自分の調査した方法を整理しつつ、エネルギーも補充しました!

シナリオ

まずは実際の利用シーンについて説明します。

  1. ユーザーがAPPをインストールしている場合、URLリンク(Google検索、Facebook投稿、Lineリンクなど)をクリックすると、直接APPが起動して目的の画面を表示します。インストールしていない場合はAPP Storeに遷移してAPPをインストールします。インストール後にAPPを開いたとき、以前に行きたかった画面を再現できること。

2.APPのダウンロードと起動データの追跡では、APPのプロモーションリンクからどれだけの人が実際にこの入口を経由してAPPをダウンロードし、起動したかを知りたいです。

  1. 特別なキャンペーン入口、例えば特定のURLからダウンロードして開くと報酬が得られる。

サポート状況:

iOS ≥ 9

iOS Deep Link自体の動作機構は、APPがインストールされているかどうかを判定するだけで、インストールされていればAPPを開き、されていなければ何もしません。

まずは「インストールされていなければAPP Storeへ遷移する」ことをユーザーに促す処理を追加します:

URL Scheme の部分はシステムによって制御されており、通常はアプリ内での呼び出しに使われ、あまり公開されていません。なぜなら、トリガーが自分で制御できない場所(例:Lineのリンク)にある場合、対応できないためです。

トリガーが自分のウェブページ上にある場合、いくつかの小技で対応可能です。詳細はこちらをご参照ください:

<html>
<head>
  <title>リダイレクト中...</title>
  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
  <script>
    var appurl = 'marry://open';
    var appstore = 'https://apps.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E6%9C%80%E5%A4%A7%E5%A9%9A%E7%A6%AE%E7%B1%8C%E5%82%99app/id1356057329';

    var timeout;
    function start() {
      window.location = appurl;
      timeout = setTimeout(function(){
        if(confirm('結婚吧アプリをすぐにインストールしますか?')){
          document.location = appstore;
        }
      }, 1000);
    }

    window.onload = function() {
      start()
    }
  </script>
</head>
<body>

</body>
</html>

大まかなロジックは 同じくURL Schemeを呼び出し、タイムアウトを設定し、時間内に遷移しなければ未インストールと判断してSchemeを呼べなかったとみなし、代わりにApp Storeページへ遷移する(ただし体験はあまり良くなく、URLエラーの表示が出ることもあり、自動リダイレクトが追加されるだけ)。

Universal Link 自体が独自のウェブページであり、遷移しない場合はデフォルトでウェブブラウザで表示されます。ここでは、ウェブサービスがある場合は直接ウェブブラウザへ遷移し、ない場合は直接App Storeページへ誘導します。

ウェブサービスのサイトでは、<head></head> 内に以下を追加できます:

<meta name="apple-itunes-app" content="app-id=APPID, app-argument=ページパラメータ">

iPhoneのSafariでウェブ版を閲覧すると、上部にAPPインストールの案内や「APPでこのページを開く」ボタンが表示されます。パラメータのapp-argumentはページの値を渡し、APPに伝達するために使われます。

「無ければAPP Storeへ遷移」フロー図

「無則APP Storeに遷移」のフローチャート追加

Deep LinkのAPP側処理の改善:

私たちが求めているのは「ユーザーがアプリをインストールしていればアプリを開く」だけではありません。ソース情報をアプリと連携させ、アプリ起動後に自動で目的のページを表示させることです。

URL Scheme は AppDelegate の func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool 内で処理できます:

func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool {
    if url.scheme == "marry",let params = url.queryParameters {
      if params["type"] == "topic" {
        let VC = TopicViewController(topicID:params["id"])
        UIApplication.shared.keyWindow?.rootViewController?.present(VC,animated: true) // トピック画面を表示
      }    
    }
    return true
}

Universal Link は AppDelegate の func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]?) -> Void) -> Bool 内で処理されます:

extension URL {
    /// test=1&a=b&c=d => ["test":"1","a":"b","c":"d"]
    /// URLのクエリを解析して[String: String]の辞書に変換する
    public var queryParameters: [String: String]? {
        guard let components = URLComponents(url: self, resolvingAgainstBaseURL: true), let queryItems = components.queryItems else {
            return nil
        }
        
        var parameters = [String: String]()
        for item in queryItems {
            parameters[item.name] = item.value
        }
        
        return parameters
    }
    
}

先に URL の拡張メソッド queryParameters を示します。これは URL のクエリを Swift の Dictionary に変換するのに便利です。

func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]?) -> Void) -> Bool {
        
  if userActivity.activityType == NSUserActivityTypeBrowsingWeb, webpageURL = userActivity.webpageURL {
    /// universal link URLの場合...
    let params = webpageURL.queryParameters
    
    if params["type"] == "topic" {
      let VC = TopicViewController(topicID:params["id"])
      UIApplication.shared.keyWindow?.rootViewController?.present(VC,animated: true)
    }
  }
  
  return true  
}

了解しました!翻訳したいMarkdown段落を送ってください。

それで何が足りないのか?

現時点ではほぼ完璧に見えます。すべての想定される状況に対応しましたが、まだ何か足りないものはありますか?

図のように、未インストール -> App Storeでインストール -> App Storeで起動の場合、元の情報が途切れてしまい、アプリは元の情報を認識できずホーム画面のみ表示されます。ユーザーは再度前のウェブページに戻ってもう一度開くをタップしなければ、アプリはページ遷移を実行しません。

これでも不可能ではありませんが、離脱率を考えると、ステップが増えるごとに離脱が増え、ユーザー体験もスムーズではありません。ましてや、ユーザーが必ずしも賢いとは限りません。

本文の本題に入る

何が Deferred Deep Link(遅延ディープリンク)か?それは、Deep Link を App Store でインストール後も元の参照情報を保持できるように拡張する仕組みです。

Androidエンジニアによると、Android自体にはこの機能がありますが、iOSではサポートされておらず、この機能を実現する方法もあまり親切ではありません。続きをご覧ください。

Deferred Deep Link(ディファードディープリンク)

もし自分で作る時間をかけたくない場合は、直接 branch.ioFirebase Dynamic Links を利用できます。この記事で紹介している方法は Firebase の使い方です。

Deferred Deep Linkの効果を達成する方法は、ネット上で主に2つあります:

一つはユーザーのデバイス、IP、環境などのパラメータからハッシュ値を計算し、ウェブ側でサーバーにデータを保存します。アプリをインストールして開いたときに同じ方法で計算し、値が一致すればデータを取得して復元する方法です(branch.io の手法)。

もう一つは本記事で紹介する方法で、Firebaseの方法と同様に、iPhoneのクリップボードとSafariとAPPのCookie共有機能を使う方法です。これはデータをクリップボードやCookieに保存し、APPのインストール完了後に読み取って使用する仕組みです。


「Open」をクリックすると、JavaScriptによってあなたのクリップボードが自動的に上書きされ、遷移に必要な情報がコピーされます:https://XXX.app.goo.gl/?link=https://XXX.net/topicID=1&type=topic

Firebase Dynamic Linksを使ったことがある人なら、この開いてジャンプするページに馴染みがあるはずです。仕組みを理解すれば、このページがフローの中で削除できないことがわかります!

また、Firebaseはスタイルの変更をサポートしていません。

サポート状況

まずは落とし穴について、対応状況の問題です。前述のように「使い勝手が良くない」点です!

もしAPPがiOS 10以上のみを対象とする場合は、ずっと簡単になります。APP側でクリップボードの読み書きを実装し、Web側でJavaScriptを使って情報をクリップボードに書き込み、その後APP Storeへ遷移してダウンロードさせれば良いです。

iOS = 9 はJavaScriptによる自動クリップボード操作をサポートしていませんが、SafariとAPPのSFSafariViewControllerでの「Cookie共有テクニック」をサポートしています。

また、APPはバックグラウンドでこっそりSFSafariViewControllerを追加してWebを読み込み、先ほどリンクをクリックしたときに保存したCookie情報をWebから取得する必要があります。

手順が複雑で、リンクのクリックはSafariブラウザのみ対応。

SFSafariViewController

SFSafariViewController

公式ドキュメントによると、iOS 11以降はユーザーのSafari Cookieを取得できません。この場合はSFAuthenticationSessionを使用できますが、この方法はバックグラウンドでの実行ができず、読み込みのたびに以下の確認ダイアログが表示されます:

*SFAuthenticationSessionの許可ダイアログ*

SFAuthenticationSession の認証ダイアログ

また、APP審査ではSFSafariViewControllerをユーザーが見えない場所に配置することは許可されていません。(プログラムでトリガーしてからaddSubviewする方法は見つけにくいですが)

実践してみよう

まず簡単に説明すると、iOS 10以上のユーザーのみを対象に、iPhoneのクリップボードを使って情報を転送する方法です。

Web 側:

Firebase Dynamic Links を参考にしてカスタムページを作成し、clipboard.js というライブラリを使ってユーザーが「すぐに移動」をクリックしたときに、APP に渡したい情報 (marry://topicID=1&type=topic) をまずクリップボードにコピーし、その後 location.href で App Store のページに遷移させています。

APP 側:

AppDelegate またはメインの UIViewController でクリップボードの値を読み取る:

let pasteData = UIPasteboard.general.string // クリップボードから文字列を取得する

ここでは情報をURL Scheme方式でパッケージ化することをおすすめします。これにより識別やデータの逆解析が容易になります:

if let pasteData = UIPasteboard.general.string,let url = URL(string: pasteData),url.scheme == "marry",let params = url.queryParameters {
    if params["type"] == "topic" {
      let VC = TopicViewController(topicID:params["id"])
      UIApplication.shared.keyWindow?.rootViewController?.present(VC,animated: true) // トピック画面を表示
    }
}

最後に処理が完了したら、UIPasteboard.general.string = “” を使ってクリップボードの情報をクリアします。

実践 — iOS 9 バージョン対応

面倒なことに、iOS 9版をサポートするためには、前述の通りクリップボードがサポートされていないため、Cookie共有の方法を使う必要があります。

Web 側:

web側も扱いやすく、ユーザーが「今すぐ行く」をクリックしたときに、APPに渡したい情報をCookieに保存します(marry://topicID=1&type=topic)。その後、location.hrefでAPP Storeのページに遷移します。

ここでは、開発を加速するための2つのラップされたJavaScriptのCookie処理メソッドを提供します:

/// name: Cookie 名
/// val: Cookie 値
/// day: Cookie の有効期限、デフォルトは1日
/// EX1: setcookie("iosDeepLinkData","marry://topicID=1&type=topic")
/// EX2: setcookie("hey","hi",365) = 1年間有効
function setcookie(name, val, day) {
    var exdate = new Date();
    day = day \\|\\| 1;
    exdate.setDate(exdate.getDate() + day);
    document.cookie = "" + name + "=" + val + ";expires=" + exdate.toGMTString();
}

/// getCookie("iosDeepLinkData") => marry://topicID=1&type=topic
function getCookie(name) {
    var arr = document.cookie.match(new RegExp("(^\\| )" + name + "=([^;]*)(;\\|$)"));
    if (arr != null) return decodeURI(arr[2]);
    return null;
}

APP 側:

本文で最も厄介な部分がやってきました。

前文で原理について述べましたが、メインページのUIViewControllerでプログラムを使ってこっそりSFSafariViewControllerをバックグラウンドで読み込み、ユーザーに気付かれないようにします。

もう一つの落とし穴: こっそり読み込む場合、iOS 10以上のSFSafariViewControllerでViewのサイズを1未満、透明度を0.05未満、またはisHiddenに設定すると、SFSafariViewControllerは 読み込まれません

p.s iOS = 10 は Cookie とクリップボードの両方をサポートしています。

<https://stackoverflow.com/questions/39019352/ios10-sfsafariviewcontroller-not-working-when-alpha-is-set-to-0/39216788>

https://stackoverflow.com/questions/39019352/ios10-sfsafariviewcontroller-not-working-when-alpha-is-set-to-0/39216788

私のやり方は、ホーム画面の UIViewController の上部に適当な高さの UIView を置き、その下端をホーム画面の UIView の上部に合わせます。そして IBOutlet(sharedCookieView)をクラスに接続します。viewDidLoad() 内で SFSafariViewController を初期化し、その View を sharedCookieView に追加します。これにより実際には表示・読み込みされていますが、画面外にあるためユーザーには見えません🌝。

SFSafariViewController の URL はどこを指すべきか?

Web側の共有ページと同様に、Cookieを読み取るためのページをもう一つ作成し、クロスドメインのCookie問題を避けるために両方のページを同じドメイン内に配置します。ページの内容は後ほど添付します。

@IBOutlet weak var SharedCookieView: UIView!

override func viewDidLoad() {
    super.viewDidLoad()
    
    let url = URL(string:"http://app.marry.com.tw/loadCookie.html")
    let sharedCookieViewController = SFSafariViewController(url: url)
    VC.view.frame = CGRect(x: 0, y: 0, width: 200, height: 200) // ビューのフレームを設定
    sharedCookieViewController.delegate = self // デリゲートを設定
    
    self.addChildViewController(sharedCookieViewController) // 子ビューコントローラーとして追加
    self.SharedCookieView.addSubview(sharedCookieViewController.view) // SharedCookieViewに追加
    
    sharedCookieViewController.beginAppearanceTransition(true, animated: false) // 表示遷移開始
    sharedCookieViewController.didMove(toParentViewController: self) // 親ビューコントローラーへの移動完了
    sharedCookieViewController.endAppearanceTransition() // 表示遷移終了
}

sharedCookieViewController.delegate = self

class HomeViewController: UIViewController, SFSafariViewControllerDelegate

読み込み完了後のコールバック処理をキャッチするには、このDelegateを追加する必要があります。

私たちは以下の場所でできます:

func safariViewController(_ controller: SFSafariViewController, didCompleteInitialLoad didLoadSuccessfully: Bool) {
// SFSafariViewControllerの初期読み込みが完了したときに呼ばれるメソッド

方法内で読み込み完了イベントをキャッチする。

ここまで来たら、次は didCompleteInitialLoad でウェブ内のCookieを読み取るだけで完了です!

ここではSFSafariViewControllerのCookieを読み取る方法が見つかりませんでした。ネット上の方法を試してもすべて空でした。

場合によってはJavaScriptを使ってページ内容とやり取りし、JavaScriptにCookieを読み取らせてUIViewControllerに返す必要があります。

TrickyなURL Scheme方法

iOS が共有された Cookie を取得できないので、「Cookie を読むページ」に直接「Cookie を読んでもらう」ようにします。

前文で紹介したJavaScriptのCookie処理メソッドgetCookie()はここで使います。私たちの「Cookieを読み取るページ」の内容は空白ページです(ユーザーは見えません)が、JavaScript部分ではbodyのonload後にCookieを読み取ります:

<html>
<head>
  <title>iOSディープリンク保存クッキーの読み込み...</title>
  <script>
  function checkCookie() {
    var iOSDeepLinkData = getCookie("iOSDeepLinkData");
    if (iOSDeepLinkData && iOSDeepLinkData != '') {
        setcookie("iOSDeepLinkData", "", -1);
        window.location.href = iOSDeepLinkData; /// marry://topicID=1&type=topic
    }
  }
  </script>
</head>

<body onload="checkCookie();">

</body>

</html>

実際の原理のまとめは次の通りです:HomeViewController viewDidLoad 時に SFSafariViewController を追加して loadCookie.html ページをこっそり読み込みます。loadCookie.html ページは先に保存されたCookieを読み取りチェックし、あれば読み出して削除し、その後 window.location.href を使って呼び出し、URL Scheme をトリガーします。

なので、その後の対応するコールバック処理は AppDelegatefunc application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool に戻って処理されます。

完了!まとめ:

面倒に感じる場合は、branch.ioFirebase Dynamic を直接使うのが良いです。無理に一から作る必要はありません。ここではインターフェースのカスタマイズや複雑な要件があるため、自前で構築しています。

iOS=9のユーザーは非常に少なく、特に必要でなければ無視しても問題ありません。クリップボードを使う方法は速くて効率的であり、クリップボードを使うことでリンクを必ずSafariで開く必要もありません!

何かご質問やご意見がありましたら、こちらからご連絡ください

PostZMediumToMarkdown によって Medium から変換されました。

GitHub で編集
この記事を改善
本記事は Medium で初公開
オリジナルを読む
この記事をシェア
リンクをコピー · SNS でシェア
ZhgChgLi
著者

ZhgChgLi

An iOS, web, and automation developer from Taiwan 🇹🇼 who also loves sharing, traveling, and writing.

コメント