Android Deep Link 深度链接,看看你在第几层?

8,379 阅读10分钟

「Offer 驾到,掘友接招!我正在参与2022春招系列活动-经验复盘,点击查看 活动详情 即算参赛

本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 提问。

1. 背景

  • 你一定遇到过这个场景,你在微信上浏览一个朋友分享的淘宝商品,但是你想要在淘宝 App 上打开(毕竟原生 App 才能提供更完整和流畅的体验)。此时,你需要打开淘宝 App,然后再通过搜索功能一步步找到刚才的商品。然后,然后就没有然后了。
  • 跳转过程的操作路径太长了,用户体验很一般,用户很容易在这个过程中流失。那么,有没有可能实现浏览器点击链接直达商品页的流畅用户体验呢?这就是 App 深度链接要做的事了。

简单来说,App 深度链接(Deep Link)是一项基础的 App 优化方法,通过技术手段缩短了用户操作路径,从而优化了产品服务的用户体验,最终帮助实现了转化率提升、用户增长等业务目标。


2. 应用场景

一键跳转是深度链接比较重要的使用场景,但它的能力不仅于此,主要包括以下几种:

  • 一键跳转: 在用户已安装 App 的情况下,从浏览器或 QQ、微信等社交平台,一键拉起 App 并直达落地页;
  • 传参安装(也叫延迟深度链接): 在用户未安装 App 的情况下,引导用户到应用市场下载安装应用,并在应用首次启动后自动直达落地页;

这两个场景分别对应用户已安装 App 和未安装 App 的两种情况,在此基础上, 还可以衍生出其他一些业务化的场景:

  • 分享闭环(也叫场景还原): 用户将 App 内容分享到微信等社交平台,其他用户通过分享链接打开或安装后打开 App,自动直达分享内容,实现流量闭环;
  • 无码邀请: 用户通过二维码 / 链接等形式邀请新用户安装,新用户下载安装后可以识别出邀请来源,免除填写邀请码,对用户更友好(通常是在链接中拼接来源业务标识,例如 code=[内容页类型]_[内容 ID]_[App标识]_[用户标识]);
  • 渠道追踪: 用户通过 Web 下载引导页安装 App 后,首次启动时 App 识别并统计下载渠道,实现渠道效果归因。


3. 数据流转流程

在深度链接的工作流程需要 Wap 端、客户端和服务端协同配合,整体的数据流转示意图如下:

  • Wap 端: 判断设备是否安装指定 App,已安装则直接拉起 App 并传递深度链接参数,未安装则引导用户重定向到应用市场安装 App,并将深度链接参数暂存到服务器;
  • 服务端: 主要是为了兼容设备未安装 App 的场景,可以理解为是前端和客户端之间的通讯桥梁。前端会将设备唯一标识和深度链接参数的映射关系临时存储在服务器,将来一段时间内,客户端可以凭借设备唯一标识从服务端读取参数;
  • 客户端: 根据前端直传的深度链接直达落地页,或者在首次启动时尝试从服务端读取暂存的参数,再直达落地页。

4. 一键跳转实现原理

在用户已安装 App 的情况,可以通过标准的协议实现一键拉起 App 并传递深度链接参数,目前主要有以下三种协议:

Deep Link描述适用系统
Scheme 协议所有系统支持的 App 相互调用的协议,并且可以传递参数所有系统
App LinksGoogle 在 Android M 提出的深度链接实现,我还没发现它比 Scheme 的优势在哪里。如果你们项目用了,请告诉我为什么Android M(6)+
Universal linksApple 在 iOS 9(WWDC 2015)推出的通用链接的 deep link 特性iOS 9 +

