شرح MVVM + Clean Architecture

اذا كنت مطور SwiftUI فمن المحتمل أنك استخدمت MVVM في أحد مشاريعك أو على الأقل سمعت به كثيراً. ومع أن MVVM يعتبر من أكثر الأنماط انتشاراً بين مطوري iOS

إلا أنك ستلاحظ أن العديد من المشاريع الكبيرة لا تكتفي به وحده، بل تضيف طبقات أخرى تعرف بـ Clean Architecture

في البداية قد يبدو الأمر وكأنه تعقيد إضافي أو مجرد زيادة في عدد الملفات، لكن مع نمو حجم التطبيق وازدياد عدد الشاشات والخدمات والمنطق البرمجي، تبدأ الفوائد الحقيقية بالظهور

في هذا المقال سنتعرف على كيفية دمج MVVM مع Clean Architecture، وما الدور الذي تؤديه كل طبقة، ولماذا تعتمد عليه الكثير من الفرق والمشاريع الاحترافية لبناء تطبيقات أكثر تنظيماً وقابلية للتوسع والصيانة

لماذا MVVM لحاله لا يكفي وحده في المشاريع الكبيره؟

في MVVM، يمثل الـ ViewModel المحور الاساسي فهو يستقبل تفاعلات المستخدم من الـ View، ويتواصل مع الخدمات الخارجية لمعالجة البيانات. في التطبيقات الصغيرة، يعمل هذا النموذج بشكل رائع

لكن مع نمو التطبيق وتعدد الميزات، يبدأ الـ ViewModel بالتضخم بشكل متسارع ليتحول إلى ما يُعرف بـ Massive View Model

 ستجده فجأة مسؤولاً عن طلبات الشبكة و الـ Business Logic، وتجهيز البيانات لعرضها. هذا التداخل يجعل الكود معقداً، ويقلل من قابلية إعادة استخدامه

والأهم من ذلك يجعل كتابة اختبارات Unit Tests أمراً صعبا ومعقداً

هنا يأتي دور Clean Architecture لإعادة هيكلة المسؤوليات وتوزيعها بشكل منطقي

فكرة Clean Architecture وهيكلة الطبقات

يعتمد الـ Clean Architecture على مبدأ فصل الاهتمامات ، فالفكرة الأساسية هي تقسيم التطبيق إلى طبقات معزولة

بحيث تكون الطبقات الداخلية (التي تحتوي على منطق العمل الأساسي) مستقلة تماماً عن الطبقات الخارجية (مثل واجهات المستخدم وقواعد البيانات)

شرح مفهوم الـ Clean Architecture مع MVVM

 

الـ Clean Architecture مع MVVM في مشاريع SwiftUI تقسم المشروع إلى ثلاث طبقات رئيسية:

 

1- طبقة الـ Domain Layer

تعتبر هذه الطبقة عقل التطبيق والمسؤولة عن القواعد والمنطق الأساسي للتطبيق

المميز فيها أنها لا تعرف من أين تأتي البيانات ولا تهتم إذا كانت من الإنترنت أو قاعدة بيانات

كل ما يهمها هو تنفيذ منطق العمل بشكل صحيح

وتنقسم لعدة اقسام

  • Entities

وهي الـ Models التي تمثل البيانات الأساسية للتطبيق والتي يتم استخدامها داخل طبقة Domain بعيداً عن تفاصيل الـ API أو قواعد البيانات

  • Use Cases

هي العمليات أو المهام التي يستطيع التطبيق تنفيذها وكل Use Case يكون مسؤول عن مهمة واحدة فقط
الغرض منها هو فصل المنطق عن الواجهة وعن مصادر البيانات بحيث يصبح الكود أكثر تنظيما واسهل في الاختبار

  • Repositories

هي عباره عن Protocols تحدد البيانات والعمليات التي تحتاجها طبقة Domain دون معرفة كيفية تنفيذها

2- طبقة الـ Data Layer

هذه الطبقة مسؤولة عن التعامل مع البيانات داخل التطبيق سواء من حيث جلبها أو حفظها أو تحديثها وهي تعبر الحلقة التي توصل طبقة الـ Domain بمصادر البيانات المختلفة دون أن تحتاج الطبقات الأخرى لمعرفة تفاصيل التنفيذ

وتنقسم لعدة أقسام

  • DTOs (Data Transfer Objects)

وهي نماذج مخصصة للتعامل مع البيانات القادمة من مصادر البيانات المختلفة مثل الـ API أو قواعد البيانات

