Использование DeepSeek при создании iOS приложения Countries Страны Мира. Часть 2. Численность населения, ВВП, флаг.

Это продолжение. Начало находится в посте «Использование DeepSeek при создании iOS приложения Countries Страны Мира. Часть 1. Список стран.».

Код находится на Github.

Корректируем поля в Индикаторе 

И опять в  улучшенной реализации с населением, ВВП, столицы и эмодзи флага мы имеем НЕВЕРНЫЙ код для ResponseMetadata 

 struct ResponseMetadata: Decodable {
            let page: Int
            let pages: Int
            let perPage: String
            let total: Int
 }

вместо ПРАВИЛЬНОГО:

struct ResponseMetadata: Decodable {
            let page: Int
            let pages: Int
            let perPage: String
            let total: Int
            
            enum CodingKeys: String, CodingKey {
                case page, pages, total
                case perPage = "per_page"
            }
 }

Но даже если мы будем использовать правильный код, мы все равно получим ту же самую ошибку: “ The data couldn’t be read because it is missing.”

Небольшая отладка и просмотр JSON данных для численности населения:

[ 
 {
    "page": 1,
    "pages": 9,
    "per_page": 30,
    "total": 266,
    "sourceid": "2",
    "lastupdated": "2025-01-28"
  },
  [
    {
      "indicator": {
        "id": "NY.GDP.MKTP.CD",
        "value": "GDP (current US$)"
      },
      "country": {
        "id": "ZH",
        "value": "Africa Eastern and Southern"
      },
      "countryiso3code": "AFE",
      "date": "2022",
      "value": 1191423176242.96,
      "unit": "",
      "obs_status": "",
      "decimal": 0
    },
....
  ]
]

… позволит определить, что поля countryiso2code, указанного в Модели  struct Entry, НЕТ в JSON данных, а есть поле countryiso3code, которое является 3-х буквенным IOS кодом страны, но это не то, что нам нужно. Нам нужен 2-х буквенный IOS код страны, который находится в id в объекте country индикатора, так что DeepSeek немного запутался. И не зря, он не рискнул взять id в объекте country response indicator, так как id в объекте Country response country — это  3-х буквенный IOS кодом страны. Нелогичность конструирования JSON данных в API World Bank приводит  DeepSeek в замешательство.

Давайте сообщим ему об этом:

В JSON indicator данных нет поля countryios2code.
Используй id из объекта country в JSON indicator данных.

В результате мы получаем рассуждения, которые длятся 18 секунд:

Вот перевод этих рассуждений:

Хорошо, давайте посмотрим. Пользователь упомянул, что в JSON данных индикатора indicator нет поля countryiso2code, и что вместо него мы должны использовать id в объекте country. Хм, мне нужно снова 

Во-первых, я припоминаю, что JSON данные индикатора indicator из API Всемирного банка имеют другую структуру. Основным идентификатором страны country там является НЕ countryiso2code, а объект country с идентификатором id, который по сути является ISO2 кодом. Позвольте мне это проверить.

Рассматривая пример ответа response от indicator endpoint, например, для численности населении population, мы убеждаемся, что каждая запись, вероятно, имеет объект country, содержащий id и value. Поэтому вместо countryiso2code мы должны получить доступ к country.id, который и будет ISO2 кодом.

В предыдущем коде структура struct Entry для IndicatorResponse указана countryiso2code, но это неверно. Правильный путь — country.id. Поэтому мне нужно настроить ключи декодирования и структуру, чтобы отразить это.

Позвольте мне изменить структуру Entry. CodingKeys для Entry должны декодировать country как вложенный объект, а затем извлечь его id. Поэтому структура struct Entry должна иметь свойство country, которое является вложенной структурой struct со свойством id и value.

Подождите, структура JSON данных для индикатора indicator, вероятно, выглядит так:
{
"country": {"id": "US", "value": "United States"},
"value": 331002651
}
Поэтому struct Entry должна иметь структуру struct Country с id, а не countryiso2code. Поэтому в структуре struct Entry у меня должно быть:
struct Entry: Decodable {
    let country: Country
    let value: Double?


    struct Country: Decodable {
        let id: String
    }
 }
Затем при обработке Entry используйте country.id вместо countryiso2code.
Таким образом, в функции func fetchIndicatorData при заполнении словаря dictionary ключом становится entry.country.id.

Подождите, но в предыдущем коде пользователь использовал countryiso2code, которого не существует. Изменение этого на использование вложенного country.id должно исправить проблему.

Позвольте мне соответствующим образом скорректировать структуру struct Entry в IndicatorResponse. Также при необходимости обновите CodingKeys. Поскольку ключами JSON данных являются country и value, нет необходимости в пользовательских CodingKeys, так как имена свойств не отличаются.

Как только это будет исправлено, код должен правильно сопоставлять ISO2 коды стран из поля country.id в данных индикатора indicator.
Тестирование этого изменения гарантирует, что данные о населении population и ВВП gdp будут правильно ассоциироваться с правильными странами country с использованием их ISO2 кодов из поля id в объекте country каждой записи индикатора indicator.

