UITableView reloadData падает при повторном появлении в iOS 11

Обновление: на мой взгляд, вопрос все еще актуален, и поэтому я отмечаю потенциальный недостаток дизайна, который у меня был в моем коде. Я вызывал асинхронный метод viewWillAppear: данных в viewWillAppear: VC1, который НИКОГДА не является хорошим местом для заполнения данных и перезагрузки табличного представления, если все не сериализовано в основном потоке. В вашем коде всегда есть потенциальные точки выполнения, когда вы должны перезагрузить табличное представление, а viewWillAppear не является одним из них. Я всегда перезагружал источник данных табличного представления в VC1 viewWillAppear при возвращении из VC2. Но идеальный дизайн мог бы использовать переход от VC2 и заполнить источник данных при его подготовке (prepareForSegue) прямо из VC2, только когда это действительно требовалось. К сожалению, похоже, никто до сих пор не упомянул об этом :(


Я думаю, что есть подобные вопросы, которые были заданы ранее. К сожалению, никто из них по сути не решил проблему, с которой я сталкиваюсь.

Моя структура проблемы очень проста. У меня есть два контроллера представления, скажем, VC1 и VC2. В VC1 я показываю список некоторых элементов в UITableView, загруженных из базы данных, а в VC2 я показываю детали выбранного элемента и позволяю его редактировать и сохранять. И когда пользователь возвращается в VC1 из VC2, я должен заново заполнить источник данных и перезагрузить таблицу. И VC1, и VC2 встроены в UINavigationController.

Звучит очень тривиально, и это действительно так, пока я не сделаю все в потоке пользовательского интерфейса. Проблема загрузки списка в VC1 отнимает много времени. Поэтому я должен делегировать тяжелую задачу по загрузке данных некоторому фоновому рабочему потоку и перезагружать таблицу в основном потоке только после завершения загрузки данных, чтобы обеспечить бесперебойную работу пользовательского интерфейса. Итак, моя первоначальная конструкция была похожа на следующую:

-(void)viewWillAppear:(BOOL)animated {
    [super viewWillAppear:animated];
    dispatch_async(self.application.commonWorkerQueue, ^{
        [self populateData]; //populate datasource
        dispatch_async(dispatch_get_main_queue(), ^{
            [self.tableView reloadData]; //reload table view
        });
    });
}

Это было очень функционально до iOS10, когда UITableView прекратил немедленный рендеринг через reloadData и начал обрабатывать reloadData просто как запрос регистрации для перезагрузки UITableView в некоторой последующей итерации цикла выполнения. Поэтому я обнаружил, что мое приложение иногда [self.tableView reloadData] если [self.tableView reloadData] не было завершено до последующего вызова [self populateData] и это было очень очевидно, поскольку [self populateData] больше не является поточно-ориентированным, и если источник данных Изменения до завершения reloadData очень вероятно, что приложение reloadData. Поэтому я попытался добавить семафор, чтобы сделать [self populateData] поточно-ориентированным, и обнаружил, что он отлично работает. Моя последующая конструкция была похожа на следующую:

-(void)viewWillAppear:(BOOL)animated {
    [super viewWillAppear:animated];
    dispatch_async(self.application.commonWorkerQueue, ^{
        [self populateData]; //populate datasource
        dispatch_async(dispatch_get_main_queue(), ^{
            [self.tableView reloadData]; //reload table view
            dispatch_async(dispatch_get_main_queue(), ^{
                dispatch_semaphore_signal(self.datasourceSyncSemaphore); //let the app know that it is free to repopulate datasource again
            });
        });
        dispatch_semaphore_wait(self.datasourceSyncSemaphore, DISPATCH_TIME_FOREVER); //wait on a semaphore so that datasource repopulation is blocked until tableView reloading completes
    });
}

К сожалению, эта конструкция также сломалась после iOS11, когда я прокручивал UITableView в VC1 вниз, выбирал элемент, который вызывает VC2, а затем возвращался к VC1. Он снова вызывает viewWillAppear: VC1, который, в свою очередь, пытается пополнить источник данных через [self populateData]. Но сбой трассировки стека показывает, что UITableView уже начал воссоздавать свои ячейки с нуля и вызывает tableView:cellForRowAtIndexPath: метод по какой-то причине, даже до viewWillAppear: где мой источник данных пополняется в фоновом режиме и находится в некотором несовместимом состоянии, В конце концов приложение вылетает. И что самое удивительное, это происходит только тогда, когда я сначала выбрал нижний ряд, которого не было на экране. Ниже приводится трассировка стека во время сбоя:

crashed stack trace

Я знаю, что все будет работать нормально, если я вызову оба метода из основного потока, например так:

-(void)viewWillAppear:(BOOL)animated {
    [super viewWillAppear:animated];
    [self populateData]; //populate datasource
    [self.tableView reloadData]; //reload table view
}

Но это не то, что ожидается для хорошего пользовательского опыта. Я чувствую, что проблема возникает, так как UITableView пытается получить закадровые верхние строки при повторном появлении при прокрутке вниз. Но, к сожалению, после понимания стольких чертовых вещей я с трудом разобрался в этом.

Мне бы очень хотелось, чтобы эксперты этого сайта помогли мне выбраться из ситуации или показать, как это можно сделать. Большое спасибо заранее!

PS: self.application.commonWorkerQueue - это очередь последовательной отправки, работающая в фоновом режиме в этом контексте.

Ответ 1

Вы должны разделить свою функцию populateData. Например, скажем, в fetchDatabaseRows и populateDataWithRows. fetchDatabaseRows должен извлекать строки в память в своем потоке и новую структуру данных. Когда часть ввода-вывода выполнена, вы должны вызвать populateDataWithRows (и затем reloadData) в потоке пользовательского интерфейса. populateDataWithRows должен изменять коллекции, используемые TableView.

Ответ 2

UIKit работает в основном потоке. Все обновления пользовательского интерфейса должны быть только в основном потоке. Нет условия гонки, если обновления источника данных происходят только в основном потоке.

Важно понимать, что вам необходимо защитить данные. Поэтому, если вы используете семафор или мьютекс или что-то вроде этой конструкции всегда:

  • претендовать на ресурс для меня. (например: mutex.lock())
  • выполните обработку
  • разблокировать ресурс (например: mutex.unlock())

Дело в том, что из-за того, что для пользовательского интерфейса используется поток пользовательского интерфейса, а фоновый поток используется для обработки, вы не можете блокировать общий источник данных, так как вы также блокируете поток пользовательского интерфейса. Основной поток будет ждать разблокировки из фонового потока. Таким образом, эта конструкция является большим NO-NO. Это означает, что ваша функция populateData() должна создать копию данных в фоновом режиме, в то время как пользовательский интерфейс использует собственную копию в основном потоке. Когда данные готовы, просто переместите обновление в основной поток (нет необходимости в семафоре или мьютексе)

dispatch_async(dispatch_get_main_queue(), ^{
            //update datasource for table view here
            //call reload data
        });

Другое дело: viewWillAppear - это не место для этого обновления. Поскольку у вас есть навигация, где вы нажимаете свою деталь, вы можете сделать салфетки для отклонения, а в середине просто передумайте и оставайтесь в деталях. Тем не менее, будет вызван vc1 viewWillAppear. Apple должен переименовать этот метод в "viewWillAppearMaybe":). Поэтому нужно сделать так, чтобы создать протокол, определить метод, который будет вызываться, и использовать делегирование для вызова функции обновления только один раз. Это не вызовет ошибку при сбое, но почему вызывать обновление более одного раза? Кроме того, почему вы извлекаете все элементы, если только один из них изменился? Я бы обновил только 1 элемент.

