Как скрэшить любое приложение на айфоне, и как этого не допустить

    image

    Однажды мы, в Surfingbird, нашли странную ошибку, из-за которой приложение стабильно крэшилось. Позже оказалось, что почти любое приложение можно довольно просто скрэшить (даже приложения, написанные самой Apple). О том, что же это за ошибка и как её обойти, мы расскажем в статье.

    Сразу уточним, всё описанное верно для iOS 7 и меньше. О том, что изменилось в iOS 8 — в конце статьи (ничего хорошего, на самом деле).

    Начнём с практики. Есть 2 кнопки, каждая из них показывает новый экран. Просто нажмите одновременно на обе кнопки (нужно немного потренироваться) и затем 2 раза назад:

    image

    Для того, чтобы уронить приложение, нам нужен navigationController. Если в navigationController запушить viewController (с анимацией), потом, не дожидаясь завершения анимации, запушить второй viewController и нажать 2 раза кнопку «назад», тогда приложение скрэшится. Сначала это звучит как бред, ведь никто так не станет делать. Однако, не стоит забывать, что в айфоне есть мультитач и одновременно можно нажать несколько кнопок. Собственно, совсем не сложный код, который к этому приведет:

    @interface ViewController ()
    @property (strong, nonatomic) UIButton *buttonL;
    @property (strong, nonatomic) UIButton *buttonR;
    @end
    
    @implementation ViewController
    
    - (void)viewDidLoad {
        [super viewDidLoad];
    
        self.navigationItem.title = @"root";
        self.view.backgroundColor = [UIColor whiteColor];
    
        self.buttonL = [[UIButton alloc] initWithFrame:CGRectMake(0.0f, 0.0f, 1.0f, 1.0f)];
        self.buttonL.backgroundColor = [UIColor blueColor];
        [self.buttonL setTitle:@"push vc #1" forState:UIControlStateNormal];
        [self.buttonL addTarget:self action:@selector(pushViewControllerOne) forControlEvents:UIControlEventTouchUpInside];
        [self.view addSubview:self.buttonL];
    
        self.buttonR = [[UIButton alloc] initWithFrame:CGRectMake(0.0f, 0.0f, 1.0f, 1.0f)];
        self.buttonR.backgroundColor = [UIColor redColor];
        [self.buttonR setTitle:@"push vc #2" forState:UIControlStateNormal];
        [self.buttonR addTarget:self action:@selector(pushViewControllerTwo) forControlEvents:UIControlEventTouchUpInside];
        [self.view addSubview:self.buttonR];
    }
    
    - (void) viewWillLayoutSubviews {
        CGFloat width = self.view.bounds.size.width /2;
        CGFloat height = self.view.bounds.size.height;
    
        [self.buttonL setFrame:CGRectMake(0.0f, 0.0f, width, height)];
        [self.buttonR setFrame:CGRectMake(width, 0.0f, width, height)];
    }
    
    - (void) pushViewControllerOne {
        
        UIViewController *vc1 = [UIViewController new];
        vc1.navigationItem.title = @"#1";
        vc1.view.backgroundColor = [UIColor whiteColor];
        [self.navigationController pushViewController:vc1 animated:YES];
    }
    
    - (void) pushViewControllerTwo {
        
        UIViewController *vc1 = [UIViewController new];
        vc1.navigationItem.title = @"#2";
        vc1.view.backgroundColor = [UIColor whiteColor];
        [self.navigationController pushViewController:vc1 animated:YES];
    }
    
    @end
    

    Если посмотреть в логи Xcode, можно увидеть предупреждения о вложенной анимации и возможных повреждениях навигейшен бара:
    nested push animation can result in corrupted navigation bar
    Finishing up a navigation transition in an unexpected state. Navigation Bar subview tree might get corrupted.
    Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'Can't add self as subview'

    В сети описание этой ошибки встречается очень редко, а решение было найдено всего одно, и оно не работает. Поэтому, мы решили стать Санта Клаусами и подарить сообществу решение проблемы, которую Apple никак не могут решить.

    Разрешение проблемы весьма очевидное: наследуемся от UINavigationController, все пуши складываем в очередь, затем выполняем их по очереди. Часть кода, необходимая для понимания реализации описана ниже:

    //
    //  StackNavigationController.m
    //
    
    #import "StackNavigationController.h"
    
    @interface StackNavigationController () <UINavigationControllerDelegate>
    @property (nonatomic, assign) BOOL isTransitioning;
    @property (nonatomic, strong) NSMutableArray *tasks;
    @property (nonatomic, weak) id<UINavigationControllerDelegate> customDelegate;
    @end
    
    @implementation StackNavigationController
    
    -(void)viewDidLoad {
        [super viewDidLoad];
        
        if (self.delegate) {
            self.customDelegate = self.delegate;
        }
        self.delegate = self;
        
        self.tasks = [NSMutableArray new];
    }
    
    // we should save navController.delegate to another property because we need delegate
    // to prevent multiple push/pop bug
    -(void)setDelegate:(id<UINavigationControllerDelegate>)delegate
    {
        if (delegate == self) {
            [super setDelegate:delegate];
        } else {
            self.customDelegate = delegate;
        }
    }
    
    - (void) pushViewController:(UIViewController *)viewController animated:(BOOL)animated {
        
        @synchronized(self.tasks) {
            if (self.isTransitioning) {
                
                void (^task)(void) = ^{
                    [self pushViewController:viewController animated:animated];
                };
                
                [self.tasks addObject:task];
            }
            else {
                self.isTransitioning = YES;
                [super pushViewController:viewController animated:animated];
            }
        }
    }
    
    - (void) runNextTask {
    
        @synchronized(self.tasks) {
            if (self.tasks.count) {
                void (^task)(void) = self.tasks[0];
                [self.tasks removeObjectAtIndex:0];
                task();
            }
        }
    }
    
    #pragma mark UINavigationControllerDelegate
    -(void)navigationController:(UINavigationController *)navigationController didShowViewController:(UIViewController *)viewController animated:(BOOL)animated
    {
        self.isTransitioning = NO;
        
        if ([self.customDelegate respondsToSelector:@selector(navigationController:didShowViewController:animated:)]) {
            [self.customDelegate navigationController:navigationController didShowViewController:viewController animated:animated];
        }
        
        // black magic :)
        // if one of push/pop will be without animation - we should place this code to the end of runLoop to prevent bad behavior
        [self performSelector:@selector(runNextTask) withObject:nil afterDelay:0.0f];
    }
    
    @end
    

    Весь код можно найти на гитхабе.

    В последних версиях iOS ситуация немного улучшилась. Если раньше в iOS 7 и меньше, приложение крэшилось при одновременном нажатии на две кнопки, то теперь в iOS 8 для этого понадобится 3 кнопки. Но крэш всё равно неизбежен.

    Повторимся, применяя эту практику можно скрэшить практически любое приложение. У нас, например, стабильно получается крэшить даже App Store. Непонятно, почему Apple не считает это проблемой и не занимается её решением. А вам встречалась подобная проблема в ваших проектах, и как её решали?

    P.S. в комментариях ASkvortsov предлагает использовать свойство exclusiveTouch
    Метки:
    Surfingbird 60,82
    Компания
    Поделиться публикацией
    Комментарии 28
    • +1
      Наблюдал ещё одну подобную интересную ситуацию: если во время анимации перехода на новый экран push-нуть ещё один новый экран… Симулятор просто выдаст ошибку в лог, мол, не могу. И всё. Никаких крэшей, падений… никаких проблем. Просто перешел на экран, на который переходил изначально, а не на следующий. Получалось это, когда переход помещал в ViewDidLoad. Решение — вынести код в например, ViewDidAppear.
      • +1
        А что если перед пушем отключать userInteraction на текущем скрине?
        Есть у вас готовый тестовый проект, на котором можно добиться краша?
        • 0
          Мы одумали про userInteraction, но в случае двойного нажатия это не спасает.
          Готового проекта нет, но его совсем не трудно сделать самостоятельно. Первый пример кода показывает как это сделать.
          • +3
            Сделал тестовый проект, вот ссылка
        • +13
          Тем временем мне в скайп постучался товарищ ASkvortsov:
          Привет, хочу поделиться по поводу статьи с хабра

          у меня просто r/o акк, поэтому не могу откомментить

          developer.apple.com/library/ios/documentation/UIKit/Reference/UIView_Class/#//apple_ref/occ/instp/UIView/exclusiveTouch — то, что нужно использовать

          нужно проставить его в YES обоим кнопкам


          И этот метод работает.
          Видимо нам нужно учить матчасть :)
          • 0
            Любопытная штука. Спасибо. Плюсую. Правда, не соглашусь с тем, что ситуация «самостоятельно скажи каждому контролу, что его нельзя нажимать параллельно с остальными» — это не корявость системы и костыль, а «важная фича, которую нужно откопать в документации и запомнить».
            • +7
              Если выбирать из двух решений (кастомный навигейшн или метод контрола), то я за стандартный ;)
              • 0
                С этим не спорю.
                • +1
                  А я — за кастомный. Потому что его надо один раз сделать — и он становится твоим стандартом, а про «стандартный» надо помнить всякий раз, когда в проект добавляется новая кнопка.
                  • +3
                    Ваше право. Я предпочитаю стандартный, потому что его проще поддерживать. Да и предложенный вариант выглядит костыльно, потому как вряд ли пользователь желает открыть два контроллера сразу.
                    • 0
                      Не то чтобы он выглядит костыльно, скорее странно. Я бы лучше сделал не очередь переходов, а запрет перехода во время перехода — потому что пользователь и правда два контроллера сразу не открывает, это — ситуация-ошибка с неопределенным ожидаемым поведением.
                    • +1
                      Сделайте себе кастомную кнопку, это намного проще и лучше кастомного навигейшен контроллера.
                      • 0
                        Судя про приведенному в статье коду, оба варианта достаточно простые.
                        • +1
                          Проблема множественных нажатий не ограничивается навигейшен контроллером как в статье. Серчбар + пуш, поповер + пуш, модальное окно + поповер, поповер + поп, и т.д. Все сильно зависит от дизайна и архитектуры приложения, во многих случаях, такие действия к крешу не приведут, но могут вылезти различные UI баги. Поэтому, все кнопки, ячейки, барбаттонитемы которые как-то меняют не данные, а представления на экране, должны иметь exclusiveTouch, это самая простая и железная защита от багов такого рода.
                • 0
                  mshershnev Предлагаю добавить это решение в конец поста :)
                • +2
                  Вы серьёзно считаете, что из-за button.exclusiveTouch = NO стоит писать целую статью? Это всё тянулось как минимум с iOS 6, где можно было развлекаться, роняя системные приложения подобным образом. Другой вопрос, почему Apple решили, что UButton должен по умолчанию иметь .exclusiveTouch = NO, и почему не исправляли это так долго.

                  Ну и вы лукавите, что «В сети описание этой ошибки встречается очень редко, а решение было найдено всего одно, и оно не работает»: раз, два (аж 2012 год).
                  • 0
                    При чем здесь UIButton? Запустить пуши можно кучей способов, а маскировать таким образом ошибку в UINavigationController не лучшая практика.
                    • –3
                      При том, что если вы стремитесь выстрелить себе в ногу, пуша два контроллера одновременно и анимировано, это полностью ваша проблема, а не «ошибка» UINavigationController. Если нужно запушить в стек два и более контроллера, правильнее сделать так:

                      UIViewController* firstViewController = [[UIViewController alloc] init];
                      UIViewController* secondViewController = [[UIViewController alloc] init];
                      UIViewController* thirdViewController = [[UIViewController alloc] init];
                      
                      [self.navigationController pushViewController:firstViewController animated:NO];
                      [self.navigationController pushViewController:secondViewController animated:NO];
                      [self.navigationController pushViewController:thirdViewController animated:YES];
                      
                      [firstViewController release];
                      [secondViewController release];
                      [thirdViewController release];
                      

                      Хотя я не могу представить сценария, где надо пушить контроллеры одновременно: пуш должен быть инициирован пользователем, и сразу после пуша пользователь должен иметь возможность вернуться назад, совершив ровно одно действие. Если же действие пользователя инициирует двойной, тройной и т.д. пуш, пользователь слегка удивится тому, что кнопка «назад» возвращает его не на тот контроллер, из которого он сюда попал.
                      • +1
                        Код заставляет перепроверить дату поста ) Я про ARC.
                        • –1
                          Ну не все используют эту технологию. :)
                  • +2
                    Вам плюс за попытку улучшить мир. Однако практическая польза от этого, к сожалению, никакая. Написание своей обертки для UINavigationController ради такой экзотической ошибки неоправданно. Я имею ввиду, что небольшая вероятность внести собственную (другую) ошибку в свой код оказывается выше (на мой субъективный взгляд) микроскопической вероятности, что пользователь наткнется на описанную проблему.
                    «Непонятно, почему Apple не считает это проблемой и не занимается её решением.» — думаю примерно по той же причине. К тому же ошибок такого низкого класса я думаю в iOS не одна и не две. Просто это та, которую вы заметили. В баг-трекере разработчика любого сколько-нибудь серьезного софта копятся такие ошибки. И если они не приводят к уязвимостям в системе их очень редко исправляют.
                    • +1
                      Первое, что я говорю нашим тестировщикам, это тестить такие вот кейсы с множественными нажатиями. Тот мегакостыль, который вы тут описали просто взорвал мне мозг. И то, он полностью не решает проблему двойных нажатий, только внутри навигейшен контроллера. А если у вас две кнопки фильтруют/меняют датасорс таблички, одновременное их нажатие, скорее всего, убъет контроллер. И для этого, у UIView есть пропертя exclusiveTouch, плохо, что она по дефолту не YES. И ее нельзя поставить глобально через UIAppearance, например.

                      В общем, горе от ума. Плохо, что большинство iOS разработчиков не читают документацию, а предпочитают StackOverflow Driven Development.
                      • +1
                        чаще всего, если ответ есть в документации, то на SO просто дают ссылку :)
                        • +1
                          Где-нибудь пятым ответом, а на первом месте почти всегда заплюсованный говнокод, который продолжают плюсовать по инерции.
                      • +2
                        Я предпочитаю проверять перед вызовом pushViewController а не равен ли topViewController текущему, и если вдруг нет то и пушить не надо.
                        • 0
                          Ровно такая же проблема существует при использовании сторибордов. Если есть сегвей на новую сцену и в этой сцене что-то внезапно долго будет выполняться (конструктор, didload, ватевер) и тут же вызвать другой сегвей — будет ровно такая же история.
                          Но тут, к сожалению, своим NavigationController не обойдёшься, поэтому тут рекомендация одна — не выполнять потенциально длинный код в основной очереди
                          • 0
                            Кроме описанного коллегами до меня, у вас еще и тут опасненькая ситуация:
                            @property (nonatomic, strong) NSMutableArray *tasks;
                                       
                            void (^task)(void) = ^{
                                   [self pushViewController:viewController animated:animated];
                            };
                                        
                            [self.tasks addObject:task];
                            


                            Пока не будут завершены все ваши анимации, навконтроллер не умрет, что не очень-то хорошо. Такой себе локальный ретейн луп.

                            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                            Самое читаемое