Страх і ненависть і пагинация

А в чому проблема?
Типова пагинация
Як би нам не хотілося цього заперечувати, практика показує, що переважна більшість свого часу типовий iOS-розробник проводить за роботою з табличками. Проектування сервісного шару — це інтригуюче, розробка універсального роутінга у додатку — захоплююче, а від налаштування гнучких політик кешування взагалі дах зносить, але робота з табличними інтерфейсами — це наші сірі будні. Іноді промінь світла все ж потрапляє і в цю область, і замість чергової метушні з constraint'ами перед нами може постати завдання реалізації посторінкового завантаження — або, як це модно називати в мобільних додатках, infinite scroll'а.

Вантажити відразу всі новини, оголошення, списки фільмів з віддаленого ресурсу як мінімум не ефективно, тому в більшості випадків сервер надає клієнтам різні механізми розбиття всього обсягу даних на частини обмеженого розміру.
Як кажуть, so far so good. Ми завантажуємо першу партію даних, відображаємо їх у таблиці, прокручуємо її до кінця, завантажуємо наступну партію — і так до нескінченності. На жаль — чи на щастя, ми ж любимо виклик — на цьому все тільки починається.
В залежності від складності проекту і ступеня костыльности API, досить просте завдання ризикує перерости в Щось. Щось саме з великої літери, бо це створення в підсумку знаходить свою власну життя і починає руйнувати долі всіх, хто його торкнеться. Ближче до справи — наведу приклад з реального життя, що трапився, тим не менш, зовсім абстрактному проекті.
Умови, з якими треба працювати:
  • будь-який з елементів, завантажених може бути змінений з плином часу;
  • на стороні клієнта потрібно вміти ховати пости авторів з чорного списку — враховуючи, що сервер продовжує їх повертати;
  • незважаючи на локальну фільтрацію, при запиті наступної сторінки користувач повинен отримати не менше 20 нових елементів;
  • видача цілком пересобирается раз в годину — деякі пости додаються, деякі зникають, і обов'язково змінюються всі позиції в списку;
  • звернення до сервера йде не безпосередньо, а через CDN, що тягне за собою цікаві артефакти;
  • єдиний механізм посторінкового завантаження, який може запропонувати сервер — це limit/offset.
Погані новини — зробити в таких випадках все правильно складно. Гарні новини — у мене в запасі є цілий структурований арсенал милиць, який допомагає впоратися з більшістю виникаючих питань і проблем.
Декомпозиція
Як завгодно складна проблема або завдання стає розв'язною, якщо розбити її на обмежену кількість кроків. Наш випадок не виняток. Щоб зрозуміти принцип роботи всієї системи посторінкового завантаження у вашому додатку досить декомпозировать її на кілька секцій:
  1. Правила зміни кількості елементів у видачі:
    • Стрічка статична. Приклад: Список закладів в редакційній добірці. Будучи один раз складеним, він вже не змінюється.
    • Нові елементи додаються строго зверху. Приклад: Історія перегляду сторінок додатку. У найпростішому вигляді єдине її зміна — це додавання в початок списку нових переглядів сторінок.

    • Будь-яка частина видачі може бути змінена. Приклад: Список листів в поштовому клієнті. Користувач може видаляти, переміщати і отримувати нові листи, тому список може бути змінений на будь-якій позиції.
  2. Правила зміни актуальності видачі:
    • Видача завжди залишається актуальною. Приклад: Все те ж поштове додаток. Елементи його можуть змінюватися поступово, немає строго детермінованою точки переходу з одного стану в інший.
    • Видача може бути переформована в невизначений момент часу. Приклад: Новинне додаток з інтелектуальним ранжируванням. Раз на годину видача новин пересобирается і найбільш актуальні матеріали перебираються в самий верх списку.

  3. Правила оновлення контенту:
    • Відображаються дані не оновлюються. Приклад: Клітинки з назвами ресторанів. Зміна назв настільки рідкісна, що цією можливістю можна сміливо знехтувати.
    • Відображаються дані можуть бути змінені. Приклад: Клітинки з лічильником лайків. З плином часу цей лічильник змінюється, і потрібно вміти оновлювати ці дані вже закэшированных елементів.

Завдання реалізації посторінкового завантаження найчастіше ділиться на дві великі частини: підвантаження даних вниз і оновлення стрічки, зазвичай здійснюється при pull-to-refresh. До кожної з цих завдань потрібен свій підхід, цілком залежний від наведеної вище схеми.
Милиці
Цей розділ — ключова частина всього матеріалу. Я зібрав всі милиці, які вимушено використовував при реалізації складного механізму посторінкового завантаження на limit/offset.
Невеликий лікнеп. Limit/offset — це найбільш часто зустрічається підхід до реалізації посторінкового завантаження даних. Offset — це зрушення щодо першого елемента. Limit — кількість завантажуваних даних.

