UIAutomation 是蘋果提供的自動化測試框架,iOS 不像 Android 那樣可以在 xml 文件中自動生成唯一的 id 作為標簽,需要我們手動為控件添加可訪問性(Accessibility)。如何讓一個控件的可訪問標簽在當前頁面唯一且不變呢?當然純手工在代碼中插入加標簽的邏輯代碼肯定是可行的,但費時費力,所以我在這裡做一些用程序自動化添加標簽嘗試性的探索。
Github 傳送門: TBUIAutoTest
分析&方案
首先回顧下生成標簽需要符合的必要條件:(以下將“自動化測試標簽”簡稱為“標簽”):
頁面內唯一:頁面內不同視圖標簽不重復。
運行時恆定:每次運行應用中此頁面時視圖的標簽始終恆定,無需修改測試腳本。
為了滿足標簽的必要條件,可以選擇其在某個類中的屬性名稱作為標簽,因為同一個類中是不可能有兩個相同名稱的屬性的,並且屬性名稱都是固定在代碼裡的,也就是恆定不變的。在此總結下標簽的策略流程:
如果變量是某個類的屬性,則可滿足標簽條件。因為代碼沒有改動,則標簽也不會變化。即使代碼有變動,也肯定是因為業務邏輯變更導致了界面上的變化,那麼測試腳本肯定也是要改的,所以無需多慮此種情況。
如果是臨時創建的局部變量,同一頁面中很有可能有相同名字的局部變量。而且 Objective-C Runtime 無法獲取局部變量名稱,所以針對此種情況盡量采用其他來源的內容作為標簽。
獲取代碼中局部變量的變量名,並與其對象綁定起來。(綁定是指將標簽賦給accessibilityIdentifier 之類的屬性)
對於上面第二條種的局部變量生成標簽的策略,詳細闡述如下:
由於編譯器對代碼進行了詞法分析、語法分析和語義分析,此時局部變量名早就消失了。運行的時候局部變量在內存裡也只是個冰冷冷的對象罷了,不像類的實例變量或屬性那樣可以獲取名稱。既然編譯階段之後就已經拿不到局部變量名了,所以只能對源代碼進行文字處理來獲取局部變量名。比如使用宏定義在 addSubview: 方法調用的時候傳入參數名,並將參數表與參數實例綁定。但是這對 Swift 算是個方案,對於語法怪異的 Objective-C 來說是不可行的。
既然從方法外不能直接傳入參數,那麼嘗試從方法內來獲取函數調用堆棧。查找上一層函數在源碼的位置,用正則表達式得到 addSubview: 的參數名稱。從函數調用堆棧獲取上一層調用函數在源碼中的位置(比如文件名和行數),然後用正則匹配抓取 addSubview: 的參數名,看樣子是個方案。獲取函數調用棧對應源碼位置可以用 backtrace_symbols,或者 [NSThread callStackSymbols] 等,但這些操作都不是在 iOS 系統內,不能將標簽綁定到 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 以下的視圖層級。這並不是我想看到的。所以在 UIView 中的 accessibilityIdentifier 方法中生成標簽可以盡可能地保留完整的視圖層級,並且是 lazy load 的方式,降低 CPU 使用峰值。
放個殘缺版的代碼,心情好的時候更新下,重要的還是思路:
@implementation UIView (TBUIAutoTest) + (void)load { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ [self swizzleSelector:@selector(accessibilityIdentifier) withAnotherSelector:@selector(tb_accessibilityIdentifier)]; [self swizzleSelector:@selector(accessibilityLabel) withAnotherSelector:@selector(tb_accessibilityLabel)]; }); } + (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.length > 0 && [[accessibilityIdentifier substringToIndex:1] isEqualToString:@"("]) { return accessibilityIdentifier; } else if ([accessibilityIdentifier isEqualToString:@"null"]) { accessibilityIdentifier = @""; } NSString *labelStr = [self.superview findNameWithInstance:self]; if (labelStr && ![labelStr isEqualToString:@""]) { labelStr = [NSString stringWithFormat:@"(%@)",labelStr]; } else { if ([self isKindOfClass:[UILabel class]]) {//UILabel 使用 text labelStr = [NSString stringWithFormat:@"(%@)",((UILabel *)self).text?:@""]; } else if ([self isKindOfClass:[UIImageView class]]) {//UIImageView 使用 image 的 imageName labelStr = [NSString stringWithFormat:@"(%@)",((UIImageView *)self).image.accessibilityIdentifier?:[NSString stringWithFormat:@"image%ld",(long)((UIImageView *)self).tag]]; } else if ([self isKindOfClass:[UIButton class]]) {//UIButton 使用 button 的 text 和 image labelStr = [NSString stringWithFormat:@"(%@%@)",((UIButton *)self).titleLabel.text?:@"",((UIButton *)self).imageView.image.accessibilityIdentifier?:@""]; } else if (accessibilityIdentifier) {// 已有 label,則在此基礎上再次添加更多信息 labelStr = [NSString stringWithFormat:@"(%@)",accessibilityIdentifier]; } if ([self isKindOfClass:[UIButton class]]) { self.accessibilityValue = [NSString stringWithFormat:@"(%@)",((UIButton *)self).currentBackgroundImage.accessibilityIdentifier?:@""]; } } if ([labelStr isEqualToString:@"()"] || [labelStr isEqualToString:@"(null)"] || [labelStr isEqualToString:@"null"]) { labelStr = @""; } [self setAccessibilityIdentifier:labelStr]; return labelStr; } - (NSString *)tb_accessibilityLabel { if ([self isKindOfClass:[UIImageView class]]) {//UIImageView 特殊處理 NSString *name = [self.superview findNameWithInstance:self]; if (name) { self.accessibilityIdentifier = [NSString stringWithFormat:@"(%@)",name]; } else { self.accessibilityIdentifier = [NSString stringWithFormat:@"(%@)",((UIImageView *)self).image.accessibilityIdentifier?:[NSString stringWithFormat:@"image%ld",(long)((UIImageView *)self).tag]]; } } if ([self isKindOfClass:[UITableViewCell class]]) {//UITableViewCell 特殊處理 self.accessibilityIdentifier = [NSString stringWithFormat:@"(%@)",((UITableViewCell *)self).reuseIdentifier]; } return [self tb_accessibilityLabel]; } @end
在獲取到變量名之後,還需要進行處理才能作為標簽。首先在變量名外加一層括號,目的是區分下此標簽是代碼生成的而不是手動加上去的。方法結尾的 [self setAccessibilityIdentifier:subLabelStr] 用來給 _accessibilityIdentifier 賦值生成好的標簽。
對於獲取不到變量名的臨時變量和視圖層級中一些系統私有的視圖變量,才去之前分析中提到的方案特殊處理。好一長串的 if-else 啊,為了處理這些特殊情況寫一坨髒代碼我也是醉了。最後別忘處理下無意義的字符串,比如 “null”。
在 UIAutomation 生成控件樹時,大部分 UIImageView 和 UITableViewCell 無法通過 hook accessibilityIdentifier 來在控件樹中獲取到自動化測試標簽,或者獲得的標簽不是屬性名而是圖片資源名。解決方案是 hook accessibilityLabel 方法,並在其中為 UIImageView 和 UITableViewCell 加自動化測試標簽。
為了將 UIImage 的圖片資源名和實例綁定,我又 hook 了 UIImage 的 imageNamed: 類方法:
+ (UIImage *)tb_imageNamed:(NSString *)imageName{ UIImage *image = [UIImage tb_imageNamed:imageName]; image.accessibilityIdentifier = imageName; return image; }
下面說下獲取變量名的 findNameWithInstance: 方法的實現:
@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]; } if ([name hasPrefix:@"_"]) { //去掉變量名的下劃線前綴 name = [name substringFromIndex:1]; } return name; }
因為我們並不知道某個視圖對象在哪個類中充當了屬性或成員變量,所以 findNameWithInstance: 方法會沿著響應鏈向上遞歸查找,范圍不僅涵蓋 UIView,連 UIViewController 都不能放過。每找一層就要調用 nameWithInstance: 方法用 Objective-C Runtime 遍歷成員變量列表的方式查找變量名。
別忘去掉開頭的下劃線,因為在Runtime中保存的其實是成員變量的名稱,默認都是帶有下劃線前綴的。
因為 hook 的方法不是 accessibilityLabel,所以不能通過 Xcode 中的 View Debug 頁面來查看加標簽的效果。最好的方法還是通過 UIAutomation 抓取控件樹,這樣的效果比較真實。為了便於在真機上查看標簽內容,我為所有視圖增加了長按手勢,長按視圖後彈警告框顯示自動化測試標簽的內容。
hook addSubview: 方法,在其中添加長按手勢。 longPress: 方法中主要是讓長按的視圖高亮並彈 Alert 顯示自動化測試標簽的內容,代碼就不貼了。
- (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 單例作為所有手勢的委托,並在其中實現一些手勢捕獲優先級的邏輯,以此解決手勢沖突帶來的問題:
@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 自動化測試框架,請告訴我!據說整個騰訊都是手動加自動化測試標簽,老大說做有挑戰的事情才有意思嘛。
歡迎給 TBUIAutoTest 提 PR 和 Issue