Tutorial Swift: Pengantar Pola Desain MVVM
Diterbitkan: 2022-03-11Jadi, Anda memulai proyek iOS baru, Anda menerima dari desainer semua dokumen .pdf
dan .sketch
yang dibutuhkan, dan Anda sudah memiliki visi tentang bagaimana Anda akan membangun aplikasi baru ini.
Anda mulai mentransfer layar UI dari sketsa desainer ke file ViewController
.swift
, .xib
dan .storyboard
.
UITextField
di sini, UITableView
di sana, beberapa UILabels
lagi dan sedikit UIButtons
. IBOutlets
dan IBActions
juga disertakan. Semua baik, kami masih di zona UI.
Namun, inilah saatnya untuk melakukan sesuatu dengan semua elemen UI ini; UIButtons
akan menerima sentuhan jari, UILabels
dan UITableViews
akan membutuhkan seseorang untuk memberi tahu mereka apa yang harus ditampilkan dan dalam format apa.
Tiba-tiba, Anda memiliki lebih dari 3.000 baris kode.
Anda berakhir dengan banyak kode spaghetti.
Langkah pertama untuk mengatasinya adalah dengan menerapkan pola desain Model-View-Controller (MVC). Namun, pola ini memiliki masalah sendiri. Muncullah pola desain Model-View-ViewModel (MVVM) yang menyelamatkan hari.
Berurusan dengan Spaghetti Code
Dalam waktu singkat, ViewController
awal Anda menjadi terlalu pintar dan terlalu masif.
Kode jaringan, kode penguraian data, kode penyesuaian data untuk presentasi UI, pemberitahuan status aplikasi, perubahan status UI. Semua kode itu terpenjara di dalam if
-ology dari satu file yang tidak dapat digunakan kembali dan hanya akan muat dalam proyek ini.
Kode ViewController
Anda telah menjadi kode spaghetti yang terkenal.
Bagaimana itu bisa terjadi?
Kemungkinan alasannya adalah seperti ini:
Anda terburu-buru untuk melihat bagaimana data back-end berperilaku di dalam UITableView
, jadi Anda meletakkan beberapa baris kode jaringan di dalam metode temp ViewController
hanya untuk mengambil .json
itu dari jaringan. Selanjutnya, Anda perlu memproses data di dalam .json
, jadi Anda menulis metode temp lain untuk mencapainya. Atau, lebih buruk lagi, Anda melakukannya di dalam metode yang sama.
ViewController
terus berkembang ketika kode otorisasi pengguna muncul. Kemudian format data mulai berubah, UI berevolusi dan membutuhkan beberapa perubahan radikal, dan Anda terus menambahkan lebih banyak if
s ke dalam if
-ology yang sudah masif.
Tapi, kenapa UIViewController
menjadi tidak terkendali?
UIViewController
adalah tempat yang logis untuk mulai mengerjakan kode UI Anda. Ini mewakili layar fisik yang Anda lihat saat menggunakan aplikasi apa pun dengan perangkat iOS Anda. Bahkan Apple menggunakan UIViewControllers
di aplikasi sistem utamanya ketika beralih di antara aplikasi yang berbeda dan UI animasinya.
Apple mendasarkan abstraksi UI-nya di dalam UIViewController
, karena merupakan inti dari kode UI iOS dan bagian dari pola desain MVC .
Meningkatkan ke Pola Desain MVC
Dalam pola desain MVC, View seharusnya tidak aktif dan hanya menampilkan data yang disiapkan sesuai permintaan.
Controller harus bekerja pada data Model untuk mempersiapkannya untuk Views , yang kemudian menampilkan data tersebut.
View juga bertanggung jawab untuk memberi tahu Controller tentang tindakan apa pun, seperti sentuhan pengguna.
Seperti disebutkan, UIViewController
biasanya merupakan titik awal dalam membangun layar UI. Perhatikan bahwa dalam namanya, ini berisi "tampilan" dan "pengontrol". Ini berarti bahwa itu "mengontrol tampilan." Ini tidak berarti bahwa kode "pengontrol" dan "tampilan" harus masuk ke dalam.
Campuran tampilan dan kode pengontrol ini sering terjadi ketika Anda memindahkan IBOutlets
dari subview kecil di dalam UIViewController
, dan memanipulasi subview tersebut langsung dari UIViewController
. Sebagai gantinya, Anda seharusnya membungkus kode itu di dalam subkelas UIView
khusus.
Mudah untuk melihat bahwa ini dapat menyebabkan jalur kode View dan Controller disilangkan.
MVVM Untuk Menyelamatkan
Di sinilah pola MVVM berguna.
Karena UIViewController
seharusnya menjadi Pengendali dalam pola MVC, dan itu sudah melakukan banyak hal dengan Views , kita dapat menggabungkannya ke dalam View dari pola baru kita - MVVM .
Dalam pola desain MVVM, Model sama dengan pola MVC. Ini mewakili data sederhana.
Tampilan diwakili oleh objek UIView
atau UIViewController
, disertai dengan file .xib
dan .storyboard
, yang seharusnya hanya menampilkan data yang telah disiapkan. (Kami tidak ingin memiliki kode NSDateFormatter
, misalnya, di dalam Tampilan.)
Hanya string sederhana yang diformat yang berasal dari ViewModel .
ViewModel menyembunyikan semua kode jaringan asinkron, kode persiapan data untuk presentasi visual, dan mendengarkan kode untuk perubahan Model . Semua ini tersembunyi di balik model API yang terdefinisi dengan baik agar sesuai dengan View khusus ini.
Salah satu manfaat menggunakan MVVM adalah pengujian. Karena ViewModel adalah NSObject
murni (atau struct
misalnya), dan tidak digabungkan dengan kode UIKit
, Anda dapat mengujinya dengan lebih mudah dalam pengujian unit Anda tanpa memengaruhi kode UI.
Sekarang, View ( UIViewController
/ UIView
) menjadi lebih sederhana sementara ViewModel bertindak sebagai perekat antara Model dan View .
Menerapkan MVVM Di Swift
Untuk menunjukkan MVVM beraksi, Anda dapat mengunduh dan memeriksa contoh proyek Xcode yang dibuat untuk tutorial ini di sini. Proyek ini menggunakan Swift 3 dan Xcode 8.1.
Ada dua versi proyek: Pemula dan Selesai.
Versi Selesai adalah aplikasi mini yang lengkap, di mana Pemula adalah proyek yang sama tetapi tanpa metode dan objek yang diimplementasikan.
Pertama, saya sarankan Anda mengunduh proyek Starter , dan ikuti tutorial ini. Jika Anda memerlukan referensi cepat proyek untuk nanti, unduh proyek Selesai .
Pengenalan Proyek Tutorial
Proyek tutorial adalah aplikasi bola basket untuk melacak tindakan pemain selama pertandingan.
Ini digunakan untuk pelacakan cepat gerakan pengguna dan skor keseluruhan dalam permainan pikap.
Dua tim bermain sampai skor 15 (dengan setidaknya selisih dua poin) tercapai. Setiap pemain dapat mencetak satu poin hingga dua poin, dan setiap pemain dapat membantu, rebound, dan melakukan pelanggaran.
Hirarki proyek terlihat seperti ini:
Model
-
Game.swift
- Berisi logika permainan, melacak skor keseluruhan, melacak gerakan setiap pemain.
-
Team.swift
- Berisi nama tim dan daftar pemain (tiga pemain di setiap tim).
-
Player.swift
- Seorang pemain tunggal dengan nama.
Melihat
-
HomeViewController.swift
- Pengontrol tampilan root, yang menghadirkan
GameScoreboardEditorViewController
- Pengontrol tampilan root, yang menghadirkan
-
GameScoreboardEditorViewController.swift
- Dilengkapi dengan tampilan Interface Builder di
Main.storyboard
. - Layar yang menarik untuk tutorial ini.
- Dilengkapi dengan tampilan Interface Builder di
-
PlayerScoreboardMoveEditorView.swift
- Dilengkapi dengan tampilan Interface Builder di
PlayerScoreboardMoveEditorView.xib
- Subview dari view di atas, juga menggunakan design pattern MVVM.
- Dilengkapi dengan tampilan Interface Builder di
LihatModel
- Grup
ViewModel
kosong, inilah yang akan Anda bangun dalam tutorial ini.
Proyek Xcode yang diunduh sudah berisi placeholder untuk objek View ( UIView
dan UIViewController
). Proyek ini juga berisi beberapa objek yang dibuat khusus yang dibuat untuk mendemonstrasikan salah satu cara tentang cara memberikan data ke objek ViewModel ( grup Services
).
Grup Extensions
berisi ekstensi yang berguna untuk kode UI yang tidak termasuk dalam cakupan tutorial ini dan sudah cukup jelas.
Jika Anda menjalankan aplikasi pada titik ini, itu akan menampilkan UI yang telah selesai, tetapi tidak ada yang terjadi, ketika pengguna menekan tombol.
Ini karena Anda hanya membuat tampilan dan IBActions
tanpa menghubungkannya ke logika aplikasi dan tanpa mengisi elemen UI dengan data dari model (dari objek Game
, seperti yang akan kita pelajari nanti).
Menghubungkan Tampilan dan Model dengan ViewModel
Dalam pola desain MVVM, View seharusnya tidak tahu apa-apa tentang Model. Satu-satunya hal yang View tahu adalah bagaimana bekerja dengan ViewModel.
Mulailah dengan memeriksa Tampilan Anda.
Dalam file GameScoreboardEditorViewController.swift
, metode fillUI
kosong pada saat ini. Ini adalah tempat Anda ingin mengisi UI dengan data. Untuk mencapai ini, Anda perlu menyediakan data untuk ViewController
. Anda melakukan ini dengan objek ViewModel.
Pertama, buat objek ViewModel yang berisi semua data yang diperlukan untuk ViewController
ini.
Buka grup proyek ViewModel Xcode, yang akan kosong, buat file GameScoreboardEditorViewModel.swift
, dan jadikan itu sebagai protokol.
import Foundation protocol GameScoreboardEditorViewModel { var homeTeam: String { get } var awayTeam: String { get } var time: String { get } var score: String { get } var isFinished: Bool { get } var isPaused: Bool { get } func togglePause(); }
Menggunakan protokol seperti ini membuat semuanya tetap bagus dan bersih; Anda hanya harus menentukan data yang akan Anda gunakan.
Selanjutnya, buat implementasi untuk protokol ini.
Buat file baru, bernama GameScoreboardEditorViewModelFromGame.swift
, dan jadikan objek ini sebagai subkelas NSObject
.
Juga, buat itu sesuai dengan protokol GameScoreboardEditorViewModel
:
import Foundation class GameScoreboardEditorViewModelFromGame: NSObject, GameScoreboardEditorViewModel { let game: Game struct Formatter { static let durationFormatter: DateComponentsFormatter = { let dateFormatter = DateComponentsFormatter() dateFormatter.unitsStyle = .positional return dateFormatter }() } // MARK: GameScoreboardEditorViewModel protocol var homeTeam: String var awayTeam: String var time: String var score: String var isFinished: Bool var isPaused: Bool func togglePause() { if isPaused { startTimer() } else { pauseTimer() } self.isPaused = !isPaused } // MARK: Init init(withGame game: Game) { self.game = game self.homeTeam = game.homeTeam.name self.awayTeam = game.awayTeam.name self.time = GameScoreboardEditorViewModelFromGame.timeRemainingPretty(for: game) self.score = GameScoreboardEditorViewModelFromGame.scorePretty(for: game) self.isFinished = game.isFinished self.isPaused = true } // MARK: Private fileprivate var gameTimer: Timer? fileprivate func startTimer() { let interval: TimeInterval = 0.001 gameTimer = Timer.schedule(repeatInterval: interval) { timer in self.game.time += interval self.time = GameScoreboardEditorViewModelFromGame.timeRemainingPretty(for: self.game) } } fileprivate func pauseTimer() { gameTimer?.invalidate() gameTimer = nil } // MARK: String Utils fileprivate static func timeFormatted(totalMillis: Int) -> String { let millis: Int = totalMillis % 1000 / 100 // "/ 100" <- because we want only 1 digit let totalSeconds: Int = totalMillis / 1000 let seconds: Int = totalSeconds % 60 let minutes: Int = (totalSeconds / 60) return String(format: "%02d:%02d.%d", minutes, seconds, millis) } fileprivate static func timeRemainingPretty(for game: Game) -> String { return timeFormatted(totalMillis: Int(game.time * 1000)) } fileprivate static func scorePretty(for game: Game) -> String { return String(format: "\(game.homeTeamScore) - \(game.awayTeamScore)") } }
Perhatikan bahwa Anda telah menyediakan semua yang diperlukan agar ViewModel bekerja melalui penginisialisasi.
Anda memberikannya objek Game
, yang merupakan Model di bawah ViewModel ini.
Jika Anda menjalankan aplikasi sekarang, itu tetap tidak akan berfungsi karena Anda belum menghubungkan data ViewModel ini ke View itu sendiri.
Jadi, kembali ke file GameScoreboardEditorViewController.swift
, dan buat properti publik bernama viewModel
.
Buatlah dari jenis GameScoreboardEditorViewModel
.
Tempatkan tepat sebelum metode viewDidLoad
di dalam GameScoreboardEditorViewController.swift
.
var viewModel: GameScoreboardEditorViewModel? { didSet { fillUI() } }
Selanjutnya, Anda perlu menerapkan metode fillUI
.
Perhatikan bagaimana metode ini dipanggil dari dua tempat, pengamat properti viewModel
( didSet
) dan metode viewDidLoad
. Ini karena kita dapat membuat ViewController
dan menetapkan ViewModel ke dalamnya sebelum melampirkannya ke tampilan (sebelum metode viewDidLoad
dipanggil).
Di sisi lain, Anda dapat melampirkan tampilan ViewController ke tampilan lain dan memanggil viewDidLoad
, tetapi jika viewModel
tidak disetel pada saat itu, tidak akan terjadi apa-apa.
Itu sebabnya pertama-tama, Anda perlu memeriksa apakah semuanya sudah diatur untuk data Anda untuk mengisi UI. Penting untuk menjaga kode Anda dari penggunaan yang tidak terduga.
Jadi, buka metode fillUI
, dan ganti dengan kode berikut:
fileprivate func fillUI() { if !isViewLoaded { return } guard let viewModel = viewModel else { return } // we are sure here that we have all the setup done self.homeTeamNameLabel.text = viewModel.homeTeam self.awayTeamNameLabel.text = viewModel.awayTeam self.scoreLabel.text = viewModel.score self.timeLabel.text = viewModel.time let title: String = viewModel.isPaused ? "Start" : "Pause" self.pauseButton.setTitle(title, for: .normal) }
Sekarang, terapkan metode pauseButtonPress
:
@IBAction func pauseButtonPress(_ sender: AnyObject) { viewModel?.togglePause() }
Yang perlu Anda lakukan sekarang, adalah mengatur properti viewModel
yang sebenarnya pada ViewController
ini. Anda melakukan ini "dari luar."
Buka file HomeViewController.swift
dan batalkan komentar pada ViewModel; buat dan atur baris dalam metode showGameScoreboardEditorViewController
:
// uncomment this when view model is implemented let viewModel = GameScoreboardEditorViewModelFromGame(withGame: game) controller.viewModel = viewModel
Sekarang, jalankan aplikasinya. Seharusnya terlihat seperti ini:
Tampilan tengah, yang bertanggung jawab atas skor, waktu, dan nama tim, tidak lagi menampilkan nilai yang ditetapkan di Pembuat Antarmuka.
Sekarang, ini menunjukkan nilai dari objek ViewModel itu sendiri, yang mendapatkan datanya dari objek Model sebenarnya (Objek Game
).
Bagus sekali! Tapi bagaimana dengan pandangan pemain? Tombol-tombol itu masih tidak melakukan apa-apa.
Anda tahu bahwa Anda memiliki enam tampilan untuk pelacakan gerakan pemain.
Anda membuat subview terpisah, bernama PlayerScoreboardMoveEditorView
untuk itu, yang tidak melakukan apa pun dengan data nyata untuk saat ini dan menampilkan nilai statis yang ditetapkan melalui Pembuat Antarmuka di dalam file PlayerScoreboardMoveEditorView.xib
.
Anda perlu memberikan beberapa data.
Anda akan melakukannya dengan cara yang sama seperti yang Anda lakukan dengan GameScoreboardEditorViewController
dan GameScoreboardEditorViewModel
.
Buka grup ViewModel di proyek Xcode, dan tentukan protokol baru di sini.
Buat file baru bernama PlayerScoreboardMoveEditorViewModel.swift
, dan masukkan kode berikut di dalamnya:
import Foundation protocol PlayerScoreboardMoveEditorViewModel { var playerName: String { get } var onePointMoveCount: String { get } var twoPointMoveCount: String { get } var assistMoveCount: String { get } var reboundMoveCount: String { get } var foulMoveCount: String { get } func onePointMove() func twoPointsMove() func assistMove() func reboundMove() func foulMove() }
Protokol ViewModel ini dirancang agar sesuai dengan PlayerScoreboardMoveEditorView
, seperti yang Anda lakukan di tampilan induk, GameScoreboardEditorViewController
.
Anda harus memiliki nilai untuk lima gerakan berbeda yang dapat dilakukan pengguna, dan Anda perlu bereaksi, saat pengguna menyentuh salah satu tombol tindakan. Anda juga memerlukan String
untuk nama pemain.
Setelah Anda selesai melakukannya, buat kelas konkret yang mengimplementasikan protokol ini, seperti yang Anda lakukan dengan tampilan induk ( GameScoreboardEditorViewController
).
Selanjutnya, buat implementasi protokol ini: Buat file baru, beri nama PlayerScoreboardMoveEditorViewModelFromPlayer.swift
, dan jadikan objek ini sebagai subkelas NSObject
. Juga, buat itu sesuai dengan protokol PlayerScoreboardMoveEditorViewModel
:
import Foundation class PlayerScoreboardMoveEditorViewModelFromPlayer: NSObject, PlayerScoreboardMoveEditorViewModel { fileprivate let player: Player fileprivate let game: Game // MARK: PlayerScoreboardMoveEditorViewModel protocol let playerName: String var onePointMoveCount: String var twoPointMoveCount: String var assistMoveCount: String var reboundMoveCount: String var foulMoveCount: String func onePointMove() { makeMove(.onePoint) } func twoPointsMove() { makeMove(.twoPoints) } func assistMove() { makeMove(.assist) } func reboundMove() { makeMove(.rebound) } func foulMove() { makeMove(.foul) } // MARK: Init init(withGame game: Game, player: Player) { self.game = game self.player = player self.playerName = player.name self.onePointMoveCount = "\(game.playerMoveCount(for: player, move: .onePoint))" self.twoPointMoveCount = "\(game.playerMoveCount(for: player, move: .twoPoints))" self.assistMoveCount = "\(game.playerMoveCount(for: player, move: .assist))" self.reboundMoveCount = "\(game.playerMoveCount(for: player, move: .rebound))" self.foulMoveCount = "\(game.playerMoveCount(for: player, move: .foul))" } // MARK: Private fileprivate func makeMove(_ move: PlayerInGameMove) { game.addPlayerMove(move, for: player) onePointMoveCount = "\(game.playerMoveCount(for: player, move: .onePoint))" twoPointMoveCount = "\(game.playerMoveCount(for: player, move: .twoPoints))" assistMoveCount = "\(game.playerMoveCount(for: player, move: .assist))" reboundMoveCount = "\(game.playerMoveCount(for: player, move: .rebound))" foulMoveCount = "\(game.playerMoveCount(for: player, move: .foul))" } }
Sekarang, Anda perlu memiliki objek yang akan membuat instance ini "dari luar", dan mengaturnya sebagai properti di dalam PlayerScoreboardMoveEditorView
.
Ingat bagaimana HomeViewController
bertanggung jawab untuk menyetel properti viewModel
pada GameScoreboardEditorViewController
?
Dengan cara yang sama, GameScoreboardEditorViewController
adalah tampilan induk dari PlayerScoreboardMoveEditorView
Anda dan bahwa GameScoreboardEditorViewController
akan bertanggung jawab untuk membuat objek PlayerScoreboardMoveEditorViewModel
.
Anda perlu memperluas GameScoreboardEditorViewModel
Anda terlebih dahulu.
Buka GameScoreboardEditorViewMode
l dan tambahkan dua properti ini:
var homePlayers: [PlayerScoreboardMoveEditorViewModel] { get } var awayPlayers: [PlayerScoreboardMoveEditorViewModel] { get }
Juga, perbarui GameScoreboardEditorViewModelFromGame
dengan dua properti ini tepat di atas metode initWithGame
:
let homePlayers: [PlayerScoreboardMoveEditorViewModel] let awayPlayers: [PlayerScoreboardMoveEditorViewModel]
Tambahkan dua baris ini di dalam initWithGame
:

self.homePlayers = GameScoreboardEditorViewModelFromGame.playerViewModels(from: game.homeTeam.players, game: game) self.awayPlayers = GameScoreboardEditorViewModelFromGame.playerViewModels(from: game.awayTeam.players, game: game)
Dan tentu saja, tambahkan metode playerViewModelsWithPlayers
yang hilang:
// MARK: Private Init fileprivate static func playerViewModels(from players: [Player], game: Game) -> [PlayerScoreboardMoveEditorViewModel] { var playerViewModels: [PlayerScoreboardMoveEditorViewModel] = [PlayerScoreboardMoveEditorViewModel]() for player in players { playerViewModels.append(PlayerScoreboardMoveEditorViewModelFromPlayer(withGame: game, player: player)) } return playerViewModels }
Besar!
Anda telah memperbarui ViewModel ( GameScoreboardEditorViewModel
) Anda dengan susunan pemain tuan rumah dan tandang. Anda masih perlu mengisi dua array ini.
Anda akan melakukannya di tempat yang sama saat Anda menggunakan viewModel
ini untuk mengisi UI.
Buka GameScoreboardEditorViewController
dan buka metode fillUI
. Tambahkan baris ini di akhir metode:
homePlayer1View.viewModel = viewModel.homePlayers[0] homePlayer2View.viewModel = viewModel.homePlayers[1] homePlayer3View.viewModel = viewModel.homePlayers[2] awayPlayer1View.viewModel = viewModel.awayPlayers[0] awayPlayer2View.viewModel = viewModel.awayPlayers[1] awayPlayer3View.viewModel = viewModel.awayPlayers[2]
Untuk saat ini, Anda memiliki kesalahan pembuatan karena Anda tidak menambahkan properti viewModel
yang sebenarnya di dalam PlayerScoreboardMoveEditorView
.
Tambahkan kode berikut di atas init method inside the
PlayerScoreboardMoveEditorView`.
var viewModel: PlayerScoreboardMoveEditorViewModel? { didSet { fillUI() } }
Dan terapkan metode fillUI
:
fileprivate func fillUI() { guard let viewModel = viewModel else { return } self.name.text = viewModel.playerName self.onePointCountLabel.text = viewModel.onePointMoveCount self.twoPointCountLabel.text = viewModel.twoPointMoveCount self.assistCountLabel.text = viewModel.assistMoveCount self.reboundCountLabel.text = viewModel.reboundMoveCount self.foulCountLabel.text = viewModel.foulMoveCount }
Terakhir, jalankan aplikasi, dan lihat bagaimana data dalam elemen UI adalah data sebenarnya dari objek Game
.
Pada titik ini, Anda memiliki aplikasi fungsional yang menggunakan pola desain MVVM.
Ini menyembunyikan Model dengan baik dari Tampilan, dan Tampilan Anda jauh lebih sederhana daripada yang biasa Anda lakukan dengan MVC.
Hingga saat ini, Anda telah membuat aplikasi yang berisi View dan ViewModel-nya.
Tampilan itu juga memiliki enam contoh subview yang sama (tampilan pemutar) dengan ViewModel-nya.
Namun, seperti yang mungkin Anda perhatikan, Anda hanya dapat menampilkan data di UI sekali (dalam metode fillUI
), dan data tersebut statis.
Jika data Anda dalam tampilan tidak akan berubah selama masa tampilan itu, maka Anda memiliki solusi yang baik dan bersih untuk menggunakan MVVM dengan cara ini.
Membuat ViewModel Dinamis
Karena data Anda akan berubah, Anda perlu membuat ViewModel Anda dinamis.
Artinya, ketika Model berubah, ViewModel harus mengubah nilai properti publiknya; itu akan menyebarkan perubahan kembali ke tampilan, yang merupakan salah satu yang akan memperbarui UI.
Ada banyak cara untuk melakukan ini.
Saat Model berubah, ViewModel akan diberi tahu terlebih dahulu.
Anda memerlukan beberapa mekanisme untuk menyebarkan perubahan apa saja ke View.
Beberapa opsi termasuk RxSwift, yang merupakan perpustakaan yang cukup besar dan membutuhkan waktu untuk membiasakan diri.
ViewModel dapat mengaktifkan NSNotification
s pada setiap perubahan nilai properti, tetapi ini menambahkan banyak kode yang memerlukan penanganan tambahan, seperti berlangganan notifikasi dan berhenti berlangganan saat tampilan dibatalkan alokasinya.
Key-Value-Observing (KVO) adalah opsi lain, tetapi pengguna akan mengonfirmasi bahwa API-nya tidak mewah.
Dalam tutorial ini, Anda akan menggunakan generik Swift dan penutupan, yang dijelaskan dengan baik di artikel Bindings, Generics, Swift dan MVVM.
Sekarang, mari kembali ke contoh aplikasi.
Buka grup proyek ViewModel, dan buat file Swift baru, Dynamic.swift
.
class Dynamic<T> { typealias Listener = (T) -> () var listener: Listener? func bind(_ listener: Listener?) { self.listener = listener } func bindAndFire(_ listener: Listener?) { self.listener = listener listener?(value) } var value: T { didSet { listener?(value) } } init(_ v: T) { value = v } }
Anda akan menggunakan kelas ini untuk properti di ViewModels yang ingin Anda ubah selama siklus hidup Tampilan.
Pertama, mulai dengan PlayerScoreboardMoveEditorView
dan ViewModel-nya, PlayerScoreboardMoveEditorViewModel
.
Buka PlayerScoreboardMoveEditorViewModel
dan lihat propertinya.
Karena nama playerName
tidak diharapkan berubah, Anda dapat membiarkannya apa adanya.
Lima properti lainnya (lima jenis gerakan) akan berubah, jadi Anda perlu melakukan sesuatu tentang itu. Solusinya? Kelas Dynamic
yang disebutkan di atas yang baru saja Anda tambahkan ke proyek.
Di dalam PlayerScoreboardMoveEditorViewModel
hapus definisi untuk lima String yang mewakili jumlah gerakan dan ganti dengan ini:
var onePointMoveCount: Dynamic<String> { get } var twoPointMoveCount: Dynamic<String> { get } var assistMoveCount: Dynamic<String> { get } var reboundMoveCount: Dynamic<String> { get } var foulMoveCount: Dynamic<String> { get }
Beginilah tampilan protokol ViewModel sekarang:
import Foundation protocol PlayerScoreboardMoveEditorViewModel { var playerName: String { get } var onePointMoveCount: Dynamic<String> { get } var twoPointMoveCount: Dynamic<String> { get } var assistMoveCount: Dynamic<String> { get } var reboundMoveCount: Dynamic<String> { get } var foulMoveCount: Dynamic<String> { get } func onePointMove() func twoPointsMove() func assistMove() func reboundMove() func foulMove() }
Jenis Dynamic
ini memungkinkan Anda untuk mengubah nilai properti tertentu, dan pada saat yang sama, memberi tahu objek change-listener, yang, dalam hal ini, akan menjadi Tampilan.
Sekarang, perbarui implementasi ViewModel PlayerScoreboardMoveEditorViewModelFromPlayer
yang sebenarnya.
Ganti ini:
var onePointMoveCount: String var twoPointMoveCount: String var assistMoveCount: String var reboundMoveCount: String var foulMoveCount: String
dengan berikut ini:
let onePointMoveCount: Dynamic<String> let twoPointMoveCount: Dynamic<String> let assistMoveCount: Dynamic<String> let reboundMoveCount: Dynamic<String> let foulMoveCount: Dynamic<String>
Catatan: Tidak apa-apa untuk mendeklarasikan properti ini sebagai konstanta dengan let
karena Anda tidak akan mengubah properti sebenarnya. Anda akan mengubah properti value
pada objek Dynamic
.
Sekarang, ada kesalahan pembuatan karena Anda tidak menginisialisasi objek Dynamic
Anda.
Di dalam metode init PlayerScoreboardMoveEditorViewModelFromPlayer
, ganti inisialisasi properti pemindahan dengan ini:
self.onePointMoveCount = Dynamic("\(game.playerMoveCount(for: player, move: .onePoint))") self.twoPointMoveCount = Dynamic("\(game.playerMoveCount(for: player, move: .twoPoints))") self.assistMoveCount = Dynamic("\(game.playerMoveCount(for: player, move: .assist))") self.reboundMoveCount = Dynamic("\(game.playerMoveCount(for: player, move: .rebound))") self.foulMoveCount = Dynamic("\(game.playerMoveCount(for: player, move: .foul))")
Di dalam PlayerScoreboardMoveEditorViewModelFromPlayer
buka metode makeMove
, dan ganti dengan kode berikut:
fileprivate func makeMove(_ move: PlayerInGameMove) { game.addPlayerMove(move, for: player) onePointMoveCount.value = "\(game.playerMoveCount(for: player, move: .onePoint))" twoPointMoveCount.value = "\(game.playerMoveCount(for: player, move: .twoPoints))" assistMoveCount.value = "\(game.playerMoveCount(for: player, move: .assist))" reboundMoveCount.value = "\(game.playerMoveCount(for: player, move: .rebound))" foulMoveCount.value = "\(game.playerMoveCount(for: player, move: .foul))" }
Seperti yang Anda lihat, Anda telah membuat instance kelas Dynamic
, dan menetapkannya nilai String
. Saat Anda perlu memperbarui data, jangan ubah properti Dynamic
itu sendiri; alih-alih perbarui properti value
.
Besar! PlayerScoreboardMoveEditorViewModel
sekarang dinamis.
Mari kita manfaatkan, dan buka tampilan yang benar-benar akan mendengarkan perubahan ini.
Buka PlayerScoreboardMoveEditorView
dan metode fillUI
-nya (Anda akan melihat kesalahan build dalam metode ini saat ini karena Anda mencoba menetapkan nilai String
ke tipe objek Dynamic
.)
Ganti baris "yang salah":
self.onePointCountLabel.text = viewModel.onePointMoveCount self.twoPointCountLabel.text = viewModel.twoPointMoveCount self.assistCountLabel.text = viewModel.assistMoveCount self.reboundCountLabel.text = viewModel.reboundMoveCount self.foulCountLabel.text = viewModel.foulMoveCount
dengan berikut ini:
viewModel.onePointMoveCount.bindAndFire { [unowned self] in self.onePointCountLabel.text = $0 } viewModel.twoPointMoveCount.bindAndFire { [unowned self] in self.twoPointCountLabel.text = $0 } viewModel.assistMoveCount.bindAndFire { [unowned self] in self.assistCountLabel.text = $0 } viewModel.reboundMoveCount.bindAndFire { [unowned self] in self.reboundCountLabel.text = $0 } viewModel.foulMoveCount.bindAndFire { [unowned self] in self.foulCountLabel.text = $0 }
Selanjutnya, terapkan lima metode yang mewakili tindakan pindah (bagian Tindakan Tombol ):
@IBAction func onePointAction(_ sender: Any) { viewModel?.onePointMove() } @IBAction func twoPointsAction(_ sender: Any) { viewModel?.twoPointsMove() } @IBAction func assistAction(_ sender: Any) { viewModel?.assistMove() } @IBAction func reboundAction(_ sender: Any) { viewModel?.reboundMove() } @IBAction func foulAction(_ sender: Any) { viewModel?.foulMove() }
Jalankan aplikasi, dan klik beberapa tombol pindah. Anda akan melihat bagaimana nilai penghitung di dalam tampilan pemutar berubah saat Anda mengklik tombol aksi.
Anda telah selesai dengan PlayerScoreboardMoveEditorView
dan PlayerScoreboardMoveEditorViewModel
.
Ini sederhana.
Sekarang, Anda perlu melakukan hal yang sama dengan tampilan utama Anda ( GameScoreboardEditorViewController
).
Pertama, buka GameScoreboardEditorViewModel
dan lihat nilai mana yang diharapkan berubah selama siklus hidup tampilan.
Ganti definisi time
, score
, isFinished
, isPaused
dengan versi Dynamic
:
import Foundation protocol GameScoreboardEditorViewModel { var homeTeam: String { get } var awayTeam: String { get } var time: Dynamic<String> { get } var score: Dynamic<String> { get } var isFinished: Dynamic<Bool> { get } var isPaused: Dynamic<Bool> { get } func togglePause() var homePlayers: [PlayerScoreboardMoveEditorViewModel] { get } var awayPlayers: [PlayerScoreboardMoveEditorViewModel] { get } }
Buka implementasi ViewModel ( GameScoreboardEditorViewModelFromGame
) dan lakukan hal yang sama dengan properti yang dideklarasikan dalam protokol.
Ganti ini:
var time: String var score: String var isFinished: Bool var isPaused: Bool
dengan berikut ini:
let time: Dynamic<String> let score: Dynamic<String> let isFinished: Dynamic<Bool> let isPaused: Dynamic<Bool>
Anda akan mendapatkan beberapa kesalahan, sekarang, karena Anda mengubah tipe ViewModel dari String
dan Bool
menjadi Dynamic<String>
dan Dynamic<Bool>
.
Mari kita perbaiki itu.
Perbaiki metode togglePause
dengan menggantinya dengan yang berikut ini:
func togglePause() { if isPaused.value { startTimer() } else { pauseTimer() } self.isPaused.value = !isPaused.value }
Perhatikan bagaimana satu-satunya perubahan adalah Anda tidak lagi menetapkan nilai properti secara langsung pada properti. Sebagai gantinya, Anda mengaturnya pada properti value
objek.
Sekarang, perbaiki metode initWithGame
dengan mengganti ini:
self.time = GameScoreboardEditorViewModelFromGame.timeRemainingPretty(game) self.score = GameScoreboardEditorViewModelFromGame.scorePretty(game) self.isFinished = game.isFinished self.isPaused = true
dengan berikut ini:
self.time = Dynamic(GameScoreboardEditorViewModelFromGame.timeRemainingPretty(for: game)) self.score = Dynamic(GameScoreboardEditorViewModelFromGame.scorePretty(for: game)) self.isFinished = Dynamic(game.isFinished) self.isPaused = Dynamic(true)
Anda harus mendapatkan intinya sekarang.
Anda membungkus nilai primitif, seperti String
, Int
dan Bool
, dengan versi Dynamic<T>
dari objek tersebut, yang memberi Anda mekanisme pengikatan yang ringan.
Anda memiliki satu kesalahan lagi untuk diperbaiki.
Dalam metode startTimer
, ganti baris kesalahan dengan:
self.time.value = GameScoreboardEditorViewModelFromGame.timeRemainingPretty(for: self.game)
Anda telah meningkatkan ViewModel menjadi dinamis, seperti yang Anda lakukan dengan ViewModel pemutar. Tetapi Anda masih perlu memperbarui Tampilan Anda ( GameScoreboardEditorViewController
).
Ganti seluruh metode fillUI
dengan ini:
fileprivate func fillUI() { if !isViewLoaded { return } guard let viewModel = viewModel else { return } self.homeTeamNameLabel.text = viewModel.homeTeam self.awayTeamNameLabel.text = viewModel.awayTeam viewModel.score.bindAndFire { [unowned self] in self.scoreLabel.text = $0 } viewModel.time.bindAndFire { [unowned self] in self.timeLabel.text = $0 } viewModel.isFinished.bindAndFire { [unowned self] in if $0 { self.homePlayer1View.isHidden = true self.homePlayer2View.isHidden = true self.homePlayer3View.isHidden = true self.awayPlayer1View.isHidden = true self.awayPlayer2View.isHidden = true self.awayPlayer3View.isHidden = true } } viewModel.isPaused.bindAndFire { [unowned self] in let title = $0 ? "Start" : "Pause" self.pauseButton.setTitle(title, for: .normal) } homePlayer1View.viewModel = viewModel.homePlayers[0] homePlayer2View.viewModel = viewModel.homePlayers[1] homePlayer3View.viewModel = viewModel.homePlayers[2] awayPlayer1View.viewModel = viewModel.awayPlayers[0] awayPlayer2View.viewModel = viewModel.awayPlayers[1] awayPlayer3View.viewModel = viewModel.awayPlayers[2] }
Satu-satunya perbedaan adalah Anda mengubah empat properti dinamis dan menambahkan pendengar perubahan ke masing-masing properti.
Pada titik ini, jika Anda menjalankan aplikasi, mengaktifkan tombol Mulai/Jeda akan memulai dan menjeda pengatur waktu game. Ini digunakan untuk time-out selama pertandingan.
Anda hampir selesai kecuali skor tidak berubah di UI, ketika Anda menekan salah satu tombol titik ( tombol 1
dan 2
poin).
Ini karena Anda belum benar-benar menyebarkan perubahan skor di objek model Game
yang mendasarinya hingga ViewModel.
Jadi, buka objek model Game
untuk sedikit pemeriksaan. Periksa metode updateScore
-nya.
fileprivate func updateScore(_ score: UInt, withScoringPlayer player: Player) { if isFinished || score == 0 { return } if homeTeam.containsPlayer(player) { homeTeamScore += score } else { assert(awayTeam.containsPlayer(player)) awayTeamScore += score } if checkIfFinished() { isFinished = true } NotificationCenter.default.post(name: Notification.Name(rawValue: GameNotifications.GameScoreDidChangeNotification), object: self) }
Metode ini melakukan dua hal penting.
Pertama, ini menetapkan properti isFinished
menjadi true
jika permainan selesai berdasarkan skor kedua tim.
Setelah itu, ia memposting pemberitahuan bahwa skor telah berubah. Anda akan mendengarkan notifikasi ini di GameScoreboardEditorViewModelFromGame
dan memperbarui nilai skor dinamis dalam metode handler notifikasi.
Add this line at the bottom of initWithGame
method (don't forget the super.init()
call to avoid errors):
super.init() subscribeToNotifications()
Below initWithGame
method, add deinit
method, since you want to do the cleanup properly and avoid crashes caused by the NotificationCenter
.
deinit { unsubscribeFromNotifications() }
Finally, add the implementations of these methods. Add this section right below the deinit
method:
// MARK: Notifications (Private) fileprivate func subscribeToNotifications() { NotificationCenter.default.addObserver(self, selector: #selector(gameScoreDidChangeNotification(_:)), name: NSNotification.Name(rawValue: GameNotifications.GameScoreDidChangeNotification), object: game) } fileprivate func unsubscribeFromNotifications() { NotificationCenter.default.removeObserver(self) } @objc fileprivate func gameScoreDidChangeNotification(_ notification: NSNotification){ self.score.value = GameScoreboardEditorViewModelFromGame.scorePretty(for: game) if game.isFinished { self.isFinished.value = true } }
Now, run the app, and click on the player views to change scores. Since you've already connected dynamic score
and isFinished
in the ViewModel with the View, everything should work when you change the score value inside the ViewModel.
How to Further Improve the App
While there's always room for improvement, this is out of scope of this tutorial.
For example, we do not stop the time automatically when the game is over (when one of the teams reaches 15 points), we just hide the player views.
You can play with the app if you like and upgrade it to have a “game creator” view, which would create a game, assign team names, assign player names and create a Game
object that could be used to present GameScoreboardEditorViewController
.
We can create another “game list” view that uses the UITableView
to show multiple games in progress with some detailed info in the table cell. In cell select, we can show the GameScoreboardEditorViewController
with the selected Game
.
The GameLibrary
has already been implemented. Just remember to pass that library reference to the ViewModel objects in their initializer. For example, “game creator's” ViewModel would need to have an instance of GameLibrary
passed through the initializer so that it would be able to insert the created Game
object into the library. “Game list's” ViewModel would also need this reference to fetch all games from the library, which will be needed by the UITableView.
The idea is to hide all of the dirty (non-UI) work inside the ViewModel and have the UI (View) only act with prepared presentation data.
Apa sekarang?
After you get used to the MVVM, you can further improve it by using Uncle Bob's Clean Architecture rules.
An additional good read is a three-part tutorial on Android architecture:
- Android Architecture: Part 1 – Every new beginning is hard,
- Android Architecture: Part 2 – The clean architecture,
- Android Architecture: Part 2 – The clean architecture.
Examples are written in Java (for Android), and if you are familiar with Java (which is much closer to Swift then Objective-C is to Java), you'll get ideas on how to further refactor your code inside the ViewModel objects so that they don't import any iOS modules ( UIKit
or CoreLocation
eg).
These iOS modules can be hidden behind the pure NSObjects
, which is good for code reusability.
MVVM is a good choice for most iOS apps, and hopefully, you will give it a try in your next project. Or, try it in your current project when you are creating a UIViewController
.