Перейти к содержанию
Educora
Продвинутый25 мин13 / 14

Сеть и хранение данных

Превращай JSON в типы Swift с помощью Codable, загружай данные с сервера через URLSession и async/await, показывай результат на экране и сохраняй заметки на устройстве с SwiftData.

Проверь себя
В этом уроке ты узнаешь
  • Читать и записывать JSON с помощью структур Codable
  • Писать сетевой запрос с URLSession и async/await и обрабатывать ошибки
  • Связывать загрузку с экраном через .task и .refreshable
  • Работать в SwiftData с @Model, @Query и modelContext

Эльвин открывает Educora в самолёте. Интернета нет, но вчерашние заметки на месте, а после посадки приложение тихо загружает с сервера новые уроки. Для этого нужны два умения: получать данные из сети (JSON, URLSession, async/await) и хранить их на устройстве (SwiftData). В этом уроке мы шаг за шагом построим и то и другое.

Codable: из JSON в Swift и обратно

Серверы обычно присылают данные в виде текста JSON. Если объявить структуру соответствующей протоколу Codable, Swift сам научится читать её из JSON (JSONDecoder) и записывать в JSON (JSONEncoder) — ни строчки лишнего кода. Имена свойств должны совпадать с ключами в JSON; лишние ключи в JSON просто пропускаются.

Swift
import Foundation

struct Lesson: Codable {
    let id: Int
    let title: String
    let minutes: Int
}

let json = """
[{"id": 1, "title": "Variables", "minutes": 10},
 {"id": 2, "title": "Loops", "minutes": 15, "level": "beginner"}]
"""

let lessons = try JSONDecoder().decode([Lesson].self, from: Data(json.utf8))
for lesson in lessons {
    print("\(lesson.id). \(lesson.title) - \(lesson.minutes) min")
}

let encoder = JSONEncoder()
encoder.outputFormatting = .sortedKeys
let data = try encoder.encode(lessons[0])
print(String(decoding: data, as: UTF8.self))
Ожидаемый результат
1. Variables - 10 min
2. Loops - 15 min
{"id":1,"minutes":10,"title":"Variables"}
Работает в Swift Playgrounds или на Mac. Ключ level пропускается, потому что его нет в структуре; .sortedKeys записывает ключи по алфавиту.

URLSession и async/await

URLSession.shared.data(from:) — async-функция: слово await отмечает место, где она ждёт ответа, но главный поток в это время не блокируется — экран продолжает реагировать. Функция возвращает пару (Data, URLResponse). Если сеть пропала, она выбрасывает ошибку, поэтому перед вызовом пишут ещё и try. В примере задачи загружаются с JSONPlaceholder — открытого сервиса для тестов.

Swift
struct Todo: Codable, Identifiable {
    let id: Int
    let title: String
    let completed: Bool
}

enum APIError: Error {
    case badStatus(Int)
}

func fetchTodos() async throws -> [Todo] {
    let url = URL(string: "https://jsonplaceholder.typicode.com/todos")!
    let (data, response) = try await URLSession.shared.data(from: url)
    if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
        throw APIError.badStatus(http.statusCode)
    }
    return try JSONDecoder().decode([Todo].self, from: data)
}
Если сервер вернул статус вне диапазона 200–299, выбрасываем собственную ошибку; если JSON не подходит, ошибку выбросит decode.
Раньше: completion handler
func fetchTodos(completion: @escaping (Result<[Todo], Error>) -> Void) {
    let url = URL(string: "https://jsonplaceholder.typicode.com/todos")!
    URLSession.shared.dataTask(with: url) { data, _, error in
        if let error {
            completion(.failure(error))
            return
        }
        do {
            let todos = try JSONDecoder().decode([Todo].self, from: data ?? Data())
            completion(.success(todos))
        } catch {
            completion(.failure(error))
        }
    }.resume()
}
Сейчас: async/await
func fetchTodos() async throws -> [Todo] {
    let url = URL(string: "https://jsonplaceholder.typicode.com/todos")!
    let (data, _) = try await URLSession.shared.data(from: url)
    return try JSONDecoder().decode([Todo].self, from: data)
}
Слева, если забыть .resume(), запрос никогда не уйдёт, а пропущенный вызов completion навсегда оставит экран в состоянии «загрузка». Справа код читается сверху вниз, а ошибки сами передаются наверх через throws.
Swift
@MainActor
@Observable
final class TodosModel {
    var todos: [Todo] = []
    var isLoading = false
    var errorMessage: String?

