iOS全埋點解決方案-APP和H5打通

2022-06-21 12:00:56

前言

​ 所謂的 APP 和 H5 打通,是指 H5 整合 JavaScript 資料採集 SDK 後,H5 觸發的事件不直接同步給伺服器,而是先發給 APP 端的資料採集 SDK,經過 APP 端資料採集 SDK 二次加工處理後存入本地快取再進行同步。

一、App 與 H5 打通原因

1.1 資料丟失率

​ APP 端採集資料的丟失率一般在 1% 左右,而 H5 採集資料的丟失率一般在 5% 左右(主要是因為快取,網路或切換介面等原因)。因此,如果 APP 與 H5 打通,H5所有事件都可以先發給 APP 端資料採集 SDK,經過 APP 端二次加工處理後併入本地資料庫,在符合特定策略後進行資料同步,即可把資料丟失率由 5% 降低到 1% 左右。

1.2 資料準確性

​ 眾所周知,H5 無法直接獲取裝置的相關資訊,只能通過解析 UserAgeng 獲取有限的資訊,而解析 UserAgent 值,至少會面臨下面的問題。

(1)有些資訊通過解析 UserAgent 值根本獲取不到,比如應用程式的版本號等。

(2)有些資訊通過解析 UserAgent 值可以獲取到,但是內容可能不正確。

​ 如果 APP 和 H5 打通,由 APP 端資料採集 SDK 補充這些資訊,即可確保事件資訊的準確性和完整性。

1.3 使用者標識

​ 對於使用者在 APP 端註冊或者登入之前,我們一般都是使用使用者匿名 ID 來標識使用者。而 APP 和 H5 標識匿名使用者的規則不一樣。進而導致一個使用者出現兩個匿名 ID 的情況。如果 APP 和 H5 打通,就可以將兩個匿名 ID 做歸一化處理。

​ APP 和 H5 打通的方案有一下兩種。

  • 通過攔截 WebView 請求進行打通。
  • 通過 JavaScript 與 WebView 相互呼叫進行打通。

二、方案一:攔截請求

​ 攔截 WebView 傳送的 URL 請求,即如果是協定好的特定格式,可進行攔截並獲取事件資料。如果不是,讓請求繼續載入。此時 JavaScript SDK 就需要知道,當前 H5 是在 APP 端顯示環視在 Safari 瀏覽器顯示,只有在 APP 端顯示時,H5 觸發事件後,JavaScript SDK 才能向 APP 傳送特定的 URL 請求進行打通;如果是在 Safari 瀏覽器顯示,JavaScript SDK 也傳送請求進行打通,會導致事件丟失。對於 iOS 應用程式來說,目前常用的方案是藉助 UserAgent 來進行判斷,即當 H5 在 APP 端顯示時,我們可以通過在當前的 UserAgent 上追加一個特殊的標記,進而告知 JavaScript SDK 當前 H5 是在 APP 端顯示並需要進行打通。

2.1 修改 UserAgent

​ 我們可以通過下面的方法來修改 UserAgent

- (void)userAgent {
    // 建立一個空的 WKWebView
    self.webView = [[WKWebView alloc] initWithFrame:CGRectZero];
    // 建立一個 self 的弱參照,防止迴圈參照
    __weak typeof (self) weakSelf = self;
    // 執行 JavaScript 程式碼,獲取 WKWebView 中的 UserAgent
    [self.webView evaluateJavaScript:@"navigator.userAgent" completionHandler:^(id _Nullable result, NSError * _Nullable error) {
        // 建立強參照
        __strong typeof (weakSelf) strongSelf = weakSelf;
        // 執行結果 result 為獲取到的 UserAgent 值
        NSString *userAgent = result;
        // 給 UserAgent 追加自己需要的內容
        userAgent = [userAgent stringByAppendingString:@" /sa-sdk-ios "];
        // 將 UserAgent 字典內容註冊到 NSUserDefault 中
        [[NSUserDefaults standardUserDefaults] registerDefaults:@{@"UserAgent": userAgent}];
        // 釋放 webView
        strongSelf.webView = nil;
    }];
}

第一步:新增 SensorsAnalyticsSDK 的類別 WebView ,並新增 - addWebViewUserAgent: 方法宣告

NS_ASSUME_NONNULL_BEGIN

@interface SensorsAnalyticsSDK (WebView)


/// 在 WebView 控制元件中新增自定義的 UserAgent,用於實現打通方案
/// @param userAgent 自定義的 UserAgent
- (void)addWebViewUserAgent:(nullable NSString *)userAgent;

@end

NS_ASSUME_NONNULL_END

第二步:實現 - addWebViewUserAgent: 方法,並修改 UserAgent 值

#import "SensorsAnalyticsSDK+WebView.h"

#import <WebKit/WebKit.h>

@interface SensorsAnalyticsSDK (WebView)

@property(nonatomic, strong) WKWebView *webView;

@end

@implementation SensorsAnalyticsSDK (WebView)

- (void)loadUserAgent:(void(^) (NSString *))completion {
    // 建立一個空的 WKWebView
    self.webView = [[WKWebView alloc] initWithFrame:CGRectZero];
    // 建立一個 self 的弱參照,防止迴圈參照
    __weak typeof (self) weakSelf = self;
    // 執行 JavaScript 程式碼,獲取 WKWebView 中的 UserAgent
    [self.webView evaluateJavaScript:@"navigator.userAgent" completionHandler:^(id _Nullable result, NSError * _Nullable error) {
      // 建立強參照
      __strong typeof (weakSelf) strongSelf = weakSelf;
      // 呼叫回撥
        completion(result);
      // 釋放 webView
      strongSelf.webView = nil;
    }];
}

