Опыт создания iOS приложения Countries  с помощью Claude 3.5 Sonnet, ChatGPT 4.o1-mini, ChatGPT 4.o1-preview и Gemini 2.0 Flash

Я занимаюсь исследованием того, как можно эффективно создавать iOS приложения с помощью ИИ: ChatGPT 4.o1, Claude 3.5 Sonnet, Gemini 2.0 Flash, а также с помощью сред разработки (IDE) типа Cursor AI и Alex Sidebar. Для этих целей я решила создать довольно простое тестовое iOS приложение Countries, которое показывает все страны Мира по регионам (Европа, Азия, Латинская Америка и т.д.) и для каждой страны её название и флаг. Если вы выбираете какую-то страну, то о ней сообщается дополнительная информация о численности населения population и размере ВВП (валового внутреннего продукта) gdp:

Я считаю Claude 3.5 Sonnet лучшим AI (ИИ) для программирования iOS приложений с точки зрения архитектуры всего приложения. Поэтому эту ИИ мы будем использовать в качестве основной и начнем именно с её использования.

Забегая немного вперед и обрисовывая наши планы, скажу, что сначала мы получим от Claude 3.5 Sonnet вариант этого iOS приложения с использованием старого GCD (Grand Central Dispatch) для многопоточной выборки данных из интернета (Github).

Затем мы сделаем рефакторинг кода, чтобы перейти к более современной версии многопоточности с использованием async await (Github).

И в заключении мы постараемся перейти на ещё более продвинутую версию многопоточности Swift 6 strict concurrency, в которой гарантировано нет “гонки данных” (data races) (Github). Но чтобы оценить этот последний вариант нашего приложения, необходимо иметь представление о возможностях Swift 6 concurrency, которое можно получить в компактной форме из превосходной статьи Concurrency Step-by-Step: A Network Request или её перевода на русский язык Многопоточность по шагам:  Сетевой запрос.

С помощью Claude 3.5 Sonnet при первом же обращении нам удастся получить превосходное iOS приложение с Моделью данных для расшифровки JSON данных, с CountriesViewModel, которая выбирает всю необходимую информацию с сервера Всемирного банка, преобразует JSON данные в данные Модели и предоставляет их Views для отображения на экране пользователя. Мы не зададим не единой ссылки на сайты Всемирного банка, ни единого намека на структуру данных, и тем не менее получим полноценное iOS приложение. 

Однако запустив это приложение, мы получим ошибку, связанную с декодированием JSON данных, полученных с сервера Всемирного Банка, которые оказались не совсем стандартными. И дальнейшие “уговоры”  Claude 3.5 Sonnet не помогли решить нам эту проблему. Так что пришлось смириться с тем фактом, что Claude 3.5 Sonnet плохо “декодирует” несложные, хотя и нестандартные JSON данные.

Нам придется обратиться к другим ИИ.

Gemini 1.5 Flash также оказался неспособным дать правильное решение, и только ChatGPT 4.o1-mini и Gemini 2.0 Flash справятся с этой казалось бы легкой задачей, их ответ и пришлось интегрировать в успешное в остальном приложение, полученное Claude 3.5 Sonnet. Надо сказать, что ChatGPT 4.o1-Preview, который, как было объявлено, отличается способностью к построению логических цепочек даст вообще фантастический результат, который вряд ли смог бы предоставить даже программист супер высокого класса, он сложнее, чем результат полученный ChatGPT 4.o1-mini, но это очень красивое решение (Примечание 2 в конце статьи).

Все нейросети:  Claude 3.5 Sonnet,  Gemini 2.0 Flash, ChatGPT 4.o1-mini, ChatGPT 4.o1-Preview прекрасно справились с рефакторингом асинхронного кода с CGD на async await и далее на Swift 6 strict concurrency.

Итак, всё по порядку.  

Claude 3.5 Sonnet. Начало

Обращаемся к Claude 3.5 Sonnet:

Я помогу вам создать iOS-приложение на SwiftUI, которое сортирует страны по категориям. и использует API Всемирного банка.

Countries by Categories iOS App version 1/3

Я создал полноценное приложение SwiftUI, которое упорядочивает страны countries по категориям categories, при этом получая реальные данные из API Всемирного банка.

Models

import Foundation

// Models
struct Country: Identifiable, Hashable {
    let id = UUID()
    let name: String
    let category: String
    let flag: String
    let population: Int?
    let gdp: Double?
    let iso2Code: String
}

struct WorldBankResponse: Decodable {
    let page: Int
    let pages: Int
    let total: Int
    let data: [CountryData]
}

struct CountryData: Decodable {
    let name: String
    let region: Region
    let capitalCity: String
    let longitude: String
    let latitude: String
    let id: String
    let iso2Code: String
    
    struct Region: Decodable {
        let id: String
        let value: String
    }
}

struct IndicatorResponse: Decodable {
    let data: [IndicatorData]
}

struct IndicatorData: Decodable {
    let value: Double?
    let date: String
}

ViewModel

import SwiftUI

// View Model
class CountriesViewModel: ObservableObject {
    @Published var countries: [Country] = []
    @Published var isLoading = false
    @Published var errorMessage: String?
    
    private let baseURL = "https://api.worldbank.org/v2"
    private var countryDataCache: [String: (population: Int?, gdp: Double?)] = [:]
    
    func loadCountries() {
        isLoading = true
        errorMessage = nil
        
        let urlString = "\(baseURL)/country?format=json&per_page=300"
        guard let url = URL(string: urlString) else {
            errorMessage = "Invalid URL"
            isLoading = false
            return
        }
        
        URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
            DispatchQueue.main.async {
                if let error = error {
                    self?.errorMessage = error.localizedDescription
                    self?.isLoading = false
                    return
                }
                
                guard let data = data else {
                    self?.errorMessage = "No data received"
                    self?.isLoading = false
                    return
                }
                
                do {
                    let decoder = JSONDecoder()
                    let response = try decoder.decode([WorldBankResponse?].self, 
                                                                             from: data)
                    guard let countryData = response[1] else {
                        self?.errorMessage = "Invalid response format"
                        self?.isLoading = false
                        return
                    }
                    
                    let countries = countryData.data.map { data in
                        Country(
                            name: data.name,
                            category: data.region.value,
                            flag: self?.flagEmoji(from: data.iso2Code) ?? "🏳️",
                            population: nil,
                            gdp: nil,
                            iso2Code: data.iso2Code
                        )
                    }
                    
                    self?.countries = countries.filter { $0.category != "" }
                    self?.fetchAdditionalData(for: countries)
                    
                } catch {
                    self?.errorMessage = 
                               "Failed to decode response: \(error.localizedDescription)"
                }
                self?.isLoading = false
            }
        }.resume()
    }
    
    private func fetchAdditionalData(for countries: [Country]) {
        for country in countries {
            fetchPopulation(for: country)
            fetchGDP(for: country)
        }
    }
    
    private func fetchPopulation(for country: Country) {
        let indicator = "SP.POP.TOTL" // Population indicator
        fetchIndicator(country: country, indicator: indicator) { [weak self] value in
            if let value = value {
                self?.updateCountryData(iso2Code: country.iso2Code, population: Int(value))
            }
        }
    }
    
    private func fetchGDP(for country: Country) {
        let indicator = "NY.GDP.MKTP.CD" // GDP indicator
        fetchIndicator(country: country, indicator: indicator) { [weak self] value in
            if let value = value {
                self?.updateCountryData(iso2Code: country.iso2Code, gdp: value)
            }
        }
    }
    
    private func fetchIndicator(country: Country, indicator: String, completion: @escaping (Double?) -> Void) {
        let urlString = "\(baseURL)/country/\(country.iso2Code)/indicator/\(indicator)?format=json&per_page=1&date=2022:2022"
        guard let url = URL(string: urlString) else {
            completion(nil)
            return
        }
        
        URLSession.shared.dataTask(with: url) { data, response, error in
            guard let data = data,
                  error == nil else {
                DispatchQueue.main.async {
                    completion(nil)
                }
                return
            }
            
            do {
                let response = try JSONDecoder().decode([IndicatorResponse?].self, 
                                                                           from: data)
                let value = response[1]?.data.first?.value
                DispatchQueue.main.async {
                    completion(value)
                }
            } catch {
                DispatchQueue.main.async {
                    completion(nil)
                }
            }
        }.resume()
    }
    
    private func updateCountryData(iso2Code: String, population: Int? = nil, 
                                                     gdp: Double? = nil) {
        var cached = countryDataCache[iso2Code] ?? (population: nil, gdp: nil)
        
        if let population = population {
            cached.population = population
        }
        if let gdp = gdp {
            cached.gdp = gdp
        }
        
        countryDataCache[iso2Code] = cached
        
        // Update the countries array with new data
        countries = countries.map { country in
            if country.iso2Code == iso2Code {
                return Country(
                    name: country.name,
                    category: country.category,
                    flag: country.flag,
                    population: cached.population,
                    gdp: cached.gdp,
                    iso2Code: country.iso2Code
                )
            }
            return country
        }
    }
    
    private func flagEmoji(from iso2Code: String) -> String {
        let base: UInt32 = 127397
        var flag = ""
        for unicode in iso2Code.uppercased().unicodeScalars {
            if let scalar = UnicodeScalar(base + unicode.value) {
                flag.append(String(scalar))
            }
        }
        return flag
    }
    
    var categories: [String] {
        Array(Set(countries.map { $0.category })).sorted()
    }
    
    func countries(in category: String) -> [Country] {
        countries.filter { $0.category == category }
    }
}

Views

import SwiftUI

// Main Content View
struct ContentView: View {
    @StateObject private var viewModel = CountriesViewModel()
    
    var body: some View {
        NavigationView {
            Group {
                if viewModel.isLoading {
                    ProgressView("Loading countries...")
                } else if let errorMessage = viewModel.errorMessage {
                    ErrorView(message: errorMessage, retryAction: {
                        viewModel.loadCountries()
                    })
                } else {
                    CountryListView(viewModel: viewModel)
                }
            }
            .navigationTitle("World Countries")
        }
        .onAppear {
            viewModel.loadCountries()
        }
    }
}

struct CountryListView: View {
    @ObservedObject var viewModel: CountriesViewModel
    
    var body: some View {
        List {
            ForEach(viewModel.categories, id: \.self) { category in
                Section(header: Text(category)) {
                    ForEach(viewModel.countries(in: category)) { country in
                        NavigationLink(destination: CountryDetailView(country: country)) {
                            CountryRowView(country: country)
                        }
                    }
                }
            }
        }
        .refreshable {
            viewModel.loadCountries()
        }
    }
}

struct CountryRowView: View {
    let country: Country
    
    var body: some View {
        HStack {
            Text(country.flag)
                .font(.title2)
            Text(country.name)
                .font(.body)
        }
        .padding(.vertical, 4)
    }
}

struct CountryDetailView: View {
    let country: Country
    
    var body: some View {
        ScrollView {
            VStack(spacing: 20) {
                Text(country.flag)
                    .font(.system(size: 100))
                
                Text(country.name)
                    .font(.title)
                    .fontWeight(.bold)
                
                VStack(alignment: .leading, spacing: 15) {
                    DetailRow(title: "Region", value: country.category)
                    
                    if let population = country.population {
                        DetailRow(
                            title: "Population",
                            value: formatNumber(population)
                        )
                    }
                    
                    if let gdp = country.gdp {
                        DetailRow(
                            title: "GDP (USD)",
                            value: formatCurrency(gdp)
                        )
                    }
                }
                .padding()
                
                Spacer()
            }
        }
        .navigationBarTitleDisplayMode(.inline)
        .padding()
    }
    
    private func formatNumber(_ number: Int) -> String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .decimal
        return formatter.string(from: NSNumber(value: number)) ?? String(number)
    }
    
    private func formatCurrency(_ number: Double) -> String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .currency
        formatter.currencyCode = "USD"
        formatter.maximumFractionDigits = 0
        return formatter.string(from: NSNumber(value: number)) ?? String(number)
    }
}

struct DetailRow: View {
    let title: String
    let value: String
    
    var body: some View {
        HStack {
            Text(title)
                .fontWeight(.medium)
                .foregroundColor(.gray)
            Spacer()
            Text(value)
                .fontWeight(.medium)
        }
    }
}

struct ErrorView: View {
    let message: String
    let retryAction: () -> Void
    
    var body: some View {
        VStack(spacing: 16) {
            Text("Error")
                .font(.title)
                .foregroundColor(.red)
            Text(message)
                .multilineTextAlignment(.center)
            Button("Retry", action: retryAction)
                .buttonStyle(.bordered)
        }
        .padding()
    }
}

#Preview {  
        ContentView()
}

Вот ключевые особенности:

  1. Структуры данных:
  • Страны countries организованы по категориям categories (Европа, Азия, Америка).
  • У каждой страны country есть название name, категория category, флаг-эмодзи flag, население population, ВВП gpd, код iso2Code.
  • Добавлены правильные модели данных для ответов response API Всемирного банка 
  1. Views:
  • Основной View —  список List с разделами Sections для каждой категории category
  • CountryRowView для строк, показывающее флаг и название страны
  • Детальное CountryDetailView, показывающее дополнительную информацию о стране
  • Загрузка состояний с индикатором выполнения isLoading
  • Обработка ошибок errorMessage с возможностью повтора (Retry)
  • Правильное форматирование чисел для населения и ВВП
  • Динамическая генерация эмодзи флага из  ISO кодов стран
  1. Архитектура:
  • Паттерн MVVM с ObservableObject для управления данными
  • Понятное разделение задач между различными Views: CountryRowView, CountryDetailView