Для пользователя выдается:

Вы абсолютно правы — я допустил ошибку в обработке структуры данных индикатора indicator. Давайте исправим это, правильно получив доступ к идентификатору страны id из вложенного объекта country в ответах response индикатора indicator. Вот исправленная реализация:

class CountryViewModel: ObservableObject {
    // ... keep previous properties and other methods unchanged ...

    private func fetchIndicatorData(url: String) async throws -> [String: Double] {
        struct IndicatorResponse: Decodable {
            let entries: [Entry]
            
            init(from decoder: Decoder) throws {
                var container = try decoder.unkeyedContainer()
                _ = try container.decode(ResponseMetadata.self)
                entries = try container.decode([Entry].self)
            }
            
            struct Entry: Decodable {
                let country: Country
                let value: Double?
                
                struct Country: Decodable {
                    let id: String  // This is the ISO2 code
                }
            }
        }
        
        let (data, _) = try await URLSession.shared.data(from: 
                                                         URL(string: url)!)
        let response = try JSONDecoder().decode(IndicatorResponse.self, 
                                                               from: data)
        
        return response.entries.reduce(into: [:]) { dict, entry in
            guard let value = entry.value else { return }
            dict[entry.country.id] = value
        }
    }
}

Ключевые изменения произошли в структуре IndicatorResponse.Entry, где теперь мы правильно декодируем вложенный объект country:

struct Entry: Decodable {
    let country: Country
    let value: Double?
    
    struct Country: Decodable {
        let id: String  // ISO2 code from indicator data
    }
}

Это соответствует фактической структуре ответа response API индикатора indicator Всемирного банка, которая выглядит следующим образом (позже мы увидим, что ошибочно указано, что per_page в метаданных indicator является String, a на самом деле это  Int ):

[
  {
    "page": 1,
    "pages": 5,
    "per_page": "50",
    "total": 299
  },
  [
    {
      "indicator": {"id": "SP.POP.TOTL", "value": "Population, total"},
      "country": {"id": "US", "value": "United States"},
      "value": 331002651,
      "decimal": "0",
      "date": "2021"
    },
    // ... other entries
  ]
]

Остальная часть кода остается прежней, но позвольте мне выделить важные связи:

1. Идентификация страны:

  • Базовые данные страны Country используют поле iso2Code
  • Данные индикатора indicator используют поле country.id
  • Оба представляют  ISO2 коды и используются для взаимосвязи

2. Объединение  данных:

finalCountries = finalCountries.map { country in
    var modified = country
    modified.population = populationDict[country.iso2Code]
    modified.gdp = gdpDict[country.iso2Code]
    return modified
}

3. Обработка ошибок

guard let value = entry.value //пропускает записи без данных
  • Метод reduce безопасно создает словарь
  • В окончательном списке стран filtered фильтруются страны с пустыми столицами

Это исправление гарантирует, что мы правильно извлекаем ISO2 коды стран из обоих источников данных:

  • Из основной country endpoint: iso2Code
  • Из indicator endpoint: country.id

Остальная часть  UI и модели данных остаются неизменной, но теперь экономические данные будут правильно сопоставляться со странами на основе их ISO2 кодов.

Запускаем приложение  и мы все равно получим ту же самую ошибку: “ The data couldn’t be read because it is missing.”

Небольшая отладка и просмотр “живых” JSON данных для численности населения:

[
  {
    "page": 1,
    "pages": 9,
    "per_page": 30,
    "total": 266,
    "sourceid": "2",
    "lastupdated": "2025-01-28"
  },
  [
    {
      "indicator": {"id": "SP.POP.TOTL", "value": "Population, total"},
      "country": {"id": "US", "value": "United States"},
      "value": 331002651,
      "decimal": "0",
      "date": "2021"
    },
    // ... other entries
  ]
]

… позволяют заметить, что поле «per_page» является целым числом Int в метаданных indicator, в то время как в метаданных country это строка String. Следовательно, для декодирования структура IndicatorResponseMetadata должна отличаться от ResponseMetadata, которая сейчас используется в метаданных indicator. Еще раз убеждаемся, что ИИ плохо работают с плохо спроектированными человеком структурами.

Давайте сообщим об этом DeepSeek:

IndicatorResponseMetadata отличается от ResponseMetadata:
поле per_page в  ResponseMetadata является строкой String,
a поле per_page в IndicatorResponseMetadata  является целым числом Int

В результате мы получаем рассуждения, которые длятся 19 секунд:

Вот перевод этих рассуждений:

Хорошо, давайте займемся этой проблемой. Пользователь указал на несоответствие между полями `per_page` в двух разных структурах метаданных ответа. В исходном `ResponseMetadata` `per_page` — это String, но в `IndicatorResponseMetadata` — это Int. Это вызывает ошибку декодирования, поскольку строгая система типов Swift ожидает, что ТИПы должны точно совпадать.
Во-первых, мне нужно проверить фактические структуры API ответов response Всемирного банка. Позвольте мне напомнить, что основная country endpoint возвращает `per_page` как String, в то время как indicator endpoint возвращают `per_page` как Int. Это несоответствие в дизайне API является основной причиной проблемы.

