Bekerja Dengan Pola Statis: Tutorial MVVM Swift
Diterbitkan: 2022-03-11Hari ini kita akan melihat bagaimana kemungkinan dan harapan teknis baru dari pengguna kami untuk aplikasi berbasis data real-time menciptakan tantangan baru dalam cara kami menyusun program kami, terutama aplikasi seluler kami. Meskipun artikel ini membahas tentang iOS dan Swift, banyak pola dan kesimpulan yang sama-sama berlaku untuk Android dan aplikasi web.
Ada evolusi penting dalam cara kerja aplikasi seluler modern selama beberapa tahun terakhir. Berkat akses internet yang lebih luas dan teknologi seperti pemberitahuan push dan WebSockets, pengguna biasanya tidak lagi menjadi satu-satunya sumber peristiwa runtime—dan belum tentu yang paling penting lagi—di banyak aplikasi seluler saat ini.
Mari kita lihat lebih dekat seberapa baik dua pola desain Swift masing-masing bekerja dengan aplikasi obrolan modern: pola model-view-controller (MVC) klasik dan pola model-view-viewmodel yang disederhanakan (MVVM, terkadang bergaya “pola ViewModel” ”). Aplikasi obrolan adalah contoh yang baik karena mereka memiliki banyak sumber data dan perlu memperbarui UI mereka dengan berbagai cara setiap kali data diterima.
Aplikasi Obrolan kami
Aplikasi yang akan kita gunakan sebagai pedoman dalam tutorial Swift MVVM ini akan memiliki sebagian besar fitur dasar yang kita ketahui dari aplikasi obrolan seperti WhatsApp. Mari kita bahas fitur yang akan kita terapkan dan bandingkan MVVM vs MVC. Aplikasi:
- Akan memuat obrolan yang diterima sebelumnya dari disk
- Akan menyinkronkan obrolan yang ada melalui
GET
-permintaan dengan server - Akan menerima pemberitahuan push ketika pesan baru dikirim ke pengguna
- Akan terhubung ke WebSocket setelah kita berada di layar obrolan
- Dapat
POST
pesan baru ke obrolan - Akan menampilkan pemberitahuan dalam aplikasi ketika pesan baru diterima dari obrolan yang saat ini tidak kami gunakan
- Akan segera menampilkan pesan baru ketika kami menerima pesan baru untuk obrolan saat ini
- Akan mengirim pesan yang sudah dibaca ketika kita membaca pesan yang belum dibaca
- Akan menerima pesan yang telah dibaca ketika seseorang membaca pesan kami
- Memperbarui lencana penghitung pesan yang belum dibaca pada ikon aplikasi
- Menyinkronkan semua pesan yang diterima atau diubah kembali ke Data Inti
Dalam aplikasi demo ini, tidak akan ada implementasi API, WebSocket, atau Data Inti yang sebenarnya untuk menjaga implementasi Model sedikit lebih sederhana. Sebagai gantinya, saya telah menambahkan chatbot yang akan mulai membalas Anda setelah Anda memulai percakapan. Namun, semua perutean dan panggilan lainnya diimplementasikan seperti jika penyimpanan dan koneksinya nyata, termasuk jeda asinkron kecil sebelum kembali.
Tiga layar berikut telah dibuat:
MVC klasik
Pertama-tama, ada pola MVC standar untuk membangun aplikasi iOS. Ini adalah cara Apple menyusun semua kode dokumentasinya dan cara kerja elemen API dan UI. Itulah yang diajarkan kebanyakan orang saat mereka mengikuti kursus iOS.
Seringkali MVC disalahkan karena menyebabkan UIViewController
s membengkak dari beberapa ribu baris kode. Tetapi jika diterapkan dengan baik, dengan pemisahan yang baik antara setiap lapisan, kita dapat memiliki ViewController
s yang cukup ramping yang hanya berfungsi seperti manajer perantara antara View
s, Model
s, dan Controller
s lainnya.
Berikut adalah diagram alur untuk implementasi MVC aplikasi (mengabaikan CreateViewController
untuk kejelasan):
Mari kita membahas lapisan secara rinci.
Model
Lapisan model biasanya merupakan lapisan yang paling tidak bermasalah di MVC. Dalam hal ini, saya memilih untuk menggunakan ChatWebSocket
, ChatModel
, dan PushNotificationController
untuk menengahi antara objek Chat
dan Message
, sumber data eksternal, dan aplikasi lainnya. ChatModel
adalah sumber kebenaran dalam aplikasi dan hanya berfungsi di memori dalam aplikasi demo ini. Dalam aplikasi kehidupan nyata, itu mungkin akan didukung oleh Data Inti. Terakhir, ChatEndpoint
menangani semua panggilan HTTP.
Melihat
Tampilannya cukup besar karena harus menangani banyak tanggung jawab karena saya telah dengan hati-hati memisahkan semua kode tampilan dari UIViewController
s. Saya telah melakukan hal berikut:
- Menggunakan pola
enum
status (sangat direkomendasikan) untuk menentukan status tampilan saat ini. - Menambahkan fungsi yang terhubung ke tombol dan item antarmuka pemicu tindakan lainnya (seperti mengetuk Kembali saat memasukkan nama kontak.)
- Siapkan batasan dan hubungi kembali delegasi setiap saat.
Setelah Anda memasukkan UITableView
ke dalam campuran, tampilan sekarang jauh lebih besar daripada UIViewController
s, yang mengarah ke 300+ baris kode yang mengkhawatirkan dan banyak tugas campuran di ChatView
.
Pengontrol
Karena semua logika penanganan model telah dipindahkan ke ChatModel
. Semua kode tampilan—yang mungkin bersembunyi di sini dalam proyek yang kurang optimal dan terpisah—sekarang tinggal di tampilan, jadi UIViewController
s cukup ramping. Pengontrol tampilan sama sekali tidak menyadari seperti apa data model, bagaimana diambil, atau bagaimana seharusnya ditampilkan—itu hanya koordinat. Dalam proyek contoh, tidak ada UIViewController
s yang melebihi 150 baris kode.
Namun, ViewController masih melakukan hal-hal berikut:
- Menjadi delegasi untuk tampilan dan pengontrol tampilan lainnya
- Membuat instance dan mendorong (atau memunculkan) pengontrol tampilan jika diperlukan
- Mengirim dan menerima panggilan ke dan dari
ChatModel
- Memulai dan menghentikan WebSocket tergantung pada tahap siklus pengontrol tampilan
- Membuat keputusan logis seperti tidak mengirim pesan jika kosong
- Memperbarui tampilan
Ini masih banyak, tetapi sebagian besar adalah koordinasi, pemrosesan blok panggilan balik, dan penerusan.
Manfaat
- Pola ini dipahami oleh semua orang dan dipromosikan oleh Apple
- Bekerja dengan semua dokumentasi
- Tidak diperlukan kerangka kerja tambahan
Kekurangan
- View controller memiliki banyak tugas; banyak dari mereka pada dasarnya meneruskan data bolak-balik antara tampilan dan lapisan model
- Tidak terlalu cocok untuk menangani banyak sumber acara
- Kelas cenderung tahu banyak tentang kelas lain
Definisi masalah
Ini bekerja dengan sangat baik selama aplikasi mengikuti tindakan pengguna dan meresponsnya, seperti yang Anda bayangkan aplikasi seperti Adobe Photoshop atau Microsoft Word akan berfungsi. Pengguna mengambil tindakan, pembaruan UI, ulangi.
Tetapi aplikasi modern terhubung, seringkali dalam lebih dari satu cara. Misalnya, Anda berinteraksi melalui REST API, menerima pemberitahuan push, dan dalam beberapa kasus, Anda juga terhubung ke WebSocket.
Dengan itu, tiba-tiba pengontrol tampilan perlu menangani lebih banyak sumber informasi, dan setiap kali pesan eksternal diterima tanpa pengguna memicunya—seperti menerima pesan melalui WebSocket—sumber informasi perlu menemukan jalan kembali ke kanan. melihat pengontrol. Ini membutuhkan banyak kode hanya untuk merekatkan setiap bagian bersama-sama untuk melakukan apa yang pada dasarnya adalah tugas yang sama.
Sumber Data Eksternal
Mari kita lihat apa yang terjadi ketika kita menerima pesan push:
class PushNotificationController { class func received(notification: UNNotification, whenProcessed result: (_ shouldShow: Bool) -> Void) { let shouldShowNotification: Bool defer { result(shouldShowNotification) } let content = notification.request.content let date = DateParser.date(from: content.subtitle) ?? Date() let sender: Message.Sender = .other(name: content.title) let pushedMessage = Message(with: sender, message: content.body, state: .sent, sendDate: date) ChatModel.received(message: pushedMessage, by: content.title) if let chatViewController = chatViewController(handling: content.title) { chatViewController.received(message: pushedMessage) shouldShowNotification = false } else { shouldShowNotification = true } updateChats(for: content.title) } private static func updateChats(for contact: String) { guard let chat = ChatModel.loadedChats.first(where: { (chat) -> Bool in chat.contact == contact }) else { return assertionFailure("Chat for received message should always exist") } BaseNavigationViewController.navigationController?.viewControllers.forEach({ (viewController) in switch viewController { case let chatsViewController as UpdatedChatDelegate: chatsViewController.updated(chat: chat) default: break } }) } private static func chatViewController(handling contact: String) -> ChatViewController? { guard let lastViewController = BaseNavigationViewController.navigationController?.viewControllers.last as? ChatViewController, lastViewController.chat.contact == contact else { return nil } return lastViewController } }
Kami harus menggali tumpukan pengontrol tampilan secara manual untuk mencari tahu apakah ada pengontrol tampilan yang perlu memperbarui sendiri setelah kami mendapatkan pemberitahuan push. Dalam hal ini, kami juga ingin memperbarui layar yang mengimplementasikan UpdatedChatDelegate
, yang, dalam hal ini, hanya ChatsViewController
. Kami juga melakukan ini untuk mengetahui apakah kami harus menekan notifikasi karena kami sudah melihat Chat
yang dimaksudkan. Dalam hal ini, kami akhirnya mengirimkan pesan ke pengontrol tampilan. Cukup jelas bahwa PushNotificationController
perlu mengetahui terlalu banyak tentang aplikasi untuk dapat melakukan tugasnya.
Jika ChatWebSocket
akan mengirimkan pesan ke bagian lain dari aplikasi juga, alih-alih memiliki hubungan satu-ke-satu dengan ChatViewController
, kita akan menghadapi masalah yang sama di sana.
Jelas kita harus menulis kode yang cukup invasif setiap kali kita menambahkan sumber eksternal lain. Kode ini juga cukup rapuh, karena sangat bergantung pada struktur aplikasi dan mendelegasikan meneruskan data kembali ke hierarki untuk bekerja.
Delegasi
Pola MVC juga menambahkan kompleksitas ekstra ke dalam campuran setelah kami menambahkan pengontrol tampilan lainnya. Ini karena pengontrol tampilan cenderung mengetahui satu sama lain melalui delegasi, penginisialisasi, dan—dalam kasus papan cerita— prepareForSegue
saat meneruskan data dan referensi. Setiap pengontrol tampilan menangani koneksinya sendiri ke model atau pengontrol mediasi, dan keduanya mengirim dan menerima pembaruan.
Selain itu, tampilan berkomunikasi kembali ke pengontrol tampilan melalui delegasi. Meskipun ini berhasil, itu berarti ada cukup banyak langkah yang perlu kita ambil untuk meneruskan data, dan saya selalu menemukan diri saya banyak melakukan refactoring di sekitar panggilan balik dan memeriksa apakah delegasi benar-benar diatur.
Dimungkinkan untuk memecahkan satu pengontrol tampilan dengan mengubah kode di yang lain, seperti data basi di ChatsListViewController
karena ChatViewController
tidak memanggil lagi yang updated(chat: Chat)
. Terutama dalam skenario yang lebih kompleks, sangat sulit untuk menjaga semuanya tetap sinkron.
Pemisahan antara Tampilan dan Model
Dengan menghapus semua kode terkait tampilan dari pengontrol tampilan ke customView
s dan memindahkan semua kode terkait model ke pengontrol khusus, pengontrol tampilan cukup ramping dan terpisah. Namun, masih ada satu masalah yang tersisa: Ada kesenjangan antara apa yang ingin ditampilkan oleh tampilan dan data yang ada dalam model. Contoh yang bagus adalah ChatListView
. Apa yang ingin kami tampilkan adalah daftar sel yang memberi tahu kami dengan siapa kami berbicara, apa pesan terakhir, tanggal pesan terakhir, dan berapa banyak pesan yang belum dibaca yang tersisa di Chat
:
Namun, kami melewati model yang tidak tahu apa yang ingin kami lihat. Sebaliknya, itu hanya Chat
dengan kontak, yang berisi pesan:
class Chat { let contact: String var messages: [Message] init(with contact: String, messages: [Message] = []) { self.contact = contact self.messages = messages } }
Sekarang dimungkinkan untuk dengan cepat menambahkan beberapa kode tambahan yang akan memberi kita pesan terakhir dan jumlah pesan, tetapi memformat tanggal ke string adalah tugas yang secara tegas dimiliki oleh lapisan tampilan:
var unreadMessages: Int { return messages.filter { switch ($0.sender, $0.state) { case (.user, _), (.other, .read): return false case (.other, .sending), (.other, .sent): return true } }.count } var lastMessage: Date? { return messages.last?.sendDate }
Jadi akhirnya kami memformat tanggal di ChatItemTableViewCell
saat kami menampilkannya:
func configure(with chat: Chat) { participant.text = chat.contact lastMessage.text = chat.messages.last?.message ?? "" lastMessageDate.text = chat.lastMessage.map { lastMessageDate in DateRenderer.string(from: lastMessageDate) } ?? "" show(unreadMessageCount: chat.unreadMessages) }
Bahkan dalam contoh yang cukup sederhana, cukup jelas bahwa ada ketegangan antara apa yang dibutuhkan tampilan dan apa yang disediakan model.
MVVM yang digerakkan oleh Peristiwa Statis, alias Pengambilan Berbasis Peristiwa Statis pada "Pola ViewModel"
MVVM statis berfungsi dengan model tampilan, tetapi alih-alih membuat lalu lintas dua arah melaluinya—seperti yang biasa kami lakukan melalui pengontrol tampilan dengan MVC—kami membuat model tampilan yang tidak dapat diubah yang memperbarui UI setiap kali UI perlu berubah sebagai respons terhadap suatu peristiwa .
Suatu peristiwa dapat dipicu oleh hampir semua bagian kode, selama ia dapat menyediakan data terkait yang diperlukan oleh peristiwa enum
. Misalnya, menerima acara yang received(new: Message)
dapat dipicu oleh pemberitahuan push, WebSocket, atau panggilan jaringan biasa.
Mari kita lihat dalam diagram:
Pada pandangan pertama, tampaknya sedikit lebih kompleks daripada contoh MVC klasik, karena ada lebih banyak kelas yang terlibat untuk mencapai hal yang persis sama. Tetapi pada pemeriksaan lebih dekat, tidak ada hubungan dua arah lagi.
Yang lebih penting adalah setiap pembaruan UI dipicu oleh suatu peristiwa, jadi hanya ada satu rute melalui aplikasi untuk semua yang terjadi. Segera jelas peristiwa apa yang dapat Anda harapkan. Juga jelas di mana Anda harus menambahkan yang baru jika diperlukan, atau menambahkan perilaku baru saat menanggapi peristiwa yang ada.
Setelah refactoring, saya berakhir dengan banyak kelas baru, seperti yang saya tunjukkan di atas. Anda dapat menemukan implementasi versi MVVM statis saya di GitHub. Namun, ketika saya membandingkan perubahan dengan alat cloc
, menjadi jelas bahwa sebenarnya tidak ada banyak kode tambahan sama sekali:
Pola | File | Kosong | Komentar | Kode |
---|---|---|---|---|
MVC | 30 | 386 | 217 | 1807 |
MVVM | 51 | 442 | 359 | 1981 |
Hanya ada peningkatan 9 persen dalam baris kode. Lebih penting lagi, ukuran rata-rata file ini turun dari 60 baris kode menjadi hanya 39.
Juga yang terpenting, penurunan terbesar dapat ditemukan di file yang biasanya terbesar di MVC: pengontrol tampilan dan tampilan. Tampilan hanya 74 persen dari ukuran aslinya dan pengontrol tampilan sekarang hanya 53 persen dari ukuran aslinya.
Perlu dicatat juga bahwa banyak kode tambahan adalah kode perpustakaan yang membantu melampirkan blok ke tombol dan objek lain di pohon visual, tanpa memerlukan @IBAction
klasik atau pola delegasi MVC.
Mari kita jelajahi lapisan yang berbeda dari desain ini satu per satu.
Peristiwa
Acara selalu enum
, biasanya dengan nilai terkait. Seringkali mereka akan tumpang tindih dengan salah satu entitas dalam model Anda tetapi belum tentu demikian. Dalam hal ini, aplikasi dibagi menjadi dua enum
event utama: ChatEvent
dan MessageEvent
. ChatEvent
adalah untuk semua pembaruan pada objek obrolan itu sendiri:
enum ChatEvent { case started case loaded(chats: [Chat]) case creating(chat: Chat) case created(chat: Chat) case createChatFailed(reason: String) }
Yang lainnya berurusan dengan semua acara terkait Pesan:
enum MessageEvent { case sending(message: Message, contact: String, previousMessages: [Message]) case sent(message: Message, contact: String) case failedSending(message: Message, contact: String, reason: String) case received(message: Message, contact: String) case userReads(messagesSentBy: String) case userRead(othersMessages: [Message], sentBy: String) case otherRead(yourMessage: Message, reader: String) }
Sangat penting untuk membatasi *Event
enum
s Anda ke ukuran yang wajar. Jika Anda membutuhkan 10 kasus atau lebih, itu biasanya pertanda Anda mencoba membahas lebih dari satu subjek.
Catatan: Konsep enum
sangat kuat di Swift. Saya cenderung sering menggunakan enum
s dengan nilai terkait, karena mereka dapat menghilangkan banyak ambiguitas yang seharusnya Anda miliki dengan nilai opsional.
Tutorial MVVM Swift: Router Acara
Router acara adalah titik masuk untuk setiap acara yang terjadi dalam aplikasi. Setiap kelas yang dapat memberikan nilai terkait dapat membuat acara dan mengirimkannya ke router acara. Jadi mereka dapat dipicu oleh segala jenis sumber, misalnya:
- Pengguna masuk ke pengontrol tampilan tertentu
- Pengguna mengetuk tombol tertentu
- Aplikasi dimulai
- Acara eksternal seperti:
- Permintaan jaringan kembali dengan kegagalan atau data baru
- Pemberitahuan push
- Pesan WebSocket
Perute acara harus tahu sesedikit mungkin tentang sumber acara dan sebaiknya tidak tahu sama sekali. Tak satu pun dari peristiwa dalam aplikasi contoh ini memiliki indikator dari mana asalnya, jadi sangat mudah untuk mencampurkan segala jenis sumber pesan. Misalnya, WebSocket memicu peristiwa yang sama— received(message: Message, contact: String)
—sebagai pemberitahuan push baru.

