iOS 統(tǒng)計打點那些事
統(tǒng)計打點是 App 開發(fā)里很重要的一個環(huán)節(jié),App 的運行狀態(tài)、改版后的效果、用戶的各種行為等都需要打點,市面上也有不少可供選擇的第三方庫。 假設(shè)產(chǎn)品有這么個需求:當用戶在詳情頁點擊購買按鈕時,記錄一下事件。我們實現(xiàn)起來大概會是這樣
- // DetailViewController.m
- - (void)onBuyButtonTapped:(UIButton *)button
- {
- // do some stuff, maybe send a request to server
- [XXXAnalytics event:kSomeEventYouDefined];
- }
這個需求就這樣輕松搞定了,但細細想想還是有不少問題的:
頁面上會有其他的 Button,可能每個 Button 都要放上這么一段代碼。
這些統(tǒng)計其實跟具體的業(yè)務(wù)無關(guān),沒必要跟業(yè)務(wù)代碼混雜在一起,不優(yōu)雅。
當改版或者重構(gòu)時,有可能忘了把相應(yīng)的打點代碼遷移過去。
所以需要一種更好的方式來做這件事,這就是使用 AOP(Aspect-Oriented-Programming),翻譯過來就是「面向切面編程」
通過預(yù)編譯方式和運行期動態(tài)代理實現(xiàn)在不修改源代碼的情況下給程序動態(tài)統(tǒng)一添加功能的一種技術(shù)。
簡單來說,就是可以動態(tài)的在函數(shù)調(diào)用的前后插一段代碼。iOS 可以使用 Pete Steinberger 開發(fā)的 Aspects 這個庫,大致原理是在 runtime 層,通過 swizzle method 來實現(xiàn)的。
來看一個小 Demo
- [UIViewController aspect_hookSelector:@selector(viewWillAppear:) withOptions:AspectPositionAfter usingBlock:^(idaspectInfo, BOOL animated) {
- NSLog(@"View Controller %@ will appear animated: %tu", aspectInfo.instance, animated);
- } error:NULL];
這樣在 UIViewController 的 viewWillAppear: 被調(diào)用后,還會再調(diào)一下我們定義的 Block,這段日志就會被輸出。而打點正好符合這種場景:正事干完之后,額外干一些跟業(yè)務(wù)無關(guān)的事情。
上面的例子,我們通過 AOP 來做的話,大概就是這樣
- // DetailViewController.m
- - (void)onBuyButtonTapped:(UIButton *)button
- {
- // do some stuff, maybe send a request to server
- // no need to call [XXXAnalytics event:]
- }
- // AppDelegate.m
- - (void)setupAnalytics
- {
- [DetailViewController aspect_hookSelector:@selector(onBuyButtonTapped:) withOptions:AspectPositionAfter usingBlock:^(idaspectInfo, BOOL animated) {
- [XXXAnalytics event:kSomeEventYouDefined];
- } error:NULL];
- }
這樣統(tǒng)計代碼就從業(yè)務(wù)代碼中剝離出來了。但是又產(chǎn)生了一個新問題,多個 Button Event,豈不是要寫很多行這樣的代碼,「重復(fù)」這樣的事情,作為一個程序員怎么能忍,簡單,造一個方法
- - (void)trackEventWithClass:(Class)klass selector:(SEL)selector event:(NSString *)event
- {
- [klass aspect_hookSelector:@selector(selector) withOptions:AspectPositionAfter usingBlock:^(idaspectInfo, BOOL animated) {
- [XXXAnalytics event:event];
- } error:NULL];
- }
使用起來就像這樣
- - (void)setupAnalytics
- {
- [self trackEventWithClass:DetailViewController selector:@seletor(onBuyButtonTapped:) event:kSomeEventYouDefined];
- [self trackEventWithClass:ListViewController selector:@seletor(followButtonTapped:) event:kAnotherEventYouDefined];
- // ...
- }
看起來又干凈了些。這時,產(chǎn)品經(jīng)理又提了個需求:當這個按鈕點擊時,如果已經(jīng)登錄了,發(fā)送 EventA,如果沒有登錄則發(fā)送 EventB,也就是說,不再只是 [XXXAnalytics event:] 這么簡單了,還需要加上額外的邏輯,這也難不倒我們,加上一個 block 即可。
- - (void)trackEventWithClass:(Class)klass
- selector:(SEL)selector
- eventHandler:(void (^)(idaspectInfo))eventHandler
- {
- [klass aspect_hookSelector:@selector(selector) withOptions:AspectPositionAfter usingBlock:^(idaspectInfo, BOOL animated) {
- if (eventHandler) {
- eventHandler(aspectInfo);
- }
- } error:NULL];
- }
- // 使用
- [self trackEventWithClass:DetailViewController selector:@seletor(onBuyButtonTapped:) eventHandler:^(idaspectInfo){
- user.loggedIn ? [XXXAnalytics event:EventA] : [XXXAnalytics event:EventB];
- }];
好了,現(xiàn)在只要不是太復(fù)雜的打點邏輯(那些需要方法上下文變量的)我們都能應(yīng)付了,接下來就該等產(chǎn)品來驗收了。產(chǎn)品搬了個凳子坐在身邊,然后點一下 Button,看一下 Console,被幾輪蹂躪后,產(chǎn)品也慢慢地接受了這種驗收方式。后來某一天,忽然發(fā)現(xiàn)某一項或某幾項數(shù)據(jù)有異常,然后找到開發(fā),瞄了一眼:哦,這個方法被重構(gòu)了?;蛘咝录拥姆椒ㄍ思咏y(tǒng)計了。只能等到下個版本再加上了,如果只是一般的統(tǒng)計數(shù)據(jù)倒還好,跟錢相關(guān)的就麻煩了。
那么有沒有一種直觀的驗證方式呢?當然,程序員是***的呀。一個理想的狀況是,產(chǎn)品打開 App 后,開啟某個開關(guān)就能看到所有會發(fā)送 Event 的按鈕,就像這樣
其中數(shù)字代表了 EventID。如何實現(xiàn)呢?還記得注冊事件時,我們有傳入 class 和 selector 么,一般我們都會有一個 BaseViewController,那么就可以在 BaseViewController 的 viewDidAppear: 里做點文章了。
- // BaseViewController.m
- - (void)viewDidAppear:(BOOL)animated
- {
- [super viewDidAppear:animated];
- // 獲取已經(jīng)注冊過的 classes
- NSDictionary *registeredClasses = [OurAnalytics sharedInstance].registeredClasses;
- [registeredClasses enumerateKeysAndObjectsUsingBlock:^(NSString *className, NSArray *selectors, BOOL *stop) {
- if ([self isKindOfClass:NSClassFromString(className)]) {
- // 如何根據(jù) selector 找到它的宿主?
- }
- }];
- }
所以現(xiàn)在問題就剩下,如何根據(jù) selector 找到對應(yīng)的 Button,這里要注意,有些 Button 可能要等網(wǎng)絡(luò)請求完成才會出現(xiàn),比如 TableViewCell 里的 Button。
沒有想到太方便的方法,簡單粗暴點就是設(shè)置個 Timer 每隔一段時間掃一下 subviews,如果是 button 或 包含 tapGesture 的,就拿它們的 action 對比一下,如果 match 就可以高亮那個 button / view 了。
EventID 也一樣,之前在注冊時也會傳一個 EventID 過來,這里直接顯示出來即可。對于那些傳 eventHandler 的就不行了。
所以理論上是可行的,性能上會稍微有點損耗,尤其是當 subViews 的結(jié)構(gòu)比較復(fù)雜時,不過只是內(nèi)部用來做驗證,所以這也不是什么問題。
看起來效果已經(jīng)不錯了,有沒有可能讓這套體系再靈活一些?比如可以從后端制定打點規(guī)則?客戶端只是讀取一個配置文件,就像這樣
- - (void)setupAnalytics
- {
- // analyticsRules 是從配置文件中讀取出來的
- [analyticsRules enumerateObjectsUsingBlock:^(NSDictionary *rules, NSUInteger idx, BOOL *stop) {
- Class klass = NSClassFromString(rules[@"class"]);
- SEL selector = NSSelectorFromString(rules[@"selector"]);
- NSString *eventID = rules[@"eventID"];
- [self trackEventWithClass:klass seletor:seletor event: eventID];
- }];
- }
那如果在后臺的時候填錯了 Class 或 Selector 怎么辦?還好有 objc_getClassList 和 class_copyMethodList 這兩個運行時方法,有了它們就可以在 App 啟動時掃一遍已注冊的類(過濾掉 UI / NS 開頭的),然后將它們的 seletor 也一并保存下來發(fā)送給服務(wù)端,當然這種操作只需在適當?shù)臅r機做一下就可以了,比如集成打包時。
現(xiàn)在,這套體系就比較完整了。當然這只是我的一些構(gòu)想,并沒有在實踐中嘗試過,所以肯定會踩到各種各樣的坑,不過至少看起來是個可行的方案。