Awalnya, seluruh proyek ditulis dalam Objective-C dan menggunakan ReactiveCocoa versi 2.0
Interaksi antara View dan ViewModel dilakukan dengan cara mengikat sifat-sifat model view, dan semua akan baik-baik saja, kecuali bahwa debugging kode seperti itu sangat sulit. Semua karena kurangnya mengetik dan bubur di jejak tumpukan :(
Dan sekarang saatnya menggunakan Swift. Pada awalnya, kami memutuskan untuk mencoba tanpa reaktivitas sama sekali. Lihat metode yang disebut secara eksplisit di ViewModel, dan ViewModel melaporkan perubahannya menggunakan delegasi:
protocol ViewModelDelegate { func didUpdateTitle(newTitle: String) } class View: UIView, ViewModelDelegate { var viewModel: ViewModel func didUpdateTitle(newTitle: String) { //handle viewModel updates } } class ViewModel { weak var delegate: ViewModelDelegate? func handleTouch() { //respond to some user action } }
Itu terlihat bagus. Tetapi seiring dengan perkembangan ViewModel, kami mulai mendapatkan banyak metode dalam delegasi untuk menangani setiap bersin yang dihasilkan oleh ViewModel:
protocol ViewModelDelegate { func didUpdate(title: String) func didUpdate(subtitle: String) func didReceive(items: [SomeItem]) func didReceive(error: Error) func didChangeLoading(isLoafing: Bool) //... }
Setiap metode perlu diimplementasikan, dan sebagai hasilnya kita mendapatkan footcloth besar dari metode dalam tampilan. Itu tidak terlihat sangat keren. Sama sekali tidak keren. Jika Anda memikirkannya, jika Anda menggunakan RxSwift, Anda akan mendapatkan situasi yang serupa, tetapi alih-alih menerapkan metode delegasi, akan ada banyak pengikat untuk properti ViewModel yang berbeda.
Output menunjukkan sendiri: Anda perlu menggabungkan semua metode menjadi satu dan properti enumerasi seperti ini:
enum ViewModelEvent { case updateTitle(String) case updateSubtitle(String) case items([SomeItem]) case error(Error) case loading(Bool) //... }
Sepintas, esensi tidak berubah. Tetapi alih-alih enam metode, kita mendapatkan satu dengan switch:
func handle(event: ViewModelEvent) { switch event { case .updateTitle(let newTitle): //... case .updateSubtitle(let newSubtitle): //... case .items(let newItems): //... case .error(let error): //... case .loading(let isLoading): //... } }
Untuk simetri, Anda bisa membuat enumerasi lain dan penangannya di ViewModel:
enum ViewEvent { case touchButton case swipeLeft } class ViewModel { func handle(event: ViewEvent) { switch event { case .touchButton: //... case .swipeLeft: //... } } }
Itu semua terlihat jauh lebih ringkas, plus itu memberikan satu titik interaksi antara View dan ViewModel, yang sangat baik mempengaruhi keterbacaan kode. Ternyata win-win - dan review permintaan tarik dipercepat, dan pendatang baru dengan cepat masuk ke proyek.
Tapi bukan obat mujarab. Masalah mulai muncul ketika satu model tampilan ingin melaporkan kejadiannya ke beberapa tampilan, misalnya, ContainerView dan ContentView (satu tertanam di yang lain). Solusinya, sekali lagi, muncul dengan sendirinya, kami menulis kelas baru alih-alih delegasi:
class Output<Event> { var handlers = [(Event) -> Void]() func send(_ event: Event) { for handler in handlers { handler(event) } } }
Di properti handlers
, handlers
menyimpan bookmark dengan panggilan ke metode handle(event:)
, dan ketika kami memanggil metode send(_ event:)
, kami memanggil semua handler dengan event ini. Dan sekali lagi, masalahnya tampaknya telah dipecahkan, tetapi setiap kali Anda mengikat View - ViewModel, Anda harus menulis ini:
vm.output.handlers.append({ [weak view] event in DispatchQueue.main.async { view?.handle(event: event) } }) view.output.handlers.append({ [weak vm] event in vm?.handle(event: event) })
Tidak terlalu keren.
Kami menutup View dan ViewModel dengan protokol:
protocol ViewModel { associatedtype ViewEvent associatedtype ViewModelEvent var output: Output<ViewModelEvent> { get } func handle(event: ViewEvent) func start() } protocol View: ViewModelContainer { associatedtype ViewModelEvent associatedtype ViewEvent var output: Output<ViewEvent> { get } func setupBindings() func handle(event: ViewModelEvent) }
Mengapa metode start()
dan setupBindings()
diperlukan - kami akan uraikan nanti. Kami sedang menulis ekstensi untuk protokol:
extension View where Self: NSObject { func bind<ViewModelType: ViewModel>(with vm: ViewModelType?) where ViewModelType.ViewModelEvent == ViewModelEvent, ViewModelType.ViewEvent == ViewEvent { guard let vm = vm else { return } vm.output.handlers.append({ [weak self] event in DispatchQueue.main.async { self?.handle(event: event) } }) output.handlers.append({ [weak vm] event in vm?.handle(event: event) }) setupBindings() vm.start() } }
Dan kami mendapatkan metode yang siap pakai untuk menautkan View - ViewModel, acara yang cocok. Metode start()
memastikan bahwa ketika dijalankan, tampilan sudah akan menerima semua peristiwa yang akan dikirim dari ViewModel, dan metode setupBindings()
akan diperlukan jika Anda perlu membuang ViewModel ke dalam subviews Anda sendiri, sehingga metode ini dapat diimplementasikan secara default di ekstensi ' e.
Ternyata untuk hubungan antara View dan ViewModel, implementasi spesifik mereka sama sekali tidak penting, yang utama adalah agar View dapat menangani event ViewModel, dan sebaliknya. Dan untuk menyimpan dalam tampilan bukan tautan spesifik ke ViewModel, tetapi versi generalnya, Anda dapat menulis pembungkus TypeErasure tambahan (karena tidak mungkin untuk menggunakan properti tipe protokol dengan tipe associatedtype
):
class AnyViewModel<ViewModelEvent, ViewEvent>: ViewModel { var output: Output<ViewModelEvent> let startClosure: EmptyClosure let handleClosure: (ViewEvent) -> Void let vm: Any? private var isStarted = false init?<ViewModelType: ViewModel>(with vm: ViewModelType?) where ViewModelType.ViewModelEvent == ViewModelEvent, ViewModelType.ViewEvent == ViewEvent { guard let vm = vm else { return nil } self.output = vm.output self.vm = vm self.startClosure = { [weak vm] in vm?.start() } self.handleClosure = { [weak vm] in vm?.handle(event: $0) }//vm.handle } func start() { if !isStarted { isStarted = true startClosure() } } func handle(event: ViewEvent) { handleClosure(event) } }
Lebih jauh lagi
Kami memutuskan untuk melangkah lebih jauh, dan jelas tidak menyimpan properti dalam tampilan, tetapi mengaturnya melalui runtime, secara total, ekstensi untuk protokol View
ternyata seperti ini:
extension View where Self: NSObject { func bind<ViewModelType: ViewModel>(with vm: ViewModelType?) where ViewModelType.ViewModelEvent == ViewModelEvent, ViewModelType.ViewEvent == ViewEvent { guard let vm = AnyViewModel(with: vm) else { return } vm.output.handlers.append({ [weak self] event in if #available(iOS 10.0, *) { RunLoop.main.perform(inModes: [.default], block: { self?.handle(event: event) }) } else { DispatchQueue.main.async { self?.handle(event: event) } } }) output.handlers.append({ [weak vm] event in vm?.handle(event: event) }) p_viewModelSaving = vm setupBindings() vm.start() } private var p_viewModelSaving: Any? { get { return objc_getAssociatedObject(self, &ViewModelSavingHandle) } set { objc_setAssociatedObject(self, &ViewModelSavingHandle, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } var viewModel: AnyViewModel<ViewModelEvent, ViewEvent>? { return p_viewModelSaving as? AnyViewModel<ViewModelEvent, ViewEvent> } }
Ini adalah momen yang kontroversial, tetapi kami memutuskan bahwa akan lebih mudah untuk tidak menyatakan properti ini setiap saat.
Pola
Pendekatan ini sangat cocok dengan template Xcode dan memungkinkan Anda untuk menghasilkan modul dengan sangat cepat dalam beberapa klik. Contoh template untuk Tampilan:
final class ___VARIABLE_moduleName___ViewController: UIView, View { var output = Output<___VARIABLE_moduleName___ViewModel.ViewEvent>() override func viewDidLoad() { super.viewDidLoad() setupViews() } private func setupViews() { //Do layout and more } func handle(event: ___VARIABLE_moduleName___ViewModel.ViewModelEvent) { } }
Dan untuk ViewModel:
final class ___VARIABLE_moduleName___ViewModel: ViewModel { var output = Output<ViewModelEvent>() func start() { } func handle(event: ViewEvent) { } } extension ___VARIABLE_moduleName___ViewModel { enum ViewEvent { } enum ViewModelEvent { } }
Dan membuat inisialisasi modul dalam kode hanya membutuhkan tiga baris:
let viewModel = SomeViewModel() let view = SomeView() view.bind(with: viewModel)
Kesimpulan
Sebagai hasilnya, kami mendapat cara yang fleksibel untuk bertukar pesan antara View dan ViewModel, yang memiliki satu titik masuk dan berdasarkan pada pembuatan kode Xcode. Pendekatan ini memungkinkan untuk mempercepat pengembangan fitur dan ulasan permintaan-tarik, selain itu meningkatkan pembacaan dan kesederhanaan kode dan menyederhanakan penulisan tes (karena fakta bahwa, mengetahui urutan yang diinginkan dari peristiwa yang diterima dari model tampilan, mudah untuk menulis Unit-tes dengan urutan mana urutan ini dapat dijamin). Meskipun pendekatan ini telah mulai digunakan bersama kami baru-baru ini, kami berharap pendekatan ini akan sepenuhnya membenarkan dirinya dan sangat menyederhanakan pembangunan.
PS
Dan pengumuman kecil untuk pecinta pengembangan untuk iOS - sudah Kamis ini, 25 Juli, kami akan mengadakan mitap iOS di ART-SPACE , tiket masuk gratis, datang.