Чтобы решить эту проблему, я не могу использовать одну и ту же структуру `ResponseMetadata` для обоих случаев. Вместо этого мне нужно создать отдельные структуры метаданных для каждого типа ответа. Для основного списка стран метаданные имеют `per_page` как строку String, а для данных индикатора — это Int.

Итак, я определяю две отдельные структуры: `CountryResponseMetadata` и `IndicatorResponseMetadata`. Каждая будет иметь правильный ТИП для `per_page`. Затем в процессе декодирования для каждого API вызова я буду использовать соответствующую структуру метаданных.

В методе `fetchCountries` при декодировании данных о странах я буду использовать `CountryResponseMetadata`. В методе `fetchIndicatorData` я буду использовать `IndicatorResponseMetadata` для правильного анализа метаданных индикатора. Таким образом, каждый ответ response декодируется с правильными ТИПами, предотвращая любые несоответствия.

Кроме того, мне нужно убедиться, что все ссылки на `ResponseMetadata` в коде заменены правильной структурой. Например, в структуре `WorldBankResponse` не ключевой контейнер unkeyedContainer должен декодировать `CountryResponseMetadata`, a в `IndicatorResponse` - это `IndicatorResponseMetadata` вместо прежнего `ResponseMetadata`.
Тестирование этого изменения имеет решающее значение. Мне нужно будет смоделировать оба API ответа, чтобы подтвердить, что декодирование работает без ошибок. Если поле `per_page` правильно анализируется как String или Int в зависимости от endpoint, проблема должна быть решена.

Еще одно соображение — обработка ошибок. Если в  API ответах response есть какие-либо другие несоответствия, приложение должно корректно их обработать. Добавление более описательных сообщений об ошибках может помочь в отладке подобных проблем в будущем.

Наконец, я обновлю фрагменты кода, предоставляемые пользователю, обязательно выделив изменения в определениях структур и их использовании в процессе декодирования. Это четкое разделение гарантирует, что каждая часть API ответа response будет правильно проанализирована в соответствии с ее структурой.

Кратко для пользователя:

Вы абсолютно правы — разные endpoints API Всемирного банка используют разные ТИПы данных для поля per_page. Давайте правильно справимся с этим, создав отдельные структуры метаданных для каждого типа ответа response. 

Вот исправленная реализация:

// Separate metadata structures for different endpoints
struct CountryResponseMetadata: Decodable {
    let page: Int
    let pages: Int
    let perPage: String  // String in country endpoint
    let total: Int
    
    enum CodingKeys: String, CodingKey {
        case page, pages, total
        case perPage = "per_page"
    }
}

struct IndicatorResponseMetadata: Decodable {
    let page: Int
    let pages: Int
    let perPage: Int  // Int in indicator endpoints
    let total: Int
    
    enum CodingKeys: String, CodingKey {
        case page, pages, total
        case perPage = "per_page"
    }
}

class CountryViewModel: ObservableObject {
    // ... rest of the class remains the same ...

    private func fetchCountries() async throws -> [Country] {
        struct WorldBankResponse: Decodable {
            let countries: [Country]
            
            init(from decoder: Decoder) throws {
                var container = try decoder.unkeyedContainer()
                _ = try container.decode(CountryResponseMetadata.self)  // Use correct metadata
                countries = try container.decode([Country].self)
            }
        }
        
        let (data, _) = try await URLSession.shared.data(from: URL(string: countryURL)!)
        return try JSONDecoder().decode(WorldBankResponse.self, from: data).countries
    }

    private func fetchIndicatorData(url: String) async throws -> [String: Double] {
        struct IndicatorResponse: Decodable {
            let entries: [Entry]
            
            init(from decoder: Decoder) throws {
                var container = try decoder.unkeyedContainer()
                _ = try container.decode(IndicatorResponseMetadata.self)  // Use correct metadata
                entries = try container.decode([Entry].self)
            }
            
            struct Entry: Decodable {
                let country: Country
                let value: Double?
                
                struct Country: Decodable {
                    let id: String
                }
            }
        }
        
        let (data, _) = try await URLSession.shared.data(from: 
                                                        URL(string: url)!)
        let response = try JSONDecoder().decode(IndicatorResponse.self, 
                                                        from: data)
        
        return response.entries.reduce(into: [:]) { dict, entry in
            guard let value = entry.value else { return }
            dict[entry.country.id] = value
        }
    }
}

Ключевые изменения:

  1. Созданы отдельные типы CountryResponseMetadata и IndicatorResponseMetadata
  2. CountryResponseMetadata использует String для perPage
  3. IndicatorResponseMetadata использует Int для perPage
  4. Обновлено декодирование в fetchCountries и fetchIndicatorData для использования соответствующих ТИПов метаданных

Это соответствует фактической структуре API Всемирного банка:

  • Country endpoint response:
{
  "page": 1,
  "pages": 6,
  "per_page": "50",
  "total": 299
}
  • Indicator endpoint response:
{
  "page": 1,
  "pages": 10,
  "per_page": 50,
  "total": 500
}