4. Интеграция API (ViewModel):

  • Получаем список стран countries из API Всемирного банка с помощью loadCountries()
  • Получаем данные о населении population и ВВП gdp для каждой страны с помощью  func fetchPopulation(for country: Country) и func fetchGDP(for country: Country) 
  • Обрабатывает ответы response и ошибки error API должным образом

Итак, мы получили превосходное iOS приложение с Моделью данных для расшифровки JSON данных, с CountriesViewModel, которая выбирает всю необходимую информацию с сервера Всемирного банка, преобразует JSON данные в данные Модели и предоставляет их Views для отображения на экране пользователя.

Но если мы запустим это приложение. то получим ошибку, которую не удается убрать никакими повторами Retry:

Ясно, что ошибка связана с декодированием JSON данных, полученных с сервера Всемирного Банка.
Вот два фрагмента кода, которые отвечают за это: Модель данных и её декодирование в CountriesViewModel:

// 1-ый фрагмент
// Models
struct Country: Identifiable, Hashable {
    let id = UUID()
    let name: String
    let category: String
    let flag: String
    let population: Int?
    let gdp: Double?
    let iso2Code: String
}

struct WorldBankResponse: Decodable {
    let page: Int
    let pages: Int
    let total: Int
    let data: [CountryData]
}

struct CountryData: Decodable {
    let name: String
    let region: Region
    let capitalCity: String
    let longitude: String
    let latitude: String
    let id: String
    let iso2Code: String
    
    struct Region: Decodable {
        let id: String
        let value: String
    }
}
// 2-ой фрагмент
// .  .  .  .  .  .
              do {
                 let decoder = JSONDecoder()
                 let response = try decoder.decode([WorldBankResponse?].self, from: data)
                  guard let countryData = response[1] else {
                        self?.errorMessage = "Invalid response format"
                        self?.isLoading = false
                        return
                  }
// .  .  .  .  .  .
                } catch {
                    self?.errorMessage = 
                            "Failed to decode response: \(error.localizedDescription)"
                }
// .  .  .  .  .  .

Будем далее следить за тем, как Claude 3.5 Sonnet будет менять эти фрагменты кода, стремясь избежать ошибок.
Давайте сообщим Claude 3.5 Sonnet о нашей ошибке:

Позвольте мне исправить проблему с анализом данных с помощью API Всемирного банка.

Fixed Countries App with World Bank API Integration 2/3

Models

import Foundation

// Models
struct Country: Identifiable, Hashable {
    let id = UUID()
    let name: String
    let category: String
    let flag: String
    let population: Int?
    let gdp: Double?
    let iso2Code: String
}

// World Bank API response format
struct WorldBankResponse: Decodable {
    let data: [CountryData]
    
    enum CodingKeys: String, CodingKey {
        case data = "1"
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.data = try container.decode([CountryData].self, forKey: .data)
    }
}

struct CountryData: Decodable {
    let name: String
    let region: Region
    let capitalCity: String?
    let longitude: String?
    let latitude: String?
    let id: String
    let iso2Code: String
    
    struct Region: Decodable {
        let id: String
        let value: String
    }
}

struct IndicatorResponse: Decodable {
    let data: [IndicatorData]
    
    enum CodingKeys: String, CodingKey {
        case data = "1"
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.data = (try? container.decode([IndicatorData].self, forKey: .data)) ?? []
    }
}

struct IndicatorData: Decodable {
    let value: Double?
    let date: String
}

ViewModel

// View Model
class CountriesViewModel: ObservableObject {
    @Published var countries: [Country] = []
    @Published var isLoading = false
    @Published var errorMessage: String?
    
    private let baseURL = "https://api.worldbank.org/v2"
    private var countryDataCache: [String: (population: Int?, gdp: Double?)] = [:]
    
    func loadCountries() {
        isLoading = true
        errorMessage = nil
        
        let urlString = "\(baseURL)/country?format=json&per_page=300"
        
        guard let url = URL(string: urlString) else {
            errorMessage = "Invalid URL"
            isLoading = false
            return
        }
        
        let task = URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
            DispatchQueue.main.async {
                if let error = error {
                    self?.errorMessage = "Network error: \(error.localizedDescription)"
                    self?.isLoading = false
                    return
                }
                
                guard let data = data else {
                    self?.errorMessage = "No data received"
                    self?.isLoading = false
                    return
                }
                
                do {
                    // Print the raw JSON for debugging
                    if let jsonString = String(data: data, encoding: .utf8) {
                        print("Raw JSON response: \(jsonString)")
                    }
                    
                    let decoder = JSONDecoder()
                    let response = try decoder.decode([Any?].self, from: data) as! 
                                                                         [String: Any]
                    
                    if let countryDataArray = response["1"] as? [[String: Any]] {
                        let countries = countryDataArray.compactMap {
                           countryDict -> Country? in
                            guard let name = countryDict["name"] as? String,
                                  let region = countryDict["region"] as? [String: Any],
                                  let regionValue = region["value"] as? String,
                                  let iso2Code = countryDict["iso2Code"] as? String else {
                                return nil
                            }
                            
                            return Country(
                                name: name,
                                category: regionValue,
                                flag: self?.flagEmoji(from: iso2Code) ?? "🏳️",
                                population: nil,
                                gdp: nil,
                                iso2Code: iso2Code
                            )
                        }
                        
                        self?.countries = countries.filter { !$0.category.isEmpty }
                        self?.fetchAdditionalData(for: countries)
                    }
                    
                } catch {
                    self?.errorMessage = "Failed to decode response: \(error)"
                    print("Decoding error: \(error)")
                }
                self?.isLoading = false
            }
        }
        task.resume()
    }
    
    private func fetchAdditionalData(for countries: [Country]) {
        for country in countries {
            fetchPopulation(for: country)
            fetchGDP(for: country)
        }
    }
    
    private func fetchPopulation(for country: Country) {
        let indicator = "SP.POP.TOTL"
        let urlString = "\(baseURL)/country/\(country.iso2Code)/indicator/\(indicator)?format=json&per_page=1&date=2022"
        
        guard let url = URL(string: urlString) else { return }
        
        URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
            guard let data = data else { return }
            
            do {
                if let jsonArray = try JSONSerialization.jsonObject(with: data) as? [Any],
                   let dataArray = (jsonArray[1] as? [[String: Any]])?.first,
                   let value = dataArray["value"] as? Double {
                    
                    DispatchQueue.main.async {
                      self?.updateCountryData(iso2Code: country.iso2Code, 
                                                            population: Int(value))
                    }
                }
            } catch {
                print("Population fetch error: \(error)")
            }
        }.resume()
    }
    
    private func fetchGDP(for country: Country) {
        let indicator = "NY.GDP.MKTP.CD"
        let urlString = "\(baseURL)/country/\(country.iso2Code)/indicator/\(indicator)?format=json&per_page=1&date=2022"
        
        guard let url = URL(string: urlString) else { return }
        
        URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
            guard let data = data else { return }
            
            do {
                if let jsonArray = try JSONSerialization.jsonObject(with: data) as? [Any],
                   let dataArray = (jsonArray[1] as? [[String: Any]])?.first,
                   let value = dataArray["value"] as? Double {
                    
                    DispatchQueue.main.async {
                        self?.updateCountryData(iso2Code: country.iso2Code, gdp: value)
                    }
                }
            } catch {
                print("GDP fetch error: \(error)")
            }
        }.resume()
    }
    
    private func updateCountryData(iso2Code: String, population: Int? = nil, gdp: Double? = nil) {
        var cached = countryDataCache[iso2Code] ?? (population: nil, gdp: nil)
        
        if let population = population {
            cached.population = population
        }
        if let gdp = gdp {
            cached.gdp = gdp
        }
        
        countryDataCache[iso2Code] = cached
        
        countries = countries.map { country in
            if country.iso2Code == iso2Code {
                return Country(
                    name: country.name,
                    category: country.category,
                    flag: country.flag,
                    population: cached.population,
                    gdp: cached.gdp,
                    iso2Code: country.iso2Code
                )
            }
            return country
        }
    }
    
    private func flagEmoji(from iso2Code: String) -> String {
        let base: UInt32 = 127397
        var flag = ""
        for unicode in iso2Code.uppercased().unicodeScalars {
            if let scalar = UnicodeScalar(base + unicode.value) {
                flag.append(String(scalar))
            }
        }
        return flag
    }
    
    var categories: [String] {
        Array(Set(countries.map { $0.category })).sorted()
    }
    
    func countries(in category: String) -> [Country] {
        countries.filter { $0.category == category }
    }
}

Views ( в точности такие же, как в прошлой версии ):

// Views remain the same as in the previous version
struct ContentView: View {
    @StateObject private var viewModel = CountriesViewModel()
    
    var body: some View {
        NavigationView {
            Group {
                if viewModel.isLoading {
                    ProgressView("Loading countries...")
                } else if let errorMessage = viewModel.errorMessage {
                    ErrorView(message: errorMessage, retryAction: {
                        viewModel.loadCountries()
                    })
                } else {
                    CountryListView(viewModel: viewModel)
                }
            }
            .navigationTitle("World Countries")
        }
        .onAppear {
            viewModel.loadCountries()
        }
    }
}

// Rest of the view code remains the same...
struct CountryListView: View {
    @ObservedObject var viewModel: CountriesViewModel
    
    var body: some View {
        List {
            ForEach(viewModel.categories, id: \.self) { category in
                Section(header: Text(category)) {
                    ForEach(viewModel.countries(in: category)) { country in
                        NavigationLink(destination: CountryDetailView(country: country)) {
                            CountryRowView(country: country)
                        }
                    }
                }
            }
        }
        .refreshable {
            viewModel.loadCountries()
        }
    }
}

struct CountryRowView: View {
    let country: Country
    
    var body: some View {
        HStack {
            Text(country.flag)
                .font(.title2)
            Text(country.name)
                .font(.body)
        }
        .padding(.vertical, 4)
    }
}

struct CountryDetailView: View {
    let country: Country
    
    var body: some View {
        ScrollView {
            VStack(spacing: 20) {
                Text(country.flag)
                    .font(.system(size: 100))
                
                Text(country.name)
                    .font(.title)
                    .fontWeight(.bold)
                
                VStack(alignment: .leading, spacing: 15) {
                    DetailRow(title: "Region", value: country.category)
                    
                    if let population = country.population {
                        DetailRow(
                            title: "Population",
                            value: formatNumber(population)
                        )
                    }
                    
                    if let gdp = country.gdp {
                        DetailRow(
                            title: "GDP (USD)",
                            value: formatCurrency(gdp)
                        )
                    }
                }
                .padding()
                
                Spacer()
            }
        }
        .navigationBarTitleDisplayMode(.inline)
        .padding()
    }
    
    private func formatNumber(_ number: Int) -> String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .decimal
        return formatter.string(from: NSNumber(value: number)) ?? String(number)
    }
    
    private func formatCurrency(_ number: Double) -> String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .currency
        formatter.currencyCode = "USD"
        formatter.maximumFractionDigits = 0
        return formatter.string(from: NSNumber(value: number)) ?? String(number)
    }
}

struct DetailRow: View {
    let title: String
    let value: String
    
    var body: some View {
        HStack {
            Text(title)
                .fontWeight(.medium)
                .foregroundColor(.gray)
            Spacer()
            Text(value)
                .fontWeight(.medium)
        }
    }
}

struct ErrorView: View {
    let message: String
    let retryAction: () -> Void
    
    var body: some View {
        VStack(spacing: 16) {
            Text("Error")
                .font(.title)
                .foregroundColor(.red)
            Text(message)
                .multilineTextAlignment(.center)
            Button("Retry", action: retryAction)
                .buttonStyle(.bordered)
        }
        .padding()
    }
}

