cover_image

作为插件全埋点解决方案

贾锡瑞 之家技术
2022年07月27日 10:00

关注“之家技术”,获取更多技术干货

图片

总篇154篇 2022年第29篇

背景

用户数据分析与埋点,互联网产品不可缺少的部分,帮助产品分析功能决策,配合回溯用户操作路径及习惯,分析后产生更适合用户的需求。

目前,业界主流埋点方式,主要为以下三种:

a.代码埋点

一直采用代码埋点,通过sdk方法进行触发。代码埋点是“最原始”的埋点方式,同时也是“最万能”的方式

优点: 1.精准控制埋点位置;2.方便采集自定义属性和数据 ;3.满足精细化分析需求

缺点: 1.埋点成本较高;2.需求发生变化时,需要重新设计修改

b.全埋点   (本文只关注作为插件的全埋点的点击事件)

全埋点也叫无埋点,自动埋点等。目的是写少量代码,收集用户所有或大部分数据的行为,然后进行整理分析

优点:前期埋点成本较低 发生设计变化无需修改埋点 有效解决历史数据回溯问题

缺点:无法覆盖复杂操作 无法满足更精细化分析需求 不同版本可能存在兼容性

c.可视化埋点 

建立在全埋点上,通过圈选方式埋点

解决方案

结合全埋点和代码埋点的优点,设计出一套全埋点点击收集方案作为代码埋点及产品临时查找埋点的补充。针对业务情况做出如下。

思考:

  • 点击收集如何获取所有的点击事件,并标记唯一id的规则。

  • 市面上的全埋点方案都是以AOP方式进行。是否我们也可行

  • AOP优点明显入侵量小,不涉及业务代码,但缺点也明显他需要侵入系统级方法。

  • 我们在APP内只是个业务线,切面到系统的方法可以做到,但作为插件不应影响其他业务线,AOP未作为我们可选方案。

解决: 本文共两个部分:IOS全埋点,flutter全埋点。

  • IOS全埋点

我们项目内点击的事件主要有 UIButton,UISwitch,UITableView,主软件提供的公共控件点击回调 既然AOP不行那我们就采用继承方式。然后AOP切入我们自己的方法内这就不会影响其他业务线代码

1.对于UIButton和UISwitch等继承自UIControl的控件就简单了 获取唯一标识方法如下

a.只需要在内部实现,为保证切入方法独立并没在子类处理埋点内容

@interface XXYButton : UIButton
- (void)sendAction:(SEL)action to:(nullable id)target forEvent:(nullable UIEvent *)event {
    [super sendAction:action to:target forEvent:event];
}
b.生成同名分类XXYButton+AOP.h
+ (void)load {
   static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Method srcMethod = class_getInstanceMethod([self class], @selector(sendAction:to:forEvent:));
        Method tarMethod = class_getInstanceMethod([self class],@selector(xxy_sendAction:to:forEvent:));
        method_exchangeImplementations(srcMethod, tarMethod);
    });
}
-(void)xxy_sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event
{
    [self xxy_sendAction:action to:target forEvent:event];

    NSString * identifier = [NSString stringWithFormat:@"%@/%@/%ld", [target class], NSStringFromSelector(action),self.tag];
}

identifier就是我们要获取的唯一标识,包括类名方法名和tag

2.对于UITableView

获取唯一标识方法如下

a.子类实现代理

 - (void)setDelegate:(id<UITableViewDelegate>)delegate {
    [super setDelegate:delegate];
}

b.同理生成同名分类XXYTableView+AOP.h

+ (void)load {
   .....
}
- (void)xxy_setDelegate:(id<UITableViewDelegate>)delegate {
    [self xxy_setDelegate:delegate];
    
    SEL sel = @selector(tableView:didSelectRowAtIndexPath:);
    
    SEL sel_ =  NSSelectorFromString([NSString stringWithFormat:@"%@/%@/%ld", NSStringFromClass([delegate class]), NSStringFromClass([self class]),self.tag]);
    
    //因为 tableView:didSelectRowAtIndexPath:方法是optional的,所以没有实现的时候直接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);
    
    if (addsuccess) {
      ....
    }}
    
 -(void)user_tableView:(XXYTableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    
    NSString *identifier = [NSString stringWithFormat:@"%@/%@/%ld", NSStringFromClass([self class]),  NSStringFromClass([tableView class]), tableView.tag];
    SEL sel = NSSelectorFromString(identifier);
    if ([self respondsToSelector:sel]) {
        IMP imp = [self methodForSelector:sel];
        void (*func)(id, SEL,id,id) = (void *)imp;
        func(self, sel,tableView,indexPath);
        }
    }

identifier就是我们要获取的唯一标识,包括类名方法名和tag

3.对于主软件公共控件 考虑过继承,写起来太麻烦,并且如果有更改我们还随着改有可能影响业务,在不影响业务的前提下。我们选择了在自己类重新实现下主软件代理并用AOP切入当前类

