引子
公元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/B
由於Objective-C 的Runtime 動態特性,我們可以把方法選擇子快取在一個字典中,在需要確定 A/B 方案的呼叫處判斷一次,得到對應方案的方法快取字典,在呼叫的時候,只需要去對應的快取字典中呼叫就可以了,當然這裡需要擴充套件NSObject類中的- (id)performSelector:(SEL)aSelector withObject:(id)object;
使其支援多個引數的傳遞。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
- (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函式呼叫
- 方案一:動態解析
12+ (BOOL)resolveInstanceMethod:(SEL)sel;+ (BOOL)resolveClassMethod:(SEL)sel; - 方案二:快速轉發
12//返回實現了方法的訊息轉發物件- (id)forwardingTargetForSelector:(SEL)aSelector OBJC_AVAILABLE(10.5, 2.0,9.0, 1.0); - 方案三:慢速轉發
1234//函式簽名- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector//函式呼叫- (void)forwardInvocation:(NSInvocation *)anInvocation OBJC_SWIFT_UNAVAILABLE("");
因此,協議分發器Dispatcher可以在該函式中將Protocol中Selector的呼叫傳遞給實現者Implemertor,由實現者Implemertor實現具體的Selector函式即可,而現實指定的A/B呼叫,需要傳入所有實現者組織的下標,來指定呼叫
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
/** 協議分發器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函式的呼叫做分發是設計的關鍵,系統提供有函式
1 |
objc_method_description protocol_getMethodDescription(Protocol *p, SEL aSel, BOOL isRequiredMethod, BOOL isInstanceMethod) |
通過以下方法即可判斷Selector是否屬於某一Protocol
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
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; } |
還有一點,協議分發器並不是一個單例,而是一個區域性變數,那如何來防止一個區域性變數延遲釋放呢?這裡使用了“自釋放”的一種思想,看原始碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
- (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; } |
注意事項
協議分發器使用需要了解如何處理帶有返回值的函式 ,比如
1 |
- (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架構
1lipo -info libname.a(或者libname.framework/libname) - 合併靜態庫
1lipo -create 靜態庫存放路徑1 靜態庫存放路徑2 ... -output 整合後存放的路徑 - 靜態庫拆分
1lipo 靜態庫原始檔路徑 -thin CPU架構名稱 -output 拆分後檔案存放路徑
那麼合併相同架構的不同靜態庫是怎麼做的?
靜態庫檔案也稱為“文件檔案”,它是一些.o檔案的集合。在Linux(Unix)中使用工具“ar”對它進行維護管理。它所包含的成員(member)就是若干.o檔案。除了.o檔案,還有一個一個特殊的成員,它的名字是__.SYMDEF
。它包含了靜態庫中所有成員所定義的有效符號(函式名、變數名)。因此,當為庫增加了一個成員時,相應的就需要更新成員__.SYMDEF
,否則所增加的成員中定義的所有的符號將無法被連線程式定位。完成更新的命令是:
1 |
ranlib libname.a |
舉個例子:
我們有倆個靜態庫libFlight.a
和libHotel.a
,合併成一個libFlight_Hotel.a
。
- 取出相同架構下的Lib.a。
首先檢視靜態庫Flight.a
的架構:
1lipo -info Flight.a
可以看到:
12input file /Users/f.li/Desktop/相同架構的不同靜態庫合併/libFlight.a is not a fat fileNon-fat file: /Users/f.li/Desktop/相同架構的不同靜態庫合併/libFlight.a is architecture: x86_64
libFlight.a is not a fat file 和 libFlight.a is architecture: x86_64fat file 那麼代表這個包是支援多平臺的,not a fat file 就是不支援多平臺的,架構是x86_64。
當然,如果是 fat file ,我們就需要取出相同平臺架構的庫。
1lipo libFlight.a -thin x86_64 -output libFlight.a這樣,就會取出 x86_64 架構下的
libFlight.a
。 - 檢視庫中所包含的檔案列表。
123ar -t /Users/f.li/Desktop/相同架構的不同靜態庫合併/libFlight.a__.SYMDEF SORTEDFlight.o
看到
libFlight.a
有兩個檔案,__.SYMDEF SORTED
和Flight.o
- 解壓出object file(即.o字尾檔案)。
123~libFlight_o ar xv /Users/f.li/Desktop/libFlight.ax - __.SYMDEF SORTEDx - 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;1ar rcs libFlight_Hotel.a /Users/f.li/Desktop/libFlight_Hotel_o/*o這樣就得到了
libFlight_Hotel.a
。 - 更新
__.SYMDEF
檔案。
其實,我們是把Hotel.o
加入了LibFlight.a中,最後,需要更新__.SYMDEF
檔案。1ranlib 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 開發的進階速度
純日常開發
之前自己的進階速度僅僅到寫部落格的分段,最近這半年在團隊中發起了技術分享了和團隊部落格的浪潮,希望能夠向系統性分享、討論和完整的開源方案這兩個高分段衝分,本次結合最近的業務和自身的一些想法和實踐,完成了一次衝分嘗試,希望在衝分的路上越戰越勇!