Acara (Anda sudah dapat menebaknya) dialihkan ke kelas yang perlu memproses lebih lanjut acara ini. Biasanya, satu-satunya kelas yang dipanggil adalah model layer (jika data perlu ditambahkan, diubah, atau dihapus) dan event handler. Saya akan membahas keduanya sedikit lebih jauh, tetapi fitur utama dari router acara adalah memberikan satu titik akses mudah ke semua acara dan meneruskan pekerjaan ke kelas lain. Berikut ChatEventRouter
sebagai contoh:
class ChatEventRouter { static func route(event: ChatEvent) { switch event { case .loaded(let chats): ChatEventHandler.loaded(chats: chats) case .creatingChat(let contact): let chat = ChatModel.create(chatWith: contact) ChatEndpoint.create(chat: chat) ChatEventHandler.creatingChat() case .created(let chat): ChatEventHandler.created(chat: chat) case .createChatFailed(let reason): ChatEventHandler.failedCreatingChat(reason: reason) } } }
Cukup sedikit yang terjadi di sini: Satu-satunya hal yang kami lakukan adalah memperbarui model dan meneruskan acara ke ChatEventHandler
sehingga UI diperbarui.
Tutorial MVVM Swift: Pengontrol Model
Ini adalah kelas yang persis sama seperti yang kita gunakan di MVC, karena sudah bekerja dengan cukup baik. Ini mewakili keadaan aplikasi dan biasanya akan didukung oleh Data Inti atau perpustakaan penyimpanan lokal.
Lapisan model—jika diterapkan dengan benar di MVC—sangat jarang memerlukan pemfaktoran ulang agar sesuai dengan pola yang berbeda. Perubahan terbesar adalah bahwa perubahan model terjadi dari kelas yang lebih sedikit, membuatnya sedikit lebih jelas di mana perubahan terjadi.
Dalam alternatif mengambil pola ini, Anda dapat mengamati perubahan model dan memastikan mereka ditangani. Dalam hal ini, saya memilih untuk membiarkan hanya kelas *EventRouter
dan *Endpoint
mengubah model, jadi ada tanggung jawab yang jelas tentang di mana dan kapan model diperbarui. Sebaliknya, jika kita mengamati perubahan, kita harus menulis kode tambahan untuk menyebarkan peristiwa yang tidak mengubah model seperti kesalahan melalui ChatEventHandler
, yang akan membuatnya kurang jelas bagaimana peristiwa mengalir melalui aplikasi.
Tutorial MVVM Swift: Penangan Acara
Pengendali kejadian adalah tempat di mana tampilan atau pengontrol tampilan dapat mendaftarkan (dan membatalkan pendaftaran) diri mereka sendiri sebagai pendengar untuk menerima model tampilan yang diperbarui, yang dibuat setiap kali ChatEventRouter
memanggil fungsi di ChatEventHandler
.
Anda dapat melihat bahwa ini secara kasar mencerminkan semua status tampilan yang kami gunakan di MVC sebelumnya. Jika Anda menginginkan jenis pembaruan UI lainnya—seperti suara atau memicu mesin Taptic—mereka juga dapat dilakukan dari sini.
protocol ChatListListening: class { func updated(list: ChatListViewModel) } protocol CreateChatListening: class { func updated(create: CreateChatViewModel) } class ChatEventHandler { private static var chatListListening: [ChatListListening?] = [] private static var createChatListening: [CreateChatListening?] = [] class func add(listener: ChatListListening) { weak var weakListener = listener chatListListening.append(weakListener) } class func remove(listener: ChatListListening) { chatListListening = chatListListening.filter { $0 !== listener } } class func add(listener: CreateChatListening) { weak var weakListener = listener createChatListening.append(weakListener) listener.updated(create: CreateChatViewModelBuilder.build(isSending: false, error: nil)) } class func remove(listener: CreateChatListening) { createChatListening = createChatListening.filter { $0 !== listener } } class func started() { ChatEndpoint.fetchChats() let loadingViewModel = ChatListViewModelBuilder.buildLoading() chatListListening.forEach { $0?.updated(list: loadingViewModel) } } class func loaded(chats: [Chat]) { let chatList = ChatListViewModelBuilder.build(for: chats) chatListListening.forEach { $0?.updated(list: chatList) } } class func creatingChat() { let createChat = CreateChatViewModelBuilder.build(isSending: true, error: nil) createChatListening.forEach { $0?.updated(create: createChat) } } class func failedCreatingChat(reason: String) { let createChat = CreateChatViewModelBuilder.build(isSending: false, error: reason) createChatListening.forEach { $0?.updated(create: createChat) } } class func created(chat: Chat) { let createChat = CreateChatViewModelBuilder.build(isSending: false, error: nil) createChatListening.forEach { $0?.updated(create: createChat) } updateAllChatLists() let chatViewController = ChatViewController(for: chat) BaseNavigationViewController.pushViewController(chatViewController, animated: true, removePreviousFromStack: true) } class func updateAllChatLists() { let chatListViewModel = ChatListViewModelBuilder.build(for: ChatModel.allChats()) chatListListening.forEach { $0?.updated(list: chatListViewModel) } } }
Kelas ini tidak lebih dari memastikan bahwa pendengar yang tepat bisa mendapatkan model tampilan yang tepat setiap kali peristiwa tertentu terjadi. Listener baru bisa segera mendapatkan model tampilan saat mereka ditambahkan jika itu diperlukan untuk menyiapkan status awal mereka. Selalu pastikan Anda menambahkan referensi yang weak
ke daftar untuk mencegah siklus retensi.
Tutorial Swift MVVM: Lihat Model
Berikut adalah salah satu perbedaan terbesar antara apa yang dilakukan banyak pola MVVM versus apa yang dilakukan varian statis. Dalam hal ini, model tampilan tidak dapat diubah alih-alih mengatur dirinya sendiri sebagai perantara dua arah yang permanen antara model dan tampilan. Mengapa kita mau melakukan hal tersebut? Mari kita berhenti sejenak untuk menjelaskannya sejenak.
Salah satu aspek terpenting dalam membuat aplikasi yang berfungsi dengan baik dalam semua kemungkinan kasus adalah memastikan bahwa status aplikasi sudah benar. Jika UI tidak cocok dengan model atau memiliki data yang kedaluwarsa, semua yang kami lakukan dapat menyebabkan penyimpanan data yang salah atau aplikasi mogok atau berperilaku dengan cara yang tidak terduga.
Salah satu tujuan penerapan pola ini adalah kita tidak memiliki status dalam aplikasi kecuali benar-benar diperlukan. Apa itu negara, tepatnya? State pada dasarnya adalah setiap tempat di mana kita menyimpan representasi dari tipe data tertentu. Salah satu jenis status khusus adalah status UI Anda saat ini, yang tentu saja tidak dapat kami cegah dengan aplikasi berbasis UI. Jenis status lainnya semuanya terkait dengan data. Jika kita memiliki salinan larik Chat
yang mencadangkan UITableView
kita di layar Daftar Obrolan, itu adalah contoh status duplikat. Model tampilan terikat dua arah tradisional akan menjadi contoh lain dari duplikat Chat
s pengguna kami.
Dengan meneruskan model tampilan yang tidak dapat diubah yang disegarkan pada setiap perubahan model, kami menghilangkan jenis status duplikat ini, karena setelah diterapkan sendiri ke UI, status tersebut tidak lagi digunakan. Kemudian kita hanya memiliki dua jenis status yang tidak dapat kita hindari—UI dan model—dan keduanya sangat sinkron satu sama lain.
Jadi model tampilan di sini cukup berbeda dari beberapa aplikasi MVVM. Ini hanya berfungsi sebagai penyimpanan data yang tidak dapat diubah untuk semua flag, nilai, blok, dan nilai lain yang diperlukan tampilan untuk mencerminkan keadaan model, tetapi tidak dapat diperbarui dengan cara apa pun oleh Tampilan.
Oleh karena itu dapat menjadi struct
sederhana yang tidak dapat diubah . Untuk menjaga struct
ini sesederhana mungkin, kami akan membuat instance dengan pembuat model tampilan. Salah satu hal menarik tentang model tampilan adalah ia mendapatkan tanda perilaku seperti shouldShowBusy
dan shouldShowError
yang menggantikan mekanisme enum
keadaan yang sebelumnya ditemukan dalam tampilan. Berikut data untuk ChatItemTableViewCell
yang telah kami analisis sebelumnya:
struct ChatListItemViewModel { let contact: String let message: String let lastMessageDate: String let unreadMessageCount: Int let itemTapped: () -> Void }
Karena pembuat model tampilan sudah menangani nilai dan tindakan yang tepat yang dibutuhkan tampilan, semua data diformat sebelumnya. Juga baru adalah blok yang akan dipicu setelah item di-tap. Mari kita lihat bagaimana itu dibuat oleh pembuat model tampilan.
Lihat Pembuat Model
Pembuat model tampilan dapat membuat instance model tampilan, mengubah input seperti Chat
s atau Message
s menjadi model tampilan yang disesuaikan secara sempurna untuk tampilan tertentu. Salah satu hal terpenting yang terjadi dalam pembuat model tampilan adalah menentukan apa yang sebenarnya terjadi di dalam blok dalam model tampilan. Blok yang dipasang oleh pembuat model tampilan harus sangat pendek, memanggil fungsi bagian lain dari arsitektur sesegera mungkin. Blok semacam itu seharusnya tidak memiliki logika bisnis apa pun.
class ChatListItemViewModelBuilder { class func build(for chat: Chat) -> ChatListItemViewModel { let lastMessageText = chat.messages.last?.message ?? "" let lastMessageDate = (chat.messages.last?.sendDate).map { DateRenderer.string(from: $0) } ?? "" let unreadMessageCount = ChatModel.unreadMessages(for: chat.contact).count return ChatListItemViewModel(contact: chat.contact, message: lastMessageText, lastMessageDate: lastMessageDate, unreadMessageCount: unreadMessageCount, itemTapped: { show(chat: chat) }) } private class func show(chat: Chat) { let chatViewController = ChatViewController(for: chat) BaseNavigationViewController.pushViewController(chatViewController, animated: true) } }
Sekarang semua pemformatan sebelumnya terjadi di tempat yang sama dan perilaku akan diputuskan di sini juga. Ini adalah kelas yang cukup penting dalam hierarki ini dan mungkin menarik untuk melihat bagaimana builder yang berbeda dalam aplikasi demo telah diimplementasikan dan menangani skenario yang lebih rumit.
Tutorial Swift MVVM: Lihat Pengontrol
Pengontrol tampilan dalam arsitektur ini tidak banyak berfungsi. Ini akan mengatur dan meruntuhkan segala sesuatu yang berhubungan dengan pandangannya. Paling cocok untuk melakukan ini karena mendapatkan semua panggilan balik siklus hidup yang diperlukan untuk menambah dan menghapus pendengar pada waktu yang tepat.
Terkadang perlu memperbarui elemen UI yang tidak tercakup oleh tampilan root, seperti judul atau tombol di bilah navigasi. Itu sebabnya saya biasanya masih mendaftarkan pengontrol tampilan sebagai pendengar router acara jika saya memiliki model tampilan yang mencakup seluruh tampilan untuk pengontrol tampilan yang diberikan; Saya meneruskan model tampilan ke tampilan sesudahnya. Tetapi juga baik untuk mendaftarkan UIView
sebagai pendengar secara langsung jika ada bagian layar yang memiliki tingkat pembaruan berbeda, misalnya ticker saham langsung di atas halaman tentang perusahaan tertentu.
Kode untuk ChatsViewController
sekarang sangat pendek sehingga dibutuhkan kurang dari satu halaman. Yang tersisa adalah mengesampingkan tampilan dasar, menambahkan dan menghapus tombol tambah dari bilah navigasi, mengatur judul, menambahkan dirinya sendiri sebagai pendengar, dan menerapkan protokol ChatListListening
:
class ChatsViewController: UIViewController { private lazy var customView: ChatsView = { let customView = ChatsView() return customView }() private var addButton: UIBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: nil, action: nil) override func loadView() { view = customView } override func viewDidLoad() { super.viewDidLoad() ChatEventHandler.add(listener: self) ChatEventRouter.route(event: .started) title = "Chats" } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationItem.rightBarButtonItem = addButton } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) navigationItem.rightBarButtonItem = nil } } extension ChatsViewController: ChatListListening { func updated(list: ChatListViewModel) { addButton.action(block: { _ in list.addChat() }) customView.display(viewModel: list) } }
Tidak ada lagi yang bisa dilakukan di tempat lain, karena ChatsViewController
dilucuti hingga minimum.
Tutorial MVVM Swift: Lihat
Tampilan dalam arsitektur MVVM yang tidak dapat diubah masih bisa sangat berat, karena masih memiliki daftar tugas, tetapi saya berhasil menghapus tanggung jawab berikut dibandingkan dengan arsitektur MVC:
- Menentukan apa yang perlu diubah dalam menanggapi keadaan baru
- Menerapkan delegasi dan fungsi untuk tindakan
- Tangani pemicu lihat-untuk-lihat seperti gerakan dan animasi yang dipicu
- Mengubah data sedemikian rupa sehingga dapat ditampilkan (seperti
Date
s keString
s)
Apalagi poin terakhir memiliki keunggulan yang cukup besar. Di MVC, ketika pengontrol tampilan atau tampilan bertanggung jawab untuk mengubah data untuk tampilan, itu akan selalu melakukan ini di utas utama karena sangat sulit untuk memisahkan perubahan nyata pada UI yang diperlukan untuk terjadi di utas ini dari hal-hal yang tidak diharuskan untuk berjalan di atasnya. Dan menjalankan kode non-UI-perubahan di utas utama dapat menyebabkan aplikasi yang kurang responsif.
Sebagai gantinya, dengan pola MVVM ini, semuanya mulai dari blok yang dipicu oleh ketukan hingga model tampilan dibuat dan akan diteruskan ke pendengar—kita dapat menjalankan ini semua di utas terpisah dan hanya masuk ke utas utama di akhir untuk melakukan pembaruan UI. Jika aplikasi kita menghabiskan lebih sedikit waktu di utas utama, itu akan berjalan lebih lancar.
Setelah model tampilan menerapkan status baru ke tampilan, itu dibiarkan menguap alih-alih berlama-lama sebagai lapisan status lainnya. Segala sesuatu yang mungkin memicu suatu peristiwa dilampirkan ke item dalam tampilan dan kami tidak akan berkomunikasi kembali ke model tampilan.
Satu hal yang penting untuk diingat: Anda tidak dipaksa untuk memetakan model tampilan melalui pengontrol tampilan ke tampilan. Seperti disebutkan sebelumnya, bagian tampilan dapat dikelola oleh model tampilan lain, terutama bila kecepatan pembaruan bervariasi. Pertimbangkan Google Spreadsheet sedang diedit oleh orang yang berbeda sambil tetap membuka panel obrolan untuk kolaborator—tidak terlalu berguna untuk menyegarkan dokumen setiap kali pesan obrolan masuk.
Contoh yang terkenal adalah implementasi type-to-find di mana kotak pencarian diperbarui dengan hasil yang lebih akurat saat kita memasukkan lebih banyak teks. Ini adalah bagaimana saya akan mengimplementasikan pelengkapan otomatis di kelas CreateAutocompleteView
: Seluruh layar dilayani oleh CreateViewModel
tetapi kotak teks mendengarkan AutocompleteContactViewModel
sebagai gantinya.
Contoh lain adalah menggunakan validator formulir, yang dapat dibuat sebagai "loop lokal" (melampirkan atau menghapus status kesalahan ke bidang dan mendeklarasikan formulir sebagai valid) atau dilakukan dengan memicu suatu peristiwa.
Model Tampilan Statis yang Tidak Dapat Diubah Memberikan Pemisahan yang Lebih Baik
Dengan menggunakan implementasi MVVM statis, kami akhirnya berhasil memisahkan semua lapisan sepenuhnya karena model tampilan sekarang menjembatani antara model dan tampilan. Kami juga mempermudah pengelolaan peristiwa yang tidak disebabkan oleh tindakan pengguna dan menghapus banyak ketergantungan antara berbagai bagian aplikasi kami. Satu-satunya hal yang dilakukan pengontrol tampilan adalah mendaftarkan (dan membatalkan pendaftaran) dirinya sendiri ke pengendali acara sebagai pendengar untuk acara yang ingin diterimanya.
Manfaat:
- Lihat dan lihat implementasi pengontrol cenderung jauh lebih ringan
- Kelas lebih terspesialisasi dan terpisah
- Acara dapat dipicu dengan mudah dari mana saja
- Acara mengikuti jalur yang dapat diprediksi melalui sistem
- Status hanya diperbarui dari satu tempat
- App can be more performant as it's easier to do work off the main thread
- Views receive tailor-made view models and are perfectly separated from the models
Downsides:
- A full view model is created and sent every time the UI needs to update, often overwriting the same button text with the same button text, and replacing blocks with blocks that do exactly the same
- Requires some helper extensions to make button taps and other UI events work well with the blocks in the view model
- Event
enum
s can easily grow pretty large in complex scenarios and might be hard to split up
The great thing is that this is a pure Swift pattern: It does not require a third-party Swift MVVM framework, nor does it exclude the use of classic MVC, so you can easily add new features or refactor problematic parts of your application today without being forced to rewrite your whole application.
There are other approaches to combat large view controllers that provide better separation as well. I couldn't include them all in full detail to compare them, but let's take a brief look at some of the alternatives:
- Some form of the MVVM pattern
- Some form of Reactive (using RxSwift, sometimes combined with MVVM)
- The model-view-presenter pattern (MVP)
- The view-interactor-presenter-entity-router pattern (VIPER)
Traditional MVVM replaces most of the view controller code with a view model that is just a regular class and can be tested more easily in isolation. Since it needs to be a bi-directional bridge between the view and the model it often implements some form of Observables. That's why you often see it used together with a framework like RxSwift.
MVP and VIPER deal with extra abstraction layers between the model and the view in a more traditional way, while Reactive really remodels the way data and events flow through your application.
The Reactive style of programming is gaining a lot of popularity lately and actually is pretty close to the static MVVM approach with events, as explained in this article. The major difference is that it usually requires a framework, and a lot of your code is specifically geared towards that framework.
MVP is a pattern where both the view controller and the view are considered to be the view layer. The presenter transforms the model and passes it to the view layer, while I transform the data into a view model first. Since the view can be abstracted to a protocol, it's much easier to test.
VIPER takes the presenter from MVP, adds a separate “interactor” for business logic, calls the model layer “entity,” and has a router for navigation purposes (and to complete the acronym). It can be considered a more detailed and decoupled form of MVP.
So there you have it: static event-driven MVVM explained. I look forward to hearing from you in the comments below!