Method srcMethod = class_getInstanceMethod([self class], @selector(onClickItemIndex:atIndex:));
  Method tarMethod = class_getInstanceMethod([self class],@selector(xxy_onClickItemIndex:atIndex:));
  
- (void)xxy_onClickItemIndex:(id)scrollTitleBar atIndex:(NSInteger)aIndex {
    [self xxy_onClickItemIndex:scrollTitleBar atIndex:aIndex];
    NSString * identifier = [NSString stringWithFormat:@"%@/%@/%ld",NSStringFromClass([self class]), NSStringFromClass([scrollTitleBar class]),aIndex];
ic];
}

identifier就是我们要获取的唯一标识,包括类名方法名和index

原生获取全埋点标识相对简单,只需要关注不影响其他业务,拿到唯一id,在有self的情况下,也能通过运行时获取对象内属性,不在列举

  • flutter全埋点

1.flutter页面ID规则分析

首先页面ID不像原生有个统一规则,我们在获取某个widget会使用个唯一GlobalKey标识,除非有特殊情况通常不会去使用,更不会所有控件都标记获取,这时候我们想到了flutter的视图树。

图片
alt 属性文本

每个组件都是根据父子,兄弟关系绘制,根据控件本身向上逐级遍历拿到根节点,这样就能得到一个树的的路径,这个路径就是我们的唯一ID图片(引入网络图片)

上面就是flutter经典的三棵树,widget树并没有parent和child供我们遍历,所以要从element树入手,Element树实现了BuildContext,而BuildContext实现了遍历的一些方法

abstract class BuildContext {

T? findAncestorStateOfType<T extends State>();
void visitAncestorElements(bool visitor(Element element));
.....
}

Element实现的搜索方法 (源码)

void visitAncestorElements(bool visitor(Element element)) {
assert(_debugCheckStateIsActiveForAncestorLookup());
Element? ancestor = _parent;
while (ancestor != null && visitor(ancestor))
 ancestor = ancestor._parent;
}

注:市面上flutter的全埋点也是采用AOP方式,都是采用闲鱼的Aspectd框架,根据上面IOS分析,我们是无法触及到底层flutter,更何况Aspectd更改了编译方法。AOP行不通,继续使用继承是否就可以了,当然可以,flutter只占了我们一个页面。

a.收集项目内可点击方法,当时觉得用系统的麻烦还传些默认参数,我们都给做了封装UCGesture(),现在正好能用上。我们要关注的点击方法 GestureDetector(),InkWell()

为了方便我们做了统一封装UCGesture(), 点击操作必须使用规范,保证了我们点击事件的入口统一 是否存在像IOS主软的插件需要单独处理,flutter写法视图使用 UCGesture()不需要处理。

b.确定页面路径终点,一直向上遍历找到APP入口层级,我们是否需要这么长路径确定唯一,答案是否定的 我们这么做,增加一层state继承AHContainerState,这就是我们一个小模块的路径根视图

abstract class UCSamoCommonState<T extends StatefulWidget> extends AHContainerState<T> 

让我们的state在继承自UCSamoCommonState ,这时候我们只需要找到UCSamoCommonState页面即可,不需要遍历到APP层级。

UCSamoCommonState statetHome = context.findAncestorStateOfType<UCSamoCommonState>();
String pagename = statetHome.widget.runtimeType.toString();

我们拿到了当前层级name,这就是我们ID标识的第一个节点,同时这个UCSamoCommonState也是作为我们遍历的终止条件

因为我们在点击是在UCGesture()方法触发,这里的context就是我们遍历的第一个节点

static List<Element> getElementPathList(Element element) {
List<Element> elementPathList = [];
elementPathList.add(element);
element.visitAncestorElements((element) {
if (_isLocalState(element)) {
 elementPathList.add(element);
 return false;
  } else {
    elementPathList.add(element);
  }
    return true;
  });
    return listreversed;
  }
}

通过上面的递归我们拿到了一个List,下到上的对象,路径要求相反,我们在listreversed下得到正向数据

下面是产生ID路径的方法,利用他的层级_getIndex方法获取他的index属性确定层级

static String getPath(List list, String pagename) {
  var listResult = list;

  String finalResult = "";
  if (pagename?.length > 0) {
    finalResult = "${pagename}[0]";
  }

  listResult.forEach((ele) {
    finalResult += "/${ele.widget.runtimeType.toString()}";
    int slot = _getIndex(ele);
    if (slot >= 0) {
      finalResult += "[$slot]";
    }
    if (ele == listResult.last) {
      finalResult += "-[${ele.hashCode.toString()}]";
    }
  });

  if (finalResult.startsWith('/')) {
    finalResult = finalResult.replaceFirst('/''');
  }
  return finalResult;
}

产生路径举例,flutter看过模块产生的路径有这么长