وتكون مسؤولة عن تمثيل البيانات بالشكل الذي يصل من المصدر ثم تحويلها إلى Entities داخل طبقة الـ Domain ليتم استخدامها داخل التطبيق

أهميتها تكون في عزل التطبيق عن التغيرات التي قد تحدث في مصادر البيانات ففي حال تم تغيير هيكل البيانات القادم من الـ API، فقط سوف تحتاج الى تعديل الـ DTO فقط دون الحاجة الى تعديل الـ Entities

  • Data Sources

هي المكونات المسؤولة عن التواصل المباشر مع مصادر البيانات المختلفة وقراءة البيانات أو حفظها

وتحتوي على أكواد Networking للتعامل مع الـ APIs، وأكواد التخزين المحلي مع قواعد البيانات مثل SwiftData و CoreData وغيرها

  • Repositories

وهي المسؤولة عن تنفيذ الـ Protocols الموجودة في طبقة الـ Domain وتوفير البيانات المطلوبة للتطبيق

تعتبر حلقة الوصل بين طبقة الـ Domain ومصادر البيانات المختلفة، حيث تقوم بالاعتماد على الـ Data Sources لجلب البيانات أو حفظها ثم إرجاعها بالشكل المناسب لطبقة الـ Domain

كما أنها تخفي تفاصيل مصدر البيانات عن بقية أجزاء التطبيق، لذلك لا تحتاج طبقة الـ Domain لمعرفة ما إذا كانت البيانات قادمة من API أو قاعدة بيانات محلية أو أي مصدر آخر

3. طبقة الـ Presentation Layer

وهي الطبقة المسؤولة عن كل ما يراه المستخدم ويتفاعل معه

في تطبيقات SwiftUI غالباً يتم تطبيق الـ MVVM داخل طبقة Presentation Layer

وتنقسم الى

  • View 

هي المسؤولة عن عرض البيانات ورسم الواجهة واستقبال تفاعلات المستخدم

  • ViewModel

وهي حلقة الوصل بين الـ View وطبقة الـ Domain

تستقبل الأحداث والطلبات القادمة من الـ View، ثم تستدعي الـ Use Cases المناسبة في طبقة الـ Domain، وبعد الحصول على النتيجة تقوم بتحديث الـ State ليتم عكس التغييرات تلقائياً على الواجهة

كما أنها مسؤولة عن تجهيز البيانات بالشكل المناسب للعرض وإدارة حالات الشاشة المختلفة مثل التحميل ونجاح العملية والأخطاء


عند تفاعل المستخدم مع الواجهة، ينتقل الطلب أولاً إلى الـ ViewModel. بعد ذلك يستدعي الـ ViewModel الـ Use Case المناسب داخل طبقة الـ Domain، والذي بدوره يتواصل مع الـ Repository للحصول على البيانات المطلوبة. يقوم الـ Repository بالتعامل مع الـ Data Sources لجلب البيانات، ثم تعود النتيجة مرة أخرى إلى الـ ViewModel ليتم تحديث الواجهة وعرض البيانات للمستخدم


تطبيق عملي

بعد أن تعرفنا على الطبقات المختلفة ودور كل طبقة، حان الوقت لرؤية كيفية تطبيق هذه المفاهيم في المشروع

في هذا المشروع سوف نعتمد على API محاني يدعى Jsonplaceholder

وتحديدا هذا الـ API الذي يحتوي على مقالات عشوائية

https://jsonplaceholder.typicode.com/posts

قبل أن نبدأ قم بعمل مجلدات بهذا الشكل

الان نبدأ في التطبيق

 طبقة الـ Domain

 

Entities

قم بإنشاء ملف Swift بإسم Post في داخل مجلد Entities

ثم اكتب هذا الكود

import Foundation

struct Post: Identifiable, Hashable {
let userId: Int
let id: Int
let title: String
let body: String
}

تذكر أن الـ Entity هو الـ Model الأساسي الذي يستخدم داخل التطبيق وطبقة الـ Domain وليس الـ Model الذي يمثل شكل البيانات القادمة من الـ API

Repositories

قم بإنشاء ملف Swift بإسم PostRepository في داخل مجلد Repositories

ثم اكتب هذا الكود

import Foundation

protocol PostRepository {

    func fetchPost() async throws -> [Post]

}

هنا نقوم فقط بتعريف الـ functions المطلوبة على شكل Protocol بدون أي تفاصيل للتنفيذ

