- Читать и записывать 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 просто пропускаются.
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"}level пропускается, потому что его нет в структуре; .sortedKeys записывает ключи по алфавиту.URLSession и async/await
URLSession.shared.data(from:) — async-функция: слово await отмечает место, где она ждёт ответа, но главный поток в это время не блокируется — экран продолжает реагировать. Функция возвращает пару (Data, URLResponse). Если сеть пропала, она выбрасывает ошибку, поэтому перед вызовом пишут ещё и try. В примере задачи загружаются с JSONPlaceholder — открытого сервиса для тестов.
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)
}decode.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()
}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.@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 гарантирует, что свойства модели меняются только в главном потоке — именно там обновляется интерфейс.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 сам обновляет список.
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)
}
}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"))
}
}
}
}
}+ добавляет заметку, свайп влево удаляет. Если закрыть и снова открыть приложение, заметки останутся на месте.| Что хранить | Где |
|---|---|
| небольшие настройки: тема, дневная цель | @AppStorage (UserDefaults) |
| заметки, результаты, списки с поиском | SwiftData |
| пароли и токены входа | Keychain |
| прогресс, одинаковый на всех устройствах | сервер |
@AppStorage("dailyGoal") private var dailyGoal = 15 сохраняет значение и после закрытия приложения.- 1Проект со SwiftData
При создании проекта выбери
SwiftDataв полеStorage— Xcode сам создаст пример моделиItem,.modelContainerи экран со списком. - 2Хранилище в памяти для превью
Чтобы превью не трогало настоящую базу, напиши:
#Preview { NotesView().modelContainer(for: Note.self, inMemory: true) }. - 3Пройди отладчиком
Поставь точку останова внутри
load(), запусти приложение и иди построчно сF6: увидишь, как заполняется массивtodos.
Главное
- Структура
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.
try await URLSession.shared.data(from: url)?