Остальной код может оставаться неизменным. Это решение правильно обрабатывает:

  • Разные ТИПы данных для одного и того же поля на разных endpoints
  • Вложенную структуру country.id в ответах response индикатора indicator
  • Безопасное декодирование JSON со Swift протоколом Codable 
  • Правильную обработку ошибок при несоответствиях декодирования

Это должно устранить любые ошибки декодирования, связанные с несоответствиями ТИПов в метаданных, при этом сохраняя всю предыдущую функциональность с населением, ВВП, столицами и эмодзи флагов.

Запускаем приложение — получаем результат:

Разделение Model и ViewModel

Давайте попросим выделить Model из файла CountryViewModel:

Раздели Model и View Model в нашем коде CountryViewModel

В результате мы получаем рассуждения, на которые затрачено 64 секунд (чуть более 1 минуты):

Вот перевод этих рассуждений:

Хорошо, пользователь хочет, чтобы я разделил Model и View Model в существующем коде SwiftUI. Начну с того, что напомню текущую структуру. В предыдущем коде была структура struct Country и класс class CountryViewModel, но все они находились в одном файле. Чтобы следовать передовой практике, мне следует разбить их на отдельные файлы или, по крайней мере, на отдельные разделы в коде.

Во-первых, мне нужно определить все Модели данных. Структура struct Country уже присутствует, но есть также вложенные структуры, такие как struct Region и метаданные для ответов API response. Их следует переместить в отдельную группу Моделей. View Model CountryViewModel, должна обрабатывать только бизнес-логику, выборку данных и управление состоянием.

Рассматривая структуру struct Country, у нее есть такие свойства, как id, iso2Code, name, capitalCity, region, population и gdp. Структура struct Region является частью struct Country, поэтому, возможно, ее следует оставить в Модели Country, если она не используется где-то еще. Такие структуры метаданных, как CountryResponseMetadata и IndicatorResponseMetadata, используются при декодировании ответов API, поэтому они также относятся к разделу Модели.

Далее, View Model CountryViewModel в настоящее время имеет @Published свойства loadState и groupedCountries. Она также имеет fetch методы для выборки данных. Мне нужно убедиться, что View Model не содержит никаких определений Моделей. Роль View Model заключается в управлении данными и состоянием, поэтому она должен использовать Модели, но НЕ определять их.

Мне следует создать отдельную группу для Models, которая включает Country, Region и структуры метаданных. Затем View Model импортирует модели и использует их. Кроме того, IndicatorResponse и его вложенные структуры Entry и Country являются частью логики выборки данных, поэтому их следует поместить в область действия View Model, возможно, как private структуры struct внутри методов, где они используются.

Подождите, IndicatorResponse используется в методе fetchIndicatorData. Поскольку это зависит от того, как View Model обрабатывает данные, возможно, лучше хранить эти структуры ответов response внутри View Model как private вложенные структуры. Таким образом, Модели остаются чистыми и представляют только данные приложения, а не структуры API ответов response.

Еще одно соображение — перечисление enum LoadingState. Это часть управления состоянием View Model, поэтому оно должно оставаться внутри View Model. Модели не должны иметь никаких перечислений enum, связанных с состоянием View Model.

Мне также нужно проверить, нет ли других вложенных структур данных. ResponseMetadata ранее была отдельной структурой, но после последних изменений она разделена на CountryResponseMetadata и IndicatorResponseMetadata. Они должны быть частью Моделей, поскольку они связаны со структурой API ответа response.
Подождите, на самом деле, эти структуры метаданных используются только во время процесса декодирования в View Model. Возможно, они должны быть private в методах View Model, где они используются. Потому что если они являются частью Модели, но не используются где-либо еще, лучше оставить их внутри View Model, чтобы не раскрывать ненужные типы.
Итак, план такой:

1. Создайте группу Models, содержащую Country, Region и любые другие сущности данных.

2. Переместите структуры метаданных (CountryResponseMetadata, IndicatorResponseMetadata) во View Model как private  структуры, поскольку они используются только во время декодирования.

3. Убедитесь, что методы View Model используют эти Модели и управляют выборкой и обработкой данных.

4. Выделите View Model в отдельный файл или раздел, сохранив его отдельно от Моделей.
Если посмотреть на существующий код, структура Country определена в том же файле, что и View Model. Чтобы разделить их, я могу реорганизовать код в два раздела: один для Моделей и один для View Model. В Swift это можно сделать с помощью расширений или просто упорядочив код. Однако в реальном проекте они могут находиться в отдельных файлах.

Еще один момент, на который следует обратить внимание, — это struct IndicatorResponse и его вложенные структуры. Поскольку они предназначены для анализа данных индикатора в View Model, они должны оставаться в View Model как private структуры. Это позволяет Моделям сосредоточиться на данных приложения, а не на структурах API ответов response.

Мне также нужно проверить CodingKeys для Модели Country. Ранее в ней были CodingKeys для iso2Code и capitalCity. Это верно, так как API использует разные имена ключей. Соответствие протоколу Codable должно оставаться в Модели.