这里我们主要介绍 Android 端的实现,主要分为以下几个步骤:

  • 配置 AndroidManifest.xml: 在 AndroidManifest.xml 中定义接收参数的 Activity,并配置 IntentFilter 筛选期望接收的参数。例如:

    <activity 
        android:name=".app.ProxyActivity"
        android:exported="true"
        android:launchMode="singleTask"
        android:theme="@style/SplashTheme">
        <intent-filter>
            <action android:name="android.intent.action.VIEW"/>
    
            <category android:name="android.intent.category.DEFAULT"/>
            <category android:name="android.intent.category.BROWSABLE"/>
    
            <!-- 注意:path 必须有 / 前缀 -->
            <data 
                android:scheme="xiaopeng"
                android:path="goodsId" />
        </intent-filter>
    </activity>
    

    这里需要注意下几个细节:

    • android:exported: 从 Android 12 开始,所有支持隐式启动的组件必须显式设置 android:exported 属性,因此这里必须设置 android:exported="true";
    • SplashTheme 主题: Scheme 协议是支持冷启动拉起 App 的,为了保证与点击 Launcher 冷启动的体验相同(如 windowBackground 占位图),需要用到 SplashActivity 的主题。
  • 解析 Intent: 通过 Scheme 拉起的 Activity,在其 Intent 中会包含 Web 端传递过来的深度链接参数。参数实体是一个 URI 格式的字符串,获取到这个 URI 后,App 就可以根据自定义协议来拉起落地页。例如:

    class ProxyActivity : BaseActivity() {
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
    
            if (null == savedInstanceState) {
                dispatchIntent(intent)
            }
            finish()
        }
    
        override fun onNewIntent(intent: Intent?) {
            super.onNewIntent(intent)
    
            dispatchIntent(intent)
            finish()
        }
    
        private fun dispatchIntent(intent: Intent?) {
            if (null == intent) {
                return
            }
            val uri : Uri = intent.data ?: return
            // 根据自定义协议解析 Uri
        }
    }
    
  • 延迟直达: 严格来说,每次启动 ProxyActivity 就立刻拉起落地页并不是一个可靠的方式,因为有时候在拉起落地页前有一些无法跳过的初始化页面。比如用户之前清除过 App 数据,或者 App 隐私政策更新,这个时候一定需要用户先同意隐私政策,再拉起落地页。又比如需要用户先进入启动广告,或进行一些必要的设置页(选择个性标签、选择城市等)才允许进入落地页。经过分析,可以归纳出这些场景都是在 App 冷启动的时候发生的,所以我们只要区分下冷启动和热启动进入 ProxyActivity 的情况即可。伪代码示例:

    class ProxyActivity : BaseActivity() {
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
    
            if (null == savedInstanceState) {
                dispatchIntent(intent)
            }
            finish()
        }
    
        override fun onNewIntent(intent: Intent?) {
            super.onNewIntent(intent)
    
            dispatchIntent(intent)
            finish()
        }
    
        private fun dispatchIntent(intent: Intent?) {
            if (null == intent) {
                return
            }
            val uri : Uri = intent.data ?: return
            if (SchemeHelper.getRunningActivityCount() == 1) {
                // 1. 冷启动
                // 1.1 将 Uri 临时存储到全局静态域
                SchemeHelper.setPendingSchemeUri(intent.data)
                // 1.2 转而启动 SplashActivity,走正常点 Launcher 的启动流程
                startActivity(Intent(this, SplashActivity::class.java))
            } else {
                // 2. 热启动,直接打开落地页
                SchemeHelper.handleDeepLink(this, uri)
            }
        }
    }
    
    /**
     * 主页面
     */
    class MainActivity : BaseActivity() {
        // 启动初始化逻辑走完后调用:
        SchemeHelper.handleDeepLink(this)
    }
    
    - 1、SchemeHelper.getRunningActivityCount():registerActivityLifecycleCallbacks 回调,在 Activity onCreate 和 onDestroy 时维护 runningActivityCount 
    - 2、SchemeHelper.setPendingSchemeUri():    将 Uri 临时存储到全局静态域
    - 3、SchemeHelper.handleDeepLink():         根据自定义协议解析 Uri
    
  • 网页容器重定向: App 的网络容器也有跳转 App 原生页的需求,有两种实现方式:

    • H5 通过 @JavascriptInterface 桥方法传递深度链接,App 层从桥方法参数中获取链接,再进行跳转;
    • H5 通过 window.location.href 重定向,App 层需要重写 WebViewClient#shouldOverrideUrlLoading 拦截,再进行跳转。这里需要区分是跳转当前 App 还是其他 App 两种情况:
    @Override
    public boolean shouldOverrideUrlLoading(boolean lastResult, WebView paramWebView, String newurl) {
        if(newurl.startsWith("xiaopeng://")) {
            // 跳转当前 App
            return true;
        } else if (newurl.startsWith("tel://")) {
            // 拨打电话
            return true;
        } else if (newurl.startsWith("weixin://")) {
            // 打开微信
            return true;
        } else if(newurl.startsWith("otherApp://")) {
            // 跳转其他 App
            try {
                final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(newurl));
                intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
                startActivity(intent);
            } catch (Exception e) {
                // 没有安装的情况
                e.printStackTrace();
            }
            return true;
        }
        retuen super.shouldOverrideUrlLoading(lastResult, paramWebView, newurl);
    }
    
  • 统一路由入口: 除了刚才提到了冷启动和热启动的两个场景,实践中的 App 跳转或路由行为可远不止这两种,例如:

    • App 原生跳转
    • App 网页容器跳转 App 原生页(跳转当前 App / 跳转其他 App)
    • Scheme 协议唤醒 App 的跳转(如前所述,分为冷启动和热启动)
    • Push 消息唤醒 App 跳转(分为端外推送和端内推送)

    那么,这么多入口的路由行为如果不统一起来,对于后续的维护工作会劣化。所以这块需要把 SchemeHelper 中的协议解析部分统一封装为全局可用的路由分发器 RootUrlDispatcher ,实现收口。

  • adb 测试: 如果开发阶段自测时需要依赖 Web 端给我们提供一个网页来拉起 App,测试效率就太低了。我们可以使用 adb 命令来自测:

    命令模板:
    adb shell am start
        -W -a android.intent.action.VIEW
        -d <URI-定义的URI> <PACKAGE-需要测试的应用包名>
    示例:
    adb shell am start
        -W -a android.intent.action.VIEW
        -d "xiaopeng://www.myapp.com/goods/?goodsId=123456" com.xiaopeng.app
    

