MVI dan SwiftUI - satu negara





Katakanlah kita perlu melakukan sedikit perubahan pada cara kerja layar. Layar berubah setiap detik karena ada banyak proses yang terjadi pada saat bersamaan. Sebagai aturan, untuk menyelesaikan semua status layar, perlu untuk merujuk ke variabel, yang masing-masing memiliki kehidupannya sendiri. Mengingatnya sangat sulit atau sama sekali tidak mungkin. Untuk menemukan sumber masalahnya, Anda harus memahami variabel dan status layar, dan bahkan memastikan bahwa perbaikan kami tidak merusak sesuatu di tempat lain. Katakanlah kita menghabiskan banyak waktu dan masih melakukan pengeditan yang diperlukan. Apakah mungkin menyelesaikan masalah ini lebih mudah dan lebih cepat? Mari kita cari tahu.



MVI



Pola ini pertama kali dijelaskan oleh pengembang JavaScript Andre Stalz. Prinsip umum dapat ditemukan di tautan







Intent : menunggu kejadian dari pengguna dan memprosesnya

Model : menunggu kejadian yang ditangani untuk mengubah keadaan.

View : menunggu perubahan status dan menampilkannya

Elemen khusus : sub-bagian dari View, yang merupakan elemen UI sendiri. Dapat diimplementasikan sebagai MVI atau sebagai komponen web. Opsional dalam Tampilan.



Di muka pendekatan reaktif. Setiap modul (fungsi) mengharapkan beberapa peristiwa, dan setelah menerima dan memprosesnya, ia meneruskan peristiwa ini ke modul berikutnya. Ternyata aliran searah. Keadaan tunggal Tampilan berada di Model, dan dengan demikian memecahkan masalah banyak keadaan yang sulit dilacak.



Bagaimana ini bisa diterapkan di aplikasi seluler?



Martin Fowler dan Rice David dalam buku mereka "Patterns of Enterprise Applications" menulis bahwa pola adalah pola untuk memecahkan masalah, dan daripada menyalinnya satu ke satu, lebih baik menyesuaikannya dengan kenyataan saat ini. Aplikasi seluler memiliki batasan dan fitur tersendiri yang harus diperhitungkan. View menerima sebuah peristiwa dari pengguna, dan kemudian dapat diproksikan ke Intent. Skema ini sedikit dimodifikasi, tetapi prinsip polanya tetap sama.







Penerapan





Akan ada banyak kode di bawah ini.

Kode terakhir dapat dilihat di bawah spoiler di bawah ini.



Implementasi MVI
Melihat



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()
    }
}




Model



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
        }
    }
}




Maksud



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()
    }
}






Sekarang mari kita mulai memeriksa setiap modul secara terpisah.



Sebelum melanjutkan dengan implementasi, kita membutuhkan sebuah ekstensi untuk View, yang akan menyederhanakan penulisan kode dan membuatnya lebih mudah dibaca.



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




Melihat



View - menerima kejadian dari pengguna, meneruskannya ke Intent dan menunggu perubahan status dari Model



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. Semua peristiwa yang diterima View diteruskan ke Intent. Maksud menyimpan link ke keadaan sebenarnya dari View, karena dialah yang mengubah keadaan. Pembungkus @ObservedObject diperlukan untuk mentransfer ke Lihat semua perubahan yang terjadi dalam Model (lebih detail di bawah)
  2. Menyederhanakan pembuatan Tampilan, sehingga lebih mudah menerima data dari layar lain (contoh RootView.build () atau HomeView.build (articul: 42) )
  3. Mengirim peristiwa siklus hidup View ke Intent
  4. Fungsi yang membuat elemen khusus
  5. Pengguna dapat melihat status layar yang berbeda, itu semua tergantung pada data apa yang ada di Model. Jika nilai boolean dari atribut intent.model.isLoading adalah true , pengguna melihat pemuatan, jika false, maka dia melihat konten yang dimuat atau kesalahan. Bergantung pada negara bagian, pengguna akan melihat elemen Kustom yang berbeda.


Model



