Android 上玩转 DeepLink:如何最大程度的向 App 引流

转载请联系: 微信号: michaelzhoujay
原文请访问我的博客


如果你的产品向用户提供网页服务,像 Web 页面或者为移动设备设计的 Html5 页面,那么我猜你一定会鼓励用户将这些内容分享到其他平台,或者通过信息邮件分享。

一般来说产品经理会用各种机制来鼓励用户主动完成分享,有的产品会对完成分享的用户奖励,比如积分、优惠券等。
分享 的实质是基于用户关系的传播,让更多人接触到你的产品。
这些看到分享链接或者页面的人,如果产生一次点击,你需要尽一切可能把他转化成你的用户。提高点击链接的效果,也就提高了产品的 分享转化率

所以本文主要解决的问题其实是如何在 Android 上尽可能提高分享转化率

基础设施: URL 路由

这是后续步骤的基础,没有这个基础,后面说道的很多事情没有办法完成。
URL路由指的是你的 App 里的产品页面都需要能用户 URL 跳转。Github 上有非常多非常优秀的URL 路由,像阿里巴巴技术团队的ARouter
你只需要简单配置,加上注解,就可以很快的搭建自己的 URL 路由框架。

下面我们简单介绍一下基本原理。

举个例子,一个新闻 App 提供"新闻详情页"、"新闻专题页"、"新闻讨论页" 这个3个功能模块。
我们先假设我们要处理的 App 的包名为 com.zhoulujue.news, 所以这些功能模块的连接
看起来应该是这样:

指向id=123456的新闻详情页:http://news.zhoulujue.com/article/123456/
指向id=123457的新闻专题页:http://news.zhoulujue.com/story/123457/
指向id=123456的新闻讨论页:http://news.zhoulujue.com/article/123456/comments/

再假设这些页面的类名分别为:

新闻详情页:ArticleActivity
新闻专题页:StoryActivity
新闻讨论页:CommentsActivity

所以我们需要一个管理中心,完成两件事情:

  1. 将外界传递进来的 URL,分发给各个 Activity 来处理;
  2. 管理 URL 路径和 Activity 的对应关系。

为了统一入口,我们创建一个入口 Activity: RouterActivty,它用来向系统声明 App 能打开哪些链接,同时接受外界传递过来的 URL。首先我们在 Manifest 里声明它:

<activity
    android:name=".RouterActivty"
    android:theme="@android:style/Theme.Translucent.N
    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.acti
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BR
        <data
            android:host="news.zhoulujue.com"
            android:pathPattern="/.*"
            android:scheme="http" />
        <data
            android:host="news.zhoulujue.com"
            android:pathPattern="/.*"
            android:scheme="https" />
    </intent-filter>
</activity>

上面的声明表示,RouterActivty 可以打开所有域名为news.zhoulujue.com 的 https/http链接。

这个 RouterActivty 在收到 http://news.zhoulujue.com/article/123456/ 后,需要负责将 /article/123456/ 解析出来,根据 对应关系 找到ArticleActivity,唤起它并且把123456这个 id 作为参数传递给ArticleActivity

常见的 Router 框架通过在 Activity 的类名上添加注解来管理对应关系:

@Route(path = "/acticel/")
public class ArticleActivity extend Activity {
    ...
}

实际上它在处理这个注解的时候生成了一个建造者模式里的 builder,然后向 管理中心 注册,说自己(ArticleActivity)能处理/acticel/xxx的子域名。

Scheme 的选择很重要:URL Scheme 唤醒

上面简述原理的时候说道了 Manifest 的声明,我们只声明了 android:scheme="http"android:scheme="http" , 但是实际上很多 App 还会用特定 scheme 的方式来唤起App,例如在 iOS 早期没有 UniversalLink 的时候,大家这样来唤起。

