MVI و SwiftUI - دولة واحدة





لنفترض أننا بحاجة إلى إجراء تغيير بسيط على طريقة عمل الشاشة. تتغير الشاشة كل ثانية لأن هناك العديد من العمليات الجارية في نفس الوقت. كقاعدة عامة ، من أجل تسوية جميع حالات الشاشة ، من الضروري الرجوع إلى المتغيرات ، كل منها له حياته الخاصة. إن وضعها في الاعتبار أمر صعب للغاية أو مستحيل تمامًا. للعثور على مصدر المشكلة ، سيتعين عليك فهم متغيرات وحالات الشاشة ، وحتى التأكد من أن إصلاحنا لا يكسر شيئًا في مكان آخر. لنفترض أننا قضينا الكثير من الوقت وما زلنا نجري التعديلات اللازمة. هل كان من الممكن حل هذه المشكلة بشكل أسهل وأسرع؟ دعونا نفهم ذلك.



MVI



تم وصف هذا النمط لأول مرة بواسطة مطور JavaScript Andre Stalz. يمكن العثور على المبادئ العامة في الرابط







Intent : ينتظر الأحداث من المستخدم ويعالجها

النموذج : ينتظر الأحداث التي تمت معالجتها لتغيير الحالة

العرض : ينتظر تغييرات الحالة ويظهر لهم

عنصر مخصص : قسم فرعي من طريقة العرض ، وهو في حد ذاته عنصر واجهة المستخدم. يمكن تنفيذه باعتباره MVI أو كمكون ويب. اختياري في العرض.



في مواجهة نهج رد الفعل. تتوقع كل وحدة نمطية (وظيفة) حدثًا ، وبعد استلامها ومعالجتها ، تمرر هذا الحدث إلى الوحدة التالية. اتضح تدفق أحادي الاتجاه. توجد الحالة الفردية للعرض في النموذج ، وبالتالي تحل مشكلة العديد من الحالات التي يصعب تتبعها.



كيف يمكن تطبيق ذلك في تطبيق الهاتف المحمول؟



كتب مارتن فاولر ورايس ديفيد في كتابهما "أنماط تطبيقات المؤسسة" أن الأنماط هي أنماط لحل المشكلات ، وبدلاً من نسخ واحد إلى واحد ، من الأفضل تكييفها مع الحقائق الحالية. تطبيق الهاتف المحمول له حدوده وميزاته الخاصة التي يجب مراعاتها. تستقبل طريقة العرض حدثًا من المستخدم ، ومن ثم يمكن ربطها بالنوايا. تم تعديل المخطط قليلاً ، لكن مبدأ النموذج يظل كما هو.







التنفيذ





سيكون هناك الكثير من التعليمات البرمجية أدناه.

يمكن الاطلاع على الكود النهائي تحت المفسد أدناه.



تنفيذ MVI
رأي



import SwiftUI

struct RootView: View {

    // Or @StateObject for iOS 14
    @ObservedObject private var intent: RootIntent

    var body: some View {
        ZStack {
            imageView()
                .onTapGesture(perform: intent.onTapImage)
            errorView()
            loadView()
        }
        .overlay(RootRouter(screen: intent.model.routerSubject))
        .onAppear(perform: intent.onAppear)
    }

    static func build() -> some View {
        let model = RootModel()
        let intent = RootIntent(model: model)
        let view = RootView(intent: intent)
        return view
    }
}

// MARK: - Private - Views
private extension RootView {

    private func imageView() -> some View {
        Group { () -> AnyView  in
            if let image = intent.model.image {
                return Image(uiImage: image)
                    .resizable()
                    .toAnyView()
            } else {
                return Color.gray.toAnyView()
            }
        }
        .cornerRadius(6)
        .shadow(radius: 2)
        .frame(width: 100, height: 100)
    }

    private func loadView() -> some View {
        guard intent.model.isLoading else {
            return EmptyView().toAnyView()
        }
        return ZStack {
            Color.white
            Text("Loading")
        }.toAnyView()
    }

