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

в 15:08, , рубрики: apple, iOS, iOS разработка, mobile development, xcode, Блог компании Surfingbird, мобильные приложения, разработка под iOS

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 не считает это проблемой и не занимается её решением. А вам встречалась подобная проблема в ваших проектах, и как её решали?

Автор: mshershnev

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js