Azimjon Abdurasulov

Bazel 14: Loyiha #3: "Ob-havo" ilova — Networking, SPM dependency, resurslar va mock testlar

📦 Bazel iOS — Mukammal Qo‘llanma & Amaliyot

Bazel 14: Loyiha #3: "Ob-havo" ilova — Networking, SPM dependency, resurslar va mock testlar

Holat: 2026-yil oktabr. Ushbu darsda biz uchinchi amaliy loyihamizni quramiz: "Ob-havo" — real REST API (Open-Meteo) dan ma'lumot oluvchi, Asset Catalog (.xcassets) resurslari bilan bezatilgan, tashqi SPM kutubxonalarini Bzlmod orqali integratsiya qilgan va eng muhimi — 100% offline hermetik mock testlar bilan himoyalangan zamonaviy iOS SwiftUI ilovasi. Kod misollari Bazel 9.3.0, rules_apple 5.3.0, rules_swift 4.2.1 va Xcode 27.1'da tekshirilgan.

Bu darsdan keyin siz: Bazel loyihalarida rasmlar, ranglar va Asset Catalog'larni to'g'ri ulashni, asinxron tarmoq qatlamini (Networking) arxitekturaviy ajratishni va CI tizimlarida internet bo'lmasa ham muammosiz o'tadigan deterministik mock testlar yozishni o'rganasiz.

  • Haqiqiy REST API chaqiruvlari uchun async/await asosidagi toza NetworkKit modulini yaratasiz.
  • Resurslarni (Assets.xcassets, AppIcon, SVG/PNG ikonlar) Bazel'da actool orqali to'g'ri kompilyatsiya qilasiz.
  • Hermetik testlash qoidasi: CI'da testlar tashqi internetga bog'lanmasligi uchun URLProtocol orqali tarmoq so'rovlarini tutib qolib (intercept), lokal JSON fixture'lar orqali sinaysiz.
  • Modullarda resurslarga xavfsiz kirish (Bundle.module) qoidalarini amalda tushunasiz.
  • Toshkent va boshqa shaharlar ob-havo ma'lumotlarini chiroyli SwiftUI interfeysida ko'rsatasiz.

Mundarija

  1. Loyiha arxitekturasi: Tarmoq va Resurslar
  2. Loyihaning papkalar tuzilishi
  3. WeatherCore moduli: Ob-havo ma'lumotlar modellari
  4. NetworkKit moduli: Generic HTTP mijoz
  5. Hermetik Mock Testlar: Nega internetga so'rov jo'natilmaydi?
  6. Resurslar va Asset Catalog: WeatherUI moduli
  7. App qatlami va ilovani birlashtirish
  8. Simulyatorda ishga tushirish
  9. Resurslar kompilyatsiyasi (actool) qanday keshlanadi?
  10. Amaliy mashqlar
  11. O'zingizni tekshiring
  12. Xulosa
  13. Manbalar