    func load() async {
        isLoading = true
        defer { isLoading = false }
        do {
            todos = try await fetchTodos()
            errorMessage = nil
        } catch {
            errorMessage = "Could not load the list"
        }
    }
}
@MainActor гарантирует, что свойства модели меняются только в главном потоке — именно там обновляется интерфейс.
Swift
struct TodosView: View {
    @State private var model = TodosModel()

    var body: some View {
        List(model.todos) { todo in
            Label(todo.title, systemImage: todo.completed ? "checkmark.circle.fill" : "circle")
        }
        .overlay {
            if model.isLoading { ProgressView() }
        }
        .task { await model.load() }
        .refreshable { await model.load() }
    }
}
На экране: сначала крутящийся индикатор, затем список задач с отмеченными и пустыми кружками; если потянуть список вниз, он загрузится заново.

Блок defer выполняется в конце, как бы ни завершилась функция — успешно или с ошибкой, — поэтому индикатор никогда не «зависает» на экране. Когда errorMessage заполнен, вместо списка удобно показать готовое представление пустого состояния из iOS 17: ContentUnavailableView("No connection", systemImage: "wifi.slash"). Под ним можно поставить кнопку «Try again», которая снова вызывает load().

SwiftData: хранение на устройстве

SwiftData (iOS 17+) — самый короткий способ хранить класс Swift в базе данных. Перед классом пишут @Model, в приложении создают хранилище через .modelContainer(for:), а в представлении читают данные через @Query. Новый объект добавляют через modelContext.insert(...), удаляют через delete; изменения сохраняются автоматически, а @Query сам обновляет список.

Swift
import SwiftData
import SwiftUI

@Model
final class Note {
    var text: String
    var createdAt: Date

    init(text: String, createdAt: Date = .now) {
        self.text = text
        self.createdAt = createdAt
    }
}

@main
struct EducoraNotesApp: App {
    var body: some Scene {
        WindowGroup {
            NotesView()
        }
        .modelContainer(for: Note.self)
    }
}
Swift
struct NotesView: View {
    @Environment(\.modelContext) private var context
    @Query(sort: \Note.createdAt, order: .reverse) private var notes: [Note]

    var body: some View {
        NavigationStack {
            List {
                ForEach(notes) { note in
                    Text(note.text)
                }
                .onDelete { offsets in
                    for index in offsets { context.delete(notes[index]) }
                }
            }
            .navigationTitle("Notes")
            .toolbar {
                Button("Add", systemImage: "plus") {
                    context.insert(Note(text: "New note"))
                }
            }
        }
    }
}
На экране: список «Notes», самая новая заметка сверху; + добавляет заметку, свайп влево удаляет. Если закрыть и снова открыть приложение, заметки останутся на месте.
Что хранитьГде
небольшие настройки: тема, дневная цель@AppStorage (UserDefaults)
заметки, результаты, списки с поискомSwiftData
пароли и токены входаKeychain
прогресс, одинаковый на всех устройствахсервер
Например: @AppStorage("dailyGoal") private var dailyGoal = 15 сохраняет значение и после закрытия приложения.
  1. 1
    Проект со SwiftData

    При создании проекта выбери SwiftData в поле Storage — Xcode сам создаст пример модели Item, .modelContainer и экран со списком.

  2. 2
    Хранилище в памяти для превью

    Чтобы превью не трогало настоящую базу, напиши: #Preview { NotesView().modelContainer(for: Note.self, inMemory: true) }.

  3. 3
    Пройди отладчиком

    Поставь точку останова внутри load(), запусти приложение и иди построчно с F6: увидишь, как заполняется массив todos.

перейти к следующей строке в отладчике — Step OverF6
войти внутрь вызываемой функции — Step IntoF7
продолжить выполнение после остановкиCtrl+Cmd+Y
искать по всему проектуCmd+Shift+F

Главное

  • Структура Codable читается из JSON через JSONDecoder и записывается через JSONEncoder; лишние ключи пропускаются.
  • try await URLSession.shared.data(from:) возвращает (Data, URLResponse) и не блокирует экран.
  • Модель помечают @MainActor @Observable; .task запускает загрузку и сам её отменяет, .refreshable даёт жест обновления.
  • SwiftData: класс @Model, .modelContainer(for:) в приложении, @Query и modelContext.insert/delete в представлении.
  • Небольшие настройки хранят в @AppStorage, пароли — в Keychain; используй только HTTPS.

Проверь себя

Вопросов: 10. Каждый правильный ответ приносит XP.

1 / 10
Что возвращает try await URLSession.shared.data(from: url)?