Однажды мы, в 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 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