5. 自定义 Scheme 协议设计

自定义 Scheme 协议本质上就是定义一套标识 App 行为的规则,实践中采用的 URI(Uniform Resource Identifier,统一资源标识符) 方案,下图是 URI 的通用格式:

实践中的设计过程多少会带点 Restful API 的风格。Restful 本身是接口命名的一种规范,用 URI 标识一种资源,再用 HTTP 方法来定义对资源的操作。比如定义 /goods/{goodsId} 是商品的路径,那么对于商品这个资源的操作可以分为以下几种:

  • 获取商品信息: GET /goods/123456
  • 修改商品信息: POST/PATCH /goods/123456
  • 删除商品: DELETE /goods/123456

把 Restful API 这套理论带到 App 这边,是不是也适用呢?比如以下行为是不是也可以用 Restful API 的风格表示:

  • 打开 App 商品详情页: GET GoodsDetailActivity/123456
  • 修改 / 删除商品详情: 经过分析,这个行为在 Scheme 的场景不成立;
  • 打开 App 商品推荐列表: GET GoodsListActivity
  • 打开 App 商品评价页: GET GoodsCommentDetailActivity/123456
  • 打开 App 商品评价修改页: GET GoodsToCommentActivity/123456(是的,即使是修改的行为也用 GET,这就是 App 相对于 API 的差异性,因为 GoodsToCommentActivity 本身就带修改的动作)

既然在 App 端对资源的访问行为只有 GET,那么就可以省略掉 GET 这个元素。再考虑到链接需要跨平台,还有多参数等因素,链接模板需要再进一步改进。一般推荐采用这种格式的 URI:scheme://host/path?query。 例如,链接 xiaopeng://www.myapp.com/goods?goodsId=123456&size=1 打开商品详情页,并且选择 size=1 的规格。

部分参数描述
schemexiaopeng://业务独有的领域,一个 App 可以支持多个 Scheme
hostwww.myapp.com某一个子域名
path/goods页面路径,可以多级别
query?goodsId=123456&size=1页面参数,可以多参数

这里需要注意下几个细节:

  • 登录引导: 我们定义了 needLogin 这个参数呢,因为实践中发现用户的账单详情页这一类落地页是一定要求用户登录的。所以我们在拉起落地页之前增加了一个登录引导,在登录成功后再进入落地页。例如,链接 xiaopeng://www.myapp.com/goods?goodsId=123456&size=1?needLogin=1 表示打开 App,先要求用户登录后再打开商品详情页,并且选择 size=1 的规格;
  • H5 跳转: 有一些活动页是需要通过网页容器来承载的,因此我们希望打开 App 后唤起 MyWebViewActivity 网页容器来显示。对于这样的场景我们可以直接使用 http 或 https 作为 Scheme,App 将这类链接直接转交给 MyWebViewActivity 去呈现;
  • 数据加密: 为了提高安全性,URI 中的 path?query 的部分可以使用加密算法,scheme://host 的部分需要用于匹配,并且不带有风险数据,可以不加密。

6. 总结

在 PC 端,浏览器是用户流量的主要入口,但在移动端,用户的流量(使用时间)被分散到大大小小的 APP 上,而不再是浏览器。用户感兴趣的内容分散在各个 APP 里,当用户想在 APP 上找到某个感兴趣的页面时,深度链接(Deeplink)是一个可以从任何地方将用户带到应用内容页的简单方式。你用起来了吗?

参考资料

你的点赞对我意义重大!微信搜索公众号 [彭旭锐],希望大家可以一起讨论技术,找到志同道合的朋友,我们下次见!