Сеть, 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-ответыURLCacheCache-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.

Проверьте себя
1. Сервер отдаёт ключи в snake_case, а поле is_premium иногда отсутствует. Что достаточно сделать?
AНаписать полный init(from decoder:) — иначе никак
BСделать все поля модели опциональными
CЗадать keyDecodingStrategy = .convertFromSnakeCase и читать поле через decodeIfPresent со значением по умолчанию
DПерейти на JSONSerialization и словари [String: Any]
2. В ленте из 200 статей у одной сломано поле даты, и декодирование [Article] падает целиком. Какой приём чинит это правильнее всего?
AОбернуть каждый элемент в FailableDecodable и собрать результат через compactMap
BОбернуть весь decode в try? и показать пустой экран
CСделать все поля Article опциональными
DДекодировать в [String: Any] и разбирать вручную
3. Где хранить access-токен и почему?
AВ UserDefaults — быстро и просто
BВ Keychain — данные шифруются системой, есть контроль доступности и переживание переустановки при iCloud Keychain
CВ JSON-файле в Caches — систему легко почистить
DВ NSCache — он автоматически чистится при нехватке памяти