Я сделала несколько важных исправлений для правильной обработки ответа response API Всемирного банка:

  1. Исправлен парсинг данных (Model):
  • Обновлена ​​стратегия декодирования JSON для соответствия формату API Всемирного банка  с помощью init(from decoder: Decoder) throws {
  • Добавлена ​​правильная обработка ошибок и отладочный вывод данных
  • Необязательные поля сделаны действительно Optional для предотвращения сбоев декодирования
  1. Улучшена обработка ошибок (ViewModel):
  • Добавлены более подробные сообщения об ошибках
  • Включена отладочная печать для устранения неполадок

На этот раз мы вообще получаем ошибку на уровне компиляции, и как раз в одном из 2-х фрагментов, за которыми мы наблюдаем, а именно в CountriesViewModel:

У нас одна ошибка и предупреждение:

Any не может соответствовать протоколу ‘Decodable
Кастинг [Any?] неподходящим ТИПом ‘[String:A
ny?]’ всегда будет ошибаться.

Короче, этот код не работает:

Хотя сделаны существенные изменения в 1-ом фрагменте, a именно в Модель WorldBankResponse добавлено “ручное” декодирование с помощью init(from decoder: Decoder) throws, но оно выполнено неверно, так как в корневом сегменте JSON данных находится массив, а Claude 3.5 Sonnet  использует словарь вместо массива:

// World Bank API response format
struct WorldBankResponse: Decodable {
    let data: [CountryData]
    
    enum CodingKeys: String, CodingKey {
        case data = "1"
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.data = try container.decode([CountryData].self, forKey: .data)
    }
}

Итак, несмотря на то, что Claude 3.5 Sonnet предоставляет нам полное iOS приложение, она не справляется с такой простой операцией как декодирование JSON данных  с сервера Всемирного банка.

Давайте все же попытаемся добиться от нее правильного декодирования JSON данных и напрямую получим эти JSON  данные с сервера Всемирного банка по адресу, указанному в приложении https://api.worldbank.org/v2/country?format=json&per_page=300 

Ясно, что структура JSON данных отличается от стандартной, когда корневой структурой является словарь с ключами и значениями. В нашем случае корневой структурой является массив разнородных элементов: метаданные Metadata и массив стран [Country].

[
 MetaData,
  [Country]
]  

Наверное, именно в этом сложность декодирования этих JSON данных. Но в Swift механизме Codable есть все необходимые для таких конструкций.

Попробуем “подсунуть” эти «живые» JSON данные  Claude 3.5 Sonnet и попросим её правильно считать их с сервера:

Я обновляю модели данных, чтобы они точно соответствовали структуре JSON из API Всемирного банка.

Fixed Countries App with Correct World Bank API Models 3 / 3

Models:

import Foundation

// Models
struct Country: Identifiable, Hashable {
    let id = UUID()
    let name: String
    let category: String
    let flag: String
    let population: Int?
    let gdp: Double?
    let iso2Code: String
}

// Exact World Bank API response format
struct WorldBankMetadata: Decodable {
    let page: Int
    let pages: Int
    let per_page: String
    let total: Int
}

struct WorldBankCountry: Decodable {
    let id: String
    let iso2Code: String
    let name: String
    let region: Region
    let adminregion: AdminRegion
    let incomeLevel: IncomeLevel
    let lendingType: LendingType
    let capitalCity: String
    let longitude: String
    let latitude: String
    
    struct Region: Decodable {
        let id: String
        let iso2code: String
        let value: String
    }
    
    struct AdminRegion: Decodable {
        let id: String
        let iso2code: String
        let value: String
    }
    
    struct IncomeLevel: Decodable {
        let id: String
        let iso2code: String
        let value: String
    }
    
    struct LendingType: Decodable {
        let id: String
        let iso2code: String
        let value: String
    }
}

struct IndicatorResponse: Decodable {
    let metadata: IndicatorMetadata
    let data: [IndicatorData]
    
    init(from decoder: Decoder) throws {
        var container = try decoder.unkeyedContainer()
        metadata = try container.decode(WorldBankMetadata.self)
        data = try container.decode([IndicatorData].self)
    }
}

struct IndicatorMetadata: Decodable {
    let page: Int
    let pages: Int
    let per_page:Int
    let total: Int
}

struct IndicatorData: Decodable {
    let value: Double?
    let date: String
}

ViewModel:

import SwiftUI

// View Model
class CountriesViewModel: ObservableObject {
    @Published var countries: [Country] = []
    @Published var isLoading = false
    @Published var errorMessage: String?
    
    private let baseURL = "https://api.worldbank.org/v2"
    private var countryDataCache: [String: (population: Int?, gdp: Double?)] = [:]
    
    func loadCountries() {
        isLoading = true
        errorMessage = nil
        
        let urlString = "\(baseURL)/country?format=json&per_page=300"
        guard let url = URL(string: urlString) else {
            errorMessage = "Invalid URL"
            isLoading = false
            return
        }
        
        URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
            DispatchQueue.main.async {
                if let error = error {
                    self?.errorMessage = "Network error: \(error.localizedDescription)"
                    self?.isLoading = false
                    return
                }
                
                guard let data = data else {
                    self?.errorMessage = "No data received"
                    self?.isLoading = false
                    return
                }
                
                do {
                    let decoder = JSONDecoder()
                    // Decode as array containing metadata and countries array
                    let container = try decoder.decode([Any].self, from: data)
                    if let jsonData = 
                try? JSONSerialization.data(withJSONObject: container[1], options: []),
                let countries = try? decoder.decode([WorldBankCountry].self, 
                                                                    from: jsonData) {
                        
                        let filteredCountries = countries
                            .filter { $0.region.value != "Aggregates" } 
                            .map { countryData in
                                Country(
                                  name: countryData.name,
                                  category: countryData.region.value,
                                  flag: 
                                  self?.flagEmoji(from: countryData.iso2Code) ?? "🏳️",
                                  population: nil,
                                  gdp: nil,
                                  iso2Code: countryData.iso2Code
                                )
                            }
                        
                        self?.countries = filteredCountries
                        self?.fetchAdditionalData(for: filteredCountries)
                    }
                } catch {
                    self?.errorMessage = 
                             "Failed to decode response: \(error.localizedDescription)"
                    print("Decoding error: \(error)")
                }
                self?.isLoading = false
            }
        }.resume()
    }
    
    private func fetchAdditionalData(for countries: [Country]) {
        for country in countries {
            fetchPopulation(for: country)
            fetchGDP(for: country)
        }
    }
    
    private func fetchPopulation(for country: Country) {
        let indicator = "SP.POP.TOTL"
        let urlString = "\(baseURL)/country/\(country.iso2Code)/indicator/\(indicator)?format=json&per_page=1&date=2022"
        
        guard let url = URL(string: urlString) else { return }
        
        URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
            guard let data = data else { return }
            
            do {
                let decoder = JSONDecoder()
                let response = try decoder.decode([Any].self, from: data)
                if let jsonData =
                   try? JSONSerialization.data(withJSONObject: response[1], options: []),
                   let indicators = try? decoder.decode([IndicatorData].self, 
                                                             from: jsonData),
                   let value = indicators.first?.value {
                    DispatchQueue.main.async {
                        self?.updateCountryData(iso2Code: country.iso2Code, population: Int(value))
                    }
                }
            } catch {
                print("Population fetch error: \(error)")
            }
        }.resume()
    }
    
    private func fetchGDP(for country: Country) {
        let indicator = "NY.GDP.MKTP.CD"
        let urlString = "\(baseURL)/country/\(country.iso2Code)/indicator/\(indicator)?format=json&per_page=1&date=2022"
        
        guard let url = URL(string: urlString) else { return }
        
        URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
            guard let data = data else { return }
            
            do {
              let decoder = JSONDecoder()
              let response = try decoder.decode([Any].self, from: data)
              if let jsonData = try? 
                JSONSerialization.data(withJSONObject: response[1], options: []),
               let indicators = try? decoder.decode([IndicatorData].self, from: jsonData),
                   let value = indicators.first?.value {
                    DispatchQueue.main.async {
                        self?.updateCountryData(iso2Code: country.iso2Code, gdp: value)
                    }
              }
           } catch {
                print("GDP fetch error: \(error)")
            }
        }.resume()
    }
    
    // Rest of the ViewModel implementation remains the same...
    private func updateCountryData(iso2Code: String, population: Int? = nil, gdp: Double? = nil) {
        var cached = countryDataCache[iso2Code] ?? (population: nil, gdp: nil)
        
        if let population = population {
            cached.population = population
        }
        if let gdp = gdp {
            cached.gdp = gdp
        }
        
        countryDataCache[iso2Code] = cached
        
        countries = countries.map { country in
            if country.iso2Code == iso2Code {
                return Country(
                    name: country.name,
                    category: country.category,
                    flag: country.flag,
                    population: cached.population,
                    gdp: cached.gdp,
                    iso2Code: country.iso2Code
                )
            }
            return country
        }
    }
    
    private func flagEmoji(from iso2Code: String) -> String {
        let base: UInt32 = 127397
        var flag = ""
        for unicode in iso2Code.uppercased().unicodeScalars {
            if let scalar = UnicodeScalar(base + unicode.value) {
                flag.append(String(scalar))
            }
        }
        return flag
    }
    
    var categories: [String] {
        Array(Set(countries.map { $0.category })).sorted()
    }
    
    func countries(in category: String) -> [Country] {
        countries.filter { $0.category == category }
    }
}

// Views remain the same as in the previous version
// (ContentView, CountryListView, CountryRowView, CountryDetailView, DetailRow, ErrorView)

Я сделал несколько важных обновлений для соответствия точному формату ответа API Всемирного банка:

  1. Обновленные модели данных:
  • Добавлены WorldBankMetadata для соответствия первому объекту в массиве response
  • Созданы правильные вложенные структуры для регионов Region, административных регионов AdminRegion, уровней дохода IncomeLevel и типов кредитования LendingType
  • Обновлён парсинг response для обработки формата на основе массива
  1. Улучшена фильтрация:
  • Добавлена ​​фильтрация для удаления агрегированных регионов Aggregate 
  • Лучшая обработка Optional полей
  • Более надежная обработка ошибок
  1. Исправлена ​​стратегия декодирования:
  • Теперь правильно обрабатывает формат response как двухэлементного массива
  • Лучшая безопасность ТИПов в процессе декодирования
  • Более точные сообщения об ошибках

Ключевые изменения, которые должны исправить проблемы парсинга:

  1. Правильная обработка формата response на основе массива
  2. Правильное декодирование объекта метаданных
  3. Точное соответствие имен и ТИПов полей
  4. Лучшая обработка  Optional значений

Но теперь мы получаем 3 однотипные ошибки, и каждый раз при декодировании JSON данных полученных с сервера Всемирного банка:

Any’ не может соответствовать протоколу ‘Decodable’.
Только конкретные ТИПы — структуры struct, перечисления enum, классы class могут соответствовать протоколу ‘Decodable’.

Кроме того, используется какое-то “древнее” JSONSerialization.
Давайте более подробно посмотрим на Модель данных, предложенную на этот раз Claude 3.5 Sonnet:

import SwiftUI

// Models
struct Country: Identifiable, Hashable {
    let id = UUID()
    let name: String
    let category: String
    let flag: String
    let population: Int?
    let gdp: Double?
    let iso2Code: String
}

// Exact World Bank API response format
struct WorldBankMetadata: Decodable {
    let page: Int
    let pages: Int
    let per_page: String
    let total: Int
}

struct WorldBankCountry: Decodable {
    let id: String
    let iso2Code: String
    let name: String
    let region: Region
    let adminregion: AdminRegion
    let incomeLevel: IncomeLevel
    let lendingType: LendingType
    let capitalCity: String
    let longitude: String
    let latitude: String
    
    struct Region: Decodable {
        let id: String
        let iso2code: String
        let value: String
    }
    
    struct AdminRegion: Decodable {
        let id: String
        let iso2code: String
        let value: String
    }
    
    struct IncomeLevel: Decodable {
        let id: String
        let iso2code: String
        let value: String
    }
    
    struct LendingType: Decodable {
        let id: String
        let iso2code: String
        let value: String
    }
}

Что касается стран Countries, то здесь есть WorldBankMetadata и очень подробный WorldBankCountry, но нет корневой структуры WorldBankResponse, состоящей из массива с двумя элементами — один WorldBankMetadata , a другой — массив [WorldBankCountry].

Поэтому Claude 3.5 Sonnet, пытаясь «выкрутиться», и предлагает этот “сомнительный код” с ‘Any’, который не работает:

let decoder = JSONDecoder()
// Decode as array containing metadata and countries array