    private func errorView() -> some View {
        guard intent.model.error != nil else {
            return EmptyView().toAnyView()
        }
        return ZStack {
            Color.white
            Text("Fail")
        }.toAnyView()
    }
}




نموذج



import SwiftUI
import Combine

protocol RootModeling {
    var image: UIImage? { get }
    var isLoading: Bool { get }
    var error: Error? { get }
    var routerSubject: PassthroughSubject<RootRouter.ScreenType, Never> { get }
}

class RootModel: ObservableObject, RootModeling {

    enum StateType {
        case loading, show(image: UIImage), failLoad(error: Error)
    }

    @Published private(set) var image: UIImage?
    @Published private(set) var isLoading: Bool = true
    @Published private(set) var error: Error?

    let routerSubject = PassthroughSubject<RootRouter.ScreenType, Never>()

    func update(state: StateType) {
        switch state {
        case .loading:
            isLoading = true
            error = nil
            image = nil

        case .show(let image):
            self.image = image
            isLoading = false

        case .failLoad(let error):
            self.error = error
            isLoading = false
        }
    }
}




نوايا



import SwiftUI
import Combine

class RootIntent: ObservableObject {

    let model: RootModeling

    private var rootModel: RootModel! { model as? RootModel }
    private var cancellable: Set<AnyCancellable> = []

    init(model: RootModeling) {
        self.model = model
        cancellable.insert(rootModel.objectWillChange.sink { self.objectWillChange.send() })
    }
}

// MARK: - API
extension RootIntent {

    func onAppear() {
        rootModel?.update(state: .loading)

        let url: URL! = URL(string: "https://upload.wikimedia.org/wikipedia/commons/f/f4/Honeycrisp.jpg")
        let task = URLSession.shared.dataTask(with: url) { [weak self] (data, _, error) in
            guard let data = data, let image = UIImage(data: data) else {
                DispatchQueue.main.async {
                    self?.rootModel?.update(state: .failLoad(error: error ?? NSError()))
                    self?.rootModel?.routerSubject.send(.alert(title: "Error",
                                                               message: "It was not possible to upload a image"))
                }
                return
            }
            DispatchQueue.main.async {
                self?.rootModel?.update(state: .show(image: image))
            }
        }
        task.resume()
    }

    func onTapImage() {
        guard let image = rootModel?.image else {
            rootModel?.routerSubject.send(.alert(title: "Error", message: "Failed to open the screen"))
            return
        }
        rootModel?.routerSubject.send(.descriptionImage(image: image))
    }
}




Router



import SwiftUI
import Combine

struct RootRouter: View {

    enum ScreenType {
        case alert(title: String, message: String)
        case descriptionImage(image: UIImage)
    }

    let screen: PassthroughSubject<ScreenType, Never>

    @State private var screenType: ScreenType? = nil
    @State private var isFullImageVisible = false
    @State private var isAlertVisible = false

    var body: some View {
        Group {
            alertView()
            descriptionImageView()
        }.onReceive(screen, perform: { type in
            self.screenType = type
            switch type {
            case .alert:
                self.isAlertVisible = true

            case .descriptionImage:
                self.isFullImageVisible = true
            }
        })
    }
}

private extension RootRouter {

    private func alertView() -> some View {
        guard let type = screenType, case .alert(let title, let message) = type else {
            return EmptyView().toAnyView()
        }
        return Spacer().alert(isPresented: $isAlertVisible, content: {
            Alert(title: Text(title), message: Text(message))
        }).toAnyView()
    }

    private func descriptionImageView() -> some View {
        guard let type = screenType, case .descriptionImage(let image) = type else {
            return EmptyView().toAnyView()
        }
        return Spacer().sheet(isPresented: $isFullImageVisible, onDismiss: {
            self.screenType = nil
        }, content: {
            DescriptionImageView.build(image: image, action: { _ in
                // code
            })
        }).toAnyView()
    }
}






الآن دعنا ننتقل إلى فحص كل وحدة على حدة.



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



extension View {
    func toAnyView() -> AnyView {
        AnyView(self)
    }
}




رأي