Приклад: користувач хоче завантажити 10 новин, починаючи з двадцятою. offset: 10, limit: 20.

Підвантаження вниз / Стрічка змінюється

Якщо стрічка може бути змінена, не важливо яким чином, то при спробі запросити на наступну сторінку ми можемо зіткнутися зі зсувом елементів вгору або вниз. Рішення проблеми складається з двох кроків. Перший відноситься до зміщень вниз. Уявімо ситуацію, за якої у нас вже завантажена перша сторінка з п'яти елементів. До наступного запиту структура даних на сервері змінилася і зверху видачі додалися два нові елементи. Тепер при завантаженні наступної сторінки ми отримаємо два елементи, які у нас вже є в кеші.
Підвантаження вниз / Стрічка змінюється
Ситуація може бути і гірше, якщо за час відсутності синхронізації на сервер додалося елементів більше, ніж встановлено у властивості limit — тоді ми не матимемо жодного нового елемента. Якщо в якості offset'а ми будемо використовувати загальна кількість елементів в кеші, то застрягнемо в цій точці назавжди, так як продовжимо запитувати ті елементи, які вже були завантажені.
Проблема вирішується досить просто. При отриманні чергової порції даних ми підраховуємо кількість перетинів з закэшированным контентом — і надалі використовуємо отримане значення як зсув offset'а. У наведеному вище прикладі цей зсув дорівнює 2.
paging.startIndex = cachedPosts.count + intersections;
paging.count = 5;

Підвантаження вниз / Будь-яка частина видачі може бути змінена

Розглянемо наступну ситуацію: ми завантажили сторінку, але потім перші два елементи були видалені. Запросивши наступну порцію даних, отримуємо дірку в два елемента. Якщо нічого не зробити, додаток про це нічого не дізнається — і дані ніколи не завантажаться.
Підвантаження вниз / Будь-яка частина видачі може бути змінена
Вихід з цієї ситуації — завжди запитувати дані з покладенням на один елемент. Якщо при отриманні даних ми не знайдемо перетинів — можна скасувати результат запиту, або повторити його з іншими параметрами.
paging.startIndex = startIndex - 1;
paging.count = 5;

Невеликий лайфхак в скарбничку — якщо якісь елементи в стрічці загубляться, завжди можна зробити серйозне обличчя і прочитати лекцію про інтелектуальне ранжування даних, що виконується на стороні клієнта. Не повірять — покажіть Facebook, у яких одна й та ж видача на кожному пристрої завжди виглядає абсолютно по-різному.

Підвантаження вниз / Видача може бути реструктуризована у випадковий момент часу

В такому випадку найпростішим варіантом реалізації буде робота з так званим зліпком стрічки. Під цим терміном звичайно розуміють список ідентифікаторів всіх записів видачі. При першому відкритті ми запитуємо такий зліпок і зберігаємо його в базі, або просто тримаємо в пам'яті. Тепер для отримання наступної сторінки ми будемо користуватися не стандартним limit/offset, а більш складним запитом — просити сервер віддати пости по конкретних 20 ідентифікаторів.
NSRange pageRange = NSMakeRange = (startIndex, 20);
NSArray *postIds = [snapshot subarrayWithRange:pageRange];
[self makeRequestWithIds:postIds];

Навіть якщо видача несподівано буде реструктуризована, нам це не завадить — ми працюємо з тим зліпком, який був актуальний на момент першого запиту — і для клієнта стрічка буде відсортована точно також, як у момент першого відкриття.
Підвантаження вниз / Видача може бути реструктуризована у випадковий момент часу

Оновлення стрічки / Елементи додаються зверху

В цьому випадку нам в першу чергу необхідно вміти визначати наявність дірок, що утворюються в тому випадку, якщо з часу останньої синхронізації було додано велику кількість нових елементів.
Ми запитуємо константна кількість даних і перевіряємо наявність перетинів з даними з кешу. Якщо перетину є — все добре, можна продовжувати працювати в звичайному режимі. Якщо перетинів немає — це означає, що ми пропустили кілька постів.
Оновлення стрічки / Елементи додаються зверху
Що робити в цьому випадку — потрібно вирішувати для кожного конкретного додатка. Можна скинути всі дані, крім запитаних п'яти елементів. Можна продовжувати довантажувати елементи зверху посторінково, поки не будуть виявлені перетину.
if (intersections == 0) {
[self dropCache];
}

Оновлення стрічки / Будь-яка частина видачі може бути змінена