الهدف هو أن تعرف طبقة الـ Domain ما هي العمليات المتاحة دون معرفة كيفية تنفيذها

بينما سيتم تنفيذ هذه العمليات لاحقاً داخل طبقة الـ Data

UseCases

قم بإنشاء ملف Swift بإسم GetPostsUseCase في داخل مجلد UseCases

ثم اكتب هذا الكود

import Foundation

protocol GetPostsUseCase {
    func execute() async throws -> [Post]
}

struct DefaultGetPostsUseCase: GetPostsUseCase {
    
    private let repository: PostRepository
    
    init(respository: PostRepository) {
        self.repository = respository
    }
    
    func execute() async throws -> [Post] {
        try await repository.fetchPost()
    }
}

 

الـ Use Case يمثل عملية واحدة فقط داخل التطبيق

في هذا المثال مسؤوليته جلب قائمة المقالات لاحظ أن الـ Use Case لا يعرف من أين تأتي البيانات ولا يتعامل مباشرة مع الـ API أو قاعدة البيانات

كل ما يفعله هو استدعاء الـ Repository وطلب البيانات منه بهذه الطريقة يبقى منطق التطبيق معزولاً عن تفاصيل جلب البيانات، مما يجعل الكود أسهل في الاختبار والصيانة

 


 

 طبقة الـ Data

 

DTOs

قم بإنشاء ملف Swift بإسم PostDTO في داخل مجلد DTOs

ثم اكتب هذا الكود

struct PostDTO: Decodable {
    let userId: Int
    let id: Int
    let title: String
    let body: String
    
    func toDomain() -> Post {
        Post(userId: userId, id: id, title: title, body: body)
    }
}

تذكر أن الـ DTO يمثل البيانات كما تصل من مصدر البيانات مثل الـ API

بينما الـ Entity يمثل البيانات التي يستخدمها التطبيق داخل طبقة الـ Domain

قد تبدو الحقول متشابهة في هذا المثال البسيط لكن في المشاريع غالباً يختلف شكل البيانات القادمة من الـ API عن الشكل الذي يحتاجه التطبيق

لذلك نقوم بفصل الـ DTO عن الـ Entity حتى لا يصبح التطبيق مرتبطاً بشكل البيانات القادم من الـ API

 

لاحظ يوجد function باسم toDomain

وظيفته تحويل الـ DTO الموجود في طبقة الـ Data إلى Entity في طبقة الـ Domain.

بهذه الطريقة تبقى طبقة الـ Domain معزولة تماماً عن تفاصيل الـ API ولا تتعامل إلا مع الـ Entities الخاصة بها

فإذا تغيرت استجابة الـ API مستقبلاً، غالباً سنحتاج إلى تعديل الـ DTO فقط دون التأثير على بقية أجزاء التطبيق

DataSources

قم بإنشاء ملف Swift بإسم PostRemoteDataSource في داخل مجلد DataSources

ثم اكتب هذا الكود

import Foundation

protocol PostRemoteDataSource {
    func getPosts() async throws -> [PostDTO]
}

struct DefaultPostRemoteDataSource: PostRemoteDataSource {
    private let session: URLSession
    
    init(session: URLSession = .shared) {
        self.session = session
    }
    
    func getPosts() async throws -> [PostDTO] {
        let url = URL(string: "https://jsonplaceholder.typicode.com/posts")!

        let (data, response) = try await session.data(from: url)

        guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else {
            throw URLError(.badServerResponse)
        }
        
        return try JSONDecoder().decode([PostDTO].self, from: data)
    }
    
}

هذا الجزء يمثل مصدر البيانات الفعلي الذي يتعامل معه التطبيق

قد يكون API عبر الإنترنت أو قاعدة بيانات محلية أو ملفاً مخزناً داخل الجهاز أو أي مصدر بيانات آخر

كما تلاحظ في الكود السابق قمنا بإنشاء Data Source مسؤول عن جلب البيانات من الـ API

مهمة الـ Data Source هي التعامل المباشر مع مصدر البيانات فقط

لذلك ستجد بداخله أكواد الـ Networking أو أكواد التعامل مع قواعد البيانات بينما لا يحتوي على أي Business Logic خاص بالتطبيق

لاحظ أيضاً أن الـ Data Source يقوم بإرجاع PostDTO وليس Post

وذلك لأننا ما زلنا داخل طبقة الـ Data، وبالتالي نتعامل مع DTOs

أما تحويل البيانات إلى Entities فسيتم لاحقاً قبل إرسالها إلى طبقة الـ Domain

Repositories

