iOS Deferred Deep Link 延遲深度連結實作(Swift)
動手打造適應所有場景、不中斷的App轉跳流程
[2022/07/22] 更新 iOS 16 Upcoming Changes
iOS ≥ 16 開始非使用者主動操作貼上動作,App 主動讀取剪貼簿的行為會跳出詢問視窗,使用者需要按允許,App 才能讀取到剪貼簿資訊。
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
何謂 Deferred Deep Link 與 Deep Link 的差別?
純 Deep Link 本身:
可以看到 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」的流程圖
完善 Deep Link APP 端處理:
我們要的當然不只是「當使用者有安裝 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 上並不支援此設定、要達到此功能的做法也不友善,請繼續看下去。
Deferred Deep Link
如果不想花時間自己做的話可以直接使用 branch.io 或 Firebase 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瀏覽器。
根據官方文件,iOS 11 已無法取得使用者的 Safari Cookie,若有這方面需求可使用 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 及 剪貼簿。
我這邊的做法是在 主頁的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.io 或 Firebase Dynamic 沒必要重造輪子,這邊是因為介面客製化及一些複雜需求,只好自己打造。
iOS=9 的用戶已經非常稀少,不是很必要的話可以直接忽略;使用剪貼簿的方法快又有效率,而且用剪貼簿就不用局限連結一定要用 Safari 開啟!
有任何問題及指教歡迎 與我聯絡 。
===
View the English version of this article here.
本文首次發表於 Medium ➡️ 前往查看