let container = try decoder.decode([Any].self, from: data)
if let jsonData = try? JSONSerialization.data(withJSONObject: container[1], options: []),
 let countries = try? decoder.decode([WorldBankCountry].self, from: jsonData) {

Но с Моделями для выборки численности населения population определенной страны и величины её ВВП gdp все вроде бы в порядке: есть корневая структура IndicatorResponse, состоящая из массива с двумя элементами — один IndicatorMetadata , a другой — массив [IndicatorData] и с правильным пользовательским инициализатором init (from decoder: Decoder) с decoder.unkeyedContainer() для массива:

struct IndicatorResponse: Decodable {
    let metadata: IndicatorMetadata
    let data: [IndicatorData]
    
    init(from decoder: Decoder) throws {
        var container = try decoder.unkeyedContainer()
        metadata = try container.decode(IndicatorMetadata.self)
        data = try container.decode([IndicatorData].self)
    }
}

struct IndicatorData: Decodable {
    let value: Double?
    let date: String
}

// Exact World Bank API response format
struct IndicatorMetadata: Decodable {
    let page: Int
    let pages: Int
    let per_page: Int
    let total: Int
}

Достаточно было бы использовать код для выборки численности населения population в таком виде:

do {
   let response = try JSONDecoder().decode(IndicatorResponse.self, from: data)
   let indicators = response.data
   let value = indicators.first?.value
   DispatchQueue.main.async {
     self?.updateCountryData(iso2Code: country.iso2Code, 
                                                     population: Int(value!))
   }
                
} catch {
     print("Population fetch error: \(error)")
}

. . . и всё бы работало, но нет  Claude 3.5 Sonnet опять предлагает “сомнительный код” с ‘Any’, который не работает:

 do {
    let decoder = JSONDecoder()
    let response = try decoder.decode([Any].self, from: data)
    if let jsonData = try? JSONSerialization.data(withJSONObject: response[1], 
                                                                options: []),
     let indicators = try? decoder.decode([IndicatorData].self, 
                                                             from: jsonData),
     let value = indicators.first?.value {
      DispatchQueue.main.async {
         self?.updateCountryData(iso2Code: country.iso2Code, 
                                                     population: Int(value))
      }
    }
} catch {
                print("Population fetch error: \(error)")
}

Непонятно. По-прежнему мы получаем ошибку:

Any’ не может соответствовать протоколу ‘Decodable’.
Только конкретные ТИПы — структуры struct, перечисления enum, классы class могут соответствовать протоколу ‘Decodable’.

Я пыталась еще дважды “толкнуть”  Claude 3.5 Sonnet, но нет, ничего не получается.

Так что надо констатировать, что  Claude 3.5 Sonnet плохо “декодирует” несложные, но не стандартные JSON данные.

В остальном, то есть в проектировании UI и понимания того, откуда нужно взять данные, Claude 3.5 Sonnet хороша, так как нам вообще не пришлось указывать ей никакие сайты, мы просто попросили взять данные у Всемирного банка, что очень круто,  но пока мы не можем проверить это.

Кто мы мог подумать, что такая элементарная задачка, как декодирование немного нестандартных JSON данных,  вызовет затруднение в ИИ Claude 3.5 Sonnet .

Давайте попробуем решить эту задачу декодирования с помощью другого ИИ — ChatGPT 4.0-mini.

ChatGPT decode JSON для стран Country

Чтобы декодировать эту структуру JSON в Swift, вам нужно определить модель, которая соответствует как метаданным, так и массиву данных о стране. Данные JSON состоят из двух частей:

  1. Metadata (page, pages, per_page, total)
  2. Список сведений о стране Country с вложенной информацией (например, регионом region: Region, уровень дохода incomeLevel: IncomeLevel и т. д.).

Вот как можно настроить модели и декодировать этот JSON в Swift.

Шаг 1: Определение Моделей Данных

import Foundation

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

struct Region: Decodable {
    let id: String
    let iso2code: String
    let value: String
}

struct AdminRegion: Decodable {
    let id: String
    let iso2code: String
    let value: String
}

struct IncomeLevel: Decodable {
    let id: String
    let iso2code: String
    let value: String
}

struct LendingType: Decodable {
    let id: String
    let iso2code: String
    let value: String
}

struct Country: Decodable {
    let id: String
    let iso2Code: String
    let name: String
    let region: Region
    let adminregion: AdminRegion
    let incomeLevel: IncomeLevel
    let lendingType: LendingType
    let capitalCity: String
    let longitude: String
    let latitude: String
}

struct Response: Decodable {
    let metadata: Metadata
    let countries: [Country]
}

Шаг 2: Реализовать пользовательского декодера для обработки массива Array

Структура JSON имеет массив на корневом уровне, где первый элемент — метаданные, а второй элемент — список стран. Нам нужно вручную обработать эту структуру.

extension Response {
    init(from decoder: Decoder) throws {
        var container = try decoder.unkeyedContainer()
        self.metadata = try container.decode(Metadata.self)
        self.countries = try container.decode([Country].self)
    }
}

Step 3: Декодируем JSON

Теперь используйте эту модель для декодирования JSON:

let jsonData = """
[
    {"page":1,"pages":1,"per_page":"300","total":296},
    [
        {
            "id":"ABW",
            "iso2Code":"AW",
            "name":"Aruba",
            "region":{"id":"LCN","iso2code":"ZJ","value":"Latin America & Caribbean "},
            "adminregion":{"id":"","iso2code":"","value":""},
            "incomeLevel":{"id":"HIC","iso2code":"XD","value":"High income"},
            "lendingType":{"id":"LNX","iso2code":"XX","value":"Not classified"},
            "capitalCity":"Oranjestad",
            "longitude":"-70.0167",
            "latitude":"12.5167"
        },
        {
            "id":"AFE",
            "iso2Code":"ZH",
            "name":"Africa Eastern and Southern",
            "region":{"id":"NA","iso2code":"NA","value":"Aggregates"},
            "adminregion":{"id":"","iso2code":"","value":""},
            "incomeLevel":{"id":"NA","iso2code":"NA","value":"Aggregates"},
            "lendingType":{"id":"","iso2code":"","value":"Aggregates"},
            "capitalCity":"",
            "longitude":"",
            "latitude":""
        }
    ]
]
""".data(using: .utf8)!

do {
    let response = try JSONDecoder().decode(Response.self, from: jsonData)
    print("Metadata:", response.metadata)
    print("Countries:", response.countries)
} catch {
    print("Decoding error:", error)
}

Объяснение

  1. Data Models: Каждая структура struct представляет собой часть JSON структуры.
  2. Пользовательский (“ручной”) инициализатор: У Response есть пользовательский (“ручной”) инициализатор для  для обработки unkeyedContainer контейнера (массива Array) в корне.
  3. Декодирование: JSONDecoder().decode(Response.self, from: jsonData) парсит JSON в Response модель.

Это правильное решение задачи декодирования JSON данных, ChatGPT 4.0-mini прекрасно справилась с поставленной задачей.

Кстати, Gemini 2.0 Flash (смотри Примечание 1) также дает практически тот же самый код для правильного декодирования данных с сервера Всемирного банка, что и ChatGPT 4.0-mini. А вот Gemini 1.5 Flash не может предложить ничего разумного, и также как и Claude 3.5 Sonnet, предоставляет ошибочный код.

Теперь понятно, что Claude 3.5 Sonnet  не смогла предложить пользовательский (“ручной”) инициализатор для  обработки unkeyedContainer контейнера (массива Array) в корне. И в этом её ошибка.

Возвращаемся в Claude 3.5 Sonnet и приспосабливаем полученную в ChatGPT структуру  Response с пользовательским (“ручным”) инициализатором для обработки корневого контейнера unkeyedContainer (массива Array).

Claude 3.5 Sonnet продолжение: интеграция Моделей

Адаптация “ручного” инициализатора для обработки корневого массива Array

Fixed Countries App with Correct World Bank API Models 

Models:

import SwiftUI

// Models
struct Country: Identifiable, Hashable {
    let id = UUID()
    let name: String
    let category: String
    let flag: String
    let population: Int?
    let gdp: Double?
    let iso2Code: String
}
// Exact World Bank API response format
//----- Root
struct WorldBankResponse: Decodable {
    let metadata: WorldBankMetadata
    let countries: [WorldBankCountry]
    
    init(from decoder: Decoder) throws {
        var container = try decoder.unkeyedContainer()
        metadata = try container.decode(WorldBankMetadata.self)
        countries = try container.decode([WorldBankCountry].self)
    }
}
//------- Meta
struct WorldBankMetadata: Decodable {
    let page: Int
    let pages: Int
    let perPage: String
    let total: Int
    
    enum CodingKeys: String, CodingKey {
            case page
            case pages
            case perPage = "per_page"
            case total
        }
}
//------- Country
struct WorldBankCountry: Decodable {
    let id: String
    let iso2Code: String
    let name: String
    let region: Region
    let adminregion: AdminRegion
    let incomeLevel: IncomeLevel
    let lendingType: LendingType
    let capitalCity: String
    let longitude: String
    let latitude: String
    
    struct Region: Decodable {
        let id: String
        let iso2code: String
        let value: String
    }
    
    struct AdminRegion: Decodable {
        let id: String
        let iso2code: String
        let value: String
    }
    
    struct IncomeLevel: Decodable {
        let id: String
        let iso2code: String
        let value: String
    }
    
    struct LendingType: Decodable {
        let id: String
        let iso2code: String
        let value: String
    }
}
//----- Root
struct IndicatorResponse: Decodable {
    let metadata: IndicatorMetadata
    let data: [IndicatorData]
    
    init(from decoder: Decoder) throws {
        var container = try decoder.unkeyedContainer()
        metadata = try container.decode(IndicatorMetadata.self)
        data = try container.decode([IndicatorData].self)
    }
}
//----- Meta
struct IndicatorMetadata: Decodable {
    let page: Int
    let pages: Int
    let per_page: Int
    let total: Int
}
//----- Indicator
struct IndicatorData: Decodable {
    let value: Double?
    let date: String
}

ViewModel:

import SwiftUI

// View Model
class CountriesViewModel: ObservableObject {
    @Published var countries: [Country] = []
    @Published var isLoading = false
    @Published var errorMessage: String?
    
    private let baseURL = "https://api.worldbank.org/v2"
    private var countryDataCache: [String: (population: Int?, gdp: Double?)] = [:]
    
    func loadCountries() {
      isLoading = true
      errorMessage = nil
        
      let urlString = "\(baseURL)/country?format=json&per_page=300"
      guard let url = URL(string: urlString) else {
          errorMessage = "Invalid URL"
          isLoading = false
          return
      }
        
      URLSession.shared.dataTask(with: url) { [weak self] data, response,
        error in
            DispatchQueue.main.async {
                if let error = error {
                    self?.errorMessage = 
                               "Network error: \(error.localizedDescription)"
                    self?.isLoading = false
                    return
                }
                
                guard let data = data else {
                    self?.errorMessage = "No data received"
                    self?.isLoading = false
                    return
                }
                
                do {
                  let decoder = JSONDecoder()
                   //---------------
                  let response = try decoder.decode(WorldBankResponse.self, 
                                                                 from: data)
                  let countries = response.countries
                  //---------------

                  // Filter out aggregate regions                      
                  let filteredCountries = countries
                        .filter { $0.region.value != "Aggregates" } 
                        .map { countryData in
                            Country(
                              name: countryData.name,
                              category: countryData.region.value,
                              flag: 
                        self?.flagEmoji(from: countryData.iso2Code) ?? "🏳️",
                              population: nil,
                              gdp: nil,
                              iso2Code: countryData.iso2Code
                            )
                        }
                        
                        self?.countries = filteredCountries
                        self?.fetchAdditionalData(for: filteredCountries)
                } catch {
                    self?.errorMessage = 
                   "Failed to decode response:\(error.localizedDescription)"
                    print("Decoding error: \(error)")
                }
                self?.isLoading = false
            }
        }.resume()
    }
    
    private func fetchAdditionalData(for countries: [Country]) {
        for country in countries {
            fetchPopulation(for: country)
            fetchGDP(for: country)
        }
    }
    
    private func fetchPopulation(for country: Country) {
        let indicator = "SP.POP.TOTL"
        let urlString = "\(baseURL)/country/\(country.iso2Code)/indicator/\(indicator)?format=json&per_page=1&date=2022"
        
        guard let url = URL(string: urlString) else { return }
        
        URLSession.shared.dataTask(with: url) { [weak self] data, response, 
         error in
            guard let data = data else { return }
            
            do {
                //---------------
                let response = try 
                    JSONDecoder().decode(IndicatorResponse.self, from: data)
                let indicators = response.data
                let value = indicators.first?.value
                DispatchQueue.main.async {
                  self?.updateCountryData(iso2Code: country.iso2Code, 
                                                   population: Int(value!))
                }
                //-------------
            } catch {
                print("Population fetch error: \(error)")
            }
        }.resume()
    }
    
    private func fetchGDP(for country: Country) {
        let indicator = "NY.GDP.MKTP.CD"
        let urlString = "\(baseURL)/country/\(country.iso2Code)/indicator/\(indicator)?format=json&per_page=1&date=2022"
        
        guard let url = URL(string: urlString) else { return }
        
        URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
            guard let data = data else { return }
            
            do {
                //--------------
                let response = try 
                     JSONDecoder().decode(IndicatorResponse.self, from: data)
                let indicators = response.data
                let value = indicators.first?.value
                DispatchQueue.main.async {
              self?.updateCountryData(iso2Code: country.iso2Code, gdp: value)
                    }
                //--------------
            } catch {
                print("GDP fetch error: \(error)")
            }
        }.resume()
    }
    
    // Rest of the ViewModel implementation remains the same...
    private func updateCountryData(iso2Code: String, population: Int? = nil, gdp: Double? = nil) {
        var cached = countryDataCache[iso2Code] ?? 
                                      (population: nil, gdp: nil)
        
        if let population = population {
            cached.population = population
        }
        if let gdp = gdp {
            cached.gdp = gdp
        }
        
        countryDataCache[iso2Code] = cached
        
        countries = countries.map { country in
            if country.iso2Code == iso2Code {
                return Country(
                    name: country.name,
                    category: country.category,
                    flag: country.flag,
                    population: cached.population,
                    gdp: cached.gdp,
                    iso2Code: country.iso2Code
                )
            }
            return country
        }
    }
    
    private func flagEmoji(from iso2Code: String) -> String {
        let base: UInt32 = 127397
        var flag = ""
        for unicode in iso2Code.uppercased().unicodeScalars {
            if let scalar = UnicodeScalar(base + unicode.value) {
                flag.append(String(scalar))
            }
        }
        return flag
    }
    
    var categories: [String] {
        Array(Set(countries.map { $0.category })).sorted()
    }
    
    func countries(in category: String) -> [Country] {
        countries.filter { $0.category == category }
    }
}

