diff --git a/Chapter1 - iOS/1.55.md b/Chapter1 - iOS/1.55.md index 6339768..84c8e98 100644 --- a/Chapter1 - iOS/1.55.md +++ b/Chapter1 - iOS/1.55.md @@ -7,7 +7,17 @@ -## 0x01. 埋点手段 +## 前置说明 + +看到我在下面的代码段,有些命名风格、简写、分类、方法的命名等,我简单做个说明。 + +- 埋点 SDK 叫 `UserAnalysis`,我们规定类的命名一般用 SDK 的名字缩写,当前情况下缩写为 `UA` +- 给 Category 命名,规则为 `类名 + SDK 前缀缩写的小写格式 + 下划线 + 驼峰命名格式的功能描述`。比如给 NSDate 增加一个获取毫秒时间戳的分类,那么类名为 `NSDate+ua_TimeStamp` +- 给 Category 的方法命名,规则为 `SDK 前缀缩写的小写格式 + 下划线 + 驼峰命名格式的功能描述`。比如给 NSDate 增加一个根据当前时间获取毫秒时间戳的方法,那么方法名为 `+ (long long)ua_currentTimestamp;` + + + +## 一. 埋点手段 业界中对于代码埋点主要有3种主流的方案:代码手动埋点、可视化埋点、无痕埋点。简单说说这几种埋点方案。 @@ -17,7 +27,7 @@ -## 0x02. 技术选型 +## 二. 技术选型 @@ -39,11 +49,11 @@ **可视化埋点的出现,是为解决代码埋点流程复杂、成本高、新开发的页面(H5、或者服务端下发的 json 去生成相应页面)不能及时拥有埋点能力** - 前端在「埋点编辑模式」下,以“可视化”的方式去配置、绑定关键业务模块的路径到前端可以唯一确定到view的xpath过程。 + 前端在「埋点编辑模式」下,以“可视化”的方式去配置、绑定关键业务模块的路径到前端可以唯一确定到 view 的xpath 过程。 -用户每次操作的控件,都生成一个 **xpath** 字符串,然后通过接口将 xpath 字符串(view在前端系统中的唯一定位。以 iOS 为例,App名称、控制器名称、一层层view、同类型view的序号:“GoodCell.21.RetailTableView.GoodsViewController.*baoApp”)到真正的业务模块(“宝App-商城控制器-分销商品列表-第21个商品被点击了”)的映射关系上传到服务端。xpath 具体是什么在下文会有介绍。 +用户每次操作的控件,都生成一个 **xpath** 字符串,然后通过接口将 xpath 字符串(view在前端系统中的唯一定位。以 iOS 为例,App名称、控制器名称、一层层view、同类型view的序号:`GoodCell.21.RetailTableView.GoodsViewController.***App` 到真正的业务模块(“某App-商城控制器-分销商品列表-第21个商品被点击了”)的映射关系上传到服务端。xpath 具体是什么在下文会有介绍。 -之后操作 App 就生成对应的 xpath 和埋点数据(开发者通过技术手段将从服务端获取的关键数据塞到前端的 UI 控件上。 iOS 端为例, UIView 的 accessibilityIdentifier 属性可以设置我们从服务端获取的埋点数据)上传到服务端。 +之后操作 App 就生成对应的 xpath 和埋点数据(开发者通过技术手段将从服务端获取的关键数据塞到前端的 UI 控件上。 iOS 端为例, UIView 的 `accessibilityIdentifier` 属性可以设置我们从服务端获取的埋点数据)上传到服务端。 优点:数据量相对准确、后期数据分析成本低 @@ -67,13 +77,13 @@ 怎么说呢?对于关键的业务开发结束上线后、通过可视化方案(类似于一个界面,想想看 Dreamwaver,你在界面上拖拖控件,简单编辑下就可以生成对应的 HTML 代码)点击一下绑定对应关系到服务端。 -那么这个对应关系是什么?我们需要唯一定位一个前端元素,那么想到的办法就是不管 Native 和 Web 前端,控件或者元素来说就是一个树形层级,DOM tree 或者 UI tree,所以我们通过技术手段定位到这个元素,以 Native iOS 为例子,假如点击商品详情页的加入购物车按钮会根据 UI 层级结构生成一个唯一标识 “addCartButton.GoodsViewController.GoodsView.*BaoApp” 。但是用户在使用 App 的时候,上传的是这串东西的 MD5到服务端。 +那么这个对应关系是什么?我们需要唯一定位一个前端元素,那么想到的办法就是不管 Native 和 Web 前端,控件或者元素来说就是一个树形层级,DOM tree 或者 UI tree,所以我们通过技术手段定位到这个元素,以 Native iOS 为例子,假如点击商品详情页的加入购物车按钮会根据 UI 层级结构生成一个唯一标识 `addCartButton.GoodsViewController.GoodsView.*BaoApp`。但是用户在使用 App 的时候,上传的是这串东西的 MD5 到服务端。 这么做有2个原因:服务端数据库存储这串很长的东西不是很好;埋点数据被劫持的话直接看到明文不太好。所以 MD5 再上传。 -## 0x03. 操刀就干 +## 三. 操刀就干 一言以蔽之就是:**AOP -> Event Collector -> Event Cache -> Data Upload** - AOP:通过 runtime hook 的能力做到提供合适的时机去生成点击事件的数据 @@ -95,45 +105,26 @@ 以 iOS 为例。我们会想到 **AOP(Aspect Oriented Programming)**面向切面编程思想。动态地在函数调用前后插入相应的代码,在 Objective-C 中我们可以利用 Runtime 特性,用 **Method Swizzling** 来 hook 相应的函数 -为了给所有类方便地 hook,我们可以给 NSObject 添加个 Category,名字叫做 NSObject+MethodSwizzling +为了给所有类方便地 hook,我们可以给 NSObject 添加个 Category,名字叫做 `NSObject+u a_MethodSwizzling` ```objective-c #pragma mark - public Method -+ (void)lbp_swizzleMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector ++ (void)ua_swizzleMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector { - class_swizzleInstanceMethod(self, originalSelector, swizzledSelector); + ua_swizzleInstanceMethod(self, originalSelector, swizzledSelector); } -+ (void)lbp_swizzleClassMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector ++ (void)ua_swizzleClassMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector { //类方法实际上是储存在类对象的类(即元类)中,即类方法相当于元类的实例方法,所以只需要把元类传入,其他逻辑和交互实例方法一样。 Class class2 = object_getClass(self); - class_swizzleInstanceMethod(class2, originalSelector, swizzledSelector); + ua_swizzleInstanceMethod(class2, originalSelector, swizzledSelector); } #pragma mark - private method -void class_swizzleInstanceMethod(Class class, SEL originalSEL, SEL replacementSEL) +void ua_swizzleInstanceMethod(Class class, SEL originalSEL, SEL replacementSEL) { - /* - Class class = [self class]; - //原有方法 - Method originalMethod = class_getInstanceMethod(class, originalSelector); - //替换原有方法的新方法 - Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector); - //先尝试給源SEL添加IMP,这里是为了避免源SEL没有实现IMP的情况 - BOOL didAddMethod = class_addMethod(class,originalSelector, - method_getImplementation(swizzledMethod), - method_getTypeEncoding(swizzledMethod)); - if (didAddMethod) {//添加成功:表明源SEL没有实现IMP,将源SEL的IMP替换到交换SEL的IMP - class_replaceMethod(class,swizzledSelector, - method_getImplementation(originalMethod), - method_getTypeEncoding(originalMethod)); - } else {//添加失败:表明源SEL已经有IMP,直接将两个SEL的IMP交换即可 - method_exchangeImplementations(originalMethod, swizzledMethod); - } - */ - Method originMethod = class_getInstanceMethod(class, originalSEL); Method replaceMethod = class_getInstanceMethod(class, replacementSEL); @@ -166,10 +157,10 @@ void class_swizzleInstanceMethod(Class class, SEL originalSEL, SEL replacementSE ```objective-c -static char *lbp_viewController_open_time = "lbp_viewController_open_time"; -static char *lbp_viewController_close_time = "lbp_viewController_close_time"; +static char *ua_viewController_open_time = "ua_viewController_open_time"; +static char *ua_viewController_close_time = "ua_viewController_close_time"; -@implementation UIViewController (lbpka) +@implementation UIViewController (uaka) // load 方法里面添加 dispatch_once 是为了防止手动调用 load 方法。 + (void)load @@ -177,36 +168,16 @@ static char *lbp_viewController_close_time = "lbp_viewController_close_time"; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ @autoreleasepool { - [[self class] lbp_swizzleMethod:@selector(viewWillAppear:) swizzledSelector:@selector(lbp_viewWillAppear:)]; - [[self class] lbp_swizzleMethod:@selector(viewWillDisappear:) swizzledSelector:@selector(lbp_viewWillDisappear:)]; + [[self class] ua_swizzleMethod:@selector(viewWillAppear:) swizzledSelector:@selector(ua_viewWillAppear:)]; + [[self class] ua_swizzleMethod:@selector(viewWillDisappear:) swizzledSelector:@selector(ua_viewWillDisappear:)]; } }); } -#pragma mark - add prop +#pragma mark - private method -- (void)setOpenTime:(NSDate *)openTime -{ - objc_setAssociatedObject(self,&lbp_viewController_open_time, openTime, OBJC_ASSOCIATION_RETAIN_NONATOMIC); -} - -- (NSDate *)getOpenTime -{ - return objc_getAssociatedObject(self, &lbp_viewController_open_time); -} - -- (void)setCloseTime:(NSDate *)closeTime -{ - objc_setAssociatedObject(self,&lbp_viewController_close_time, closeTime, OBJC_ASSOCIATION_RETAIN_NONATOMIC); -} - -- (NSDate *)getCloseTime -{ - return objc_getAssociatedObject(self, &lbp_viewController_close_time); -} - -- (void)lbp_viewWillAppear:(BOOL)animated +- (void)ua_viewWillAppear:(BOOL)animated { NSString *className = NSStringFromClass([self class]); NSString *refer = [NSString string]; @@ -227,21 +198,19 @@ static char *lbp_viewController_close_time = "lbp_viewController_close_time"; [UserTrackDataCenter openPage:[self getPageUrl:className] fromPage:refer]; } - [self lbp_viewWillAppear:animated]; + [self ua_viewWillAppear:animated]; } -- (void)lbp_viewWillDisappear:(BOOL)animated +- (void)ua_viewWillDisappear:(BOOL)animated { NSString *className = NSStringFromClass([self class]); if ([self getPageUrl:className]) { [self setCloseTime:[NSDate dateWithTimeIntervalSinceNow:0]]; [UserTrackDataCenter leavePage:[self getPageUrl:className] spendTime:[self p_calculationTimeSpend]]; } - [self lbp_viewWillDisappear:animated]; + [self ua_viewWillDisappear:animated]; } -#pragma mark - private method - - (NSString *)p_calculationTimeSpend { @@ -259,6 +228,30 @@ static char *lbp_viewController_close_time = "lbp_viewController_close_time"; return [NSString stringWithFormat:@"%d",second]; } + +#pragma mark - getter && setter + +- (void)setOpenTime:(NSDate *)openTime +{ + objc_setAssociatedObject(self,&ua_viewController_open_time, openTime, OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + +- (NSDate *)getOpenTime +{ + return objc_getAssociatedObject(self, &ua_viewController_open_time); +} + +- (void)setCloseTime:(NSDate *)closeTime +{ + objc_setAssociatedObject(self,&ua_viewController_close_time, closeTime, OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + +- (NSDate *)getCloseTime +{ + return objc_getAssociatedObject(self, &ua_viewController_close_time); +} + + @end ``` @@ -295,19 +288,17 @@ static char *lbp_viewController_close_time = "lbp_viewController_close_time"; ### 5. 同类型的 view 的唯一定位问题 -有个问题,比如我们点击的元素是 UITableViewCell,那么它虽然可以定位到类似于这个标示 xxApp.GoodsViewController.GoodsTableView.GoodsCell,同类型的 Cell 有多个,所以单凭借这个字符串是没有办法定位具体的那个 Cell 被点击了。 +有个问题,比如我们点击的元素是 UITableViewCell,那么它虽然可以定位到类似于这个标示 `xxApp.GoodsViewController.GoodsTableView.GoodsCell`,同类型的 Cell 有多个,所以单凭借这个字符串是没有办法定位具体的那个 Cell 被点击了。 当然有解决方案啦。 - 找出当前元素在父层同类型元素中的索引。根据当前的元素遍历当前元素的父级元素的子元素,如果出现相同的元素,则需要判断当前元素是所在层级的第几个元素 - 对当前的控件元素的父视图的全部子视图进行遍历,如果存在和当前的控件元素同类型的控件,那么需要判断当前控件元素在同类型控件元素中的所处的位置,那么则可以唯一定位。举例:GoodsCell-3.GoodsTableView.GoodsViewController.xxApp - - + 对当前的控件元素的父视图的全部子视图进行遍历,如果存在和当前的控件元素同类型的控件,那么需要判断当前控件元素在同类型控件元素中的所处的位置,那么则可以唯一定位。举例:`GoodsCell-3.GoodsTableView.GoodsViewController.xxApp` ```Objective-c - //UIResponder分类 - - (NSString *)lbp_identifierKa +//UIResponder分类 + - (NSString *)ua_identifierKa { // if (self.xq_identifier_ka == nil) { if ([self isKindOfClass:[UIView class]]) { @@ -316,7 +307,7 @@ static char *lbp_viewController_close_time = "lbp_viewController_close_time"; NSMutableString *str = [NSMutableString string]; //特殊的 加减购 因为带有spm但是要区分加减 需要带TreeNode NSString *className = [NSString stringWithUTF8String:object_getClassName(view)]; - if (!view.accessibilityIdentifier || [className isEqualToString:@"lbpButton"]) { + if (!view.accessibilityIdentifier || [className isEqualToString:@"uaButton"]) { [str appendString:sameViewTreeNode]; [str appendString:@","]; } @@ -382,7 +373,7 @@ static char *lbp_viewController_close_time = "lbp_viewController_close_time"; } ``` - + ![页面唯一标识示意图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-04-09-UserTrack.png) @@ -391,7 +382,9 @@ static char *lbp_viewController_close_time = "lbp_viewController_close_time"; ### 6. 同类型的view,但是点击的意义却不一样。如何唯一标识? -问题5说明的是在一个界面上有多个不同的 view,他们的类型是同一种(CycleBannerView,但是数据源不一样,那么当数据源长度大于1的时候会轮播,下面会展示 UIPageControl。如果数据源是1个,那么就不会轮播和展示 UIPageControl)。情况6是同一种类型的 View,但是根据展示的内容不一样,点击的意义也不一样。也就是运营需要去知道用户到底点击的是哪一个。如下图所示,「立即抢购」和「分享赚佣金」是同一种类型的 View,但是点击意义不一样,需要我们需要唯一标识出来。之前的方法通过 **“viewPath 配合同类型的 view 去加索引值“** 的方式还是没有办法唯一标识出来。所以想到一个方案,给 NSObject 添加一个分类,在分类里面添加一个协议。让需要复用但需要唯一标识的 view 去实现协议方法,因为是给 NSObject 分类添加的协议,所以 view 不需要去指定遵循。 +问题5说明的是在一个界面上有多个不同的 view,他们的类型是同一种(`CycleBannerView`,但是数据源不一样,那么当数据源长度大于1的时候会轮播,下面会展示 `UIPageControl`。如果数据源是1个,那么就不会轮播和展示 `UIPageControl`)。 + +情况6是同一种类型的 View,但是根据展示的内容不一样,点击的意义也不一样。也就是运营需要去知道用户到底点击的是哪一个。如下图所示,「立即抢购」和「分享赚佣金」是同一种类型的 View,但是点击意义不一样,需要我们需要唯一标识出来。之前的方法通过 **「viewPath 配合同类型的 view 去加索引值」** 的方式还是没有办法唯一标识出来。所以想到一个方案,给 NSObject 添加一个分类,在分类里面添加一个协议。让需要复用但需要唯一标识的 view 去实现协议方法,因为是给 NSObject 分类添加的协议,所以 view 不需要去指定遵循。 !["立即抢购"、"分享赚佣金"同类型view,但点击意义不一样](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-04-11-UserTrack01.png) @@ -406,7 +399,7 @@ static char *lbp_viewController_close_time = "lbp_viewController_close_time"; ```objective-c -//NSObject+UniqueIdentify.h +//NSObject+uaUniqueIdentify.h #import NS_ASSUME_NONNULL_BEGIN @@ -425,8 +418,8 @@ NS_ASSUME_NONNULL_BEGIN NS_ASSUME_NONNULL_END -//NSObject+UniqueIdentify.m -#import "NSObject+UniqueIdentify.h" +//NSObject+ua_UniqueIdentify.m +#import "NSObject+ua_UniqueIdentify.h" @implementation NSObject (UniqueIdentify) @@ -459,7 +452,7 @@ NSString *const ShareToAward = @"分享赚佣金"; ```objective-c //UIResponder Category 生成 viewPath -- (NSString *)lbp_identifierKa +- (NSString *)ua_identifierKa { // if (self.xq_identifier_ka == nil) { if ([self isKindOfClass:[UIView class]]) { @@ -468,7 +461,7 @@ NSString *const ShareToAward = @"分享赚佣金"; NSMutableString *str = [NSMutableString string]; //特殊的 加减购 因为带有spm但是要区分加减 需要带TreeNode NSString *className = [NSString stringWithUTF8String:object_getClassName(view)]; - if (!view.accessibilityIdentifier || [className isEqualToString:@"lbpButton"]) { + if (!view.accessibilityIdentifier || [className isEqualToString:@"uaButton"]) { [str appendString:sameViewTreeNode]; [str appendString:@","]; } @@ -499,9 +492,13 @@ NSString *const ShareToAward = @"分享赚佣金"; -### 7. 疑惑点 +### 7. 特殊 case -根据在同一个 view 上会有多个 subview,那么生成的 xpath 会携带在同类型 views 中的索引,所以一个登录、注册按钮的 xpath 可能为 ...btn1、...btn2。那么在版本A上线后运行了一段时间,上传并统计了数据。过了一段时间版本迭代,UI 搞事情,把登录和注册按钮的位置欢乐,变成了注册、登录。按照之前的逻辑生成的 xpath 为 ...btn1、...btn2。那么新的 xpath 虽然唯一,但是点击产生的数据会和之前的埋点数据意义不一样。别怕,你忘了还有一步绑定的逻辑。绑定的这一步会把每次开发的功能,通过可视化界面去将 xpath 和功能名称绑定一下。看下面的动图。所以不用担心虽然生成了唯一的 xpath,但是 App 在不同版本之间 UI 控件位置更换造成之前的统计数据在分析的时候不准确的问题。因为在绑定的时候就将新的 xpath 和功能名称进行了绑定,接口携带版本号。所以分析的时候注意版本号就好了。sql 一句话的事情。 +根据在同一个 view 上会有多个 subview,那么生成的 xpath 会携带在同类型 views 中的索引,所以一个登录、注册按钮的 xpath 可能为 `btn1.LoginView.LoginViewController.*baoApp`、`bt21.LoginView.LoginViewController.*baoApp`。 + +问题来了,当版本 A 上线后运行了一段时间,上传并统计了数据。但是过了一段时间版本迭代,UI 为了 KPI 搞事情,把登录和注册按钮的位置换了一下,变成了注册、登录。按照之前的逻辑生成的 xpath 还是 `btn1.LoginView.LoginViewController.*baoApp`、`bt21.LoginView.LoginViewController.*baoApp`。那么新的 xpath 虽然唯一,但是点击产生的数据会和之前的埋点数据意义不一样。别怕,你忘了还有一步绑定的逻辑。可视化绑定这个步骤会把每次开发的功能,通过可视化界面去将 `xpath` 和功能模块名称绑定一下。 + +看下面的动图。所以不用担心虽然生成了唯一的 `xpath`,但是 App 在不同版本之间 UI 控件位置更换造成之前的统计数据在分析的时候不准确的问题。因为在绑定的时候就将新的 `xpath` 和功能名称进行了绑定,接口携带版本号。所以分析的时候注意版本号就好了。sql 一句话的事情。 @@ -509,24 +506,22 @@ NSString *const ShareToAward = @"分享赚佣金"; + + ### 8. 数据如何处理 -#### A. 如何处理业务数据 - -利用系统提供的 **accessibilityIdentifier** 官方给出的解释是标识用户界面元素的字符串 - -> */** -> -> *A string that identifies the user interface element.* -> -> *default == nil* -> -> **/* -> -> **@property**(**nullable**, **nonatomic**, **copy**) NSString *accessibilityIdentifier NS_AVAILABLE_IOS(5_0); +#### 1. 如何处理业务数据 +利用系统提供的 `accessibilityIdentifier` 官方给出的解释是标识用户界面元素的字符串 +```objective-c +/* + A string that identifies the user interface element. + default == nil +*/ +@property(nullable, nonatomic, copy) NSString *accessibilityIdentifier API_AVAILABLE(ios(5.0)); +``` 服务端下发唯一标识 @@ -538,11 +533,11 @@ cell.accessibilityIdentifier = [[[SDGGoodsCategoryServices sharedInstance].categ -#### B. 基础数据 +#### 2. 基础数据 -设计上分为2个 pod 库,一个是 TriggerKit(专门用来 hook 机会需要的所有事件,页面停留时间、页面标识、view标识),另一个是 Appmonitor(专门用来提供基础数据、埋点数据的维护、上传机制)。所以在 Appmonitor 里面有个类叫做 UserTrackDataCenter 的类,专门提供一些基础数据(系统版本、操作系统、地理位置、网络等信息)。 +设计上分为2个 pod 库,一个是 `UserAnalysis`(专门用来 hook 机会需要的所有事件,页面停留时间、页面标识、view标识),另一个是 AppMonitor(专门用来提供基础数据、埋点数据的维护、上传机制)。所以在 AppMonitor 里面有个类叫做 UserTrackDataCenter 的类,专门提供一些基础数据(系统版本、操作系统、地理位置、网络等信息)。 -对外暴露出一些方法,用来将埋点数据交给 Appmonitor 去维护埋点数据,达到合适的“机制”再去上传埋点数据到服务端。 +对外暴露出一些方法,用来将埋点数据交给 AppMonitor 去维护埋点数据,达到合适的“机制”再去上传埋点数据到服务端。 ```objective-c + (void)clickEventUuid:(NSString *)uuid otherParam:(NSDictionary *)otherParam spmContent:(NSDictionary *)spmContent @@ -562,11 +557,9 @@ cell.accessibilityIdentifier = [[[SDGGoodsCategoryServices sharedInstance].categ ### 9. 曝光时间的统计 - - 曝光的意义是什么? -我们的产品中可能有合作伙伴的广告,我们需要收取服务费。那如何计价?CPM(cost per Mille)每千人成本、CPC(cost per click)每点击成本、CPA(cost per action)每行动成本,根据这些指标来计算价格。或者自己的产品中运营人员在商城中投放了一次新的活动,为了这次活动在某个钻石展位放了设计人员精心设计的炫酷 Banner。这次活动后运营人员想分析在这个图片的作用下有多少人点击了这个活动页。 +我们的产品中可能有合作伙伴的广告,我们需要收取服务费。那如何计价?`CPM`(cost per Mille)每千人成本、`CPC`(cost per click)每点击成本、`CPA`(cost per action)每行动成本,根据这些指标来计算价格。或者自己的产品中运营人员在商城中投放了一次新的活动,为了这次活动在某个钻石展位放了设计人员精心设计的炫酷 Banner。这次活动后运营人员想分析在这个图片的作用下有多少人点击了这个活动页。 @@ -576,7 +569,7 @@ cell.accessibilityIdentifier = [[[SDGGoodsCategoryServices sharedInstance].categ -#### A. 有效曝光的判断 +#### 1. 有效曝光的判断 显示在屏幕可见区域如何判断?一个 View 显示在屏幕可见区域内,那么它肯定是经过从未初始化到初始化,再到设置 Frame 或者 Bounds 或者 Alpha 或者 Hidden 的。且它的根 view 一定是 UIWindow 对象。所以上面这句话进行分析整理就是下面的条件 @@ -584,7 +577,7 @@ cell.accessibilityIdentifier = [[[SDGGoodsCategoryServices sharedInstance].categ - alpha 小于 0.1 或者 hidden 为 YES - 根视图为 window -对于上面的三点可以用 AOP 进行判断。 hook 掉相应的方法,然后处理判断是否在可见区域内显示。最后的一个点经过一番查找,看到了一个 api `didMoveToWindow` ,根据它可以判断 view 是否显示到屏幕中(文档中说明:当它的 window 对象发送改变的时候会调用 view 的 didMoveToWindow 方法)。 +对于上面的三点可以用 AOP 进行判断。 hook 掉相应的方法,然后处理判断是否在可见区域内显示。最后的一个点经过一番查找,看到了一个 api `- (void)didMoveToWindow;` ,根据它可以判断 view 是否显示到屏幕中(文档中说明:当它的 window 对象发送改变的时候会调用 view 的 `didMoveToWindow` 方法)。 ``` Tells the view that its window object changed. @@ -596,7 +589,7 @@ The window property may be nil by the time that this method is called, indicatin -#### B. 曝光代码的执行效率优化 +#### 2. 曝光代码的执行效率优化 设想一下,某个复杂的页面可能是一个大的 UIViewController 顶部是店铺的基本信息,下面是 2个 UIViewController:左侧负责展示商品的一级、二级、三级分类,且负责选中和未选中的 UI 效果;右侧负责展示商品信息(顶部有商品的排序查找 ,下面是商品展示的 UICollectionView)。由于页面结构复杂,UI 层级嵌套严重,所以代码层面不注意的话,页面上计算量会比较大,CPU 负荷严重,直接影响着手机的 `耗电量`。改进的手段是在合适的地方提前 return 掉(比如 hidden 等于 YES 或者 aplha 小于 0.1 的时候)。 @@ -620,17 +613,15 @@ iOS 11 以下 UITableViewWrapperView 大小为屏幕中第一个完整的屏幕 + + ### 10. 数据的上报 - - 数据通过上面的办法收集完了,那么如何及时、高效的上传到后端,给运营分析、处理呢? App 运行期间用户会点击非常多的数据,如果实时上传的话对于网络的利用率较低,所以需要考虑一个机制去控制用户产生的埋点数据的上传。 - - -思路是这样的。对外部暴露出一个接口,用来将产生的数据往数据中心存储。用户产生的数据会先保存到 AppMonitor 的内存中去,设置一个临界值(memoryEventMax = 50),如果存储的值达到设置的临界值 memoryEventMax,那么将内存中的数据写入文件系统,以 zip 的形式保存下来,然后上传到埋点系统。如果没有达到临界值但是存在一些 App 状态切换的情况,这时候需要及时保存数据到持久化。当下次打开 App 就去从本地持久化的地方读取是否有未上传的数据,如果有就上传日志信息,成功后删除本地的日志压缩包。 +思路是这样的。对外部暴露出一个接口,用来将产生的数据往数据中心存储。用户产生的数据会先保存到 `AppMonitor` 的内存中去,设置一个临界值(memoryEventMax = 50),如果存储的值达到设置的临界值 memoryEventMax,那么将内存中的数据写入文件系统,以 zip 的形式保存下来,然后上传到埋点系统。如果没有达到临界值但是存在一些 App 状态切换的情况,这时候需要及时保存数据到持久化。当下次打开 App 就去从本地持久化的地方读取是否有未上传的数据,如果有就上传日志信息,成功后删除本地的日志压缩包。 App 应用状态的切换策略如下: @@ -722,6 +713,12 @@ App 应用状态的切换策略如下: } ``` +其实 App 内部的数据上报有很多场景,比如 [APM 监控](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.74.md) 数据的上报、埋点数据的上报、业务线的数据上报等等,所以可以设计为[一个通用、可配置的数据上报 SDK](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md)。 + + + +### 11. 总结 + 使用的时候就是在 hook 系统事件的时候,去调用统计页面上传数据 ```objective-c @@ -736,31 +733,29 @@ App 应用状态的切换策略如下: 总结下来关键步骤: 1. hook 系统的各种事件(UIResponder、UITableView、UICollectionView代理事件、UIControl事件、UITapGestureRecognizers)、hook 应用程序、控制器生命周期。在做本来的逻辑之前添加额外的监控代码 -2. 对于点击的元素按照视图树生成对应的唯一标识(addCartButton.GoodsView.GoodsViewController) 的 md5 值 -3. 在业务开发完毕,进入埋点的编辑模式,将 md5 和关键的页面的关键事件(运营、产品想统计的关键模块:App层级、业务模块、关键页面、关键操作)给绑定起来。比如 addCartButton.GoodsView.GoodsViewController.tbApp 对应了 tbApp-商城模块-商品详情页-加入购物车功能。 +2. 对于点击的元素按照视图树生成对应的唯一标识 `addCartButton.GoodsView.GoodsViewController.**App` 的 md5 值 +3. 在业务开发完毕,进入埋点的编辑模式,将 md5 和关键的页面的关键事件(运营、产品想统计的关键模块:App层级、业务模块、关键页面、关键操作)给绑定起来。比如 `addCartButton.GoodsView.GoodsViewController.**App` 对应了 `**App-商城模块-商品详情页-加入购物车功能`。 4. 将所需要的数据存储下来 5. 设计机制等到合适的时机去上传数据 +## 四. 演示一个完整的埋点上报流程 - -## 举例说明一个完整的埋点上报流程 - -埋伏模块分为2个pod组件库,TriggerKit 负责拦截系统事件,拿到埋点数据。Appmonitor 负责收集埋点数据,本地持久化或内存储存,等到合适时机去上传埋点数据。 +埋伏模块分为2个pod组件库,`UserAnalysis` 负责拦截系统事件,拿到埋点数据。`AppMonitor` 负责收集埋点数据,本地持久化或内存储存,等到合适时机去上传埋点数据。 -1. 通过接口获取数据,给对应的 view 的 accessibilityIdentifier 属性绑定埋点数据 +1. 通过接口获取数据,给对应的 view 的 `accessibilityIdentifier` 属性绑定埋点数据 ![接口拿到的数据](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-04-05-userTrack1.png) ![绑定埋点数据到view](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-04-05-userTrack2.png) -2. hook 系统事件,点击拿到 view,获取 accessibilityIdentifier 属性值 +2. hook 系统事件,点击拿到 view,获取 `accessibilityIdentifier` 属性值 ![hook系统事件获取accessibilityIdentifier](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-04-05-userTrack3.png) -3. 将数据向的数据中心发送,数据中心处理数据(埋点数据结合App基础信息,图上 UserTrackDataCenter 对象)。根据情况将数据存储到内存或者本地,等到合适的时机去上传 +3. 将数据向的数据中心发送,数据中心处理数据(埋点数据结合App基础信息,图上 `UserTrackDataCenter` 对象)。根据情况将数据存储到内存或者本地,等到合适的时机去上传 ![拦截系统事件后将数据交给数据中心处理](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-04-05-userTrack4.png) \ No newline at end of file diff --git a/Chapter1 - iOS/1.74.md b/Chapter1 - iOS/1.74.md index 01b139d..b6a0f3a 100644 --- a/Chapter1 - iOS/1.74.md +++ b/Chapter1 - iOS/1.74.md @@ -365,7 +365,7 @@ dispatch_async(dispatch_get_global_queue(0, 0), ^{ #### 3.2 子线程 ping 主线程监听的方式 -开启一个子线程,创建一个初始值为0的信号量、一个初始值为 YES 的布尔值类型标志位。将设置标志位为 NO 的任务派发到主线程中去,子线程休眠阈值时间,时间到后判断标志位是否被主线程成功(值为 NO),如果没成功则认为猪线程发生了卡顿情况,此时 dump 堆栈信息并结合数据上报机制,按照一定策略上传数据到服务器。数据上报会在 [打造功能强大、灵活可配置的数据上报组件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) 讲 +开启一个子线程,创建一个初始值为0的信号量、一个初始值为 YES 的布尔值类型标志位。将设置标志位为 NO 的任务派发到主线程中去,子线程休眠阈值时间,时间到后判断标志位是否被主线程成功(值为 NO),如果没成功则认为主线程发生了卡顿情况,此时 dump 堆栈信息并结合数据上报机制,按照一定策略上传数据到服务器。数据上报会在 [打造功能强大、灵活可配置的数据上报组件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) 讲 ```Objective-c while (self.isCancelled == NO) { @@ -6578,3 +6578,5 @@ Crash log 统一入库 Kibana 时是没有符号化的,所以需要符号化 - [MDN-HTTP Messages](https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages) - [DWARF 和符号化](https://junyixie.github.io/2018/09/30/dwarf和符号化/) + + diff --git a/Chapter1 - iOS/1.80.md b/Chapter1 - iOS/1.80.md index cf6eb49..e98ec44 100644 --- a/Chapter1 - iOS/1.80.md +++ b/Chapter1 - iOS/1.80.md @@ -1,5 +1,2488 @@ -# 打造一个通用、可配置的数据上报 SDK +# 打造一个通用、可配置、多句柄的数据上报 SDK + +> 一个 App 一般会存在很多场景去上传 App 中产生的数据,比如 APM、埋点统计、开发者自定义的数据等等。所以本篇文章就讲讲如何设计一个通用的、可配置的、多句柄的数据上报 SDK。 +> + + + +## 前置说明 + +因为这篇文章和 APM 是属于姊妹篇,所以看这篇文章的时候有些东西不知道活着好奇的时候可以看[带你打造一套 APM 监控系统](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.74.md)。 + +另外看到我在下面的代码段,有些命名风格、简写、分类、方法的命名等,我简单做个说明。 + +- 数据上报 SDK 叫 `PrismClient`,我们规定类的命名一般用 SDK 的名字缩写,当前情况下缩写为 `PCT` +- 给 Category 命名,规则为 `类名 + SDK 前缀缩写的小写格式 + 下划线 + 驼峰命名格式的功能描述`。比如给 NSDate 增加一个获取毫秒时间戳的分类,那么类名为 `NSDate+pct_TimeStamp` +- 给 Category 的方法命名,规则为 `SDK 前缀缩写的小写格式 + 下划线 + 驼峰命名格式的功能描述`。比如给 NSDate 增加一个根据当前时间获取毫秒时间戳的方法,那么方法名为 `+ (long long)pct_currentTimestamp;` + + + +## 一、 首先定义需要做什么 + +我们要做的是「一个通用可配置、多句柄的数据上报 SDK」,也就是说这个 SDK 具有这么几个功能: + +- 具有从服务端拉取配置信息的能力,这些配置用来控制 SDK 的上报行为(需不需要默认行为?) +- SDK 具有多句柄特性,也就是拥有多个对象,每个对象具有自己的控制行为,彼此之间的运行、操作互相隔离 +- APM 监控作为非常特殊的能力存在,它也使用数据上报 SDK。它的能力是 App 质量监控的保障,所以针对 APM 的数据上报通道是需要特殊处理的。 +- 数据先根据配置决定要不要存,存下来之后再根据配置决定如何上报 + +明白我们需要做什么,接下来的步骤就是分析设计怎么做。 + + + +## 二、 拉取配置信息 + +### 1. 需要哪些配置信息 + +首先明确几个原则: + +- 因为监控数据上报作为数据上报的一个特殊 case,那么监控的配置信息也应该特殊处理。 +- 监控能力包含很多,比如卡顿、网络、奔溃、内存、电量、启动时间、CPU 使用率。每个监控能力都需要一份配置信息,比如监控类型、是否仅 WI-FI 环境下上报、是否实时上报、是否需要携带 Payload 数据。(注:Payload 其实就是经过 gZip 压缩、AES-CBC 加密后的数据) +- 多句柄,所以需要一个字段标识每份配置信息,也就是一个 namespace 的概念 +- 每个 namespace 下都有自己的配置,比如数据上传后的服务器地址、上报开关、App 升级后是否需要清除掉之前版本保存的数据、单次上传数据包的最大体积限制、数据记录的最大条数、在非 WI-FI 环境下每天上报的最大流量、数据过期天数、上报开关等 +- 针对 APM 的数据配置,还需要一个是否需要采集的开关。 + +所以数据字段基本如下 + +```objective-c +@interface PCTItemModel : NSObject + +@property (nonatomic, copy) NSString *type; /<上报数据类型*/ +@property (nonatomic, assign) BOOL onlyWifi; /<是否仅 Wi-Fi 上报*/ +@property (nonatomic, assign) BOOL isRealtime; /<是否实时上报*/ +@property (nonatomic, assign) BOOL isUploadPayload; /<是否需要上报 Payload*/ + +@end + +@interface PCTConfigurationModel : NSObject + +@property (nonatomic, copy) NSString *url; /<当前 namespace 对应的上报地址 */ +@property (nonatomic, assign) BOOL isUpload; /<全局上报开关*/ +@property (nonatomic, assign) BOOL isGather; /<全局采集开关*/ +@property (nonatomic, assign) BOOL isUpdateClear; /<升级后是否清除数据*/ +@property (nonatomic, assign) NSInteger maxBodyMByte; /<最大包体积单位 M (范围 < 3M)*/ +@property (nonatomic, assign) NSInteger periodicTimerSecond; /<定时上报时间单位秒 (范围1 ~ 30秒)*/ +@property (nonatomic, assign) NSInteger maxItem; /<最大条数 (范围 < 100)*/ +@property (nonatomic, assign) NSInteger maxFlowMByte; /<每天最大非 Wi-Fi 上传流量单位 M (范围 < 100M)*/ +@property (nonatomic, assign) NSInteger expirationDay; /<数据过期时间单位 天 (范围 < 30)*/ +@property (nonatomic, copy) NSArray *monitorList; /<配置项目*/ + +@end +``` + +因为数据需要持久化保存,所以需要实现 `NSCoding` 协议。 + +一个小窍门,每个属性写 `encode`、`decode` 会很麻烦,可以借助于宏来实现快速编写。 + +```objective-c +#define PCT_DECODE(decoder, dataType, keyName) \ +{ \ +_##keyName = [decoder decode##dataType##ForKey:NSStringFromSelector(@selector(keyName))]; \ +}; + +#define PCT_ENCODE(aCoder, dataType, key) \ +{ \ +[aCoder encode##dataType:_##key forKey:NSStringFromSelector(@selector(key))]; \ +}; + +- (instancetype)initWithCoder:(NSCoder *)aDecoder { + if (self = [super init]) { + PCT_DECODE(aDecoder, Object, type) + PCT_DECODE(aDecoder, Bool, onlyWifi) + PCT_DECODE(aDecoder, Bool, isRealtime) + PCT_DECODE(aDecoder, Bool, isUploadPayload) + } + return self; +} + +- (void)encodeWithCoder:(NSCoder *)aCoder { + PCT_ENCODE(aCoder, Object, type) + PCT_ENCODE(aCoder, Bool, onlyWifi) + PCT_ENCODE(aCoder, Bool, isRealtime) + PCT_ENCODE(aCoder, Bool, isUploadPayload) +} +``` + + + + + +抛出一个问题:既然监控很重要,那别要配置了,直接全部上传。 + +我们想一想这个问题,监控数据都是不直接上传的,监控 SDK 的责任就是收集监控数据,而且监控后的数据非常多,App 运行期间的网络请求可能都有 n 次,App 启动时间、卡顿、奔溃、内存等可能不多,但是这些数据直接上传后期拓展性非常差,比如根据 APM 监控大盘分析出某个监控能力暂时先关闭掉。这时候就无力回天了,必须等下次 SDK 发布新版本。监控数据必须先存储,假如 crash 了,则必须保存了数据等下次启动再去组装数据、上传。而且数据在消费、新数据在不断生产,假如上传失败了还需要对失败数据的处理,所以这些逻辑还是挺多的,对于监控 SDK 来做这个事情,不是很合适。答案就显而易见了,必须要配置(监控开关的配置、数据上报的行为配置)。 + + + +### 2. 默认配置 + +因为监控真的很特殊,App 一启动就需要去收集 App 的性能、质量相关数据,所以需要一份默认的配置信息。 + +```objective-c +// 初始化一份默认配置 +- (void)setDefaultConfigurationModel { + PCTConfigurationModel *configurationModel = [[PCTConfigurationModel alloc] init]; + configurationModel.url = @"https://.com"; + configurationModel.isUpload = YES; + configurationModel.isGather = YES; + configurationModel.isUpdateClear = YES; + configurationModel.periodicTimerSecond = 5; + configurationModel.maxBodyMByte = 1; + configurationModel.maxItem = 100; + configurationModel.maxFlowMByte = 20; + configurationModel.expirationDay = 15; + + PCTItemModel *appCrashItem = [[PCTItemModel alloc] init]; + appCrashItem.type = @"appCrash"; + appCrashItem.onlyWifi = NO; + appCrashItem.isRealtime = YES; + appCrashItem.isUploadPayload = YES; + + PCTItemModel *appLagItem = [[PCTItemModel alloc] init]; + appLagItem.type = @"appLag"; + appLagItem.onlyWifi = NO; + appLagItem.isRealtime = NO; + appLagItem.isUploadPayload = NO; + + PCTItemModel *appBootItem = [[PCTItemModel alloc] init]; + appBootItem.type = @"appBoot"; + appBootItem.onlyWifi = NO; + appBootItem.isRealtime = NO; + appBootItem.isUploadPayload = NO; + + PCTItemModel *netItem = [[PCTItemModel alloc] init]; + netItem.type = @"net"; + netItem.onlyWifi = NO; + netItem.isRealtime = NO; + netItem.isUploadPayload = NO; + + PCTItemModel *netErrorItem = [[PCTItemModel alloc] init]; + netErrorItem.type = @"netError"; + netErrorItem.onlyWifi = NO; + netErrorItem.isRealtime = NO; + netErrorItem.isUploadPayload = NO; + configurationModel.monitorList = @[appCrashItem, appLagItem, appBootItem, netItem, netErrorItem]; + self.configurationModel = configurationModel; +} +``` + +上面的例子是一份默认配置信息 + + + +### 3. 拉取策略 + +网络拉取使用了基础 SDK (非网络 SDK)的能力 mGet,根据 key 注册网络服务。这些 key 一般是 SDK 内部的定义好的,比如统跳路由表等。 + +这类 key 的共性是 App 在打包阶段会内置一份默认配置,App 启动后会去拉取最新数据,然后完成数据的缓存,缓存会在 `NSDocumentDirectory` 目录下按照 SDK 名称、 App 版本号、打包平台上分配的打包任务 id、 key 建立缓存文件夹。 + +此外它的特点是等 App 启动完成后才去请求网络,获取数据,不会影响 App 的启动。 + +流程图如下 + +![数据上报配置信息获取流程](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-29-DataUploadConfigurationStructure.png) + +下面是一个截取代码,对比上面图看看。 + +```objective-c +@synthesize configurationDictionary = _configurationDictionary; + +#pragma mark - Initial Methods + ++ (instancetype)sharedInstance { + static PCTConfigurationService *_sharedInstance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + _sharedInstance = [[self alloc] init]; + }); + return _sharedInstance; +} + +- (instancetype)init { + if (self = [super init]) { + [self setUp]; + } + return self; +} + +#pragma mark - public Method + +- (void)registerAndFetchConfigurationInfo { + __weak typeof(self) weakself = self; + NSDictionary *params = @{@"deviceId": [[PrismClient sharedInstance] getCommon].SYS_DEVICE_ID}; + + [self.requester fetchUploadConfigurationWithParams:params success:^(NSDictionary * _Nonnull configurationDictionary) { + weakself.configurationDictionary = configurationDictionary; + [NSKeyedArchiver archiveRootObject:configurationDictionary toFile:[self savedFilePath]]; + } failure:^(NSError * _Nonnull error) { + + }]; +} + +- (PCTConfigurationModel *)getConfigurationWithNamespace:(NSString *)namespace { + if (!PCT_IS_CLASS(namespace, NSString)) { + NSAssert(PCT_IS_CLASS(namespace, NSString), @"需要根据 namespace 参数获取对应的配置信息,所以必须是 NSString 类型"); + return nil; + } + if (namespace.length == 0) { + NSAssert(namespace.length > 0, @"需要根据 namespace 参数获取对应的配置信息,所以必须是非空的 NSString"); + return nil; + } + id configurationData = [self.configurationDictionary objectForKey:namespace]; + if (!configurationData) { + return nil; + } + if (!PCT_IS_CLASS(configurationData, NSDictionary)) { + return nil; + } + NSDictionary *configurationDictionary = (NSDictionary *)configurationData; + return [PCTConfigurationModel modelWithDictionary:configurationDictionary]; +} + + +#pragma mark - private method + +- (void)setUp { + // 创建数据保存的文件夹 + [[NSFileManager defaultManager] createDirectoryAtPath:[self configurationDataFilePath] withIntermediateDirectories:YES attributes:nil error:nil]; + [self setDefaultConfigurationModel]; + [self getConfigurationModelFromLocal]; +} + +- (NSString *)savedFilePath { + return [NSString stringWithFormat:@"%@/%@", [self configurationDataFilePath], PCT_CONFIGURATION_FILEPATH]; +} + +// 初始化一份默认配置 +- (void)setDefaultConfigurationModel { + PCTConfigurationModel *configurationModel = [[PCTConfigurationModel alloc] init]; + configurationModel.url = @"https://.com"; + configurationModel.isUpload = YES; + configurationModel.isGather = YES; + configurationModel.isUpdateClear = YES; + configurationModel.periodicTimerSecond = 5; + configurationModel.maxBodyMByte = 1; + configurationModel.maxItem = 100; + configurationModel.maxFlowMByte = 20; + configurationModel.expirationDay = 15; + + PCTItemModel *appCrashItem = [[PCTItemModel alloc] init]; + appCrashItem.type = @"appCrash"; + appCrashItem.onlyWifi = NO; + appCrashItem.isRealtime = YES; + appCrashItem.isUploadPayload = YES; + + PCTItemModel *appLagItem = [[PCTItemModel alloc] init]; + appLagItem.type = @"appLag"; + appLagItem.onlyWifi = NO; + appLagItem.isRealtime = NO; + appLagItem.isUploadPayload = NO; + + PCTItemModel *appBootItem = [[PCTItemModel alloc] init]; + appBootItem.type = @"appBoot"; + appBootItem.onlyWifi = NO; + appBootItem.isRealtime = NO; + appBootItem.isUploadPayload = NO; + + PCTItemModel *netItem = [[PCTItemModel alloc] init]; + netItem.type = @"net"; + netItem.onlyWifi = NO; + netItem.isRealtime = NO; + netItem.isUploadPayload = NO; + + PCTItemModel *netErrorItem = [[PCTItemModel alloc] init]; + netErrorItem.type = @"netError"; + netErrorItem.onlyWifi = NO; + netErrorItem.isRealtime = NO; + netErrorItem.isUploadPayload = NO; + configurationModel.monitorList = @[appCrashItem, appLagItem, appBootItem, netItem, netErrorItem]; + self.configurationModel = configurationModel; +} + +- (void)getConfigurationModelFromLocal { + id unarchiveObject = [NSKeyedUnarchiver unarchiveObjectWithFile:[self savedFilePath]]; + if (unarchiveObject) { + if (PCT_IS_CLASS(unarchiveObject, NSDictionary)) { + self.configurationDictionary = (NSDictionary *)unarchiveObject; + [self.configurationDictionary enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) { + if ([key isEqualToString:PRISMNAMESPACE]) { + if (PCT_IS_CLASS(obj, NSDictionary)) { + NSDictionary *configurationDictionary = (NSDictionary *)obj; + self.configurationModel = [PCTConfigurationModel modelWithDictionary:configurationDictionary]; + } + } + }]; + } + } +} + + +#pragma mark - getters and setters + +- (NSString *)configurationDataFilePath { + NSString *filePath = [NSString stringWithFormat:@"%@/%@/%@/%@", NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).firstObject, @"prism", [CMAppProfile sharedInstance].mAppVersion, [[PrismClient sharedInstance] getCommon].WAX_CANDLE_TASK_ID]; + return filePath; +} + +- (PCTRequestFactory *)requester { + if (!_requester) { + _requester = [[PCTRequestFactory alloc] init]; + } + return _requester; +} + +- (void)setConfigurationDictionary:(NSDictionary *)configurationDictionary +{ + @synchronized (self) { + _configurationDictionary = configurationDictionary; + } +} + +- (NSDictionary *)configurationDictionary +{ + @synchronized (self) { + if (_configurationDictionary == nil) { + NSDictionary *prismDictionary = [self.configurationModel getDictionary]; + _configurationDictionary = @{PRISMNAMESPACE: prismDictionary}; + } + return _configurationDictionary; + } +} + +@end +``` + + + +## 三、数据存储 + +### 1. 数据存储技术选型 + +记得在做数据上报技术的评审会议上,Android 同事说用 [WCDB](https://github.com/Tencent/wcdb),特色是 ORM、多线程安全、高性能。然后就被质疑了。因为上个版本使用的技术是基于系统自带的 sqlite2,单纯为了 ORM、多线程问题就额外引入一个三方库,是不太能说服人的。有这样几个疑问 + +- ORM 并不是核心诉求,利用 Runtime 可以在基础上进行修改,也可支持 ORM 功能 + +- 线程安全。WCDB 在线程安全的实现主要是基于`Handle`,`HandlePool` 和 `Database` 三个类完成的。`Handle` 是 sqlite3 指针,`HandlePool` 用来处理连接。 + + ```c++ + RecyclableHandle HandlePool::flowOut(Error &error) + { + m_rwlock.lockRead(); + std::shared_ptr handleWrap = m_handles.popBack(); + if (handleWrap == nullptr) { + if (m_aliveHandleCount < s_maxConcurrency) { + handleWrap = generate(error); + if (handleWrap) { + ++m_aliveHandleCount; + if (m_aliveHandleCount > s_hardwareConcurrency) { + WCDB::Error::Warning( + ("The concurrency of database:" + + std::to_string(tag.load()) + " with " + + std::to_string(m_aliveHandleCount) + + " exceeds the concurrency of hardware:" + + std::to_string(s_hardwareConcurrency)) + .c_str()); + } + } + } else { + Error::ReportCore( + tag.load(), path, Error::CoreOperation::FlowOut, + Error::CoreCode::Exceed, + "The concurrency of database exceeds the max concurrency", + &error); + } + } + if (handleWrap) { + handleWrap->handle->setTag(tag.load()); + if (invoke(handleWrap, error)) { + return RecyclableHandle( + handleWrap, [this](std::shared_ptr &handleWrap) { + flowBack(handleWrap); + }); + } + } + + handleWrap = nullptr; + m_rwlock.unlockRead(); + return RecyclableHandle(nullptr, nullptr); + } + + void HandlePool::flowBack(const std::shared_ptr &handleWrap) + { + if (handleWrap) { + bool inserted = m_handles.pushBack(handleWrap); + m_rwlock.unlockRead(); + if (!inserted) { + --m_aliveHandleCount; + } + } + } + ``` + + 所以 WCDB 连接池通过读写锁保证线程安全。所以之前版本的地方要实现线程安全修改下缺陷就可以。增加了 sqlite3,虽然看起来就是几兆大小,但是这对于公共团队是致命的。业务线开发者每次接入 SDK 会注意App 包体积的变化,为了数据上报增加好几兆,这是不可以接受的。 + +- 高性能的背后是 WCDB 自带的 sqlite3 开启了 `WAL模式` (Write-Ahead Logging)。当 WAL 文件超过 1000 个页大小时,SQLite3 会将 WAL 文件写会数据库文件。也就是 checkpointing。当大批量的数据写入场景时,如果不停提交文件到数据库事务,效率肯定低下,WCDB 的策略就是在触发 checkpoint 时,通过延时队列去处理,避免不停的触发 WalCheckpoint 调用。通过 `TimedQueue` 将同个数据库的 `WalCheckpoint` 合并延迟到2秒后执行 + + ```c++ + { + Database::defaultCheckpointConfigName, + [](std::shared_ptr &handle, Error &error) -> bool { + handle->registerCommittedHook( + [](Handle *handle, int pages, void *) { + static TimedQueue s_timedQueue(2); + if (pages > 1000) { + s_timedQueue.reQueue(handle->path); + } + static std::thread s_checkpointThread([]() { + pthread_setname_np( + ("WCDB-" + Database::defaultCheckpointConfigName) + .c_str()); + while (true) { + s_timedQueue.waitUntilExpired( + [](const std::string &path) { + Database database(path); + WCDB::Error innerError; + database.exec(StatementPragma().pragma( + Pragma::WalCheckpoint), + innerError); + }); + } + }); + static std::once_flag s_flag; + std::call_once(s_flag, + []() { s_checkpointThread.detach(); }); + }, + nullptr); + return true; + }, + (Configs::Order) Database::ConfigOrder::Checkpoint, + }, + ``` + + + + + +一般来说公共组做事情,SDK 命名、接口名称、接口个数、参数个数、参数名称、参数数据类型是严格一致的,差异是语言而已。实在万不得已,能力不能堆砌的情况下是可以不一致的,但是需要在技术评审会议上说明原因,需要在发布文档、接入文档都有所体现。 + + + +所以最后的结论是在之前的版本基础上进行修改,之前的版本是 FMDB。 + + + +### 2. 数据库维护队列 + +#### 1. FMDB 队列 + +`FMDB` 使用主要是通过 `FMDatabaseQueue` 的 `- (void)inTransaction:(__attribute__((noescape)) void (^)(FMDatabase *db, BOOL *rollback))block` 和 `- (void)inDatabase:(__attribute__((noescape)) void (^)(FMDatabase *db))block`。这2个方法的实现如下 + +```objective-c +- (void)inDatabase:(__attribute__((noescape)) void (^)(FMDatabase *db))block { +#ifndef NDEBUG + /* Get the currently executing queue (which should probably be nil, but in theory could be another DB queue + * and then check it against self to make sure we're not about to deadlock. */ + FMDatabaseQueue *currentSyncQueue = (__bridge id)dispatch_get_specific(kDispatchQueueSpecificKey); + assert(currentSyncQueue != self && "inDatabase: was called reentrantly on the same queue, which would lead to a deadlock"); +#endif + + FMDBRetain(self); + + dispatch_sync(_queue, ^() { + + FMDatabase *db = [self database]; + + block(db); + + if ([db hasOpenResultSets]) { + NSLog(@"Warning: there is at least one open result set around after performing [FMDatabaseQueue inDatabase:]"); + +#if defined(DEBUG) && DEBUG + NSSet *openSetCopy = FMDBReturnAutoreleased([[db valueForKey:@"_openResultSets"] copy]); + for (NSValue *rsInWrappedInATastyValueMeal in openSetCopy) { + FMResultSet *rs = (FMResultSet *)[rsInWrappedInATastyValueMeal pointerValue]; + NSLog(@"query: '%@'", [rs query]); + } +#endif + } + }); + + FMDBRelease(self); +} +``` + +```objective-c +- (void)inTransaction:(__attribute__((noescape)) void (^)(FMDatabase *db, BOOL *rollback))block { + [self beginTransaction:FMDBTransactionExclusive withBlock:block]; +} + +- (void)beginTransaction:(FMDBTransaction)transaction withBlock:(void (^)(FMDatabase *db, BOOL *rollback))block { + FMDBRetain(self); + dispatch_sync(_queue, ^() { + + BOOL shouldRollback = NO; + + switch (transaction) { + case FMDBTransactionExclusive: + [[self database] beginTransaction]; + break; + case FMDBTransactionDeferred: + [[self database] beginDeferredTransaction]; + break; + case FMDBTransactionImmediate: + [[self database] beginImmediateTransaction]; + break; + } + + block([self database], &shouldRollback); + + if (shouldRollback) { + [[self database] rollback]; + } + else { + [[self database] commit]; + } + }); + + FMDBRelease(self); +} +``` + +上面的 `_queue` 其实是一个串行队列,通过 `_queue = dispatch_queue_create([[NSString stringWithFormat:@"fmdb.%@", self] UTF8String], NULL);` 创建。所以,`FMDB` 的核心就是以同步的形式向串行队列提交任务,来保证多线程操作下的读写问题(比每个操作加锁效率高很多)。只有一个任务执行完毕,才可以执行下一个任务。 + +上一个版本的数据上报 SDK 功能比较简单,就是上报 APM 监控后的数据,所以数据量不会很大,之前的人封装超级简单,仅以事务的形式封装了一层 FMDB 的增删改查操作。那么就会有一个问题。假如 SDK 被业务线接入,业务线开发者不知道数据上报 SDK 的内部实现,直接调用接口去写入大量数据,结果 App 发生了卡顿,那不得反馈你这个 SDK 超级难用啊。 + + + +#### 2. 针对 FMDB 的改进 + +改法也比较简单,我们先弄清楚 `FMDB` 这样设计的原因。数据库操作的环境可能是主线程、子线程等不同环境去修改数据,主线程、子线程去读取数据,所以创建了一个串行队列去执行真正的数据增删改查。 + +目的就是让不同线程去使用 `FMDB` 的时候不会阻塞当前线程。既然 `FMDB` 内部维护了一个串行队列去处理多线程情况下的数据操作,那么改法也比较简单,那就是创建一个并发队列,然后以异步的方式提交任务到 `FMDB` 中去,`FMDB` 内部的串行队列去执行真正的任务。 + +代码如下 + +```objective-c +// 创建队列 +self.dbOperationQueue = dispatch_queue_create(PCT_DATABASE_OPERATION_QUEUE, DISPATCH_QUEUE_CONCURRENT); +self.dbQueue = [FMDatabaseQueue databaseQueueWithPath:[PCTDatabase databaseFilePath]]; + +// 以删除数据为例,以异步任务的方式向并发队列提交任务,任务内部调用 FMDatabaseQueue 去串行执行每个任务 +- (void)removeAllLogsInTableType:(PCTLogTableType)tableType { + [self isExistInTable:tableType]; + __weak typeof(self) weakself = self; + dispatch_async(self.dbOperationQueue, ^{ + NSString *tableName = PCTGetTableNameFromType(tableType); + [weakself removeAllLogsInTable:tableName]; + }); +} + +- (void)removeAllLogsInTable:(NSString *)tableName { + NSString *sqlString = [NSString stringWithFormat:@"delete from %@", tableName]; + [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { + [db executeUpdate:sqlString]; + }]; +} +``` + +小实验模拟下流程 + +```objective-c +sleep(1); +NSLog(@"1"); +dispatch_queue_t concurrentQueue = dispatch_queue_create("PCT_DATABASE_OPERATION_QUEUE", DISPATCH_QUEUE_CONCURRENT); +dispatch_async(concurrentQueue, ^{ + sleep(2); + NSLog(@"2"); +}); +sleep(1); +NSLog(@"3"); +dispatch_async(concurrentQueue, ^{ + sleep(3); + NSLog(@"4"); +}); +sleep(1); +NSLog(@"5"); + +2020-07-01 13:28:13.610575+0800 Test[54460:1557233] 1 +2020-07-01 13:28:14.611937+0800 Test[54460:1557233] 3 +2020-07-01 13:28:15.613347+0800 Test[54460:1557233] 5 +2020-07-01 13:28:15.613372+0800 Test[54460:1557280] 2 +2020-07-01 13:28:17.616837+0800 Test[54460:1557277] 4 +``` + + + +![MainThread Dispatch Async Task To ConcurrentQueue](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-07-01-MainThreadDispatchTaskToConcurrentQueue.png) + + + +### 3. 数据表设计 + +通用的数据上报 SDK 的功能是数据的保存和上报。从数据的角度来划分,数据可以分为 APM 监控数据和业务线的业务数据。 + +数据各有什么特点呢?APM 监控数据一般可以划分为:基本信息、异常信息、线程信息,也就是最大程度的还原案发线程的数据。业务线数据基本上不会有所谓的大量数据,最多就是数据条数非常多。鉴于此现状,可以将数据表设计为 **meta 表**、**payload 表**。meta 表用来存放 APM 的基础数据和业务线的数据,payload 表用来存放 APM 的线程堆栈数据。 + +数据表的设计是基于业务情况的。那有这样几个背景 + +- APM 监控数据需要报警(具体可以查看 APM 文章,地址在开头 ),所以数据上报 SDK 上报后的数据需要实时解析 +- 产品侧比如监控大盘可以慢,所以符号化系统是异步的 +- 监控数据实在太大了,如果同步解析会因为压力较大造成性能瓶颈 + +所以把监控数据拆分为2块,即 meta 表、payload 表。meta 表相当于记录索引信息,服务端只需要关心这个。而 payload 数据在服务端是不会处理的,会有一个异步服务单独处理。 + +meta 表、payload 表结构如下: + +```sql +create table if not exists ***_meta (id integer NOT NULL primary key autoincrement, report_id text, monitor_type text, is_biz integer NOT NULL, created_time datetime, meta text, namespace text, is_used integer NOT NULL, size integer NOT NULL); + +create table if not exists ***_payload (id integer NOT NULL primary key autoincrement, report_id text, monitor_type text, is_biz integer NOT NULL, created_time datetime, meta text, payload blob, namespace text, is_used integer NOT NULL, size integer NOT NULL); +``` + + + +### 4. 数据库表的封装 + +```objective-c +#import "PCTDatabase.h" +#import + +static NSString *const PCT_LOG_DATABASE_NAME = @"***.db"; +static NSString *const PCT_LOG_TABLE_META = @"***_prism_meta"; +static NSString *const PCT_LOG_TABLE_PAYLOAD = @"***_prism_payload"; +const char *PCT_DATABASE_OPERATION_QUEUE = "com.***.pct_database_operation_QUEUE"; + +@interface PCTDatabase () + +@property (nonatomic, strong) dispatch_queue_t dbOperationQueue; +@property (nonatomic, strong) FMDatabaseQueue *dbQueue; +@property (nonatomic, strong) NSDateFormatter *dateFormatter; + +@end + +@implementation PCTDatabase + +#pragma mark - life cycle ++ (instancetype)sharedInstance { + static PCTDatabase *_sharedInstance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + _sharedInstance = [[self alloc] init]; + }); + return _sharedInstance; +} + +- (instancetype)init { + self = [super init]; + self.dateFormatter = [[NSDateFormatter alloc] init]; + [self.dateFormatter setDateFormat:@"yyyy-MM-dd HH:mm:ss A Z"]; + self.dbOperationQueue = dispatch_queue_create(PCT_DATABASE_OPERATION_QUEUE, DISPATCH_QUEUE_CONCURRENT); + self.dbQueue = [FMDatabaseQueue databaseQueueWithPath:[PCTDatabase databaseFilePath]]; + [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { + [self createLogMetaTableIfNotExist:db]; + [self createLogPayloadTableIfNotExist:db]; + }]; + return self; +} + +#pragma mark - public Method + +- (void)add:(NSArray *)logs inTableType:(PCTLogTableType)tableType { + [self isExistInTable:tableType]; + __weak typeof(self) weakself = self; + dispatch_async(self.dbOperationQueue, ^{ + NSString *tableName = PCTGetTableNameFromType(tableType); + [weakself add:logs inTable:tableName]; + }); +} + +- (void)remove:(NSArray *)logs inTableType:(PCTLogTableType)tableType { + [self isExistInTable:tableType]; + __weak typeof(self) weakself = self; + dispatch_async(self.dbOperationQueue, ^{ + NSString *tableName = PCTGetTableNameFromType(tableType); + [weakself remove:logs inTable:tableName]; + }); +} + +- (void)removeAllLogsInTableType:(PCTLogTableType)tableType { + [self isExistInTable:tableType]; + __weak typeof(self) weakself = self; + dispatch_async(self.dbOperationQueue, ^{ + NSString *tableName = PCTGetTableNameFromType(tableType); + [weakself removeAllLogsInTable:tableName]; + }); +} + +- (void)removeOldestRecordsByCount:(NSInteger)count inTableType:(PCTLogTableType)tableType { + [self isExistInTable:tableType]; + __weak typeof(self) weakself = self; + dispatch_async(self.dbOperationQueue, ^{ + NSString *tableName = PCTGetTableNameFromType(tableType); + [weakself removeOldestRecordsByCount:count inTable:tableName]; + }); +} + +- (void)removeLatestRecordsByCount:(NSInteger)count inTableType:(PCTLogTableType)tableType { + [self isExistInTable:tableType]; + __weak typeof(self) weakself = self; + dispatch_async(self.dbOperationQueue, ^{ + NSString *tableName = PCTGetTableNameFromType(tableType); + [weakself removeLatestRecordsByCount:count inTable:tableName]; + }); +} + +- (void)removeRecordsBeforeDays:(NSInteger)day inTableType:(PCTLogTableType)tableType { + [self isExistInTable:tableType]; + __weak typeof(self) weakself = self; + dispatch_async(self.dbOperationQueue, ^{ + NSString *tableName = PCTGetTableNameFromType(tableType); + [weakself removeRecordsBeforeDays:day inTable:tableName]; + }); + [self rebuildDatabaseFileInTableType:tableType]; +} + +- (void)removeDataUseCondition:(NSString *)condition inTableType:(PCTLogTableType)tableType { + if (!PCT_IS_CLASS(condition, NSString)) { + NSAssert(PCT_IS_CLASS(condition, NSString), @"自定义删除条件必须是字符串类型"); + return; + } + if (condition.length == 0) { + NSAssert(!(condition.length == 0), @"自定义删除条件不能为空"); + return; + } + [self isExistInTable:tableType]; + __weak typeof(self) weakself = self; + dispatch_async(self.dbOperationQueue, ^{ + NSString *tableName = PCTGetTableNameFromType(tableType); + [weakself removeDataUseCondition:condition inTable:tableName]; + }); +} + +- (void)updateData:(NSString *)state useCondition:(NSString *)condition inTableType:(PCTLogTableType)tableType { + if (!PCT_IS_CLASS(state, NSString)) { + NSAssert(PCT_IS_CLASS(state, NSString), @"数据表字段更改命令必须是合法字符串"); + return; + } + if (state.length == 0) { + NSAssert(!(state.length == 0), @"数据表字段更改命令必须是合法字符串"); + return; + } + + if (!PCT_IS_CLASS(condition, NSString)) { + NSAssert(PCT_IS_CLASS(condition, NSString), @"数据表字段更改条件必须是字符串类型"); + return; + } + if (condition.length == 0) { + NSAssert(!(condition.length == 0), @"数据表字段更改条件不能为空"); + return; + } + [self isExistInTable:tableType]; + __weak typeof(self) weakself = self; + dispatch_async(self.dbOperationQueue, ^{ + NSString *tableName = PCTGetTableNameFromType(tableType); + [weakself updateData:state useCondition:condition inTable:tableName]; + }); +} + +- (void)recordsCountInTableType:(PCTLogTableType)tableType completion:(void (^)(NSInteger count))completion { + [self isExistInTable:tableType]; + __weak typeof(self) weakself = self; + dispatch_async(self.dbOperationQueue, ^{ + NSString *tableName = PCTGetTableNameFromType(tableType); + NSInteger recordsCount = [weakself recordsCountInTable:tableName]; + if (completion) { + completion(recordsCount); + } + }); +} + +- (void)getLatestRecoreds:(NSInteger)count inTableType:(PCTLogTableType)tableType completion:(void (^)(NSArray *records))completion { + [self isExistInTable:tableType]; + __weak typeof(self) weakself = self; + dispatch_async(self.dbOperationQueue, ^{ + NSString *tableName = PCTGetTableNameFromType(tableType); + NSArray *records = [weakself getLatestRecoreds:count inTable:tableName]; + if (completion) { + completion(records); + } + }); +} + +- (void)getOldestRecoreds:(NSInteger)count inTableType:(PCTLogTableType)tableType completion:(void (^)(NSArray *records))completion { + [self isExistInTable:tableType]; + __weak typeof(self) weakself = self; + dispatch_async(self.dbOperationQueue, ^{ + NSString *tableName = PCTGetTableNameFromType(tableType); + NSArray *records = [weakself getOldestRecoreds:count inTable:tableName]; + if (completion) { + completion(records); + } + }); +} + +- (void)getRecordsByCount:(NSInteger)count condtion:(NSString *)condition inTableType:(PCTLogTableType)tableType completion:(void (^)(NSArray *records))completion { + if (!PCT_IS_CLASS(condition, NSString)) { + NSAssert(PCT_IS_CLASS(condition, NSString), @"自定义查询条件必须是字符串类型"); + if (completion) { + completion(nil); + } + } + if (condition.length == 0) { + NSAssert(!(condition.length == 0), @"自定义查询条件不能为空"); + if (completion) { + completion(nil); + } + } + [self isExistInTable:tableType]; + __weak typeof(self) weakself = self; + dispatch_async(self.dbOperationQueue, ^{ + NSString *tableName = PCTGetTableNameFromType(tableType); + NSArray *records = [weakself getRecordsByCount:count condtion:condition inTable:tableName]; + if (completion) { + completion(records); + } + }); +} + +- (void)rebuildDatabaseFileInTableType:(PCTLogTableType)tableType { + __weak typeof(self) weakself = self; + dispatch_async(self.dbOperationQueue, ^{ + NSString *tableName = PCTGetTableNameFromType(tableType); + [weakself rebuildDatabaseFileInTable:tableName]; + }); +} + +#pragma mark - CMDatabaseDelegate + +- (void)add:(NSArray *)logs inTable:(NSString *)tableName { + if (logs.count == 0) { + return; + } + __weak typeof(self) weakself = self; + [self.dbQueue inTransaction:^(FMDatabase *_Nonnull db, BOOL *_Nonnull rollback) { + [db setDateFormat:weakself.dateFormatter]; + for (NSInteger index = 0; index < logs.count; index++) { + id obj = logs[index]; + // meta 类型数据的处理逻辑 + if (PCT_IS_CLASS(obj, PCTLogMetaModel)) { + PCTLogMetaModel *model = (PCTLogMetaModel *)obj; + if (model.monitor_type == nil || model.meta == nil || model.created_time == nil || model.namespace == nil) { + PCTLOG(@"参数错误 { monitor_type: %@, meta: %@, created_time: %@, namespace: %@}", model.monitor_type, model.meta, model.created_time, model.namespace); + return; + } + + NSString *sqlString = [NSString stringWithFormat:@"insert into %@ (report_id, monitor_type, is_biz, created_time, meta, namespace, is_used, size) values (?, ?, ?, ?, ?, ?, ?, ?)", tableName]; + [db executeUpdate:sqlString withArgumentsInArray:@[model.report_id, model.monitor_type, @(model.is_biz), model.created_time, model.meta, model.namespace, @(model.is_used), @(model.size)]]; + } + + // payload 类型数据的处理逻辑 + if (PCT_IS_CLASS(obj, PCTLogPayloadModel)) { + PCTLogPayloadModel *model = (PCTLogPayloadModel *)obj; + if (model.monitor_type == nil || model.meta == nil || model.created_time == nil || model.namespace == nil) { + PCTLOG(@"参数错误 { monitor_type: %@, meta: %@, created_time: %@, namespace: %@}", model.monitor_type, model.meta, model.created_time, model.namespace); + return; + } + + NSString *sqlString = [NSString stringWithFormat:@"insert into %@ (report_id, monitor_type, is_biz, created_time, meta, payload, namespace, is_used, size) values (?, ?, ?, ?, ?, ?, ?, ?, ?)", tableName]; + [db executeUpdate:sqlString withArgumentsInArray:@[model.report_id, model.monitor_type, @(model.is_biz), model.created_time, model.meta, model.payload ?: [NSData data], model.namespace, @(model.is_used), @(model.size)]]; + } + } + }]; +} + +- (NSInteger)remove:(NSArray *)logs inTable:(NSString *)tableName { + NSString *sqlString = [NSString stringWithFormat:@"delete from %@ where report_id = ?", tableName]; + [self.dbQueue inTransaction:^(FMDatabase *_Nonnull db, BOOL *_Nonnull rollback) { + [logs enumerateObjectsUsingBlock:^(PCTLogModel *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { + [db executeUpdate:sqlString withArgumentsInArray:@[obj.report_id]]; + }]; + }]; + return 0; +} + +- (void)removeAllLogsInTable:(NSString *)tableName { + NSString *sqlString = [NSString stringWithFormat:@"delete from %@", tableName]; + [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { + [db executeUpdate:sqlString]; + }]; +} + +- (void)removeOldestRecordsByCount:(NSInteger)count inTable:(NSString *)tableName { + NSString *sqlString = [NSString stringWithFormat:@"delete from %@ where id in (select id from %@ order by created_time asc limit ? )", tableName, tableName]; + [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { + [db executeUpdate:sqlString withArgumentsInArray:@[@(count)]]; + }]; +} + +- (void)removeLatestRecordsByCount:(NSInteger)count inTable:(NSString *)tableName { + NSString *sqlString = [NSString stringWithFormat:@"delete from %@ where id in (select id from %@ order by created_time desc limit ?)", tableName, tableName]; + [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { + [db executeUpdate:sqlString withArgumentsInArray:@[@(count)]]; + }]; +} + +- (void)removeRecordsBeforeDays:(NSInteger)day inTable:(NSString *)tableName { + // 找出从create到现在已经超过最大 day 天的数据,然后删除 :delete from ***_prism_meta where strftime('%s', date('now', '-2 day')) >= created_time; + NSString *sqlString = [NSString stringWithFormat:@"delete from %@ where strftime('%%s', date('now', '-%zd day')) >= created_time", tableName, day]; + [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { + [db executeUpdate:sqlString]; + }]; +} + +- (void)removeDataUseCondition:(NSString *)condition inTable:(NSString *)tableName { + NSString *sqlString = [NSString stringWithFormat:@"delete from %@ where %@", tableName, condition]; + [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { + [db executeUpdate:sqlString]; + }]; +} + +- (void)updateData:(NSString *)state useCondition:(NSString *)condition inTable:(NSString *)tableName +{ + NSString *sqlString = [NSString stringWithFormat:@"update %@ set %@ where %@", tableName, state, condition]; + [self.dbQueue inDatabase:^(FMDatabase * _Nonnull db) { + BOOL res = [db executeUpdate:sqlString]; + PCTLOG(res ? @"更新成功" : @"更新失败"); + }]; +} + +- (NSInteger)recordsCountInTable:(NSString *)tableName { + NSString *sqlString = [NSString stringWithFormat:@"select count(*) as count from %@", tableName]; + __block NSInteger recordsCount = 0; + [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { + FMResultSet *resultSet = [db executeQuery:sqlString]; + [resultSet next]; + recordsCount = [resultSet intForColumn:@"count"]; + [resultSet close]; + }]; + return recordsCount; +} + +- (NSArray *)getLatestRecoreds:(NSInteger)count inTable:(NSString *)tableName { + __block NSMutableArray *records = [NSMutableArray new]; + NSString *sql = [NSString stringWithFormat:@"select * from %@ order by created_time desc limit ?", tableName]; + + __weak typeof(self) weakself = self; + [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { + [db setDateFormat:weakself.dateFormatter]; + FMResultSet *resultSet = [db executeQuery:sql withArgumentsInArray:@[@(count)]]; + while ([resultSet next]) { + if ([tableName isEqualToString:PCT_LOG_TABLE_META]) { + PCTLogMetaModel *model = [[PCTLogMetaModel alloc] init]; + model.log_id = [resultSet intForColumn:@"id"]; + model.report_id = [resultSet stringForColumn:@"report_id"]; + model.monitor_type = [resultSet stringForColumn:@"monitor_type"]; + model.created_time = [resultSet stringForColumn:@"created_time"]; + model.meta = [resultSet stringForColumn:@"meta"]; + model.namespace = [resultSet stringForColumn:@"namespace"]; + model.size = [resultSet intForColumn:@"size"]; + model.is_biz = [resultSet boolForColumn:@"is_biz"]; + model.is_used = [resultSet boolForColumn:@"is_used"]; + [records addObject:model]; + + } else if ([tableName isEqualToString:PCT_LOG_TABLE_PAYLOAD]) { + PCTLogPayloadModel *model = [[PCTLogPayloadModel alloc] init]; + model.log_id = [resultSet intForColumn:@"id"]; + model.report_id = [resultSet stringForColumn:@"report_id"]; + model.monitor_type = [resultSet stringForColumn:@"monitor_type"]; + model.created_time = [resultSet stringForColumn:@"created_time"]; + model.meta = [resultSet stringForColumn:@"meta"]; + model.payload = [resultSet dataForColumn:@"payload"]; + model.namespace = [resultSet stringForColumn:@"namespace"]; + model.size = [resultSet intForColumn:@"size"]; + model.is_biz = [resultSet boolForColumn:@"is_biz"]; + model.is_used = [resultSet boolForColumn:@"is_used"]; + [records addObject:model]; + } + } + [resultSet close]; + }]; + return records; +} + +- (NSArray *)getOldestRecoreds:(NSInteger)count inTable:(NSString *)tableName { + __block NSMutableArray *records = [NSMutableArray array]; + NSString *sqlString = [NSString stringWithFormat:@"select * from %@ order by created_time asc limit ?", tableName]; + + __weak typeof(self) weakself = self; + [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { + [db setDateFormat:weakself.dateFormatter]; + + FMResultSet *resultSet = [db executeQuery:sqlString withArgumentsInArray:@[@(count)]]; + while ([resultSet next]) { + if ([tableName isEqualToString:PCT_LOG_TABLE_META]) { + PCTLogMetaModel *model = [[PCTLogMetaModel alloc] init]; + model.log_id = [resultSet intForColumn:@"id"]; + model.report_id = [resultSet stringForColumn:@"report_id"]; + model.monitor_type = [resultSet stringForColumn:@"monitor_type"]; + model.created_time = [resultSet stringForColumn:@"created_time"]; + model.meta = [resultSet stringForColumn:@"meta"]; + model.namespace = [resultSet stringForColumn:@"namespace"]; + model.size = [resultSet intForColumn:@"size"]; + model.is_biz = [resultSet boolForColumn:@"is_biz"]; + model.is_used = [resultSet boolForColumn:@"is_used"]; + [records addObject:model]; + + } else if ([tableName isEqualToString:PCT_LOG_TABLE_PAYLOAD]) { + PCTLogPayloadModel *model = [[PCTLogPayloadModel alloc] init]; + model.log_id = [resultSet intForColumn:@"id"]; + model.report_id = [resultSet stringForColumn:@"report_id"]; + model.monitor_type = [resultSet stringForColumn:@"monitor_type"]; + model.created_time = [resultSet stringForColumn:@"created_time"]; + model.meta = [resultSet stringForColumn:@"meta"]; + model.payload = [resultSet dataForColumn:@"payload"]; + model.namespace = [resultSet stringForColumn:@"namespace"]; + model.size = [resultSet intForColumn:@"size"]; + model.is_used = [resultSet boolForColumn:@"is_used"]; + model.is_biz = [resultSet boolForColumn:@"is_biz"]; + [records addObject:model]; + } + } + [resultSet close]; + }]; + return records; +} + +- (NSArray *)getRecordsByCount:(NSInteger)count condtion:(NSString *)condition inTable:(NSString *)tableName { + __block NSMutableArray *records = [NSMutableArray array]; + __weak typeof(self) weakself = self; + NSString *sqlString = [NSString stringWithFormat:@"select * from %@ where %@ order by created_time desc limit %zd", tableName, condition, count]; + [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { + [db setDateFormat:weakself.dateFormatter]; + + FMResultSet *resultSet = [db executeQuery:sqlString]; + + while ([resultSet next]) { + if ([tableName isEqualToString:PCT_LOG_TABLE_META]) { + PCTLogMetaModel *model = [[PCTLogMetaModel alloc] init]; + model.log_id = [resultSet intForColumn:@"id"]; + model.report_id = [resultSet stringForColumn:@"report_id"]; + model.monitor_type = [resultSet stringForColumn:@"monitor_type"]; + model.created_time = [resultSet stringForColumn:@"created_time"]; + model.meta = [resultSet stringForColumn:@"meta"]; + model.namespace = [resultSet stringForColumn:@"namespace"]; + model.size = [resultSet intForColumn:@"size"]; + model.is_biz = [resultSet boolForColumn:@"is_biz"]; + model.is_used = [resultSet boolForColumn:@"is_used"]; + [records addObject:model]; + + } else if ([tableName isEqualToString:PCT_LOG_TABLE_PAYLOAD]) { + PCTLogPayloadModel *model = [[PCTLogPayloadModel alloc] init]; + model.log_id = [resultSet intForColumn:@"id"]; + model.report_id = [resultSet stringForColumn:@"report_id"]; + model.monitor_type = [resultSet stringForColumn:@"monitor_type"]; + model.created_time = [resultSet stringForColumn:@"created_time"]; + model.meta = [resultSet stringForColumn:@"meta"]; + model.payload = [resultSet dataForColumn:@"payload"]; + model.namespace = [resultSet stringForColumn:@"namespace"]; + model.size = [resultSet intForColumn:@"size"]; + model.is_biz = [resultSet boolForColumn:@"is_biz"]; + model.is_used = [resultSet boolForColumn:@"is_used"]; + [records addObject:model]; + } + } + [resultSet close]; + }]; + return records; +} + +- (void)rebuildDatabaseFileInTable:(NSString *)tableName { + NSString *sqlString = [NSString stringWithFormat:@"vacuum %@", tableName]; + [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { + [db executeUpdate:sqlString]; + }]; +} + +#pragma mark - private method + ++ (NSString *)databaseFilePath { + NSString *docsPath = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)[0]; + NSString *dbPath = [docsPath stringByAppendingPathComponent:PCT_LOG_DATABASE_NAME]; + PCTLOG(@"上报系统数据库文件位置 -> %@", dbPath); + return dbPath; +} + +- (void)createLogMetaTableIfNotExist:(FMDatabase *)db { + NSString *createMetaTableSQL = [NSString stringWithFormat:@"create table if not exists %@ (id integer NOT NULL primary key autoincrement, report_id text, monitor_type text, is_biz integer NOT NULL, created_time datetime, meta text, namespace text, is_used integer NOT NULL, size integer NOT NULL)", PCT_LOG_TABLE_META]; + BOOL result = [db executeStatements:createMetaTableSQL]; + PCTLOG(@"确认日志Meta表是否存在 -> %@", result ? @"成功" : @"失败"); +} + +- (void)createLogPayloadTableIfNotExist:(FMDatabase *)db { + NSString *createMetaTableSQL = [NSString stringWithFormat:@"create table if not exists %@ (id integer NOT NULL primary key autoincrement, report_id text, monitor_type text, is_biz integer NOT NULL, created_time datetime, meta text, payload blob, namespace text, is_used integer NOT NULL, size integer NOT NULL)", PCT_LOG_TABLE_PAYLOAD]; + BOOL result = [db executeStatements:createMetaTableSQL]; + PCTLOG(@"确认日志Payload表是否存在 -> %@", result ? @"成功" : @"失败"); +} + +NS_INLINE NSString *PCTGetTableNameFromType(PCTLogTableType type) { + if (type == PCTLogTableTypeMeta) { + return PCT_LOG_TABLE_META; + } + if (type == PCTLogTableTypePayload) { + return PCT_LOG_TABLE_PAYLOAD; + } + return @""; +} + +// 每次操作前检查数据库以及数据表是否存在,不存在则创建数据库和数据表 +- (void)isExistInTable:(PCTLogTableType)tableType { + NSString *databaseFilePath = [PCTDatabase databaseFilePath]; + BOOL isExist = [[NSFileManager defaultManager] fileExistsAtPath:databaseFilePath]; + if (!isExist) { + self.dbQueue = [FMDatabaseQueue databaseQueueWithPath:[PCTDatabase databaseFilePath]]; + } + [self.dbQueue inDatabase:^(FMDatabase *db) { + NSString *tableName = PCTGetTableNameFromType(tableType); + BOOL res = [db tableExists:tableName]; + if (!res) { + if (tableType == PCTLogTableTypeMeta) { + [self createLogMetaTableIfNotExist:db]; + } + if (tableType == PCTLogTableTypeMeta) { + [self createLogPayloadTableIfNotExist:db]; + } + } + }]; +} + +@end +``` + + + +### 5. 数据存储流程 + + APM 监控数据会比较特殊点,比如 iOS 当发生 crash 后是没办法上报的,只有将 crash 信息保存到文件中,下次 App 启动后读取 crash 日志文件夹再去交给数据上报 SDK。Android 在发生 crash 后由于机制不一样,可以马上将 crash 信息交给数据上报 SDK。 + +由于 payload 数据,也就是堆栈数据非常大,所以上报的接口也有限制,一次上传接口中报文最大包体积的限制等等。 + +可以看一下 Model 信息, + +```objective-c +@interface PCTItemModel : NSObject + +@property (nonatomic, copy) NSString *type; /**<上报数据类型*/ +@property (nonatomic, assign) BOOL onlyWifi; /**<是否仅 Wi-Fi 上报*/ +@property (nonatomic, assign) BOOL isRealtime; /**<是否实时上报*/ +@property (nonatomic, assign) BOOL isUploadPayload; /**<是否需要上报 Payload*/ + +@end + +@interface PCTConfigurationModel : NSObject + +@property (nonatomic, copy) NSString *url; /**<当前 namespace 对应的上报地址 */ +@property (nonatomic, assign) BOOL isUpload; /**<全局上报开关*/ +@property (nonatomic, assign) BOOL isGather; /**<全局采集开关*/ +@property (nonatomic, assign) BOOL isUpdateClear; /**<升级后是否清除数据*/ +@property (nonatomic, assign) NSInteger maxBodyMByte; /**<最大包体积单位 M (范围 < 3M)*/ +@property (nonatomic, assign) NSInteger periodicTimerSecond; /**<定时上报时间单位秒 (范围1 ~ 30秒)*/ +@property (nonatomic, assign) NSInteger maxItem; /**<最大条数 (范围 < 100)*/ +@property (nonatomic, assign) NSInteger maxFlowMByte; /**<每天最大非 Wi-Fi 上传流量单位 M (范围 < 100M)*/ +@property (nonatomic, assign) NSInteger expirationDay; /**<数据过期时间单位 天 (范围 < 30)*/ +@property (nonatomic, copy) NSArray *monitorList; /**<配置项目*/ + +@end +``` + +监控数据存储流程: + +1. 每个数据(监控数据、业务线数据)过来先判断该数据所在的 namespace 是否开启了收集开关 + +2. 判断数据是否可以落库,根据数据接口中 type 能否命中上报配置数据中的 monitorList 中的任何一项的 type + +3. 监控数据先写入 meta 表,然后判断是否写入 payload 表。判断标准是计算监控数据的 payload 大小是否超过了上报配置数据的 `maxBodyMByte`。超过大小的数据就不能入库,因为这是服务端消耗 payload 的一个上限 + +4. 走监控接口过来的数据,在方法内部会为监控数据增加基础信息(比如 App 名称、App 版本号、打包任务 id、设备类型等等) + + ```objective-c + @property (nonatomic, copy) NSString *xxx_APP_NAME; /** 0, warning, type); + return; + } + + if (!PCT_IS_CLASS(meta, NSDictionary)) { + return; + } + if (meta.allKeys.count == 0) { + return; + } + + // 2. 判断当前 namespace 是否开启了收集 + if (!self.configureModel.isGather) { + PCTLOG(@"%@", [NSString stringWithFormat:@"Namespace: %@ 下数据收集开关为关闭状态", self.namespace]); + return ; + } + + // 3. 判断是否是有效的数据。可以落库(type 和监控参数的接口中 monitorList 中的任一条目的type 相等) + BOOL isValidate = [self validateLogData:type]; + if (!isValidate) { + return; + } + + // 3. 先写入 meta 表 + PCTCommonModel *commonModel = [[PCTCommonModel alloc] init]; + [self sendWithType:type namespace:PRISMNAMESPACE meta:meta isBiz:NO commonModel:commonModel]; + + // 4. 如果 payload 不存在则退出当前执行 + if (!PCT_IS_CLASS(payload, NSData) && !payload) { + return; + } + + // 5. 添加限制(超过大小的数据就不能入库,因为这是服务端消耗 payload 的一个上限) + CGFloat payloadSize = [self calculateDataSize:payload]; + if (payloadSize > self.configureModel.maxBodyMByte) { + NSString *assertString = [NSString stringWithFormat:@"payload 数据的大小超过临界值 %zdKB", self.configureModel.maxBodyMByte]; + NSAssert(payloadSize <= self.configureModel.maxBodyMByte, assertString); + return; + } + + // 6. 合并 meta 与 Common 基础数据,用来存储 payload 上报所需要的 meta 信息 + NSMutableDictionary *metaDictionary = [NSMutableDictionary dictionary]; + NSDictionary *commonDictionary = [commonModel getDictionary]; + // Crash 类型为特例,外部传入的 Crash 案发现场信息不能被覆盖 + if ([type isEqualToString:@"appCrash"]) { + [metaDictionary addEntriesFromDictionary:commonDictionary]; + [metaDictionary addEntriesFromDictionary:meta]; + } else { + [metaDictionary addEntriesFromDictionary:meta]; + [metaDictionary addEntriesFromDictionary:commonDictionary]; + } + + NSError *error; + NSData *metaData = [NSJSONSerialization dataWithJSONObject:metaDictionary options:0 error:&error]; + if (error) { + PCTLOG(@"%@", error); + return; + } + NSString *metaContentString = [[NSString alloc] initWithData:metaData encoding:NSUTF8StringEncoding]; + + // 7. 计算上报时 payload 这条数据的大小(meta+payload) + NSMutableData *totalData = [NSMutableData data]; + [totalData appendData:metaData]; + [totalData appendData:payload]; + + // 8. 再写入 payload 表 + PCTLogPayloadModel *payloadModel = [[PCTLogPayloadModel alloc] init]; + payloadModel.is_used = NO; + payloadModel.namespace = PRISMNAMESPACE; + payloadModel.report_id = PCT_SAFE_STRING(commonModel.REPORT_ID); + payloadModel.monitor_type = PCT_SAFE_STRING(type); + payloadModel.is_biz = NO; + payloadModel.created_time = PCT_SAFE_STRING(commonModel.CREATE_TIME); + payloadModel.meta = PCT_SAFE_STRING(metaContentString); + payloadModel.payload = payload; + payloadModel.size = totalData.length; + [PCT_DATABASE add:@[payloadModel] inTableType:PCTLogTableTypePayload]; + + // 9. 判断是否触发实时上报 + [self handleUploadDataWithtype:type]; +} +``` + +业务线数据存储流程基本和监控数据的存储差不多,有差别的是某些字段的标示,用来区分业务线数据。 + + + +## 四、数据上报机制 + +### 1. 数据上报流程和机制设计 + +数据上报机制需要结合数据特点进行设计,数据分为 APM 监控数据和业务线上传数据。先分析下2部分数据的特点。 + +- 业务线数据可能会要求实时上报,需要有根据上报配置数据控制的能力 + +- 整个数据聚合上报过程需要有根据上报配置数据控制的能力定时器周期的能力,隔一段时间去触发上报 + +- 整个数据(业务数据、APM 监控数据)的上报与否需要有通过配置数据控制的能力 + +- 因为 App 在某个版本下收集的数据可能会对下个版本的时候无效,所以上报 SDK 启动后需要有删除之前版本数据的能力(上报配置数据中删除开关打开的情况下) + +- 同样,需要删除过期数据的能力(删除距今多少个自然天前的数据,同样走下发而来的上报配置项) + +- 因为 APM 监控数据非常大,且数据上报 SDK 肯定数据比较大,所以一个网络通信方式的设计好坏会影响 SDK 的质量,为了网络性能不采用传统的 `key/value` 传输。采用**自定义报文结构** + +- 数据的上报流程触发方式有3种:App 启动后触发(APM 监控到 crash 的时候写入本地,启动后处理上次 crash 的数据,是一个特殊 case );定时器触发;数据调用数据上报 SDK 接口后命中实时上报逻辑 + +- 数据落库后会触发一次完整的上报流程 + +- 上报流程的第一步会先判断该数据的 type 能否名字上报配置的 type,命中后如果实时上报配置项为 true,则马上执行后续真正的数据聚合过程;否则中断(只落库,不触发上报) + +- 由于频率会比较高,所以需要做节流的逻辑 + +- 上报流程会首先判断(为了节约用户流量) + + - 判断当前网络环境为 WI-FI 则实时上报 + - 判断当前网络环境不可用,则实时中断后续 + - 判断当前网络环境为蜂窝网络, 则做是否超过**1个自然天内使用流量是否超标**的判断 + - T(当前时间戳) - T(上次保存时间戳) > 24h,则清零已使用的流量,记录当前时间戳到上次上报时间的变量中 + - T(当前时间戳) - T(上次保存时间戳) <= 24h,则判断一个自然天内已使用流量大小是否超过下发的数据上报配置中的流量上限字段,超过则 exit;否则执行后续流程 + +- 数据聚合分表进行,且会有一定的规则 + + - 优先获取 crash 数据 + - 单次网络上报中,整体数据条数不能数据上报配置中的条数限制;数据大小不能超过数据配置中的数据大小 + +- 数据取出后将这批数据标记为 dirty 状态 + +- meta 表数据需要先 `gZip` 压缩,再使用 `AES 128` 加密 + +- payload 表数据需组装自定义格式的报文。格式如下 + + Header 部分: + + ```shell + 2字节大小、数据类型 unsigned short 表示 meta 数据大小 + n 条 payload 数据结构(2字节大小、数据类型为 unsigned int 表示单条 payload 数据大小) + ``` + + ```shell + header + meta 数据 + payload 数据 + ``` + +- 发起数据上报网络请求 + + - 成功回调:删除标记为`dirty` 的数据。判断为流量环境,则将该批数据大小叠加到1个自然天内已使用流量大小的变量中。 + - 失败回调:更新标记为`dirty` 的数据为正常状态。判断为流量环境,则将该批数据大小叠加到1个自然天内已使用流量大小的变量中。 + +整个上报流程图如下: + +![数据上报流程](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-07-04-DataUploaderSDKStructure.png) + + + +### 2. 踩过的坑 && 做得好的地方 + +- 之前做针对网络接口基本上都是使用现有协议的 `key/value` 协议上开发的,它的优点是使用简单,缺点是协议体太大。在设计方案的时候分析道数据上报 SDK 网络上报肯定是非常高频的所以我们需要设计自定义的报文协议,这部分的设计上可以参考 `TCP 报文头结构`。 + +- 当时和后端对接接口的时候发现数据上报过去,服务端解析不了。断点调试发现数据聚合后的大小、条数、压缩、加密都是正常的,在本地 Mock 后完全可以反向解析出来。但为什么到服务端就解析不了,联调后发现是**字节端序**(Big-Endian)的问题。简单介绍如下,关于大小端序的详细介绍请查看我的[这篇文章](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter5%20-%20Network/5.3.md) + + 主机字节顺序HBO(Host Byte Order):与 CPU 类型有关。Big-Endian: PowerPC、IBM、Sun。Little-Endian:x86、DEC + + 网络字节顺序 NBO(Network Byte Order):网络默认为大端序。 + +- 上面的逻辑有一步是当网络上报成功后需要删除标记为 dirty 的数据。但是测试了一下发现,大量数据删除后数据库文件的大小不变,理论上需要腾出内存数据大小的空间。 + + sqlite 采用的是变长记录存储,当数据被删除后,未使用的磁盘空间被添加到一个内在的“空闲列表”中,用于下次插入数据,这属于优化机制之一,sqlite 提供 `vacuum` 命令来释放。 + + 这个问题类似于 Linux 中的文件引用计数的意思,虽然不一样,但是提出来做一下参考。实验是这样的 + + 1. 先看一下当前各个挂载目录的空间大小:`df -h` + + 2. 首先我们产生一个50M大小的文件 + + 3. 写一段代码读取文件 + + ```c++ + #include + #include + int main(void) + {    FILE *fp = NULL;    + fp = fopen("/boot/test.txt", "rw+");    + if(NULL == fp){       + perror("open file failed");    + return -1;    + }     + while(1){       + //do nothing       sleep(1);    + }    + fclose(fp);   + return 0; + } + ``` + + 4. 命令行模式下使用 `rm` 删除文件 + + 5. 查看文件大小: `df -h`,发现文件被删除了,但是该目录下的可用空间并未变多 + + 解释:实际上,只有当一个文件的引用计数为0(包括硬链接数)的时候,才可能调用 unlink 删除,只要它不是0,那么就不会被删除。所谓的删除,也不过是文件名到 inode 的链接删除,只要不被重新写入新的数据,磁盘上的 block 数据块不会被删除,因此,你会看到,即便删库跑路了,某些数据还是可以恢复的。换句话说,当一个程序打开一个文件的时候(获取到文件描述符),它的引用计数会被+1,rm虽然看似删除了文件,实际上只是会将引用计数减1,但由于引用计数不为0,因此文件不会被删除。 + +- 在数据聚合的时候优先获取 crash 数据,总数据条数需要小于上报配置数据的条数限制、总数据大小需要小于上报配置数据的大小限制。这里的处理使用了递归,改变了函数参数 + + ```objective-c + - (void)assembleDataInTable:(PCTLogTableType)tableType networkType:(NetworkingManagerStatusType)networkType completion:(void (^)(NSArray *records))completion { + // 1. 获取到合适的 Crash 类型的数据 + [self fetchCrashDataByCount:self.configureModel.maxFlowMByte + inTable:tableType + upperBound:self.configureModel.maxBodyMByte + completion:^(NSArray *records) { + NSArray *crashData = records; + // 2. 计算剩余需要的数据条数和剩余需要的数据大小 + NSInteger remainingCount = self.configureModel.maxItem - crashData.count; + float remainingSize = self.configureModel.maxBodyMByte - [self calculateDataSize:crashData]; + // 3. 获取除 Crash 类型之外的其他数据,且需要符合相应规则 + BOOL isWifi = (networkType == NetworkingManagerStatusReachableViaWiFi); + [self fetchDataExceptCrash:remainingCount + inTable:tableType + upperBound:remainingSize + isWiFI:isWifi + completion:^(NSArray *records) { + NSArray *dataExceptCrash = records; + + NSMutableArray *dataSource = [NSMutableArray array]; + [dataSource addObjectsFromArray:crashData]; + [dataSource addObjectsFromArray:dataExceptCrash]; + if (completion) { + completion([dataSource copy]); + } + }]; + }]; + } + + - (void)fetchDataExceptCrash:(NSInteger)count inTable:(PCTLogTableType)tableType upperBound:(float)size isWiFI:(BOOL)isWifi completion:(void (^)(NSArray *records))completion { + // 1. 根据剩余需要数据条数去查询表中非 Crash 类型的数据集合 + __block NSMutableArray *conditions = [NSMutableArray array]; + [self.configureModel.monitorList enumerateObjectsUsingBlock:^(PCTItemModel * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + if (isWifi) { + if (![obj.type isEqualToString:@"appCrash"]) { + [conditions addObject:[NSString stringWithFormat:@"'%@'", obj.type]]; + } + } else { + if (!obj.onlyWifi && ![obj.type isEqualToString:@"appCrash"]) { + [conditions addObject:[NSString stringWithFormat:@"'%@'", PCT_SAFE_STRING(obj.type)]]; + } + } + }]; + NSString *queryCrashDataCondition = [NSString stringWithFormat:@"monitor_type in (%@) and is_used = 0 and namespace = '%@'", [conditions componentsJoinedByString:@","], self.namespace]; + + // 2. 根据是否有 Wifi 查找对应的数据 + [PCT_DATABASE getRecordsByCount:count + condtion:queryCrashDataCondition + inTableType:tableType + completion:^(NSArray *_Nonnull records) { + // 3. 非 Crash 类型的数据集合大小是否超过剩余需要的数据大小 + float dataSize = [self calculateDataSize:records]; + + // 4. 大于最大包体积则递归获取 maxItem-1 条非 Crash 数据集合并判断数据大小 + if (size == 0) { + if (completion) { + completion(records); + } + } else if (dataSize > size) { + NSInteger currentCount = count - 1; + return [self fetchDataExceptCrash:currentCount inTable:tableType upperBound:size isWiFI:isWifi completion:completion]; + } else { + if (completion) { + completion(records); + } + } + }]; + } + ``` + +- 整个 SDK 的 Unit Test 通过率 100%,代码分支覆盖率为 93%。测试基于 TDD 和 BDD。测试框架:系统自带的 `XCTest`,第三方的 `OCMock`、`Kiwi`、`Expecta`、`Specta`。测试使用了基础类,后续每个文件都设计继承自测试基类的类。 + + Xcode 可以看到整个 SDK 的测试覆盖率和单个文件的测试覆盖率 + + ![Xcode 测试覆盖率](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-07-06-XcodeTestCoverage.png) + + 也可以使用 [slather](https://github.com/SlatherOrg/slather)。在项目终端环境下新建 `.slather.yml` 配置文件,然后执行语句 `slather coverage -s --scheme prism-client-Example --workspace prism-client.xcworkspace prism-client.xcodeproj`。 + + + +## 五、 接口设计 + +```objective-c +@interface PrismClient : NSObject + +- (instancetype)init NS_UNAVAILABLE; + ++ (instancetype)new NS_UNAVAILABLE; + +/** + 单例方式初始化全局唯一对象。单例之后必须马上 setUp + + @return 单例对象 + */ ++ (instancetype)sharedInstance; + +/** + 当前 SDK 初始化。当前功能:注册配置下发服务。 + */ +- (void)setup; + +/** + 上报 payload 类型的数据 + + @param type 监控类型 + @param meta 元数据 + @param payload payload类型的数据 + */ +- (void)sendWithType:(NSString *)type meta:(NSDictionary *)meta payload:(NSData *__nullable)payload; + +/** + 上报 meta 类型的数据,需要传递三个参数。type 表明是什么类型的数据;prefix 代表前缀,上报到后台会拼接 prefix+type;meta 是字典类型的元数据 + + @param type 数据类型 + @param prefix 数据类型的前缀。一般是业务线名称首字母简写。比如记账:JZ + @param meta description元数据 + */ +- (void)sendWithType:(NSString *)type prefix:(NSString *)prefix meta:(NSDictionary *)meta; + +/** + 获取上报相关的通用信息 + + @return 上报基础信息 + */ +- (PCTCommonModel *)getCommon; + +/** + 是否需要采集上报 + + @return 上报开关 + */ +- (BOOL)isGather:(NSString *)namespace; + +@end +``` + +`PrismClient` 类是整个 SDK 的入口,也是接口的提供者。其中 `- (void)sendWithType:(NSString *)type prefix:(NSString *)prefix meta:(NSDictionary *)meta;` 接口给业务方使用。 + +`- (void)sendWithType:(NSString *)type meta:(NSDictionary *)meta payload:(NSData *__nullable)payload;` 给监控数据使用。 + +`setup` 方法内部开启多个 namespace 下的处理 handler。 + +```objective-c +- (void)setup { + // 注册 mget 获取监控和各业务线的配置信息,会产生多个 namespace,彼此平行、隔离 + [[PCTConfigurationService sharedInstance] registerAndFetchConfigurationInfo]; + + [self.configutations enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + PCTService *service = [[PCTService alloc] initWithNamespace:obj]; + [self.services setObject:service forKey:obj]; + }]; + PCTService *prismService = [self.services objectForKey:PRISMNAMESPACE]; + if (!prismService) { + prismService = [[PCTService alloc] initWithNamespace:PRISMNAMESPACE]; + [self.services setObject:prismService forKey:PRISMNAMESPACE]; + } +} +``` + +真正处理逻辑的是 `PCTService` 类。这里贴一下代码,点击展开 + +```objective-c +#define PCT_SAVED_FLOW @"PCT_SAVED_FLOW" +#define PCT_SAVED_TIMESTAMP @"PCT_SAVED_TIMESTAMP" + +@interface PCTService () + +@property (nonatomic, copy) NSString *requestBaseUrl; /**<需要配置的baseUrl*/ +@property (nonatomic, copy) PCTConfigurationModel *configureModel; /**<当前 namespace 下的配置信息*/ +@property (nonatomic, copy) NSString *metaURL; /** 0, warning, type); + return; + } + + if (!PCT_IS_CLASS(meta, NSDictionary)) { + return; + } + if (meta.allKeys.count == 0) { + return; + } + + // 2. 判断当前 namespace 是否开启了收集 + if (!self.configureModel.isGather) { + PCTLOG(@"%@", [NSString stringWithFormat:@"Namespace: %@ 下数据收集开关为关闭状态", self.namespace]); + return ; + } + + // 3. 判断是否是有效的数据。可以落库(type 和监控参数的接口中 monitorList 中的任一条目的type 相等) + BOOL isValidate = [self validateLogData:type]; + if (!isValidate) { + return; + } + + // 3. 先写入 meta 表 + PCTCommonModel *commonModel = [[PCTCommonModel alloc] init]; + [self sendWithType:type namespace:PRISMNAMESPACE meta:meta isBiz:NO commonModel:commonModel]; + + // 4. 如果 payload 不存在则退出当前执行 + if (!PCT_IS_CLASS(payload, NSData) && !payload) { + return; + } + + // 5. 添加限制(超过大小的数据就不能入库,因为这是服务端消耗 payload 的一个上限) + CGFloat payloadSize = [self calculateDataSize:payload]; + if (payloadSize > self.configureModel.maxBodyMByte) { + NSString *assertString = [NSString stringWithFormat:@"payload 数据的大小超过临界值 %zdKB", self.configureModel.maxBodyMByte]; + NSAssert(payloadSize <= self.configureModel.maxBodyMByte, assertString); + return; + } + + // 6. 合并 meta 与 Common 基础数据,用来存储 payload 上报所需要的 meta 信息 + NSMutableDictionary *metaDictionary = [NSMutableDictionary dictionary]; + NSDictionary *commonDictionary = [commonModel getDictionary]; + // Crash 类型为特例,外部传入的 Crash 案发现场信息不能被覆盖 + if ([type isEqualToString:@"appCrash"]) { + [metaDictionary addEntriesFromDictionary:commonDictionary]; + [metaDictionary addEntriesFromDictionary:meta]; + } else { + [metaDictionary addEntriesFromDictionary:meta]; + [metaDictionary addEntriesFromDictionary:commonDictionary]; + } + + NSError *error; + NSData *metaData = [NSJSONSerialization dataWithJSONObject:metaDictionary options:0 error:&error]; + if (error) { + PCTLOG(@"%@", error); + return; + } + NSString *metaContentString = [[NSString alloc] initWithData:metaData encoding:NSUTF8StringEncoding]; + + // 7. 计算上报时 payload 这条数据的大小(meta+payload) + NSMutableData *totalData = [NSMutableData data]; + [totalData appendData:metaData]; + [totalData appendData:payload]; + + // 8. 再写入 payload 表 + PCTLogPayloadModel *payloadModel = [[PCTLogPayloadModel alloc] init]; + payloadModel.is_used = NO; + payloadModel.namespace = PRISMNAMESPACE; + payloadModel.report_id = PCT_SAFE_STRING(commonModel.REPORT_ID); + payloadModel.monitor_type = PCT_SAFE_STRING(type); + payloadModel.is_biz = NO; + payloadModel.created_time = PCT_SAFE_STRING(commonModel.CREATE_TIME); + payloadModel.meta = PCT_SAFE_STRING(metaContentString); + payloadModel.payload = payload; + payloadModel.size = totalData.length; + [PCT_DATABASE add:@[payloadModel] inTableType:PCTLogTableTypePayload]; + + // 9. 判断是否触发实时上报 + [self handleUploadDataWithtype:type]; +} + +- (void)sendWithType:(NSString *)type prefix:(NSString *)prefix meta:(NSDictionary *)meta { + // 1. 校验参数合法性 + NSString *prefixWarning = [NSString stringWithFormat:@"%@不能是空字符串", prefix]; + if (!PCT_IS_CLASS(prefix, NSString)) { + NSAssert1(PCT_IS_CLASS(prefix, NSString), prefixWarning, prefix); + return; + } + if (prefix.length == 0) { + NSAssert1(prefix.length > 0, prefixWarning, prefix); + return; + } + + NSString *typeWarning = [NSString stringWithFormat:@"%@不能是空字符串", type]; + if (!PCT_IS_CLASS(type, NSString)) { + NSAssert1(PCT_IS_CLASS(type, NSString), typeWarning, type); + return; + } + if (type.length == 0) { + NSAssert1(type.length > 0, typeWarning, type); + return; + } + + if (!PCT_IS_CLASS(meta, NSDictionary)) { + return; + } + if (meta.allKeys.count == 0) { + return; + } + + // 2. 私有接口处理 is_biz 逻辑 + PCTCommonModel *commonModel = [[PCTCommonModel alloc] init]; + [self sendWithType:type namespace:prefix meta:meta isBiz:YES commonModel:commonModel]; +} + + +#pragma mark - private method + +// 基础配置 +- (void)setupConfig { + _requestBaseUrl = @"https://common.***.com"; + _metaURL = @"prism/metrics/meta"; + _payloadURL = @"prism/metrics/detail"; +} + +- (void)executeHandlerWhenAppLaunched +{ + // 1. 删除非法数据 + [self handleInvalidateData]; + // 2. 回收数据库磁盘碎片空间 + [self rebuildDatabase]; + // 3. 开启定时器去定时上报数据 + [self executeTimedTask]; +} + +/* + 1. 当 App 版本变化的时候删除数据 + 2. 删除过期数据 + 3. 删除 Payload 表里面超过限制的数据 + 4. 删除上传接口网络成功,但是突发 crash 造成没有删除这批数据的情况,所以启动完成后删除 is_used = YES 的数据 + */ +- (void)handleInvalidateData +{ + NSString *currentVersion = [[PrismClient sharedInstance] getCommon].APP_VERSION; + NSString *savedVersion = [[NSUserDefaults standardUserDefaults] stringForKey:PCT_SAVED_APP_VERSION] ?: [currentVersion copy]; + + NSInteger threshold = [NSDate pct_currentTimestamp]; + if (![currentVersion isEqualToString:savedVersion] && self.configureModel.isUpdateClear) { + [[NSUserDefaults standardUserDefaults] setObject:currentVersion forKey:PCT_SAVED_APP_VERSION]; + } else { + threshold = [NSDate pct_currentTimestamp] - self.configureModel.expirationDay * 24 * 60 * 60 *1000; + } + NSInteger sizeUpperLimit = self.configureModel.maxBodyMByte * 1024 * 1024; + NSString *sqlString = [NSString stringWithFormat:@"(created_time < %zd and namespace = '%@') or size > %zd or is_used = 1", threshold, self.namespace, sizeUpperLimit]; + [PCT_DATABASE removeDataUseCondition:sqlString inTableType:PCTLogTableTypeMeta]; + [PCT_DATABASE removeDataUseCondition:sqlString inTableType:PCTLogTableTypePayload]; +} + +// 启动时刻清理数据表空间碎片,回收磁盘大小 +- (void)rebuildDatabase { + [PCT_DATABASE rebuildDatabaseFileInTableType:PCTLogTableTypeMeta]; + [PCT_DATABASE rebuildDatabaseFileInTableType:PCTLogTableTypePayload]; +} + +// 判断数据是否可以落库 +- (BOOL)validateLogData:(NSString *)dataType { + NSArray *monitors = self.configureModel.monitorList; + __block BOOL isValidate = NO; + [monitors enumerateObjectsUsingBlock:^(PCTItemModel *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { + if ([obj.type isEqualToString:dataType]) { + isValidate = YES; + *stop = YES; + } + }]; + return isValidate; +} + +- (void)executeTimedTask { + __weak typeof(self) weakself = self; + self.taskExecutor = [[TMLoopTaskExecutor alloc] init]; + TMTaskOption *dataUploadOption = [[TMTaskOption alloc] init]; + dataUploadOption.option = TMTaskRunOptionRuntime; + dataUploadOption.interval = self.configureModel.periodicTimerSecond; + TMTask *dataUploadTask = [[TMTask alloc] init]; + dataUploadTask.runBlock = ^{ + [weakself upload]; + }; + [self.taskExecutor addTask:dataUploadTask option:dataUploadOption]; +} + +- (void)handleUploadDataWithtype:(NSString *)type { + __block BOOL canUploadInTime = NO; + [self.configureModel.monitorList enumerateObjectsUsingBlock:^(PCTItemModel *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { + if ([type isEqualToString:obj.type]) { + if (obj.isRealtime) { + canUploadInTime = YES; + *stop = YES; + } + } + }]; + if (canUploadInTime) { + // 节流 + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [self upload]; + }); + } +} + +// 对内和对外的存储都走这个流程。通过这个接口设置 is_biz 信息 +- (void)sendWithType:(NSString *)type namespace:(NSString *)namespace meta:(NSDictionary *)meta isBiz:(BOOL)is_biz commonModel:(PCTCommonModel *)commonModel { + // 0. 判断当前 namespace 是否开启了收集 + if (!self.configureModel.isGather) { + PCTLOG(@"%@", [NSString stringWithFormat:@"Namespace: %@ 下数据收集开关为关闭状态", self.namespace]); + return ; + } + + // 1. 检查参数合法性 + NSString *warning = [NSString stringWithFormat:@"%@不能是空字符串", type]; + if (!PCT_IS_CLASS(type, NSString)) { + NSAssert1(PCT_IS_CLASS(type, NSString), warning, type); + return; + } + if (type.length == 0) { + NSAssert1(type.length > 0, warning, type); + return; + } + + if (!PCT_IS_CLASS(meta, NSDictionary)) { + return; + } + if (meta.allKeys.count == 0) { + return; + } + + // 2. 判断是否是有效的数据。可以落库(type 和监控参数的接口中 monitorList 中的任一条目的type 相等) + BOOL isValidate = [self validateLogData:type]; + if (!isValidate) { + return; + } + + // 3. 合并 meta 与 Common 基础数据 + NSMutableDictionary *mutableMeta = [NSMutableDictionary dictionaryWithDictionary:meta]; + mutableMeta[@"MONITOR_TYPE"] = is_biz ? [NSString stringWithFormat:@"%@-%@", namespace, type] : type; + meta = [mutableMeta copy]; + + commonModel.IS_BIZ = is_biz; + NSMutableDictionary *metaDictionary = [NSMutableDictionary dictionary]; + NSDictionary *commonDictionary = [commonModel getDictionary]; + + // Crash 类型为特例,外部传入的 Crash 案发现场信息不能被覆盖 + if ([type isEqualToString:@"appCrash"]) { + [metaDictionary addEntriesFromDictionary:commonDictionary]; + [metaDictionary addEntriesFromDictionary:meta]; + } else { + [metaDictionary addEntriesFromDictionary:meta]; + [metaDictionary addEntriesFromDictionary:commonDictionary]; + } + + // 4. 转换为 NSData + NSError *error; + NSData *metaData = [NSJSONSerialization dataWithJSONObject:metaDictionary options:0 error:&error]; + if (error) { + PCTLOG(@"%@", error); + return; + } + + // 5. 添加限制(超过 10K 的数据就不能入库,因为这是服务端消耗 meta 的一个上限) + CGFloat metaSize = [self calculateDataSize:metaData]; + if (metaSize > 10 / 1024.0) { + NSAssert(metaSize <= 10 / 1024.0, @"meta 数据的大小超过临界值 10KB"); + return; + } + + NSString *metaContentString = [[NSString alloc] initWithData:metaData encoding:NSUTF8StringEncoding]; + + // 6. 构造 MetaModel 模型 + PCTLogMetaModel *metaModel = [[PCTLogMetaModel alloc] init]; + metaModel.namespace = namespace; + metaModel.report_id = PCT_SAFE_STRING(commonModel.REPORT_ID); + metaModel.monitor_type = PCT_SAFE_STRING(type); + metaModel.created_time = PCT_SAFE_STRING(commonModel.CREATE_TIME); + metaModel.meta = PCT_SAFE_STRING(metaContentString); + metaModel.size = metaData.length; + metaModel.is_biz = is_biz; + + // 7. 写入数据库 + [PCT_DATABASE add:@[metaModel] inTableType:PCTLogTableTypeMeta]; + + // 8. 判断是否触发实时上报(对内的接口则在函数内部判断,如果是对外的则在这里判断) + if (is_biz) { + [self handleUploadDataWithtype:type]; + } +} + +- (BOOL)needUploadPayload:(PCTLogPayloadModel *)model { + __block BOOL needed = NO; + [self.configureModel.monitorList enumerateObjectsUsingBlock:^(PCTItemModel *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { + if ([obj.type isEqualToString:model.monitor_type] && obj.isUploadPayload) { + needed = YES; + *stop = YES; + } + }]; + return needed; +} + +/* + 计算 数据包大小,分为2种情况。 + 1. 上传前使用数据表中的 size 字段去判断大小 + 2. 上报完成后则根据真实网络通信中组装的 payload 进行大小计算 + */ +- (float)calculateDataSize:(id)data { + if (PCT_IS_CLASS(data, NSArray)) { + __block NSInteger dataLength = 0; + NSArray *uploadDatasource = (NSArray *)data; + [uploadDatasource enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { + if (PCT_IS_CLASS(obj, PCTLogModel)) { + PCTLogModel *uploadModel = (PCTLogModel *)obj; + dataLength += uploadModel.size; + } + }]; + return dataLength / (1024 * 1024.0); + } else if (PCT_IS_CLASS(data, NSData)) { + NSData *rawData = (NSData *)data; + return rawData.length / (1024 * 1024.0); + } else { + return 0; + } +} + +// 上报流程的主函数 +- (void)upload { + /* + 1. 判断能否上报 + 2. 数据聚合 + 3. 加密压缩 + 4. 1分钟内的网络请求合并为1次 + 5. 上报(全局上报开关是开着的情况) + - 成功:删除本地数据、调用更新策略的接口 + - 失败:不删除本地数据 + */ + [self judgeCanUploadCompletionBlock:^(BOOL canUpload, NetworkingManagerStatusType networkType) { + if (canUpload && self.configureModel.isUpload) { + [self handleUploadTask:networkType]; + } + }]; +} + +/** + 上报前的校验 + - 判断网络情况,分为 wifi 和 非 Wi-Fi 、网络不通的情况。 + - 从配置下发的 monitorList 找出 onlyWifi 字段为 true 的 type,组成数组 [appCrash、appLag...] + - 网络不通,则不能上报 + - 网络通,则判断上报校验 + 1. 当前GMT时间戳-保存的时间戳超过24h。则认为是一个新的自然天 + - 清除 currentFlow + - 触发上报流程 + 2. 当前GMT时间戳-保存的时间戳不超过24h + - 当前的流量是否超过配置信息里面的最大流量,未超过(<):触发上报流程 + - 当前的流量是否超过配置信息里面的最大流量,超过:结束流程 + */ +- (void)judgeCanUploadCompletionBlock:(void (^)(BOOL canUpload, NetworkingManagerStatusType networkType))completionBlock { + // WIFI 的情况下不判断直接上传;不是 WIFI 的情况需要判断「当日最大限制流量」 + [self.requester networkStatusWithBlock:^(NetworkingManagerStatusType status) { + switch (status) { + case NetworkingManagerStatusUnknown: { + PCTLOG(@"没有网络权限哦"); + if (completionBlock) { + completionBlock(NO, NetworkingManagerStatusUnknown); + } + break; + } + case NetworkingManagerStatusNotReachable: { + if (completionBlock) { + completionBlock(NO, NetworkingManagerStatusNotReachable); + } + break; + } + case NetworkingManagerStatusReachableViaWiFi: { + if (completionBlock) { + completionBlock(YES, NetworkingManagerStatusReachableViaWiFi); + } + break; + } + case NetworkingManagerStatusReachableViaWWAN: { + if ([self currentGMTStyleTimeStamp] - self.currentTimestamp.integerValue > 24 * 60 * 60 * 1000) { + self.currentFlow = [NSNumber numberWithFloat:0]; + self.currentTimestamp = [NSNumber numberWithInteger:[self currentGMTStyleTimeStamp]]; + if (completionBlock) { + completionBlock(YES, NetworkingManagerStatusReachableViaWWAN); + } + } else { + if (self.currentFlow.floatValue < self.configureModel.maxFlowMByte) { + if (completionBlock) { + completionBlock(YES, NetworkingManagerStatusReachableViaWWAN); + } + } else { + if (completionBlock) { + completionBlock(NO, NetworkingManagerStatusReachableViaWWAN); + } + } + } + break; + } + } + }]; +} + +- (void)handleUploadTask:(NetworkingManagerStatusType)networkType { + // 数据聚合(2张表分别扫描) -> 压缩 -> 上报 + [self handleUploadTaskInMetaTable:networkType]; + [self handleUploadTaskInPayloadTable:networkType]; +} + +- (void)handleUploadTaskInMetaTable:(NetworkingManagerStatusType)networkType { + __weak typeof(self) weakself = self; + // 1. 数据聚合 + [self assembleDataInTable:PCTLogTableTypeMeta + networkType:networkType + completion:^(NSArray *records) { + if (records.count == 0) { + return; + } + // 2. 加密压缩处理:(meta 整体先加密再压缩,payload一条条先加密再压缩) + __block NSMutableString *metaStrings = [NSMutableString string]; + __block NSMutableArray *usedReportIds = [NSMutableArray array]; + + // 2.1. 遍历拼接model,取出 meta,用 \n 拼接 + [records enumerateObjectsUsingBlock:^(PCTLogModel *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { + if (PCT_IS_CLASS(obj, PCTLogMetaModel)) { + PCTLogMetaModel *metaModel = (PCTLogMetaModel *)obj; + BOOL shouldAppendLineBreakSymbol = idx < (records.count - 1); + [usedReportIds addObject:[NSString stringWithFormat:@"'%@'", metaModel.report_id]]; + [metaStrings appendString:[NSString stringWithFormat:@"%@%@", metaModel.meta, shouldAppendLineBreakSymbol ? @"\n" : @""]]; + } + }]; + if (metaStrings.length == 0) { + return; + } + // 2.2 拼接后的内容先压缩再加密 + NSData *data = [PCTDataSerializer compressAndEncryptWithString:metaStrings]; + + // 3. 将取出来用于接口请求的数据标记为 dirty + NSString *updateCondtion = [NSString stringWithFormat:@"report_id in (%@)", [usedReportIds componentsJoinedByString:@","]]; + [[PCTDatabase sharedInstance] updateData:@"is_used = 1" useCondition:updateCondtion inTableType:PCTLogTableTypeMeta]; + + // 4. 请求网络 + NSString *requestURL = [NSString stringWithFormat:@"%@/%@", weakself.requestBaseUrl, weakself.metaURL]; + + [weakself.requester postDataWithRequestURL:requestURL + bodyData:data + success:^{ + [weakself deleteInvalidateData:records inTableType:PCTLogTableTypeMeta]; + if (networkType == NetworkingManagerStatusReachableViaWWAN) { + float currentFlow = [weakself.currentFlow floatValue]; + currentFlow += [weakself calculateDataSize:data]; + weakself.currentFlow = [NSNumber numberWithFloat:currentFlow]; + } + } + failure:^(NSError *_Nonnull error) { + [[PCTDatabase sharedInstance] updateData:@"is_used = 0" useCondition:updateCondtion inTableType:PCTLogTableTypeMeta]; + if (networkType == NetworkingManagerStatusReachableViaWWAN) { + float currentFlow = [weakself.currentFlow floatValue]; + currentFlow += [weakself calculateDataSize:data]; + weakself.currentFlow = [NSNumber numberWithFloat:currentFlow]; + } + }]; + }]; +} + +- (NSData *)handlePayloadData:(NSArray *)rawArray { + // 1. 数据校验 + if (rawArray.count == 0) { + return nil; + } + // 2. 加密压缩处理:(meta 整体先加密再压缩,payload一条条先加密再压缩) + __block NSMutableString *metaStrings = [NSMutableString string]; + __block NSMutableArray *payloads = [NSMutableArray array]; + + + // 2.1. 遍历拼接model,取出 meta,用 \n 拼接 + [rawArray enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { + if (PCT_IS_CLASS(obj, PCTLogPayloadModel)) { + PCTLogPayloadModel *payloadModel = (PCTLogPayloadModel *)obj; + BOOL shouldAppendLineBreakSymbol = idx < (rawArray.count - 1); + + [metaStrings appendString:[NSString stringWithFormat:@"%@%@", PCT_SAFE_STRING(payloadModel.meta), shouldAppendLineBreakSymbol ? @"\n" : @""]]; + + // 2.2 判断是否需要上传 payload 信息。如果需要则将 payload 取出。 + if ([self needUploadPayload:payloadModel]) { + if (payloadModel.payload) { + NSData *payloadData = [PCTDataSerializer compressAndEncryptWithData:payloadModel.payload]; + if (payloadData) { + [payloads addObject:payloadData]; + } + } + } + } + }]; + + NSData *metaData = [PCTDataSerializer compressAndEncryptWithString:metaStrings]; + + __block NSMutableData *headerData = [NSMutableData data]; + unsigned short metaLength = (unsigned short)metaData.length; + HTONS(metaLength); // 处理2字节的大端序 + [headerData appendData:[NSData dataWithBytes:&metaLength length:sizeof(metaLength)]]; + + Byte payloadCountbytes[] = {payloads.count}; + NSData *payloadCountData = [[NSData alloc] initWithBytes:payloadCountbytes length:sizeof(payloadCountbytes)]; + [headerData appendData:payloadCountData]; + + [payloads enumerateObjectsUsingBlock:^(NSData *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { + unsigned int payloadLength = (unsigned int)obj.length; + HTONL(payloadLength); // 处理4字节的大端序 + [headerData appendData:[NSData dataWithBytes:&payloadLength length:sizeof(payloadLength)]]; + }]; + + __block NSMutableData *uploadData = [NSMutableData data]; + // 先添加 header 基础信息,不需要加密压缩 + [uploadData appendData:[headerData copy]]; + // 再添加 meta 信息,meta 信息需要先压缩再加密 + [uploadData appendData:metaData]; + // 再添加 payload 信息 + [payloads enumerateObjectsUsingBlock:^(NSData *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { + [uploadData appendData:obj]; + }]; + return [uploadData copy]; +} + +- (void)handleUploadTaskInPayloadTable:(NetworkingManagerStatusType)networkType { + __weak typeof(self) weakself = self; + // 1. 数据聚合 + [self assembleDataInTable:PCTLogTableTypePayload + networkType:networkType + completion:^(NSArray *records) { + if (records.count == 0) { + return; + } + // 2. 取出可以上传的 payload 数据 + NSArray *canUploadPayloadData = [self fetchDataCanUploadPayload:records]; + + if (canUploadPayloadData.count == 0) { + return; + } + + // 3. 将取出来用于接口请求的数据标记为 dirty + __block NSMutableArray *usedReportIds = [NSMutableArray array]; + [canUploadPayloadData enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + if (PCT_IS_CLASS(obj, PCTLogModel)) { + PCTLogModel *model = (PCTLogModel *)obj; + [usedReportIds addObject:[NSString stringWithFormat:@"'%@'", model.report_id]]; + } + }]; + NSString *updateCondtion = [NSString stringWithFormat:@"report_id in (%@)", [usedReportIds componentsJoinedByString:@","]]; + + [[PCTDatabase sharedInstance] updateData:@"is_used = 1" useCondition:updateCondtion inTableType:PCTLogTableTypePayload]; + + // 4. 将取出的数据聚合,组成报文 + NSData *uploadData = [self handlePayloadData:canUploadPayloadData]; + + // 5. 请求网络 + NSString *requestURL = [NSString stringWithFormat:@"%@/%@", weakself.requestBaseUrl, weakself.payloadURL]; + + [weakself.requester postDataWithRequestURL:requestURL + bodyData:uploadData + success:^{ + [weakself deleteInvalidateData:canUploadPayloadData inTableType:PCTLogTableTypePayload]; + if (networkType == NetworkingManagerStatusReachableViaWWAN) { + float currentFlow = [weakself.currentFlow floatValue]; + currentFlow += [weakself calculateDataSize:uploadData]; + weakself.currentFlow = [NSNumber numberWithFloat:currentFlow]; + } + } + failure:^(NSError *_Nonnull error) { + [[PCTDatabase sharedInstance] updateData:@"is_used = 0" useCondition:updateCondtion inTableType:PCTLogTableTypePayload]; + if (networkType == NetworkingManagerStatusReachableViaWWAN) { + float currentFlow = [weakself.currentFlow floatValue]; + currentFlow += [weakself calculateDataSize:uploadData]; + weakself.currentFlow = [NSNumber numberWithFloat:currentFlow]; + } + }]; + }]; +} + +// 清除过期数据 +- (void)deleteInvalidateData:(NSArray *)data inTableType:(PCTLogTableType)tableType { + [PCT_DATABASE remove:data inTableType:tableType]; +} + +// 以秒为单位的时间戳 +- (NSInteger)currentGMTStyleTimeStamp { + return [NSDate pct_currentTimestamp]/1000; +} + +#pragma mark-- 数据库操作 + +/** + 根据接口配置信息中的条件获取表中的上报数据 + - Wi-Fi 的时候都上报 + - 不为 Wi-Fi 的时候:onlyWifi 为 false 的类型进行上报 + */ +- (void)assembleDataInTable:(PCTLogTableType)tableType networkType:(NetworkingManagerStatusType)networkType completion:(void (^)(NSArray *records))completion { + // 1. 获取到合适的 Crash 类型的数据 + [self fetchCrashDataByCount:self.configureModel.maxFlowMByte + inTable:tableType + upperBound:self.configureModel.maxBodyMByte + completion:^(NSArray *records) { + NSArray *crashData = records; + // 2. 计算剩余需要的数据条数和剩余需要的数据大小 + NSInteger remainingCount = self.configureModel.maxItem - crashData.count; + float remainingSize = self.configureModel.maxBodyMByte - [self calculateDataSize:crashData]; + // 3. 获取除 Crash 类型之外的其他数据,且需要符合相应规则 + BOOL isWifi = (networkType == NetworkingManagerStatusReachableViaWiFi); + [self fetchDataExceptCrash:remainingCount + inTable:tableType + upperBound:remainingSize + isWiFI:isWifi + completion:^(NSArray *records) { + NSArray *dataExceptCrash = records; + + NSMutableArray *dataSource = [NSMutableArray array]; + [dataSource addObjectsFromArray:crashData]; + [dataSource addObjectsFromArray:dataExceptCrash]; + if (completion) { + completion([dataSource copy]); + } + }]; + }]; +} + + +- (NSArray *)fetchDataCanUploadPayload:(NSArray *)datasource { + __weak typeof(self) weakself = self; + __block NSMutableArray *array = [NSMutableArray array]; + if (!PCT_IS_CLASS(datasource, NSArray)) { + NSAssert(PCT_IS_CLASS(datasource, NSArray), @"参数必须是数组"); + return nil; + } + [datasource enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { + if (PCT_IS_CLASS(obj, PCTLogPayloadModel)) { + PCTLogPayloadModel *payloadModel = (PCTLogPayloadModel *)obj; + // 判断是否需要上传 payload 信息 + if ([weakself needUploadPayload:payloadModel]) { + [array addObject:payloadModel]; + } + } + }]; + return [array copy]; +} + +// 递归获取符合条件的 Crash 数据集合(count < maxItem && size < maxBodySize) +- (void)fetchCrashDataByCount:(NSInteger)count inTable:(PCTLogTableType)tableType upperBound:(NSInteger)size completion:(void (^)(NSArray *records))completion { + // 1. 先通过接口拿到的 maxItem 数去查询表中的 Crash 数据集合 + NSString *queryCrashDataCondition = [NSString stringWithFormat:@"monitor_type = 'appCrash' and is_used = 0 and namespace = '%@'", self.namespace]; + [PCT_DATABASE getRecordsByCount:count + condtion:queryCrashDataCondition + inTableType:tableType + completion:^(NSArray *_Nonnull records) { + // 2. Crash 数据集合大小是否超过配置接口拿到的最大包体积(单位M) maxBodySize + float dataSize = [self calculateDataSize:records]; + + // 3. 大于最大包体积则递归获取 maxItem-- 条 Crash 数据集合并判断数据大小 + if (size == 0) { + if (completion) { + completion(records); + } + } else if (dataSize > size) { + NSInteger currentCount = count - 1; + [self fetchCrashDataByCount:currentCount inTable:tableType upperBound:size completion:completion]; + } else { + if (completion) { + completion(records); + } + } + }]; +} + +- (void)fetchDataExceptCrash:(NSInteger)count inTable:(PCTLogTableType)tableType upperBound:(float)size isWiFI:(BOOL)isWifi completion:(void (^)(NSArray *records))completion { + // 1. 根据剩余需要数据条数去查询表中非 Crash 类型的数据集合 + __block NSMutableArray *conditions = [NSMutableArray array]; + [self.configureModel.monitorList enumerateObjectsUsingBlock:^(PCTItemModel * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + if (isWifi) { + if (![obj.type isEqualToString:@"appCrash"]) { + [conditions addObject:[NSString stringWithFormat:@"'%@'", obj.type]]; + } + } else { + if (!obj.onlyWifi && ![obj.type isEqualToString:@"appCrash"]) { + [conditions addObject:[NSString stringWithFormat:@"'%@'", PCT_SAFE_STRING(obj.type)]]; + } + } + }]; + NSString *queryCrashDataCondition = [NSString stringWithFormat:@"monitor_type in (%@) and is_used = 0 and namespace = '%@'", [conditions componentsJoinedByString:@","], self.namespace]; + + // 2. 根据是否有 Wifi 查找对应的数据 + [PCT_DATABASE getRecordsByCount:count + condtion:queryCrashDataCondition + inTableType:tableType + completion:^(NSArray *_Nonnull records) { + // 3. 非 Crash 类型的数据集合大小是否超过剩余需要的数据大小 + float dataSize = [self calculateDataSize:records]; + + // 4. 大于最大包体积则递归获取 maxItem-1 条非 Crash 数据集合并判断数据大小 + if (size == 0) { + if (completion) { + completion(records); + } + } else if (dataSize > size) { + NSInteger currentCount = count - 1; + return [self fetchDataExceptCrash:currentCount inTable:tableType upperBound:size isWiFI:isWifi completion:completion]; + } else { + if (completion) { + completion(records); + } + } + }]; +} + + +#pragma mark - getters and setters + +- (PCTRequestFactory *)requester { + if (!_requester) { + _requester = [[PCTRequestFactory alloc] init]; + } + return _requester; +} + +- (NSNumber *)currentTimestamp { + if (!_currentTimestamp) { + NSInteger currentTimestampValue = [[NSUserDefaults standardUserDefaults] integerForKey:PCT_SAVED_TIMESTAMP]; + _currentTimestamp = [NSNumber numberWithInteger:currentTimestampValue]; + } + return _currentTimestamp; +} + +- (void)setCurrentTimestamp:(NSNumber *)currentTimestamp { + [[NSUserDefaults standardUserDefaults] setInteger:[currentTimestamp integerValue] forKey:PCT_SAVED_TIMESTAMP]; + _currentTimestamp = currentTimestamp; +} + +- (NSNumber *)currentFlow { + if (!_currentFlow) { + float currentFlowValue = [[NSUserDefaults standardUserDefaults] floatForKey:PCT_SAVED_FLOW]; + _currentFlow = [NSNumber numberWithFloat:currentFlowValue]; + } + return _currentFlow; +} + +- (void)setCurrentFlow:(NSNumber *)currentFlow { + [[NSUserDefaults standardUserDefaults] setFloat:[currentFlow floatValue] forKey:PCT_SAVED_FLOW]; + _currentFlow = currentFlow; +} + +- (PCTConfigurationModel *)configureModel +{ + return [[PCTConfigurationService sharedInstance] getConfigurationWithNamespace:self.namespace]; +} + +- (NSString *)requestBaseUrl +{ + return self.configureModel.url ? self.configureModel.url : @"https://common.***.com"; +} + +- (BOOL)isAppLaunched +{ + id isAppLaunched = [[PrismClient sharedInstance] valueForKey:@"isAppLaunched"]; + return [isAppLaunched boolValue]; +} + +@end +``` + + + +## 六、 总结与思考 + +### 1. 技术方面 + +多线程技术很强大,但是很容易出问题。普通做业务的时候用一些简单的 GCD、NSOperation 等就可以满足基本需求了,但是做 SDK 就不一样,你需要考虑各种场景。比如 FMDB 在多线程读写的时候,设计了 FMDatabaseQueue 以串行队列的方式同步执行任务。但是这样一来假如使用者在主线程插入 n 次数据到数据库,这样会发生 ANR,所以我们还得维护一个任务派发队列,用来维护业务方提交的任务,是一个并发队列,以异步任务的方式提交给 FMDB 以同步任务的方式在串行队列上执行。 + +AFNetworking 2.0 使用了 NSURLConnection,同时维护了一个常驻线程,去处理网络成功后的回调。AF 存在一个常驻线程,假如其他 n 个 SDK 的其中 m 个 SDK 也开启了常驻线程,那你的 App 集成后就有 1+m 个常驻线程。 + +AFNetworking 3.0 使用 NSURLSession 替换 NSURLConnection,取消了常驻线程。为什么换了? 😂 逼不得已呀,Apple 官方出了 NSURLSession,那就不需要 NSURLConnection,并为之创建常驻线程了。至于为什么 NSURLSession 不需要常驻线程?它比 NSURLConnecction 多做了什么,以后再聊 + +创建线程的过程,需要用到物理内存,CPU 也会消耗时间。新建一个线程,系统会在该进程空间分配一定的内存作为线程堆栈。堆栈大小是 4KB 的倍数。在 iOS 主线程堆栈大小是 1MB,新创建的子线程堆栈大小是 512KB。此外线程创建得多了,CPU 在切换线程上下文时,还会更新寄存器,更新寄存器的时候需要寻址,而寻址的过程有 CPU 消耗。线程过多时内存、CPU 都会有大量的消耗,出现 ANR 甚至被强杀。 + +举了 🌰 是 FMDB 和 AFNetworking 的作者那么厉害,设计的 FMDB 不包装会 ANR,AFNetworking 必须使用常驻线程,为什么?正是由于多线程太强大、灵活了,开发者骚操作太多,所以 FMDB 设计最简单保证数据库操作线程安全,具体使用可以自己维护队列去包一层。AFNetworking 内的多线程也严格基于系统特点来设计。 + +所以有必要再研究下多线程,建议读 GCD 源码,也就是 [libdispatch](https://opensource.apple.com/tarballs/libdispatch/) + + + +### 2. 规范方面 + +很多开发都不做测试,我们公司都严格约定测试。写基础 SDK 更是如此,一个 App 基础功能必须质量稳定,所以测试是保证手段之一。一定要写好 Unit Test。这样子不断版本迭代,对于 UT,输入恒定,输出恒定,这样内部实现如何变动不需要关心,只需要判断恒定输入,恒定输出就足够了。(针对每个函数单一原则的基础上也是满足 UT)。还有一个好处就是当和别人讨论的的时候,你画个技术流程图、技术架构图、测试的 case、测试输入、输出表述清楚,听的人再看看边界情况是否都考虑全,基本上很快沟通完毕,效率考高。 + +在做 SDK 的接口设计的时候,方法名、参数个数、参数类型、参数名称、返回值名称、类型、数据结构,尽量要做到 iOS 和 Android 端一致,除非某些特殊情况,无法保证一致的输出。别问为什么?好处太多了,成熟 SDK 都这么做。 + + + +比如一个数据上报 SDK。需要考虑数据来源是什么,我设计的接口需要暴露什么信息,数据如何高效存储、数据如何校验、数据如何高效及时上报。 假如我做的数据上报 SDK 可以上报 APM 监控数据、同时也开放能力给业务线使用,业务线自己将感兴趣的数据并写入保存,保证不丢失的情况下如何高效上报。因为数据实时上报,所以需要考虑上传的网络环境、Wi-Fi 环境和 4G 环境下的逻辑不一样的、数据聚合组装成自定义报文并上报、一个自然天内数据上传需要做流量限制等等、App 版本升级一些数据可能会失去意义、当然存储的数据也存在时效性。种种这些东西就是在开发前需要考虑清楚的。所以基础平台做事情基本是 **设计思考时间:编码时间 = 7:3**。 + + 为什么?假设你一个需求,预期10天时间;前期架构设计、类的设计、Uint Test 设计估计7天,到时候编码开发2天完成。 这么做的好处很多,比如: + +1. 除非是非常优秀,不然脑子想的再前面到真正开发的时候发现有出入,coding 完发现和前期方案设计不一样。所以建议用流程图、UML图、技术架构图、UT 也一样,设计个表格,这样等到时候编码也就是 coding 的工作了,将图翻译成代码 + +2. 后期和别人讨论或者沟通或者 CTO 进行 code review 的时候不需要一行行看代码。你将相关的架构图、流程图、UML 图给他看看。他再看看一些关键逻辑的 UT,保证输入输出正确,一般来说这样就够了 + + + +### 3. 质量保证 + +UT 是质量保证的一个方面,另一个就是 MR 机制。我们团队 MR 采用 `+1` 机制。每个 merge request 必须有团队内至少3个人 +1,且其中一人必须为同技术栈且比你资深一些的同事 +1,一人为和你参加同一个项目的同事。 + +当有人评论或者有疑问时,你必须解答清楚,别人提出的修改点要么修改好,要么解释清楚,才可以 +1。当 +1 数大于3,则合并分支代码。 + +连带责任制。当你的线上代码存在 bug 时,为你该次 MR +1 的同事具有连带责任。 + + + + + + + +## 参考资料 + +- [WAL](https://en.wikipedia.org/wiki/Write-ahead_logging) +- [WCDB 的 WAL 模式和异步 Checkpoint](https://cloud.tencent.com/developer/article/1031030) + + + + - diff --git a/Chapter1 - iOS/collection.md b/Chapter1 - iOS/collection.md new file mode 100644 index 0000000..f5ea797 --- /dev/null +++ b/Chapter1 - iOS/collection.md @@ -0,0 +1,124 @@ + + +# APM + +内存泄露检测 +各家都是基于 Facebook 的 FBRetainCycleDetector 实现,有: +腾讯 MLeaksFinder +https://github.com/Tencent/MLeaksFinder +PLeakSniffer +https://github.com/music4kid/PLeakSniffer +LeaksMonitor +https://github.com/tripleCC/Laboratory/tree/master/LeaksMonitor + +ANR 检测 +ANR 检测相对比较简单,各家原理基本相同。新建一个 thread 在 threshold 的间隔时间内(一般 200 - 400毫秒)循环等待一个 semaphore,并从主线程 async dispatch semaphore 的 signal 函数,如果超时则报卡顿。 +1. https://gist.github.com/leilee/3275b94c381114332242978fc4366591 +2. https://gist.github.com/Adlai-Holler/ea7e3e98333b3b84f13d19b169ccf989 +1. https://gist.github.com/mikeash/5172803 这个是纯 GCD 的实现 +2. https://gist.github.com/steipete/5664345 PFPDFKit 里用的是这个 +3. https://gist.github.com/jspahrsummers/419266f5231832602bec GitHub 客户端里用的 +4. https://github.com/wojteklu/Watchdog 这个有用 atomic_store 和 atomic_load 实现死锁回调 +5. DoKit +8. https://github.com/ming1016/study/wiki/检测iOS的APP性能的一些方法 +启动优化 +《Reducing Your App's Launch Time》- Apple +https://developer.apple.com/documentation/xcode/improving_your_app_s_performance/reducing_your_app_s_launch_time +《抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%》 +https://mp.weixin.qq.com/s?__biz=MzI1MzYzMjE0MQ==&mid=2247485101&idx=1&sn=abbbb6da1aba37a04047fc210363bcc9&scene=21#wechat_redirect +// 这种纬度的启动时间优化对小公司来说 ROI 太低 +《美团外卖 iOS App 冷启动治理》 +https://tech.meituan.com/2018/12/06/waimai-ios-optimizing-startup.html + +测量进程的 uptime +https://github.com/tripleCC/Laboratory/blob/master/ProcessStartTime/ProcessStartTime/main.m +https://stackoverflow.com/questions/40649964/ios-get-self-process-start-time-after-the-fact/40677286 + +滴滴出行 DoraemonKit +https://github.com/didi/DoraemonKit +功能非常多,感觉侧重了 UI 的运行时调试。APM 的部分主要依赖第三方库,如 MLeakFinder。有提供问题上报机制,所以会有不少额外的网络请求发送到 dokit 的API。 + +腾讯 Matrix +https://github.com/tencent/matrix +功能精简,只有 ANR 和内存检测。 + +美图秀秀 MTHawkeye +https://github.com/meitu/MTHawkeye/tree/develop/MTHawkeye +监控项目比 DoKit 多,ANR 中能看到主线程堆栈信息,网络层有做一些基本优化指标的监控。 + +阿里巴巴 GodEye +https://github.com/zixun/GodEye +项目已经三年没有维护,不过作者的小专栏有提供整体思路,值得参考学习。https://xiaozhuanlan.com/godeye + + + + + +# 技术博客 + +AloneMonkey http://www.alonemonkey.com/ https://github.com/AloneMonkey #ios #re +yulingtianxia https://yulingtianxia.com/ https://github.com/yulingtianxia #ios #ml +SatanWoo http://satanwoo.github.io/ https://github.com/SatanWoo #ios #ml +Naville Zhang https://mayuyu.io/ https://github.com/Naville #ios #re #llvm +Cocoa Oikawa https://blog.0xbbc.com/ https://github.com/BlueCocoa #ios +jmpews https://jmpews.github.io https://github.com/jmpews #ios #re +kov4l3nko https://kov4l3nko.github.io https://github.com/kov4l3nko #ios +mikeash https://www.mikeash.com/pyblog/ #ios +NSBLOGGING https://kandelvijaya.com/post/ #ios +4ch12dy http://4ch12dy.site/ https://github.com/4ch12dy +ibireme https://blog.ibireme.com/ https://github.com/ibireme/ #ios +bang http://blog.cnbang.net/ https://github.com/bang590 #ios +everettjf https://everettjf.github.io/ https://github.com/everettjf #ios #re +Gityuan http://gityuan.com/ #android #flutter +Meituan https://tech.meituan.com/ #ios #android #company +draveness https://draveness.me/ #ios #golang +chy305chy https://chy305chy.github.io/ #ios +Urinx https://urinx.github.io/v6/ https://github.com/Urinx #ios #re +bdunagan http://bdunagan.com/ #ios +la0s https://la0s.github.io/ #re #ios +caijinglong https://www.kikt.top/ #flutter +weishu http://weishu.me/ https://github.com/tiann #android #re +zixia https://github.com/huan #ml #re +xelz https://github.com/xelzmm http://xelz.info/ #re +ChiChou https://github.com/ChiChou https://blog.chichou.me/ #re + + + +# 学习 + +# 资料 +* 经典WWDC视频 + [WWDC2016:Optimizing App Startup Time](https://developer.apple.com/videos/play/wwdc2016/406) + [WWDC2016:Optimizing I/O for Performance and Battery Life](https://developer.apple.com/videos/play/wwdc2016/719/) + [WWDC2017:App Startup Time: Past, Present, and Future](https://developer.apple.com/videos/play/wwdc2017/413/) +* 图片的加载过程 + [Core Image: Performance, Prototyping, and Python](https://developer.apple.com/videos/play/wwdc2018/719/) +* Facebook的二进制优化,开拓思路 + [通过优化二进制布局提升iOS启动性能](https://www.bilibili.com/video/BV1NJ411w7hv) + + +# 文章 +[如何对 iOS 启动阶段耗时进行分析](https://ming1016.github.io/2019/12/07/how-to-analyze-startup-time-cost-in-ios/) +[objc_msgSend Hook 精简学习过程](https://linux.ctolib.com/czqasngit-objc_msgSend_hook.html) +[如何对 iOS 启动阶段耗时进行分析](http://www.starming.com/2019/12/07/how-to-analyze-startup-time-cost-in-ios/) +[App 启动速度怎么做优化与监控?](https://time.geekbang.org/column/article/85331) +[监控所有的OC方法耗时](https://juejin.im/post/5d146490f265da1bc37f2065) +[Hook objc_msgSend to hotfix](https://www.dazhuanlan.com/2019/10/18/5da8a4b2a7da7/) + +[iOS App启动优化(一)—— 了解App的启动流程](https://juejin.im/post/5da830a4e51d457805049817) +[iOS App启动优化(二)—— 使用“Time Profiler”工具监控App的启动耗时](https://juejin.im/post/5dad6bfb6fb9a04de818fcb8) +[iOS App启动优化(三)—— 自己做一个工具监控App的启动耗时](https://juejin.im/post/5de501e0e51d4540a15879ff) + + +* facebook的启动优化(2015年的老文章) +[Optimizing Facebook for iOS start time](https://engineering.fb.com/ios/optimizing-facebook-for-ios-start-time/) + +[优化 App 的启动时间](http://yulingtianxia.com/blog/2016/10/30/Optimizing-App-Startup-Time/#%E5%AE%89%E5%85%A8) +[深入理解App的启动过程](https://github.com/LeoMobileDeveloper/Blogs/blob/master/iOS/What%20happened%20at%20startup%20time.md) +[iOS启动速度优化](https://github.com/BiBoyang/BoyangBlog/blob/master/File/iOS_APM_03.md) +[iOS app启动速度研究实践](https://zhuanlan.zhihu.com/p/38183046?from=1086193010&wm=3333_2001&weiboauthoruid=1690182120) +[iOS Dynamic Framework 对App启动时间影响实测](https://www.jianshu.com/p/3263009e9228) +[iOS App 启动性能优化](https://mp.weixin.qq.com/s/Kf3EbDIUuf0aWVT-UCEmbA) +[今日头条iOS客户端启动速度优化](https://juejin.im/entry/5b6061bef265da0f574dfd21) +[如何精确度量 iOS App 的启动时间](https://www.jianshu.com/p/c14987eee107) +[ iOS App冷启动治理:来自美团外卖的实践 ](https://mp.weixin.qq.com/s/jN3jaNrvXczZoYIRCWZs7w) \ No newline at end of file diff --git a/Chapter2 - Web FrontEnd/2.40.md b/Chapter2 - Web FrontEnd/2.40.md index 8425f77..54d09ed 100644 --- a/Chapter2 - Web FrontEnd/2.40.md +++ b/Chapter2 - Web FrontEnd/2.40.md @@ -178,7 +178,7 @@ electron 分为**渲染进程和主进程**。和 Native 中的概念不一样 -**解决了不稳定问题。**由于进程之间是彼此隔离的,所以当一个页面或者插件奔溃时,受影响的仅仅是当前的页面或者插件进程,并不会影响到浏览器和其他的页面。也就是说解决了早期浏览器某个页面或者插件奔溃导致整个浏览器的奔溃,从而解决了不稳定问题。 +**解决了不稳定问题**。由于进程之间是彼此隔离的,所以当一个页面或者插件奔溃时,受影响的仅仅是当前的页面或者插件进程,并不会影响到浏览器和其他的页面。也就是说解决了早期浏览器某个页面或者插件奔溃导致整个浏览器的奔溃,从而解决了不稳定问题。 **解决了不流畅问题。** 同样,Javascript 进行也是运行在渲染进程中的,所以即使当前 Javascript 阻塞了渲染进程,影响到的也只是当前的渲染页面,并不会影响到浏览器和其他页面或者插件进程(其他的页面的脚本是运行在自己的渲染进程中的)。 @@ -221,7 +221,7 @@ Chrome 团队一直在寻求新的弹性方案,既可以解决资源占用较 #### 1.4 未来面向服务的架构 -2016年 Chrome 官方团队使用 “**面向服务的架构**”(Services Oriented Architecture,简称 SOA)的思想设计了最新的 Chrome 架构。Chrome 整体架构会向现代操作系统所采用的“面向服务的架构”方向发展。 +2016年 Chrome 官方团队使用“**面向服务的架构**”(Services Oriented Architecture,简称 SOA)的思想设计了最新的 Chrome 架构。Chrome 整体架构会向现代操作系统所采用的“面向服务的架构”方向发展。 之前的各种模块会被重构成为单独的服务(Services),每个服务都可以运行在独立的进程中,访问服务必须使用定义好的接口,通过 IPC 进行通信。从而构建一个更内聚、低耦合、易于维护和拓展的系统。 diff --git a/Chapter5 - Network/5.3.md b/Chapter5 - Network/5.3.md index 3a297aa..a896c0b 100644 --- a/Chapter5 - Network/5.3.md +++ b/Chapter5 - Network/5.3.md @@ -32,7 +32,7 @@ 在几乎所有的机器上,**多字节对象都被存储为连续的字节序列**。例如在 C 语言中,一个 `int` 类型的变量 x 地址为 0x100,那么其对应的地址表达式 `&x` 的值为 `0x100`,且 x 的4个字节将被存储在存储器的 `0x100`,`0x101`,`0x102`,`0x103` 位置。 -字节的排列方式有2个通用规则。例如一个多位整数,按照存储地址从低到高排序的字节中,如果该整数的最低有效字节(类似于最低有效位)排在最高有效字节前面,则成为**“小端序“**;反之成为**”大端序“**。在计算机网络中,字节序是一个必须要考虑的因素,因为不同类型的机器可能采用不同标准的字节序,所以均需要按照网络标准进行转化。 +字节的排列方式有2个通用规则。例如一个多位整数,按照存储地址从低到高排序的字节中,如果该整数的最低有效字节(类似于最低有效位)排在最高有效字节前面,则称为“**小端序**“;反之成为”**大端序**“。在计算机网络中,字节序是一个必须要考虑的因素,因为不同类型的机器可能采用不同标准的字节序,所以均需要按照网络标准进行转化。 假设一个类型为 int 的变量 x,位于地址 0x100 处,它的值为 0x01234567,地址范围为 0x100~0x103字节,其内部的排列顺序由机器决定,也就是和 CPU 有关,和操作系统无关。 @@ -164,6 +164,12 @@ NBO(Network Byte Order):按照从高到低的顺序存储,在网络上使用 主机字节顺序(HBO:Host Byte Order):不同机器 HBO 不相同,与 CPU 有关。计算机存储数据有两种字节优先顺序:Big Endian 和 Little Endian。Internet 以 Big Endian 顺序在网络上传输,所以对于在内部是以 Little Endian 方式存储数据的机器,在网络通信时就需要进行转换。 +Big-Endian: PowerPC、IBM、Sun + +Little-Endian:x86、DEC + +ARM 既可以工作在大端模式,也可以工作在小端模式。 + ## 如何转换 diff --git a/README.md b/README.md index 0e57cfa..b13fcd1 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,15 @@ ## **knowledge-kit** + ### 目的 -形式记录大前端路上的探索,主要研究基础平台开发、工程效率、质量监控保证、SDK 输出、多端融合能力、动态化、组件化、工程化能力。 +形式记录大前端路上的探索,主要研究基础平台开发、工程效率、质量监控保证、SDK 输出、多端融合能力、动态化、组件化、工程化能力。 技术点: iOS、Web 前端、后端、Hybrid、Node 的应用、爬虫、反爬虫、后端、数据库、算法等领域。 -偶尔记录自学经济学遇到的概念或者有趣的生活现象解读. +偶尔记录自学经济学遇到的概念或者有趣的生活现象解读。 + ## 目录 @@ -15,10 +17,14 @@ 如果要方便的查看文章目录,请点击[链接](https://github.com/FantasticLBP/knowledge-kit/blob/master/SUMMARY.md) + ## 反馈 -定期更新博文。如果在查看文章的时候发现了问题可以提出 issue。(95年小双鱼, 技术专家进阶中,有技术问题或者想聊聊程序员的事情可以通过微博联系)[微博](http://weibo.com/u/3194053975) +定期更新博文。如果在查看文章的时候发现了问题可以提出 issue。(95年小双鱼,关注大前端领域,有事情可以通过[微博](http://weibo.com/u/3194053975)联系) + + + ## 交流 -如果你也是大前端路上的一名修行者,可以加我 Wechat:704568245,拉你入群一起在「大前端自习室」学习交流。 \ No newline at end of file +如果你也是大前端路上的一名修行者,可以通过[微博](http://weibo.com/u/3194053975)联系我,拉你入群一起在「大前端自习室」学习交流。 \ No newline at end of file diff --git a/SUMMARY.md b/SUMMARY.md index 2b5f2fd..cd4a840 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -82,7 +82,7 @@ * [77、iOS 打包系统构建加速](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.77.md) * [78、上架包预检](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.78.md) * [79、深入理解各种锁](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.79.md) - * [80、打造一个通用、可配置的数据上报 SDK](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) + * [80、打造一个通用、可配置、多句柄的数据上报 SDK](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) * [81、__asm__ 重命名符号](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.81.md) * [82、runtime](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.82.md) * [83、NSURLProtocol 应用场景](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.83.md) diff --git a/assets/2020-06-29-DataUploadConfigurationStructure.png b/assets/2020-06-29-DataUploadConfigurationStructure.png new file mode 100644 index 0000000..32e8017 Binary files /dev/null and b/assets/2020-06-29-DataUploadConfigurationStructure.png differ diff --git a/assets/2020-07-01-MainThreadDispatchTaskToConcurrentQueue.png b/assets/2020-07-01-MainThreadDispatchTaskToConcurrentQueue.png new file mode 100644 index 0000000..8f8f353 Binary files /dev/null and b/assets/2020-07-01-MainThreadDispatchTaskToConcurrentQueue.png differ diff --git a/assets/2020-07-04-DataUploaderSDKStructure.png b/assets/2020-07-04-DataUploaderSDKStructure.png new file mode 100644 index 0000000..fe6ceef Binary files /dev/null and b/assets/2020-07-04-DataUploaderSDKStructure.png differ diff --git a/assets/2020-07-06-XcodeTestCoverage.png b/assets/2020-07-06-XcodeTestCoverage.png new file mode 100644 index 0000000..04a8950 Binary files /dev/null and b/assets/2020-07-06-XcodeTestCoverage.png differ