記事

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

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

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

本記事は AI による翻訳をもとに作成されています。表現が不自然な箇所がありましたら、ぜひコメントでお知らせください。

記事一覧


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

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

iOS ≥ 16 から、ユーザーが自発的に貼り付け操作を行わない限り、アプリが剪貼簿を読み取ると確認ダイアログが表示され、ユーザーが許可しないとアプリは剪貼簿の情報を取得できません。

[UIPasteBoardのiOS 16におけるプライバシー変更](https://sarunw.com/posts/uipasteboard-privacy-change-ios16/){:target="_blank"}

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

[2020/07/02] 更新

無関係

卒業して兵役を終えてから、なんとなく働き続けてもうすぐ3年。成長は頭打ちになり、居心地の良い環境に入ってしまった。幸いにも思い切って退職を決め、気持ちを整理して再スタートを切った。

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

シナリオ

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

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

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

  1. 特別なイベント入口、例えば特定のURLからダウンロードして開くと報酬がもらえる場合。

サポート状況:

iOS ≥ 9

iOS Deep Linkの仕組みは、アプリがインストールされているかどうかを判定し、インストールされていればアプリを開き、されていなければ何もしないという動作のみです。

まず「未インストールの場合はApp Storeへ遷移する」ことをユーザーに促す処理を追加します:

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

自身のウェブページでトリガーがある場合は、いくつかの小技を使って対応できます。詳しくはこちらを参照してください:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<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> 内に以下を追加できます:

1
<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内で処理できます:

1
2
3
4
5
6
7
8
9
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 内で処理します:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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に簡単に変換できます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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  
}

完了!

それで何が足りない?

現時点でほぼ完璧に仕上がっており、発生しうるすべての状況に対応していますが、まだ何が不足しているのでしょうか?

図のように、未インストール → 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インストール後に読み取って使用します。

1
2
「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](https://developer.apple.com/documentation/safariservices/sfsafariviewcontroller){:target="_blank"}

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スキーム方式で包むことを推奨します。識別やデータの復号が容易になるためです:

1
2
3
4
5
6
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のページに遷移します。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/// 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>{:target="_blank"}

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

こちらの方法は、メインの UIViewController の上部に UIView を設置し、適当な高さを設定しますが、その下端をメイン UIView の上端に合わせます。そして IBOutlet(sharedCookieView)をクラスに接続します。viewDidLoad() 内で SFSafariViewController を初期化し、そのビューを sharedCookieView に追加します。これにより、実際には表示されて読み込まれていますが、画面には表示されずユーザーは見えません🌝。

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@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) // サブビューとして追加
    
    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) {

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

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

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

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

TrickyなURL Schemeの方法

iOSが共有されたCookieを取得できないので、「Cookieを読み取るページ」に直接「Cookieの読み取り」を任せましょう。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<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 をトリガーします。

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

完了!まとめ:

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

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

ご質問やご意見がございましたら、お問い合わせ ください。

Post は Medium から ZMediumToMarkdown によって変換されました。


🍺 Buy me a beer on PayPal

👉👉👉 Follow Me On Medium! (1,053+ Followers) 👈👈👈

本記事は Medium にて初公開されました(こちらからオリジナル版を確認)。ZMediumToMarkdown による自動変換・同期技術を使用しています。

Improve this page on Github.

本記事は著者により CC BY 4.0 に基づき公開されています。