UCLookCardPageB[0]/Container[0]/Container[0]/Padding[0]/Padding[0]/Row[0]/Row[0]/Expanded[1]/Expanded[1]/Stack[1]/Stack[1]/Container[0]/Container[0]/ConstrainedBox[0]/ConstrainedBox[0]/ListView[0]/ListView[0]/Scrollable[0]/Scrollable[0]/_ScrollSemantics[0]/_ScrollSemantics[0]/_ScrollableScope[0]/_ScrollableScope[0]/Listener[0]/Listener[0]/RawGestureDetector[0]/RawGestureDetector[0]/_GestureSemantics[0]/_GestureSemantics[0]/Listener[0]/Listener[0]/Semantics[0]/Semantics[0]/IgnorePointer[0]/IgnorePointer[0]/Viewport[0]/Viewport[0]/SliverPadding[0]/SliverPadding[0]/MediaQuery[0]/MediaQuery[0]/SliverList[0]/SliverList[0]/KeyedSubtree[0]/KeyedSubtree[0]/AutomaticKeepAlive[0]/AutomaticKeepAlive[0]/KeepAlive[0]/KeepAlive[0]/NotificationListener<KeepAliveNotification>[0]/NotificationListener<KeepAliveNotification>[0]/IndexedSemantics[0]/IndexedSemantics[0]/RepaintBoundary[0]/RepaintBoundary[0]/Container[0]/Container[0]/ConstrainedBox[0]/ConstrainedBox[0]/Stack[0]/Stack[0]/Container[0]/Container[0]/Padding[0]/Padding[0]/ConstrainedBox[0]/ConstrainedBox[0]/UCGesture[0]-[1379]

2.关于ID路径优化

路径优化 :ID路径过长,上面我们自定义的state已经做了优化,只找到我们组件名位置UCLookCardPageB,如果找到APP里内容更长。

只关注关键路径, 移除平台差异产生的路径,进一步优化去掉无关的路径。

方法也很简单只需要遍历时缓存关键路径即可

if (element is StatelessElement || element is StatefulElement) {
 elementPathList.add(element);
}
if (element.widget is Column || element.widget is Row ) {
 elementPathList.add(element);
}

Column和Row 是否也能省掉,答案是不能,这样同一层级控件将无法区分

优化后路径基本能满足要求

UCLookCardPageB[0]/Container[0]/Row[0]/Container[0]/ListView[0]/Scrollable[0]/RawGestureDetector[0]/KeyedSubtree[0]/AutomaticKeepAlive[0]/NotificationListener<KeepAliveNotification>[0]/Container[0]/Container[0]/UCGesture[0]-[1268]
UCSelectedCarPageB[0]/Container[0]/Container[0]/Column[0]/Row[0]/UCGesture[0]-[994]

3.页面名称

按照上面模块名称很容易 建立个UCContainerState当我们页面名称根节点

UCContainerState stateHome =
context.findAncestorStateOfType<UCContainerState>();
String pageViewId = stateHome.widget.runtimeType.toString();
return pageViewId;

4.组件位置坐标

也是通过Element获取方法如下

final RenderBox box = element.renderObject as RenderBox;
final size = box.size;
final offset = box.localToGlobal(Offset.zero);
Rect.fromLTWH(offset.dx, offset.dy, size.width, size.height);

按照屏幕左侧顶点对应坐标

5.遍历自己页面找到所有点击事件

a.先找到最上层UCContainerState,上面已经提到我们自定义的state,作为反向遍历起点。

b.向下遍历找到UCGesture为终止

static List<Element> getAllEvent(BuildContext context) {
List<Element> dataList = [];
UCContainerState stateHome =
context.findAncestorStateOfType<UCContainerState>();
final viewPageElement =findElementByType<InheritedElement>(stateHome.context);
viewPageElement.visitChildElements((element) {
 traverseAllElement(dataList, element);
});
 return dataList;
}

c.记录所有的element,优化方案和上面相同,获取路径方法和上面相同。

d.获取坐标需要注意 if(offset.dx.isNaN || offset.dy.isNaN) 增加判断的原因是发现未显示在页面的视图还没坐标,直接使用会产生异常。

最后我们定义对象,生成一个如下数组

{"left":151,"top":132,"width":72,"height":62,"viewid":"UCBusinessPageB[0]/Container[0]/Column[0]/Container[1]/Container[0]/Row[0]/UCGesture[2]-[905]","pageid":"HomePageB"}
{"left":223,"top":132,"width":72,"height":62,"viewid":"UCBusinessPageB[0]/Container[0]/Column[0]/Container[1]/Container[0]/Row[0]/UCGesture[3]-[932]","pageid":"HomePageB"}


结束

基于插件的特点我们整理出了一套自己可以用的方案,前期改动位置多,但好在我们页面少,并没有对业务有多大影响。原理上大概分享到这。

作者简介

图片

汽车之家

贾锡瑞

二手车事业部-技术部

加入汽车之家多年,一直从事研发工作,现负责二手车之家以及其他汽车之家二手车业务的相关研发工作。

图片

阅读更多:


▼ 关注「之家技术」,获取更多技术干货 

图片

继续滑动看下一个
之家技术
向上滑动看下一个