Генерация эмодзи флага — это вычисляемое flag свойство в Модели Country. Это нормально, потому что оно получено из iso2Code, который является частью модели. Это логика View, но она достаточно проста, чтобы оставаться в Модели.
Теперь соберем все вместе:

1. Группа Models будет иметь:

- Country с Region и CodingKeys.
- Возможно, структуры метаданных, если они используются где-то еще, но, скорее всего, они остаются в View Model.

2. Группа View Model будет иметь:

- CountryViewModel с loadingState, c @Published свойствами и методами.
- Структуры метаданных как private вложенные структуры внутри методов View Model, где они используются.
Подождите, в Swift у вас не могут быть структуры внутри методов, но вы можете иметь их внутри класса class View Model как private  структуры. Таким образом, CountryResponseMetadata и IndicatorResponseMetadata могут быть private структурами внутри View Model, поскольку они используются только там.
Аналогично, IndicatorResponse и его структуры struct Entry и struct Country должны быть private в области действия метода fetchIndicatorData во View Model. Но поскольку Swift не допускает структуры внутри методов, их необходимо вложить в класс View Model как private структуры.
Это спорное утверждение.

1. Модели:

- Countryid, iso2Code, имя, capitalCity, region, population, gdp
- Структура Region
- перечисление enum CodingKeys
- вычисляемое свойство flag

2. View Model:

- CountryViewModel
- перечисление enum LoadingState
- @Published свойства
- URL-адреса API
- Методы выборки данных
- private структуры для ответов API (CountryResponseMetadata, IndicatorResponseMetadata, IndicatorResponse и т. д.)
Это разделение позволяет Моделям сосредоточиться на представлении данных, а View Model — на обработке данных и бизнес-логике.

Тестирование этого, при разделении на разные разделы или файлы, View Model импортирует Foundation и SwiftUI, если необходимо, но поскольку это ObservableObject, ему нужен SwiftUI. Модели нуждаются только в Foundation для Codable.

Мне также нужно убедиться, что вся логика декодирования в View Model использует правильные private структуры для API ответов, сохраняя Модели чистыми.

Еще одна проверка: CodingKeys структуры Country включают iso2Code и capitalCity, которые соответствуют ключам API. Это верно. Методы анализа View Model используют структуры ответа response, которые декодируют JSON API в Модели.

Наконец, метод fetchAllData View Model объединяет данные из нескольких endpoints и объединяет их в Модели Country, что верно.

Итак, окончательный ответ должен представлять код, разделенный на Models и View Model, с соответствующими структурами struct и перечислениями enum в каждой из них, гарантируя, что View Model обрабатывает данные, а Модели представляют структуру данных.

Вот правильно разделенная структура кода:

1. Models/Country.swift (Data Model Layer)

import Foundation

struct Country: Decodable, Identifiable {
    let id: String
    let iso2Code: String
    let name: String
    let capitalCity: String
    let region: Region
    var population: Int?
    var gdp: Double?
    
    var flag: String {
        iso2Code.unicodeScalars
            .map { 127397 + $0.value }
            .compactMap(UnicodeScalar.init)
            .map(String.init)
            .joined()
    }
    
    struct Region: Decodable {
        let id: String
        let value: String
    }
    
    enum CodingKeys: String, CodingKey {
        case id, name, region
        case iso2Code = "iso2Code"
        case capitalCity = "capitalCity"
    }
}

2. Models/WorldBankResponse.swift (API Response Models)

import Foundation
struct CountryResponseMetadata: Decodable {
    let page: Int
    let pages: Int
    let perPage: String
    let total: Int
    
    enum CodingKeys: String, CodingKey {
        case page, pages, total
        case perPage = "per_page"
    }
}

struct IndicatorResponseMetadata: Decodable {
    let page: Int
    let pages: Int
    let perPage: Int
    let total: Int
    
    enum CodingKeys: String, CodingKey {
        case page, pages, total
        case perPage = "per_page"
    }
}

 3. ViewModels/CountryViewModel.swift (Business Logic Layer)

import SwiftUI
import Combine

class CountryViewModel: ObservableObject {
    enum LoadingState {
        case loading, loaded, error(String)
    }
    
    @Published var loadingState: LoadingState = .loading
    @Published var groupedCountries: [String: [Country]] = [:]
    
    private let countryURL = "https://api.worldbank.org/v2/country?format=json&per_page=300"
    private let populationURL = "https://api.worldbank.org/v2/country/all/indicator/SP.POP.TOTL?format=json&date=2022&per_page=300"
    private let gdpURL = "https://api.worldbank.org/v2/country/all/indicator/NY.GDP.MKTP.CD?format=json&date=2022&per_page=300"
    