قم بإنشاء ملف Swift بإسم DefaultPostRepository في داخل مجلد Repositories

ثم اكتب هذا الكود

struct DefaultPostRepository: PostRepository {
    private let remote: PostRemoteDataSource
    
    init(remote: PostRemoteDataSource) {
        self.remote = remote
    }
    
    func fetchPost() async throws -> [Post] {
        return try await remote.getPosts().map{$0.toDomain()}
    }
    
}

أحياناً يتم تسمية هذا المجلد بـ RepositoryImpl أو RepositoriesImpl للتمييز بين الـ Repository Protocols

الموجودة في طبقة الـ Domain وبين التطبيق الفعلي لها داخل طبقة الـ Data

في هذا الملف نقوم بتنفيذ الـ PostRepository الذي قمنا بتعريفه سابقاً في طبقة الـ Domain

كما تلاحظ قمنا بحقن PostRemoteDataSource داخل الـ Repository

وبالتالي فإن الـ Repository لا يقوم بجلب البيانات بنفسه،بل يعتمد على الـ Data Sources للحصول عليها

بعد الحصول على البيانات من الـ Data Source والتي تكون على شكل PostDTO نقوم بتحويلها إلى Post باستخدام toDomain

 قبل إرجاعها إلى طبقة الـ Domain

وبهذه الطريقة تبقى طبقة الـ Domain معزولة تماماً عن الـ DTOs وعن تفاصيل مصادر البيانات

بمعنى آخر الـ Repository يعتبر حلقة الوصل بين طبقة الـ Domain وطبقة الـ Data


 طبقة الـ Presentation

قم بإنشاء ملف Swift بإسم PostsViewModel في داخل مجلد Presentation

ملاحظة : لا يوجد أسلوب واحد متفق عليه لتنظيم هذه الطبقة

بعض المطورين يفضلون إنشاء مجلد مستقل للـ Views ومجلد آخر للـ ViewModels

بينما يفضل الكثير من المطورين تنظيم الملفات حسب الصفحة بحيث يتم إنشاء مجلد لكل صفحة داخل التطبيق

على سبيل المثال يمكن إنشاء مجلد باسم Posts يحتوي على:

  • PostsView.swift
  • PostsViewModel.swift

شخصياً أفضل هذا الأسلوب لأنه يجعل الملفات المتعلقة بنفس الصفحة موجودة في مكان واحد ويسهل التنقل داخل المشروع مع زيادة عدد الصفحات

ومن ثم اكتب كود ملف PostsViewModel
import SwiftUI

@MainActor @Observable
final class PostsViewModel {
    
    enum State: Equatable {
        case idle
        case loading
        case loaded([Post])
        case error(String)
    }
    
     private(set) var state: State = .idle
    
    private let getPosts: GetPostsUseCase
    
    init(getPosts: GetPostsUseCase) {
        self.getPosts = getPosts
    }
    
    func onAppear() async {
        state = .loading
        
        do {
            let posts = try await getPosts.execute()
            state = .loaded(posts)
        } catch {
            state = .error(error.localizedDescription)

        }
        
    }
    
}

في هذا الـ ViewModel قمنا بإنشاء State لمتابعة حالة الصفحة.

عن تجربة هذه من أفضل الطرق لتنظيم حالات الواجهة

لأن الصفحة غالباً تكون في واحدة من هذه الحالات:

idle الحالة الافتراضية قبل تنفيذ أي طلب

loading عند بدء جلب البيانات

loaded عند نجاح الطلب ورجوع البيانات

error عند حدوث خطأ

الفائدة من هذا الأسلوب أنه يجعل حالة الصفحة واضحة ومحددة بدلاً من استخدام أكثر من متغير مثل isLoading و posts و errorMessage، مما قد يسبب تداخل أو حالات غير منطقية

الجزء الأهم هنا هو حقن الـ GetPostsUseCase داخل الـ ViewModel

كما شرحنا سابقاً، الـ ViewModel لا يتعامل مباشرة مع الـ API أو الـ Repository بل يستدعي الـ Use Case فقط

بعد استدعاء execute ينتقل الطلب إلى الـ Use Case ثم إلى الـ Repository ثم إلى الـ Data Source الذي يقوم بتنفيذ الطلب وجلب البيانات

بعد رجوع النتيجة يقوم الـ ViewModel بتحديث الـ state وبناءً على هذه الحالة يتم تحديث الواجهة وعرض البيانات للمستخدم

الـ View

الآن وصلنا إلى آخر جزء في التطبيق وهو الـ View.

