Сеть, Codable, кэширование и персистентность
Сеть, декодирование и хранение — темы, на которых видно, писали ли вы приложение, живущее дольше одного релиза.
Задача сетевого слоя — превратить чужой JSON в типы вашего домена и не уронить экран из-за одного кривого поля.
Вопрос: «Как устроен ваш сетевой слой?»
Что на самом деле проверяют
Умеете ли вы спрятать URLSession за узкой абстракцией, разбираете ли статусы ответа и как типизируете ошибки. Плохой признак — обработка ошибок «по месту» и try? вместо разбора причины.
enum APIError: Error {
case invalidResponse, http(status: Int, body: Data), decoding(Error), transport(Error)
}
struct APIClient {
let baseURL: URL, session: URLSession, decoder: JSONDecoder
func send<T: Decodable>(_ request: URLRequest, as type: T.Type) async throws -> T {
let data: Data, response: URLResponse
do { (data, response) = try await session.data(for: request) }
catch { throw APIError.transport(error) }
guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
guard (200..<300).contains(http.statusCode) else {
throw APIError.http(status: http.statusCode, body: data)
}
do { return try decoder.decode(T.self, from: data) } catch { throw APIError.decoding(error) }
}
}
async/await убирает пирамиду замыканий и делает отмену естественной: задача из .task {} отменяется вместе с экраном. Отдельный enum ошибок нужен, чтобы UI отличал «нет сети» от 401 и показывал разные экраны, а не общий «Что-то пошло не так».
Вопрос: «Как разобрать JSON, где ключи в snake_case, а поле может отсутствовать?»
Что на самом деле проверяют
Знание Codable дальше учебного примера: стратегии декодера, значения по умолчанию, вложенность и понимание, когда нужен свой init.
{ "id": 42, "first_name": "Аня", "created_at": "2026-07-29T10:15:00Z",
"profile": { "city": "Казань" } }
struct User: Decodable {
let id: Int, firstName: String, createdAt: Date, city: String?, isPremium: Bool
enum CodingKeys: String, CodingKey { case id, firstName, createdAt, profile, isPremium }
enum ProfileKeys: String, CodingKey { case city }
init(from d: Decoder) throws {
let c = try d.container(keyedBy: CodingKeys.self)
id = try c.decode(Int.self, forKey: .id)
firstName = try c.decode(String.self, forKey: .firstName)
createdAt = try c.decode(Date.self, forKey: .createdAt)
isPremium = try c.decodeIfPresent(Bool.self, forKey: .isPremium) ?? false
let profile = try? c.nestedContainer(keyedBy: ProfileKeys.self, forKey: .profile)
city = try profile?.decodeIfPresent(String.self, forKey: .city)
}
}
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601
Codable — это Encodable & Decodable. Пока хватает автосинтеза, ручной init(from:) не нужен: CodingKeys покрывает переименование, keyDecodingStrategy — snake_case сразу для всех полей, опциональность — «поля может не быть». Свой инициализатор обязателен в двух случаях: поле приходит то строкой, то числом, и полиморфный ответ с дискриминатором.
enum Feed: Decodable {
case article(Article), ad(Ad)
private enum Keys: String, CodingKey { case type }
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: Keys.self)
switch try c.decode(String.self, forKey: .type) {
case "article": self = .article(try Article(from: decoder))
case "ad": self = .ad(try Ad(from: decoder))
case let other: throw DecodingError.dataCorruptedError(
forKey: .type, in: c, debugDescription: "Неизвестный тип: \(other)")
}
}
}
Отдельная боль — один битый элемент роняет весь список: [Article] декодируется атомарно. Лечится обёрткой, глотающей ошибку поэлементно.
struct FailableDecodable<T: Decodable>: Decodable {
let value: T?
init(from decoder: Decoder) throws { value = try? T(from: decoder) }
}
let articles = try decoder
.decode([FailableDecodable<Article>].self, from: data)
.compactMap(\.value) // 199 из 200 лучше, чем пустой экран
Вопрос: «Где хранить токен и где — список статей?»
Что на самом деле проверяют
Понимаете ли вы, что у каждого хранилища своя гарантия. Ответ «в UserDefaults» про токен — почти гарантированный минус: это незашифрованный plist, попадающий в резервные копии.
| Что храним | Чем | Почему |
| Флаг «онбординг пройден», тема | UserDefaults | Мелочь, потеря не критична |
| Токен, пароль, ключ шифрования | Keychain | Шифруется системой, переживает переустановку при iCloud Keychain |
| Кэш ленты, черновики | JSON-файлы на диске | Просто и легко чистить; Caches система вправе удалять |
| Граф со связями и запросами | Core Data / SwiftData | Выборки, ленивые связи, миграции |
| HTTP-ответы | URLCache | Cache-Control и ETag работают сами, ревалидация даёт 304 |
| Картинки в памяти | NSCache | Чистится при нехватке памяти |
URLCache недооценивают: если сервер отдаёт корректные Cache-Control и ETag, повторный запрос вернётся из кэша или получит 304 без тела — бесплатно, без единой строки кода. Свой слой кэширования нужен для офлайн-сценариев и своей политики устаревания.
Картинки держат в NSCache с выставленным totalCostLimit, где cost считают по числу пикселей. Обычный Dictionary плох тем, что не отдаёт память при нехватке, не потокобезопасен и не знает про стоимость объектов.
Типичные ошибки кандидатов
- Хранить токен в
UserDefaults«пока временно». - Не проверять
statusCode: сервер отдал 500 с HTML, а пользователь видит «ошибка парсинга». - Делать все поля опциональными «на всякий случай»: опциональность расползается по коду.
- Писать свой
init(from:)там, где хватило быCodingKeys. - Ставить Core Data ради кэша из двадцати записей: миграции дороже кэша.
Как ответить кратко
Сеть — тонкий
APIClientповерхURLSessionсasync/await: проверяюstatusCode, ошибки заворачиваю в свой enum, чтобы UI отличал 401 от отсутствия сети. Парсинг —Codable: snake_case черезkeyDecodingStrategy, даты через.iso8601, необязательные поля черезdecodeIfPresentс дефолтом; свойinit(from:)— только для полиморфных ответов, а списки декодирую через обёртку, чтобы один битый элемент не убил ленту. Хранение по назначению: секреты в Keychain, настройки в UserDefaults, HTTP-кэш отдаюURLCache, картинки вNSCache.