    func fetchAllData() async {
        do {
            async let countries = fetchCountries()
            async let populationData = fetchIndicatorData(url: populationURL)
            async let gdpData = fetchIndicatorData(url: gdpURL)
            
            var finalCountries = try await countries
            let populationDict = try await populationData
            let gdpDict = try await gdpData
            
            finalCountries = finalCountries.map { country in
                var modified = country
                modified.population = populationDict[country.iso2Code]
                modified.gdp = gdpDict[country.iso2Code]
                return modified
            }
            
            let filtered = finalCountries.filter {
                !$0.region.value.lowercased().contains("aggregate") &&
                $0.region.id != "NA" &&
                $0.capitalCity != ""
            }
            
            let grouped = Dictionary(grouping: filtered) {
                $0.region.value.trimmingCharacters(in: .whitespacesAndNewlines)
            }
            
            await MainActor.run {
                groupedCountries = grouped
                loadingState = .loaded
            }
        } catch {
            await MainActor.run {
                loadingState = .error("Failed to load data: \(error.localizedDescription)")
            }
        }
    }
    
    private func fetchCountries() async throws -> [Country] {
        struct WorldBankResponse: Decodable {
            let countries: [Country]
            
            init(from decoder: Decoder) throws {
                var container = try decoder.unkeyedContainer()
                _ = try container.decode(CountryResponseMetadata.self)
                countries = try container.decode([Country].self)
            }
        }
        
        guard let url = URL(string: countryURL) else {
            throw URLError(.badURL)
        }
        
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode(WorldBankResponse.self, from: data).countries
    }
    
    private func fetchIndicatorData(url: String) async throws -> [String: Double] {
        struct IndicatorResponse: Decodable {
            let entries: [Entry]
            
            init(from decoder: Decoder) throws {
                var container = try decoder.unkeyedContainer()
                _ = try container.decode(IndicatorResponseMetadata.self)
                entries = try container.decode([Entry].self)
            }
            
            struct Entry: Decodable {
                let country: Country
                let value: Double?
                
                struct Country: Decodable {
                    let id: String
                }
            }
        }
        
        guard let url = URL(string: url) else {
            throw URLError(.badURL)
        }
        
        let (data, _) = try await URLSession.shared.data(from: url)
        let response = try JSONDecoder().decode(IndicatorResponse.self, from: data)
        
        return response.entries.reduce(into: [:]) { dict, entry in
            guard let value = entry.value else { return }
            dict[entry.country.id] = value
        }
    }
}

4. Views/CountryListView.swift (UI Layer)

import SwiftUI

struct CountryListView: View {
    @StateObject private var viewModel = CountryViewModel()
    
    var body: some View {
        NavigationView {
            Group {
                switch viewModel.loadingState {
                case .loading:
                    ProgressView("Loading countries...")
                case .loaded:
                    List {
                        ForEach(viewModel.groupedCountries.keys.sorted(), id: \.self) { region in
                            Section(header: Text(region)) {
                                ForEach(viewModel.groupedCountries[region] ?? []) { country in
                                    CountryRow(country: country)
                                }
                            }
                        }
                    }
                case .error(let message):
                    Text(message)
                        .foregroundColor(.red)
                }
            }
            .navigationTitle("World Countries")
        }
        .task {
            await viewModel.fetchAllData()
        }
    }
}
struct CountryRow: View {
    let country: Country
    
    var body: some View {
        HStack(spacing: 12) {
            Text(country.flag)
                .font(.system(size: 32))
            
            VStack(alignment: .leading, spacing: 4) {
                Text(country.name)
                    .font(.headline)
                
                HStack(spacing: 16) {
                    Label(country.capitalCity, systemImage: "building.2")
                    Text(country.iso2Code)
                        .monospaced()
                }
                .font(.caption)
                
                HStack(spacing: 16) {
                    if let population = country.population {
                        Label(population.formatted() + " people", systemImage: "person.2")
                    }
                    if let gdp = country.gdp {
                        Label("$" + gdp.formatted(), systemImage: "dollarsign.circle")
                    }
                }
                .font(.caption)
                .foregroundColor(.secondary)
            }
        }
    }
}

Это разделение обеспечивает:

  1. Четкую архитектуру: следует шаблону MVVM с отдельными слоями
  2. Тестируемость: View Model можно тестировать независимо
  3. Удобство обслуживания: каждый компонент несет единственную ответственность
  4. Повторное использование: модели можно использовать в разных представлениях
  5. Масштабируемость: легко добавлять новые функции или изменять существующие

Ключевые улучшения:

  • Модели содержат только структуры данных и Codable логику
  • View Model обрабатывает данных и всю бизнес-логику 
  • Views сосредоточены исключительно на отображении
  • Модели API ответов response отделены от моделей предметной области
  • Централизованная обработка ошибок в View Model
  • Сетевой уровень безопасности типов с правильным распространением ошибок

Запускаем приложение — получаем результат, который не изменился, так как никаких функциональных добавлений мы не внесли:

Обработка ошибок

Теперь давайте улучшим обработку ошибок с помощью do {  } catch {  } блока.

Добавьте  do {  } catch {  } блоки в функции выборки fetch

В результате мы получаем рассуждения, которые заняли 27 секунд:

Вот перевод этих рассуждений:

Хорошо, давайте займемся добавлением соответствующих блоков do-catch к функциям fetch в CountryViewModel. Пользователь хочет, чтобы обработка ошибок была более явной и локализованной. Сначала мне нужно просмотреть существующий код, чтобы увидеть, где могут возникнуть исключения, и убедиться, что все возможные ошибки перехватываются и обрабатываются соответствующим образом.