像淘宝就会用 tbopen的 scheme,例如 tbopen://item.taobao.com/item.htm?id=xxxx,当你在网页点击链接以后,页面会创建一个隐藏的 iframe,用它来打开自定义 scheme 的 URL,浏览器无法响应时向系统发送一个 Action 为 android.intent.action.VIEW、Data 为 tbopen://item.taobao.com/item.htm?id=xxxx 的 Intent,如果 App 已经按照上述章节改造,那么系统将唤起 RouterActivity、并将 Intent 传递过去。
所以问题就来了:如何选取一个 URL Scheme 使得浏览器无法响应,所以你的 scheme最好满足以下两个条件:

  1. 区别于其他应用:唯一性
  2. 区别于浏览器已经能处理的 scheme:特殊性

在我们上述假设的新闻 App 里,我们可以定义 scheme 为 zljnews,那么在 URL Scheme发送的 URL 将会是这样:

指向id=123456的新闻详情页:zljnews://news.zhoulujue.com/article/123456/
指向id=123457的新闻专题页:zljnews://news.zhoulujue.com/story/123457/
指向id=123456的新闻讨论页:zljnews://news.zhoulujue.com/article/123456/comments/

为了避免某些应用会预处理 scheme 和 host,我们还需要将 URL Scheme 的 Host 也做相应
更改:

指向id=123456的新闻详情页:zljnews://zljnews/article/123456/
指向id=123457的新闻专题页:zljnews://zljnews/story/123457/
指向id=123456的新闻讨论页:zljnews://zljnews/article/123456/comments/

这样的我们的 Manifest 里 RouterActivity 的声明要改为:

<activity
    android:name=".RouterActivty"
    android:theme="@android:style/Theme.Translucent.N
    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.acti
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BR
        <data
            android:host="news.zhoulujue.com"
            android:pathPattern="/.*"
            android:scheme="http" />
        <data
            android:host="news.zhoulujue.com"
            android:pathPattern="/.*"
            android:scheme="https" />
    </int
    <intent-filter>
        <action android:name="android.intent.acti
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BR
        <data android:scheme="zljnews" />
        <data android:host="zljnews" />
        <data android:pathPattern="/.*" />
    </intent-filter>
</activity>

App Links 与 Universal Links,来自官方的方式

我们假设一个用例:用户在印象笔记里写了一篇笔记,笔记里有一个链接:
http://news.zhoulujue.com/article/123456/
那么问题来了:用户点击以后,将会发生什么?

答案是:很大的可能是系统弹出一个对话框,列出若干个 App,问你想用哪一个打开。

选择App列表

这样体验其实不够好,因为用户路径变长了,转化率 将下降。所以我们应该尽可能去掉这个
对话框,其实上述章节说到了一个方法:将 http://news.zhoulujue.com/article/123456/
改为 zljnews://zljnews/article/123456/,原理是我们选取了看起来"唯一性"的 scheme,
但是如果用户没有安装你的 App,这个体验就相当糟糕了,用户在点击以后将没有任何反应。

此时就需要 AppLinks 和 UniversalLinks 了,一言以蔽之,就是域名持有者向系统证明自己
拥有 news.zhoulujue.com 这个域名并且 App 属于自己,这样系统就会直接将 App 唤起
并把 intent 传递给 App。

如何配置 AppLinks 就不在赘述了,参考官方的教程

App Links 实现的另一种方式

Facebook 在2014年的F8开发者大会上公布了 AppLinks 协议,在Android 的 AppLinks之前(Google I/O 15),
也是一种可行的“链接跳转 App”的方式。
这里也不在赘述细节,可以参考 Facebook 官方的介绍来实现,也特别简单。Facebook AppLinks

Facebook Bolts On Android

非自己的代码怎么办

上面说了很多在网页中唤醒 App 的方式,但是这些都是建立在我们可以改页面 JS 等代码的前提下,
如果页面由第三方提供,举个例子,由广告主提供,表现方式是广告主提供一个落地页放在你的 App 里,
推动第三方去按照你的要求去改动他们的代码,可能比较困难,但是如果只是修改一下跳转链接就可以达到
唤起 App 的效果,这样性价比就比较高了。这个时候就需要 chrome 推荐的 intent scheme 了:

<a href="intent://zljnews/recipe/100390954#Intent;scheme=zljnews;package=com.zhoulujue.news;end"> Intent scheme </a>

如代码所示,scheme填写的是我们上面假设的 scheme:zljnews,保持一致。
package 填写 App 包名:com.zhoulujue.news,参考Chrome官方 Intent 编写规范

微信里怎么办

总所周知,微信是限制唤起 App 的行为的,坊间流传着各种微信唤起的 hack,但总是不知道什么时候就被封禁了,这里介绍
微信官方的 正规 搞法:微下载链接:

微信微下载

如上图,知乎就使用了微下载来向知乎的 App 导流,这种方式 Android iOS 都是通用的,具体实现方式参考腾讯微信官方的文档

优化1:从网页到 App 的无缝体验

假设一个场景,用户访问 http://news.zhoulujue.com 阅读新闻时,被推荐下载了 App,此时安装完毕后打开 App后,最好
的体验当然是帮用户打开他没有看完新闻,直接跳转到刚刚在网页版阅读的文章。
最佳实践是:在用户点击下载时,把当前页面的 URL 写到 APK 文件的 ZIP 文件头里,待用户下载安装完毕后,启动时去读取这个
URL,然后结合上面说到过的 Router,路由到新闻详情页。下面跟我来一步一步实现吧。

在网页上下载APK时:将路径写如 APK 的 ZIP 文件头里

将下面的 Java 代码保存为 WriteAPK.java 并用 javac 编译好。

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.zip.ZipFile;

/**
 * Created by michael on 16/9/8.
 */
public class WriteApk {

    public static void main(String[] args) {
        for (int i = 0; i < args.length; i++) {
            System.out.println(args[i]);
        }
        if (args.length < 2) {
            System.out.println("Wrong parameters! Usage : WriteApk path comment\n");
        }
        String path = args[0];
        String comment = args[1];
        writeApk(new File(path), comment);
        System.out.println("Complete! File lies in " + path);
        try {
            ZipFile zipFile = new ZipFile(new File(path));
            System.out.println("Zip file comment = " + zipFile.getComment());
        } catch(IOException e) {
            e.printStackTrace();
            System.out.println("Zip file comment read failed!");
        }
    }

    public static void writeApk(File file, String comment) {
        ZipFile zipFile = null;
        ByteArrayOutputStream outputStream = null;
        RandomAccessFile accessFile = null;
        try {
            zipFile = new ZipFile(file);
            String zipComment = zipFile.getComment();
            if (zipComment != null) {
                return;
            }

            byte[] byteComment = comment.getBytes();
            outputStream = new ByteArrayOutputStream();

            outputStream.write(byteComment);
            outputStream.write(short2Stream((short) byteComment.length));

            byte[] data = outputStream.toByteArray();

            accessFile = new RandomAccessFile(file, "rw");
            accessFile.seek(file.length() - 2);
            accessFile.write(short2Stream((short) data.length));
            accessFile.write(data);
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                if (zipFile != null) {
                    zipFile.close();
                }
                if (outputStream != null) {
                    outputStream.close();
                }
                if (accessFile != null) {
                    accessFile.close();
                }
            } catch (Exception e) {

            }

        }
    }

    /**
     * 字节数组转换成short(小端序)
     */
    private static byte[] short2Stream(short data) {
        ByteBuffer buffer = ByteBuffer.allocate(2);
        buffer.order(ByteOrder.LITTLE_ENDIAN);
        buffer.putShort(data);
        buffer.flip();
        return buffer.array();
    }
}

然后使用下面的命令对 APK 写入 URL:

$java -jar WriteAPK /path/to/your/APK http://news.zhoulujue.com/article/12345/

用户首次打开时:读取 URL 并打开

在 App 首次打开的时候读取 ZIP 文件头里你写入的 URL,读取代码如下:

public static String getUnfinishedURL(Context context) {
    //获取缓存的 APK 文件
    File file = new File(context.getPackageCodePath());
    byte[] bytes;
    RandomAccessFile accessFile = null;
    // 从指定的位置找到 WriteAPK.java 写入的信息
    try {
        accessFile = new RandomAccessFile(file, "r");
        long index = accessFile.length();
        bytes = new byte[2];
        index = index - bytes.length;
        accessFile.seek(index);
        accessFile.readFully(bytes);
        int contentLength = stream2Short(bytes, 0);
        bytes = new byte[contentLength];
        index = index - bytes.length;
        accessFile.seek(index);
        accessFile.readFully(bytes);
        return new String(bytes, "utf-8");
    } catch (IOException e) {
        e.printStackTrace();
    } finally {
        if (accessFile != null) {
            try {
                accessFile.close();
            } catch (IOException ignored) {
                ignored.printStackTrace();
            }
        }
    }
    return null;    
}

接着只要将getUnfinishedURL返回值交给 Router 去处理,从而将用户导向没有阅读完毕的新闻详情页。

优化2:有控制的允许流量的导出

上面的内容都是在讲如何尽可能地把用户导进 App 里来,从另外一个角度,为了提高用户转化率我们要降低用户的跳出率,也就是说尽量避免用户从我们的 App 里被带跑了。

很多情况下,如果我们运营一个 UGC 的社区,我们无法控制用户创建内容的时候会填写哪些 URL,当然作为一个开放的平台我们肯定希望用户能够更高地利用各种工具将他们所专注的任务完成。

但是如果平台出现了一些人不受限制的发广告,或者利用你的平台运营竞争对手的产品,这种方式对成长中的产品打击有可能将是毁灭性的。

最佳实践:在服务器维护一个白名单,这个白名单中被允许的域名将被允许唤醒,否则拦截。

而这个拦截最好的方式是在WebView里,因为大多数跳转代码都在 URL 指向的落地页里。所以我们需要这样定义WebViewWebViewClient

public class ControlledWebViewClient extends WebViewClient {

    @Override
    public boolean shouldOverrideUrlLoading(WebView view, String url) {
        Context context =  view.getContext();
        try {
            String host = Uri.parse(url.getOriginalUrl()).getHost();
            if (!isHostInWhiteList(host)) {
                return false;
            }
            
            String scheme = Uri.parse(url).getScheme();
            if (!TextUtils.isEmpty(scheme) && !scheme.equals("http") && !scheme.equals("https")) {
                Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
                intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
                intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
                context.getApplicationContext().startActivity(intent);
                return true;
            }
        } catch (Throwable t) {
            t.printStackTrace();
        }

        return false;
    }

    private boolean isHostInWhiteList(String) {
        // 查询白名单,是否在白名单里
        ...
    }
}

为了尽可能获取正确的 Host,请注意在上面第7行代码里,使用的是url.getOriginalUrl()


好了,App 里面利用链接跳来跳去的事情基本上就讲完了,希望对你有帮助。如果你还有什么建议,可以通过扫描下面的二维码联系我,或者在下面留言哦~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,386评论 6 479
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,939评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,851评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,953评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,971评论 5 369
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,784评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,126评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,765评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,148评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,744评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,858评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,479评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,080评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,053评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,278评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,245评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,590评论 2 343

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,598评论 18 139
  • ¥开启¥ 【iAPP实现进入界面执行逐一显】 〖2017-08-25 15:22:14〗 《//首先开一个线程,因...
    小菜c阅读 6,357评论 0 17
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,482评论 25 707
  • 前几天跟一个程序员基友讨论怎么泡妞的问题,最终讨论得出的结论是:呵,愚蠢的人类。 我们用产品经理的思维进行了分析,...
    皇上明鉴阅读 210评论 0 0
  • 【成長的一課 - 接納自己的不完美】 你有試過不喜歡自己嗎?接納不到自己的缺點或陰暗面? 人生中最美好的遇見就是遇...
    Monginspire阅读 229评论 0 0