Еще одно: Вероятно, вы создали ссылочный цикл. Будьте осторожны при использовании self в блоках.

Ваш первый пример был бы почти хорош, если бы он выглядел так:

-(void)viewWillAppear:(BOOL)animated {
  [super viewWillAppear:animated];
  dispatch_async(self.application.commonWorkerQueue, ^{
    NSArray* newData = [self populateData]; //this creates new array, don't touch tableView data source here!
    dispatch_async(dispatch_get_main_queue(), ^{
        self.tableItems = newData; //replace old array with new array
        [self.tableView reloadData]; //reload
    });
  });
}

(self.tableItems - NSArray, простой источник данных для tableView в качестве примера источника данных)

Ответ 3

Обновление: после того, как вы посмотрели на свой вопрос немного более тщательно, кажется, что основной причиной вашей проблемы является использование изменяемой структуры данных резервного копирования для вашего табличного представления. Система ожидает, что данные никогда не изменятся без явного вызова reloadData в той же итерации цикла выполнения, что и при изменении данных. Строки всегда были загружены лениво.

Как говорили другие люди, решение заключается в использовании свойства readwrite с неизменяемым значением. Когда обработка данных завершается, обновите свойство и вызовите reloadData, как в основной очереди.

Ответ 4

Мое предположение состоит в том, что, поскольку у вас есть рефериный цикл при доступе к self.tableView внутри getMain. Ans есть пропущенные версии этого табличного представления где-то в фоновом режиме, который начал разбивать приложение в iOS 11

Есть вероятность, что вы можете проверить это с помощью графика памяти в Xcode.

Чтобы исправить этот доступ, вам нужен доступ к слабой копии себя, как это

   -(void)viewWillAppear:(BOOL)animated {
        [super viewWillAppear:animated];
        __weak typeof(self) weakSelf = self;
        dispatch_async(self.application.commonWorkerQueue, ^{
            if (!weakSelf) { return; }
            [weakSelf populateData]; //populate datasource
            dispatch_async(dispatch_get_main_queue(), ^{
                [weakSelf.tableView reloadData]; //reload table view
            });
        });
    }

Ответ 5

В iOS11 правильным способом выполнения "тяжелой задачи по загрузке данных" является реализация протокола UITableViewDataSourcePrefetching, как описано здесь: https://developer.apple.com/documentation/uikit/uitableviewdatasourceprefetching

Если вы правильно реализуете "tableView: prefetchRowsAtIndexPaths:", вам не нужно беспокоиться о фоновых потоках, рабочих очередях, временных источниках данных или синхронизации потоков. UIKit заботится обо всем этом для вас.