寫在題前:
文章為本人原創(chuàng), 如果文章轉(zhuǎn)載,必須標明作者與出處,并將原文鏈接以及github地址附在文章首行, 否則將追究其法律責任。 請尊重作者勞動成果! github地址:https://github.com/SandyLoo/iOS-AnalysisProject。 方案暫時還不完善,您的star就是我的動力!
前言
當前互聯(lián)網(wǎng)行業(yè)的競爭已經(jīng)是非常激烈了, “功能驅(qū)動”的時代已經(jīng)過去了, 現(xiàn)在更加注重軟件的細節(jié), 以及用戶的體驗問題。 說到用戶體驗,就不得不提到用戶的操作行為。 在我們的軟件中,我們會到處進行埋點, 以便提取到我們想要的數(shù)據(jù),進而分析用戶的行為習慣。 通過這些數(shù)據(jù),我們也可以更好的分析出用戶的操作趨勢,從而在用戶體驗上把我們的app做的更好。
隨著公司業(yè)務(wù)的發(fā)展,數(shù)據(jù)的重要性日益體現(xiàn)出來。 數(shù)據(jù)埋點的全面性和準確性尤為重要。 只有拿到精準并詳細的數(shù)據(jù), 后面的分析才有意義。 然后隨著業(yè)務(wù)的不斷變化, 埋點的動態(tài)性也越來越重要。為了解決這些問題, 很多公司都提出自己的解決方案, 各中解決方案中,大體分為以下三種:
代碼埋點
由開發(fā)人員在觸發(fā)事件的具體方法里,植入多行代碼把需要上傳的參數(shù)上報至服務(wù)端。可視化埋點
根據(jù)標識來識別每一個事件, 針對指定的事件進行取參埋點。而事件的標識與參數(shù)信息都寫在配置表中,通過動態(tài)下發(fā)配置表來實現(xiàn)埋點統(tǒng)計。無埋點
無埋點并不是不需要埋點,更準確的說應(yīng)該是“全埋”, 前端的任意一個事件都被綁定一個標識,所有的事件都別記錄下來。 通過定期上傳記錄文件,配合文件解析,解析出來我們想要的數(shù)據(jù), 并生成可視化報告供專業(yè)人員分析 , 因此實現(xiàn)“無埋點”統(tǒng)計。
由于考慮到“無埋點”的方案成本較高,并且后期解析也比較復(fù)雜,加上view_path的不確定性(具體可以參考: 網(wǎng)易HubbleData無埋點SDK在iOS端的設(shè)計與實現(xiàn))。所以本文重點分享一個 可視化埋點 的簡單實現(xiàn)方式。
可視化埋點
首先,可視化埋點并非完全拋棄了代碼埋點,而是在代碼埋點的上層封裝的一套邏輯來代替手工埋點,大體上架構(gòu)如下圖:
不過要實現(xiàn)可視化埋點也有很多問題需要解決,比如事件唯一標識的確定,業(yè)務(wù)參數(shù)的獲取,有邏輯判斷的埋點配置項信息等等。接下來我會重點圍繞唯一標識以及業(yè)務(wù)參數(shù)獲取這兩個問題給出自己的一個解決方案。
唯一標識問題
唯一標識的組成方式主要是又 target + action 來確定, 即任何一個事件都存在一個target與action。 在此引入AOP編程,AOP(Aspect-Oriented-Programming)即面向切面編程的思想,基于 Runtime 的 Method Swizzling能力,來 hook 相應(yīng)的方法,從而在hook方法中進行統(tǒng)一的埋點處理。例如所有的按鈕被點擊時,都會觸發(fā)UIApplication的sendAction方法,我們hook這個方法,即可攔截所有按鈕的點擊事件。
這里主要分為兩個部分 :
事件的鎖定
事件的鎖定主要是靠 “事件唯一標識符”來鎖定,而事件的唯一標識是由我們寫入配置表中的。埋點數(shù)據(jù)的上報。
埋點數(shù)據(jù)的數(shù)據(jù)又分為兩種類型: 固定數(shù)據(jù)與可變的業(yè)務(wù)數(shù)據(jù), 而固定數(shù)據(jù)我們可以直接寫到配置表中, 通過唯一標識來獲取。而對于業(yè)務(wù)數(shù)據(jù),我是這么理解的: 數(shù)據(jù)是有持有者的, 例如我們Controller的一個屬性值, 又或者數(shù)據(jù)再Model的某一個層級。 這么的話我們就可以通過KVC的的方式來遞歸獲取該屬性的值來取到業(yè)務(wù)數(shù)據(jù), 代碼后面會有介紹。
整體代碼示例
由于iOS中的事件場景是多樣的, 在此我以UIControl, UITablview(collectionView與tableView基本相同), UITapGesture, UIViewController的PV統(tǒng)計 為例,介紹一下具體思路。
- UIViewController PV統(tǒng)計
頁面的統(tǒng)計較為簡單,利用Method Swizzing hook 系統(tǒng)的viewDidLoad, 直接通過頁面名稱即可鎖定頁面的展示代碼如下:
@implementation UIViewController (Analysis)
+(void)load
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
SEL originalDidLoadSelector = @selector(viewDidLoad);
SEL swizzingDidLoadSelector = @selector(user_viewDidLoad);
[MethodSwizzingTool swizzingForClass:[self class] originalSel:originalDidLoadSelector swizzingSel:swizzingDidLoadSelector];
});
}
-(void)user_viewDidLoad
{
[self user_viewDidLoad];
//從配置表中取參數(shù)的過程 1 固定參數(shù) 2 業(yè)務(wù)參數(shù)(此處參數(shù)被target持有)
NSString * identifier = [NSString stringWithFormat:@"%@", [self class]];
NSDictionary * dic = [[[DataContainer dataInstance].data objectForKey:@"PAGEPV"] objectForKey:identifier];
if (dic) {
NSString * pageid = dic[@"userDefined"][@"pageid"];
NSString * pagename = dic[@"userDefined"][@"pagename"];
NSDictionary * pagePara = dic[@"pagePara"];
__block NSMutableDictionary * uploadDic = [NSMutableDictionary dictionaryWithCapacity:0];
[pagePara enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) {
id value = [CaptureTool captureVarforInstance:self withPara:obj];
if (value && key) {
[uploadDic setObject:value forKey:key];
}
}];
NSLog(@"\n 事件唯一標識為:%@ \n pageid === %@,\n pagename === %@,\n pagepara === %@ \n", [self class], pageid, pagename, uploadDic);
}
}
- UIControl 點擊統(tǒng)計。
主要通過hook sendAction:to:forEvent: 來實現(xiàn), 其唯一標識符我們用 targetname/selector/tag來標記,具體代碼如下:
@implementation UIControl (Analysis)
+(void)load
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
SEL originalSelector = @selector(sendAction:to:forEvent:);
SEL swizzingSelector = @selector(user_sendAction:to:forEvent:);
[MethodSwizzingTool swizzingForClass:[self class] originalSel:originalSelector swizzingSel:swizzingSelector];
});
}
-(void)user_sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event
{
[self user_sendAction:action to:target forEvent:event];
NSString * identifier = [NSString stringWithFormat:@"%@/%@/%ld", [target class], NSStringFromSelector(action),self.tag];
NSDictionary * dic = [[[DataContainer dataInstance].data objectForKey:@"ACTION"] objectForKey:identifier];
if (dic) {
NSString * eventid = dic[@"userDefined"][@"eventid"];
NSString * targetname = dic[@"userDefined"][@"target"];
NSString * pageid = dic[@"userDefined"][@"pageid"];
NSString * pagename = dic[@"userDefined"][@"pagename"];
NSDictionary * pagePara = dic[@"pagePara"];
__block NSMutableDictionary * uploadDic = [NSMutableDictionary dictionaryWithCapacity:0];
[pagePara enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) {
id value = [CaptureTool captureVarforInstance:target withPara:obj];
if (value && key) {
[uploadDic setObject:value forKey:key];
}
}];
NSLog(@" \n 唯一標識符為 : %@, \n event id === %@,\n target === %@, \n pageid === %@,\n pagename === %@,\n pagepara === %@ \n", identifier, eventid, targetname, pageid, pagename, uploadDic);
}
}
- TableView (CollectionView) 的點擊統(tǒng)計。
tablview的唯一標識, 我們使用 delegate.class/tableview.class/tableview.tag的組合來唯一鎖定。 主要是通過hook setDelegate 方法, 在設(shè)置代理的時候再去交互 didSelect 方法來實現(xiàn), 具體的原理是 具體代碼如下:
@implementation UITableView (Analysis)
+(void)load
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
SEL originalAppearSelector = @selector(setDelegate:);
SEL swizzingAppearSelector = @selector(user_setDelegate:);
[MethodSwizzingTool swizzingForClass:[self class] originalSel:originalAppearSelector swizzingSel:swizzingAppearSelector];
});
}
-(void)user_setDelegate:(id<UITableViewDelegate>)delegate
{
[self user_setDelegate:delegate];
SEL sel = @selector(tableView:didSelectRowAtIndexPath:);
SEL sel_ = NSSelectorFromString([NSString stringWithFormat:@"%@/%@/%ld", NSStringFromClass([delegate class]), NSStringFromClass([self class]),self.tag]);
//因為 tableView:didSelectRowAtIndexPath:方法是optional的,所以沒有實現(xiàn)的時候直接return
if (![self isContainSel:sel inClass:[delegate class]]) {
return;
}
BOOL addsuccess = class_addMethod([delegate class],
sel_,
method_getImplementation(class_getInstanceMethod([self class], @selector(user_tableView:didSelectRowAtIndexPath:))),
nil);
//如果添加成功了就直接交換實現(xiàn), 如果沒有添加成功,說明之前已經(jīng)添加過并交換過實現(xiàn)了
if (addsuccess) {
Method selMethod = class_getInstanceMethod([delegate class], sel);
Method sel_Method = class_getInstanceMethod([delegate class], sel_);
method_exchangeImplementations(selMethod, sel_Method);
}
}
//判斷頁面是否實現(xiàn)了某個sel
- (BOOL)isContainSel:(SEL)sel inClass:(Class)class {
unsigned int count;
Method *methodList = class_copyMethodList(class,&count);
for (int i = 0; i < count; i++) {
Method method = methodList[i];
NSString *tempMethodString = [NSString stringWithUTF8String:sel_getName(method_getName(method))];
if ([tempMethodString isEqualToString:NSStringFromSelector(sel)]) {
return YES;
}
}
return NO;
}
// 由于我們交換了方法, 所以在tableview的 didselected 被調(diào)用的時候, 實質(zhì)調(diào)用的是以下方法:
-(void)user_tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
SEL sel = NSSelectorFromString([NSString stringWithFormat:@"%@/%@/%ld", NSStringFromClass([self class]), NSStringFromClass([tableView class]), tableView.tag]);
if ([self respondsToSelector:sel]) {
IMP imp = [self methodForSelector:sel];
void (*func)(id, SEL,id,id) = (void *)imp;
func(self, sel,tableView,indexPath);
}
NSString * identifier = [NSString stringWithFormat:@"%@/%@/%ld", [self class],[tableView class], tableView.tag];
NSDictionary * dic = [[[DataContainer dataInstance].data objectForKey:@"TABLEVIEW"] objectForKey:identifier];
if (dic) {
NSString * eventid = dic[@"userDefined"][@"eventid"];
NSString * targetname = dic[@"userDefined"][@"target"];
NSString * pageid = dic[@"userDefined"][@"pageid"];
NSString * pagename = dic[@"userDefined"][@"pagename"];
NSDictionary * pagePara = dic[@"pagePara"];
UITableViewCell * cell = [tableView cellForRowAtIndexPath:indexPath];
__block NSMutableDictionary * uploadDic = [NSMutableDictionary dictionaryWithCapacity:0];
[pagePara enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) {
NSInteger containIn = [obj[@"containIn"] integerValue];
id instance = containIn == 0 ? self : cell;
id value = [CaptureTool captureVarforInstance:instance withPara:obj];
if (value && key) {
[uploadDic setObject:value forKey:key];
}
}];
NSLog(@"\n event id === %@,\n target === %@, \n pageid === %@,\n pagename === %@,\n pagepara === %@ \n", eventid, targetname, pageid, pagename, uploadDic);
}
}
@end
- gesture方式添加的的點擊統(tǒng)計。
gesture的事件,是通過 hook initWithTarget:action:方法來實現(xiàn)的, 事件的唯一標識依然是target.class/actionname來鎖定的, 代碼如下:
@implementation UIGestureRecognizer (Analysis)
+ (void)load
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
[MethodSwizzingTool swizzingForClass:[self class] originalSel:@selector(initWithTarget:action:) swizzingSel:@selector(vi_initWithTarget:action:)];
});
}
- (instancetype)vi_initWithTarget:(nullable id)target action:(nullable SEL)action
{
UIGestureRecognizer *selfGestureRecognizer = [self vi_initWithTarget:target action:action];
if (!target || !action) {
return selfGestureRecognizer;
}
if ([target isKindOfClass:[UIScrollView class]]) {
return selfGestureRecognizer;
}
Class class = [target class];
SEL originalSEL = action;
NSString * sel_name = [NSString stringWithFormat:@"%s/%@", class_getName([target class]),NSStringFromSelector(action)];
SEL swizzledSEL = NSSelectorFromString(sel_name);
//給原對象添加一共名字為 “sel_name”的方法,并將方法的實現(xiàn)指向本類中的 responseUser_gesture:方法的實現(xiàn)
BOOL isAddMethod = class_addMethod(class,
swizzledSEL,
method_getImplementation(class_getInstanceMethod([self class], @selector(responseUser_gesture:))),
nil);
if (isAddMethod) {
[MethodSwizzingTool swizzingForClass:class originalSel:originalSEL swizzingSel:swizzledSEL];
}
//將gesture的對應(yīng)的sel存儲到 methodName屬性中,主要是方便 responseUser_gesture: 方法中取出來
self.methodName = NSStringFromSelector(action);
return selfGestureRecognizer;
}
-(void)responseUser_gesture:(UIGestureRecognizer *)gesture
{
NSString * identifier = [NSString stringWithFormat:@"%s/%@", class_getName([self class]),gesture.methodName];
//調(diào)用原方法
SEL sel = NSSelectorFromString(identifier);
if ([self respondsToSelector:sel]) {
IMP imp = [self methodForSelector:sel];
void (*func)(id, SEL,id) = (void *)imp;
func(self, sel,gesture);
}
//處理業(yè)務(wù),上報埋點
NSDictionary * dic = [[[DataContainer dataInstance].data objectForKey:@"GESTURE"] objectForKey:identifier];
if (dic) {
NSString * eventid = dic[@"userDefined"][@"eventid"];
NSString * targetname = dic[@"userDefined"][@"target"];
NSString * pageid = dic[@"userDefined"][@"pageid"];
NSString * pagename = dic[@"userDefined"][@"pagename"];
NSDictionary * pagePara = dic[@"pagePara"];
__block NSMutableDictionary * uploadDic = [NSMutableDictionary dictionaryWithCapacity:0];
[pagePara enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) {
id value = [CaptureTool captureVarforInstance:self withPara:obj];
if (value && key) {
[uploadDic setObject:value forKey:key];
}
}];
NSLog(@"\n event id === %@,\n target === %@, \n pageid === %@,\n pagename === %@,\n pagepara === %@ \n", eventid, targetname, pageid, pagename, uploadDic);
}
}
配置表結(jié)構(gòu)
首先那, 配置表是一個json數(shù)據(jù)。 針對不同的場景 (UIControl , 頁面PV, Tabeview, Gesture)都做了區(qū)分, 用不同的key區(qū)別。 對于 "固定參數(shù)" , 我們之間寫到配置表中,而對于業(yè)務(wù)參數(shù), 我們之間寫清楚參數(shù)在業(yè)務(wù)內(nèi)的名字, 以及上傳時的 keyName, 參數(shù)的持有者。 通過Runtime + KVC來取值。 配置表可以是這個樣子:(僅供參考)
說明: json最外層有四個Key, 分別為 ACTION PAGEPV TABLEVIEW GESTURE, 分別對應(yīng) UIControl的點擊, 頁面PV, tableview cell點擊, Gesture 單擊事件的參數(shù)。 每個key對應(yīng)的value為json格式,Json中的keys, 即為唯一標識符。 標識符下的json有兩個key : userDefine指的 固定數(shù)據(jù), 即直接取值進行上報。 而pagePara為業(yè)務(wù)參數(shù)。 pagePara對應(yīng)的value也是一個json, json的keys, 即上報的keys, value內(nèi)的json包含三個參數(shù): propertyName 為屬性名字, containIn 參數(shù)只有0 ,1 兩種情況, 其實這個參數(shù)主要是為tabview cell的點擊取參做區(qū)別的,因為點擊cell的時候, 上報的參數(shù)可能是被target持有,又或者是被cell本身持有 。 當containIn = 0的時候, 取參數(shù)時就從target中取值,= 1的時候就從cell中取值。 propertyPath 是一般備選項, 因為有時候從instace內(nèi)遞歸取值的時候,可能會出現(xiàn)在不同的層級有相同的屬性名字, 此時 propertyPath就派上用處了。 例如有屬性 self.age 和 self.person.age , 其實如果需要self.person.age, 就把 propertyPath的值設(shè)為 person/age, 接著在取值的時候就會按照指定路徑進行取值。
{
"ACTION": {
"ViewController/jumpSecond": {
"userDefined": {
"eventid": "201803074|93",
"target": "",
"pageid": "234",
"pagename": "button點擊,跳轉(zhuǎn)至下一個頁面"
},
"pagePara": {
"testKey9": {
"propertyName": "testPara",
"propertyPath":"",
"containIn": "0"
}
}
}
},
"PAGEPV": {
"ViewController": {
"userDefined": {
"pageid": "234",
"pagename": "XXX 頁面展示了"
},
"pagePara": {
"testKey10": {
"propertyName": "testPara",
"propertyPath":"",
"containIn": "0"
}
}
}
},
"TABLEVIEW": {
"ViewController/UITableView/0":{
"userDefined": {
"eventid": "201803074|93",
"target": "",
"pageid": "234",
"pagename": "tableview 被點擊"
},
"pagePara": {
"user_grade": {
"propertyName": "grade",
"propertyPath":"",
"containIn": "1"
}
}
}
},
"GESTURE": {
"ViewController/controllerclicked:":{
"userDefined": {
"eventid": "201803074|93",
"target": "",
"pageid": "123",
"pagename": "手勢響應(yīng)"
},
"pagePara": {
"testKey1": {
"propertyName": "testPara",
"propertyPath":"",
"containIn": "0"
}
}
}
}
}
取參方法
@implementation CaptureTool
+(id)captureVarforInstance:(id)instance varName:(NSString *)varName
{
id value = [instance valueForKey:varName];
unsigned int count;
objc_property_t *properties = class_copyPropertyList([instance class], &count);
if (!value) {
NSMutableArray * varNameArray = [NSMutableArray arrayWithCapacity:0];
for (int i = 0; i < count; i++) {
objc_property_t property = properties[i];
NSString* propertyAttributes = [NSString stringWithUTF8String:property_getAttributes(property)];
NSArray* splitPropertyAttributes = [propertyAttributes componentsSeparatedByString:@"\""];
if (splitPropertyAttributes.count < 2) {
continue;
}
NSString * className = [splitPropertyAttributes objectAtIndex:1];
Class cls = NSClassFromString(className);
NSBundle *bundle2 = [NSBundle bundleForClass:cls];
if (bundle2 == [NSBundle mainBundle]) {
// NSLog(@"自定義的類----- %@", className);
const char * name = property_getName(property);
NSString * varname = [[NSString alloc] initWithCString:name encoding:NSUTF8StringEncoding];
[varNameArray addObject:varname];
} else {
// NSLog(@"系統(tǒng)的類");
}
}
for (NSString * name in varNameArray) {
id newValue = [instance valueForKey:name];
if (newValue) {
value = [newValue valueForKey:varName];
if (value) {
return value;
}else{
value = [[self class] captureVarforInstance:newValue varName:varName];
}
}
}
}
return value;
}
+(id)captureVarforInstance:(id)instance withPara:(NSDictionary *)para
{
NSString * properyName = para[@"propertyName"];
NSString * propertyPath = para[@"propertyPath"];
if (propertyPath.length > 0) {
NSArray * keysArray = [propertyPath componentsSeparatedByString:@"/"];
return [[self class] captureVarforInstance:instance withKeys:keysArray];
}
return [[self class] captureVarforInstance:instance varName:properyName];
}
+(id)captureVarforInstance:(id)instance withKeys:(NSArray *)keyArray
{
id result = [instance valueForKey:keyArray[0]];
if (keyArray.count > 1 && result) {
int i = 1;
while (i < keyArray.count && result) {
result = [result valueForKey:keyArray[i]];
i++;
}
}
return result;
}
@end
結(jié)尾
以上是自己的一些想法與實踐, 感覺目前的無痕埋點方案都還是不是很成熟, 不同的公司會有不同的方案, 但是可能大部分還是用的代碼埋點的方式。 代碼埋點的侵入性,維護性成本比較大, 尤其是當埋點特別多的時候, 有時候自己幾個月前寫的埋點代碼,突然需要改,自己都要找半天才能找到。 并且代碼埋點很致命的一個問題是無法動態(tài)更新, 即每次修改埋點,必須重新上線, 有時候上線后產(chǎn)品經(jīng)理突然跑過來問:為什么埋點數(shù)據(jù)不太正常那, 此時你突然發(fā)現(xiàn)有一句埋點代碼寫錯了, 這個時候你要么承認錯誤,承諾下次加上。要么趕快緊急上線解決。 通過以上方式,可以實現(xiàn)埋點的動態(tài)追加。 配置表可以通過服務(wù)端下載, 每次下載后就存在本地, 如果配置表有更新,只需要重新更新配置表就可以解決 。 方案中可能很多細節(jié)還需要完善,例如selector方法中存在業(yè)務(wù)邏輯判斷,即一個標識符無法唯一的鎖定一個埋點。 這種情況目前用配置表解決的成本較大, 并且業(yè)務(wù)是靈活的不好控制。 所以以上方案也只是涵蓋了大部分場景, 并非所有場景都適用,具體大家可以根據(jù)業(yè)務(wù)情況來決定使用范圍。
最后, 大家如果有什么建議,歡迎簡信給我。 我們一起來探討完善這個一個方案。