عرض - يقبل الأحداث من المستخدم ويمررها إلى النية وينتظر تغيير الحالة من النموذج



import SwiftUI

struct RootView: View {

    // 1
    @ObservedObject private var intent: RootIntent

    var body: some View {
        ZStack {
   	       // 4
            imageView()
            errorView()
            loadView()
        }
        // 3
        .onAppear(perform: intent.onAppear)
    }

    // 2
    static func build() -> some View {
        let intent = RootIntent()
        let view = RootView(intent: intent)
        return view
    }

    private func imageView() -> some View {
        Group { () -> AnyView  in
		 // 5
            if let image = intent.model.image {
                return Image(uiImage: image)
                    .resizable()
                    .toAnyView()
            } else {
                return Color.gray.toAnyView()
            }
        }
        .cornerRadius(6)
        .shadow(radius: 2)
        .frame(width: 100, height: 100)
    }

    private func loadView() -> some View {
	   // 5
        guard intent.model.isLoading else {
            return EmptyView().toAnyView()
        }
        return ZStack {
            Color.white
            Text("Loading")
        }.toAnyView()
    }

    private func errorView() -> some View {
	   // 5
        guard intent.model.error != nil else {
            return EmptyView().toAnyView()
        }
        return ZStack {
            Color.white
            Text("Fail")
        }.toAnyView()
    }
}


  1. يتم تمرير جميع الأحداث التي يتلقاها العرض إلى Intent. تحتفظ النية بالارتباط بالحالة الفعلية للعرض في حد ذاتها ، نظرًا لأنه هو الذي يغير الحالات. يلزم تضمينObservedObject من أجل النقل إلى عرض جميع التغييرات التي تحدث في النموذج (مزيد من التفاصيل أدناه)
  2. يبسط إنشاء طريقة عرض ، وبالتالي يكون من الأسهل قبول البيانات من شاشة أخرى (مثل RootView.build () أو HomeView.build (مقالة: 42) )
  3. يرسل حدث دورة حياة العرض إلى النية
  4. الوظائف التي تنشئ عناصر مخصصة
  5. يمكن للمستخدم رؤية حالات الشاشة المختلفة ، كل هذا يتوقف على البيانات الموجودة حاليًا في النموذج. إذا كانت القيمة المنطقية للسمة intent.model.isLoading صحيحة ، فسيرى المستخدم التحميل ، إذا كان خطأ ، فسيرى المحتوى الذي تم تحميله أو خطأ. اعتمادًا على الحالة ، سيرى المستخدم عناصر مخصصة مختلفة.


نموذج



الطراز - يحافظ على الحالة الفعلية للشاشة



 import SwiftUI

// 1
protocol RootModeling {
    var image: UIImage? { get }
    var isLoading: Bool { get }
    var error: Error? { get }
}

class RootModel: ObservableObject, RootModeling {
    // 2
    @Published var image: UIImage?
    @Published var isLoading: Bool = true
    @Published var error: Error?
} 


  1. البروتوكول مطلوب لإظهار العرض فقط ما هو مطلوب لعرض واجهة المستخدم
  2. Published مطلوب لنقل البيانات التفاعلية إلى طريقة العرض


نوايا



Inent - ينتظر الأحداث من العرض لمزيد من الإجراءات. يعمل مع منطق الأعمال وقواعد البيانات ، ويقدم الطلبات إلى الخادم ، إلخ.



import SwiftUI
import Combine

class RootIntent: ObservableObject {

    // 1
    let model: RootModeling

    // 2
    private var rootModel: RootModel! { model as? RootModel }

    // 3
    private var cancellable: Set<AnyCancellable> = []

    init() {
        self.model = RootModel()

	  // 3
        let modelCancellable = rootModel.objectWillChange.sink { self.objectWillChange.send() }
        cancellable.insert(modelCancellable)
    }
}

// MARK: - API
extension RootIntent {

