Как проверить гонки и дубликаты при пагинации iOS

Как проверить гонки и дубликаты при пагинации iOS

Пользователь быстро переключает фильтры в списке iOS: первая страница по новым условиям уже показана, а вторая страница по старым ещё загружается. Через несколько секунд приходит старый ответ, и в списке внезапно появляются посторонние элементы. На стабильном соединении такую ошибку трудно воспроизвести. Даже если многократно прокручивать список вручную на облачном Mac, нельзя гарантировать нужный порядок ответов. Надёжнее сначала выделить правила обновления списка в модель состояния, проверить их на заранее заданной последовательности событий и только затем подключить модель к интерфейсу.

Сначала определите допустимые изменения списка

В примере ниже используются номера страниц, начиная с 1, и целочисленные идентификаторы записей. Настоящий API может возвращать и другие поля, но модель должна как минимум различать текущий запрос, ожидаемую страницу, уже показанные ID и наличие следующей страницы на сервере. Каждая смена фильтра создаёт новый запрос. Даже если старые и новые условия в итоге возвращают одни и те же элементы, ответ на старый запрос не должен изменять новый список.

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

Отмена запроса экономит сетевые и вычислительные ресурсы, но не гарантирует, что старый обработчик ответа не выполнится. Перед изменением списка всё равно нужно сверять идентификатор запроса.

Проверьте три порядка событий на исполняемой модели

Сохраните приведённый ниже код в файл Pager.swift и запустите xcrun swift Pager.swift на Mac с установленными инструментами командной строки Xcode. Код не использует сеть или таймеры, поэтому при каждом запуске проверяет одни и те же последовательности событий, способные вызвать ошибку.

struct Ticket: Equatable {
    let generation: Int
    let page: Int
}

struct Pager {
    private(set) var generation = 0
    private(set) var nextPage = 1
    private(set) var ids: [Int] = []
    private(set) var inFlight: Ticket?
    private(set) var hasMore = true

    mutating func start() -> Ticket? {
        guard hasMore, inFlight == nil else { return nil }
        let ticket = Ticket(generation: generation, page: nextPage)
        inFlight = ticket
        return ticket
    }

    mutating func reset() {
        generation += 1
        nextPage = 1
        ids = []
        inFlight = nil
        hasMore = true
    }

    mutating func finish(
        _ ticket: Ticket,
        rows: [Int],
        hasMore: Bool
    ) {
        guard inFlight == ticket else { return }
        var seen = Set(ids)
        for id in rows where seen.insert(id).inserted {
            ids.append(id)
        }
        nextPage += 1
        self.hasMore = hasMore
        inFlight = nil
    }
}

var pager = Pager()
let page1 = pager.start()!
assert(pager.start() == nil)
pager.finish(page1, rows: [1, 2], hasMore: true)

let page2 = pager.start()!
pager.finish(page2, rows: [2, 3], hasMore: true)
pager.finish(page2, rows: [2, 3], hasMore: true)
assert(pager.ids == [1, 2, 3])

let oldPage = pager.start()!
pager.reset()
let newPage = pager.start()!
pager.finish(oldPage, rows: [4], hasMore: false)
assert(pager.ids.isEmpty)
pager.finish(newPage, rows: [7], hasMore: false)
assert(pager.ids == [7])
assert(pager.start() == nil)

Первая проверка assert подтверждает, что повторная прокрутка до конца запускает загрузку только один раз. Следующая группа проверяет и повторение ID на разных страницах, и повторный вызов обработчика одного ответа: элемент 2 появляется только один раз, а завершённая страница больше не принимается. Последняя группа имитирует смену фильтра. Страница из старого поколения приходит с опозданием, но не может изменить ни новый список, ни его hasMore.

Сохраните границу обновления списка при подключении реального API

Интерфейс отвечает только за события «загрузить следующую страницу» и «сбросить запрос». При запуске запроса сохраните идентификатор, возвращённый start(). По завершении передайте ответ в finish, а затем прочитайте список из модели. При сбросе можно одновременно отменить старую сетевую задачу, но нельзя позволять обработчику её завершения обходить модель и напрямую добавлять элементы в массив интерфейса.

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

Как обрабатывать ошибки и повторные попытки

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

Запускайте тесты в стабильной среде

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

Задержка сети при работе через удалённый рабочий стол может помочь заметить проблему, но тесты не должны от неё зависеть. Ценность облачного Mac в том, что команда может многократно выполнять эти проверки в одинаковой среде инструментов и симулятора. Порядок ответов при этом по-прежнему задаётся тестом. После каждого изменения реализации списка сохраняйте четыре группы тестов: повторная прокрутка до конца, дубликаты на разных страницах, запоздалый старый ответ и повторная попытка после ошибки.

Перед слиянием проверьте все пути записи в список

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

Чтобы выявить гонку при пагинации, не нужно ждать случайного совпадения условий в слабой сети. Если выразить правило «какой ответ вправе изменить список» в исполняемом коде, ответы не по порядку станут обычным случаем для регрессионного теста, а не редкой проблемой, о которой узнают по скриншоту из работающего приложения.

Часто задаваемые вопросы

Достаточно ли отменить предыдущий запрос?

Нет. Перед изменением списка нужно также сверить поколение поиска и ожидаемую страницу с данными ответа.

Что делать, если запись повторяется на разных страницах?

Удалять повторы по устойчивому идентификатору записи и отдельно проверять продвижение номера страницы или курсора.

Выделенный физический сервер

Подготовьте Mac для следующей удалённой разработки

Выберите модель и регион под свои задачи, а затем подключитесь через общий доступ к экрану, VNC или SSH. Актуальные цены и доступность смотрите в консоли.

Настроить удалённый Mac