Azimjon Abdurasulov

14. [Demo Medium] CryptoTracker (Multi-Target, Interface/Impl & Kesh)

Arxitektura

SPM bilan CryptoTracker — [Demo: Medium]

Qiyinlik darajasi: O'rta (Medium) Texnologiyalar: Multi-Target SPM Package, Interface vs Implementation ajratilishi, Swift 6 package Access Modifier, Tashqi SPM qaramliklar, Kesh qatlami, Mock Testing Target.


1. Loyihaning Arxitektura Rejasi

O'rta darajadagi ushbu loyihada biz kriptovalyutalar kursini real vaqt rejimida kuzatuvchi CryptoTracker ilovasi uchun ko'p targetli (Multi-Target) SPM modulini quramiz.

Katta loyihalardagi eng asosiy muammo — tarmoq so'rovlari yoki ma'lumotlar bazasi o'zgarganda butun UI qayta kompilyatsiya bo'lishidir. Buni oldini olish uchun biz paketni 4 ta alohida targetga bo'lamiz:

CryptoTrackerPackage/
├── Package.swift
├── Sources/
│   ├── CryptoTrackerInterface/    # 1. Sof Shartnoma (DTO modellar & Protocols)
│   │   ├── CoinDTO.swift
│   │   └── CryptoRepositoryProtocol.swift
│   │
│   ├── CryptoTrackerCore/         # 2. Tarmoq va Kesh Logikasi (Implementation)
│   │   ├── API/
│   │   │   └── CryptoAPIService.swift
│   │   └── Cache/
│   │       └── CryptoLocalCache.swift
│   │
│   ├── CryptoTrackerFeature/      # 3. SwiftUI Ekranlari & ViewModel
│   │   ├── Views/
│   │   │   ├── CoinListView.swift
│   │   │   └── CoinRowView.swift
│   │   └── ViewModels/
│   │       └── CryptoListViewModel.swift
│   │
│   └── CryptoTrackerTesting/      # 4. Mock va Stublar (Faqat testlar uchun)
│       └── MockCryptoRepository.swift
│
└── Tests/
    └── CryptoTrackerFeatureTests/
        └── CryptoListViewModelTests.swift

Targetlar o'rtasidagi munosabat (DAG - Directed Acyclic Graph):

<div style="margin: 1.5rem 0; overflow-x: auto;"> <svg viewBox="0 0 840 450" width="100%" style="background: rgba(10, 14, 23, 0.85); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 14px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;"> <defs> <linearGradient id="dagFeatureGrad" x1="0" y1="0" x2="1" y2="1"> <stop offset="0%" stop-color="#0284c7" stop-opacity="0.3"/> <stop offset="100%" stop-color="#0369a1" stop-opacity="0.55"/> </linearGradient> <linearGradient id="dagInterfaceGrad" x1="0" y1="0" x2="1" y2="1"> <stop offset="0%" stop-color="#059669" stop-opacity="0.3"/> <stop offset="100%" stop-color="#047857" stop-opacity="0.55"/> </linearGradient> <linearGradient id="dagCoreGrad" x1="0" y1="0" x2="1" y2="1"> <stop offset="0%" stop-color="#d97706" stop-opacity="0.3"/> <stop offset="100%" stop-color="#b45309" stop-opacity="0.55"/> </linearGradient> <linearGradient id="dagTestGrad" x1="0" y1="0" x2="1" y2="1"> <stop offset="0%" stop-color="#7c3aed" stop-opacity="0.3"/> <stop offset="100%" stop-color="#6d28d9" stop-opacity="0.55"/> </linearGradient> <marker id="dagArrowBlue" viewBox="0 0 10 10" refX="6" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"> <path d="M 0 1 L 8 5 L 0 9 z" fill="#38bdf8"/> </marker> <marker id="dagArrowGreen" viewBox="0 0 10 10" refX="6" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"> <path d="M 0 1 L 8 5 L 0 9 z" fill="#34d399"/> </marker> <marker id="dagArrowAmber" viewBox="0 0 10 10" refX="6" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"> <path d="M 0 1 L 8 5 L 0 9 z" fill="#fbbf24"/> </marker> <marker id="dagArrowPurple" viewBox="0 0 10 10" refX="6" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"> <path d="M 0 1 L 8 5 L 0 9 z" fill="#a78bfa"/> </marker> </defs> <pattern id="dagGridDots" width="20" height="20" patternUnits="userSpaceOnUse"> <circle cx="2" cy="2" r="1" fill="#ffffff" fill-opacity="0.04"/> </pattern> <rect width="840" height="450" fill="url(#dagGridDots)"/> <g transform="translate(240, 25)"> <rect width="360" height="85" rx="10" fill="url(#dagFeatureGrad)" stroke="#38bdf8" stroke-width="2"/> <rect x="14" y="10" width="105" height="18" rx="4" fill="#0284c7"/> <text x="66" y="23" text-anchor="middle" fill="#ffffff" font-size="9" font-weight="700" letter-spacing="0.5">FEATURE TARGET</text> <text x="14" y="48" fill="#ffffff" font-size="16" font-weight="700">CryptoTrackerFeature</text> <text x="14" y="66" fill="#bae6fd" font-size="11">SwiftUI Views • ViewModels • UI State</text> <circle cx="335" cy="42" r="8" fill="#0284c7" fill-opacity="0.4"/> <text x="335" y="46" text-anchor="middle" fill="#38bdf8" font-size="11">UI</text> </g> <path d="M 360 110 L 360 170" stroke="#34d399" stroke-width="2" fill="none" marker-end="url(#dagArrowGreen)"/> <rect x="250" y="125" width="155" height="22" rx="4" fill="#064e3b" stroke="#059669" stroke-width="1"/> <text x="327" y="140" text-anchor="middle" fill="#a7f3d0" font-size="9.5" font-weight="600">Faqat interfeysga bog'liq</text> <path d="M 480 110 L 480 145 L 670 145 L 670 170" stroke="#fbbf24" stroke-width="2" stroke-dasharray="4 4" fill="none" marker-end="url(#dagArrowAmber)"/> <rect x="520" y="134" width="115" height="22" rx="4" fill="#451a03" stroke="#d97706" stroke-width="1"/> <text x="577" y="149" text-anchor="middle" fill="#fde68a" font-size="9.5" font-weight="600">DI orqali ulanadi</text> <g transform="translate(30, 180)"> <rect width="210" height="100" rx="10" fill="url(#dagTestGrad)" stroke="#a78bfa" stroke-width="1.8"/> <rect x="12" y="10" width="100" height="18" rx="4" fill="#7c3aed"/> <text x="62" y="23" text-anchor="middle" fill="#ffffff" font-size="8.5" font-weight="700">TESTING TARGET</text> <text x="12" y="48" fill="#ffffff" font-size="13.5" font-weight="700">CryptoTrackerTesting</text> <text x="12" y="66" fill="#ddd6fe" font-size="10.5">MockCryptoRepository</text> <text x="12" y="82" fill="#c4b5fd" font-size="10">Kompilyatsiya: ~0.05s</text> </g> <path d="M 240 230 L 290 230" stroke="#a78bfa" stroke-width="2" fill="none" marker-end="url(#dagArrowPurple)"/> <g transform="translate(300, 175)"> <rect width="240" height="110" rx="10" fill="url(#dagInterfaceGrad)" stroke="#34d399" stroke-width="2"/> <rect x="14" y="10" width="145" height="18" rx="4" fill="#059669"/> <text x="86" y="23" text-anchor="middle" fill="#ffffff" font-size="8.5" font-weight="700">INTERFACE (ZERO DEPS)</text> <text x="14" y="48" fill="#ffffff" font-size="14" font-weight="700">CryptoTrackerInterface</text> <text x="14" y="67" fill="#a7f3d0" font-size="10.5">CryptoRepositoryProtocol</text> <text x="14" y="82" fill="#a7f3d0" font-size="10.5">CryptoCoin • CryptoPrice</text> <text x="14" y="98" fill="#6ee7b7" font-size="9.5" font-weight="600">⚡️ O'zgarmas shartnoma</text> </g> <path d="M 600 230 L 550 230" stroke="#34d399" stroke-width="2" fill="none" marker-end="url(#dagArrowGreen)"/> <g transform="translate(600, 180)"> <rect width="210" height="100" rx="10" fill="url(#dagCoreGrad)" stroke="#fbbf24" stroke-width="1.8"/> <rect x="12" y="10" width="90" height="18" rx="4" fill="#d97706"/> <text x="57" y="23" text-anchor="middle" fill="#ffffff" font-size="8.5" font-weight="700">CORE TARGET</text> <text x="12" y="48" fill="#ffffff" font-size="14" font-weight="700">CryptoTrackerCore</text> <text x="12" y="66" fill="#fde68a" font-size="10.5">URLSession • Network</text> <text x="12" y="82" fill="#fde68a" font-size="10.5">LiveWebSocket • Storage</text> </g> <g transform="translate(30, 320)"> <rect width="780" height="105" rx="10" fill="rgba(15, 23, 42, 0.9)" stroke="rgba(255, 255, 255, 0.12)" stroke-width="1"/> <text x="20" y="28" fill="#38bdf8" font-size="12" font-weight="700">DAG Arxitekturasining 3 ta Asosiy Foydasi:</text> <circle cx="28" cy="50" r="4" fill="#34d399"/> <text x="42" y="54" fill="#e2e8f0" font-size="11.5"><tspan font-weight="700" fill="#34d399">Nol Kaskad Qayta Build:</tspan> Core modulidagi URL yoki API o'zgarishi Feature yoki Test modulini qayta kompilyatsiya qildirMAYDI.</text> <circle cx="28" cy="72" r="4" fill="#a78bfa"/> <text x="42" y="76" fill="#e2e8f0" font-size="11.5"><tspan font-weight="700" fill="#a78bfa">Tezkor Unit Testlar:</tspan> Testlar CryptoTrackerCore tarmog'iga ulanmaydi, MockCryptoRepository orqali &lt; 0.05 soniyada tugaydi.</text> <circle cx="28" cy="94" r="4" fill="#38bdf8"/> <text x="42" y="98" fill="#e2e8f0" font-size="11.5"><tspan font-weight="700" fill="#38bdf8">Parallel Jamoaviy Ish:</tspan> UI jamoasi (Feature) va Backend/SDK jamoasi (Core) bitta Interface shartnomasi asosida bir vaqtda mustaqil ishlay oladi.</text> </g> </svg> </div>


2. Ko'p Targetli Package.swift Manifesti

Ushbu manifestda bitta SPM to'plami ichida qanday qilib 4 ta mustaqil target yaratish va ularning o'zaro bog'liqliklarini chegaralash ko'rsatilgan:

// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "CryptoTrackerPackage",
    platforms: [
        .iOS(.v17),
        .macOS(.v14)
    ],
    products: [
        // Asosiy ilova UI uchun faqat Feature kutubxonasini import qiladi
        .library(name: "CryptoTrackerFeature", targets: ["CryptoTrackerFeature"]),
        
        // Bog'lanishlar uchun Interface
        .library(name: "CryptoTrackerInterface", targets: ["CryptoTrackerInterface"]),
        
        // Haqiqiy implementatsiya (DI konteyneri uchun)
        .library(name: "CryptoTrackerCore", targets: ["CryptoTrackerCore"]),
        
        // Testlar uchun mocklar kutubxonasi
        .library(name: "CryptoTrackerTesting", targets: ["CryptoTrackerTesting"])
    ],
    dependencies: [
        // Tashqi rasmiy Apple algoritmlari (masalan, swift-collections)
        .package(url: "https://github.com/apple/swift-collections.git", .upToNextMajor(from: "1.1.0"))
    ],
    targets: [
        // ── TARGET 1: INTERFACE (Hech narsaga bog'liq emas!) ──
        .target(
            name: "CryptoTrackerInterface",
            dependencies: [],
            swiftSettings: [
                .enableUpcomingFeature("StrictConcurrency"),
                .swiftLanguageMode(.v6)
            ]
        ),

        // ── TARGET 2: CORE (API va Kesh) ──
        .target(
            name: "CryptoTrackerCore",
            dependencies: [
                "CryptoTrackerInterface",
                .product(name: "OrderedCollections", package: "swift-collections")
            ],
            swiftSettings: [
                .enableUpcomingFeature("StrictConcurrency"),
                .swiftLanguageMode(.v6)
            ]
        ),

        // ── TARGET 3: FEATURE (UI & ViewModel) ──
        .target(
            name: "CryptoTrackerFeature",
            dependencies: [
                "CryptoTrackerInterface" // DIQQAT: Core'ga emas, Interface'ga bog'langan!
            ],
            swiftSettings: [
                .enableUpcomingFeature("StrictConcurrency"),
                .swiftLanguageMode(.v6)
            ]
        ),

        // ── TARGET 4: TESTING HELPERS (Mocklar) ──
        .target(
            name: "CryptoTrackerTesting",
            dependencies: [
                "CryptoTrackerInterface"
            ],
            swiftSettings: [
                .enableUpcomingFeature("StrictConcurrency"),
                .swiftLanguageMode(.v6)
            ]
        ),

        // ── TARGET 5: UNIT TESTS ──
        .testTarget(
            name: "CryptoTrackerFeatureTests",
            dependencies: [
                "CryptoTrackerFeature",
                "CryptoTrackerTesting"
            ]
        )
    ]
)

3. Kodlarni Yozish: Qadam-baqadam

3.1. Target 1: CryptoTrackerInterface

Ushbu modulda faqat biznes shartnomalari (Contracts) joylashadi:

// Sources/CryptoTrackerInterface/CoinDTO.swift
import Foundation

public struct CoinDTO: Identifiable, Sendable, Codable, Equatable {
    public let id: String
    public let symbol: String
    public let name: String
    public let priceUSD: Decimal
    public let change24h: Double
    
    public init(id: String, symbol: String, name: String, priceUSD: Decimal, change24h: Double) {
        self.id = id
        self.symbol = symbol
        self.name = name
        self.priceUSD = priceUSD
        self.change24h = change24h
    }
}
// Sources/CryptoTrackerInterface/CryptoRepositoryProtocol.swift
import Foundation

public protocol CryptoRepositoryProtocol: Sendable {
    /// Barcha tangalar ro'yxatini olish (tarmoqdan yoki keshdan)
    func fetchTopCoins(forceRefresh: Bool) async throws -> [CoinDTO]
    
    /// Bitta tanga haqida batafsil ma'lumot
    func fetchCoinDetails(id: String) async throws -> CoinDTO
}

3.2. Target 2: CryptoTrackerCore

Ushbu modulda haqiqiy tarmoq so'rovlari va lokal kesh mexanizmi yoziladi:

// Sources/CryptoTrackerCore/Cache/CryptoLocalCache.swift
import Foundation
import CryptoTrackerInterface

// Swift 6 'package' modifikatori: faqat shu paket ichidagi boshqa targetlar ko'radi!
package actor CryptoLocalCache {
    private var inMemoryCache: [String: CoinDTO] = [:]
    
    package init() {}
    
    package func save(coins: [CoinDTO]) {
        for coin in coins {
            inMemoryCache[coin.id] = coin
        }
    }
    
    package func getAll() -> [CoinDTO] {
        Array(inMemoryCache.values)
    }
}
// Sources/CryptoTrackerCore/API/RealCryptoRepository.swift
import Foundation
import CryptoTrackerInterface

public final class RealCryptoRepository: CryptoRepositoryProtocol {
    private let cache = CryptoLocalCache()
    private let session: URLSession
    
    public init(session: URLSession = .shared) {
        self.session = session
    }
    
    public func fetchTopCoins(forceRefresh: Bool) async throws -> [CoinDTO] {
        if !forceRefresh {
            let cached = await cache.getAll()
            if !cached.isEmpty {
                return cached
            }
        }
        
        // Haqiqiy CoinGecko yoki Binance API URL
        guard let url = URL(string: "https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd") else {
            throw URLError(.badURL)
        }
        
        let (data, response) = try await session.data(from: url)
        guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
            throw URLError(.badServerResponse)
        }
        
        // JSON parsing... (Demo sifatida statik DTO qaytarish namunasi)
        let coins = [
            CoinDTO(id: "bitcoin", symbol: "BTC", name: "Bitcoin", priceUSD: 68450.00, change24h: 3.45),
            CoinDTO(id: "ethereum", symbol: "ETH", name: "Ethereum", priceUSD: 3620.50, change24h: -1.20),
            CoinDTO(id: "solana", symbol: "SOL", name: "Solana", priceUSD: 182.30, change24h: 8.75)
        ]
        
        await cache.save(coins: coins)
        return coins
    }
    
    public func fetchCoinDetails(id: String) async throws -> CoinDTO {
        let all = try await fetchTopCoins(forceRefresh: false)
        guard let coin = all.first(where: { $0.id == id }) else {
            throw URLError(.resourceUnavailable)
        }
        return coin
    }
}

3.3. Target 3: CryptoTrackerFeature (SwiftUI & ViewModel)

Bu yerda eng muhim qoida: CryptoTrackerFeature o'z ichida CryptoTrackerCore ni import qilmaydi! U faqat CryptoTrackerInterface bilan muloqot qiladi:

// Sources/CryptoTrackerFeature/ViewModels/CryptoListViewModel.swift
import SwiftUI
import Observation
import CryptoTrackerInterface // Faqat interfeys!

@Observable
@MainActor
public final class CryptoListViewModel {
    public private(set) var coins: [CoinDTO] = []
    public private(set) var isLoading: Bool = false
    public var errorMessage: String? = nil
    
    // Protokol orqali ineksiya qilinadi
    private let repository: any CryptoRepositoryProtocol
    
    public init(repository: any CryptoRepositoryProtocol) {
        self.repository = repository
    }
    
    public func loadCoins(forceRefresh: Bool = false) async {
        isLoading = true
        errorMessage = nil
        do {
            self.coins = try await repository.fetchTopCoins(forceRefresh: forceRefresh)
        } catch {
            self.errorMessage = "Ma'lumotlarni yuklab bo'lmadi: \(error.localizedDescription)"
        }
        isLoading = false
    }
}
// Sources/CryptoTrackerFeature/Views/CoinListView.swift
import SwiftUI
import CryptoTrackerInterface

public struct CoinListView: View {
    @State private var viewModel: CryptoListViewModel
    
    public init(repository: any CryptoRepositoryProtocol) {
        _viewModel = State(initialValue: CryptoListViewModel(repository: repository))
    }
    
    public var body: some View {
        NavigationStack {
            List(viewModel.coins) { coin in
                HStack {
                    VStack(alignment: .leading, spacing: 4) {
                        Text(coin.name).font(.headline)
                        Text(coin.symbol).font(.caption).foregroundColor(.secondary)
                    }
                    Spacer()
                    VStack(alignment: .trailing, spacing: 4) {
                        Text("$\(coin.priceUSD)").font(.headline)
                        Text(String(format: "%+.2f%%", coin.change24h))
                            .font(.caption)
                            .foregroundColor(coin.change24h >= 0 ? .green : .red)
                    }
                }
            }
            .navigationTitle("CryptoTracker (SPM)")
            .refreshable {
                await viewModel.loadCoins(forceRefresh: true)
            }
            .task {
                await viewModel.loadCoins()
            }
        }
    }
}

3.4. Target 4: CryptoTrackerTesting (Mocklar)

// Sources/CryptoTrackerTesting/MockCryptoRepository.swift
import Foundation
import CryptoTrackerInterface

public final class MockCryptoRepository: CryptoRepositoryProtocol, @unchecked Sendable {
    public var stubbedCoins: [CoinDTO] = []
    public var shouldThrowError: Bool = false
    
    public init(stubbedCoins: [CoinDTO] = []) {
        self.stubbedCoins = stubbedCoins
    }
    
    public func fetchTopCoins(forceRefresh: Bool) async throws -> [CoinDTO] {
        if shouldThrowError {
            throw URLError(.notConnectedToInternet)
        }
        return stubbedCoins
    }
    
    public func fetchCoinDetails(id: String) async throws -> CoinDTO {
        if let coin = stubbedCoins.first(where: { $0.id == id }) {
            return coin
        }
        throw URLError(.resourceUnavailable)
    }
}

4. Izolyatsiyalangan Unit Testlar

Tests/CryptoTrackerFeatureTests/CryptoListViewModelTests.swift faylida test yozamiz:

import XCTest
@testable import CryptoTrackerFeature
import CryptoTrackerTesting
import CryptoTrackerInterface

@MainActor
final class CryptoListViewModelTests: XCTestCase {
    
    func testLoadCoins_Success_PopulatesCoins() async {
        // Given: Mock ma'lumotlar bilan tayyorlash
        let mockRepo = MockCryptoRepository(stubbedCoins: [
            CoinDTO(id: "ton", symbol: "TON", name: "Toncoin", priceUSD: 5.40, change24h: 12.4)
        ])
        let vm = CryptoListViewModel(repository: mockRepo)
        
        // When: Yuklash
        await vm.loadCoins()
        
        // Then: Natijani tekshirish
        XCTAssertEqual(vm.coins.count, 1)
        XCTAssertEqual(vm.coins.first?.symbol, "TON")
        XCTAssertNil(vm.errorMessage)
        XCTAssertFalse(vm.isLoading)
    }
    
    func testLoadCoins_Failure_SetsErrorMessage() async {
        // Given: Xatolik qaytaruvchi mock
        let mockRepo = MockCryptoRepository()
        mockRepo.shouldThrowError = true
        let vm = CryptoListViewModel(repository: mockRepo)
        
        // When
        await vm.loadCoins()
        
        // Then
        XCTAssertTrue(vm.coins.isEmpty)
        XCTAssertNotNil(vm.errorMessage)
    }
}

Testni terminalda ishga tushirish:

swift test --filter CryptoTrackerFeatureTests

Natija:

Executed 2 tests, with 0 failures in 0.051 seconds.

5. Asosiy App Targetida Barchasini Birlashtirish (Dependency Injection)

Asosiy iOS ilovangiz (CryptoApp.swift):

import SwiftUI
import CryptoTrackerFeature
import CryptoTrackerCore

@main
struct CryptoApp: App {
    // Haqiqiy repository bitta joyda initsializatsiya qilinadi
    private let repository = RealCryptoRepository()
    
    var body: some Scene {
        WindowGroup {
            CoinListView(repository: repository)
        }
    }
}

6. Medium Demo Xulosasi

MuammoMonolitdaBizning Ko'p Targetli SPM Arxitekturamizda
Tarmoq kodi o'zgarsaButun UI va ViewModel recompile bo'ladiFaqat CryptoTrackerCore qayta yig'iladi (UI keshdan olinadi!)
Testlar tezligiSimulyator kutish bilan 25–40 sekundMockCryptoRepository bilan 0.05 sekund
Kodni yashirishHamma narsa bir-birini ko'radiCryptoLocalCache faqat paket ichiga yashirilgan (package keyword)

Markdown manba

iOS Lessons