برنامج Swift التعليمي: مقدمة لنمط تصميم MVVM
نشرت: 2022-03-11إذن أنت تبدأ مشروع iOS جديدًا ، وقد تلقيت من المصمم كل ما يلزم من مستندات .pdf
و. .sketch
، ولديك بالفعل رؤية حول كيفية إنشاء هذا التطبيق الجديد.
تبدأ في نقل شاشات واجهة المستخدم من رسومات المصمم إلى ViewController
.swift
و .xib
و .storyboard
.
UITextField
هنا ، UITableView
هناك ، عدد قليل من UILabels
وقليل من UIButtons
. يتم أيضًا تضمين IBOutlets
و IBActions
. كل خير ، ما زلنا في منطقة واجهة المستخدم.
ومع ذلك ، فقد حان الوقت لعمل شيء ما مع كل عناصر واجهة المستخدم هذه ؛ UIButtons
لمسات من الأصابع ، UILabels
و UITableViews
إلى شخص ما ليخبرهم بما سيتم عرضه وبأي شكل.
فجأة ، لديك أكثر من 3000 سطر من التعليمات البرمجية.
انتهى بك الأمر مع الكثير من كود السباغيتي.
تتمثل الخطوة الأولى لحل هذه المشكلة في تطبيق نمط تصميم Model-View-Controller (MVC). ومع ذلك ، فإن هذا النمط له مشاكله الخاصة. يأتي نمط تصميم Model-View- ViewModel (MVVM) الذي يوفر اليوم.
التعامل مع كود السباغيتي
في أي وقت من الأوقات ، أصبح ViewController
الذي بدأ تشغيله ذكيًا جدًا وضخمًا جدًا.
رمز الشبكة ، رمز تحليل البيانات ، رمز تعديلات البيانات لعرض واجهة المستخدم ، إعلامات حالة التطبيق ، تغييرات حالة واجهة المستخدم. كل هذا الكود يُسجن داخل علم النفس لملف واحد if
يمكن إعادة استخدامه ولن يتناسب إلا مع هذا المشروع.
أصبح كود ViewController
الخاص بك هو رمز السباغيتي سيء السمعة.
كيف حدث هذا؟
السبب المحتمل هو شيء من هذا القبيل:
لقد كنت في عجلة من أمرنا لمعرفة كيف كانت البيانات الخلفية تتصرف داخل UITableView
، لذلك قمت بوضع بضعة أسطر من كود الشبكة داخل طريقة temp لـ ViewController
فقط لجلب ذلك .json
من الشبكة. بعد ذلك ، كنت بحاجة إلى معالجة البيانات الموجودة داخل .json
، لذلك قمت بكتابة طريقة مؤقتة أخرى لإنجاز ذلك. أو الأسوأ من ذلك أنك فعلت ذلك بنفس الطريقة.
استمر ViewController
في النمو عندما ظهر رمز تفويض المستخدم. ثم بدأت تنسيقات البيانات في التغيير ، وتطورت واجهة المستخدم واحتاجت إلى بعض التغييرات الجذرية ، وواصلت إضافة المزيد if
if
s إلى علم-علم ضخم بالفعل.
ولكن ، كيف أن UIViewController
هو ما خرج عن السيطرة؟
UIViewController
هو المكان المنطقي لبدء العمل على كود واجهة المستخدم الخاص بك. إنه يمثل الشاشة الفعلية التي تراها أثناء استخدام أي تطبيق مع جهاز iOS الخاص بك. حتى Apple تستخدم UIViewControllers
في تطبيق النظام الرئيسي الخاص بها عندما تقوم بالتبديل بين التطبيقات المختلفة وواجهة المستخدم المتحركة.
تعتمد Apple على تجريد واجهة المستخدم الخاصة بها داخل UIViewController
، نظرًا لأنها جوهر رمز واجهة مستخدم iOS وجزءًا من نمط تصميم MVC .
الترقية إلى نمط تصميم MVC
في نمط تصميم MVC ، من المفترض أن يكون العرض غير نشط ويعرض فقط البيانات المعدة عند الطلب.
يجب أن تعمل وحدة التحكم على بيانات النموذج لتحضيرها لطرق العرض ، والتي تعرض بعد ذلك تلك البيانات.
View مسؤولة أيضًا عن إخطار وحدة التحكم بأي إجراءات ، مثل لمسات المستخدم.
كما ذكرنا ، UIViewController
هو عادة نقطة البداية في بناء شاشة واجهة المستخدم. لاحظ أنه يحتوي في اسمه على "العرض" و "وحدة التحكم". هذا يعني أنه "يتحكم في العرض". هذا لا يعني أن كلاً من كود "وحدة التحكم" و "العرض" يجب أن يدخلوا إلى الداخل.
غالبًا ما يحدث هذا المزيج من رمز العرض ووحدة التحكم عند نقل IBOutlets
من العروض الفرعية الصغيرة داخل UIViewController
، والتلاعب في تلك العروض الفرعية مباشرة من UIViewController
. بدلاً من ذلك ، كان يجب عليك تغليف هذا الرمز داخل فئة فرعية مخصصة لـ UIView
.
من السهل رؤية أن هذا قد يؤدي إلى تقاطع مسارات رمز العرض والتحكم.
MVVM إلى الإنقاذ
هذا هو المكان الذي يكون فيه نمط MVVM مفيدًا.
نظرًا لأنه من المفترض أن يكون UIViewController
وحدة تحكم في نمط MVC ، وهو يفعل الكثير بالفعل مع طرق العرض ، يمكننا دمجها في عرض النمط الجديد - MVVM .
في نمط تصميم MVVM ، يكون النموذج هو نفسه الموجود في نمط MVC. إنه يمثل بيانات بسيطة.
يتم تمثيل العرض بواسطة كائنات UIView
أو UIViewController
، مصحوبة بملفات .xib
و .storyboard
الخاصة بهم ، والتي يجب أن تعرض البيانات المعدة فقط. (لا نريد أن يكون لدينا كود NSDateFormatter
، على سبيل المثال ، داخل العرض.)
فقط سلسلة بسيطة منسقة تأتي من ViewModel .
يقوم ViewModel بإخفاء جميع رموز الشبكات غير المتزامنة ، وكود إعداد البيانات للعرض التقديمي المرئي ، والاستماع إلى التعليمات البرمجية لتغييرات النموذج . كل هذه الأشياء مخفية خلف واجهة برمجة تطبيقات محددة جيدًا تم تصميمها لتلائم هذا العرض المحدد.
إحدى فوائد استخدام MVVM هي الاختبار. نظرًا لأن ViewModel عبارة عن NSObject
خالص (أو هيكل على سبيل المثال) ، ولا يقترن UIKit
struct
يمكنك اختباره بسهولة أكبر في اختبارات الوحدة الخاصة بك دون أن يؤثر ذلك على رمز واجهة المستخدم.
الآن ، أصبح العرض ( UIViewController
/ UIView
) أبسط بكثير بينما يعمل ViewModel كغراء بين النموذج والعرض .
تطبيق MVVM في Swift
لتظهر لك MVVM أثناء العمل ، يمكنك تنزيل وفحص مثال مشروع Xcode الذي تم إنشاؤه لهذا البرنامج التعليمي هنا. يستخدم هذا المشروع Swift 3 و Xcode 8.1.
هناك نسختان من المشروع: Starter و Finished.
الإصدار النهائي هو تطبيق صغير مكتمل ، حيث يكون Starter هو نفس المشروع ولكن بدون الأساليب والكائنات المنفذة.
أولاً ، أقترح عليك تنزيل مشروع Starter واتباع هذا البرنامج التعليمي. إذا كنت بحاجة إلى مرجع سريع للمشروع لوقت لاحق ، فقم بتنزيل المشروع المنتهي .
مقدمة مشروع تعليمي
المشروع التعليمي عبارة عن تطبيق لكرة السلة لتتبع تصرفات اللاعب أثناء اللعبة.
يتم استخدامه للتتبع السريع لتحركات المستخدم والنتيجة الإجمالية في لعبة الالتقاط.
يلعب فريقان حتى يتم الوصول إلى النتيجة 15 (بفارق نقطتين على الأقل). يمكن لكل لاعب تسجيل نقطة واحدة مقابل نقطتين ، ويمكن لكل لاعب المساعدة والارتداد والخطأ.
يبدو التسلسل الهرمي للمشروع كما يلي:
نموذج
-
Game.swift
- تحتوي على منطق اللعبة ، وتتبع النتيجة الإجمالية ، وتتبع تحركات كل لاعب.
-
Team.swift
- يحتوي على اسم الفريق وقائمة اللاعبين (ثلاثة لاعبين في كل فريق).
-
Player.swift
- لاعب واحد باسم.
رأي
-
HomeViewController.swift
- وحدة تحكم عرض الجذر ، والتي تقدم
GameScoreboardEditorViewController
- وحدة تحكم عرض الجذر ، والتي تقدم
-
GameScoreboardEditorViewController.swift
- تستكمل مع عرض منشئ الواجهة في
Main.storyboard
. - شاشة الاهتمام لهذا البرنامج التعليمي.
- تستكمل مع عرض منشئ الواجهة في
-
PlayerScoreboardMoveEditorView.swift
- تُستكمل مع عرض منشئ الواجهة في
PlayerScoreboardMoveEditorView.xib
- العرض الفرعي للعرض أعلاه ، يستخدم أيضًا نمط تصميم MVVM.
- تُستكمل مع عرض منشئ الواجهة في
ViewModel
- مجموعة
ViewModel
فارغة ، هذا ما ستقوم ببنائه في هذا البرنامج التعليمي.
يحتوي مشروع Xcode الذي تم تنزيله بالفعل على عناصر نائبة لعرض الكائنات ( UIView
و UIViewController
). يحتوي المشروع أيضًا على بعض الكائنات المصممة خصيصًا لعرض إحدى الطرق حول كيفية توفير البيانات لكائنات ViewModel (مجموعة Services
).
تحتوي مجموعة Extensions
على امتدادات مفيدة لرمز واجهة المستخدم التي ليست في نطاق هذا البرنامج التعليمي والتي تشرح نفسها بنفسها.
إذا قمت بتشغيل التطبيق في هذه المرحلة ، فسيعرض واجهة المستخدم النهائية ، ولكن لا يحدث شيء ، عندما يضغط المستخدم على الأزرار.
هذا لأنك قمت فقط بإنشاء طرق عرض وإجراءات IBActions
بدون توصيلها بمنطق التطبيق وبدون ملء عناصر واجهة المستخدم بالبيانات من النموذج (من كائن Game
، كما سنتعلم لاحقًا).
ربط العرض والنموذج مع ViewModel
في نمط تصميم MVVM ، يجب ألا تعرف طريقة العرض أي شيء عن النموذج. الشيء الوحيد الذي يعرفه View هو كيفية العمل مع ViewModel.
ابدأ بفحص وجهة نظرك.
في ملف GameScoreboardEditorViewController.swift
، تكون طريقة fillUI
فارغة في هذه المرحلة. هذا هو المكان الذي تريد تعبئة واجهة المستخدم بالبيانات فيه. لتحقيق ذلك ، تحتاج إلى توفير بيانات لـ ViewController
. يمكنك القيام بذلك باستخدام كائن ViewModel.
أولاً ، قم بإنشاء كائن ViewModel يحتوي على جميع البيانات الضرورية لـ ViewController
.
انتقل إلى مجموعة مشروع ViewModel Xcode ، والتي ستكون فارغة ، وأنشئ ملف GameScoreboardEditorViewModel.swift
، واجعله بروتوكولًا.
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(); }
استخدام بروتوكولات مثل هذا يحافظ على شيء جميل ونظيف ؛ يجب عليك فقط تحديد البيانات التي ستستخدمها.
بعد ذلك ، قم بإنشاء تطبيق لهذا البروتوكول.
قم بإنشاء ملف جديد يسمى GameScoreboardEditorViewModelFromGame.swift
، وجعل هذا الكائن فئة فرعية من NSObject
.
أيضًا ، اجعله متوافقًا مع بروتوكول 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)") } }
لاحظ أنك قدمت كل ما يلزم لـ ViewModel للعمل من خلال المُهيئ.
لقد وفرت له كائن Game
، وهو النموذج الموجود أسفل نموذج العرض هذا.
إذا قمت بتشغيل التطبيق الآن ، فلن يعمل لأنك لم تقم بتوصيل بيانات ViewModel هذه إلى طريقة العرض نفسها.
لذا ، ارجع إلى ملف GameScoreboardEditorViewController.swift
، وأنشئ خاصية عامة باسم viewModel
.
اجعلها من النوع GameScoreboardEditorViewModel
.
ضعه مباشرة قبل طريقة viewDidLoad
داخل GameScoreboardEditorViewController.swift
.
var viewModel: GameScoreboardEditorViewModel? { didSet { fillUI() } }
بعد ذلك ، تحتاج إلى تنفيذ طريقة fillUI
.
لاحظ كيف يتم استدعاء هذه الطريقة من مكانين ، مراقب خاصية viewModel
( didSet
) وطريقة viewDidLoad
. هذا لأنه يمكننا إنشاء ViewController
وتعيين ViewModel إليه قبل إرفاقه بمشاهدة (قبل استدعاء طريقة viewDidLoad
).
من ناحية أخرى ، يمكنك إرفاق عرض ViewController بعرض آخر واستدعاء viewDidLoad
، ولكن إذا لم يتم تعيين viewModel
في ذلك الوقت ، فلن يحدث شيء.
لهذا السبب تحتاج أولاً إلى التحقق مما إذا تم تعيين كل شيء لبياناتك لملء واجهة المستخدم. من المهم حماية التعليمات البرمجية الخاصة بك من الاستخدام غير المتوقع.
لذلك ، انتقل إلى طريقة fillUI
، واستبدلها بالكود التالي:
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) }
الآن ، قم بتنفيذ طريقة pauseButtonPress
:
@IBAction func pauseButtonPress(_ sender: AnyObject) { viewModel?.togglePause() }
كل ما تحتاجه الآن هو تعيين خاصية viewModel
الفعلية على ViewController
. تفعل هذا "من الخارج".
افتح ملف HomeViewController.swift
في ViewModel ؛ إنشاء خطوط وإعدادها في طريقة showGameScoreboardEditorViewController
:
// uncomment this when view model is implemented let viewModel = GameScoreboardEditorViewModelFromGame(withGame: game) controller.viewModel = viewModel
الآن ، قم بتشغيل التطبيق. يجب أن يبدو مثل هذا:
لم يعد العرض الأوسط ، المسؤول عن الدرجة والوقت وأسماء الفريق ، يعرض القيم المحددة في Interface Builder.
الآن ، يعرض القيم من كائن ViewModel نفسه ، والذي يحصل على بياناته من كائن النموذج الفعلي (كائن Game
).
ممتاز! لكن ماذا عن آراء اللاعب؟ هذه الأزرار ما زالت لا تفعل أي شيء.
أنت تعلم أن لديك ستة مشاهدات لتتبع حركات اللاعب.
لقد أنشأت عرضًا فرعيًا منفصلاً ، باسم PlayerScoreboardMoveEditorView
لذلك ، والذي لا يفعل شيئًا مع البيانات الحقيقية في الوقت الحالي ويعرض القيم الثابتة التي تم تعيينها من خلال منشئ الواجهة داخل ملف PlayerScoreboardMoveEditorView.xib
.
تحتاج إلى إعطائها بعض البيانات.
ستفعل ذلك بنفس الطريقة التي فعلت بها مع GameScoreboardEditorViewController
و GameScoreboardEditorViewModel
.
افتح مجموعة ViewModel في مشروع Xcode ، وحدد البروتوكول الجديد هنا.
أنشئ ملفًا جديدًا باسم PlayerScoreboardMoveEditorViewModel.swift
، ثم ضع الكود التالي بداخله:
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() }
تم تصميم بروتوكول ViewModel هذا PlayerScoreboardMoveEditorView
الخاص بك ، تمامًا كما فعلت في العرض الرئيسي ، GameScoreboardEditorViewController
.
يجب أن يكون لديك قيم للحركات الخمس المختلفة التي يمكن للمستخدم القيام بها ، وتحتاج إلى الرد ، عندما يلمس المستخدم أحد أزرار الإجراءات. تحتاج أيضًا إلى String
لاسم المشغل.
بعد القيام بذلك ، قم بإنشاء فئة ملموسة تنفذ هذا البروتوكول ، تمامًا كما فعلت مع العرض الأصل ( GameScoreboardEditorViewController
).
بعد ذلك ، قم بإنشاء تطبيق لهذا البروتوكول: قم بإنشاء ملف جديد ، PlayerScoreboardMoveEditorViewModelFromPlayer.swift
، واجعل هذا الكائن فئة فرعية من NSObject
. أيضًا ، اجعله متوافقًا مع بروتوكول 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))" } }
الآن ، أنت بحاجة إلى كائن يقوم بإنشاء هذا المثيل "من الخارج" ، وتعيينه كخاصية داخل PlayerScoreboardMoveEditorView
.
تذكر كيف كان HomeViewController
مسؤولاً عن تعيين خاصية viewModel
على GameScoreboardEditorViewController
؟
بالطريقة نفسها ، GameScoreboardEditorViewController
هي طريقة عرض رئيسية لـ PlayerScoreboardMoveEditorView
وستكون GameScoreboardEditorViewController
مسؤولة عن إنشاء كائنات PlayerScoreboardMoveEditorViewModel
.
تحتاج إلى توسيع GameScoreboardEditorViewModel
أولاً.
افتح GameScoreboardEditorViewMode
l وأضف هاتين الخاصيتين:
var homePlayers: [PlayerScoreboardMoveEditorViewModel] { get } var awayPlayers: [PlayerScoreboardMoveEditorViewModel] { get }
أيضًا ، قم بتحديث GameScoreboardEditorViewModelFromGame
بهاتين الخاصيتين أعلى طريقة initWithGame
:
let homePlayers: [PlayerScoreboardMoveEditorViewModel] let awayPlayers: [PlayerScoreboardMoveEditorViewModel]
أضف هذين السطرين داخل initWithGame
:
self.homePlayers = GameScoreboardEditorViewModelFromGame.playerViewModels(from: game.homeTeam.players, game: game) self.awayPlayers = GameScoreboardEditorViewModelFromGame.playerViewModels(from: game.awayTeam.players, game: game)
وبالطبع أضف طريقة playerViewModelsWithPlayers
المفقودة:

// 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 }
رائعة!
لقد قمت بتحديث ViewModel ( GameScoreboardEditorViewModel
) مع مجموعة اللاعبين في المنزل وخارجه. ما زلت بحاجة لملء هاتين المصفوفتين.
ستفعل ذلك في نفس المكان الذي استخدمت viewModel
هذا لملء واجهة المستخدم.
افتح GameScoreboardEditorViewController
إلى طريقة fillUI
. أضف هذه الأسطر في نهاية الطريقة:
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]
في الوقت الحالي ، لديك أخطاء في الإنشاء لأنك لم تقم بإضافة خاصية viewModel
الفعلية داخل PlayerScoreboardMoveEditorView
.
أضف التعليمات البرمجية التالية أعلى init method inside the
PlayerScoreboardMoveEditorView`.
var viewModel: PlayerScoreboardMoveEditorViewModel? { didSet { fillUI() } }
ونفذ طريقة 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 }
أخيرًا ، قم بتشغيل التطبيق ، وشاهد كيف أن البيانات الموجودة في عناصر واجهة المستخدم هي البيانات الفعلية من كائن Game
.
في هذه المرحلة ، لديك تطبيق وظيفي يستخدم نمط تصميم MVVM.
إنه يخفي النموذج بشكل جيد من العرض ، وطريقة العرض الخاصة بك أبسط بكثير مما اعتدت عليه مع MVC.
حتى هذه اللحظة ، قمت بإنشاء تطبيق يحتوي على طريقة العرض و ViewModel الخاصة به.
يحتوي هذا العرض أيضًا على ست مثيلات من نفس العرض الفرعي (عرض المشغل) مع ViewModel الخاص به.
ومع ذلك ، كما قد تلاحظ ، يمكنك فقط عرض البيانات في واجهة المستخدم مرة واحدة (في طريقة fillUI
) ، وهذه البيانات ثابتة.
إذا لم تتغير بياناتك في طرق العرض خلال عمر هذا العرض ، فلديك حل جيد ونظيف لاستخدام MVVM بهذه الطريقة.
جعل ViewModel ديناميكيًا
نظرًا لأن بياناتك ستتغير ، فأنت بحاجة إلى جعل ViewModel الخاص بك ديناميكيًا.
ما يعنيه هذا هو أنه عندما يتغير النموذج ، يجب على ViewModel تغيير قيم ملكيته العامة ؛ سينشر التغيير مرة أخرى إلى العرض ، وهو العرض الذي سيؤدي إلى تحديث واجهة المستخدم.
هناك الكثير من الطرق للقيام بذلك.
عندما يتغير النموذج ، يتم إخطار ViewModel أولاً.
أنت بحاجة إلى آلية لنشر التغييرات التي تطرأ على طريقة العرض.
تتضمن بعض الخيارات RxSwift ، وهي مكتبة كبيرة جدًا وتستغرق بعض الوقت لتعتاد عليها.
قد يقوم ViewModel بإطلاق NSNotification
على كل تغيير في قيمة الخاصية ، ولكن هذا يضيف الكثير من التعليمات البرمجية التي تحتاج إلى معالجة إضافية ، مثل الاشتراك في الإشعارات وإلغاء الاشتراك عند إلغاء تخصيص العرض.
يعد Key-Value-Observing (KVO) خيارًا آخر ، لكن المستخدمين سيؤكدون أن واجهة برمجة التطبيقات (API) الخاصة به ليست خيالية.
في هذا البرنامج التعليمي ، ستستخدم أدوات Swift العامة والإغلاق ، والتي تم وصفها بشكل جيد في مقالة Bindings و Generics و Swift و MVVM.
الآن ، دعنا نعود إلى مثال التطبيق.
انتقل إلى مجموعة مشروع ViewModel ، وأنشئ ملف Swift جديدًا ، 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 } }
ستستخدم هذه الفئة للخصائص الموجودة في ViewModels التي تتوقع تغييرها أثناء دورة حياة العرض.
أولاً ، ابدأ بـ PlayerScoreboardMoveEditorView
و ViewModel الخاص به ، PlayerScoreboardMoveEditorViewModel
.
افتح PlayerScoreboardMoveEditorViewModel
وانظر إلى خصائصه.
نظرًا لأنه من غير المتوقع أن يتغير اسم playerName
، يمكنك تركه كما هو.
ستتغير الخصائص الخمس الأخرى (أنواع النقل الخمسة) ، لذلك عليك أن تفعل شيئًا حيال ذلك. الحل؟ الفئة Dynamic
المذكورة أعلاه التي أضفتها للتو إلى المشروع.
داخل PlayerScoreboardMoveEditorViewModel
قم بإزالة التعريفات لخمسة سلاسل تمثل أعداد النقل واستبدلها بهذا:
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 }
هذا ما يجب أن يبدو عليه بروتوكول ViewModel الآن:
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() }
يمكّنك هذا النوع Dynamic
من تغيير قيمة تلك الخاصية المعينة ، وفي نفس الوقت ، إخطار كائن مستمع التغيير ، والذي ، في هذه الحالة ، سيكون العرض.
الآن ، قم بتحديث تطبيق ViewModel الفعلي PlayerScoreboardMoveEditorViewModelFromPlayer
.
استبدل هذا:
var onePointMoveCount: String var twoPointMoveCount: String var assistMoveCount: String var reboundMoveCount: String var foulMoveCount: String
كالآتي:
let onePointMoveCount: Dynamic<String> let twoPointMoveCount: Dynamic<String> let assistMoveCount: Dynamic<String> let reboundMoveCount: Dynamic<String> let foulMoveCount: Dynamic<String>
ملاحظة: من المقبول إعلان هذه الخصائص على أنها ثوابت مع let
لأنك لن تغير الخاصية الفعلية. ستقوم بتغيير خاصية value
على الكائن Dynamic
.
الآن ، هناك أخطاء في البناء لأنك لم تقم بتهيئة الكائنات Dynamic
الخاصة بك.
داخل طريقة PlayerScoreboardMoveEditorViewModelFromPlayer
، استبدل تهيئة خصائص النقل بهذا:
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))")
داخل PlayerScoreboardMoveEditorViewModelFromPlayer
انتقل إلى طريقة makeMove
التالي:
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))" }
كما ترى ، لقد قمت بإنشاء مثيلات للفئة Dynamic
، وقمت بتعيين قيم String
لها. عندما تحتاج إلى تحديث البيانات ، لا تغير الخاصية Dynamic
نفسها ؛ بدلا من تحديثها value
الممتلكات.
رائعة! PlayerScoreboardMoveEditorViewModel
ديناميكيًا الآن.
دعنا نستفيد منها ، ونذهب إلى وجهة النظر التي ستستمع بالفعل لهذه التغييرات.
افتح PlayerScoreboardMoveEditorView
وطريقة fillUI
الخاصة به (يجب أن ترى أخطاء الإنشاء في هذه الطريقة في هذه المرحلة نظرًا لأنك تحاول تعيين قيمة String
إلى نوع الكائن Dynamic
.)
استبدل الأسطر "الخطأ":
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
كالآتي:
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 }
بعد ذلك ، قم بتنفيذ الطرق الخمس التي تمثل إجراءات النقل (قسم إجراء الزر ):
@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() }
قم بتشغيل التطبيق ، وانقر على بعض أزرار النقل. سترى كيف تتغير قيم العداد داخل عروض المشغل عندما تنقر على زر الإجراء.
لقد انتهيت من PlayerScoreboardMoveEditorView
و PlayerScoreboardMoveEditorViewModel
.
كان هذا بسيطًا.
الآن ، عليك أن تفعل الشيء نفسه مع العرض الرئيسي ( GameScoreboardEditorViewController
).
أولاً ، افتح GameScoreboardEditorViewModel
واطلع على القيم المتوقع تغييرها أثناء دورة حياة العرض.
استبدل تعريفات time
score
isFinished
isPaused
بالإصدارات 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 } }
انتقل إلى تطبيق ViewModel ( GameScoreboardEditorViewModelFromGame
) وافعل الشيء نفسه مع الخصائص المعلنة في البروتوكول.
استبدل هذا:
var time: String var score: String var isFinished: Bool var isPaused: Bool
كالآتي:
let time: Dynamic<String> let score: Dynamic<String> let isFinished: Dynamic<Bool> let isPaused: Dynamic<Bool>
ستحصل الآن على بعض الأخطاء ، لأنك غيرت نوع ViewModel من String
و Bool
إلى Dynamic<String>
و Dynamic<Bool>
.
دعونا نصلح ذلك.
أصلح طريقة togglePause
عن طريق استبدالها بما يلي:
func togglePause() { if isPaused.value { startTimer() } else { pauseTimer() } self.isPaused.value = !isPaused.value }
لاحظ كيف أن التغيير الوحيد هو أنك لم تعد تعيّن قيمة الخاصية مباشرة على العقار. بدلاً من ذلك ، يمكنك تعيينه على خاصية value
الكائن.
الآن ، قم بإصلاح التابع initWithGame
باستبدال هذا:
self.time = GameScoreboardEditorViewModelFromGame.timeRemainingPretty(game) self.score = GameScoreboardEditorViewModelFromGame.scorePretty(game) self.isFinished = game.isFinished self.isPaused = true
كالآتي:
self.time = Dynamic(GameScoreboardEditorViewModelFromGame.timeRemainingPretty(for: game)) self.score = Dynamic(GameScoreboardEditorViewModelFromGame.scorePretty(for: game)) self.isFinished = Dynamic(game.isFinished) self.isPaused = Dynamic(true)
يجب أن تفهم النقطة الآن.
أنت تغلف القيم الأولية ، مثل String
و Int
و Bool
، بإصدارات Dynamic<T>
لتلك الكائنات ، والتي تمنحك آلية الربط خفيفة الوزن.
لديك خطأ آخر لإصلاحه.
في طريقة startTimer
، استبدل سطر الخطأ بـ:
self.time.value = GameScoreboardEditorViewModelFromGame.timeRemainingPretty(for: self.game)
لقد قمت بترقية ViewModel الخاص بك ليكون ديناميكيًا ، تمامًا كما فعلت مع ViewModel الخاص باللاعب. لكنك لا تزال بحاجة إلى تحديث طريقة العرض الخاصة بك ( GameScoreboardEditorViewController
).
استبدل طريقة fillUI
بالكامل بهذا:
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] }
الاختلاف الوحيد هو أنك قمت بتغيير الخصائص الديناميكية الأربع الخاصة بك وإضافة مستمعين للتغيير إلى كل واحد منهم.
في هذه المرحلة ، إذا قمت بتشغيل تطبيقك ، فسيبدأ تبديل زر البدء / الإيقاف المؤقت ويوقف مؤقت اللعبة. يستخدم هذا في المهلات المستقطعة أثناء المباراة.
لقد انتهيت تقريبًا باستثناء أن النتيجة لا تتغير في واجهة المستخدم ، عندما تضغط على أحد أزرار النقاط (زر النقطة 1
و 2
).
هذا لأنك لم تنشر بالفعل تغييرات النتيجة في كائن نموذج Game
الأساسي حتى ViewModel.
لذا ، افتح كائن نموذج Game
لفحص بسيط. تحقق من طريقة updateScore
الخاصة به.
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) }
هذه الطريقة تفعل شيئين مهمين.
أولاً ، يقوم بتعيين الخاصية isFinished
على true
إذا تم إنهاء اللعبة بناءً على نتائج كلا الفريقين.
بعد ذلك ، تنشر إشعارًا بأن النتيجة قد تغيرت. ستستمع إلى هذا الإشعار في GameScoreboardEditorViewModelFromGame
وتحديث قيمة النتيجة الديناميكية في طريقة معالج الإشعارات.
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.
ماذا الان؟
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
.