// Views remain the same as in the previous version
// (ContentView, CountryListView, CountryRowView, CountryDetailView, DetailRow, ErrorView)

Что касается стран Countries, то теперь у нас есть корневая структура WorldBankResponse, состоящей из массива с двумя элементами — один WorldBankMetadata , a другой — массив [WorldBankCountry], c пользовательским инициализатором  init(from decoder: Decoder) throws  для обработки корневого контейнера как unkeyedContainer (массива Array). И в этом “гвоздь программы”

// Exact World Bank API response format
//----- Root
struct WorldBankResponse: Decodable {
    let metadata: WorldBankMetadata
    let countries: [WorldBankCountry]

 //-------------    
    init(from decoder: Decoder) throws {
        var container = try decoder.unkeyedContainer()
        metadata = try container.decode(WorldBankMetadata.self)
        countries = try container.decode([WorldBankCountry].self)
    }
 //-------------
}

//------- Meta
struct WorldBankMetadata: Decodable {
    let page: Int
    let pages: Int
    let perPage: String
    let total: Int
    
    enum CodingKeys: String, CodingKey {
            case page
            case pages
            case perPage = "per_page"
            case total
        }
}
//------- Country
struct WorldBankCountry: Decodable {
    let id: String
    let iso2Code: String
    let name: String
    let region: Region
    let adminregion: AdminRegion
    let incomeLevel: IncomeLevel
    let lendingType: LendingType
    let capitalCity: String
    let longitude: String
    let latitude: String
    
    struct Region: Decodable {
        let id: String
        let iso2code: String
        let value: String
    }
    
    struct AdminRegion: Decodable {
        let id: String
        let iso2code: String
        let value: String
    }
    
    struct IncomeLevel: Decodable {
        let id: String
        let iso2code: String
        let value: String
    }
    
    struct LendingType: Decodable {
        let id: String
        let iso2code: String
        let value: String
    }
}

