#ios #objective_c
Этот вопрос адресован участникам, хорошо знакомым с Cucumber и с проектами типа Selenium, Capybara и другими, так как именно они в первую очередь поймут специфику данного вопроса. Вот оригинальный пост автора библиотеки Frank: Writing iOS acceptance tests using Kiwi - Being Agile В этом посте рассматривается возможность написания Acceptance tests средствами самого Objective-C с использованием лишь Xcode's Application Testing Target (см. соотв. раздел "Setting Up Application Unit Tests" в документации Apple) и пары библиотек (PublicAutomation и Shelley, которые обеспечивают связь с UIAutomation). Оказалось, что такая возможность существует и подход, описанный в этой статье работает прекрасно. Вот код, в котором содержится то, что описано в этой статье (ссылка на него лежит в самом конце странице статьи, в комментариях). Следующий отрывок кода, содержащегося в этой статье, содержит метод, производящий нажатие на объект класса UIView, заданный с помощью селектора. - (void)tapViewViaSelector:(NSString *)viewSelector{ [UIAutomationBridge tapView:[self viewViaSelector:viewSelector]]; sleepFor(0.1); //ugh } Обратите внимание на строку sleepFor(0.1); //ugh. О ней-то и пойдёт речь в данном вопросе: Если вы посмотрите репозиторий на Github, вы увидите, что за ней скрывается следующая дефиниция: #define sleepFor(interval) (CFRunLoopRunInMode(kCFRunLoopDefaultMode, interval, false)) Данная строка - это наивная (не в смысле наивности автора, а в смысле это первое простое решение, которое пришло бы и мне в голову) попытка автора дождаться исчерпания главной Run loop, крутящейся в главном потоке (те, кто это знают, - знают), перед тем, как перейти к следующему действию. Пример возможной последовательности UI interactions, который продемонстрирует наглядно, о чём идёт речь: Я на экране логина приложения. Я нажимаю (tap) текстовое поле ввода E-mail адреса (Всплывает клавиатура) Я ввожу текст, нажимаю Enter (Клавиатура скрывается) Я нажимаю (tap) текстовое поле ввода Password. (Всплывает клавиатура) Я ввожу текст, нажимаю Enter (Клавиатура скрывается) Я нажимаю кнопку "Войти" (происходит запрос к серверу про аутентификацию, в случае успеха происходит насыщенный событиями переход на главный экран приложения) Я должен увидеть UILabel, содержащий текст "Вы находитесь на главной странице" Описанный сценарий полагается на описанные в статье хелперы и ЕСЛИ убрать sleepFor() из всех кода всех интеракций, стоящих за каждым из описанных действий (нажатия, вводы текстовых полей, swipe gestures и всё-всё остальное), то каждое следующее действие не будет дожидаться окончания анимаций, transitions и прочих действией, стоящих за текущим шагом и требующих времени, так как они не блокируют главный поток, а записываются на выполнения (being scheduled) в главную петлю главного потока (main thread's run loop). Простой пример: не дождавшись пропадания клавиатуры от предыдущего поля, в момент её пропадания, -[UIAutomationBridge tapViewViaSelector:] будет опираться на промежуточную координату поля, в которое нужно будет ввести значение и таким образом клик(тап) не сработает по адресу. Таких примеров можно привести бесчисленное множество (например, I should eventually see UILabel named "Some text" on a main screen). Итак, ЗАДАЧА: Написать хэлпер, который с наименьшим временем ожидания, с наименьшим количеством пустых прогонов main run loop и соответственно с наименьшим количеством пустых CPU циклов, обеспечит гарантированное ожидание момента, пока main run loop не будет исчерпана, чтобы можно было переходить к следующему шагу test scenario. ПРИЛОЖЕНИЕ 1 Вот мой мой текущий промежуточный код, который работает в силу того, что он написан in a paranoid fashion: // DON'T like it static inline void runLoopIfNeeded() { // https://developer.apple.com/library/mac/#documentation/CoreFOundation/Reference/CFRunLoopRef/Reference/reference.html while (CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.1, YES) == kCFRunLoopRunHandledSource); // DON'T like it if (CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.1, YES) == kCFRunLoopRunHandledSource) runLoopIfNeeded(); } // DON'T like it static inline BOOL eventually(BOOL(^eventualBlock)(void)) { NSDate *timeoutDate = [NSDate dateWithTimeIntervalSinceNow:10]; runLoopIfNeeded(); while (eventualBlock() == NO) { if ([timeoutDate compare:[NSDate date]] == NSOrderedAscending) { @throw [NSException exceptionWithName:NSGenericException reason:@"Wait timeout has expired" userInfo:nil]; } runLoopIfNeeded(); } runLoopIfNeeded(); return YES; } Вот следующее промежуточное решение: // It is much better, than it was, but still unsure static inline void runLoopIfNeeded() { // https://developer.apple.com/library/mac/#documentation/CoreFOundation/Reference/CFRunLoopRef/Reference/reference.html __block BOOL flag = NO; // https://stackoverflow.com/questions/7356820/specify-to-call-someting-when-main-thread-is-idle dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{ dispatch_async(dispatch_get_main_queue(), ^{ flag = YES; }); }); while (CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.1, YES) == kCFRunLoopRunHandledSource); if (flag == NO) runLoopIfNeeded(); } ПРИЛОЖЕНИЕ 2 В момент написания этого вопроса, я подумал о том, что возможны приложения, которые устанавливают таймеры (или Run loop sources) да так, что искомая функция никогда не сможет исчерпать главный поток, но давайте будем считать, что ничего экстравагантного не происходит, и __всегда наступает, такой момент, когда петля начинает крутиться вхолостую, ожидая поступления какого-либо действия исключительно от пользователя (то есть CFRunLoopRunInMode начинает стабильно возвращать kCFRunLoopRunHandledSource. A source was processed. Я пока что ни разу не встречал исключений - это всегда наступает.) ПРИЛОЖЕНИЕ 3 Я даже просто буду рад увидеть любой дельный комментарий по рассматриваемому вопросу от коллег, знающих об этих вещах лучше, чем я. ПРИЛОЖЕНИЕ 4 Я бы отдал всю свою репутацию за исчерпывающий ответ на этот вопрос. Скорее всего, мы сможем договориться с модераторами об этом ;) ПРИЛОЖЕНИЕ 5 Это простейший пример, показывающий необходимость исчерпания main run loop. Только, пожалуйста, не подумайте, что, если ваш вариант runLoopIfNeeded сработает для этого примера, то задача решена: в реальном приложении в main run loop может быть назначено такое количество всякой всячины, что ваш метод будет спотыкаться об их количество, продолжая главный поток значительно раньше, чем вам нужно. Я проверяю свой runLoopIfNeeded на своём iOS-приложении, на нём же я буду проверять ваш вариант. Итак, простейший пример: dispatch_async(^{ // ... NSLog(@"Completed"); }); runLoopIfNeeded(); // Нужно, чтобы главный поток останавливался на этой строке, продолжая крутить при этом главную петлю (main run loop), дожидаясь пока в консоли появится completed. NSLog(@"I want to be called exclusively AFTER the moment when animation becomes completed");
Ответы
Ответ 1
Публикую своё текущее решение (если интересно, оно здесь): static inline void runLoopIfNeeded() { // https://developer.apple.com/library/mac/#documentation/CoreFOundation/Reference/CFRunLoopRef/Reference/reference.html while (CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.1, YES) == kCFRunLoopRunHandledSource); } Поздний комментарий: Если сравнить это решение с промежуточными решениями (их было два, см. ПРИЛОЖЕНИЕ 1 в вопросе), то станет видно, что все три очень похожи, но это последнее состоит вообще из одной строки - это результат нескольких прояснений (см. ниже прояснение 3) и небольшого обсуждения в топике параллельно открытом на SO (см. конец вопроса). В ходе расследования прояснилось несколько вариантов поведения, о которых я не знал, а точнее просто никогда не было времени подумать над ними: Первый случай Оказывается, методы типа +[UIView animateWithDuration:...] запускают свои анимации не в главном потоке (об этом написано здесь, в разделе Starting Animations Using the Block-Based Methods), а дочернем. Отсюда вытекает, что их невозможно "исчерпать" даже правильно написанным методом runLoopIfNeeded(). Пример: [UIView animateWithDuration:10 animations:^{ self.view.backgroundColor = [UIColor redColor]; // в течение 10 секунд будем краснеть } completion:^(BOOL finished) { NSLog(@"Completion called"); }]; runLoopIfNeeded(); // <- (*) (*) Сколько не бейся над его имплементацией, а поймать момент completion не удастся, если только специально не крутить run loop 10 и более секунд, чего нельзя делать в runLoopIfNeeded(), иначе он, очевидно, перестанет быть хелпером общего назначения. Второй случай, который невозможно "вычерпывать" с помощью runLoopIfNeeded это конструкции типа: double delayInSeconds = 10.0; dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayInSeconds * NSEC_PER_SEC)); dispatch_after(popTime, dispatch_get_main_queue(), ^(void){ // <#code to be executed on the main queue after delay#> }); Я многократно проверил - они записываются куда-то не туда, откуда (CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.1, YES) == kCFRunLoopRunHandledSource) может вернуть YES. То есть эти отложенные запуски посредством dispatch_after совершенно невидимы для runLoopIfNeeded основанного на CFRunLoopRunInMode. Даже не думаем о том, чтобы ловить такое в принципе посредством runLoopIfNeeded. Третий случай, очень интересный dispatch_async поддаётся исчерпанию с помощью (CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.1, YES). Что это значит? Опять же простой пример (обратите внимание, я использую массив для регистрации вхождений вместо всяких NSLog, так как каждое присутствие NSLog в критичном коде, требуя 1 цикла run loop, искажает эксперимент): NSMutableArray *registry = [NSMutableArray new]; dispatch_async(dispatch_get_main_queue(), ^{ [registry addObject:@"main_queue"]; }); [registry addObject:@"before run loop"]; CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.1, YES); // Первый вариант запускаем так, а второй - комментируем эту строку [registry addObject:@"after run loop"]; NSLog(@"registry: %@", [registry componentsJoinedByString:@", "]); Первый вариант: registry: before run loop, main_queue, after run loop Второй вариант: registry: before run loop, after run loop (то есть к моменту NSLog назначенный блок не выполнился) Из этого третьего случая следует очень-очень интересное следствие: Вам приходилось сталкиваться с необходимостью юнит-тестирования асинхронных сетевых запросов с использованием, скажем, библиотеки AFNetworking, ну или даже просто +[NSURLConnection sendAsynchronousRequest:queue:completionHandler:]? На эту тему написано огромное количество топиков на SO. Я не могу вдаваться сейчас в подробности того, как это делается обычно, поэтому просто для знающих предмет покажу такой пример: [someAsynchronousRequestWithCompletionHandler:^(id JSON){ // some test assertions on JSON }]; runLoopIfNeeded(); // на весь запрос может потребоваться 2-3 запуска CFRunLoopInMode // (*) (*) так вот присутствия этой строки будет вполне достаточно, чтобы "выпрямить" асинхронный запрос, то есть дождаться его выполнения без использования всяких прожорливых на CPU-циклы циклов методов вроде __block BOOL done = NO; [someAsynchronousRequestWithCompletionHandler:^(id JSON){ // some test assertions on JSON done = YES }]; while(done == NO) {} или на секунды и требующих правильной настройки: dispatch_semaphore_t sema = dispatch_semaphore_create(0); [someAsynchronousRequestWithCompletionHandler:^(id JSON){ // some test assertions on JSON dispatch_semaphore_release(sema); }]; while (dispatch_semaphore_wait(semaphore, DISPATCH_TIME_NOW)) { [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:2]]; } Теперь обратно к главному вопросу: Описанные три случая и ещё столько же неописанных убедительно показали мне, что насколько бы ни был хорош метод runLoopIfNeeded(), невозможно знать гарантированно и наверняка, что ничего важного не происходит сейчас на экране, текущая реализация runLoopIfNeeded даёт, я полагаю навскидку, процентов 60% достоверности. Для того, чтобы все мои хелперы, похожие на аналогичные в Capybara заработали, мне понадобилось ввести дополнительный хелпер, который используя параноидальную стратегию проверяет самые разные утверждения на истинность: // Is it possible to make it less paranoid? static inline BOOL eventually(BOOL(^eventualBlock)(void)) { NSDate *timeoutDate = [NSDate dateWithTimeIntervalSinceNow:10]; runLoopIfNeeded(); while (eventualBlock() == NO) { if ([timeoutDate compare:[NSDate date]] == NSOrderedAscending) { @throw [NSException exceptionWithName:NSGenericException reason:@"Wait timeout has expired" userInfo:nil]; } runLoopIfNeeded(); } runLoopIfNeeded(); return YES; } Так, например хэлпер для ввода текста в заданное текстовое поле с добавлением изрядной доли паранойи начинает выглядеть так: #pragma mark #pragma mark Text fills void fillTextFieldWithText(UITextField *textField, NSString *text) { runLoopIfNeeded(); tapView(textField); BOOL keyboardAppeared = eventually(^BOOL{ return [UIAutomationBridge checkForKeyboard] && textField.isEditing; }); if (keyboardAppeared){ [UIAutomationBridge typeIntoKeyboard:text]; [textField endEditing:YES]; eventually(^BOOL{ return textField.isEditing == NO; }); } runLoopIfNeeded(); } Аналогичные хэлперы принимают такой же вид, и в результате я могу запускать тесты следующего вида без каких-либо проблем с тем, что что-то ещё не до конца появилось, не стало видно, не перестало работать и т.п.: it(@"should...", ^{ tapButtonWithTitle(@"Зарегистрироваться"); [[theValue(RegistrationScreen.isCurrentScreen) should] beYes]; fillTextFieldWithText(RegistrationScreen.nameField, @"stanislaw"); fillTextFieldWithText(RegistrationScreen.emailField, @"s.pankevich@gmail.com"); fillTextFieldWithText(RegistrationScreen.passwordField, @"11111"); tapButtonWithTitle(@"Зарегистрироваться"); [[theValue(eventually(^{ return hasLabelWithText(@"Проверьте, пожалуйста, почту"); })) should] beYes]; tapButtonWithTitle(@"Готово"); [[theValue(LoginScreen.isCurrentScreen) should] beYes]; }); Те, кому приходилось сталкиваться со связкой Cucumber + Capybara наверняка увидят замечательную схожесть этого примера с тем, как подобные вещи пишутся на Capybara или уровнем ниже на Selenium. Если кому-то интересно, то я обернул всю эту логику в проект NativeAutomation, который выложил на Github. ОБНОВЛЕНО ЕЩЁ ПОЗЖЕ: я только что получил первый ответ в топике, параллельно открытом на SO, по которому видно, что отвечающий мыслит точно в таком же направлении.
Комментариев нет:
Отправить комментарий