Демо-отчёт. Проект и код написаны для примера — в них специально оставлены частые ошибки тестовых заданий. Это не работа реального клиента.
CodeCheckupОтчёт по код-ревью

Заметки с синхронизацией — тестовое задание

Список заметок, редактор с автосохранением, вход и синхронизация с сервером. Swift, SwiftUI, URLSession.

Заявка
№DEMO
Стек
iOS
Что проверяли
Тестовое задание
Уровень
Junior+
Репозиторий
демо-проект
Ревьюер
iOS-разработчик CodeCheckup
Дата
20 октября 2026

Главное за минуту

3Исправить обязательно
4Желательно поправить
2На будущее

Интерфейс аккуратный, экраны разбиты правильно. До отправки исправьте 3 пункта P1 — это около 2 часов.

  1. Уберите try! из загрузки заметок — P1-1. Без сети приложение падает, а авиарежим проверяющие включают одним из первых.
  2. Перенесите токен из UserDefaults в Keychain — P1-3.
  3. Обновляйте экран из главного потока: пометьте модель @MainActor — P1-2. Сейчас Xcode показывает фиолетовые предупреждения.
  4. Замените @ObservedObject на @StateObject в списке — P2-1. Иначе список сбрасывается при перерисовке родителя.
  5. Если останется время — таймер автосохранения P2-3 и тесты без сети P2-4.

Ответ на ваш вопрос

«Не уверена, когда нужен @StateObject, а когда @ObservedObject».

Правило простое: кто создаёт модель, тот и владеет ею. Если экран сам создаёт модель — @StateObject: SwiftUI создаст её один раз и сохранит между перерисовками. Если модель пришла снаружи (её создал родитель) — @ObservedObject. В NotesListView модель создаётся внутри, а объявлена как @ObservedObject — отсюда сброс списка P2-1.

Кто чем владеет после исправлений
NotesApp@StateObject NotesViewModelNotesListView
NotesListViewNoteRow (получает Note — простое значение)
NotesListViewEditorView@StateObject EditorViewModel(note)

С iOS 17 то же самое делается макросом @Observable и @State — если минимальная версия позволяет, это можно упомянуть в README как следующий шаг.

Сильные стороны

  • Экраны разбиты на небольшие View с превью — NoteRow, EmptyStateView, SyncBadge. Код читается легко.
  • Сетевой слой за протоколом NotesAPI: его легко подменить в тестах и превью — после исправления P2-4 это станет сильным пунктом.
  • Модели — структуры Codable без лишних классов, даты разбираются через JSONDecoder.dateDecodingStrategy.
  • Поддержаны тёмная тема и Dynamic Type: системные цвета и шрифты, вёрстка не ломается на крупном тексте.

Соответствие требованиям задания

Требование Статус Комментарий
Список заметок частично частично без сети приложение падает — P1-1; список сбрасывается — P2-1
Редактор с автосохранением частично частично автосохранение продолжается после закрытия экрана — P2-3
Вход и синхронизация частично частично токен хранится в UserDefaults — P1-3
SwiftUI выполнено экраны и навигация на SwiftUI, без UIKit-обёрток
Тесты частично частично есть, но ходят в сеть — P2-4

Замечания

Надёжность

Исправить обязательноP1-1

Без сети приложение падает

Где
Notes/NotesViewModel.swift:26
Время
~30 мин

Что не так. Ответ сервера разбирается через try!. Любая ошибка — нет сети, таймаут, неожиданный JSON — превращается в падение приложения.

Было · Notes/NotesViewModel.swift
func load() {
    api.fetchNotes { result in
        self.notes = try! result.get()
    }
}

Как исправить. Перейдите на async/await и показывайте ошибку с кнопкой «Повторить»:

Стало · Notes/NotesViewModel.swift
@MainActor
final class NotesViewModel: ObservableObject {
    @Published private(set) var notes: [Note] = []
    @Published var error: Error?
    private let api: NotesAPI

    init(api: NotesAPI = LiveNotesAPI()) { self.api = api }

    func load() async {
        do {
            notes = try await api.notes()
            error = nil
        } catch {
            self.error = error
        }
    }
}

В экране загрузка запускается через .task { await model.load() } — задача отменится сама, когда экран закроют.

Исправить обязательноP1-2

@Published меняется из фонового потока

Где
Notes/NotesViewModel.swift:20
Время
~20 мин

Обработчик URLSession вызывается в фоновой очереди, а из него меняется notes. Xcode показывает предупреждение «Publishing changes from background threads is not allowed», интерфейс обновляется с задержкой или не обновляется. Пометка модели @MainActor из P1-1 решает проблему целиком: компилятор сам проследит, чтобы состояние менялось в главном потоке.

Было · Notes/NotesViewModel.swift
final class NotesViewModel: ObservableObject {
    @Published var notes: [Note] = []

    func load() {
        api.fetchNotes { result in
            // обработчик URLSession вызывается в фоновой очереди
            self.notes = try! result.get()
        }
    }
}
Стало · Notes/NotesViewModel.swift
@MainActor
final class NotesViewModel: ObservableObject {
    @Published private(set) var notes: [Note] = []
    // load() — async, как в P1-1: после await код продолжается в главном потоке
}