Это существенно упрощает декодирование JSON данных для стран countries:

  do {
 
      let decoder = JSONDecoder()
      let response = try decoder.decode(WorldBankResponse.self, from: data)
      let countries = response.countries
//. . . . . . . .

Модель для выборки численности населения population определенной страны и величины её валового продукта gdp осталась прежней с корневой структурой IndicatorResponse, состоящей из массива с двумя элементами — один IndicatorMetadata , a другой — массив [IndicatorData] и с пользовательским инициализатором init (from decoder: Decoder):

//----- Root
struct IndicatorResponse: Decodable {
    let metadata: IndicatorMetadata
    let data: [IndicatorData]
    
    init(from decoder: Decoder) throws {
        var container = try decoder.unkeyedContainer()
        metadata = try container.decode(IndicatorMetadata.self)
        data = try container.decode([IndicatorData].self)
    }
}
//----- Meta
struct IndicatorMetadata: Decodable {
    let page: Int
    let pages: Int
    let per_page: Int
    let total: Int
}
//----- Indicator
struct IndicatorData: Decodable {
    let value: Double?
    let date: String
}

A вот декодирование численности населения population определенной страны и величины её валового продукта gdp поменялось и упростилось благодаря использованию корневой структуры IndicatorResponse:

 private func fetchPopulation(for country: Country) {
        let indicator = "SP.POP.TOTL"
        let urlString = "\(baseURL)/country/\(country.iso2Code)/indicator/\(indicator)?format=json&per_page=1&date=2022"
        
        guard let url = URL(string: urlString) else { return }
        
        URLSession.shared.dataTask(with: url) { [weak self] data, response, 
           error in
            guard let data = data else { return }   
            do {
               //---------------
               let response = try 
                    JSONDecoder().decode(IndicatorResponse.self, from: data)
               let indicators = response.data
               let value = indicators.first?.value
               DispatchQueue.main.async {
                self?.updateCountryData(iso2Code: country.iso2Code, 
                                                   population: Int(value!))
               }
               //-------------
            } catch {
                print("Population fetch error: \(error)")
            }
        }.resume()
    }
    
    private func fetchGDP(for country: Country) {
        let indicator = "NY.GDP.MKTP.CD"
        let urlString = "\(baseURL)/country/\(country.iso2Code)/indicator/\(indicator)?format=json&per_page=1&date=2022"
        
        guard let url = URL(string: urlString) else { return }
        URLSession.shared.dataTask(with: url) { [weak self] data, response, 
             error in
            guard let data = data else { return }  
            do {
                //--------------
                let response = try 
                    JSONDecoder().decode(IndicatorResponse.self, from: data)
                let indicators = response.data
                let value = indicators.first?.value
                DispatchQueue.main.async {
               self?.updateCountryData(iso2Code: country.iso2Code, gdp: value)
                }
                //--------------
            } catch {
                print("GDP fetch error: \(error)")
            }
        }.resume()
    }

Теперь мы действительно получаем работающее iOS приложение:

Claude 3.5 Sonnet : Удаление кэша и изменение свойств population и gdp напрямую

У нас есть ещё одна вещь, которую Claude 3.5 Sonnet нам предоставляет в этом приложении — это кэш countryDataCache для хранения полученных в фоновом (background) режиме значений численности населения population и величины валового продукта gdp, который мы заполняем и используем  на main queue для обновления дополнительной информации о стране country в массиве countries с помощью updateCountryData (iso2Code: country.iso2Code, gdp: value) и updateCountryData (iso2Code: country.iso2Code, population: Int(value!)):

// View Model
class CountriesViewModel: ObservableObject {
    @Published var countries: [Country] = []
    @Published var isLoading = false
    @Published var errorMessage: String?
    
    private let baseURL = "https://api.worldbank.org/v2"
    private var countryDataCache: [String: (population: Int?, gdp: Double?)] = [:]


//. . . . . . . . . . . . . . . . .

 private func updateCountryData(iso2Code: String, population: Int? = nil, gdp: Double? = nil) {
        var cached = countryDataCache[iso2Code] ?? (population: nil, gdp: nil)
        
        if let population = population {
            cached.population = population
        }
        if let gdp = gdp {
            cached.gdp = gdp
        }
        
        countryDataCache[iso2Code] = cached
        
        countries = countries.map { country in
            if country.iso2Code == iso2Code {
                return Country(
                    name: country.name,
                    category: country.category,
                    flag: country.flag,
                    population: cached.population,
                    gdp: cached.gdp,
                    iso2Code: country.iso2Code
                )
            }
            return country
        }
    }
//. . . . . . . . . . . . . . . . .

Изменение свойств population и gdp страны country напрямую с ChatGPT

Поскольку мы всё равно находимся в функции func loadCountries() на  main queue благодаря  DispatchQueue.main.async {. . . , мы можем изменить дополнительную информацию о стране country со значением iso2Code напрямую в массиве countries.

Давайте спросим  ChatGPT, a точнее Swift Copilot внутри  ChatGPT, (он дает самое лаконичное решение по сравнению с Claude 3.5 Sonnet и Gemini 1.5 Flash), как это сделать:

Чтобы обновить свойство population структуры  struct Country в массиве [Country], где iso2Code соответствует определенному значению iso2CodeFix, вы можете использовать возможности изменяемости Swift. Однако, поскольку Country является структурой struct, она неизменяема в массивах, если явно не обработана правильно (через индексы indices или повторное создание массива).
Вот как это можно сделать пошагово:

Решение

Вам необходимо найти индекс index страны country, соответствующий iso2CodeFix, и обновить ее свойство population.

Пример кода

import Foundation

struct Country: Identifiable, Hashable {
    let id = UUID()
    let name: String
    let category: String
    let flag: String
    var population: Int? // `var` is required to allow mutation
    var gdp: Double?
    let iso2Code: String
}

 var countries: [Country] = [
    Country(name: "CountryA", category: "Category1", flag: "🇦🇺", 
                          population: 1000000, gdp: 1.2, iso2Code: "AU"),
    Country(name: "CountryB", category: "Category2", flag: "🇺🇸", 
                          population: 5000000, gdp: 20.5, iso2Code: "US"),
    Country(name: "CountryC", category: "Category3", flag: "🇮🇳", 
                          population: 3000000, gdp: 3.1, iso2Code: "IN")
]

 let iso2CodeFix = "US"
 let newPopulation = 6000000

// Update population for the country with iso2Code == iso2CodeFix
 if let index = countries.firstIndex(where: { $0.iso2Code == iso2CodeFix }) {
    countries[index].population = newPopulation
 }

 print(countries)

Объяснение

  1. Находим индекс index:
    • Используем  firstIndex(where:) для нахождения индекса index страны country, соответствующей iso2CodeFix.
  2. Изменение структуры struct:
    • Так как структуры struct являются  Value ТИПами, изменение напрямую свойства population разрешено при доступе к определенному элементу с использованием countries[index].
  3. Обновление свойства:
    • Присвойте значение newPopulation свойству population.

Замечание:

  • Убедитесь, что свойство population является var, чтобы разрешить изменения.
  • Этот подход работает напрямую с изменяемыми массивами.

Результат

Для данных примера обновленный массив будет выглядеть так:

[
    Country(name: "CountryA", category: "Category1", flag: "🇦🇺", 
                             population: 1000000, gdp: 1.2, iso2Code: "AU"),
    Country(name: "CountryB", category: "Category2", flag: "🇺🇸",
                             population: 6000000, gdp: 20.5, iso2Code: "US"),
    Country(name: "CountryC", category: "Category3", flag: "🇮🇳", 
                             population: 3000000, gdp: 3.1, iso2Code: "IN")
]

Давайте сделаем свойства population и gdp переменными var, уберем кэш countryDataCache и всё, что с ним связано, и используем присвоение значений напрямую в изменяемом массиве countries:

// Models
struct Country: Identifiable, Hashable {
    let id = UUID()
    let name: String
    let category: String
    let flag: String
    var population: Int?
    var gdp: Double?
    let iso2Code: String
}

//. . . . . . . . . .

// View Model
class CountriesViewModel: ObservableObject {
    @Published var countries: [Country] = []
    @Published var isLoading = false
    @Published var errorMessage: String?
    
    private let baseURL = "https://api.worldbank.org/v2"
//.  . . . . . . . . . .

private func fetchPopulation(for country: Country) {
        let indicator = "SP.POP.TOTL"
        let urlString = "\(baseURL)/country/\(country.iso2Code)/indicator/\(indicator)?format=json&per_page=1&date=2022"
        
        guard let url = URL(string: urlString) else { return }
        
        URLSession.shared.dataTask(with: url) { 
         [weak self] data, response, error in
            guard let data = data else { return }
            
            do {
                //-------
                let response = try 
                    JSONDecoder().decode(IndicatorResponse.self, from: data)
                let indicators = response.data
                let value = indicators.first?.value
                DispatchQueue.main.async {
                  if let index = self?.countries.firstIndex(where: 
                                    { $0.iso2Code == country.iso2Code }) {
                            self?.countries[index].population = Int(value!)
                  }
                }
                //--------
            } catch {
                print("Population fetch error: \(error) \(country.iso2Code)")
            }
        }.resume()
    }
    
    private func fetchGDP(for country: Country) {
        let indicator = "NY.GDP.MKTP.CD"
        let urlString = "\(baseURL)/country/\(country.iso2Code)/indicator/\(indicator)?format=json&per_page=1&date=2022"
        
        guard let url = URL(string: urlString) else { return }
        
        URLSession.shared.dataTask(with: url) { 
           [weak self] data, response, error in
            guard let data = data else { return }
            
            do {
                //-------
                let response = try 
                     JSONDecoder().decode(IndicatorResponse.self, from: data)
                let indicators = response.data
                let value = indicators.first?.value
                    DispatchQueue.main.async {
                        if let index = self?.countries.firstIndex(where: 
                                    { $0.iso2Code == country.iso2Code }) {
                            self?.countries[index].gdp = value
                        }
                    }
                //-------
            } catch {
                print("GDP fetch error: \(error)")
            }
        }.resume()
    }
    
//. . . . . . . . . .

A это полный код для GCD версии этого iOS приложения.

Correct Countries App with Correct World Bank API Models (GCD)

Models:

import Foundation

// Models
struct Country: Identifiable, Hashable {
    let id = UUID()
    let name: String
    let category: String
    let flag: String
    var population: Int?
    var gdp: Double?
    let iso2Code: String
}
// Exact World Bank API response format
//----- Root
struct WorldBankResponse: Decodable {
    let metadata: WorldBankMetadata
    let countries: [WorldBankCountry]
    
   init(from decoder: Decoder) throws {
        var container = try decoder.unkeyedContainer()
        metadata = try container.decode(WorldBankMetadata.self)
        countries = try container.decode([WorldBankCountry].self)
    }
}
//------- Meta
struct WorldBankMetadata: Decodable {
    let page: Int
    let pages: Int
    let perPage: String
    let total: Int
    
    enum CodingKeys: String, CodingKey {
            case page
            case pages
            case perPage = "per_page"
            case total
        }
}
//------- Country
struct WorldBankCountry: Decodable {
    let id: String
    let iso2Code: String
    let name: String
    let region: Region
    let adminregion: AdminRegion
    let incomeLevel: IncomeLevel
    let lendingType: LendingType
    let capitalCity: String
    let longitude: String
    let latitude: String
    
    struct Region: Decodable {
        let id: String
        let iso2code: String
        let value: String
    }
    
    struct AdminRegion: Decodable {
        let id: String
        let iso2code: String
        let value: String
    }
    
    struct IncomeLevel: Decodable {
        let id: String
        let iso2code: String
        let value: String
    }
    
    struct LendingType: Decodable {
        let id: String
        let iso2code: String
        let value: String
    }
}
//----- Root
struct IndicatorResponse: Decodable {
    let metadata: IndicatorMetadata
    let data: [IndicatorData]
    
    init(from decoder: Decoder) throws {
        var container = try decoder.unkeyedContainer()
        metadata = try container.decode(IndicatorMetadata.self)
        data = try container.decode([IndicatorData].self)
    }
}
//----- Meta
struct IndicatorMetadata: Decodable {
    let page: Int
    let pages: Int
    let per_page: Int
    let total: Int
}
//----- Indicator
struct IndicatorData: Decodable {
    let value: Double?
    let date: String
}

ViewModel:

// View Model
class CountriesViewModel: ObservableObject {
    @Published var countries: [Country] = []
    @Published var isLoading = false
    @Published var errorMessage: String?
    
    private let baseURL = "https://api.worldbank.org/v2"
    
    func loadCountries() {
        isLoading = true
        errorMessage = nil
        
        let urlString = "\(baseURL)/country?format=json&per_page=300"
        guard let url = URL(string: urlString) else {
            errorMessage = "Invalid URL"
            isLoading = false
            return
        }
        
        URLSession.shared.dataTask(with: url) { 
         [weak self] data, response, error in
            DispatchQueue.main.async {
                if let error = error {
                    self?.errorMessage = 
                            "Network error: \(error.localizedDescription)"
                    self?.isLoading = false
                    return
                }
                
                guard let data = data else {
                    self?.errorMessage = "No data received"
                    self?.isLoading = false
                    return
                }
                
                do {
                    let decoder = JSONDecoder()
                    let response = 
                      try decoder.decode(WorldBankResponse.self, from: data)
                    let countries = response.countries
                        
                        let filteredCountries = countries
                            .filter { $0.region.value != "Aggregates" } 
                            .map { countryData in
                                Country(
                                    name: countryData.name,
                                    category: countryData.region.value,
                                    flag: 
                          self?.flagEmoji(from: countryData.iso2Code) ?? "🏳️",
                                    population: nil,
                                    gdp: nil,
                                    iso2Code: countryData.iso2Code
                                )
                            }
                   
                        self?.countries = filteredCountries
                        self?.fetchAdditionalData(for: filteredCountries)
                } catch {
                    self?.errorMessage = 
                  "Failed to decode response: \(error.localizedDescription)"
                    print("Decoding error: \(error)")
                }
                self?.isLoading = false
            }
        }.resume()
    }
    
    private func fetchAdditionalData(for countries: [Country]) {
        for country in countries {
            fetchPopulation(for: country)
            fetchGDP(for: country)
        }
    }
    
    private func fetchPopulation(for country: Country) {
        let indicator = "SP.POP.TOTL"
        let urlString = "\(baseURL)/country/\(country.iso2Code)/indicator/\(indicator)?format=json&per_page=1&date=2022"
        
        guard let url = URL(string: urlString) else { return }
        
        URLSession.shared.dataTask(with: url) {
           [weak self] data, response, error in
            guard let data = data else { return }
            
            do {
                //-------
                let response = try 
                    JSONDecoder().decode(IndicatorResponse.self, from: data)
              
                   let indicators = response.data
                   let value = indicators.first?.value
                    DispatchQueue.main.async {
                      if let index = self?.countries.firstIndex(where: 
                                  { $0.iso2Code == country.iso2Code }) {
                            self?.countries[index].population = Int(value!)
                        }
                    }
                //--------
            } catch {
                print("Population fetch error: \(error) \(country.iso2Code)")
            }
        }.resume()
    }
    
    private func fetchGDP(for country: Country) {
        let indicator = "NY.GDP.MKTP.CD"
        let urlString = "\(baseURL)/country/\(country.iso2Code)/indicator/\(indicator)?format=json&per_page=1&date=2022"
        
        guard let url = URL(string: urlString) else { return }
        
        URLSession.shared.dataTask(with: url) {
          [weak self] data, response, error in
            guard let data = data else { return }
            
            do {
                //-------
                let response = try 
                    JSONDecoder().decode(IndicatorResponse.self, from: data)
                let indicators = response.data
                let value = indicators.first?.value
                DispatchQueue.main.async {
                    if let index = self?.countries.firstIndex(where: 
                                    { $0.iso2Code == country.iso2Code }) {
                            self?.countries[index].gdp = value
                        }
                    }
                //-------
            } catch {
                print("GDP fetch error: \(error)")
            }
        }.resume()
    }
    
    // Rest of the ViewModel implementation remains the same...
    
    private func flagEmoji(from iso2Code: String) -> String {
        let base: UInt32 = 127397
        var flag = ""
        for unicode in iso2Code.uppercased().unicodeScalars {
            if let scalar = UnicodeScalar(base + unicode.value) {
                flag.append(String(scalar))
            }
        }
        return flag
    }
    
    var categories: [String] {
        Array(Set(countries.map { $0.category })).sorted()
    }
    
    func countries(in category: String) -> [Country] {
        countries.filter { $0.category == category }
    }
}

Views:

// Views remain the same as in the previous version
struct ContentView: View {
    @StateObject private var viewModel = CountriesViewModel()
    
    var body: some View {
        NavigationView {
            Group {
                if viewModel.isLoading {
                    ProgressView("Loading countries...")
                } else if let errorMessage = viewModel.errorMessage {
                    ErrorView(message: errorMessage, retryAction: {
                        viewModel.loadCountries()
                    })
                } else {
                    CountryListView(viewModel: viewModel)
                }
            }
            .navigationTitle("World Countries")
        }
        .onAppear {
            viewModel.loadCountries()
        }
    }
}
// Rest of the view code remains the same...
struct CountryListView: View {
    @ObservedObject var viewModel: CountriesViewModel
    
    var body: some View {
        List {
            ForEach(viewModel.categories, id: \.self) { category in
                Section(header: Text(category)) {
                    ForEach(viewModel.countries(in: category)) { 
                    country in
                        NavigationLink(destination: 
                           CountryDetailView(country: country)) {
                               CountryRowView(country: country)
                        }
                    }
                }
            }
        }
        .refreshable {
            viewModel.loadCountries()
        }
    }
}
struct CountryRowView: View {
    let country: Country
    
    var body: some View {
        HStack {
            Text(country.flag)
                .font(.title2)
            Text(country.name)
                .font(.body)
        }
        .padding(.vertical, 4)
    }
}
struct CountryDetailView: View {
    let country: Country
    
    var body: some View {
        ScrollView {
            VStack(spacing: 20) {
                Text(country.flag)
                    .font(.system(size: 100))
                
                Text(country.name)
                    .font(.title)
                    .fontWeight(.bold)
                
                VStack(alignment: .leading, spacing: 15) {
                    DetailRow(title: "Region", value: country.category)
                    
                    if let population = country.population {
                        DetailRow(
                            title: "Population",
                            value: formatNumber(population)
                        )
                    }
                    
                    if let gdp = country.gdp {
                        DetailRow(
                            title: "GDP (USD)",
                            value: formatCurrency(gdp)
                        )
                    }
                }
                .padding()
                
                Spacer()
            }
        }
        .navigationBarTitleDisplayMode(.inline)
        .padding()
    }
    
    private func formatNumber(_ number: Int) -> String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .decimal
        return 
          formatter.string(from: NSNumber(value: number)) ?? String(number)
    }
    
    private func formatCurrency(_ number: Double) -> String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .currency
        formatter.currencyCode = "USD"
        formatter.maximumFractionDigits = 0
        return 
          formatter.string(from: NSNumber(value: number)) ?? String(number)
    }
}
struct DetailRow: View {
    let title: String
    let value: String
    
    var body: some View {
        HStack {
            Text(title)
                .fontWeight(.medium)
                .foregroundColor(.gray)
            Spacer()
            Text(value)
                .fontWeight(.medium)
        }
    }
}
struct ErrorView: View {
    let message: String
    let retryAction: () -> Void
    
    var body: some View {
        VStack(spacing: 16) {
            Text("Error")
                .font(.title)
                .foregroundColor(.red)
            Text(message)
                .multilineTextAlignment(.center)
            Button("Retry", action: retryAction)
                .buttonStyle(.bordered)
        }
        .padding()
    }
}
#Preview {
    ContentView()
}

Эта GCD версия приложения Countries прекрасно работает:

Однако в этом приложении используется старая версия  URLSession.shared.dataTask (with: url), внутри которой происходит переключение на main queue с помощью  DispatchQueue.main.async для обновления UI.

У нас есть более современная версия выполнения многопоточных операций с помощью async / await. Давайте выполним рефакторинг кода для перехода на эту версию многопоточности.

 ChatGPT: рефакторинг CountriesViewModel с помощью async await

Вот CountriesViewModel с использованием async/await Swift для более понятного и читабельного асинхронного кода. 

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

Рефакторинг CountriesViewModel():

  • Теперь асинхронные функции помечены как async, нужно использовать await при их вызове.
  • Использование URLSession обновлено с использованием URLSession.shared.data(from:)  и с try await
  • Вызовы DispatchQueue.main.async удалены в MainActor async функциях, теперь это можно сделать непосредственно в async блоке, поскольку обновления UI должны происходить в основном потоке (main thread).
  • Добавлено withTaskGroup для многопоточной выборки данных о населении population и ВВП gdp для всех стран countries.

CountriesViewModel with async await

import Foundation

@MainActor
class CountriesViewModel: ObservableObject {
    @Published var countries: [Country] = []
    @Published var isLoading = false
    @Published var errorMessage: String?
    
    private let baseURL = "https://api.worldbank.org/v2"
    
    // MARK: - Load Countries
    func loadCountries() async {
        isLoading = true
        errorMessage = nil
        
        let urlString = "\(baseURL)/country?format=json&per_page=300"
        guard let url = URL(string: urlString) else {
            errorMessage = "Invalid URL"
            isLoading = false
            return
        }
        
        do {
            let (data, _) = try await URLSession.shared.data(from: url)
            let response = 
                try JSONDecoder().decode(WorldBankResponse.self, from: data)
            let fetchedCountries = response.countries
            
            // Filter and map countries
            let filteredCountries = fetchedCountries
                .filter { $0.region.value != "Aggregates" }
                .map { countryData in
                    Country(
                        name: countryData.name,
                        category: countryData.region.value,
                        flag: self.flagEmoji(from: countryData.iso2Code),
                        population: nil,
                        gdp: nil,
                        iso2Code: countryData.iso2Code
                    )
                }
            
            countries = filteredCountries
             isLoading = false
            
            // Fetch additional data concurrently
            await fetchAdditionalData(for: filteredCountries)
        } catch {
            errorMessage = 
                  "Failed to load countries: \(error.localizedDescription)"
            print("Error: \(error)")
        }
        
        isLoading = false
    }
    
    // MARK: - Fetch Additional Data
    private func fetchAdditionalData(for countries: [Country]) async {
        await withTaskGroup(of: Void.self) { group in
            for country in countries {
                group.addTask { await self.fetchPopulation(for: country) }
                group.addTask { await self.fetchGDP(for: country) }
            }
        }
    }
    
    // MARK: - Fetch Population
    private func fetchPopulation(for country: Country) async {
        let indicator = "SP.POP.TOTL"
        let urlString = "\(baseURL)/country/\(country.iso2Code)/indicator/\(indicator)?format=json&per_page=1&date=2022"
        guard let url = URL(string: urlString) else { return }
        
        do {
            let (data, _) = try await URLSession.shared.data(from: url)
            let response = 
                try JSONDecoder().decode(IndicatorResponse.self, from: data)
            let population = response.data.first?.value.flatMap { Int($0) }
            
            // Update population in the main thread
            if let index = countries.firstIndex(
                                where:{ $0.iso2Code == country.iso2Code }) {
                countries[index].population = population
            }
        } catch {
            print(
            "Failed to fetch population for \(country.iso2Code): \(error)")
        }
    }
    
    // MARK: - Fetch GDP
    private func fetchGDP(for country: Country) async {
        let indicator = "NY.GDP.MKTP.CD"
        let urlString = "\(baseURL)/country/\(country.iso2Code)/indicator/\(indicator)?format=json&per_page=1&date=2022"
        guard let url = URL(string: urlString) else { return }
        
        do {
            let (data, _) = try await URLSession.shared.data(from: url)
            let response = 
                try JSONDecoder().decode(IndicatorResponse.self, from: data)
            let gdp = response.data.first?.value
            
            // Update GDP in the main thread
            if let index = countries.firstIndex(
                         where:{ $0.iso2Code == country.iso2Code }) {
                countries[index].gdp = gdp
            }
        } catch {
            print("Failed to fetch GDP for \(country.iso2Code): \(error)")
        }
    }    

    // MARK: - Flag Emoji
    private func flagEmoji(from iso2Code: String) -> String {
        let base: UInt32 = 127397
        return iso2Code.uppercased().unicodeScalars.compactMap {
            UnicodeScalar(base + $0.value).map(String.init)
        }.joined()
    }
    
    // MARK: - Categories
    var categories: [String] {
        Array(Set(countries.map { $0.category })).sorted()
    }
    
    func countries(in category: String) -> [Country] {
        countries.filter { $0.category == category }
    }
}

Рефакторинг ContentView:

  • Модификатор .onAppear заменен модификатором .task {} для вызова async функции в SwiftUI, что гарантирует, что CountriesViewModel выбирает страны countries при появления View.

ContentView with async await

import SwiftUI

struct ContentView: View {
    @StateObject private var viewModel = CountriesViewModel()
    
    var body: some View {
        NavigationView {
            Group {
                if viewModel.isLoading {
                    ProgressView("Loading countries...")
                } else if let errorMessage = viewModel.errorMessage {
                    ErrorView(message: errorMessage, retryAction: {
                        viewModel.loadCountries()
                    })
                } else {
                    CountryListView(viewModel: viewModel)
                }
            }
            .navigationTitle("World Countries")
        }
        .task {
            await viewModel.loadCountries()
        }
    }
}
// Rest of the view code remains the same...

Преимущества

  1. Улучшенная читабельность:
    • Async/await устраняет необходимость во вложенных замыканиях и явной диспетчеризации потоков.
  2. Многопоточность:
    • Численность населения population и ВВП gdp выбираются одновременно (многопоточно) с повышенной производительностью.
  3. Type Safety:
    • async/await  и  try обеспечивают четкое распространение и обработку ошибок.

Что выполняется на @MainActor?

Аннотация @MainActor класса class  CountriesViewModel: ObservableObject применяется ко всему классу: ко всем свойствам и функциям:

@MainActor
class CountriesViewModel: ObservableObject {
    @Published var countries: [Country] = []
    @Published var isLoading = false
    @Published var errorMessage: String?
    
    private let baseURL = "https://api.worldbank.org/v2"
    
    // MARK: - Load Countries
    func loadCountries() async {. . .}
               
    // MARK: - Fetch Additional Data
    private func fetchAdditionalData(for countries: [Country]) async {. . .}       
    
    // MARK: - Fetch Population
    private func fetchPopulation(for country: Country) async {. . .}    
    
    // MARK: - Fetch GDP
    private func fetchGDP(for country: Country) async {. . .}
     
    // MARK: - Flag Emoji
    private func flagEmoji(from iso2Code: String) -> String { . . .} 
    
    // MARK: - Categories
    var categories: [String] {. . .}           
    func countries(in category: String) -> [Country] {. . . }
 }

Это не означает, что запрос в интернет будет выполняться в основном потоке (main thread). И я хочу рассмотреть один конкретный фрагмент.

 func loadCountries() async {
  //.  .  .  .  . 
 
 // MainActor here
 let urlString = "\(baseURL)/country?format=json&per_page=300"
        guard let url = URL(string: urlString) else {
            errorMessage = "Invalid URL"
            isLoading = false
            return
        }
    
// this call might switch to something else, that's up to URLSession
    let (data, _) = try await URLSession.shared.data(from: url)
   
  // back on the MainActor now

    let response = 
           try JSONDecoder().decode(WorldBankResponse.self, from: data)
    let fetchedCountries = response.countries

   //.  .  .  .  .   

Видите это ключевое слово await

Это важно. Прямо здесь оно станет определением URLSession.data(for:), которая решает, какая изоляция isolation будет действовать. Этот вызов отменяет MainActor и освободит его для выполнения другой работы. Только после того, как ответ response будет доступен, функция будет снова перезапущена на MainActor.

nonisolated

Мы хотим выполнять только основную работу на MainActor и переместить все остальное на фоновый поток background. До сих пор мы знали, что наша функция func loadСountries является MainActor. Но мы хотим чего-то, что определенно не является MainActor.

Для этого есть инструмент: ключевое слово nonisolated.

Что делает nonisolated, так это останавливает любой вывод из контекста (inference) для actor и гарантирует, что для функции не будет иметь никакой изоляции  isolation. Отсутствие изоляции isolation означает отсутствие MainActor, а это означает фоновый поток (background). 

Claude 3.5 Sonnet Swift 6 strict concurrency

Давайте переместим всю выборку данных в функции fetch и сделаем их  nonisolated:

Ключевые изменения, внесенные для Swift 6 concurrency:

  1. Изменение протокола  ObservableObject на новый макрос @Observable
  2. Выделение fetchCountries() в отдельный private метод, который обрабатывает только логику выборки данных.
  3. Функция loadCountries() упростилась и сосредоточилась на  управлении состоянием выборки isLoading и на ошибках errorMessage

Ключевые изменения, делающие все функции выборки nonisolated , которым не требуется доступ к actor-isolated состоянию.

  1. Ко всем функциям выборки и вспомогательным методам добавляем ключевое слово nonisolated, теперь они возвращают выбранные значения, не пытаясь обновлять UI.
  2. Модифицируем fetchCountryDetails для сбора и прямого возврата обновлений.
  3. Добавлен новый метод updateCountries для обновлений состояния на MainActor

CountriesViewModel c Swift 6 Concurrency

import SwiftUI

@MainActor
@Observable
class CountriesViewModel {
    var countriesApp: [Country] = []
    var isLoading = false
    var errorMessage: String?
    
    private let baseURL = "https://api.worldbank.org/v2"
    
    private struct CountryDetail {
           let iso2Code: String
           let population: Int?
           let gdp: Double?
    }
    
    // Main load function marked as async
   func loadCountries() async {
        isLoading = true
        errorMessage = nil
        let urlString = "\(baseURL)/country?format=json&per_page=300"
        guard let url = URL(string: urlString) else {
                errorMessage = "Invalid URL"
                isLoading = false
                return
        }
       
       let filteredCountries = await fetchCountries(url: url)
       // Update UI on main actor
       if let countries = filteredCountries {
           self.countriesApp = countries
           isLoading = false
           
           // Fetch and update with details
            let details = await fetchCountryDetails(for: countries)
            updateCountries(with: details)
       } else {
           print("Error: Failed to load countries")
           errorMessage = "Failed to load countries"
           isLoading = false
       }
    }
    
    // Helper method for fetching countries
    private nonisolated func fetchCountries(url: URL) async ->  [Country]? {
        do {
            // Use structured concurrency with async/await
            let (data, _) = try await URLSession.shared.data(from: url)
            
            let decoder = JSONDecoder()
            let response = 
                   try decoder.decode(WorldBankResponse.self, from: data)
            
            let filteredCountries = response.countries
                .filter { $0.region.value != "Aggregates" }
                .map { countryData in
                    Country(
                        name: countryData.name,
                        category: countryData.region.value,
                        flag: flagEmoji(from: countryData.iso2Code),
                        population: nil,
                        gdp: nil,
                        iso2Code: countryData.iso2Code
                    )
                }
            return filteredCountries
        } catch {
            print("Error: \(error.localizedDescription)")
            return nil
        }
    }
    
    // Helper method for fetching additional data
    // Now returns collected data instead of updating state directly
    private nonisolated func fetchCountryDetails(for countries: [Country]) async -> [CountryDetail] {
           await withTaskGroup(of: CountryDetail?.self) { group in
               for country in countries {
                   let iso2Code = country.iso2Code
                    group.addTask {
                       let (population, gdp ) = 
                       await (self.fetchPopulation(for: iso2Code), 
                               self.fetchGDP(for: iso2Code))
                       return CountryDetail(
                               iso2Code: iso2Code,
                               population: population,
                               gdp: gdp
                              )
                   }
               }
               
               // Collect results
               var details: [CountryDetail] = []
               for await detail in group {
                   if let detail = detail {
                       details.append(detail)
                   }
               }
               return details
           }
       }
 
       private func updateCountries(with details: [CountryDetail]) {
           for detail in details {
               if let index = countriesApp.firstIndex(
                          where: { $0.iso2Code == detail.iso2Code }) {
                   countriesApp[index].population = detail.population
                   countriesApp[index].gdp = detail.gdp
               }
           }
       }
   
    // Updated to return population as an optional integer
    private nonisolated func fetchPopulation(for  iso2Code: String) async -> Int? {
        let indicator = "SP.POP.TOTL"
        let urlString = "\(baseURL)/country/\(iso2Code)/indicator/\(indicator)?format=json&per_page=1&date=2023"
       
        guard let url = URL(string: urlString) else {
            print("Invalid URL for population data")
            return  nil
        }
        
        do {
            let (data, _) = try await URLSession.shared.data(from: url)
            let response = 
                try JSONDecoder().decode(IndicatorResponse.self, from: data)
           let fetchPopulation = response.data.first?.value.flatMap { Int($0) }
           return fetchPopulation
        } catch {
            print("Failed to fetch population for \(iso2Code): \(error)")
            return nil
        }
    }
    
    // Updated to return GDP as an optional double
    private nonisolated func fetchGDP(for  iso2Code: String) async -> Double? {
        let indicator = "NY.GDP.MKTP.CD"
        let urlString = "\(baseURL)/country/\(iso2Code)/indicator/\(indicator)?format=json&per_page=1&date=2022"
        
        guard let url = URL(string: urlString) else {
            print("Invalid URL for GDP data")
            return nil
        }
        
        do {
            let (data, _) = try await URLSession.shared.data(from: url)
            let response = 
               try JSONDecoder().decode(IndicatorResponse.self, from: data)
            
         //   return response.data.first?.value
            let fetchedGDP = response.data.first?.value
            return fetchedGDP
        } catch {
            print("Failed to fetch GDP for \(iso2Code): \(error)")
            return nil
        }
    }
    
    private nonisolated func flagEmoji(from iso2Code: String) -> String {
        let base: UInt32 = 127397
        var flag = ""
        for scalar in iso2Code.uppercased().unicodeScalars {
            if let flagScalar = UnicodeScalar(base + scalar.value) {
                flag.append(String(flagScalar))
            }
        }
        return flag.isEmpty ? "🏳️" : flag
    }
    
    var categories: [String] {
        Array(Set(countriesApp.map { $0.category })).sorted()
     }
     
     func countries(in category: String) -> [Country] {
         countriesApp.filter { $0.category == category }
     }
}

// Views and NModels are the same

Этот код работает и в Swift 6:

A также проверьте, что  режим многопоточности (Swift Concurrency Checking) выставлен как Complete, то есть strict concurrency:

Теперь класс class CountriesViewModel имеет следующий вид:

import Foundation

@MainActor
@Observable
class CountriesViewModel {
    var countriesApp: [Country] = []
    var isLoading = false
    var errorMessage: String?
    
    private let baseURL = "https://api.worldbank.org/v2"
    
    private struct CountryDetail {
           let iso2Code: String
           let population: Int?
           let gdp: Double?
    }
    
    // Main load function marked as async
   func loadCountries() async {
        isLoading = true
        errorMessage = nil
        let urlString = "\(baseURL)/country?format=json&per_page=300"
        guard let url = URL(string: urlString) else {
                errorMessage = "Invalid URL"
                isLoading = false
                return
        }
       
       let filteredCountries = await fetchCountries(url: url)
       // Update UI on main actor
       if let countries = filteredCountries {
           countriesApp = countries
           isLoading = false
           
           // Fetch and update with details
            let details = await fetchCountryDetails(for: countries)
            updateCountries(with: details)
       } else {
           print("Error: Failed to load countries")
           errorMessage = "Failed to load countries"
           isLoading = false
       }
    }
    
    // Helper method for fetching countries
    private nonisolated func fetchCountries(url: URL) async ->  [Country]? {. . .}
           
    // Helper method for fetching additional data
    // Now returns collected data instead of updating state directly
    private nonisolated func fetchCountryDetails(for countries: [Country]) async ->  
                                                             [CountryDetail] {. . .}
        
    // Updated to return population as an optional integer
    private nonisolated func fetchPopulation(for  iso2Code: String) async -> Int? {. . .}
        
    // Updated to return GDP as an optional double
    private nonisolated func fetchGDP(for  iso2Code: String) async -> Double? {. . .}
       
    private nonisolated func flagEmoji(from iso2Code: String) -> String {. . .}

    private func updateCountries(with details: [CountryDetail]) {. . .}

    var categories: [String] {
        Array(Set(countriesApp.map { $0.category })).sorted()
     }
     
     func countries(in category: String) -> [Country] {
         countriesApp.filter { $0.category == category }
     }
}

Это @MainActor @Observable class c private nonisolated функциями, выполняемыми на фоновом потоке (background). Обновление MainActor переменных этого класса, как и UI, осуществляется в MainActor функциях loadCountries() и updateCountries. Это гарантирует отсутствие “гонки данных” (data races) в Swift 6.

Итак, у нас есть приложения:

CountryClaude — GCD
CountryClaudeAsync — async await
CountryClaudeSwift6Concurrency — Swift 6 strict concurrency (no data race).

Все работает.

Заключение.

С помощью Claude 3.5 Sonnet при первом же обращении нам удалось получить превосходное iOS приложение с Моделью данных для расшифровки JSON данных, с CountriesViewModel, которая выбирает всю необходимую информацию с сервера Всемирного банка, преобразует JSON данные в данные Модели и предоставляет их Views для отображения на экране пользователя. Мы не задали не единой ссылки на сайты Всемирного банка, ни единого намека на структуру данных, и тем не менее получили полноценное iOS приложение. 

Однако запустив это приложение, мы получили ошибку, связанную с декодированием JSON данных, полученных с сервера Всемирного Банка, которые оказались не совсем стандартными. И дальнейшие “уговоры”  Claude 3.5 Sonnet, a также Gemini 1.5 Flash не помогли решить нам эту проблему.

Только ChatGPT 4.o1-mini и  Gemini 2.0 Flash справилась с этой казалось бы легкой задачей, её ответ и пришлось интегрировать в приложение, полученное Claude 3.5 Sonnet. Надо сказать, что ChatGPT 4.o1-Preview, который отличается способностью к построению логических цепочек дал вообще фантастический результат, который вряд ли получил бы даже программист супер высокого класса, он сложнее, чем результат полученный ChatGPT 4.o1-mini, но это очень красивое решение (Примечание 2 в конце статьи).

Все нейросети:  Claude 3.5 Sonnet,  Gemini 2.0 Flash, ChatGPT 4.o1-mini, ChatGPT 4.o1-Preview прекрасно справились с рефакторингом асинхронного кода с CGD на async await и далее на Swift 6 strict concurrency.  

Примечание 1.  Gemini 2.0 Flash decode JSON для стран Country

import Foundation

struct Pagination: Codable {
    let page: Int
    let pages: Int
    let per_page: String
    let total: Int
}

struct Region: Codable {
    let id: String
    let iso2code: String
    let value: String
}

struct IncomeLevel: Codable {
    let id: String
    let iso2code: String
    let value: String
}

struct LendingType: Codable {
    let id: String
    let iso2code: String
    let value: String
}

struct Country: Codable {
    let id: String
    let iso2Code: String
    let name: String
    let region: Region
    let adminregion: Region // Even if values are empty strings, it's still a Region
    let incomeLevel: IncomeLevel
    let lendingType: LendingType
    let capitalCity: String
    let longitude: String
    let latitude: String
}

// Example usage (decoding the JSON):
let jsonString = """
[{"page":1,"pages":1,"per_page":"300","total":296},[{"id":"ABW","iso2Code":"AW","name":"Aruba","region":{"id":"LCN","iso2code":"ZJ","value":"Latin America & Caribbean "},"adminregion":{"id":"","iso2code":"","value":""},"incomeLevel":{"id":"HIC","iso2code":"XD","value":"High income"},"lendingType":{"id":"LNX","iso2code":"XX","value":"Not classified"},"capitalCity":"Oranjestad","longitude":"-70.0167","latitude":"12.5167"},{"id":"AFE","iso2Code":"ZH","name":"Africa Eastern and Southern","region":{"id":"NA","iso2code":"NA","value":"Aggregates"},"adminregion":{"id":"","iso2code":"","value":""},"incomeLevel":{"id":"NA","iso2code":"NA","value":"Aggregates"},"lendingType":{"id":"","iso2code":"","value":"Aggregates"},"capitalCity":"","longitude":"","latitude":""}]]
"""

let jsonData = jsonString.data(using: .utf8)!

do {
    let decoder = JSONDecoder()
    // Decode the pagination data
    let pagination = try decoder.decode(Pagination.self, from: jsonData)
    print("Pagination: \(pagination)")
    
    // Decode the country data (it's in an array within the main array)
    let outerArray = try JSONSerialization.jsonObject(with: jsonData, 
                                                      options: []) as! [Any]
    let countryArrayData = try JSONSerialization.data(withJSONObject: 
                                                              outerArray[1])
    let countries = try decoder.decode([Country].self, from: countryArrayData)

    print("Countries: \(countries)")

} catch {
    print("Error decoding JSON: \(error)")
}

Ключевые моменты:

  • Соответствие протоколу Codable: Все структуры теперь соответствуют протоколу Codable, что упрощает кодирование и декодирование.
  • Вложенные структуры: Корректно моделирует вложенные структуры JSON  Region, IncomeLevel и LendingType.
  • Обработка ошибок: Используем do-catch для надежной обработки ошибок во время декодирования JSON.
  • Пример использования: Содержит полный пример того, как декодировать строку JSON в объекты Swift, включая обработку вложенных структур массива..
  • Расшифровка внешнего массива: Код теперь правильно анализирует структуру внешнего массива JSON, используя JSONSerialization для извлечения двух частей (pagination и countries) перед их индивидуальным декодированием. Это было основной проблемой.

Однако мы получаем ошибку: «»Expected to decode Dictionary<String, Any> but found an array instead».
Давайте сообщим об этом Gemini 2.0 Flash:

mport Foundation

struct Pagination: Codable {
    let page: Int
    let pages: Int
    let per_page: String
    let total: Int
}

struct Region: Codable {
    let id: String
    let iso2code: String
    let value: String
}

struct IncomeLevel: Codable {
    let id: String
    let iso2code: String
    let value: String
}

struct LendingType: Codable {
    let id: String
    let iso2code: String
    let value: String
}

struct Country: Codable {
    let id: String
    let iso2Code: String
    let name: String
    let region: Region
    let adminregion: Region
    let incomeLevel: IncomeLevel
    let lendingType: LendingType
    let capitalCity: String
    let longitude: String
    let latitude: String
}

struct Response: Codable {
    let pagination: Pagination
    let countries: [Country]

    init(from decoder: Decoder) throws {
        var container = try decoder.unkeyedContainer()
        self.pagination = try container.decode(Pagination.self)
        self.countries = try container.decode([Country].self)
    }
}

let jsonString = """
[{"page":1,"pages":1,"per_page":"300","total":296},[{"id":"ABW","iso2Code":"AW","name":"Aruba","region":{"id":"LCN","iso2code":"ZJ","value":"Latin America & Caribbean "},"adminregion":{"id":"","iso2code":"","value":""},"incomeLevel":{"id":"HIC","iso2code":"XD","value":"High income"},"lendingType":{"id":"LNX","iso2code":"XX","value":"Not classified"},"capitalCity":"Oranjestad","longitude":"-70.0167","latitude":"12.5167"},{"id":"AFE","iso2Code":"ZH","name":"Africa Eastern and Southern","region":{"id":"NA","iso2code":"NA","value":"Aggregates"},"adminregion":{"id":"","iso2code":"","value":""},"incomeLevel":{"id":"NA","iso2code":"NA","value":"Aggregates"},"lendingType":{"id":"","iso2code":"","value":"Aggregates"},"capitalCity":"","longitude":"","latitude":""}]]
"""

let jsonData = jsonString.data(using: .utf8)!

do {
    let decoder = JSONDecoder()
    let response = try decoder.decode(Response.self, from: jsonData)

    print("Pagination: \(response.pagination)")
    print("Countries: \(response.countries)")

} catch {
    print("Error decoding JSON: \(error)")
}

Ключевые моменты:

  1. Структура Response: Введена новая структура struct Response для инкапсуляции как Pagination, так и массива [Country]. Это ключ к упрощению процесса декодирования.
  2. init(from decoder:): Структура struct Response теперь реализует инициализатор init(from decoder:). Это позволяет нам настраивать способ декодирования объекта Response.
  3. unkeyedContainer(): Внутри инициализатора init(from decoder:) используется decoder.unkeyedContainer(). Поскольку корневой уровень JSON — это массив (не словарь), мы используем UnkeyedDecodingContainer. Этот контейнер позволяет нам декодировать элементы в том порядке, в котором они появляются в массиве
  4. Расшифровка элементов по порядку: Затем мы используем container.decode(Type.self) дважды в правильном порядке: сначала для декодирования объекта Pagination, а затем для декодирования массива [Country]. UnkeyedDecodingContainer гарантирует, что элементы декодируются в том порядке, в котором они появляются в JSON.

При таком подходе логика декодирования становится намного чище и лаконичнее. Нам больше не нужно вручную использовать JSONSerialization для разбора внешнего массива и последующего декодирования каждого элемента по отдельности. Система Codable обрабатывает все за нас в рамках пользовательского init. Это наиболее эффективный и рекомендуемый способ обработки этого типа структуры JSON в Swift.

Этот код Gemini 2.0 Flash работает, и это практически то, что дает ChatGPT 4.o1-mini.

Надо отметить, что Gemini 1.5 Flash, также как и Claude 3.5 Sonnet, не справился с задачей декодирования JSON данных с сервера Всемирного банка и не смог предложить разумного решения.

Примечание 2.  ChatGPT O1-preview inside AlexSideBar  decode JSON для стран Country. Удивительный вариант.

Есть стартап AlexSideBar , который разрабатывает Cursor AI для Xcode, появилась уже версия 2.0 и это бета-версия, она бесплатна. В ней можно настраивать различные ИИ (AI), в том числе и ChatGPT O1-preview:

Я использовала ChatGPT O1-preview и задала все тот же вопрос о декодировании JSON данных для стран [Country] с сервера Всемирного банка и получила потрясающий ответ:

import Foundation
// MARK: - Top Level Array
typealias CountryResponse = [CountryData]
// MARK: - First element (metadata)
struct Metadata: Codable {
    let page: Int
    let pages: Int
    let perPage: String
    let total: Int
    
    enum CodingKeys: String, CodingKey {
        case page, pages
        case perPage = "per_page"
        case total
    }
}
// MARK: - Response Data Types
enum CountryData: Codable {
    case metadata(Metadata)
    case countries([Country])
    
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        if let metadata = try? container.decode(Metadata.self) {
            self = .metadata(metadata)
        } else if let countries = try? container.decode([Country].self) {
            self = .countries(countries)
        } else {
            throw DecodingError.dataCorruptedError(
                in: container,
                debugDescription: "Data doesn't match any expected type")
        }
    }
}
// MARK: - Country
struct Country: Codable, Identifiable {
    let id: String
    let iso2Code: String
    let name: String
    let region: Region
    let adminregion: Region
    let incomeLevel: Region
    let lendingType: Region
    let capitalCity: String
    let longitude: String
    let latitude: String
}
// MARK: - Region
struct Region: Codable {
    let id: String
    let iso2code: String
    let value: String

А это ContentView.swift:

import SwiftUI
struct ContentView: View {
    @State private var countries: [Country] = []
    @State private var errorMessage: String? = nil
    
    var body: some View {
        NavigationView {
            Group {
                if let error = errorMessage {
                    Text(error)
                        .foregroundColor(.red)
                } else {
                    List(countries) { country in
                        VStack(alignment: .leading) {
                            Text(country.name)
                                .font(.headline)
                            Text(country.region.value)
                                .font(.subheadline)
                            if !country.capitalCity.isEmpty {
                                Text("Capital: \(country.capitalCity)")
                                    .font(.caption)
                            }
                        }
                    }
                }
            }
            .navigationTitle("Countries")
            .task {
                do {
                let url = URL(string: 
           "http://api.worldbank.org/v2/country?format=json&per_page=300")!
                let (data, _) = try await URLSession.shared.data(from: url)
                let response = try 
                 JSONDecoder().decode(CountryResponse.self, from: data)
                    
                    // Extract countries from the response
                    if case .countries(let fetchedCountries) = response[1] {
                        self.countries = fetchedCountries
                    }
                } catch {
                    print("Debug error: \(error)")
                    self.errorMessage = error.localizedDescription
                }
            }
        }
    }
}
#Preview {
    ContentView()
}

Я создал комплексное решение для декодирования и отображения данных API Всемирного банка. 

Вот что я сделал:

  1. Создал новый файл CountryModels.swift со всеми необходимыми моделями для декодирования JSON данных, представляющих собой массив, содержащий два элемента: метаданные и массив стран [Country].

Для них я создал:

— структуру struct Metadata: для метаданных
— структуру struct Country:  основную модель для страны, соответствующую протоколу Identifiable с целью использования в SwiftUI списках List
— перечисление enum CountryData для двух различных ТИПов в массиве: метаданных metadata (MetaData) и стран countries([Country])
typealias CountryResponse = [CountryData] как топовый элемент JSON   данных.
— структуру struct Region: многоразовую модель для региона, административной области, уровня дохода и типа кредитования

  1. Обновил файл ContentView.swift с тем, чтобы:

— показать список стран с помощью List
— выполнить асинхронную выборку данных с использованием Swift concurrency— обработать ошибки
— отобразить базовую информации о стране

Модели правильно обрабатывают все ключи JSON, включая необычную структуру массива верхнего уровня, где первый элемент — метаданные, а второй — массив стран (обрабатывается через CodingKeys).

Теперь вы можете выбирать и отображать данные Всемирного банка по странам в интерфейсе SwiftUI.

Удивительно изворотливая Модель данных для отображения нашей нестандартной JSON структуры с использованием перечисления enum.

Браво!!!

Подобное решение можно встретить здесь.

Ссылки:

Concurrency Step-by-Step: A Network Request

Многопоточность по шагам:  Сетевой запрос. (русский перевод вышеуказанной статьи).

Codable conformance for Swift enums

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

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