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

Michael周發表於2018-06-13

轉載請聯絡: 微訊號: 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.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周 微信二維碼

相關文章