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

轉載請聯繫: 微信號: michaelzhoujayphp

原文請訪問個人博客html


若是你的產品向用戶提供網頁服務,像 Web 頁面或者爲移動設備設計的 Html5 頁面,那麼我猜你 必定會鼓勵用戶將這些內容分享到其餘平臺,或者經過信息郵件分享。java

通常來講產品經理會用各類機制來鼓勵用戶主動完成分享,有的產品會對完成分享的用戶獎勵, 好比積分、優惠券等。分享 的實質是基於用戶關係的傳播,讓更多人接觸到你的產品。這些看到 分享連接或者頁面的人,若是產生一次點擊,你須要盡一切可能把他轉化成你的用戶。提升點擊連接 的效果,也就提升了產品的 分享轉化率android

因此本文主要解決的問題實際上是如何在 Android 上儘量提升分享轉化率git

基礎設施: URL 路由

這是後續步驟的基礎,沒有這個基礎,後面說道的不少事情沒有辦法完成。 URL路由指的是你的 App 裏的產品頁面都須要能用戶 URL 跳轉。Github 上有很是多很是優秀的 URL 路由,像阿里巴巴技術團隊的ARouter。 你只須要簡單配置,加上註解,就能夠很快的搭建本身的 URL 路由框架。github

下面咱們簡單介紹一下基本原理。chrome

舉個例子,一個新聞 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.NoTitleBar">
    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <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.NTitleBar">
    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:host="news.zhoulujue.com" android:pathPattern="/.*" android:scheme="http" />
        <data android:host="news.zhoulujue.com" android:pathPattern="/.*" android:scheme="https" />
    </intent-filter>
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <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 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 裏面利用連接跳來跳去的事情基本上就講完了,但願對你有幫助。若是你還有什麼建議,能夠經過掃描下面的二維碼聯繫我,或者在下面留言哦~

Michael周 微信二維碼
相關文章
相關標籤/搜索