原文
本文由我們團隊的 李劍飛 組內分享後總結。
引子
公元2016年末,2017年初,某做旅行產品的互聯網公司內,產品經理瘋狂的提 A/BTest 需求,以至於該司程序猿談AB色變,邪惡的產品經理令程序猿們聞風喪膽,苦不堪言…咳咳,扯遠了。
近期團隊做了很多 AB Test 的業務需求,在這種需求日益見多的情況下,我們不得不提升我們的代碼組織方式,以適應或更好的在此類需求上維護我們的代碼。所以有了本文,本文主要闡述了業務團隊在做 AB Test 的一些想法和思路,才疏學淺,不靈賜教。
A/B Test
A/B Test 是什麼?
既然產品經理在 A/B Test 胯下瘋狂的輸出,那我們就要弄清楚,什麼是 A/BTest?為何產品經理如此癡情於 A/B Test ?
A/B Test 就是為了同一個目標制定兩個方案(比如兩個website,app的頁面),讓一部分用戶使用 A 方案,另一部分用戶使用 B 方案,記錄下用戶的使用情況,看哪個方案更接近測試想要的結果,並確信該結論在推廣到全部流量可信。
請注意上述那段話中的黑體字,這將是 AB Test 的核心價值所在。
其實 A/B Test 就是我們中學上化學實驗課時常做的對照試驗,把這種對照試驗搬到了互聯網上,通過改變單一變量的實驗組和原來的對照組做對比,通過數據指標對比,看哪種方案能夠提高用戶體驗(轉化率);
AB Test 的優點有哪些(對產品而言)?
優點1. 灰度發布
灰度發布,是指在黑與白之間,能夠平滑過渡的一種發布方式。A/B Test就是一種灰度發布方式,讓一部分用戶繼續用A,一部分用戶開始用B,如果用戶對B沒有什麼反對意見,那麼逐步擴大范圍,把所有用戶都遷移到B上面來。灰度發布可以保證整體系統的穩定,在初始灰度的時候就可以發現、調整問題,以保證其影響度。
優點2. 可逆方案
可逆方案,有點類似於之前的灰度發布,只不過不灰度的控制力更強,當我們發布後發現實驗組方案出現了嚴重的故障,或者對比數據量相差懸殊,那麼就完全可以全量切換回原來的對照組,保證了線上環境的穩定,不影響用戶的正常使用。
這點,對產品而言就是多了試錯的可能,想想在之前App動態化匮乏的時代,App的發布就是嫁出去的女兒潑出去的水,一去不復返,發布了的產品用戶更新完就不可能在回退到上一個版本。從這一點開始,產品經理就大愛A/B Test !
優點3. 數據驅動
數據驅動,這一點我想至關重要,在目前這種以用戶數據為商業土壤的大數據時代,一個產品是以數據驅動,將能夠更加铿锵有力的支持這個產品的全線發布,也是產品經理對新方案推進的重要王牌。之前要發布一個新產品,要麼美其名曰參考競品(不反對抄襲,抄襲是趕上競爭對手最快的手段,但是並不是超越的手段),要麼腦洞打開,認為某種新的方案或交互體驗能帶來更多的轉化率。這種方法都是沒有數據說明的,只能通過項目上線後進行後評估才能確定是否如產品經理所願真正到達了目標。
通過A/B Test,能在不全量影響線上的正常運轉的情況下,通過對照度和試驗組的數據對比,在短時間能確定哪種方案的優越,從而讓產品的轉化率在短時間能得可信性提升。這也正是產品經理說服老板,並彰顯其能力價值的精華之處!so,大愛!
開發工程師需要關注的事情
六問產品經理
在做 AB Test 之前,有幾個問題是要問產品經理的:
目標是什麼?
AB版本是什麼?
樣本量有多大?
用戶如何分流?
測試時間多長?
如何衡量效果?
這其實就是我們上面那段話中加粗文字的重點,當然,有些問題是服務端需要關心的,比如問題3和4。
那麼客戶端開發需要關心哪些個問題呢?
目標是什麼?
第一個問題,目標是什麼?目的是什麼,這是我們需要問的,對客戶端而言,A/B Test 就需要客戶端維護兩套同樣業務的代碼,這種工作量簡單理解就是之前的double,既然會導致工作量翻倍,那就要問清楚,這次做 A/B Test 的目的是什麼?評估一下真的值得這樣做嗎?雖然有時候胳膊擰不過大腿,但或許在你的分析下,某些需求是不需要做 A/B Test 的。例如:競品已經做了很久方案(你不要告訴我抄都沒自信),或者很明顯的UI改動是優於之前的方案的,等等。
A/B Test 版本是什麼?測試時間多長?
第二個問題,A/B Test 版本是什麼?測試時間多長?其實這兩個問題,就是在確認這個 A/B Test 方案什麼時候上線,什麼時候下線。上下線的時間我們要清楚,因為在這段時間內,我們都需要去維護兩套代碼,而且在 App Size 這麼緊張,大家都在搞瘦身的大環境下,你的安裝包的過大或需就是用戶從一開始就不選擇你們產品的理由!A/B Test 方案,代碼有寫就有刪,何時刪代碼取決於這個 A/B Test 方案何時下線,刪完代碼後有多久的時間給 QA 測試工程師去測試,這都是要安排的。
如何衡量效果?
對於某些開發每天都要聲嘶力竭的說5次以上:“這個(需求)是要算(研發)成本的呀。”這樣用力扣研發成本,盡量把價值低收益低的需求砍下去,把收益不明確的需求排到後面去,相當於在輸出幾乎不變的基礎上,節約了2-3個開發工程師。這也是長期維持團隊的訣竅,從源頭上精簡,而不是苛求超人般的程序員。
如何衡量效果,就是來判斷這種需求是否是價值低收益低或不明確的項目,我們都想做有價值的東西,而不是隨隨便便隨時准備砍掉的功能,希望產品經理敢想,而且加以思考!
iOS A/B Test 方案探索
好了,扯完了產品篇,咱們進入正題。
既然原本一套代碼有了兩種邏輯,或者兩種UI樣式,就需要從原本的邏輯中拆出來,其必然結果是多了一個if判斷語句,那如果判斷的地方多了,咱還這樣if、if、if、if、i….就太失水准了,常言道:寫業務代碼,搬得一手好磚是程序員的基本要求。接下來講下小生的 A/B Test 方案探索歷程。
方案探索歷程
先來大概介紹本次探索的業務背景:
A/B Test 方案背景介紹
A 方案 線上方案,全量;
B 方案,適用於 A 中的一種情況,是 A 方案的子集;
非標准 A/B Test,只是過渡,因為 A 方案為全量方案,無法被下掉,B方案為部分A中的;
我們就以 iOS 中典型的 UITabelView 中的 Delegate 和 DataSource 的協議函數分 A/B 方案來說;
最基本的函數 A/B
剛剛說了,A 方案是一個全量方案,所以這裡的switch會有一個默認方案。但是這種寫法實在是太low了,每一個調用函數中都去判斷一次A/B,影響效率暫且不提,維護起來也是坑坑坑,看見第二張圖的函數列表頁覺得頭大,而且也導致了Controller過於龐大,如果再有一個C方案豈不是要炸?所以這種方案不可取。
方法選擇子 + 字典,緩存式 A/B
由於Objective-C 的Runtime 動態特性,我們可以把方法選擇子緩存在一個字典中,在需要確定 A/B 方案的調用處判斷一次,得到對應方案的方法緩存字典,在調用的時候,只需要去對應的緩存字典中調用就可以了,當然這裡需要擴展NSObject類中的- (id)performSelector:(SEL)aSelector withObject:(id)object;使其支持多個參數的傳遞。
- (id)fperformSelector:(SEL)selector withObjects:(NSArray *)objects { NSMethodSignature *methodSignature = [[self class] instanceMethodSignatureForSelector:selector]; if(methodSignature == nil) { @throw [NSException exceptionWithName:@"拋異常錯誤" reason:@"沒有這個方法,或者方法名字錯誤" userInfo:nil]; return nil; } else { NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSignature]; [invocation setTarget:self]; [invocation setSelector:selector]; //簽名中方法參數的個數,內部包含了self和_cmd,所以參數從第3個開始 NSInteger signatureParamCount = methodSignature.numberOfArguments - 2; NSInteger requireParamCount = objects.count; NSInteger resultParamCount = MIN(signatureParamCount, requireParamCount); for (NSInteger i = 0; i < resultParamCount; i++) { id obj = objects[i]; [invocation setArgument:&obj atIndex:i+2]; } [invocation invoke]; //返回值處理 id callBackObject = nil; if(methodSignature.methodReturnLength) { [invocation getReturnValue:&callBackObject]; } return callBackObject; } }
這種方案僅僅比上個方案提高了一點,就是我們並沒有在每個函數中判斷 A/B ,只判斷了一次。但仍然解決不了Controller過於龐大,無法優雅的擴展的問題。而且還引入了新的問題,就是在進行Runtime消息轉發時的額外開銷,和performSelector返回值需要轉一下類型的尴尬。
設計模式之策略模式
如圖所示,通過策略模式,把需要分 A/B 的方法抽象到一個協議中,然後抽象出一個策略父類去遵循這個協議,其兩個A/B子類也遵循這個協議,這樣在Controller只需要在判斷A/B策略的調用處初始化對應的策略類,通過父類指針去調用子類的協議方法,達到A/B函數的執行。這樣采用了面向對象的繼承和多態的機制,完成了一次完美的 A/B 函數執行,AB策略可以自由切換,避免了使用多重條件判斷,同時滿足了開閉原則,對擴展開放(增加新的策略類),對修改關閉。
Protocol協議分發器,運用於 A/B Test 方案
協議分發可以簡單理解為將協議代理交給多個對象實現,類似於多播委托。
Protocol協議代理在開發中應用頻繁,開發者經常會遇到一個問題——事件的連續傳遞。比如,為了隔離封裝,開發者可能經常會把tableview的delegate或者datesource抽離出獨立的對象,而其它對象(比如VC)需要獲取某些delegate事件時,只能通過事件的二次傳遞。有沒有更簡單的方法了?協議分發器正好可以派上用場。
既然能實現多播委托消息分發,那麼消息分發時,指定的分發的接收者,不就是 A/B Test 的消息分為A/B分發嗎?
先給各位看官呈上干貨,LJFABTestProtocolDispatcher是一個協議分發器,通過該工具能夠輕易實現將協議事件分發給多個實現者,並且能指定調用哪些實現者。比如最常見的UITableViewDelegate和UITableViewDataSource協議,通過LJFABTestProtocolDispatcher能夠非常容易發分發給多個對象,而且可以指定A/B方案執行,具體可參考Demo。
原理解析
原理並不復雜, 協議分發器Dispatcher並不實現Protocol協議,其只需將對應的Protocol事件分發給不同的實現者Implemertor。如何實現分發?
NSObject對象主要通過以下函數響應未實現的Selector函數調用
方案一:動態解析
+ (BOOL)resolveInstanceMethod:(SEL)sel; + (BOOL)resolveClassMethod:(SEL)sel;
方案二:快速轉發
//返回實現了方法的消息轉發對象 - (id)forwardingTargetForSelector:(SEL)aSelector OBJC_AVAILABLE(10.5, 2.0,9.0, 1.0);
方案三:慢速轉發
//函數簽名 - (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector //函數調用 - (void)forwardInvocation:(NSInvocation *)anInvocation OBJC_SWIFT_UNAVAILABLE("");
因此,協議分發器Dispatcher可以在該函數中將Protocol中Selector的調用傳遞給實現者Implemertor,由實現者Implemertor實現具體的Selector函數即可,而現實指定的A/B調用,需要傳入所有實現者組織的下標,來指定調用
/** 協議分發器Dispatcher可以在該函數中將Protocol中Selector的調用傳遞給實現者Implemertor,由實現者Implemertor實現具體的Selector函數即可 */ - (void)forwardInvocation:(NSInvocation *)anInvocation { SEL aSelector = anInvocation.selector; if (!ProtocolContainSel(self.prococol, aSelector)) { [super forwardInvocation:anInvocation]; return; } if (self.indexImplemertor) { for (NSInteger i = 0; i < [self.implemertors count]; i++) { ImplemertorContext *implemertorContext = [self.implemertors objectAtIndex:i]; if (i == self.indexImplemertor.integerValue && [implemertorContext.implemertor respondsToSelector:aSelector]) { [anInvocation invokeWithTarget:implemertorContext.implemertor]; } } } else { for (ImplemertorContext *implemertorContext in self.implemertors) { if ([implemertorContext.implemertor respondsToSelector:aSelector]) { [anInvocation invokeWithTarget:implemertorContext.implemertor]; } } } }
設計關鍵
如何做到只對Protocol中Selector函數的調用做分發是設計的關鍵,系統提供有函數
objc_method_description protocol_getMethodDescription(Protocol *p, SEL aSel, BOOL isRequiredMethod, BOOL isInstanceMethod)
通過以下方法即可判斷Selector是否屬於某一Protocol
struct objc_method_description MethodDescriptionForSELInProtocol(Protocol *protocol, SEL sel) { struct objc_method_description description = protocol_getMethodDescription(protocol, sel, YES, YES); if (description.types) { return description; } description = protocol_getMethodDescription(protocol, sel, NO, YES); if (description.types) { return description; } return (struct objc_method_description){NULL, NULL}; } BOOL ProtocolContainSel(Protocol *protocol, SEL sel) { return MethodDescriptionForSELInProtocol(protocol, sel).types ? YES: NO; }
還有一點,協議分發器並不是一個單例,而是一個局部變量,那如何來防止一個局部變量延遲釋放呢?這裡使用了“自釋放”的一種思想,看源碼:
- (instancetype)initWithProtocol:(Protocol *)protocol withIndexImplemertor:(NSNumber *)indexImplemertor toImplemertors:(NSArray *)implemertors { if (self = [super init]) { self.prococol = protocol; self.indexImplemertor = indexImplemertor; NSMutableArray *implemertorContexts = [NSMutableArray arrayWithCapacity:implemertors.count]; [implemertors enumerateObjectsUsingBlock:^(id implemertor, NSUInteger idx, BOOL * _Nonnull stop){ ImplemertorContext *implemertorContext = [ImplemertorContext new]; implemertorContext.implemertor = implemertor; [implemertorContexts addObject:implemertorContext]; // 為什麼關聯個 ProtocolDispatcher 屬性? // "自釋放",ProtocolDispatcher 並不是一個單例,而是一個局部變量,當implemertor釋放時就會觸發ProtocolDispatcher釋放。 // key 需要為隨機,否則當有兩個分發器是,key 會被覆蓋,導致第一個分發器釋放。所以 key = _cmd 是不行的。 void *key = (__bridge void *)([NSString stringWithFormat:@"%p",self]); objc_setAssociatedObject(implemertor, key, self, OBJC_ASSOCIATION_RETAIN_NONATOMIC); }]; self.implemertors = implemertorContexts; } return self; }
注意事項
協議分發器使用需要了解如何處理帶有返回值的函數 ,比如
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
我們知道,iOS中,函數執行返回的結果存在於寄存器R0中,後執行的會覆蓋先執行的結果。因此,當遇到有返回結果的函數時,返回結果以後執行的函數返回結果為最終值。
感謝
Protocol協議分發器,本人並不是首創,也是看了這篇文章Protocol協議分發器得到運用於 A/B Test 的靈感,在這裡感謝作者和開源社區。
業務模塊內的 A/B Test 組件探索
隨著A/B Test 的代碼越來越多,業務模塊內的 A/B Test 組件化,無非是為了更方便的上下業務的 A/B Test 代碼,提高工作效率,讓寫代碼和刪代碼變成一件快樂的事情。
關於 iOS 組件化,網上也有很多文章,這裡就不炒冷飯了,大家可以搜索一下關於組件化的一些定義和經驗。
在整個客戶端已經被組件化的今天,不是架構組的業務程序員可不可以嘗試來解決一下業務模塊內的 A/B Test 組件化呢?iOS 組件化大部分都是圍繞 Cocoapods 來展開的,所以在基於 Cocoapods iOS 高度組件化的的框架下, 我們先來問幾個技術問題。
相同架構的不同靜態庫是否可合並?
這個問題主要是基於目前整個客戶端架構,各個業務線向殼工程提供了自己的靜態庫,
我們大部分時間(打包時)都會合並不同架構的相同靜態庫,相同架構的不同靜態庫是否可合並?
答案是,可以的。
在合並不同架構的相同靜態庫時,用到以下命令:
查看靜態庫支持的CPU架構
lipo -info libname.a(或者libname.framework/libname)
合並靜態庫
lipo -create 靜態庫存放路徑1 靜態庫存放路徑2 ... -output 整合後存放的路徑
靜態庫拆分
lipo 靜態庫源文件路徑 -thin CPU架構名稱 -output 拆分後文件存放路徑
那麼合並相同架構的不同靜態庫是怎麼做的?
靜態庫文件也稱為“文檔文件”,它是一些.o文件的集合。在Linux(Unix)中使用工具“ar”對它進行維護管理。它所包含的成員(member)就是若干.o文件。除了.o文件,還有一個一個特殊的成員,它的名字是__.SYMDEF。它包含了靜態庫中所有成員所定義的有效符號(函數名、變量名)。因此,當為庫增加了一個成員時,相應的就需要更新成員__.SYMDEF,否則所增加的成員中定義的所有的符號將無法被連接程序定位。完成更新的命令是:
ranlib libname.a
舉個例子:
我們有倆個靜態庫libFlight.a和libHotel.a,合並成一個libFlight_Hotel.a。
取出相同架構下的Lib.a。
首先查看靜態庫Flight.a的架構:
lipo -info Flight.a
可以看到:
input file /Users/f.li/Desktop/相同架構的不同靜態庫合並/libFlight.a is not a fat file Non-fat file: /Users/f.li/Desktop/相同架構的不同靜態庫合並/libFlight.a is architecture: x86_64
libFlight.a is not a fat file 和 libFlight.a is architecture: x86_64
fat file 那麼代表這個包是支持多平台的,not a fat file 就是不支持多平台的,架構是x86_64。
當然,如果是 fat file ,我們就需要取出相同平台架構的庫。
lipo libFlight.a -thin x86_64 -output libFlight.a
這樣,就會取出 x86_64 架構下的libFlight.a。
查看庫中所包含的文件列表。
ar -t /Users/f.li/Desktop/相同架構的不同靜態庫合並/libFlight.a __.SYMDEF SORTED Flight.o
看到libFlight.a有兩個文件,__.SYMDEF SORTED和Flight.o
解壓出object file(即.o後綴文件)。
~libFlight_o ar xv /Users/f.li/Desktop/libFlight.a x - __.SYMDEF SORTED x - Flight.o
這樣,在libFlight_o文件夾內,就有了__.SYMDEF SORTED和Flight.o這個兩個文件。
同樣,在libHotel_o文件夾內獲得__.SYMDEF SORTED和Hotel.o
合並,重新打包。
把__.SYMDEF SORTED和Flight.o,還有Hotel.o移動到libFlight_Hotel_o文件夾內。把重新打包object file;
ar rcs libFlight_Hotel.a /Users/f.li/Desktop/libFlight_Hotel_o/*o
這樣就得到了libFlight_Hotel.a。
更新__.SYMDEF文件。
其實,我們是把Hotel.o加入了LibFlight.a中,最後,需要更新__.SYMDEF文件。
ranlib libFlight_Hotel.a
如果包含頭文件,那麼把頭文件也放到一個文件內在使用libFlight_Hotel.a的工程中引入就可以了。
但是顯然這樣做太麻煩。
Xcode 子工程?
Xcode 子工程,其實是幫助我們在一個工程內配合git submodule 來進行分模塊開發。
整理下思路。
創建一個 target(Flight_Hotel_Project) 為 Application 的 Xcode 工程為父工程。並git化。
創建一個 tagget(Flight_SubProject) 為 Static Library 的 Xcode 工程為子工程,並git化。
為父工程添加git submodule。具體參照git。
將子工程文件夾拖入父工程。
在父工程的 link binary with library 加入Flight_SubProject.a
在父工程的 header search paths 中添加頭文件搜索路徑 $(SRCROOT)/Flight_SubProject/Flight_SubProject,其中$(SRCROOT)宏代表你的工程文件目錄。
編譯運行。
這樣其實回到了之前架構的一個狀態,無法調用解耦,相互依賴嚴重。
Cocoapods 的 subspecs 是什麼概念?subspec 有自己獨立的git倉庫嗎?是可以理解成pod的子pod嗎?
答案是,subspec 不是獨立的代碼庫,只是編譯時候分開進行,最後會和pod形成一個產物。
為什麼會問 Cocoapods subspecs?因為在基於Cocoapods架構組件化後,業務對外部提供的是靜態庫類型的pod。
源碼類型是subspec,在引入pod時,可以選擇引入subspec目錄,也可以設置podspec的默認subsepc,subspect之間也可以有依賴關系。
最終解決方案是什麼?
業務線內部拆分可以做成多個 pod,最後提供一個 pod 依賴所有業務內部的組件 pod,這樣不影響外部架構打包,業務線也可以靈活修改。
最後這個依賴所有業務內部組件的pod對外提供的也是一個靜態庫,業務內部的組件pod不需要提供靜態庫,但是也會有獨立的Git。
當然這種業務內部的 A/B Test 組件化方案目前處於探索階段,因為目前我們的 A/B Test 的代碼量並沒有達到需要我們進行拆分的地步,所有這階段尚處於技術拓展調(yi)研(yin)階段。
小結
關於 iOS A/B Test 的探索目前小生就這麼多,A/B Test 對於產品而言確實是一種比較好的方案,尤其是可逆性和數據驅動,當然小生是站在開發的角度上來看待 A/B Test。既然是對產品有利的方案,我們的代碼就應該時代潮流,畢竟技術是為業務服務的。
前段時間在看 sunny 直播時,談到了 iOS 開發的進階速度
純日常開發 < 純看書、博客 < 自己試驗、Demo < 寫博客 < 系統性分享和討論 < 提供完整的開源方案
之前自己的進階速度僅僅到寫博客的分段,最近這半年在團隊中發起了技術分享了和團隊博客的浪潮,希望能夠向系統性分享、討論和完整的開源方案這兩個高分段沖分,本次結合最近的業務和自身的一些想法和實踐,完成了一次沖分嘗試,希望在沖分的路上越戰越勇!