    // 4
    func onAppear() {
	  rootModel.isLoading = true
	  rootModel.error = nil


        let url: URL! = URL(string: "https://upload.wikimedia.org/wikipedia/commons/f/f4/Honeycrisp.jpg")
        let task = URLSession.shared.dataTask(with: url) { [weak self] (data, _, error) in
            guard let data = data, let image = UIImage(data: data) else {
                DispatchQueue.main.async {
		       // 5
                    self?.rootModel.error = error ?? NSError()
                    self?.rootModel.isLoading = false
                }
                return
            }
            DispatchQueue.main.async {
		   // 5
                self?.model.image = image
                self?.model.isLoading = false
            }
        }

        task.resume()
    }
} 


  1. يحتوي القصد على ارتباط إلى النموذج ، وعند الضرورة ، يغير بيانات النموذج. RootModelIng هو بروتوكول يعرض سمات النموذج ولا يسمح بتغييرها
  2. من أجل تغيير السمات في Intent ، نقوم بتحويل خصائص RootModelProperties إلى RootModel
  3. تنتظر النية باستمرار تغيير سمات النموذج وتمريرها إلى العرض. يتيح لك AnyCancellable عدم الاحتفاظ بمرجع في الذاكرة لانتظار التغييرات من الطراز. بهذه الطريقة البسيطة ، يحصل العرض على أحدث حالة.
  4. تستقبل هذه الوظيفة حدثًا من المستخدم وتنزيل صورة
  5. هذه هي الطريقة التي نغير بها حالة الشاشة


هذا النهج (تغيير الحالات بدوره) له عيب: إذا كان للنموذج الكثير من السمات ، فعند تغيير السمات ، يمكنك أن تنسى تغيير شيء ما.



حل واحد ممكن
protocol RootModeling {
    var image: UIImage? { get }
    var isLoading: Bool { get }
    var error: Error? { get }
}

class RootModel: ObservableObject, RootModeling {

    enum StateType {
        case loading, show(image: UIImage), failLoad(error: Error)
    }

    @Published private(set) var image: UIImage?
    @Published private(set) var isLoading: Bool = true
    @Published private(set) var error: Error?

    func update(state: StateType) {
        switch state {
        case .loading:
            isLoading = true
            error = nil
            image = nil

        case .show(let image):
            self.image = image
            isLoading = false

        case .failLoad(let error):
            self.error = error
            isLoading = false
        }
    }
}

// MARK: - API
extension RootIntent {