Безопасность

Исправить обязательноP1-3

Токен хранится в UserDefaults

Где
Auth/AuthService.swift:18
Время
~40 мин

Что не так. UserDefaults — обычный plist в папке приложения. Он попадает в незашифрованные бэкапы и читается на взломанном устройстве.

Было · Auth/AuthService.swift
UserDefaults.standard.set(token, forKey: "authToken")

Как исправить. Храните токен в Keychain. Небольшой обёртки достаточно:

Стало · Auth/TokenStore.swift
enum TokenStore {
    private static let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrAccount as String: "auth-token",
    ]

    static func save(_ token: String) throws {
        SecItemDelete(query as CFDictionary)
        var attributes = query
        attributes[kSecValueData as String] = Data(token.utf8)
        attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
        let status = SecItemAdd(attributes as CFDictionary, nil)
        guard status == errSecSuccess else { throw KeychainError(status: status) }
    }
}

SwiftUI

Желательно поправитьP2-1

Список сбрасывается при перерисовке родителя

Где
Notes/NotesListView.swift:9
Время
~5 мин

Модель создаётся прямо в объявлении @ObservedObject var model = NotesViewModel(). Когда родитель перерисовывается, SwiftUI пересоздаёт структуру экрана — и вместе с ней новую пустую модель.

Было · Notes/NotesListView.swift
struct NotesListView: View {
    @ObservedObject var model = NotesViewModel()
Стало · Notes/NotesListView.swift
@StateObject private var model = NotesViewModel()
Желательно поправитьP2-2

DateFormatter создаётся при каждой перерисовке

Где
Notes/NoteRow.swift:14
Время
~10 мин

DateFormatter() создаётся внутри body. Это дорогой объект, а body вызывается часто — при прокрутке длинного списка это заметно. Используйте встроенное форматирование:

Было · Notes/NoteRow.swift
var body: some View {
    let formatter = DateFormatter()
    formatter.dateFormat = "d MMM, HH:mm"
    return Text(formatter.string(from: note.updatedAt))
        .font(.caption)
}
Стало · Notes/NoteRow.swift
Text(note.updatedAt, format: .dateTime.day().month(.abbreviated).hour().minute())
    .font(.caption)
    .foregroundStyle(.secondary)
Желательно поправитьP2-3

Автосохранение продолжается после закрытия редактора

Где
Editor/EditorViewModel.swift:31
Время
~20 мин

Таймер держит замыкание, замыкание держит self — модель редактора никогда не освобождается, и автосохранение продолжает работать после закрытия экрана. Захватывайте self слабо и останавливайте таймер:

Было · Editor/EditorViewModel.swift
timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in
    self.saveIfChanged()
}
Стало · Editor/EditorViewModel.swift
timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
    Task { @MainActor in self?.saveIfChanged() }
}

deinit { timer?.invalidate() }

Тесты

Желательно поправитьP2-4

Тесты модели ходят в сеть

Где
NotesTests/NotesViewModelTests.swift
Время
~1 ч

Тесты обращаются к настоящему серверу и ждут ответа через wait(for:timeout: 5). Без сети они падают, с сетью — медленные. Протокол NotesAPI уже есть — подмените его фейком:

Было · NotesTests/NotesViewModelTests.swift
func testLoadsNotes() {
    let model = NotesViewModel()
    let loaded = expectation(description: "notes loaded")
    model.load()
    DispatchQueue.main.asyncAfter(deadline: .now() + 3) { loaded.fulfill() }
    wait(for: [loaded], timeout: 5)
    XCTAssertFalse(model.notes.isEmpty)
}
Стало · NotesTests/NotesViewModelTests.swift
@MainActor
final class NotesViewModelTests: XCTestCase {
    func testShowsErrorWhenOffline() async {
        let model = NotesViewModel(api: FakeNotesAPI(result: .failure(URLError(.notConnectedToInternet))))
        await model.load()
        XCTAssertTrue(model.notes.isEmpty)
        XCTAssertNotNil(model.error)
    }
}
На будущееP3-1

SwiftLint в проекте

Подключите SwiftLint с базовыми правилами — он ловит force unwrap и try!, как в P1-1. Конфиг в репозитории показывает, что вы следите за качеством кода.

На будущееP3-2

Строки в String Catalog

Тексты интерфейса записаны прямо в коде. Вынесите их в Localizable.xcstrings — даже без перевода это показывает, что приложение готово к локализации.

Что дальше

  1. P1-1 async/await и ошибка с повтором вместо try! — ~30 мин
  2. P1-3 Токен в Keychain — ~40 мин
  3. P1-2 Модель с @MainActor — ~20 мин
  4. P2-1 @StateObject в списке — ~5 мин
  5. P2-2, P2-3 и P2-4 — если останется время, ~1,5 ч

Вопросы по отчёту — 1 раунд в течение 7 дней, пишите в тот же чат. Доступ к репозиторию можно отозвать уже сейчас.

Что упомянуть в сопроводительном письме: сетевой слой за протоколом и аккуратные SwiftUI-экраны с поддержкой Dynamic Type, а после исправлений — тесты модели без сети.