Как скрэшить любое приложение на айфоне, и как этого не допустить
Однажды мы, в Surfingbird, нашли странную ошибку, из-за которой приложение стабильно крэшилось. Позже оказалось, что почти любое приложение можно довольно просто скрэшить (даже приложения, написанные самой Apple). О том, что же это за ошибка и как её обойти, мы расскажем в статье.
Сразу уточним, всё описанное верно для iOS 7 и меньше. О том, что изменилось в iOS 8 — в конце статьи (ничего хорошего, на самом деле).
Начнём с практики. Есть 2 кнопки, каждая из них показывает новый экран. Просто нажмите одновременно на обе кнопки (нужно немного потренироваться) и затем 2 раза назад:
Для того, чтобы уронить приложение, нам нужен 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 barFinishing 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 ()
@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
— (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 не считает это проблемой и не занимается её решением. А вам встречалась подобная проблема в ваших проектах, и как её решали?