- Published on
MVVM — SwiftUI uchun eng mos pattern
- Authors
- Name
- ShoxruxC
- @iOSdasturchi
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
@Publishedorqali 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
| # | Qoida | Sababi |
|---|---|---|
| 1 | ViewModel import SwiftUI qilmasin | UI dan mustaqillik — unit test uchun |
| 2 | View da biznes logika yo'q | body ichida faqat UI tuzilmasi |
| 3 | Model oddiy struct bo'lsin | Codable, Identifiable — sof ma'lumot |
| 4 | ViewModel ObservableObject bo'lsin | @Published orqali reaktiv yangilanish |
| 5 | Bitta ViewModel bitta ekran uchun | Mas'uliyat ajratish — aniq chegaralar |
| 6 | Computed property — View uchun tayyor | ViewModel 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:
VazifaControllerniVazifaViewModelga aylantiringfiltrlangancomputed property qo'shing (3 xil filter)bajarilganSonivafoizcomputed property qo'shing- ViewModel ga
import SwiftUIqo'ymang — faqat Foundation - Kamida 3 ta unit test yozing — ViewModel ni UI siz test qiling
- View da hech qanday logika bo'lmasin — faqat ViewModel chaqirish