Model - mempertahankan kondisi layar yang sebenarnya



 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. Protokol diperlukan untuk menampilkan hanya View yang diperlukan untuk menampilkan UI
  2. @Published diperlukan untuk transfer data reaktif di Tampilan


Maksud



Inent - menunggu peristiwa dari View untuk tindakan selanjutnya. Bekerja dengan logika bisnis dan database, membuat permintaan ke server, dll.



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. Maksud berisi link ke Model, dan jika perlu, mengubah data Model. RootModelIng adalah protokol yang menunjukkan atribut Model dan mencegahnya diubah
  2. Untuk mengubah atribut di Intent, kami mengonversi RootModelProperties menjadi RootModel
  3. Intent terus menunggu perubahan pada atribut Model dan meneruskannya ke View. AnyCancellable memungkinkan Anda untuk tidak menyimpan dalam memori referensi untuk menunggu perubahan dari Model. Dengan cara sederhana ini, View mendapatkan status terkini.
  4. Fungsi ini menerima peristiwa dari pengguna dan mendownload gambar
  5. Beginilah cara kami mengubah status layar


Pendekatan ini (mengubah status pada gilirannya) memiliki kelemahan: jika Model memiliki banyak atribut, maka saat mengubah atribut, Anda bisa lupa untuk mengubah sesuatu.



Satu solusi yang mungkin
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)
... 




Saya yakin ini bukan satu-satunya solusi dan Anda dapat menyelesaikan masalah dengan cara lain.



Ada satu kelemahan lagi - kelas Intent dapat berkembang pesat dengan banyak logika bisnis. Masalah ini diselesaikan dengan membagi logika bisnis menjadi layanan.



Bagaimana dengan navigasi? MVI + R



Jika Anda berhasil melakukan semuanya di View, kemungkinan besar tidak akan ada masalah. Tetapi jika logikanya semakin rumit, sejumlah kesulitan muncul. Ternyata, membuat Router dengan transfer data ke layar berikutnya dan mengembalikan data kembali ke Tampilan yang disebut layar ini tidaklah mudah. Transfer data dapat dilakukan melalui @EnvironmentObject, tetapi semua Tampilan di bawah hierarki akan memiliki akses ke data ini, yang tidak bagus. Kami menolak ide ini. Karena status layar berubah melalui Model, kami merujuk ke Router melalui entitas ini.



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. Titik masuk. Melalui atribut ini kita akan merujuk ke Router


Agar tidak menyumbat Tampilan utama, semua yang terkait dengan transisi ke layar lain dibawa keluar dalam Tampilan terpisah



 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. Tampilan terpisah yang berisi semua logika dan elemen Kustom yang terkait dengan navigasi
  2. Mengirim peristiwa siklus hidup View ke Intent


Maksud mengumpulkan semua data yang diperlukan untuk transisi



// 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. Jika karena alasan tertentu tidak ada gambar, maka itu mentransfer semua data yang diperlukan ke Model untuk menunjukkan kesalahan
  2. Mengirimkan data yang diperlukan ke Model untuk membuka layar dengan penjelasan rinci tentang gambar




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. Enum dengan data yang diperlukan untuk layar
  2. Acara akan dikirim melalui atribut ini. Berdasarkan acara, kami akan memahami layar mana yang harus ditampilkan
  3. Atribut ini diperlukan untuk menyimpan data untuk membuka layar.
  4. Ubah dari salah ke benar dan layar yang diperlukan terbuka


Kesimpulan



SwiftUI, seperti MVI, dibuat berdasarkan reaktivitas, sehingga cocok satu sama lain. Ada kesulitan dengan navigasi dan Intent yang besar dengan logika yang rumit, tetapi semuanya bisa diselesaikan. MVI memungkinkan Anda mengimplementasikan layar yang kompleks dan, dengan sedikit usaha, mengubah keadaan layar secara dinamis. Implementasi ini tentu saja bukan satu-satunya yang benar, selalu ada alternatif. Namun, polanya cocok dengan pendekatan UI baru Apple. Satu kelas untuk semua status layar membuatnya lebih mudah untuk bekerja dengan layar.



Kode dari artikel , serta Template untuk Xcode, dapat dilihat di GitHub.



All Articles