1. Що будуємо сьогодні: об’єкт → JSON-рядок → файл → об’єкт
Коли ви вперше чуєте «об’єкт у файл», мозок часто малює магію рівня «запхнув змінну у файл — а завтра дістав таку саму». Насправді магії немає: файл уміє зберігати байти або текст, а не Kotlin‑об’єкти. Тож зробимо дуже чесний ланцюжок: спочатку перетворимо об’єкт на JSON‑рядок, а потім запишемо цей рядок у файл. Далі — прочитаємо рядок із файла й перетворимо його назад на об’єкт.
Щоб усі приклади склалися в один застосунок, продовжимо наш умовний консольний міні‑проєкт: облік витрат (Expense Tracker). Поки що — без бази даних, без мережі й без «дорослої» архітектури. Зараз важливіше навчитися зберігати та завантажувати дані так, щоб завтра застосунок не починав життя з чистого аркуша й сумного println("Ну що, знову все вручну?").
Схема пайплайна
flowchart LR
A[Expense / List⟨Expense⟩
у памяті] --> B[encodeToString
JSON String]
B --> C[writeText
expenses.json]
C --> D[readText
JSON String]
D --> E[decodeFromString
List⟨Expense⟩]
2. Крок 1: об’єкт → JSON-рядок
Коли ми кажемо «серіалізувати в JSON», по суті маємо на увазі: «зроби з об’єкта рядок, який можна показати в консолі, записати у файл або надіслати куди завгодно». І тут важливо не переплутати: toString() теж повертає рядок, але це не формат даних. Це лише технічне представлення «для людини» та налагодження. JSON — формат, який бібліотека вміє прочитати назад; його можна вважати контрактом.
Міні‑модель для нашого застосунку
Припустімо, у минулій лекції (про @Serializable) ми вже зробили ось таку модель витрати:
import kotlinx.serialization.Serializable
@Serializable
data class Expense(
val id: Int,
val title: String,
val amount: Int,
val category: String,
val note: String? = null,
)
Тут усе максимально приземлено: id — ціле число, title — рядок, amount — теж ціле (наприклад, гривні/долари без копійок — неідеально, але для старту достатньо), category — рядок, note — nullable, щоб потренувати null.
Найпростіше кодування: один об’єкт
Спочатку — без файлів і без списків, щоб побачити, «що взагалі виходить». Для кодування в рядок використовується Json.encodeToString(...):
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
fun main() {
val e = Expense(id = 1, title = "Кава", amount = 250, category = "Їжа")
val jsonText = Json.encodeToString(e)
println(jsonText)
// {"id":1,"title":"Кава","amount":250,"category":"Їжа","note":null}
}
Зверніть увагу: JSON вийшов «щільний», без перенесень рядків. Це нормально. Зробити його зручнішим для читання ми будемо в наступній лекції про налаштування Json { ... }, а зараз важливіше, щоб він надійно працював як формат зберігання.
Кодування списку витрат
Наш застосунок зберігає не одну витрату, а зазвичай цілий список. JSON для списку виглядатиме як масив [...], і бібліотека підтримує це напряму:
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
fun main() {
val expenses = listOf(
Expense(1, "Кава", 250, "Їжа"),
Expense(2, "Проїзд", 60, "Транспорт", note = "Метро"),
)
val jsonText = Json.encodeToString(expenses)
println(jsonText)
// [{"id":1,"title":"Кава","amount":250,"category":"Їжа","note":null},{"id":2,"title":"Проїзд","amount":60,"category":"Транспорт","note":"Метро"}]
}
Тут корисно тримати в голові просту відповідність: List<Expense> перетворюється на JSON‑масив [...], а кожен Expense усередині — на JSON‑об’єкт {...}.
3. Крок 2: JSON-рядок → об’єкт
Тепер рухаємося у зворотному напрямку. Ідея десеріалізації проста: у нас є JSON‑рядок, і ми хочемо отримати Kotlin‑об’єкт потрібного типу. Важливо, що тип треба вказати. Сам по собі JSON‑рядок не каже: «я User» або «я Expense». Це просто дані, а тип — рішення вашого коду.
Щоб не будувати ілюзій, скажу чесно: цей крок може завершитися помилкою, якщо JSON не відповідає моделі. І це нормально. Ми якраз учимося жити у світі, де вхідні дані інколи бувають «брудними».
Для декодування використовується Json.decodeFromString<T>(...).
Декодуємо один об’єкт
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
fun main() {
val input = """{"id":10,"title":"Чай","amount":120,"category":"Їжа","note":null}"""
val e = Json.decodeFromString<Expense>(input)
println(e)
// Expense(id=10, title=Чай, amount=120, category=Їжа, note=null)
}
Зверніть увагу на <Expense>: ми явно кажемо «прочитай це як Expense».
Декодуємо список
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
fun main() {
val input = """[{"id":1,"title":"Кава","amount":250,"category":"Їжа","note":null}]"""
val expenses = Json.decodeFromString<List<Expense>>(input)
println(expenses.size) // 1
println(expenses[0].title) // Кава
}
Якщо переплутати {} і [], буде помилка. Це один із найчастіших багів: ви думаєте, що у файлі один об’єкт, а там масив — або навпаки. Тож перед декодуванням корисно хоча б «очима» перевірити, з чого починається рядок: { чи [.
4. Крок 3: файл як транспорт для рядка
На цьому етапі важливо психологічно перемкнутися: файл не «зберігає об’єкти», файл «зберігає рядок». Ми просто домовилися, що цей рядок буде JSON.
З погляду коду це означає, що операції діляться на дві незалежні частини: серіалізація (у рядок) та I/O (у файл). Такий поділ дуже допомагає з налагодженням: якщо щось зламалося, ви одразу розумієте, у чому проблема — в JSON чи в шляху/правах/каталозі.
Запис JSON‑рядка у файл
import java.io.File
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
fun main() {
val dir = File("data")
dir.mkdirs()
val file = File(dir, "expenses.json")
val text = Json.encodeToString(listOf(Expense(1, "Кава", 250, "Їжа")))
file.writeText(text)
println("Saved to ${file.path}") // Saved to data/expenses.json
}
Ключовий момент — dir.mkdirs(): якщо каталогу немає, запис може завершитися помилкою. Так, комп’ютер інколи вимагає, щоб ви спочатку створили каталог. Він упертий, але чесний.
Читання JSON‑рядка з файла
import java.io.File
fun main() {
val file = File("data/expenses.json")
val text = file.readText()
println(text) // вміст JSON одним рядком
}
Тут усе максимально прямолінійно: прочитали рядок — і далі можна передати його в decodeFromString.
5. Міні‑пайплайн: зберегти і завантажити список
Тепер склеїмо все у два кроки: збережемо список, а потім завантажимо його назад. Щоб код було простіше читати й повторно використовувати, винесемо логіку в невеликі функції. Це не «архітектура на віки», а корисна звичка: не писати один і той самий код у трьох місцях.
Функція збереження
import java.io.File
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
fun saveExpenses(file: File, expenses: List<Expense>) {
val text = Json.encodeToString(expenses)
file.parentFile?.mkdirs()
file.writeText(text)
}
Тут ми додаємо невеликий захист: file.parentFile?.mkdirs() створить каталог, якщо він є (і якщо parentFile не null). Це зручно, коли ви передаєте шлях на кшталт "data/expenses.json".
Функція завантаження
import java.io.File
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
fun loadExpenses(file: File): List<Expense> {
if (!file.exists()) return emptyList()
val text = file.readText()
return Json.decodeFromString(text)
}
Тут важливий UX‑момент: якщо файла немає, це не «помилка світу». Для першого запуску це звичайна ситуація. Тому замість падіння ми повертаємо emptyList().
Зверніть увагу: Json.decodeFromString(text) працює без явного <List<Expense>>, тому що компілятор може вивести тип із типу, який повертає функція. Якби loadExpenses повертала Any, довелося б указувати тип явно. Але ми так робити не будемо: ми ж хочемо дружити з компілятором, а не воювати з ним.
Перевіряємо в main, що «туди‑назад» працює
Тепер напишемо невеликий main, який створює список витрат, зберігає його, завантажує назад і друкує результат. Це важливий ритуал: спочатку переконатися, що пайплайн працює у вакуумі, а вже потім прикручувати його до команд add/list/remove (які ви могли реалізувати раніше).
import java.io.File
fun main() {
val file = File("data/expenses.json")
val expenses = listOf(
Expense(1, "Кава", 250, "Їжа"),
Expense(2, "Проїзд", 60, "Транспорт", note = "Метро"),
)
saveExpenses(file, expenses)
val loaded = loadExpenses(file)
println("Loaded: ${loaded.size}") // Loaded: 2
println(loaded[0]) // Expense(id=1, title=Кава, amount=250, category=Їжа, note=null)
}
Якщо цей приклад працює, значить база готова: ми навчилися зберігати дані «між запусками». Це вже майже доросла програма (залишилося зовсім трохи: навчитися не ламатися від будь‑якого дивного файла).
Вбудовуємо файл у CLI‑облік витрат
Коли ви будете (або вже зробили) консольний інтерфейс із командами на кшталт add, list, remove, важливо правильно вбудувати роботу з файлом: завантаження має відбуватися на початку програми, а збереження — після змін. Якщо зберігати лише наприкінці, а програма впала раніше — дані загубляться. Якщо ж зберігати після кожної команди, втратити дані майже неможливо (так, це трохи частіше записує у файл, але для навчального проєкту це нормально).
Нижче — дуже спрощений «скелет» main, без складного парсингу команд. Він показує саму ідею: «завантажили → змінили → зберегли».
import java.io.File
fun main() {
val file = File("data/expenses.json")
val expenses = loadExpensesSafe(file).toMutableList()
expenses.add(Expense(3, "Печиво", 90, "Їжа"))
saveExpenses(file, expenses)
println("Now total: ${expenses.size}") // Now total: 3
}
Тут важлива думка: у пам’яті ми працюємо зі списком (MutableList), а у файлі зберігаємо JSON‑рядок. Файл не «розумніший» за список. Він просто зберігає результат серіалізації.
6. Помилки й діагностика: де падає завантаження
Будь‑яка операція «завантажити дані» живе в суворому світі. Файл може бути відсутнім, може бути порожнім, може бути пошкодженим, може бути не JSON. Або ж це може бути JSON — але не того формату. І якщо ви не розділяєте етапи «прочитати рядок» та «розпарсити JSON», діагностика перетворюється на ворожіння на кавовій гущі (у нашому застосунку про витрати це особливо символічно).
Два класи проблем
Насправді проблеми майже завжди розпадаються на дві великі категорії:
| Де зламалося | Як виглядає | Що це означає по‑людськи |
|---|---|---|
| I/O (файл) | не знайдено, немає прав, каталог замість файла | «ми не змогли отримати рядок» |
| JSON/тип | помилка парсингу, невідповідність типів | «рядок отримали, але він не збігається з моделлю» |
Мінімальна діагностика через try/catch
Зробімо версію loadExpenses, яка не падає, а повертає порожній список і друкує зрозуміле повідомлення. Це не ідеальна стратегія для всіх програм, але для навчального консольного застосунку — чудовий старт.
import java.io.File
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
fun loadExpensesSafe(file: File): List<Expense> {
if (!file.exists()) return emptyList()
return try {
val text = file.readText()
Json.decodeFromString(text)
} catch (e: Exception) {
println("Failed to load ${file.path}: ${e.message}")
emptyList()
}
}
Чому ловимо Exception, а не щось точніше? Тому що зараз наша мета — навчитися не падати й побачити повідомлення. Уточнювати типи винятків можна пізніше, коли ви відчуєте впевненість. На цьому етапі головне — зрозуміти принцип: «прочитати» і «розпарсити» — це різні кроки.
7. Типові помилки
Помилка №1: намагатися «зберегти об’єкт» через toString().
У data class дуже зручний toString(), і він справді виглядає «майже як дані». Але це пастка: це рядок для налагодження, а не формат зберігання. Сьогодні ви зберегли Expense(id=1, title=Кава, ...), а завтра змінили порядок полів або імʼя властивості — і «формат» раптово став іншим. JSON же створюється бібліотекою і читається бібліотекою, тож цикл «туди‑назад» справді має сенс.
Помилка №2: змішувати I/O і JSON в одну кашу, а потім не розуміти, де саме впало.
Якщо ви робите в одному рядку Json.decodeFromString(File("x").readText()), то в разі помилки ви бачите виняток, але не розумієте, проблема була в читанні файла чи в парсингу. Коли ж ви тримаєте кроки окремо, налагодження стає майже нудним: «файл прочитали — рядок ось він — парсинг упав — значить, JSON не збігається».
Помилка №3: не створювати каталог перед записом.
File("data/expenses.json").writeText(...) виглядає гарно, але якщо папки data немає, запис може не відбутися. Через це новачки інколи думають: «серіалізація не працює», хоча серіалізація якраз спрацювала, а впав запис. Створюйте каталог через mkdirs() (або через file.parentFile?.mkdirs() усередині функції збереження).
Помилка №4: намагатися декодувати не той верхній рівень: {} замість [].
Якщо ви зберігаєте список витрат, у файлі буде JSON‑масив [...]. Якщо потім помилково спробуєте декодувати це як один Expense, отримаєте виняток. Найпростіший спосіб самоперевірки — подивитися на перший символ JSON: { означає об’єкт, [ означає список.
Помилка №5: вважати відсутність файла помилкою.
Перший запуск програми майже завжди відбувається без файла. Якщо ви в цьому місці падаєте з винятком, ви робите застосунок, який не вміє «починати життя». Краще сприймати «файла немає» як «даних поки немає» і повертати emptyList() — так набагато дружніше до користувача (і до вас, коли ви тестуєте).
Помилка №6: думати, що «якщо JSON розпарсився — значить, дані коректні».
Парсинг перевіряє структуру й типи, але не перевіряє зміст. Наприклад, amount = -999999 цілком може розпарситися. Тож корисно пам’ятати: серіалізація/десеріалізація — це шар «форма даних», а перевірки коректності — окрема логіка вашого застосунку. Сьогодні ми просто фіксуємо цю межу, щоб не вимагати від JSON неможливого.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