1. Loyiha arxitekturasi: Tarmoq va Resurslar {#arxitektura}

Mobil ilovalar odatda faqat koddan iborat emas. Unda katta hajmdagi grafik resurslar (ikonlar, rasmlar, ranglar palitrasi) va tashqi API bilan muloqot qiluvchi tarmoq drayveri bo'ladi.

Ko'p jamoalarda uchraydigan 2 ta xato:

  1. Resurslarni aralashtirib yuborish: Rasmlar boshqa modulda bo'lsa-yu, UIImage(named:) ularni topolmay qolishi.
  2. CI'da tarmoqqa so'rov yuborish: Unit testlar ichida real API serverga so'rov jo'natish. Natijada internet uzilsa yoki API sekinlashsa, CI qizilga aylanadi.

Biz bularni to'g'ri hal qilamiz:

Loyiha #3: "Ob-havo" ilovasi arxitekturasi va Hermetik Mock Test oqimi

  • //Core:WeatherCore — sof modellar.
  • //Network:NetworkKit — tarmoq mantiqi + XCTest mock'lar.
  • //UI:WeatherUI — SwiftUI komponentlari + Assets.xcassets.
  • //App:ObHavo — yakuniy ilova.

2. Loyihaning papkalar tuzilishi {#tuzilish}

obhavo-app/
├── MODULE.bazel
├── .bazelrc
├── Core/
│   ├── BUILD.bazel
│   └── Sources/
│       ├── WeatherData.swift
│       └── City.swift
├── Network/
│   ├── BUILD.bazel
│   ├── Sources/
│   │   ├── APIClient.swift
│   │   └── WeatherEndpoint.swift
│   └── Tests/
│       ├── MockURLProtocol.swift
│       ├── NetworkTests.swift
│       └── Fixtures/
│           └── weather_tashkent.json
├── UI/
│   ├── BUILD.bazel
│   ├── Assets.xcassets/
│   │   ├── Contents.json
│   │   ├── sun.imageset/...
│   │   └── rain.imageset/...
│   └── Sources/
│       ├── WeatherCardView.swift
│       └── WeatherDashboardView.swift
└── App/
    ├── BUILD.bazel
    ├── Info.plist
    └── Sources/
        └── ObHavoApp.swift

3. WeatherCore moduli: Ob-havo ma'lumotlar modellari {#core}

Open-Meteo ochiq API (hech qanday API key talab qilmaydi) javobiga mos Swift modellari:

Core/Sources/WeatherData.swift

import Foundation

public struct CurrentWeather: Codable, Equatable, Sendable {
    public let temperature: Double
    public let windSpeed: Double
    public let weatherCode: Int

    public init(temperature: Double, windSpeed: Double, weatherCode: Int) {
        self.temperature = temperature
        self.windSpeed = windSpeed
        self.weatherCode = weatherCode
    }

    public var conditionDescription: String {
        switch weatherCode {
        case 0: return "Ochiq havo"
        case 1, 2, 3: return "Bulutli"
        case 51...67: return "Yomg'ir"
        case 71...77: return "Qor"
        default: return "O'zgaruvchan"
        }
    }

    public var iconName: String {
        switch weatherCode {
        case 0: return "sun.max.fill"
        case 1...3: return "cloud.sun.fill"
        case 51...67: return "cloud.rain.fill"
        case 71...77: return "snowflake"
        default: return "cloud.fill"
        }
    }
}

public struct City: Identifiable, Sendable {
    public let id: String
    public let name: String
    public let latitude: Double
    public let longitude: Double

    public init(id: String, name: String, latitude: Double, longitude: Double) {
        self.id = id
        self.name = name
        self.latitude = latitude
        self.longitude = longitude
    }

    public static let tashkent = City(id: "TAS", name: "Toshkent", latitude: 41.2995, longitude: 69.2401)
    public static let samarkand = City(id: "SKD", name: "Samarqand", latitude: 39.6542, longitude: 66.9597)
    public static let bukhara = City(id: "BHK", name: "Buxoro", latitude: 39.7747, longitude: 64.4286)
}

Core/BUILD.bazel

load("@rules_swift//swift:swift.bzl", "swift_library")

swift_library(
    name = "WeatherCore",
    srcs = glob(["Sources/**/*.swift"]),
    module_name = "WeatherCore",
    visibility = ["//visibility:public"],
)

4. NetworkKit moduli: Generic HTTP mijoz {#network}

Network/Sources/APIClient.swift

import Foundation
import WeatherCore

public enum NetworkError: Error {
    case invalidURL
    case invalidResponse
    case decodingError
}

public protocol WeatherServiceProtocol: Sendable {
    func fetchWeather(for city: City) async throws -> CurrentWeather
}

public final class WeatherAPIClient: WeatherServiceProtocol {
    private let session: URLSession

    public init(session: URLSession = .shared) {
        self.session = session
    }

    public func fetchWeather(for city: City) async throws -> CurrentWeather {
        let urlString = "https://api.open-meteo.com/v1/forecast?latitude=\(city.latitude)&longitude=\(city.longitude)&current_weather=true"
        guard let url = URL(string: urlString) else {
            throw NetworkError.invalidURL
        }

        let (data, response) = try await session.data(from: url)
        guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
            throw NetworkError.invalidResponse
        }

        struct ResponseWrapper: Decodable {
            let current_weather: CurrentWeatherPayload
        }
        struct CurrentWeatherPayload: Decodable {
            let temperature: Double
            let windspeed: Double
            let weathercode: Int
        }

        let decoded = try JSONDecoder().decode(ResponseWrapper.self, from: data)
        return CurrentWeather(
            temperature: decoded.current_weather.temperature,
            windSpeed: decoded.current_weather.windspeed,
            weatherCode: decoded.current_weather.weathercode
        )
    }
}

5. Hermetik Mock Testlar: Nega internetga so'rov jo'natilmaydi? {#mock-tests}

Bazel tamoyiliga ko'ra, har qanday test tashqi muhitdan ajratilgan (hermetik) bo'lishi kerak. Biz URLProtocol yordamida soxta (mock) tarmoq adapteri quramiz.

Network/Tests/MockURLProtocol.swift

import Foundation

public final class MockURLProtocol: URLProtocol {
    public static var mockData: Data?
    public static var mockResponse: HTTPURLResponse?
    public static var mockError: Error?

    public override class func canInit(with request: URLRequest) -> Bool {
        return true // Barcha so'rovlarni o'zimiz ushlaymiz!
    }

    public override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        return request
    }

    public override func startLoading() {
        if let error = MockURLProtocol.mockError {
            client?.urlProtocol(self, didFailWithError: error)
            return
        }

        if let response = MockURLProtocol.mockResponse {
            client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
        }

        if let data = MockURLProtocol.mockData {
            client?.urlProtocol(self, didLoad: data)
        }

        client?.urlProtocolDidFinishLoading(self)
    }

    public override func stopLoading() {}
}

Network/Tests/NetworkTests.swift

import XCTest
@testable import NetworkKit
@testable import WeatherCore

final class NetworkTests: XCTestCase {
    var client: WeatherAPIClient!

    override func setUp() {
        super.setUp()
        let config = URLSessionConfiguration.ephemeral
        config.protocolClasses = [MockURLProtocol.self]
        let session = URLSession(configuration: config)
        client = WeatherAPIClient(session: session)
    }

    func testFetchWeatherSuccess() async throws {
        let fakeJSON = """
        {
            "current_weather": {
                "temperature": 26.5,
                "windspeed": 12.3,
                "weathercode": 0
            }
        }
        """.data(using: .utf8)!

        MockURLProtocol.mockData = fakeJSON
        MockURLProtocol.mockResponse = HTTPURLResponse(
            url: URL(string: "https://api.open-meteo.com")!,
            statusCode: 200,
            httpVersion: nil,
            headerFields: nil
        )

        let weather = try await client.fetchWeather(for: .tashkent)

        XCTAssertEqual(weather.temperature, 26.5)
        XCTAssertEqual(weather.windSpeed, 12.3)
        XCTAssertEqual(weather.conditionDescription, "Ochiq havo")
    }
}

Network/BUILD.bazel

load("@rules_swift//swift:swift.bzl", "swift_library")
load("@rules_apple//apple:ios.bzl", "ios_unit_test")

swift_library(
    name = "NetworkKit",
    srcs = glob(["Sources/**/*.swift"]),
    module_name = "NetworkKit",
    deps = ["//Core:WeatherCore"],
    visibility = ["//visibility:public"],
)

swift_library(
    name = "NetworkTestLib",
    srcs = glob(["Tests/**/*.swift"]),
    deps = [
        ":NetworkKit",
        "//Core:WeatherCore",
    ],
)

ios_unit_test(
    name = "NetworkTests",
    minimum_os_version = "17.0",
    deps = [":NetworkTestLib"],
    visibility = ["//visibility:public"],
)

Testni yugurtirish:

bazel test //Network:NetworkTests

Natija:

//Network:NetworkTests                           PASSED in 0.08s

0.08 soniyada 100% ishonchli va offline!


6. Resurslar va Asset Catalog: WeatherUI moduli {#ui-assets}

Bazel'da Asset Catalog (.xcassets) resurslari swift_library yoki apple_bundle_resources orqali ulanadi.

UI/BUILD.bazel

load("@rules_swift//swift:swift.bzl", "swift_library")

filegroup(
    name = "WeatherAssets",
    srcs = glob(["Assets.xcassets/**"]),
)

swift_library(
    name = "WeatherUI",
    srcs = glob(["Sources/**/*.swift"]),
    data = [":WeatherAssets"],
    module_name = "WeatherUI",
    deps = ["//Core:WeatherCore"],
    visibility = ["//visibility:public"],
)

UI/Sources/WeatherCardView.swift

import SwiftUI
import WeatherCore

public struct WeatherCardView: View {
    public let city: City
    public let weather: CurrentWeather?

    public init(city: City, weather: CurrentWeather?) {
        self.city = city
        self.weather = weather
    }

    public var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack {
                Text(city.name)
                    .font(.title2)
                    .fontWeight(.bold)
                    .foregroundColor(.white)
                Spacer()
                if let weather = weather {
                    Image(systemName: weather.iconName)
                        .font(.title)
                        .foregroundColor(.yellow)
                }
            }

            if let weather = weather {
                HStack(alignment: .firstTextBaseline) {
                    Text("\(Int(weather.temperature))°C")
                        .font(.system(size: 48, weight: .semibold, design: .rounded))
                        .foregroundColor(.white)
                    Spacer()
                    Text(weather.conditionDescription)
                        .font(.headline)
                        .foregroundColor(.white.opacity(0.85))
                }

                HStack {
                    Label("\(weather.windSpeed, specifier: "%.1f") km/soat", systemImage: "wind")
                        .font(.caption)
                        .foregroundColor(.white.opacity(0.8))
                }
            } else {
                ProgressView()
                    .progressViewStyle(CircularProgressViewStyle(tint: .white))
                    .frame(maxWidth: .infinity, alignment: .center)
            }
        }
        .padding(20)
        .background(
            LinearGradient(
                colors: [Color.blue.opacity(0.8), Color.indigo.opacity(0.8)],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
        )
        .cornerRadius(18)
        .shadow(color: .black.opacity(0.15), radius: 8, x: 0, y: 4)
    }
}

7. App qatlami va ilovani birlashtirish {#app}

App/Sources/ObHavoApp.swift

import SwiftUI
import WeatherCore
import NetworkKit
import WeatherUI

@main
struct ObHavoApp: App {
    private let client: WeatherServiceProtocol = WeatherAPIClient()
    @State private var weatherMap: [String: CurrentWeather] = [:]
    private let cities: [City] = [.tashkent, .samarkand, .bukhara]

    var body: some Scene {
        WindowGroup {
            NavigationStack {
                ScrollView {
                    VStack(spacing: 16) {
                        ForEach(cities) { city in
                            WeatherCardView(city: city, weather: weatherMap[city.id])
                        }
                    }
                    .padding()
                }
                .navigationTitle("O'zbekiston Ob-havosi")
                .task {
                    await loadAllWeather()
                }
                .refreshable {
                    await loadAllWeather()
                }
            }
        }
    }

    private func loadAllWeather() async {
        for city in cities {
            if let weather = try? await client.fetchWeather(for: city) {
                weatherMap[city.id] = weather
            }
        }
    }
}

App/BUILD.bazel

load("@rules_swift//swift:swift.bzl", "swift_library")
load("@rules_apple//apple:ios.bzl", "ios_application")

swift_library(
    name = "AppLib",
    srcs = ["Sources/ObHavoApp.swift"],
    deps = [
        "//Core:WeatherCore",
        "//Network:NetworkKit",
        "//UI:WeatherUI",
    ],
)

ios_application(
    name = "ObHavo",
    bundle_id = "uz.azimjondev.obhavo",
    families = ["iphone", "ipad"],
    infoplists = ["Info.plist"],
    minimum_os_version = "17.0",
    deps = [":AppLib"],
    visibility = ["//visibility:public"],
)

8. Simulyatorda ishga tushirish {#simulyator}

# Ilovani qurish va simulyatorga ochish
bazel run //App:ObHavo

Simulyatorda Toshkent, Samarqand va Buxoro uchun real vaqtdagi ob-havo kartalari paydo bo'ladi!


9. Resurslar kompilyatsiyasi (actool) qanday keshlanadi? {#actool-cache}

Xcode loyihalarida Asset Catalog keshlanishi ko'pincha nosoz ishlaydi va kichik rasm o'zgarganda butun loyiha clean qilinishi talab qilinadi.

Bazel'da esa actool alohida deterministik action sifatida ishlaydi:

  • Kirish: Assets.xcassets ichidagi fayllarning SHA-256 xesh qiymatlari.
  • Chiqish: Assets.car arxivi.
  • Agar siz .swift kodini o'zgartirsangiz, actool qayta ishlamaydi (Action Cache Hit).
  • Agar faqat bitta .png rasm qo'shilsa, faqat actool ishlaydi, NetworkKit yoki Core tegilmaydi.

Amaliy mashqlar {#mashqlar}

  1. 5 kunlik prognoz qo'shing. WeatherData.swiftga kelgusi kunlar ro'yxatini saqlovchi model qo'shing va Open-Meteo'ning daily=temperature_2m_max,temperature_2m_min parametrini integratsiya qiling.
  2. Xato holati (Error State) mock testi. NetworkTests.swiftda 500 status kodi kelganda ilova to'g'ri NetworkError.invalidResponse otishini tekshiruvchi test yozing.
  3. ⭐ App Icon qo'shing. Assets.xcassets/AppIcon.appiconset papkasini yaratib, ios_application qoidasida app_icons = ["//UI:AppIcon"] atributini bog'lang va ilovani simulyatorda yangi ikonka bilan ko'ring.

O'zingizni tekshiring {#checklist}

  • [ ] CI testlarida real tarmoqqa so'rov yuborish nega yomon ekanini va URLProtocol qanday qutqarishini bilaman.
  • [ ] Asset Catalog (.xcassets) resurslarini Bazel'da data = [":WeatherAssets"] orqali ulashni tushunaman.
  • [ ] WeatherServiceProtocol yordamida mock va real tarmoqni almashtirish mumkinligini bilaman.
  • [ ] actool action keshlanishi tufayli resurslar har safar qayta yig'ilmasligini tushunaman.

Xulosa {#xulosa}

  • Biz tashqi tarmoq bilan ishlovchi, resurslarga boy haqiqiy iOS ilovani qurdik.
  • Hermetik mock testlar yordamida testlarni bir soniyadan kam vaqtda va internetsiz yugurtirishga erishdik.
  • Endi navbatdagi bosqich — enterprise darajadagi eng katta chaqiriq: 12 modulli SuperApp va jamoaviy Remote Cache!
  • Keyingi dars: Bazel 15: Loyiha #4: 12 modulli "SuperApp" va jamoaviy remote cache

Manbalar {#manbalar}

  1. Open-Meteo Documentation — Bepul ochiq ob-havo REST API.
  2. Apple Documentation — URLProtocol — Tarmoq so'rovlarini tutib qolish va testlash.
  3. rules_apple Asset Catalog Handling — Resurslarni bundle qilish qoidalari.

Markdown manba

iOS Lessons