إذا تذكر ما شرحته سابقاً فإن الـ View في نمط MVVM يجب أن تكون مسؤولة فقط عن عرض البيانات واستقبال تفاعلات المستخدم

بينما يتم نقل منطق العمل وإدارة البيانات إلى الـ ViewModel

 

قم بإنشاء ملف Swift باسم PostsView ثم أضف الكود التالي:

import SwiftUI

struct PostsView: View {
    @State private var viewModel: PostsViewModel

    init(viewModel: PostsViewModel) {
        self.viewModel =  viewModel
    }

    var body: some View {
        NavigationStack {
            content
                .navigationTitle("Posts")
                .navigationDestination(for: Post.self) { post in
                    PostsDetailView(post: post)
                }
                .task { await viewModel.onAppear() }
        }
    }

    @ViewBuilder
    private var content: some View {
        switch viewModel.state {
        case .idle, .loading:
            ProgressView()
        case .loaded(let posts):
            List(posts) { post in
                NavigationLink(value: post) {
                    VStack(alignment: .leading, spacing: 4) {
                        Text(post.title).font(.headline)
                        Text(post.body)
                            .font(.subheadline)
                            .foregroundStyle(.secondary)
                    }
                }
            }
        case .error(let message):
            ContentUnavailableView("Something went wrong", systemImage: "exclamationmark.triangle", description: Text(message))
        }
    }
}

struct PostsDetailView: View {
    let post: Post

    var body: some View {
        List {
            LabeledContent("ID", value: "\(post.id)")
            LabeledContent("User Id", value: String(post.userId))
            LabeledContent("Title", value: post.title)
            LabeledContent("Body", value: "\(post.body)")
        }
        .navigationTitle("\(post.id)")
    }
}

في هذا المثال تلاحظ أن الـ View تستقبل الـ ViewModel من الخارج عبر الـ init بدلاً من إنشائه داخل الصفحة وهذا يجعل الصفحة مستقلة ويسهل اختبارها وإعادة استخدامها

كما تلاحظ أيضاً أننا لا نقوم بجلب البيانات مباشرة من الـ API أو استدعاء الـ UseCase داخل الـ View بل يتم الاعتماد بالكامل على الـ ViewModel


 مجلد الـ App

 

الآن انتهينا من بناء طبقات الـ Clean Architecture، لكن ما زال هناك جزء مهم لم نقم به وهو ربط هذه الطبقات مع بعضها البعض

إذا لاحظت في الأمثلة السابقة،ستجد أن أغلب الكلاسات تستقبل Dependencies عبر الـ init

على سبيل المثال:

  • الـ ViewModel يعتمد على الـ UseCase.
  • الـ UseCase يعتمد على الـ Repository.
  • الـ Repository يعتمد على الـ DataSource.

بدلاً من إنشاء هذه الـ Dependencies داخل كل كلاس نقوم بتمريرها من الخارج باستخدام مفهوم يسمى Dependency Injection

هذا الأسلوب يجعل الكود أكثر مرونة وأسهل في الاختبار والصيانة كما يسمح باستبدال أي جزء من التطبيق دون التأثير على بقية الطبقات

ولتنظيم عملية إنشاء وربط جميع الـ Dependencies في مكان واحد سنقوم بإنشاء ملف بإسم AppDIContainer

ثم نكتب فيه هذا الكود

import Foundation

final class AppDIContainer {
    static let shared = AppDIContainer()
    
    private func makePostRepositories() -> PostRepository {
        DefaultPostRepository(remote: DefaultPostRemoteDataSource())
    }
    
    private func makeGetPostsUseCase() -> GetPostsUseCase {
        DefaultGetPostsUseCase(respository: makePostRepositories())
    }
    
    @MainActor
     func makePostViewModels() -> PostsViewModel {
        PostsViewModel(getPosts: makeGetPostsUseCase())
    }

}

ومن ثم في صفحة الـ main تستدعيها هكذت

@main
struct MVVMWithCleanArchitectureApp: App {
    var body: some Scene {
        WindowGroup {
                PostsView(viewModel: AppDIContainer.shared.makePostViewModels())            
        }
    }
}

وبهذا الشكل تصبح جميع الـ Dependencies مدارة من مكان واحد

و تبقى كل طبقة مستقلة عن تفاصيل الطبقات الأخرى


لماذا هذا التقسيم يسهل Unit Testing؟

من أهم فوائد استخدام Clean Architecture مع MVVM و Dependency Injection أن الاختبار يصبح أسهل بكثير