У проблеми є два способи вирішення:
  1. Сервер віддає diff'и змін у стрічці, приміром, грунтуючись на збереженому Last-Modified останнього синхронізованого стану видачі. Параметр Last-Modified ми отримуємо з заголовків відповіді сервера. Клієнту в такому разі залишиться просто застосувати ці зміни на стан бази.
    for (ShortPost *post in diff) {
    [self updateCacheWith:post];
    }

  2. Якщо сервер так не вміє — доводиться знову писати додаткову сотню рядків на клієнті. Нам потрібно отримати зліпок постів (прямо як в одному з попередніх пунктів) і порівняти його з поточним станом збережених в кеші даних.
    for (ShortPost *post in snapshot) {
    if (![cachedPosts containsObject:post]) {
    [self downloadPost:post];
    }
    }
    for (Post *post in cachedPosts) {
    if (![snapshot containsObject:post]) {
    [self deletePost:post];
    }
    }

Всі відсутні в слепке елементи — видалити всі відсутні — завантажити.

Оновлення стрічки / Змінюється сортування

Якщо видача була реструктуризована, ми повинні про це дізнатися. Простіше всього це зробити, надіславши на сервер head-запит і порівнявши параметри etag або Last-Modified з збереженими значеннями.
NSString *lastModified = [self makeFeedHeadRequest];

if (![lastModified isEqual:cachedLastModified]) {
[self dropCache];
[self obtainPostSnapshot];
[self obtainFirstPage];
}

Якщо результат порівняння негативний — стан кеша скидається, зліпок id оновлюється.

Оновлення стрічки / Елементи видачі можуть змінюватися

Якщо елементи списку містять змінюються з часом дані, начебто рейтингу або кількості лайків — їх потрібно оновлювати. Рішення проблеми, у принципі, корелює з одним з попередніх пунктів — ми працюємо або з diff'ом, повернутим сервером, або запитуємо короткі структури даних, що містять лише значущі поля. Після цього вручну оновлюємо стан елементів.
for (NSUInteger i = 0; i < cachedPosts.count; i++) {
Post *cachedPost = cachedPosts[i];
ShortPost *post = snapshot[i];

if (![cachedPost isEqual:post]) {
[cachedPost updatePostWithShortPost:post];
}
}

Головне — максимально скоротити кількість переданих даних.
Поради на майбутнє
На завершення хочу дати кілька рад різного ступеня корисності.

Порада 1, очевидний

Буває дуже зручно мати спеціальний об'єкт, що описує поточну видачу. Назвіть його списком, категорією, фідом — не важливо.
@interface Feed: NSObject

@property (nonatomic, copy) NSArray <Post *> *posts;
@property (nonatomic, copy) NSArray <NSString *> *snapshot;
@property (nonatomic, assign) NSUInteger offset;
@property (nonatomic, assign) NSUInteger maxCount;
@property (nonatomic, strong) NSDate *lastModified;

@end

Він може містити поточний offset, максимальну кількість елементів стрічки, дату Last-Modified, зліпок id — все, що потрібно для опису списку елементів.

Порада 2, архітектурний

Не варто змішувати в одному місці запити на отримання даних і обробку результатів. Якщо намагатися відразу ж відображати отримані від сервера дані, ви напевно зіткнетеся з низкою проблем і обмежень.
Відокремлюйте логіку запиту даних від логіки їх отримання, обробки і відображення.
Відокремлюйте логіку запиту даних від логіки їх отримання, обробки і відображення
Абстрагуйтеся від складнощів і милиць, які ми обговорили в цій статті. Наша головна мета — забезпечити постійну синхронізацію стану кешу і відображення. Якщо на екрані відображається те ж саме, що знаходиться в базі даних — життя стає набагато простіше.
У цьому, наприклад, може допомогти
NSFetchedResultsController
, повідомлення CoreData або схожі механізми інших ORM.

Порада 3, маскувальний

Инкапсулируйте всю логіку оновлення стрічки і пагинации в окремому об'єкті — фасаді над сервісами, які займаються безпосередньою завантаженням.
Саме в цьому об'єкті знаходяться найстрашніші речі — вкладені блоки, звірка індексів і перетинів, логічні розгалуження. Для споживачів інтерфейс фасаду виглядає гранично простим і надає основні методи для роботи з щосторінковими даними — завантаження наступної сторінки, оновлення списку і повна його перезавантаження. Зверніть особливу увагу на те, що цей об'єкт повинен бути максимально добре протестований.

Порада 4, найголовніший

До останнього намагайтеся настояти на нормальній реалізації посторінкового завантаження на сервері. Limit/offset це, звичайно, досить гнучке рішення, однак клієнт повинен бути простим. Ні, правда, максимально простим!
Корисні посилання
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.