- (void)addWebViewUserAgent:(nullable NSString *)userAgent {
    [self loadUserAgent:^(NSString *oldUserAgent) {
        // 給 UserAgent 新增自己的內容
        NSString *newUserAgent = [oldUserAgent stringByAppendingString:userAgent ?: @" /sa-sdk-ios "];
        // 將 UserAgent 字典內容註冊到 NSUserDefault 中
        [[NSUserDefaults standardUserDefaults] registerDefaults:@{@"UserAgent": newUserAgent}];
    }];
}

@end

​ 在上面的程式碼中,我們實現了一個載入獲取 UserAgent 值得私有方法 - loadUserAgent: 方法,該方法通過回撥將 UserAgent 值返回。在 - addWebViewUserAgent: 方法中呼叫 - loadUserAgent: 方法獲取到 UserAgent 舊值,然後追加 /sa-sdk-ios 特殊符號,最後把生成的新的 UserAgent 值註冊到 NSUserDefaults 中。

2.2 是否攔截

第一步:宣告 - shouldTrackWithWebView: request: 方法。

/// 判斷是否需要攔截並處理 JavaScript SDK 傳送過來的事件資料
/// @param webView 用於介面展示的 WebView 控制元件
/// @param request 控制元件中的請求
- (BOOL)shouldTrackWithWebView:(id)webView request:(NSURLRequest *)request;

第二步:實現 - shouldTrackWithWebView: request: 方法。

- (BOOL)shouldTrackWithWebView:(id)webView request:(NSURLRequest *)request {
    // 獲取請求的完整路徑
    NSString *urlString = request.URL.absoluteURL;
    // 查詢完整路徑中是否包含 sensorsanalytics://trackEvent ,如果不包含,則是普通請求,不做處理,返回 NO
    if ([urlString rangeOfString:SensorsAnalyticsJavaScriptTrackEventScheme].location == NSNotFound) {
        return NO;
    }
    
    NSMutableDictionary *queryItems = [NSMutableDictionary dictionary];
    // 請求中的所有 Query,並解析獲取資料
    NSArray<NSString *> *allQuery = [request.URL.query componentsSeparatedByString:@"&"];
    for (NSString *query in allQuery) {
        NSArray<NSString *> *items = [query componentsSeparatedByString:@"="];
        if (items.count >= 2) {
            queryItems[items.firstObject] = [items.lastObject stringByRemovingPercentEncoding];
        }
    }
    // TODO: 採集請求中的資料
    return YES;
}

2.3 二次加工 H5 事件

第一步:新增 - trackFromH5WithEvent: 方法,用於對資料進行加工

- (void)trackFromH5WithEvent:(NSString *)jsonString {
    NSError *error = nil;
    NSData *jsonData = [jsonString dataUsingEncoding:NSUTF8StringEncoding];
    NSMutableDictionary *event = [NSJSONSerialization JSONObjectWithData:jsonData options:NSJSONReadingMutableContainers error:&error];
    if (error || !event) {
        return;
    }
    
    NSMutableDictionary *properties = [event[@"properties"] mutableCopy];
//    [properties addEntriesFromDictionary:self.automaticProperties];
    event[@"_hybrid_h5"] = @(YES);
    
//    event[@"distinct_id"] = self.loginId ?: self.anonymousId;
    
//    dispatch_async(self.serialQueue, ^{
//        // 列印
//        [self printEvent:event];
//    //    [self.fileStroe saveEvent:event];
//        [self.database insertEvent:event];
//    });
//
//    if (self.database.eventCount >= self.flushBulkSize) {
//        [self flush];
//    }
}

第二步:修改 - shouldTrackWithWebView: request: 方法,新增 - trackFromH5WithEvent: 方法呼叫

- (BOOL)shouldTrackWithWebView:(id)webView request:(NSURLRequest *)request {
    // 獲取請求的完整路徑
    NSString *urlString = request.URL.absoluteURL;
    // 查詢完整路徑中是否包含 sensorsanalytics://trackEvent ,如果不包含,則是普通請求,不做處理,返回 NO
    if ([urlString rangeOfString:SensorsAnalyticsJavaScriptTrackEventScheme].location == NSNotFound) {
        return NO;
    }
    
    NSMutableDictionary *queryItems = [NSMutableDictionary dictionary];
    // 請求中的所有 Query,並解析獲取資料
    NSArray<NSString *> *allQuery = [request.URL.query componentsSeparatedByString:@"&"];
    for (NSString *query in allQuery) {
        NSArray<NSString *> *items = [query componentsSeparatedByString:@"="];
        if (items.count >= 2) {
            queryItems[items.firstObject] = [items.lastObject stringByRemovingPercentEncoding];
        }
    }
    //
    [self trackFromH5WithEvent:queryItems[@"event"]];
    
    return YES;
}

2.4 攔截

在 - webView: decidePolicyForNavigationAction: 代理方法中進行攔截

#pragma mark - WKNavigationDelegate
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
    if ([[SensorsAnalyticsSDK sharedInstance] shouldTrackWithWebView:webView request:navigationAction.request]) {
        return decisionHandler(WKNavigationActionPolicyCancel);
    }
    decisionHandler(WKNavigationActionPolicyAllow);
}

2.5 測試驗證

三、方案二:JavaScript 與 WebView 相互呼叫

​ 實現原理:在 WKWebView 控制元件初始化之後,通過呼叫 webView.configuration.userContentController 的 - addScriptMessageHandler:name: 方法註冊回撥,然後實現 WKScriptMessageHandler 協定中的 -userContentController:didReceiveScriptMessage: 方法,JavaScript SDK 通過 window.webkit.messageHandlers..postMessage()方式觸發事件,我們就能在回撥中接受到訊息,然後從訊息中解析事件資訊,在呼叫 trackFromH5WithEvent:方法即可實現。