Покроковий посібник з імпорту CSV та Excel у мобільні додатки
Стикалися з ситуацією: очікуєте структурований CSV, а отримуєте файл, де колонки перейменовано, додано заголовок «Мої дані», кодування Windows-1251 і роздільник — кома, хоча раніше була крапка з комою? Ми, команда з 7-річним досвідом мобільної розробки, реалізували імпорт для понад 100 проєктів — від банківських застосунків до ритейл-рішень. Наше завдання — щоб будь-який файл завантажився без кражів і зі зрозумілими звітами про помилки. Економія бюджету на ручному введенні досягає 30–40% від витрат на персонал, що може сягати $5000 на рік. Вартість реалізації базового CSV-імпорту починається від $200. Працюємо на ринку з 2016 року, маємо понад 7 років досвіду та 100+ реалізованих проєктів. Зв'яжіться з нами для оцінки вашого проєкту.
Розбір вхідного файлу — найнепередбачуваніше
Визначення кодування
CSV приходить у UTF-8, UTF-8 з BOM, Windows-1251, CP866. Універсальне рішення — бібліотека визначення кодування: на Android juniversalchardet, на iOS — власний аналіз BOM + fallback на String.Encoding.windowsCP1251. Якщо кодування не визначено правильно — Клиент замість «Клієнт». Univocity-parsers на Android — один із найкращих варіантів для роботи з CSV, при цьому він в 2 рази швидше за opencsv на файлах понад 100 000 рядків.
Визначення роздільника
Парсер має пробувати ,, ;, \t і вибирати той, що дає найбільшу кількість однорідних колонок. Або дати користувачеві вибрати вручну — чесніше і надійніше.
Порожні рядки, дублі заголовків, змішані типи
Реальні користувацькі файли містять порожні рядки між блоками, об'єднані комірки в Excel, числа в колонці «дата». Кожен із цих випадків потрібно обробляти явно, а не падати з ArrayIndexOutOfBoundsException.
Як автоматично визначити кодування CSV?
На Android використовуємо juniversalchardet або ICU4C. На iOS — перевіряємо BOM, потім пробуємо найімовірніші кодування: UTF-8, Windows-1251, CP866. Якщо жодне не підходить, пропонуємо користувачеві вибрати вручну. Це покриває 99% випадків.
Чому важливий транзакційний запис?
Імпорт без транзакції — ризик отримати частково завантажені дані при збої. Транзакційний запис гарантує, що або всі рядки будуть записані, або жодна. На Android Room реалізує це через @Transaction, на iOS Core Data — через performAndWait. Як зазначено в документації Room, це стандартна практика.
Архітектура імпорту
Реалізація імпорту по кроках
Як реалізувати імпорт крок за кроком:
- Вибір файлу через DocumentPicker (Android) або UIDocumentPickerViewController (iOS).
- Парсинг за допомогою бібліотек (opencsv тощо).
- Валідація кожного рядка зі збором помилок.
- Транзакційний запис у базу даних.
Вибір файлу
Вибір файлу через DocumentPicker (Android) або UIDocumentPickerViewController (iOS).
// Android — DocumentPicker
val launcher = registerForActivityResult(
ActivityResultContracts.GetContent()
) { uri -> uri?.let { viewModel.importFile(it) } }
launcher.launch("text/csv,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
// iOS — UIDocumentPickerViewController
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.commaSeparatedText, .spreadsheet])
picker.delegate = self
present(picker, animated: true)
Парсинг
На Android для CSV — opencsv або univocity-parsers (останній швидший на великих файлах і коректно обробляє екранування лапок). Для .xlsx — Apache POI XSSFWorkbook. На iOS для CSV — власний парсер через Scanner або CSVParser із SwiftCSV. Для .xlsx — CoreXLSX.
// Android, univocity-parsers
val settings = CsvParserSettings().apply {
isHeaderExtractionEnabled = true
format.delimiter = ';'
}
val parser = CsvParser(settings)
val rows: List<Record> = parser.parseAllRecords(inputStream.reader(Charsets.UTF_8))
Валідація та маппінг
Перед записом у базу — валідація кожного рядка. Не «впасти на першій помилці», а зібрати всі невалідні рядки та показати зведення: «Імпортовано 847 із 900 рядків. 53 рядки пропущено — некоректний формат дати в колонці D». Користувач має зрозуміти, що пішло не так, і виправити файл.
data class ImportResult(
val imported: Int,
val skipped: List<SkippedRow>
)
data class SkippedRow(val line: Int, val reason: String)
Запис у базу даних
Транзакція цілком — або все, або нічого. На Room:
@Transaction
suspend fun importRows(rows: List<TransactionEntity>) {
database.clearAll()
database.insertAll(rows)
}
Для інкрементального імпорту (додати нові, оновити існуючі) — INSERT OR REPLACE з унікальним полем-ідентифікатором.
Порівняння форматів файлів
| Формат |
Android парсер |
iOS парсер |
Особливості |
| CSV |
opencsv / univocity-parsers |
SwiftCSV / Scanner |
Автовизначення кодування та роздільника |
| XLS |
Apache POI HSSFWorkbook |
— |
Застарілий формат, рідко використовується |
| XLSX |
Apache POI XSSFWorkbook |
CoreXLSX |
Підтримка макросів, великі обсяги |
Типові помилки та їх вирішення
| Проблема |
Вирішення |
| Парсинг на main thread |
Виконувати на фоновому потоці (CoroutineScope, DispatchQueue) |
| Не екрановані лапки в CSV |
Використовувати парсер з обробкою екранування (univocity, CoreXLSX) |
| Неоднозначний формат дати |
Детектувати за регулярними виразами або дати вибір користувачеві |
UI-патерни
Прогрес-бар із поточним рядком (Оброблено 3 412 із 10 000), скасування через Job.cancel() на Android / Task cancellation на iOS. Після імпорту — екран із підсумками: скільки додано, оновлено, пропущено з причинами.
Попередній перегляд перших 5–10 рядків перед підтвердженням імпорту — хороша UX-практика, яка знижує кількість «я не те завантажив».
Що входить у роботу під ключ
- Вибір файлу через DocumentPicker (CSV, XLS, XLSX)
- Автовизначення кодування та роздільника
- Валідація зі звітом про помилки по рядках
- Транзакційний запис у локальну БД
- UI прогресу та попереднього перегляду
- Підтримка скасування імпорту
Терміни
Базовий CSV-імпорт із фіксованою структурою: 1–1,5 дня. З автовизначенням формату, валідацією, попереднім переглядом та звітом про помилки: 3–4 дні. Вартість розробки варіюється від $200 до $500, але в середньому окупається за 2–3 місяці. Автоматичний імпорт заощаджує до 40% часу порівняно з ручним введенням. Замовте реалізацію імпорту — отримайте стабільний модуль за 3–4 дні. Отримайте консультацію щодо вашого проєкту.
Як вибрати рішення для локального зберігання даних (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 тижнів залежно від складності схеми та вимог до конфлікт-резолюції. Вартість розраховується індивідуально після аудиту вашого проекту. Замовте розробку під ключ — отримайте консультацію з вибору оптимального стеку та міграціям.