Post

iOS Deferred Deep Link 延迟深度连结实作|Swift 完整流程与技巧解析

针对iOS App推广,解决未安装用户跳转后资料遗失问题,透过剪贴簿与Cookie共享技术,实现安装后自动还原目标页面,提升用户体验与转换率。本文详解Swift实作步骤与iOS版本支援策略。

iOS Deferred Deep Link 延迟深度连结实作|Swift 完整流程与技巧解析

Click here to view the English version of this article.

點擊這裡查看本文章正體中文版本。

基于 SEO 考量,本文标题与描述经 AI 调整,原始版本请参考内文。


动手打造适应所有场景、不中断的App转跳流程

[2022/07/22] 更新 iOS 16 Upcoming Changes

iOS ≥ 16 开始非使用者主动操作贴上动作,App 主动读取剪贴簿的行为会跳出询问视窗,使用者需要按允许,App 才能读取到剪贴簿资讯。

[UIPasteBoard’s privacy change in iOS 16](https://sarunw.com/posts/uipasteboard-privacy-change-ios16/){:target="_blank"}

UIPasteBoard’s privacy change in iOS 16

[2020/07/02] 更新

无关

毕业当完兵到现在庸庸碌碌工作了快三年,成长已趋于平缓,开始进入舒适圈,所幸心一横提了离职,沈淀重新出发。

在阅读 做自己的生命设计师 重新梳理自己的人生规划时,回顾了一下工作跟人生,虽然本身技术能力没有很好,但在写 Medium 与大家分享能让我进入「心流」跟获得大量的精力;刚好前阵子有朋友在问 Deep Link 问题,借此整理了我研究的做法,也顺便补充下自己的精力!

场景

首先要先说明实际应用场景。

1.当使用者有装 APP 时点击网址连结(Google搜寻来源、FB贴文、Line连结…) 则直接开 APP 呈现目标画面,若无则跳转到 APP Store 安装 APP; 安装完后打开APP,要能重现之前欲前往的画面

2.APP 下载和开启数据追踪,我们想知道 APP 推广连结有多少人确实从这个入口下载和开启 APP 的。

3.特殊活动入口,例如透过特定网址下载后开启能获得奖励。

支援度:

iOS ≥ 9

可以看到 iOS Deep Link 本身运作机制只有判断 APP 有无安装,有则开 APP,无则不处理.

首先我们要先加上「无则跳转到 APP Store」提示使用者安装 APP:

URL Scheme 的部分是由系统控制,一般用于 APP 内呼叫鲜少公开出来;因为如果触发点在自己无法控制的区域(如: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>Redirect...</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('马上安装结婚吧APP?')){
          document.location = appstore;
        }
      }, 1000);
    }

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

</body>
</html>

大略逻辑是 一样呼叫 URL Scheme,然后设个 Timeout,时间到若还在本页没跳转就当没安装 Call 不到 Scheme,转而导 APP Store 页面 (但体验还是不好还是会跳网址错误提示,只是多了自动转址)。

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」的流程图

我们要的当然不只是「当使用者有安装 APP 则开启 APP」,我们还要将来源资讯与 APP 串起,让 APP 开启后自动呈现目标页面的 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"]
    /// 解析网址query转换成[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 Query 转换成 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 打开,来源所带的资料就会中断,APP 不知道来源所以就只会显示首页;使用者要再回到上一步网页再点一次开启,APP 才会驱动跳页。

虽然这样也不是不行,但考虑到跳出流失率,多一个步骤就是多一层流失,还有使用者体验起来不顺畅;更何况使用者未必这么聪明。

进入本文重点

何谓 Deferred Deep Link?,延迟深度连结;就是让我们的 Deep Link 可以延伸到 APP Store 安装完后依然保有来源资料。

据 Android 工程师表示 Android 本身就有此功能,但在 iOS 上并不支援此设定、要达到此功能的做法也不友善,请继续看下去。

如果不想花时间自己做的话可以直接使用 branch.ioFirebase Dynamic Links 本文介绍的方法就是 Firebase 使用的方式。

要达成 Deferred Deep Link 的效果网路上有两种做法:

一种是透过使用者装置、IP、环境…等等参数计算出一个杂凑值,在网页端存入资料到伺服器;当 APP 安装后打开用同样方式计算,如果值相同则取出资料恢复(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,再从 Web 取得刚才点连结时存的Cookie资讯。

步骤繁琐&连结点击仅限 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 Scheme 方式包装,方便进行辨识、资料反解:

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 商城页。

这里提供两个封装好的 JavaScript 处理 Cookie 的方法,加速开发:

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) = 一年有效
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) 到 Class;在 viewDidLoad( ) 时 init SFSafariViewController 并将其 View 加入到 sharedCookieView 上,所以他实际有显示有载入,只是跑出画面了,使用者看不到🌝。

SFSafariViewController 的 URL 该指向?

同 Web 端分享页面,我们要再刻一个 For 读取 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 才能捕获载入完成后的 CallBack 处理。

我们可以在:

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>Load iOS Deep Link Saved Cookie...</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 机制。

所以之后对应的 CallBack 处理就会回到 AppDelegate 中的 func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool 进行处理。

完工!总结:

如果觉得烦琐,可以直接使用 branch.ioFirebase Dynamic 没必要重造轮子,这边是因为介面客制化及一些复杂需求,只好自己打造。

iOS=9 的用户已经非常稀少,不是很必要的话可以直接忽略;使用剪贴簿的方法快又有效率,而且用剪贴簿就不用局限连结一定要用 Safari 开启!

有任何问题及指教欢迎 与我联络


Buy me a beer

本文首次发表于 Medium (点击查看原始版本),由 ZMediumToMarkdown 提供自动转换与同步技术。

Improve this page on Github.

This post is licensed under CC BY 4.0 by the author.