برنامج Swift التعليمي: مقدمة لنمط تصميم MVVM

نشرت: 2022-03-11

إذن أنت تبدأ مشروع iOS جديدًا ، وقد تلقيت من المصمم كل ما يلزم من مستندات .pdf و. .sketch ، ولديك بالفعل رؤية حول كيفية إنشاء هذا التطبيق الجديد.

تبدأ في نقل شاشات واجهة المستخدم من رسومات المصمم إلى ViewController .swift و .xib و .storyboard .

UITextField هنا ، UITableView هناك ، عدد قليل من UILabels وقليل من UIButtons . يتم أيضًا تضمين IBOutlets و IBActions . كل خير ، ما زلنا في منطقة واجهة المستخدم.

ومع ذلك ، فقد حان الوقت لعمل شيء ما مع كل عناصر واجهة المستخدم هذه ؛ UIButtons لمسات من الأصابع ، UILabels و UITableViews إلى شخص ما ليخبرهم بما سيتم عرضه وبأي شكل.

فجأة ، لديك أكثر من 3000 سطر من التعليمات البرمجية.

3000 سطر من كود Swift

انتهى بك الأمر مع الكثير من كود السباغيتي.

تتمثل الخطوة الأولى لحل هذه المشكلة في تطبيق نمط تصميم 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 .

الموضوعات ذات الصلة: الأخطاء العشرة الأكثر شيوعًا التي لا يعرف مطورو iOS أنهم يرتكبونها

الترقية إلى نمط تصميم MVC

نمط تصميم MVC

في نمط تصميم MVC ، من المفترض أن يكون العرض غير نشط ويعرض فقط البيانات المعدة عند الطلب.

يجب أن تعمل وحدة التحكم على بيانات النموذج لتحضيرها لطرق العرض ، والتي تعرض بعد ذلك تلك البيانات.

View مسؤولة أيضًا عن إخطار وحدة التحكم بأي إجراءات ، مثل لمسات المستخدم.

كما ذكرنا ، UIViewController هو عادة نقطة البداية في بناء شاشة واجهة المستخدم. لاحظ أنه يحتوي في اسمه على "العرض" و "وحدة التحكم". هذا يعني أنه "يتحكم في العرض". هذا لا يعني أن كلاً من كود "وحدة التحكم" و "العرض" يجب أن يدخلوا إلى الداخل.

غالبًا ما يحدث هذا المزيج من رمز العرض ووحدة التحكم عند نقل IBOutlets من العروض الفرعية الصغيرة داخل UIViewController ، والتلاعب في تلك العروض الفرعية مباشرة من UIViewController . بدلاً من ذلك ، كان يجب عليك تغليف هذا الرمز داخل فئة فرعية مخصصة لـ UIView .

من السهل رؤية أن هذا قد يؤدي إلى تقاطع مسارات رمز العرض والتحكم.

MVVM إلى الإنقاذ

هذا هو المكان الذي يكون فيه نمط MVVM مفيدًا.

نظرًا لأنه من المفترض أن يكون UIViewController وحدة تحكم في نمط MVC ، وهو يفعل الكثير بالفعل مع طرق العرض ، يمكننا دمجها في عرض النمط الجديد - MVVM .

نمط تصميم MVVM

في نمط تصميم MVVM ، يكون النموذج هو نفسه الموجود في نمط MVC. إنه يمثل بيانات بسيطة.

يتم تمثيل العرض بواسطة كائنات UIView أو UIViewController ، مصحوبة بملفات .xib و .storyboard الخاصة بهم ، والتي يجب أن تعرض البيانات المعدة فقط. (لا نريد أن يكون لدينا كود NSDateFormatter ، على سبيل المثال ، داخل العرض.)

فقط سلسلة بسيطة منسقة تأتي من ViewModel .

يقوم ViewModel بإخفاء جميع رموز الشبكات غير المتزامنة ، وكود إعداد البيانات للعرض التقديمي المرئي ، والاستماع إلى التعليمات البرمجية لتغييرات النموذج . كل هذه الأشياء مخفية خلف واجهة برمجة تطبيقات محددة جيدًا تم تصميمها لتلائم هذا العرض المحدد.

إحدى فوائد استخدام MVVM هي الاختبار. نظرًا لأن ViewModel عبارة عن NSObject خالص (أو هيكل على سبيل المثال) ، ولا يقترن UIKit struct يمكنك اختباره بسهولة أكبر في اختبارات الوحدة الخاصة بك دون أن يؤثر ذلك على رمز واجهة المستخدم.

الآن ، أصبح العرض ( UIViewController / UIView ) أبسط بكثير بينما يعمل ViewModel كغراء بين النموذج والعرض .

تطبيق MVVM في Swift

MVVM في سويفت

لتظهر لك 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

الآن ، قم بتشغيل التطبيق. يجب أن يبدو مثل هذا:

تطبيق iOS

لم يعد العرض الأوسط ، المسؤول عن الدرجة والوقت وأسماء الفريق ، يعرض القيم المحددة في 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 .

تطبيق iOS

في هذه المرحلة ، لديك تطبيق وظيفي يستخدم نمط تصميم 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() }

قم بتشغيل التطبيق ، وانقر على بعض أزرار النقل. سترى كيف تتغير قيم العداد داخل عروض المشغل عندما تنقر على زر الإجراء.

تطبيق iOS

لقد انتهيت من 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 .

Related: Working With Static Patterns: A Swift MVVM Tutorial