Рассматривая функции `fetchCountries()` и `fetchIndicatorData(url:)`, я замечаю, что в настоящее время они используют один блок `do-catch` на уровне `fetchAllData()`. Хотя это работает, лучше иметь обработку ошибок ближе к тому месту, где могут возникнуть ошибки, для более детального контроля. Таким образом, каждый сетевой запрос может обрабатывать свои собственные исключения, а View Model может предоставлять более конкретные сообщения об ошибках.
В `fetchCountries()` основными точками сбоя являются:

1. Создание URL из строки.
2. Выборка данных из сети.
3. Декодирование JSON ответа response.

Аналогично, в `fetchIndicatorData(url:)` применяются те же пункты, плюс обработка entries и их значений value.
Я начну с того, что оберну каждую потенциально генерирующую операцию в свой собственный блок `do-catch`. Для создания URL, если URL недействителен, мы должны выдать `URLError.badURL`. При выборке данных могут возникнуть сетевые ошибки, такие как отсутствие подключения к Интернету или проблемы с сервером. Ошибки декодирования могут возникнуть, если структура JSON не соответствует нашим моделям.

Для каждой обнаруженной ошибки я запишу описательное сообщение и повторно “выброшу”  throws более понятную пользователю ошибку. Это поможет в отладке и гарантирует, что пользователь получит содержательную обратную связь. В `fetchAllData()View Model я обновлю обработку ошибок, чтобы отловить эти конкретные ошибки и обновить состояние загрузки loadingState соответствующим образом.

Мне также нужно убедиться, что все ошибки правильно передаются в View Model, чтобы UI мог отражать правильное состояние. Это означает преобразование ошибок более низкого уровня в сообщения, которые могут быть отображены пользователю.
Тестирование каждого сценария имеет решающее значение. Например, что произойдет, если API вернет непредвиденную структуру JSON? Декодирование должно завершиться неудачей, и ошибка должна быть перехвачена и обработана. Аналогично, если сетевой запрос истекает по времени, блок catch должен перехватить это и обновить UI.

Еще одно соображение — сообщения об ошибках. Вместо отображения необработанных описаний ошибок они должны быть удобными для пользователя. Например, замените "The Internet connection appears to be offline." («Подключение к Интернету, похоже, отключено») на "No internet connection. Please check your network settings." («Нет подключения к Интернету. Проверьте настройки сети»).

Наконец, я проверю, что все изменения сохраняют существующую функциональность, например, группировку стран по регионам и правильное отображение их данных. Важно убедиться, что добавление блоков do-catch не приводит к появлению новых ошибок или прерыванию потока данных.

Пользователю:

Вот обновленная View Model с правильной обработкой ошибок в каждой функции выборки:

class CountryViewModel: ObservableObject {
    // ... existing properties and enums ...
    
    func fetchAllData() async {
        do {
            async let countries = try fetchCountries()
            async let populationData = 
                          try fetchIndicatorData(url: populationURL)
            async let gdpData = try fetchIndicatorData(url: gdpURL)
            
            let (finalCountries, populationDict, gdpDict) = 
                           try await (countries, populationData, gdpData)
            
            let mergedCountries = mergeData(
                countries: finalCountries,
                population: populationDict,
                gdp: gdpDict
            )
            
            await MainActor.run {
                groupedCountries = groupedCountries
                loadingState = .loaded
            }
        } catch {
            await handleError(error)
        }
    }
    
    private func fetchCountries() async throws -> [Country] {
        do {
            guard let url = URL(string: countryURL) else {
                throw APIError.invalidURL
            }
            
            let (data, response) = 
                             try await URLSession.shared.data(from: url)
            
            guard let httpResponse = response as? HTTPURLResponse,
                  (200...299).contains(httpResponse.statusCode) else {
                throw APIError.invalidResponse
            }
            
            return try decodeCountries(from: data)
        } catch {
            throw handleFetchError(error, context: "countries")
        }
    }
    
    private func fetchIndicatorData(url: String) async throws -> [String: Double] {
        do {
            guard let url = URL(string: url) else {
                throw APIError.invalidURL
            }
            
            let (data, response) = 
                            try await URLSession.shared.data(from: url)
            
            guard let httpResponse = response as? HTTPURLResponse,
                  (200...299).contains(httpResponse.statusCode) else {
                throw APIError.invalidResponse
            }
            
            return try decodeIndicatorData(from: data)
        } catch {
            throw handleFetchError(error, context: "indicator data")
        }
    }
    
    // MARK: - Decoding Helpers
    private func decodeCountries(from data: Data) throws -> [Country] {
        do {
            struct WorldBankResponse: Decodable {
                let countries: [Country]
                
                init(from decoder: Decoder) throws {
                    var container = try decoder.unkeyedContainer()
                    _ = try container.decode(CountryResponseMetadata.self)
                    countries = try container.decode([Country].self)
                }
            }
            return try JSONDecoder().decode(WorldBankResponse.self, 
                                                 from: data).countries
        } catch {
            throw APIError.decodingFailed(
                message: "Countries decoding failed: \(error.localizedDescription)"
            )
        }
    }
    
    private func decodeIndicatorData(from data: Data) throws -> [String: Double] {
        do {
            struct IndicatorResponse: Decodable {
                let entries: [Entry]
                
                init(from decoder: Decoder) throws {
                    var container = try decoder.unkeyedContainer()
                    _ = try container.decode(IndicatorResponseMetadata.self)
                    entries = try container.decode([Entry].self)
                }
                
                struct Entry: Decodable {
                    let country: CountryEntry
                    let value: Double?
                    
                    struct CountryEntry: Decodable {
                        let id: String
                    }
                }
            }
            
            let response = 
               try JSONDecoder().decode(IndicatorResponse.self, from: data)
            return response.entries.reduce(into: [:]) { dict, entry in
                guard let value = entry.value else { return }
                dict[entry.country.id] = value
            }
        } catch {
            throw APIError.decodingFailed(
                message: "Indicator data decoding failed: \(error.localizedDescription)"
            )
        }
    }
    
    // MARK: - Error Handling
    private func handleError(_ error: Error) async {
        let message: String
        switch error {
        case let apiError as APIError:
            message = apiError.localizedDescription
        case let urlError as URLError:
            message = handleUrlError(urlError)
        default:
            message = "Unknown error: \(error.localizedDescription)"
        }
        
        await MainActor.run {
            loadingState = .error(message)
        }
    }
    
    private func handleUrlError(_ error: URLError) -> String {
        switch error.code {
        case .notConnectedToInternet:
            return "No internet connection. Please check your network settings."
        case .timedOut:
            return "Request timed out. Please try again later."
        case .networkConnectionLost:
            return "Network connection lost. Please check your connection."
        default:
            return "Network error: \(error.localizedDescription)"
        }
    }
    
    private func handleFetchError(_ error: Error, context: String) -> Error {
        print("Error fetching \(context): \(error)")
        if let apiError = error as? APIError {
            return apiError
        }
        return APIError.requestFailed(
            message: "Failed to fetch \(context): \(error.localizedDescription)"
        )
    }
    
    // MARK: - Data Processing
    private func mergeData(
        countries: [Country],
        population: [String: Double],
        gdp: [String: Double]
    ) -> [String: [Country]] {
        let filtered = countries
            .map { country in
                var modified = country
                modified.population =    
                        population[country.iso2Code].flatMap(Int.init)
                modified.gdp = gdp[country.iso2Code]
                return modified
            }
            .filter {
                !$0.region.value.lowercased().contains("aggregate") &&
                $0.region.id != "NA" &&
                !$0.capitalCity.isEmpty
            }
        
        return Dictionary(grouping: filtered) {
            $0.region.value.trimmingCharacters(in: .whitespacesAndNewlines)
        }
    }
}

// Error Types
enum APIError: LocalizedError {
    case invalidURL
    case invalidResponse
    case decodingFailed(message: String)
    case requestFailed(message: String)
    
    var errorDescription: String? {
        switch self {
        case .invalidURL:
            return "Invalid API endpoint URL"
        case .invalidResponse:
            return "Received invalid response from server"
        case .decodingFailed(let message):
            return "Data parsing failed: \(message)"
        case .requestFailed(let message):
            return "Network request failed: \(message)"
        }
    }
}

Ключевые улучшения в обработке ошибок:

1, Структурированная обработка ошибок:

  • Пользовательское перечисление   enum APIError с локализованными описаниями
  • Отдельные типы ошибок для разных сценариев сбоев
  • Обработка ошибок URL-адреса

2. Гранулярные блоки try / сatch:

  • Отдельная обработка ошибок для каждой операции выборки
  • Явная проверка ошибок для кодов состояния HTTP
  • Подробная обработка ошибок декодирования

3. Распространение ошибок:

  • Ошибки всплывают с сообщениями, зависящими от контекста
  • Центральная обработка ошибок в методе handleError
  • Удобные для пользователя сообщения об ошибках

4. Особенности сетевых ошибок:

  • Обрабатывает распространенные сетевые ошибки (тайм-аут, отсутствие соединения)
  • Проверяет коды состояния ответа HTTP
  • Правильная проверка URL-адреса перед запросами

5. Безопасность декодирования:

  • Отдельные методы декодирования с обработкой ошибок
  • Тип-безопасный анализ JSON
  • Сообщения об ошибках включают контекст декодирования

6. Регистрация ошибок:

  • Консольная регистрация для отладки
  • Пользовательские сообщения без технических подробности
  • Контекстные сообщения об ошибках (какой тип данных не сработал)

Эта реализация обеспечивает:

  • Лучшую видимость ошибок для разработчиков
  • Более полезные сообщения об ошибках для пользователей
  • Более безопасные сетевые операции
  • Правильное разделение проблем
  • Подробный контекст ошибки для отладки

Запускаем приложение и имеем тот же UI, но уже с обработкой ошибок:

Продолжение следует в посте «Использование DeepSeek при создании iOS приложения Countries Страны Мира. Часть 3. Swift 6 concurrency и Map.»

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *