Published on

MVVM — SwiftUI uchun eng mos pattern

Authors

MVVM nima va nima uchun eng mashhur?

MVVM (Model-View-ViewModel) — SwiftUI bilan eng tabiiy ishlaydigan arxitektura. Microsoft tomonidan 2005-yilda yaratilgan, lekin SwiftUI ning reaktiv tizimi bilan mukammal mos keldi. Bugungi kunda iOS jamiyatida eng ko'p qo'llaniladigan pattern.

Asosiy g'oya: View va Model orasida ViewModel turadi. ViewModel:

  • Model dan ma'lumot oladi va View uchun tayyorlaydi
  • View dan foydalanuvchi amallarini qabul qilib Model ni yangilaydi
  • @Published orqali View ni avtomatik yangilaydi — vositachi (Controller) kerak emas

MVVM ning uchta qatlami — batafsil

// ═══════════════════════════════════════════════════════════════
//  📦 MODEL QATLAMI — sof ma'lumotlar
//
//  Model — ilovadagi "haqiqat manbai" (source of truth).
//  ✅ Faqat ma'lumot va biznes qoidalari
//  ✅ struct (value type) — xavfsiz, nusxa olinadi
//  ✅ Codable — JSON ga aylantirish oson
//  ✅ Identifiable — SwiftUI ro'yxatlari uchun
//  ❌ ViewModel yoki View haqida hech narsa bilmaydi
//  ❌ UI logika yo'q (format, rang, ikon — bu ViewModel ishi)
// ═══════════════════════════════════════════════════════════════
struct Vazifa: Identifiable, Codable {
    let id: UUID           // Noyob identifikator — List, ForEach uchun
    var sarlavha: String   // Vazifa matni
    var bajarildi: Bool    // Holat — bajarilganmi?

    // Maxsus init — default qiymatlar bilan
    // Har safar UUID() va false yozmaslik uchun
    init(id: UUID = UUID(), sarlavha: String, bajarildi: Bool = false) {
        self.id = id
        self.sarlavha = sarlavha
        self.bajarildi = bajarildi
    }
}


// ═══════════════════════════════════════════════════════════════
//  🧠 VIEWMODEL QATLAMI — Model va View orasidagi ko'prik
//
//  ViewModel — ilovaning "miyasi". U:
//  ✅ Model ma'lumotlarini View uchun tayyorlaydi
//     (formatlash, filtrlash, tartiblash)
//  ✅ Foydalanuvchi amallarini qabul qiladi
//     (qo'shish, o'chirish, o'zgartirish)
//  ✅ ObservableObject — @Published orqali View ni xabardor qiladi
//  ✅ Test yozish mumkin — UI siz ishlaydi
//
//  ❌ import SwiftUI QILMASIN!
//     (Nima uchun? Chunki ViewModel UI dan mustaqil bo'lishi kerak.
//      Agar SwiftUI import qilsangiz — unit test da UI kerak bo'ladi.
//      ViewModel faqat Foundation import qilishi yetarli)
//
//  ❌ View elementlarini saqlamas yoki boshqarmasin
//     (Rang, font, layout — bu View ning ishi)
// ═══════════════════════════════════════════════════════════════
import Foundation  // ✅ SwiftUI emas — faqat Foundation

class VazifaViewModel: ObservableObject {

    // ── @Published xususiyatlar ──
    // Har biri o'zgarganda barcha kuzatuvchi View lar qayta chiziladi
    // Combine framework Publisher — avtomatik signal yuboradi

    @Published var vazifalar: [Vazifa] = []
    // Barcha vazifalar ro'yxati
    // Bu o'zgarganda — List qayta chiziladi

    @Published var yangiVazifaMatni = ""
    // TextField bilan ikki tomonlama bog'langan ($vm.yangiVazifaMatni)
    // Foydalanuvchi yozganda — bu o'zgaradi
    // Biz tozalaganda — TextField bo'shaydi

    @Published var filterTuri: FilterTuri = .hammasi
    // Picker bilan bog'langan — foydalanuvchi filter tanlaydi

    // ── Enum — filter turlari ──
    // CaseIterable — Picker da barcha variantlarni ko'rsatish uchun
    enum FilterTuri: String, CaseIterable {
        case hammasi = "Hammasi"
        case bajarilmagan = "Bajarilmagan"
        case bajarilgan = "Bajarilgan"
    }

    // ── Computed Properties ──
    // View uchun tayyor ma'lumot — har chaqirilganda hisoblanadi
    // Saqlanmaydi — vazifalar yoki filterTuri o'zgarganda
    // avtomatik yangi qiymat beradi

    /// Filtrlangan vazifalar — View ro'yxatda ko'rsatadi
    var filtrlangan: [Vazifa] {
        switch filterTuri {
        case .hammasi:
            return vazifalar
            // Hamma vazifalarni ko'rsatish
        case .bajarilmagan:
            return vazifalar.filter { !$0.bajarildi }
            // filter — shartga mos elementlarni tanlaydi
            // !$0.bajarildi — bajarilMAGANlarni oladi
        case .bajarilgan:
            return vazifalar.filter { $0.bajarildi }
            // Faqat bajarilganlarni oladi
        }
    }

    /// Bajarilganlar soni — progress bar va sarlavha uchun
    var bajarilganSoni: Int {
        vazifalar.filter(\.bajarildi).count
        // \.bajarildi — KeyPath qisqartmasi
        // { $0.bajarildi } bilan bir xil, lekin qisqa
    }

    /// Foiz — ProgressView uchun 0.0...1.0
    var foiz: Double {
        guard !vazifalar.isEmpty else { return 0 }
        // guard — vazifalar bo'sh bo'lsa 0 qaytaramiz
        // aks holda 0 ga bo'lish xatosi bo'ladi
        return Double(bajarilganSoni) / Double(vazifalar.count)
    }

    /// Progress matni — "3/5 bajarildi"
    var progressMatni: String {
        "\(bajarilganSoni)/\(vazifalar.count) bajarildi"
    }

    // ── MARK: Amallar (View chaqiradi) ──
    // Bu funksiyalar View dan chaqiriladi:
    // Button("Qo'shish", action: viewModel.qoshish)
    // .onTapGesture { viewModel.almashish(vazifa) }

    /// Yangi vazifa qo'shish
    func qoshish() {
        // 1. Matnni tozalash — boshi va oxiridagi bo'shliqlarni olib tashlash
        let tozalangan = yangiVazifaMatni.trimmingCharacters(in: .whitespaces)

        // 2. Validatsiya — bo'sh matn qo'shilmasin
        guard !tozalangan.isEmpty else { return }
        // guard — shart bajarilmasa funksiyadan chiqib ketadi

        // 3. Yangi Model yaratish va ro'yxatga qo'shish
        vazifalar.append(Vazifa(sarlavha: tozalangan))
        // @Published o'zgaradi → View qayta chiziladi → ro'yxatda yangi element

        // 4. Matn maydonini tozalash
        yangiVazifaMatni = ""
        // @Published o'zgaradi → TextField bo'shaydi
    }

    /// Vazifa holatini o'zgartirish (bajarildi ↔ bajarilmadi)
    func almashish(_ vazifa: Vazifa) {
        // ID bo'yicha massivda indeksni topish
        guard let index = vazifalar.firstIndex(where: { $0.id == vazifa.id }) else {
            return // Topilmasa — hech narsa (xavfsiz)
        }
        // toggle() — true → false, false → true
        vazifalar[index].bajarildi.toggle()
        // @Published o'zgaradi → View yangilanadi
        // checkmark rangi o'zgaradi, strikethrough paydo bo'ladi
    }

    /// Vazifalarni o'chirish — List swipe-to-delete uchun
    func ochirish(at offsets: IndexSet) {
        // offsets — o'chirilishi kerak bo'lgan indekslar to'plami
        // Lekin offsets FILTRLANGAN ro'yxat indekslari!
        // Shuning uchun asl massivdan ID bo'yicha o'chiramiz
        let filtrlanganRoyxat = self.filtrlangan
        for index in offsets {
            let vazifa = filtrlanganRoyxat[index]
            vazifalar.removeAll { $0.id == vazifa.id }
        }
    }
}


// ═══════════════════════════════════════════════════════════════
//  👁 VIEW QATLAMI — faqat UI ko'rsatish va foydalanuvchi amallari
//
//  View — "yuz". U:
//  ✅ ViewModel dan ma'lumot oladi va ekranda ko'rsatadi
//  ✅ Foydalanuvchi amallarini ViewModel ga uzatadi
//  ✅ @StateObject — ViewModel ni yaratadi va egalaydi
//  ❌ Biznes logika YO'Q — if/else faqat UI uchun
//  ❌ Tarmoq so'rovi YO'Q — bu ViewModel/Service ishi
//  ❌ Ma'lumot formatlash YO'Q — bu ViewModel ishi
//
//  View = deklarativ — "nima ko'rsatish" aytasiz
//  SwiftUI o'zi "qanday ko'rsatish" ni hal qiladi
// ═══════════════════════════════════════════════════════════════
struct VazifaKorinishi: View {
    // @StateObject — ViewModel ni YARATADI va EGALAYDI
    // View qayta chizilganda ViewModel yo'qolmaydi
    // ViewModel ning lifecycle = View ning lifecycle
    //
    // ⚠️ @ObservedObject bilan aralashtirib yubormang!
    // @StateObject → View YARATADI (ota View)
    // @ObservedObject → View QABUL QILADI (bola View)
    @StateObject private var viewModel = VazifaViewModel()

    var body: some View {
        NavigationStack {
            VStack(spacing: 0) {
                // ── FILTER PICKER ──
                // viewModel.filterTuri bilan ikki tomonlama bog'langan
                // Foydalanuvchi segment tanlasa → filterTuri o'zgaradi
                // → filtrlangan computed property yangi natija beradi
                // → List qayta chiziladi
                Picker("Filter", selection: $viewModel.filterTuri) {
                    ForEach(VazifaViewModel.FilterTuri.allCases, id: \.self) {
                        Text($0.rawValue) // "Hammasi", "Bajarilmagan", "Bajarilgan"
                    }
                }
                .pickerStyle(.segmented)
                .padding()

                // ── PROGRESS BAR ──
                // viewModel.foiz — 0.0...1.0 oralig'ida
                ProgressView(value: viewModel.foiz)
                    .padding(.horizontal)

                // ── PROGRESS MATNI ──
                Text(viewModel.progressMatni)
                    .font(.caption)
                    .foregroundStyle(.secondary)

                // ── RO'YXAT ──
                List {
                    // viewModel.filtrlangan — ViewModel tayyorlagan ma'lumot
                    // View o'zi filtrlash logikasini bilmaydi!
                    ForEach(viewModel.filtrlangan) { vazifa in
                        // Har qator uchun alohida komponent
                        // Kodni qayta ishlatish va tozalik uchun
                        VazifaQatori(vazifa: vazifa) {
                            // Closure — bosganda ViewModel ga xabar
                            viewModel.almashish(vazifa)
                        }
                    }
                    .onDelete(perform: viewModel.ochirish)
                    // onDelete — swipe-to-delete
                    // IndexSet ni viewModel.ochirish ga uzatadi
                }
            }
            .toolbar {
                ToolbarItem(placement: .bottomBar) {
                    HStack {
                        // $viewModel.yangiVazifaMatni — ikki tomonlama binding
                        // Foydalanuvchi yozadi → viewModel.yangiVazifaMatni o'zgaradi
                        // viewModel tozalaydi → TextField bo'shaydi
                        TextField("Yangi vazifa", text: $viewModel.yangiVazifaMatni)
                            .textFieldStyle(.roundedBorder)

                        // action: viewModel.qoshish — ViewModel funksiyasini chaqirish
                        Button("Qo'shish", action: viewModel.qoshish)
                            .disabled(viewModel.yangiVazifaMatni
                                .trimmingCharacters(in: .whitespaces).isEmpty)
                            // disabled — bo'sh bo'lsa tugma o'chiq
                    }
                }
            }
            .navigationTitle("Vazifalar")
        }
    }
}


// ═══════════════════════════════════════════════════════════════
//  🧩 QAYTA ISHLATILADIGAN KOMPONENT
//
//  VazifaQatori — bitta vazifa qatorini ko'rsatadi
//  ViewModel haqida bilmaydi! Faqat:
//  - vazifa: Vazifa (ko'rsatish uchun ma'lumot)
//  - almashish: () -> Void (bosganda nima qilish)
//
//  Bu "Dumb Component" deyiladi — logikasi yo'q, faqat UI
//  Istalgan joyda qayta ishlatish mumkin
// ═══════════════════════════════════════════════════════════════
struct VazifaQatori: View {
    let vazifa: Vazifa          // Ko'rsatiladigan ma'lumot
    let almashish: () -> Void   // Bosganda chaqiriladigan closure

    var body: some View {
        HStack {
            Image(systemName: vazifa.bajarildi
                ? "checkmark.circle.fill"  // ✅ yashil to'ldirilgan doira
                : "circle")                 // ⭕ bo'sh doira
                .foregroundStyle(vazifa.bajarildi ? .green : .gray)
                .onTapGesture(perform: almashish)
                // onTapGesture — bosganda almashish() chaqiriladi
                // VazifaQatori nimani almashtirishni bilmaydi!
                // Faqat "bosildi" deydi — ota View hal qiladi

            Text(vazifa.sarlavha)
                .strikethrough(vazifa.bajarildi)
                // strikethrough — chizib tashlash effekti
                .foregroundStyle(vazifa.bajarildi ? .secondary : .primary)
        }
    }
}

MVC vs MVVM diagramma

MVC:                                MVVM:
┌───────┐    ┌──────────────┐       ┌───────┐    ┌──────────────┐
MODEL │◄──►│  CONTROLLER  │       │ MODEL │◄──►│  VIEWMODEL└───────┘    └──────┬───────┘       └───────┘    └──────┬───────┘
                    │                                   │
          Controller boshqaradi              @Published signal
          (vositachi — hamma ish)            (View kuzatadi)
                    │                                   │
                    ▼                                   ▼
             ┌──────────┐                        ┌──────────┐
VIEW   │                        │   VIEW             └──────────┘                        └──────────┘

Asosiy farq:
MVC: Controller hamma narsani boshqaradi → katta bo'lib ketadi
MVVM: View ViewModel ni o'zi kuzatadi → Controller kerak emas
MVVM: ViewModel test qilish oson → import SwiftUI yo'q

MVVM qoidalari

#QoidaSababi
1ViewModel import SwiftUI qilmasinUI dan mustaqillik — unit test uchun
2View da biznes logika yo'qbody ichida faqat UI tuzilmasi
3Model oddiy struct bo'lsinCodable, Identifiable — sof ma'lumot
4ViewModel ObservableObject bo'lsin@Published orqali reaktiv yangilanish
5Bitta ViewModel bitta ekran uchunMas'uliyat ajratish — aniq chegaralar
6Computed property — View uchun tayyorViewModel formatlab beradi, View faqat ko'rsatadi

ViewModel ni unit test qilish

// ═══════════════════════════════════════════════════════════════
//  🧪 UNIT TEST — ViewModel ni UI SIZ tekshirish
//
//  Bu MVVM ning eng katta avzalligi!
//  MVC da Controller UIViewController ga bog'langan — test qiyin
//  MVVM da ViewModel oddiy class — UI siz test oson
// ═══════════════════════════════════════════════════════════════
import XCTest

final class VazifaViewModelTests: XCTestCase {

    // ── Test 1: Vazifa qo'shish ──
    func test_qoshish_yangiVazifaQoshiladi() {
        // Given — tayyorgarlik
        let vm = VazifaViewModel()
        vm.yangiVazifaMatni = "Test vazifa"

        // When — amal bajarish
        vm.qoshish()

        // Then — natijani tekshirish
        XCTAssertEqual(vm.vazifalar.count, 1, "Bitta vazifa bo'lishi kerak")
        XCTAssertEqual(vm.vazifalar.first?.sarlavha, "Test vazifa")
        XCTAssertFalse(vm.vazifalar.first?.bajarildi ?? true,
                       "Yangi vazifa bajarilmagan bo'lishi kerak")
        XCTAssertTrue(vm.yangiVazifaMatni.isEmpty,
                      "Matn maydoni tozalangan bo'lishi kerak")
    }

    // ── Test 2: Bo'sh matn qo'shilmasin ──
    func test_qoshish_boshMatnQoshilmaydi() {
        let vm = VazifaViewModel()
        vm.yangiVazifaMatni = "   " // Faqat bo'shliqlar

        vm.qoshish()

        XCTAssertEqual(vm.vazifalar.count, 0, "Bo'sh vazifa qo'shilmasligi kerak")
    }

    // ── Test 3: Holatni almashish ──
    func test_almashish_holatniToggleQiladi() {
        let vm = VazifaViewModel()
        vm.yangiVazifaMatni = "Vazifa"
        vm.qoshish()

        // Birinchi almashish — bajarildi
        vm.almashish(vm.vazifalar[0])
        XCTAssertTrue(vm.vazifalar[0].bajarildi)

        // Ikkinchi almashish — bajarilmadi
        vm.almashish(vm.vazifalar[0])
        XCTAssertFalse(vm.vazifalar[0].bajarildi)
    }

    // ── Test 4: Filtrlash ──
    func test_filtrlash_bajarilmaganlarniKorsatadi() {
        let vm = VazifaViewModel()

        // 2 ta vazifa qo'shish
        vm.yangiVazifaMatni = "A"
        vm.qoshish()
        vm.yangiVazifaMatni = "B"
        vm.qoshish()

        // "A" ni bajarildi qilish
        vm.almashish(vm.vazifalar[0])

        // Faqat bajarilmaganlarni filtrlash
        vm.filterTuri = .bajarilmagan

        XCTAssertEqual(vm.filtrlangan.count, 1)
        XCTAssertEqual(vm.filtrlangan.first?.sarlavha, "B")
    }

    // ── Test 5: Foiz hisoblash ──
    func test_foiz_togriHisoblaydi() {
        let vm = VazifaViewModel()

        // 4 ta vazifa, 1 tasi bajarildi
        for i in 1...4 {
            vm.yangiVazifaMatni = "Vazifa \(i)"
            vm.qoshish()
        }
        vm.almashish(vm.vazifalar[0]) // 1/4 = 25%

        XCTAssertEqual(vm.foiz, 0.25, accuracy: 0.01)
    }
}

🎯 Topshiriq: MVC dan MVVM ga

Oldingi darsdagi MVC ilovasini MVVM ga o'tkazing:

  1. VazifaController ni VazifaViewModel ga aylantiring
  2. filtrlangan computed property qo'shing (3 xil filter)
  3. bajarilganSoni va foiz computed property qo'shing
  4. ViewModel ga import SwiftUI qo'ymang — faqat Foundation
  5. Kamida 3 ta unit test yozing — ViewModel ni UI siz test qiling
  6. View da hech qanday logika bo'lmasin — faqat ViewModel chaqirish
Buy mea coffee