Я занимаюсь исследованием того, как можно эффективно создавать 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()
}
Вот ключевые особенности:
- Структуры данных:
- Страны
countries
организованы по категориямcategories
(Европа, Азия, Америка). - У каждой страны
country
есть названиеname
, категорияcategory
, флаг-эмодзиflag
, населениеpopulation
, ВВПgpd
, кодiso2Code
. - Добавлены правильные модели данных для ответов
response
API Всемирного банка
Views
:
- Основной
View
— списокList
с разделамиSections
для каждой категорииcategory
CountryRowView
для строк, показывающее флаг и название страны- Детальное
CountryDetailView
, показывающее дополнительную информацию о стране - Загрузка состояний с индикатором выполнения
isLoading
- Обработка ошибок
errorMessage
с возможностью повтора (Retry
) - Правильное форматирование чисел для населения и ВВП
- Динамическая генерация эмодзи флага из ISO кодов стран
- Архитектура:
- Паттерн 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 Всемирного банка:
- Исправлен парсинг данных (
Model
):
- Обновлена стратегия декодирования JSON для соответствия формату API Всемирного банка с помощью
init(from decoder: Decoder) throws {
- Добавлена правильная обработка ошибок и отладочный вывод данных
- Необязательные поля сделаны действительно
Optional
для предотвращения сбоев декодирования
- Улучшена обработка ошибок (
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 Всемирного банка:
- Обновленные модели данных:
- Добавлены
WorldBankMetadata
для соответствия первому объекту в массивеresponse
- Созданы правильные вложенные структуры для регионов
Region
, административных регионовAdminRegion
, уровней доходаIncomeLevel
и типов кредитованияLendingType
- Обновлён парсинг
response
для обработки формата на основе массива
- Улучшена фильтрация:
- Добавлена фильтрация для удаления агрегированных регионов
Aggregate
- Лучшая обработка
Optional
полей - Более надежная обработка ошибок
- Исправлена стратегия декодирования:
- Теперь правильно обрабатывает формат
response
как двухэлементного массива - Лучшая безопасность ТИПов в процессе декодирования
- Более точные сообщения об ошибках
Ключевые изменения, которые должны исправить проблемы парсинга:
- Правильная обработка формата
response
на основе массива - Правильное декодирование объекта метаданных
- Точное соответствие имен и ТИПов полей
- Лучшая обработка
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 состоят из двух частей:
Metadata (page, pages, per_page, total)
- Список сведений о стране
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)
}
Объяснение
- Data Models: Каждая структура
struct
представляет собой часть JSON структуры. - Пользовательский (“ручной”) инициализатор: У
Response
есть пользовательский (“ручной”) инициализатор для для обработкиunkeyedContainer
контейнера (массиваArray
) в корне. - Декодирование:
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)
Объяснение
- Находим индекс index:
- Используем
firstIndex(where:)
для нахождения индексаindex
страныcountry
, соответствующейiso2CodeFix
.
- Используем
- Изменение структуры struct:
- Так как структуры
struct
являютсяValue
ТИПами, изменение напрямую свойстваpopulation
разрешено при доступе к определенному элементу с использованиемcountries[index].
- Так как структуры
- Обновление свойства:
- Присвойте значение
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...
Преимущества
- Улучшенная читабельность:
Async/await
устраняет необходимость во вложенных замыканиях и явной диспетчеризации потоков.
- Многопоточность:
- Численность населения
population
и ВВПgdp
выбираются одновременно (многопоточно) с повышенной производительностью.
- Численность населения
- 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:
- Изменение протокола
ObservableObject
на новый макрос@Observable
- Выделение
fetchCountries()
в отдельныйprivate
метод, который обрабатывает только логику выборки данных. - Функция
loadCountries()
упростилась и сосредоточилась на управлении состоянием выборкиisLoading
и на ошибкахerrorMessage
Ключевые изменения, делающие все функции выборки nonisolated
, которым не требуется доступ к actor-isolated
состоянию.
- Ко всем функциям выборки и вспомогательным методам добавляем ключевое слово
nonisolated
, теперь они возвращают выбранные значения, не пытаясь обновлять UI. - Модифицируем
fetchCountryDetails
для сбора и прямого возврата обновлений. - Добавлен новый метод
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)")
}
Ключевые моменты:
- Структура Response: Введена новая структура
struct Response
для инкапсуляции какPagination
, так и массива[Country].
Это ключ к упрощению процесса декодирования. - init(from decoder:): Структура
struct Response т
еперь реализует инициализаторinit(from decoder:).
Это позволяет нам настраивать способ декодирования объектаResponse
. - unkeyedContainer(): Внутри инициализатора
init(from decoder:)
используетсяdecoder.unkeyedContainer().
Поскольку корневой уровень JSON — это массив (не словарь), мы используемUnkeyedDecodingContainer
. Этот контейнер позволяет нам декодировать элементы в том порядке, в котором они появляются в массиве - Расшифровка элементов по порядку: Затем мы используем
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 Всемирного банка.
Вот что я сделал:
- Создал новый файл CountryModels.swift со всеми необходимыми моделями для декодирования JSON данных, представляющих собой массив, содержащий два элемента: метаданные и массив стран
[Country].
Для них я создал:
— структуру struct Metadata
: для метаданных
— структуру struct Country
: основную модель для страны, соответствующую протоколу Identifiable
с целью использования в SwiftUI списках List
— перечисление enum CountryData
для двух различных ТИПов в массиве: метаданных metadata (MetaData)
и стран countries([Country])
— typealias CountryResponse = [CountryData]
как топовый элемент JSON данных.
— структуру struct Region:
многоразовую модель для региона, административной области, уровня дохода и типа кредитования
- Обновил файл ContentView.swift с тем, чтобы:
— показать список стран с помощью List
— выполнить асинхронную выборку данных с использованием Swift concurrency— обработать ошибки
— отобразить базовую информации о стране
Модели правильно обрабатывают все ключи JSON, включая необычную структуру массива верхнего уровня, где первый элемент — метаданные, а второй — массив стран (обрабатывается через CodingKeys).
Теперь вы можете выбирать и отображать данные Всемирного банка по странам в интерфейсе SwiftUI.
Удивительно изворотливая Модель данных для отображения нашей нестандартной JSON структуры с использованием перечисления enum
.
Браво!!!
Подобное решение можно встретить здесь.
Ссылки:
Concurrency Step-by-Step: A Network Request
Многопоточность по шагам: Сетевой запрос. (русский перевод вышеуказанной статьи).