    func onAppear() {
	   rootModel?.update(state: .loading)
... 




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



هناك عيب آخر - يمكن أن تنمو فئة Intent كثيرًا مع الكثير من منطق الأعمال. يتم حل هذه المشكلة عن طريق تقسيم منطق الأعمال إلى خدمات.



ماذا عن الملاحة؟ MVI + R



إذا تمكنت من القيام بكل شيء في العرض ، فعلى الأرجح لن تكون هناك مشاكل. ولكن إذا أصبح المنطق أكثر تعقيدًا ، فسيظهر عدد من الصعوبات. كما اتضح ، فإن إنشاء جهاز توجيه بنقل البيانات إلى الشاشة التالية وإرجاع البيانات مرة أخرى إلى طريقة العرض التي تسمى هذه الشاشة ليس بالأمر السهل. يمكن إجراء نقل البيانات عبرEnvironmentObject ، ولكن بعد ذلك ستتمكّن جميع طرق العرض الموجودة أسفل التسلسل الهرمي من الوصول إلى هذه البيانات ، وهو أمر غير جيد. نحن نرفض هذه الفكرة. نظرًا لأن حالات الشاشة تتغير من خلال النموذج ، فإننا نشير إلى جهاز التوجيه من خلال هذا الكيان.



protocol RootModeling {
    var image: UIImage? { get }
    var isLoading: Bool { get }
    var error: Error? { get }

    // 1
    var routerSubject: PassthroughSubject<RootRouter.ScreenType, Never> { get }
}

class RootModel: ObservableObject, RootModeling {

    // 1
    let routerSubject = PassthroughSubject<RootRouter.ScreenType, Never>() 


  1. نقطة الدخول. من خلال هذه السمة سوف نشير إلى جهاز التوجيه


من أجل عدم انسداد العرض الرئيسي ، يتم إخراج كل ما يتعلق بالانتقالات إلى الشاشات الأخرى في عرض منفصل



 struct RootView: View {

    @ObservedObject private var intent: RootIntent

    var body: some View {
        ZStack {
            imageView()
		   // 2
                .onTapGesture(perform: intent.onTapImage)
            errorView()
            loadView()
        }
	  // 1
        .overlay(RootRouter(screen: intent.model.routerSubject))
        .onAppear(perform: intent.onAppear)
    }
} 


  1. طريقة عرض منفصلة تحتوي على جميع العناصر المنطقية والمخصصة المتعلقة بالتنقل
  2. يرسل حدث دورة حياة العرض إلى النية


يجمع Intent جميع البيانات اللازمة لعملية الانتقال



// MARK: - API
extension RootIntent {

    func onTapImage() {
        guard let image = rootModel?.image else {
	      // 1
            rootModel?.routerSubject.send(.alert(title: "Error", message: "Failed to open the screen"))
            return
        }
        // 2
        model.routerSubject.send(.descriptionImage(image: image))
    }
} 


  1. إذا لم تكن هناك صورة لسبب ما ، فإنه ينقل جميع البيانات الضرورية إلى النموذج لإظهار الخطأ
  2. يرسل البيانات الضرورية إلى النموذج لفتح شاشة مع وصف مفصل للصورة




import SwiftUI
import Combine

struct RootRouter: View {

    // 1
    enum ScreenType {
        case alert(title: String, message: String)
        case descriptionImage(image: UIImage)
    }

    // 2
    let screen: PassthroughSubject<ScreenType, Never>


    // 3
    @State private var screenType: ScreenType? = nil


    // 4
    @State private var isFullImageVisible = false
    @State private var isAlertVisible = false

    var body: some View {
	  Group {
            alertView()
            descriptionImageView()
        }
	  // 2
        .onReceive(screen, perform: { type in
            self.screenType = type
            switch type {
            case .alert:
                self.isAlertVisible = true

            case .descriptionImage:
                self.isFullImageVisible = true
            }
        }).overlay(screens())
    }

    private func alertView() -> some View {
	  // 3
        guard let type = screenType, case .alert(let title, let message) = type else {
            return EmptyView().toAnyView()
        }
	  
        // 4
        return Spacer().alert(isPresented: $isAlertVisible, content: {
            Alert(title: Text(title), message: Text(message))
        }).toAnyView()
    }

    private func descriptionImageView() -> some View {
	  // 3
        guard let type = screenType, case .descriptionImage(let image) = type else {
            return EmptyView().toAnyView()
        }

        // 4
        return Spacer().sheet(isPresented: $isFullImageVisible, onDismiss: {
            self.screenType = nil
        }, content: {
            DescriptionImageView.build(image: image)
        }).toAnyView()
    }
}


  1. تعداد البيانات المطلوبة للشاشات
  2. سيتم إرسال الأحداث من خلال هذه السمة. حسب الأحداث ، سوف نفهم الشاشة التي يجب عرضها
  3. هذه السمة مطلوبة لتخزين البيانات لفتح الشاشة.
  4. غيّر من خطأ إلى صحيح وستفتح الشاشة المطلوبة


خاتمة



تم تصميم SwiftUI ، مثل MVI ، حول التفاعل ، لذا فهي تتوافق معًا بشكل جيد. هناك صعوبات في التنقل ونية كبيرة مع منطق معقد ، لكن كل شيء يمكن حله. يسمح لك MVI بتنفيذ شاشات معقدة ، وبأقل جهد ، يمكنك تغيير حالة الشاشة بشكل ديناميكي. هذا التنفيذ ، بالطبع ، ليس هو التطبيق الصحيح الوحيد ، فهناك دائمًا بدائل. ومع ذلك ، فإن النمط يتناسب بشكل جيد مع نهج واجهة مستخدم Apple الجديد. تسهل فئة واحدة لجميع حالات الشاشة العمل مع الشاشة. يمكن الاطلاع على



الكود من المقالة ، وكذلك قوالب Xcode ، على GitHub.



All Articles