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
packageAccess 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 < 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
| Muammo | Monolitda | Bizning Ko'p Targetli SPM Arxitekturamizda |
|---|---|---|
| Tarmoq kodi o'zgarsa | Butun UI va ViewModel recompile bo'ladi | Faqat CryptoTrackerCore qayta yig'iladi (UI keshdan olinadi!) |
| Testlar tezligi | Simulyator kutish bilan 25–40 sekund | MockCryptoRepository bilan 0.05 sekund |
| Kodni yashirish | Hamma narsa bir-birini ko'radi | CryptoLocalCache faqat paket ichiga yashirilgan (package keyword) |