Deferred Deep Linking in iOS

Deep Linking

其实 deep linking 并不是一个新名词,在 web 开发领域,区别于指向首页的链接(http://tech.glowing.com/),deep linking 是指向具体内容页的链接(http://tech.glowing.com/cn/advices-to-junior-developers/)。在移动开发领域,deep linking 则是指 mobile app 在 handle 特定 URI 的时候可以直接跳转到对应的内容页或触发特定逻辑,而不仅仅是启动 app。比如 dianping://shopinfo?id=1859284,如果你的手机上装了大众点评的话点击这个链接可以直接跳转到商铺页面。这样做的好处主要有:

  • 在 web 和 app 的切换过程中保留上下文
  • App 间带上下文切换(用于实现 app 间参数的传递,如授权协议,分享 API 等)
  • Web 页可以被搜索引擎索引,可以通过 SEO 增加访问量从而提高 app 下载量和开启率

目前处理 deep linking,主要有两种方式:

Custom URL Scheme

在 universal links 出现之前的很长一段时间里,iOS 上主要通过 custom URL scheme 来实现 deep linking,以及 app 间的通信。

在 info plist 里设置了自定义 URL 后,handle URL 的入口是 app delegate 方法 application:openURL:sourceApplication:annotation:(iOS 9 开始被 deprecate)或 application:openURL:options:(iOS 9 引入,但如果没有实现这个方法,在 iOS 9 上还是会向前兼容 call 老方法,所以一般还是实现老方法)。

- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url
        sourceApplication:(NSString *)sourceApplication annotation:(id)annotation {
    BOOL handled = NO;
    // code to handle the URL
    return handled;
}

一个比较完整的 NSURL 可以包含以下部分:scheme://user:password@host:port/path?query#fragment。但对于 deep linking 来说大部分时候只需要 scheme://host/path?query。有时候会省去 path 部分,把 host 直接作为 command,如上文提到的点评的 link;也有些 app 会省去 query 部分,用 path 传参,更接近 RESTful API 的风格。这取决于具体业务逻辑复杂程度以及 handler 的实现方式。有一点需要注意的是,规范的 URL 是 percentage encoded 的,所以取出来的参数需要用 stringByReplacingPercentEscapesUsingEncoding: 或 stringByRemovingPercentEncoding(iOS 7+)方法 decode。反之,拼 URL 的时候应该使用 stringByAddingPercentEscapesUsingEncoding: 或 stringByAddingPercentEncodingWithAllowedCharacters:(iOS 7+)方法 encode。

在 iOS 7+ 上处理 query 的时候也可以配合使用 NSURLComponents 类。

具体 handle URL 的时候,对于需要处理的业务逻辑较少的 app 来说,可以简单地通过字符串比较来区分业务逻辑。对于业务逻辑相对复杂,特别是在跨团队共同维护 URL handler 的时候,则需要引入 router 来分发请求。关于 router 已经有很多文章涉及,GitHub 上也有很多开源代码可供参考或使用,比如:

具体选型或自己实现 router 的时候主要考虑一些问题比如:用 code 注册还是配置文件;是否需要去中心化;如何传参;以 view controller 还是 block (closure) 为单位来注册 handler;是否需要像淘宝一样做 web 版的 failover 等等…… 这里不再展开。

相关文档:Using URL Schemes to Communicate with Apps

Apple 在 iOS 9 上引入了 universal links,相较 custom URL scheme,universal links 有以下好处:

  • Custom URL scheme 因为是自定义的协议,所以在没有安装 app 的情况下是无法直接打开的,而 universal links 本身是一个 HTTP/HTTPS 链接,所以有更好的兼容性。
  • 不同的 app 是可以定义相同的 custom URL scheme 的,所以会存在抢占或冲突的问题,而 universal links 是从 server 查询由哪个 app 打开的,所以不存在上述问题。
  • Universal links 支持从其他 app 的 MKWebView 或 UIWebView 中跳转到目标 app。
  • Universal links 本身可以被搜索引擎索引。

Universal links 的具体实现可以参考官方文档:Support Universal Links。简单来说你需要:

  • 添加一个 apple-app-site-association 文件到你的网站来描述 URL 和 app 的关联。
  • 添加 com.apple.developer.associated-domains entitlement 来指定要从哪些域名查询 universal links support。
  • 在 app delegate 的 application:continueUserActivity:restorationHandler: 方法中 handle userActivity.webpageURL

处理 URL 本身的方法跟前面处理 custom URL 类似,不再赘述。

Deferred Deep Linking

顾名思义,deferred deep linking 是指用户打开一个 web page 的时候并没有安装对应的 app,希望用户在安装 app 以后可以 deep link 到对应内容。这里有三个需要解决的问题:

  1. 判断是否已经安装了 app,如果已经安装了直接 deep link 到 app,否则跳转 App Store。
  2. 用户匹配(user matching),如何把一个 install 对应到某一次 web page view 或者某一次 click。
  3. Deep linking

问题 1

以前在使用 custom URL 的时候一般用类似这样的一段 JS 处理:

window.location = 'lexie://';  
setTimeout(function() {  
    window.location = 'itms-apps://itunes.apple.com/us/app/eve-by-glow-period-tracker/id1002275138'
}, 250);

这是因为在 iOS 9.2 以前,Safari 里是否用 app 打开 custom URL 的提示是 blocking JS 的,所以如果用户同意用 app 打开链接以后就不会跳转 App Store,反之,用户选择取消或者并没有安装 app 的时候,会跳转 App Store。iOS 9.2 Apple 做了一个更新就是这个提示不再 block JS,所以无论如何都会跳转 App Store。因此现在会推荐使用 universal links 来实现这样的逻辑,对于需要强制安装 app 后才能浏览的内容,可以提供一个直接跳转 App Store 的中转页面,如果装了 app,iOS 会自动跳转到 app 内处理。

2016-01-06 更新:

经过 @zyg 的提醒和测试,这里的逻辑在 iOS 9.2 上还分两种情况:

http://tech.glowing.com/cn/content/images/deeplink.html
http://tech.glowing.com/cn/content/images/deeplink2.html

代码的区别:前者是 link 触发 JS;后者是 onload 时候直接触发。
结果的区别:前者会直接跳转 App Store;后者不会。

原因:Safari 里,点击事件触发跳转 App Store 没有是否在 App Store 打开的确认提示;非用户操作触发的跳转会有确认提示。所以 9.2 点击 link 时,跳转 App Store 的事件因为没有被 block 所以会直接跳转(区别于 9.1 及以前因为被 block 所以不会跳转)。而直接触发的跳转(比如 onload)因为 App Store 的确认提示被前一个用 app 打开的提示 block 了,所以虽然和之前 work 的原理(blocking JS)不一样,但至少是部分 work 的。

问题 2

这曾经是个老大难的问题,受系统所限,在 iOS 上很难追踪到一个安装的来源,但是这样的需求又很多,主要的场景有:

  • 追踪广告效果
  • 追踪用户推荐 / 邀请链接
  • 在 app 内保持网页浏览的上下文,如登录信息,购物车等

对于这个问题,在 iOS 9 以前常见的做法是猜,没错,就是用猜的。在访问特定页面或点击特定链接的时候记录用户特征,如 IP,系统版本,手机型号,语言等等。然后在打开 app 的时候发送这些特征到服务器,查询一段时间内(如 1 小时内)有可以匹配的用户点击过的链接,然后处理这个链接。这样做的缺点很明显,因为是通过特征模糊匹配的,所以很容易匹配不到或匹配到错误的上下文。但是其实大部分第三方服务会从不同来源收集更多信息,所以这个准确率其实比想象中高很多,尤其是在打开了 IDFA 的情况下。

这个问题却在 iOS 9 引入 SFSafariViewController 以后得到了很好的解决,因为 SFSafariViewController 和 Safari 的 cookies 是互通的!所以理论上可以做到 100% 的 match。解决方案也很简单,本地生成一个 UUID 并通过一个隐藏的 SFSafariViewController 传回给 server,server 就可以把这个 UUID 跟之前的 session 对应起来,然后通过一般的 API call 查询更多跟这个 session 有关的信息。具体的 code 可以参考 Branch SDK 的实现

问题 3

上个章节已经提到,不再赘述,只是处理 URL 的入口换成了某个 API 请求的 callback 里。

Branch SDK

有很多第三方提供了 deep linking 和 deferred deep linking 的服务,比如 AppsFlyer 和 Branch。目前在 Glow 的 app 里这两个 SDK 都有用到。

其中 AppsFlyer 的优势在于他们跟很多公司有合作关系,比如 Facebook,所以用于追踪 Facebook 广告效果表现较好。另外 AppsFlyer 支持很多第三方服务的 server callback,可以方便集成很多第三方服务。缺点是 AppsFlyer 按 non-organic install 量收费。而且 AppsFlyer 的 SDK 和 API doc 写的不是很好,在 track 安装以后的后续 deep link 的时候感觉有很多 bug。

Branch 的优势在于免费,SDK 和 API doc 都写的比较好,而且有一些特殊的功能比如用户邀请及奖励之类的,适合做一些运营活动。另外 Branch 可以实现一个 link 根据平台自动跳转不同 Store,甚至可以在 desktop 上通过短信发送可以追踪的链接。缺点是 Branch 运营时间不久,服务稳定性有待验证,dashboard 的功能也还比较轻量。

总的来说 AppsFlyer 更适合 track 广告效果,Branch 更适合实现 feature。必须一提的是,因为这两个服务都是主要面向海外市场的,所以曾经都遇到过国内短暂抽风的现象,所以国内的 app 如果要用的话风险自担 🙂 如果国内有类似的服务的话也欢迎留言补充。

Branch 的集成比较简单,参见官方文档。一个需要注意的是,自己实现的时候在 handle URL 或者 user activity 的时候可以直接处理 URL,但是用 Branch 的时候,第一级的 URL 是 Branch 的 URL,所以要通过 [[Branch getInstance] handleDeepLink:url] 和 / 或 [[Branch getInstance] continueUserActivity:userActivity] 交由 Branch 处理,然后在 init Branch 时传入的 block (closure) 中处理各类参数

[branch initSessionWithLaunchOptions:launchOptions andRegisterDeepLinkHandler:^(NSDictionary *params, NSError *error) {
    if (!error) {
        // params are the deep linked params associated with the link that the user clicked -> was re-directed to this app
        // params will be empty if no data found
        // ... insert custom logic here ...
        NSLog(@"params: %@", params.description);
    }
}];