Ми часто стикаємося з ситуацією, коли просте завдання — показати PDF — перетворюється на головний біль. 200-сторінковий документ на iPhone SE призводить до смиканого скролу та неконтрольованого зростання пам'яті до 500 МБ з наступним SIGKILL від iOS. Проблема в рендерингу: кожна сторінка PDF — це векторний документ, який потрібно растерувати під конкретний масштаб та роздільність екрану. Наша команда, маючи 5-річний досвід у мобільній розробці та реалізувавши понад 30 проєктів з інтеграцією PDF-в'юверів, вирішила це завдання для кількох замовників. Один із клієнтів — великий агрегатор документації — зіткнувся з падінням додатку на Android при відкритті 150-сторінкового PDF. Ми впровадили посторінкове завантаження на основі PdfRenderer, що знизило пікове споживання пам'яті на 60% та усунуло краші. Середня економія часу розробки при використанні готових модулів становить 3–4 тижні, а вартість інтеграції базового рішення визначається після аналізу завдання.
Чому нативний рендеринг кращий за WebView?
<WebView source={{ uri: 'file://...' }} — найшвидший спосіб показати PDF. iOS WebKit рендерить PDF нативно. Однак немає контролю над UI (не додати custom toolbar, анотації, пошук), і PDF відкривається цілком у пам'ять. На 50+ сторінках — ризик OOM. Нативний рендеринг через PDFKit (iOS) та PdfRenderer (Android) дає повний контроль, але вимагає нативних модулів у React Native. Ми зазвичай обираємо золоту середину — react-native-pdf.
Порівняння підходів до вбудовування PDF-в'ювера
| Підхід |
Продуктивність |
Контроль UI |
Час розробки |
| WebView |
Середня (ризик на великих PDF) |
Низький |
1–2 дні |
| Нативний модуль (PDFKit/PdfRenderer) |
Висока |
Повний |
1–2 тижні |
| react-native-pdf |
Висока |
Частковий (через пропси) |
3–5 днів |
| Flutter SfPdfViewer |
Висока |
Повний (через Flutter API) |
3–5 днів |
react-native-pdf: готове рішення
react-native-pdf — React Native обгортка над нативними PDF API обох платформ. Під капотом: PDFKit на iOS, PdfRenderer на Android. Посторінковий рендеринг — у пам'яті лише видимі сторінки + 1–2 буферні.
import Pdf from 'react-native-pdf';
const PDFViewer = ({ uri }: { uri: string }) => {
const [totalPages, setTotalPages] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
return (
<Pdf
source={{ uri, cache: true }}
onLoadComplete={(numberOfPages) => setTotalPages(numberOfPages)}
onPageChanged={(page) => setCurrentPage(page)}
onError={(error) => console.error(error)}
style={{ flex: 1 }}
enablePaging
horizontal
fitPolicy={0}
scale={1.0}
minScale={0.5}
maxScale={3.0}
/>
);
};
Параметр cache: true — перше завантаження зберігає файл у директорію кешу додатку. Повторне відкриття не робить HTTP-запит, що прискорює відображення до 3 разів на файлах 10+ МБ. Важливо: кеш не керується автоматично, потрібне очищення за TTL або розміром.
Як реалізувати ліниве завантаження сторінок?
При відкритті PDF з URL бібліотека завантажує весь файл перед рендерингом. 50 МБ PDF — користувач чекає 8–12 секунд. Оптимальне рішення — HTTP Range запити, якщо сервер підтримує Accept-Ranges: bytes. Нативна реалізація для iOS через PDFDocument(url:) з URLSession підтримує прогресивний рендеринг (Apple PDFKit Documentation). Для React Native розроблено custom native module, який використовує PDFDocument з CGPDFDataProvider для потокового завантаження. Для більшості проєктів простіше: показуємо першу сторінку як placeholder (thumbnail, попередньо згенерований на сервері через pdf2pic або ghostscript), поки завантажується повний файл. В одному з проєктів це скоротило час очікування з 8 до 1 секунди для PDF у 40 МБ.
Пошук по тексту та анотації
react-native-pdf підтримує пошук через pdfRef.current?.startSearch(query) — нативний пошук по тексту документа з підсвічуванням збігів. Анотації (виділення, нотатки, підписи) — окреме завдання. PSPDFKit — комерційний SDK з повним набором анотацій. Для open-source: PDFTron (Apryse) з free-тиром. У наших проєктах використовуємо PSPDFKit, якщо потрібно 10+ типів анотацій; у протилежному випадку — кастомний модуль на 2–3 типи (виділення, нотатка).
Як захистити PDF від несанкціонованого доступу?
PDF з паролем: react-native-pdf підтримує password prop. Корпоративні DRM-захищені PDF (Adobe AEPD, Microsoft IRM) потребують нативних бібліотек операторів — у RN не реалізовані напряму. У таких випадках ми рекомендуємо серверну роздачу з токеном авторизації. Приклад: підписаний URL з TTL 1 година.
Flutter: syncfusion_flutter_pdfviewer
Syncfusion надає SfPdfViewer — повнофункціональний PDF в'ювер для Flutter з посторінковим рендерингом, пошуком і виділенням. Community-ліцензія безкоштовна при доході до $1 млн/рік.
SfPdfViewer.network(
'https://cdn.example.com/document.pdf',
onPageChanged: (PdfPageChangedDetails details) {
setState(() => _currentPage = details.newPageNumber);
},
)
Порівняння бібліотек для анотацій
| Бібліотека |
Платформи |
Вартість |
Можливості |
| PSPDFKit |
iOS, Android, Flutter, RN |
Комерційна |
Анотації, форми, підписи |
| Apryse (PDFTron) |
iOS, Android, Flutter, RN |
Безкоштовний тир + платні рівні |
Анотації, редагування |
| Custom нативна |
iOS/Android |
Безкоштовно |
Обмежений функціонал |
Що входить в роботу з інтеграції PDF-в'ювера
- Аналіз вимог: визначаємо платформу, обсяг документів, необхідні функції (пошук, анотації, захист).
- Вибір стеку: react-native-pdf, Flutter SfPdfViewer або нативний модуль.
- Налаштування рендерингу: посторінкове завантаження, кешування, підтримка масштабування.
- Реалізація UI: панель навігації, рядок пошуку, кнопки зуму.
- Інтеграція анотацій (опціонально): підключення PSPDFKit або Apryse.
- Оптимізація продуктивності: тестування на 5 пристроях з різними версіями ОС.
- Деплой та документація: передача вихідного коду, інструкція по збірці, API опис, 2 тижні підтримки після здачі.
Термін реалізації: базовий в'ювер для однієї платформи займає 2–3 тижні, крос-платформенне рішення з анотаціями — 4–6 тижнів. Якщо вам потрібно вбудувати PDF-в'ювер — зв'яжіться з нами, оцінимо проєкт за один день. Ми гарантуємо стабільність та продуктивність рішення. Отримайте консультацію — допоможемо обрати оптимальний підхід. Замовте впровадження — заощадьте час на розробку.
Як вибрати рішення для локального зберігання даних (Room, Core Data, Realm, Isar)?
Ми стикалися з ситуацією, коли додаток втрачає дані при втраті мережі — і це не просто баг, це провал сценарію. Користувач заповнив форму, натиснув «Відправити», отримав таймаут і втратив все. Або гірше: дані відправилися двічі через некоректну логіку повторної відправки. Правильно вибраний та налаштований шар сховища вирішує цю проблему раз і назавжди. Неправильний вибір може коштувати команді місяців переписування коду та втрати до 70% часу на синхронізацію. Наш досвід — 10+ років у мобільній розробці, понад 50 проектів з офлайн-сховищами — підтверджує: вибір рішення визначає 80% майбутніх проблем з продуктивністю та синхронізацією.
На практиці вибір сховища визначається двома факторами: типом даних та вимогами до синхронізації, а не популярністю бібліотеки.
Room (Android) — обгортка над SQLite з compile-time верифікацією SQL-запитів. Якщо запит невалідний, збірка падає — це краще, ніж SQLiteException в рантаймі. Room добре інтегрується з Kotlin Flow та LiveData, що робить реактивні UI-оновлення прямолінійними. Основна складність — міграції схеми. @Database(version = N, exportSchema = true) з файлами міграцій в assets/databases/ — обов'язкова практика, інакше при оновленні додатка fallbackToDestructiveMigration() просто зітре дані користувача.
Core Data (iOS) — не база даних, а фреймворк управління графом об'єктів поверх SQLite (або XML, або in-memory). NSPersistentContainer з viewContext для читання на main thread та newBackgroundContext() для запису — базова схема. Проблема починається, коли розробник робить save() в viewContext з фонового потоку: EXC_BAD_ACCESS в рандомний момент, відтворюється раз на тиждень, в крешлозі майже нічого корисного. Потрібно використовувати performAndWait або perform для кожного контексту строго в своєму потоці. Apple Core Data Programming Guide рекомендує саме такий підхід.
Realm виграє там, де потрібна швидкість роботи з великими наборами об'єктів та вбудована реактивність через Results + observe(). Realm зберігає об'єкти напряму, без маппінгу ORM, тому читання не потребує десеріалізації. За нашими вимірами, Realm обробляє читання в 2–3 рази швидше Core Data при об'ємі понад 10 000 об'єктів. На Flutter Realm SDK (ex-MongoDB Realm) підтримує Device Sync — але це вже managed-сервіс з окремою інфраструктурою.
Hive та Isar — Flutter-специфічні рішення. Hive — key-value сховище, швидко, просто, підходить для налаштувань та кешів. Isar — повноцінна документо-орієнтована БД з індексами, написана на Rust, компілюється в нативний код. Для Flutter-додатків з офлайн-функціональністю Isar зараз переважніше: вбудований query builder з типобезпечними фільтрами, транзакції, watchObject/watchQuery для реактивності.
| Платформа |
Рішення |
Реактивність |
Синхронізація |
| Android |
Room + Flow |
LiveData/Flow |
WorkManager |
| iOS |
Core Data |
NSFetchedResultsController |
CloudKit |
| Flutter |
Isar |
Streams |
Custom / Realm Sync |
| Cross-platform |
Realm |
RealmResults.observe |
Device Sync |
| Flutter (простий) |
Hive |
ValueListenable |
Немає |
Зв'яжіться з нами, щоб отримати безкоштовний аудит вашого поточного сховища та рекомендації з оптимізації — це зекономить вам сотні годин розробки та до 60% трафіку на серверні запити.
Чому офлайн-синхронізація — найскладніша частина?
Локальне сховище саме по собі нескладне. Складність — у синхронізації з сервером при наявності конфліктів.
Найчастіший патерн — optimistic updates з rollback. Користувач редагує запис, UI відображає зміну миттєво, фоновий запит йде на сервер. Якщо сервер повертає помилку — відкочуємо локальний стейт. Виглядає просто. На практиці: якщо користувач встиг піти з екрану і повернутися, а відкат відбувся через 3 секунди — UX зламаний. Потрібна явна черга операцій зі станом (PENDING, SYNCED, FAILED) в окремій таблиці.
На Android для фонової синхронізації використовуємо WorkManager з Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED). Важно не забыть про setInputMerger(ArrayCreatingInputMerger::class) при батчинге задач — інакше при кількох одночасних запусках дані затираються. Типова реалізація черги операцій:
class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val pendingOps = syncDao.getPendingOperations()
for (op in pendingOps) {
try {
apiClient.send(op.payload)
syncDao.markSynced(op.id)
} catch (e: Exception) {
syncDao.markFailed(op.id, e.message)
return Result.retry()
}
}
return Result.success()
}
}
На iOS аналог — BGTaskScheduler з BGProcessingTaskRequest. Обмеження iOS на фоновий час виконання (~30 секунд для refresh tasks) означають, що синхронізація повинна бути інкрементальною: не «синхронізувати все», а «синхронізувати наступні N записів, зберегти курсор».
Конфлікти при мультипристроєвій роботі вирішуються одним із трьох підходів:
- Last-write-wins по
updated_at (найпростіший, втрачає дані при одночасному редагуванні)
- Server-wins (клієнт завжди приймає серверну версію)
- Three-way merge (складно, потрібен спільний предок — підходить для документів)
У більшості B2C-додатків достатньо last-write-wins з вектором часу на рівні користувача, але при спільному редагуванні потрібен CRDTs-підхід — тоді дивимося на Automerge або Yjs з мобільними біндингами.
Як ми будуємо шар сховища
Репозиторний патерн — не опціональний, а обов'язковий. UserRepository не знає, звідки дані: з Room, Realm чи мережі. ViewModel викликає repository.getUser(id), отримує Flow/Stream, відображає дані. Логіка кешування — всередині репозиторія.
Для Flutter типова архітектура: Isar для персистентності, Riverpod для управління стейтом, ConnectivityPlus для визначення стану мережі, кастомний SyncService з чергою операцій. Riverpod AsyncNotifier зручно покриває логіку «показати кеш, оновити з мережі, показати нові дані». Приклад репозиторія з кешуванням:
class UserRepository {
final Isar isar;
final ApiClient api;
Future<User> getUser(String id) async {
// 1. спробувати з локального сховища
final cached = await isar.user.where().idEqualTo(id).findFirst();
if (cached != null) return cached;
// 2. інакше з мережі
final remote = await api.fetchUser(id);
// 3. зберегти локально
await isar.writeTxn(() => isar.user.put(remote));
return remote;
}
}
Окрема тема — шифрування. Якщо додаток зберігає медичні дані, платіжні картки або корпоративні документи, SQLCipher (Android) та NSFileProtection (iOS) — не опція. Realm підтримує шифрування нативно через ключ у 64 байти, який потрібно зберігати в Keychain/Keystore, а не в SharedPreferences. Економія на безпеці може обійтися у витік даних з гучними наслідками.
Що входить в роботу
Ми гарантуємо прозорий процес і фіксуємо кожен етап:
| Етап |
Результат |
| Аудит вимог |
Документ з аналізом типів даних, обсягів, сценаріїв синхронізації |
| Проектування схеми |
ER-діаграма, файли міграцій, план конфлікт-резолюції |
| Розробка репозиторного шару |
Код з юніт-тестами (in-memory БД + моки мережі) |
| Інтеграція синхронізації |
Черга операцій, обробка помилок, fallback-логіка |
| Профілювання та оптимізація |
Звіт Android Profiler / Core Data SQLDebug, рекомендації |
| Деплой та документування |
Інструкція з розгортання, API-опис, доступ до репозиторію |
Бажаєте уникнути типових помилок при проектуванні сховища? Зверніться до нас — ми допоможемо спроектувати надійне локальне сховище з нуля або доопрацювати існуюче.
Які етапи роботи?
Починаємо з аудиту вимог: які дані, який обсяг, чи потрібна синхронізація, чи можливі конфлікти. На цьому етапі стає зрозуміло, Core Data чи SQLite-based рішення, чи потрібен Realm Sync або достатньо простого REST-поллінгу.
Далі — проектування схеми з урахуванням міграцій. Схему змінюють у будь-якому проекті — питання не «чи будуть міграції», а «наскільки болісно вони пройдуть». Експортуємо схему в JSON, зберігаємо в репозиторії, пишемо тести на міграцію кожної версії.
Розробка йде з покриттям репозиторного шару юніт-тестами: моки мережевого шару, реальна in-memory база для тестування запитів. Перед релізом — профілювання запитів через Android Profiler (вкладка Database Inspector) або Core Data debug флаги (-com.apple.CoreData.SQLDebug 1).
Термін реалізації шару сховища з базовою офлайн-синхронізацією — від 2 до 6 тижнів залежно від складності схеми та вимог до конфлікт-резолюції. Вартість розраховується індивідуально після аудиту вашого проекту. Замовте розробку під ключ — отримайте консультацію з вибору оптимального стеку та міграціям.