第2章
$AppViewScreen全埋點方案
$AppViewScreen事件,即頁面浏覽事件。在Android系統中,頁面浏覽其實就是指切換不同的Activity或Fragment(本書暫時隻讨論切換Activity的情況)。對于一個 Activity,它的哪個生命周期執行了,代表該頁面顯示出來了呢?通過對 Activity生命周期的了解可知,其實就是onResume(Activity activity)的回調方法。是以,當一個Activity 執行到onResume(Activity activity)生命周期時,也就代表該頁面已經顯示出來了,即該頁面被浏覽了。我們隻要自動地在onResume裡觸發$AppViewScreen事件,即可解決$AppViewScreen事件的全埋點。
2.1 關鍵技術Application.ActivityLifecycleCallbacks
ActivityLifecycleCallbacks是Application 的一個内部接口,是從 API 14(即Android 4.0)開始提供的。Application 類通過此接口提供了一系列的回調方法,用于讓開發者可以對 Activity 的所有生命周期事件進行集中處理(或稱監控)。我們可以通過Application類提供的registerActivityLifecycleCallback(ActivityLifecycleCallbacks callback)方法來注冊 ActivityLifecycleCallbacks回調。
我們下面先看看Application.ActivityLifecycleCallbacks都提供了哪些回調方法。Application.ActivityLifecycleCallbacks接口定義如下:
public interface ActivityLifecycleCallbacks {
void onActivityCreated(Activity activity, Bundle savedInstanceState);
void onActivityStarted(Activity activity);
void onActivityResumed(Activity activity);
void onActivityPaused(Activity activity);
void onActivityStopped(Activity activity);
void onActivitySaveInstanceState(Activity activity, Bundle outState);
void onActivityDestroyed(Activity activity);
}
以 Activity的onResume(Activity activity)生命周期為例,如果我們注冊了 Activity-LifecycleCallbacks回調,Android 系統會先回調 ActivityLifecycleCallbacks 的 onActivity-Resumed(Activity activity)方法,然後再執行Activity本身的onResume函數(請注意這個調用順序,因為不同的生命周期的執行順序略有差異)。通過registerActivityLifecycleCallback 方法名中的“register”字樣可以知道,一個 Application 是可以注冊多個 ActivityLifecycleCallbacks回調的,我們通過registerActivityLifecycleCallback方法的内部實作也可以證明這一點。
public void registerActivityLifecycleCallbacks(ActivityLifecycleCallbacks callback) {
synchronized (mActivityLifecycleCallbacks) {
mActivityLifecycleCallbacks.add(callback);
}
内部定義了一個list用來儲存所有已注冊的ActivityLifecycleCallbacks。
2.2原理概述
實作Activity的頁面浏覽事件,大家首先想到的是定義一個BaseActivity,然後讓其他Activity繼承這個 BaseActivity。這種方法理論上是可行的,但不是最優選擇,有些特殊的場景是無法适應的。比如,你在應用程式裡內建了一個第三方的庫(比如 IM 相關的),而這個庫裡恰巧也包含 Activity,此時你是無法讓這個第三方的庫也去繼承你的 BaseActivity(最起碼驅使第三方服務商去做這件事的難度比較大)。是以,為了實作全埋點中的頁面浏覽事件,最優的方案還是基于我們上面講的 Application.ActivityLifecycleCallbacks。
不過,使用Application.ActivityLifecycleCallbacks機制實作全埋點的頁面浏覽事件,也有一個明顯的缺點,就是注冊Application.ActivityLifecycleCallbacks 回調要求 API 14+。
在應用程式自定義的 Application類的 onCreate()方法中初始化埋點 SDK,并傳入目前的Application 對象。埋點SDK 拿到 Application 對象之後,通過調用 Application的registerActivityLifecycleCallback(ActivityLifecycleCallbacks callback)方法注冊Application.ActivityLifecycleCallbacks回調。這樣埋點 SDK 就能對目前應用程式中所有的 Activity 的生命周期事件進行集中處理(監控)了。在注冊的 Application.ActivityLifecycleCallbacks 的onActivityResumed(Activity activity)回調方法中,我們可以拿到目前正在顯示的 Activity對象,然後調用 SDK 的相關接口觸發頁面浏覽事件($AppViewScreen)即可。
2.3 案例
下面我們會詳細介紹$AppViewScreen事件全埋點方案的實作步驟。
完整的項目源碼可以參考以下網址:
https://github.com/wangzhzh/AutoTrackAppViewScreen。
第1步:建立一個項目(Project)
在建立的項目中,會自動包含一個主 module,即:app。
第2步:建立 sdk module
建立一個 Android Library module,名稱叫 sdk,這個子產品就是我們的埋點 SDK子產品。
第3步:添加依賴關系
app module需要依賴sdk module。可以通過修改app/build.gradle 檔案,在其 dependencies節點中添加依賴關系:
apply plugin: 'com.android.application'
android {
compileSdkVersion 28
defaultConfig {
applicationId "com.sensorsdata.analytics.android.app.appviewscreen"
minSdkVersion 15
targetSdkVersion 28
versionCode 1
versionName "1.0"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'com.android.support:appcompat-v7:28.0.0-rc02'
implementation 'com.android.support.constraint:constraint-layout:1.1.3'
implementation project(':sdk')
}
也可以通過 Project Structure 給子產品添加依賴關系,在此不再較長的描述。
第4步:編寫埋點 SDK
在sdk module 中我們建立一個埋點 SDK 的主類,即SensorsDataAPI.java,完整的源碼參考如下:
package com.sensorsdata.analytics.android.sdk;
import android.app.Application;
import android.support.annotation.Keep;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.util.Log;
import org.json.JSONObject;
import java.util.Map;

@Keep
public class SensorsDataAPI {
private final String TAG = this.getClass().getSimpleName();
public static final String SDK_VERSION = "1.0.0";
private static SensorsDataAPI INSTANCE;
private static final Object mLock = new Object();
private static Map<String, Object> mDeviceInfo;
private String mDeviceId;
@Keep
@SuppressWarnings("UnusedReturnValue")
public static SensorsDataAPI init(Application application) {
synchronized (mLock) {
if (null == INSTANCE) {
INSTANCE = new SensorsDataAPI(application);
}
return INSTANCE;
}
}
@Keep
public static SensorsDataAPI getInstance() {
return INSTANCE;
}
private SensorsDataAPI(Application application) {
mDeviceId = SensorsDataPrivate.getAndroidID(application.getApplicationContext());
mDeviceInfo = SensorsDataPrivate.getDeviceInfo(application.getApplicationContext());
SensorsDataPrivate.registerActivityLifecycleCallbacks(application);
}

public void track(@NonNull String eventName, @Nullable JSONObject properties) {
try {
JSONObject jsonObject = new JSONObject();
jsonObject.put("event", eventName);
jsonObject.put("device_id", mDeviceId);
JSONObject sendProperties = new JSONObject(mDeviceInfo);
if (properties != null) {
SensorsDataPrivate.mergeJSONObject(properties, sendProperties);
}
jsonObject.put("properties", sendProperties);
jsonObject.put("time", System.currentTimeMillis());
Log.i(TAG, SensorsDataPrivate.formatJson(jsonObject.toString()));
} catch (Exception e) {
e.printStackTrace();
}
}
目前這個主類比較簡單,主要包含如下幾個方法。
□init(Application application)
這是一個靜态方法,是埋點SDK的初始化函數,有一個Application類型的參數。内部實作使用到了單例設計模式,然後調用私有構造函數初始化埋點 SDK。app module 就是調用這個方法來初始化我們的埋點SDK。
□getInstance()
它也是一個靜态方法,app 通過該方法可以擷取埋點 SDK 的執行個體對象。
□SensorsDataAPI(Application application)
私有的構造函數,也是埋點 SDK 真正的初始化邏輯。在其方法内部通過調用 SDK 的内部私有類SensorsDataPrivate中的方法來注冊ActivityLifecycleCallbacks。
□track(@NonNull final String eventName, @Nullable JSONObject properties)
對外公開的 track 事件接口。通過調用該方法可以觸發事件,第一個參數 eventName 代表事件名稱,第二個參數properties代表事件屬性。本書為了簡化,觸發事件僅僅通過Log.i列印了事件的JSON資訊。
關于SensorsDataPrivate類中的getAndroidID(Context context)、getDeviceInfo(Context context)、mergeJSONObject(final JSONObject source, JSONObject dest)、formatJson(String jsonStr)方法實作可以參考如下源碼:
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.app.ActionBar;
import android.app.Activity;
import android.content.Context;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.provider.Settings;
import android.support.v7.app.AppCompatActivity;
import android.text.TextUtils;
import android.util.DisplayMetrics;
import org.json.JSONException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
第5步:注冊 ActivityLifecycleCallbacks回調
我們是通過調用 SDK 的内部私有類SensorsDataPrivate的registerActivityLifecycleCallbacks(Application application)方法來注冊ActivityLifecycleCallbacks的。
@TargetApi(14)
public static void registerActivityLifecycleCallbacks(Application application) {
application.registerActivityLifecycleCallbacks(new Application.Activity-LifecycleCallbacks() {
@Override
public void onActivityCreated(final Activity activity, Bundle bundle) {
}
@Override
public void onActivityStarted(Activity activity) {
}
@Override
public void onActivityResumed(final Activity activity) {
trackAppViewScreen(activity);
}
@Override
public void onActivityPaused(Activity activity) {
}
@Override
public void onActivityStopped(Activity activity) {
}
@Override
public void onActivitySaveInstanceState(Activity activity, Bundle bundle) {
}
@Override
public void onActivityDestroyed(Activity activity) {
}
});
需要我們注意的是,隻有 API 14+ 才能注冊ActivityLifecycleCallbacks回調。
在ActivityLifecycleCallbacks的onActivityResumed(final Activity activity)回調方法中,我們通過調用SensorsDataPrivate的trackAppViewScreen(Activity activity)方法來觸發頁面浏覽事件($AppViewScreen)。
trackAppViewScreen(Activity activity)方法的内部實作邏輯比較簡單,可以參考如下:
private static void trackAppViewScreen(Activity activity) {
try {
JSONObject properties = new JSONObject();
properties.put("$activity", activity.getClass().getCanonicalName());
SensorsDataAPI.getInstance().track("$AppViewScreen", properties);
} catch (Exception e) {
e.printStackTrace();
}
在此示例中,我們添加了一個$activity 屬性,代表目前 Activity 的名稱,我們使用包名+類名的形式表示。然後又定義了事件名稱為“$AppViewScreen”,最後調用Sensors-DataAPI的 track 方法來觸發頁面浏覽事件。
第6步:初始化埋點 SDK
需要在應用程式自定義的 Application類中初始化埋點 SDK,一般是建議在 onCreate()方法中初始化。
package com.sensorsdata.analytics.android.app;
import com.sensorsdata.analytics.android.sdk.SensorsDataAPI;
public class MyApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
initSensorsDataAPI(this);
}
private void initSensorsDataAPI(Application application) {
SensorsDataAPI.init(application);
}
第7步:聲明自定義的 Application
以上面定義的MyApplication為例,需要在AndroidManifest.xml檔案的application節點中聲明MyApplication。
<?xml version="1.0" encoding="utf-8"?>
運作 demo并啟動一個 Activity,可以看到如下列印的事件資訊,參考圖2-1。
上面的事件名稱叫“$AppViewScreen”,代表的是頁面浏覽事件,它有一個自定義屬性,叫“$activity”,代表目前正在顯示的 Activity 名稱(包名+類名)。
至此,頁面浏覽事件($AppViewScreen)的全埋點方案就算完成了。
2.4 完善方案
在Android 6.0(API 23)釋出的同時又引入了一種新的權限機制,即Runtime Permissions,又稱運作時權限。
在一般情況下,我們如果要使用 Runtime Permissions主要分為四個步驟,下面我們以使用(申請)“android.permission.READ_CONTACTS”權限為例來介紹。
第1步:聲明權限
需要在AndroidManifest.xml檔案中使用uses-permission聲明應用程式要使用的權限清單。
package="com.sensorsdata.analytics.android.app">
<uses-permission android:name="android.permission.READ_CONTACTS" />
<application
android:name=".MyApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
第2步:檢查權限
如果應用程式需要使用 READ_CONTACTS 權限,則要在每次真正使用 READ_CONTACTS 權限之前,檢測目前應用程式是否已經擁有該權限,這是因為使用者可能随時會在Android 系統的設定中關掉授予目前應用程式的任何權限。檢測權限可以使用ContextCompat的checkSelfPermission方法,簡單示例如下:
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) ==
PackageManager.PERMISSION_GRANTED) {
//擁有權限
} else {
//沒有權限,需要申請權限
其中,PackageManager.PERMISSION_GRANTED代表目前應用程式已經擁有了該權限;反之,PackageManager.PERMISSION_DENIED 代表目前應用程式沒有獲得該權限,需要再次申請。
第3步:申請權限
可以通過調用ActivityCompat的requestPermissions方法來申請一個或者一組權限,簡單示例如下:
ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_CONTACTS},
PERMISSIONS_REQUEST_READ_CONTACTS);
調用ActivityCompat.requestPermissions方法之後,系統會彈出如圖2-2的請求權限對話框(該對話框可能會随着 ROM的不同而略有差異):
第4步:處理權限請求結果
使用者選擇之後的結果會回調目前 Activity的onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults)方法,我們可以根據 requestCode和grantResults參數來判斷使用者選擇了“允許”還是“禁止”按鈕。
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
switch (requestCode) {
case PERMISSIONS_REQUEST_READ_CONTACTS:
if (grantResults.length > 0 &&
grantResults[0] == PackageManager.PERMISSION_GRANTED) {
//使用者點選允許
} else {
//使用者點選禁止
}
break;
}
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
講到這裡,你肯定開始疑惑了,這跟采集頁面浏覽事件有什麼關系呢?
其實是有關系的!我們繼續往下看。
通過測試可以發現,我們調用ActivityCompat.requestPermissions方法申請權限之後,不管使用者選擇了“允許”還是“禁止”按鈕,系統都會先調用onRequestPermissionsResult回調方法,然後再調用目前 Activity 的 onResume 生命周期函數。而我們上面介紹的,就是通過 onResume生命周期函數來采集頁面浏覽事件的,這個現象會直接導緻我們的埋點 SDK 再一次觸發頁面浏覽事件。
對于這個問題,我們該如何解決呢?事實上,雖然目前也沒有非常完美的解決方案,但是我們還是可以借助其他方法來嘗試解決。畢竟,在一個完整的應用程式中,真正需要申請權限的頁面并不是很多。是以,我們可以在這些申請權限的頁面裡進行一些特殊的“操作”來規避上面的問題。
我們可以考慮給埋點 SDK 新增一個功能,即使用者可以設定想要過濾哪些 Activity 的頁面浏覽事件(即指定不采集哪些 Activity 的頁面浏覽事件),然後通過靈活使用這個接口,解決上面的問題。
下面我們詳細地介紹一下具體的實作步驟。
第1步:在SensorsDataAPI中新增兩個接口
□ignoreAutoTrackActivity(Class<?> activity)
指定忽略采集哪個 Activity 的頁面浏覽事件。
□removeIgnoredActivity(Class<?> activity)
指定恢複采集哪個 Activity 的頁面浏覽事件。
以上兩個接口,都是調用私有類SensorsDataPrivate中相對應的方法。
......
static {
mIgnoredActivities = new ArrayList<>();
}
public static void ignoreAutoTrackActivity(Class<?> activity) {
if (activity == null) {
return;
}
mIgnoredActivities.add(activity.getClass().getCanonicalName());
}
public static void removeIgnoredActivity(Class<?> activity) {
if (activity == null) {
return;
}
if (mIgnoredActivities.contains(activity.getClass().getCanonicalName())) {
mIgnoredActivities.remove(activity.getClass().getCanonicalName());
}
}
内部實作機制比較簡單,僅僅通過定義一個List來儲存忽略采集頁面浏覽事件的 Activity 的名稱(包名+類名)。
第2步:修改trackAppViewScreen(Activity activity)方法添加相應的判斷邏輯
try {
if (activity == null) {
return;
}
if (mIgnoredActivities.contains(activity.getClass().getCanonicalName())) {
return;
}
JSONObject properties = new JSONObject();
properties.put("$activity", activity.getClass().getCanonicalName());
SensorsDataAPI.getInstance().track("$AppViewScreen", properties);
} catch (Exception e) {
e.printStackTrace();
}
首先判斷目前Activity是否已經被忽略,如果被忽略,則不觸發頁面浏覽事件,否則将觸發頁面浏覽事件。
第3步:修改申請權限的 Activity
在申請權限的 Activity中,在它的onRequestPermissionsResult回調中首先調用ignoreAutoTrackActivity方法來忽略目前 Activity 的頁面浏覽事件,然後在 onStop 生命周期函數中恢複采集目前 Activity 的頁面浏覽事件。
import android.Manifest;
import android.support.v4.app.ActivityCompat;
import android.support.v4.content.ContextCompat;
public class MainActivity extends AppCompatActivity {
private final static int PERMISSIONS_REQUEST_READ_CONTACTS = 100;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
setTitle("Home");
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) ==
PackageManager.PERMISSION_GRANTED) {
//擁有權限
} else {
//沒有權限,需要申請全新啊
ActivityCompat.requestPermissions(this, new String[]{Manifest.permission. READ_CONTACTS},
PERMISSIONS_REQUEST_READ_CONTACTS);
}
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
SensorsDataAPI.getInstance().ignoreAutoTrackActivity(MainActivity.class);
switch (requestCode) {
case PERMISSIONS_REQUEST_READ_CONTACTS:
if (grantResults.length > 0 &&
grantResults[0] == PackageManager.PERMISSION_GRANTED) {
// 使用者點選允許
} else {
// 使用者點選禁止
}
break;
}
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
}
@Override
protected void onStop() {
super.onStop();
SensorsDataAPI.getInstance().removeIgnoredActivity(MainActivity.class);
}
這樣處理之後,就可以解決申請權限再次觸發頁面浏覽事件的問題了。
2.5 擴充采集能力
對于Activity的頁面浏覽事件,僅僅采集目前 Activity 的名稱(包名 + 類名)是遠遠不夠的,還需要采集目前 Activity 的 title(标題)才能滿足實際的分析需求。
但是一個 Activity 的 title 的來源是非常複雜的,因為可以通過不同的方式來設定一個 Activity 的 title,甚至可以使用自定義的 View 來設定 title。比如說,可以在Android-Manifest.xml檔案中聲明 activity 時通過 android:label屬性來設定,還可以通過 activity.setTitle()來設定,也可以通過 ActionBar、ToolBar 來設定。是以,在擷取Activity 的 title 時,需要相容不同的設定title的方式,同時更需要考慮其優先級順序。
我們目前寫了一個比較簡單的方法來擷取一個 Activity 的 title,内容參考如下:
public static String getActivityTitle(Activity activity) {
String activityTitle = null;
if (activity == null) {
return null;
}
try {
activityTitle = activity.getTitle().toString();
if (Build.VERSION.SDK_INT >= 11) {
String toolbarTitle = getToolbarTitle(activity);
if (!TextUtils.isEmpty(toolbarTitle)) {
activityTitle = toolbarTitle;
}
}
if (TextUtils.isEmpty(activityTitle)) {
PackageManager packageManager = activity.getPackageManager();
if (packageManager != null) {
ActivityInfo activityInfo = packageManager.getActivityInfo(activity.getComponentName(), 0);
if (activityInfo != null) {
activityTitle = activityInfo.loadLabel(packageManager).toString();
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
return activityTitle;
我們首先通過activity.getTitle() 擷取目前 Activity 的 title,因為使用者有可能會使用 ActionBar 或 ToolBar,是以我們還需要擷取 ActionBar 或 ToolBar 設定的 title,如果能擷取到,就以這個為準(即覆寫通過activity.getTitle()擷取的 title)。如果以上兩個步驟都沒有擷取到 title,那我們就要嘗試擷取 android:label 屬性的值。
擷取ActionBar或ToolBar的title邏輯如下:
@TargetApi(11)
private static String getToolbarTitle(Activity activity) {
try {
ActionBar actionBar = activity.getActionBar();
if (actionBar != null) {
if (!TextUtils.isEmpty(actionBar.getTitle())) {
return actionBar.getTitle().toString();
}
} else {
if (activity instanceof AppCompatActivity) {
AppCompatActivity appCompatActivity = (AppCompatActivity) activity;
android.support.v7.app.ActionBar supportActionBar = appCompat-Activity.getSupportActionBar();
if (supportActionBar != null) {
if (!TextUtils.isEmpty(supportActionBar.getTitle())) {
return supportActionBar.getTitle().toString();
}
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
return null;
修改trackAppViewScreen(Activity activity)方法,添加設定$title 屬性的邏輯:
try {
if (activity == null) {
return;
}
if (mIgnoredActivities.contains(activity.getClass().hashCode())) {
return;
}
JSONObject properties = new JSONObject();
properties.put("$activity", activity.getClass().getCanonicalName());
properties.put("$title", getActivityTitle(activity));
SensorsDataAPI.getInstance().track("$AppViewScreen", properties);
} catch (Exception e) {
e.printStackTrace();
}
運作 demo,可以看到列印的如下事件資訊,參考圖2-3。
至此,一個相對完善的用來采集頁面浏覽事件的全埋點方案就算完成了。