Страницы

Поиск по вопросам

понедельник, 15 октября 2018 г.

Objective-C: как (возможно ли) гарантированно исчерпать main run loop?

Этот вопрос адресован участникам, хорошо знакомым с 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");


Ответ

Публикую своё текущее решение (если интересно, оно здесь):
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, по которому видно, что отвечающий мыслит точно в таком же направлении.

Комментариев нет:

Отправить комментарий