لأن كل طبقة تعتمد على Protocol وليس على تنفيذ مباشر يمكننا في الاختبارات استبدال الـ Repository الحقيقي بـ Mock Repository

بهذه الطريقة نستطيع اختبار الـ UseCase أو الـ ViewModel بدون الحاجة إلى API حقيقي أو Network Request.

لتحربة عمل اختبار
افتح صفحة File ثم اختار New ثم Target ثم اختار Unit Testing Bundle
وقم بتسميته PostsViewModelTests

اختبار الـ Repository

في هذا الاختبار سنقوم بالتأكد من أن الـ Repository يقوم بتحويل الـ DTO إلى Entity بشكل صحيح

import Testing
@testable import MVVMWithCleanArchitecture

struct MockPostRemoteDataSource: PostRemoteDataSource {

    func getPosts() async throws -> [PostDTO] {
        [
            PostDTO(
                userId: 1,
                id: 10,
                title: "DTO Title",
                body: "DTO Body"
            )
        ]
    }
}

struct PostRepositoryTests {

    @Test
    func convertsDTOToDomainModel() async throws {

        // Given
        let repository = DefaultPostRepository(
            remote: MockPostRemoteDataSource()
        )

        // When
        let posts = try await repository.fetchPost()

        // Then
        #expect(posts.count == 1)
        #expect(posts.first?.id == 10)
        #expect(posts.first?.title == "DTO Title")
    }
}

اختبار الـ ViewModel

في هذا الاختبار سنقوم بالتأكد من أن الـ ViewModel يتعامل بشكل صحيح مع حالات النجاح والفشل والحالة الابتدائية

import Testing
@testable import MVVMWithCleanArchitecture
import Foundation

struct MockGetPostsUseCase: GetPostsUseCase {
    let result: Result<[Post], Error>

    func execute() async throws -> [Post] {
        try result.get()
    }
}

@MainActor
struct PostsViewModelTests {

    @Test
    func startsWithIdleState() {
        // Given
        let sut = PostsViewModel(
            getPosts: MockGetPostsUseCase(
                result: .success([])
            )
        )

        // Then
        #expect(sut.state == .idle)
    }

    @Test
    func showsLoadedState_whenFetchSucceeds() async {
        // Given
        let posts = [
            Post(
                userId: 1,
                id: 11,
                title: "SwiftUI Post",
                body: "Test Body"
            )
        ]

        let sut = PostsViewModel(
            getPosts: MockGetPostsUseCase(
                result: .success(posts)
            )
        )

        // When
        await sut.onAppear()

        // Then
        #expect(sut.state == .loaded(posts))
    }

    @Test
    func showsErrorState_whenFetchFails() async {
        // Given
        let sut = PostsViewModel(
            getPosts: MockGetPostsUseCase(
                result: .failure(
                    URLError(.notConnectedToInternet)
                )
            )
        )

        // When
        await sut.onAppear()

        // Then
        guard case .error(let message) = sut.state else {
            Issue.record(
                "Expected .error but got \(sut.state)"
            )
            return
        }

        #expect(!message.isEmpty)
    }
}

قمنا بإنشاء MockGetPostsUseCase حتى نستطيع التحكم في النتيجة التي يحصل عليها الـ ViewModel دون الحاجة إلى API حقيقي

في الاختبار الأول تأكدنا أن الحالة الابتدائية للـ ViewModel هي idle

في الاختبار الثاني قمنا بإرجاع بيانات ناجحة من الـ Mock ثم تأكدنا أن الـ ViewModel قام بتغيير الحالة إلى loaded

أما في الاختبار الثالث قمنا بمحاكاة حدوث خطأ وتأكدنا أن الـ ViewModel انتقل إلى حالة error

بهذه الطريقة نستطيع اختبار الـ ViewModel بشكل كامل دون الحاجة إلى تشغيل التطبيق أو تنفيذ أي Network Request حقيقي


بهذا نكون قد طبقنا Clean Architecture مع MVVM خطوة بخطوة بدءاً من طبقة Domain ثم Data ثم Presentation وصولاً إلى Dependency Injection و Unit Testing.

قد يبدو عدد الملفات أكبر مقارنة باستخدام MVVM فقط، لكن مع نمو المشروع ستصبح إدارة الكود واختباره وصيانته أسهل بكثير، وهو السبب الذي يجعل العديد من الفرق تعتمد هذا الأسلوب في المشاريع المتوسطة والكبيرة.