分析&方案
首先回顧下標籤的要素:
- 唯一:同一頁面不同檢視標籤不能重名
- 不變:每次開啟這個頁面,檢視的標籤不能變,否則還得改測試指令碼
初定方案:標籤格式為:『superview標籤』+『特殊標識』
也就是說這是個遞迴的方案,標籤內容保留了檢視層級,並用特殊標識保證了唯一性。那麼重點自然在於特殊標識的選取了,為了滿足標籤唯一且不變的要素,最後選擇程式碼中宣告檢視變數的變數名。如果有重名的檢視變數,則需要手動新增程式碼設定其標籤。
那麼這裡又引發了一些問題:
- 如果變數是類的屬性或例項變數,則可保證其命名的唯一性。如果程式碼沒有改動,則標籤也不會變化。即使程式碼有變化,也肯定是改變了 UI 業務邏輯,那麼正常情況下測試指令碼肯定也是要改的,所以不必考慮因為改動程式碼帶來的標籤變化。
- 如果檢視是區域性變數,那麼很有可能從全域性來看某兩處的區域性變數重名。
- 如何獲取程式碼中區域性變數的變數名,並與其物件繫結起來。(繫結指得是講變數名賦值給
accessibilityIdentifier
之類的屬性)
由於編譯器對程式碼進行了詞法分析、語法分析和語義分析,此時區域性變數名早就消失了。執行的時候區域性變數在記憶體裡也只是個冰冷冷的物件罷了,不像類的例項變數或屬性那樣可以獲取名稱。既然編譯階段之後就已經拿不到區域性變數名了,那隻能從原始碼層面下手。比如在 addSubview:
的時候獲取引數名,並將參數列與引數例項繫結。
從函式呼叫堆疊獲取上一層呼叫函式在原始碼中的位置(比如檔名和行數),然後用正則匹配抓取 addSubview:
的引數名,看樣子是個方案。獲取函式呼叫棧對應原始碼位置可以用 backtrace_symbols
,或者 [NSThread callStackSymbols]
等,但這都脫離了純淨的 iOS 環境,需要在 PC 中處理原始碼內容,無法將變數名關聯到 iOS 執行環境中。當然也可以用指令碼程式幫我們在原始碼指定位置中插入新增標籤的邏輯程式碼,但這樣的弊端有二:
- 維護成本較高,指令碼在向原始碼中插入加標籤邏輯程式碼時需要判斷是否已經插入過這段程式碼,增加了出錯機率
- 對程式碼內容變化的魯棒性不高。因為重構等行為很可能把原有程式碼順序弄亂,指令碼需要考慮很多情況。每次新增程式碼都要重新跑一次指令碼。
既然區域性變數的名字可能重名並難於與例項繫結,不妨另闢蹊徑尋求其他方法。這裡提出一種假設:程式設計師寫程式碼的時候之所以將一個檢視變數宣告為類的屬性,是因為以後還會經常用到它。而那些被宣告為區域性變數的,肯定是臨時用一次就不用了。這種用臨時變數建立的檢視新增到檢視層級中內容極有可能就不會變了,其大部分應該是 UILabel
、UIButton
、UIImageView
以及被當做容器檢視功能的 UIView
例項。針對這種情況可以將其檢視的內容作為標籤的『特殊標識』。比如 UILabel
的文字內容、UIButton
的文字內容加圖片名以及 UIImageView
的圖片名。
總之,針對區域性變數,將其變數名作為『特殊標識』來組成標籤是很不明智的(可能重名),且實現難度較大。如果無法生成唯一的『特殊標識』就只能採取手動寫程式碼加標籤的方案。
實踐&探索
針對自動為類的屬性和例項變數加標籤,我採用 hook 和遞迴的方式。hook UIView
中的accessibilityIdentifier
的原因是此時的檢視層級更全,並且是惰性生成標籤。其實使用 accessibilityLabel
也是可以的,但對 VoiceOver 功能會有影響,畢竟變數名不像檢視文字內容那樣有實際意義。
PS:這裡之所以不 hook addSubview:
是因為在新增 subview 時,檢視層級樹並不完整。雖然呼叫 accessibilityIdentifier
時檢視層級也可能不完整(比如在 addSubview:
之前呼叫 accessibilityIdentifier
),但這樣的機率遠遠小於前者:很多時候是 [a addSubview:b]
,但此時 a
還沒有 superview
,那麼如果 hook addSubview:
方法,就只能保留 a
以下的檢視層級。這並不是我想看到的。
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 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 |
@implementation UIView (TBUIAutoTest) + (void)load { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ [self swizzleSelector:@selector(accessibilityIdentifier) withAnotherSelector:@selector(tb_accessibilityIdentifier)]; }); } + (void)swizzleSelector:(SEL)originalSelector withAnotherSelector:(SEL)swizzledSelector { Class aClass = [self class]; Method originalMethod = class_getInstanceMethod(aClass, originalSelector); Method swizzledMethod = class_getInstanceMethod(aClass, swizzledSelector); BOOL didAddMethod = class_addMethod(aClass, originalSelector, method_getImplementation(swizzledMethod), method_getTypeEncoding(swizzledMethod)); if (didAddMethod) { class_replaceMethod(aClass, swizzledSelector, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod)); } else { method_exchangeImplementations(originalMethod, swizzledMethod); } } #pragma mark - Method Swizzling - (NSString *)tb_accessibilityIdentifier { NSString *accessibilityIdentifier = [self tb_accessibilityIdentifier]; if (accessibilityIdentifier && accessibilityIdentifier.length>1) { if ([[accessibilityIdentifier substringToIndex:1] isEqualToString:@"("]) { return [self tb_accessibilityIdentifier]; } } else if ([accessibilityIdentifier isEqualToString:@"null"]) { accessibilityIdentifier = @""; } // 通過例項物件獲取其變數名 NSString *labelStr = [self.superview findNameWithInstance:self]; NSString *subLabelStr = @""; NSString *superLabelStr = @""; if (self.superview) { superLabelStr = self.superview.accessibilityIdentifier; } if (labelStr && ![labelStr isEqualToString:@""]) { subLabelStr = [NSString stringWithFormat:@"%@(%@)",superLabelStr,labelStr]; } else { if ([self isKindOfClass:[UILabel class]]) {//UILabel 使用 text subLabelStr = [NSString stringWithFormat:@"%@(%@)",superLabelStr,((UILabel *)self).text]; } else if ([self isKindOfClass:[UIImageView class]]) {//UIImageView 使用 image 的 imageName subLabelStr = [NSString stringWithFormat:@"%@(%@)",superLabelStr,((UIImageView *)self).image.accessibilityIdentifier]; } else if ([self isKindOfClass:[UIButton class]]) {//UIButton 使用 button 的 text和圖片的標籤 subLabelStr = [NSString stringWithFormat:@"%@(%@%@)",superLabelStr,((UIButton *)self).titleLabel.text,((UIButton *)self).imageView.image.accessibilityIdentifier]; } else if (accessibilityIdentifier) {// 已有標籤,則在此基礎上再次新增更多資訊 subLabelStr = [NSString stringWithFormat:@"%@(%@)",superLabelStr,accessibilityIdentifier]; } else { subLabelStr = [NSString stringWithFormat:@"%@",superLabelStr]; } if ([self isKindOfClass:[UIButton class]]) { self.accessibilityValue = [NSString stringWithFormat:@"%@(%@)",superLabelStr,((UIButton *)self).currentBackgroundImage.accessibilityIdentifier]; } } if ([subLabelStr isEqualToString:@"()"] || [subLabelStr isEqualToString:@"(null)"] || [subLabelStr isEqualToString:@"null"]) { subLabelStr = @""; } [self setAccessibilityIdentifier:subLabelStr]; return subLabelStr; } @end |
hook 那段程式碼很簡單就不細說了,主要是 tb_accessibilityIdentifier
方法。標籤字串的準確格式為:『superview標籤』(『特殊標識』),在這個格式中,括號代表了檢視層級。因為是逐級向上獲取標籤,所以為了避免重複計算更上層檢視的標籤,當存在符合格式定義的 accessibilityIdentifier
時直接呼叫 [self tb_accessibilityIdentifier]
返回 _accessibilityIdentifier
的值,與之對應的是方法結尾的 [self setAccessibilityIdentifier:subLabelStr]
用來給 _accessibilityIdentifier
賦值生成好的標籤。
對於獲取不到變數名的臨時變數和檢視層級中一些系統私有的檢視變數,才去之前分析中提到的方案特殊處理。好一長串的 if-else
啊,為了處理這些特殊情況寫一坨髒程式碼我也是醉了。最後別忘處理下無意義的字串,比如 “null”。
為了將 UIImage
的圖片資源名和例項繫結,我又 hook 了 UIImage
的 imageNamed:
類方法:
1 2 3 4 5 |
+ (UIImage *)tb_imageNamed:(NSString *)imageName{ UIImage *image = [UIImage tb_imageNamed:imageName]; image.accessibilityIdentifier = imageName; return image; } |
下面說下獲取變數名的 findNameWithInstance:
方法的實現:
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 |
@implementation UIResponder (TBUIAutoTest) -(NSString *)nameWithInstance:(id)instance { unsigned int numIvars = 0; NSString *key=nil; Ivar * ivars = class_copyIvarList([self class], &numIvars); for(int i = 0; i < numIvars; i++) { Ivar thisIvar = ivars[i]; const char *type = ivar_getTypeEncoding(thisIvar); NSString *stringType = [NSString stringWithCString:type encoding:NSUTF8StringEncoding]; if (![stringType hasPrefix:@"@"]) { continue; } if ((object_getIvar(self, thisIvar) == instance)) {//此處 crash 不要慌! key = [NSString stringWithUTF8String:ivar_getName(thisIvar)]; break; } } free(ivars); return key; } - (NSString *)findNameWithInstance:(UIView *) instance { id nextResponder = [self nextResponder]; NSString *name = [self nameWithInstance:instance]; if (!name) { return [nextResponder findNameWithInstance:instance]; } return name; } @end |
因為我們並不知道某個檢視物件在哪個類中充當了屬性或成員變數,所以 findNameWithInstance:
方法會沿著響應鏈向上遞迴查詢,範圍不僅涵蓋 UIView
,連 UIViewController
都不能放過。每找一層就要呼叫 nameWithInstance:
方法用 Objective-C Runtime 遍歷成員變數列表的方式查詢變數名。
最後為了檢視效果,我還 hook 了 addSubview:
方法,在其中新增長按手勢。 longPress:
方法中主要是讓長按的檢視高亮並彈 Alert 顯示自動化測試標籤的內容,程式碼就不貼了。
1 2 3 4 5 6 7 8 9 |
- (void)tb_addSubview:(UIView *)view { if (!view) { return; } [self tb_addSubview:view]; UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(longPress:)]; longPress.delegate = [TBUIAutoTest sharedInstance]; [self addGestureRecognizer:longPress]; } |
TBUIAutoTest
這個單例功能是處理手勢衝突,僅此而已:
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 |
@implementation TBUIAutoTest + (instancetype)sharedInstance { static dispatch_once_t onceToken; static TBUIAutoTest *_instance; dispatch_once(&onceToken, ^{ _instance = [TBUIAutoTest new]; }); return _instance; } - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer { return YES; } - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRequireFailureOfGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer { if ([otherGestureRecognizer.view isDescendantOfView:gestureRecognizer.view]) { return YES; } if (![otherGestureRecognizer isKindOfClass:[UILongPressGestureRecognizer class]]) { return YES; } return NO; } @end |
感受&總結
我是楊(gu)阿莫,今天我給大家要講的是一個月前測試帥哥要求加自動化測試標籤後老大開會討論方案組內高工一致不贊同手動加並要求自動加並在最後老大欽點這個事情就交給我了的故事。這其中還經歷了方案的各種改,五子棋同學的實力參(jiao)謀(ji),以及拉屎時把本該思考人生的時間花在了改進方案。這個月部落格實在不知道該寫啥眼看月底了再不更新怕以後再也不想更新了呢所以你會發現這篇文章水水的科科!
如果大家有更好的方案,或者覺得我的方案一開始就跑偏了,甚至是已經有一個不用手動加標籤程式碼的現成的超屌超牛逼的 iOS 自動化測試框架,請告訴我!據說整個騰訊都是手動加自動化測試標籤,老大說做有挑戰的事情才有意思嘛。