العمل مع الأنماط الثابتة: برنامج تعليمي Swift MVVM
نشرت: 2022-03-11سنرى اليوم كيف تخلق الاحتمالات والتوقعات التقنية الجديدة من مستخدمينا للتطبيقات القائمة على البيانات في الوقت الفعلي تحديات جديدة في طريقة هيكلة برامجنا ، وخاصة تطبيقات الهاتف المحمول الخاصة بنا. بينما تدور هذه المقالة حول iOS و Swift ، فإن العديد من الأنماط والاستنتاجات قابلة للتطبيق بشكل متساوٍ على تطبيقات Android والويب.
كان هناك تطور مهم في كيفية عمل تطبيقات الأجهزة المحمولة الحديثة على مدى السنوات القليلة الماضية. بفضل الوصول إلى الإنترنت الأكثر انتشارًا والتقنيات مثل دفع الإخطارات ومآخذ الويب ، لم يعد المستخدم عادةً المصدر الوحيد لأحداث وقت التشغيل - ولم يعد بالضرورة أهم مصدر بعد الآن - في العديد من تطبيقات الأجهزة المحمولة اليوم.
دعنا نلقي نظرة فاحصة على مدى نجاح نمطي تصميم Swift في عمل كل منهما مع تطبيق دردشة حديث: النمط الكلاسيكي للتحكم في عرض النموذج (MVC) ونمط نموذج عرض نموذج مبسط غير قابل للتغيير (MVVM ، مبسط أحيانًا "نمط ViewModel" "). تعد تطبيقات الدردشة مثالًا جيدًا لأن لديها العديد من مصادر البيانات وتحتاج إلى تحديث واجهات المستخدم الخاصة بها بعدة طرق مختلفة كلما تم تلقي البيانات.
تطبيق الدردشة لدينا
سيحتوي التطبيق الذي سنستخدمه كمبدأ توجيهي في هذا البرنامج التعليمي Swift MVVM على معظم الميزات الأساسية التي نعرفها من تطبيقات الدردشة مثل WhatsApp. دعنا ننتقل إلى الميزات التي سننفذها ونقارن بين MVVM و MVC. تطبيق:
- سيتم تحميل الدردشات المستلمة مسبقًا من القرص
- سيتم مزامنة الدردشات الموجودة عبر طلب
GET
مع الخادم - سوف تتلقى دفع الإخطارات عندما يتم إرسال رسالة جديدة إلى المستخدم
- سيتم توصيله بـ WebSocket بمجرد دخولنا إلى شاشة الدردشة
- يمكن
POST
رسالة جديدة في الدردشة - سيعرض إشعارًا داخل التطبيق عند تلقي رسالة جديدة من محادثة لسنا فيها حاليًا
- ستظهر رسالة جديدة على الفور عندما نتلقى رسالة جديدة للدردشة الحالية
- سنرسل رسالة مقروءة عندما نقرأ رسالة غير مقروءة
- سوف تتلقى رسالة مقروءة عندما يقرأ شخص ما رسالتنا
- يحدّث شارة عداد الرسائل غير المقروءة على أيقونة التطبيق
- يقوم بمزامنة جميع الرسائل التي تم استلامها أو تغييرها مرة أخرى إلى Core Data
في هذا التطبيق التجريبي ، لن يكون هناك تطبيق حقيقي لواجهة برمجة تطبيقات أو WebSocket أو Core Data لإبقاء تنفيذ النموذج أكثر بساطة. بدلاً من ذلك ، أضفت روبوت محادثة سيبدأ في الرد عليك بمجرد بدء محادثة. ومع ذلك ، يتم تنفيذ جميع التوجيهات والمكالمات الأخرى كما لو كانت التخزين والتوصيلات حقيقية ، بما في ذلك التوقفات غير المتزامنة الصغيرة قبل العودة.
تم بناء الشاشات الثلاث التالية:
كلاسيك ام في سي
بادئ ذي بدء ، هناك نمط MVC القياسي لبناء تطبيق iOS. هذه هي الطريقة التي تبني بها Apple كل كود التوثيق الخاص بها والطريقة التي تتوقع بها واجهات برمجة التطبيقات وعناصر واجهة المستخدم. إنه ما يتعلمه معظم الناس عندما يأخذون دورة iOS.
غالبًا ما يتم إلقاء اللوم على MVC لأنه أدى إلى UIViewController
s لبضعة آلاف من أسطر التعليمات البرمجية. ولكن إذا تم تطبيقه جيدًا ، مع وجود فصل جيد بين كل طبقة ، فيمكننا الحصول على ViewController
ضئيلة جدًا تعمل فقط مثل المديرين الوسيطين بين طرق View
Model
ووحدات Controller
الأخرى.
إليك المخطط الانسيابي لتنفيذ MVC للتطبيق (مع استبعاد CreateViewController
من أجل الوضوح):
لنستعرض الطبقات بالتفصيل.
نموذج
عادة ما تكون طبقة النموذج هي الطبقة الأقل إشكالية في MVC. في هذه الحالة ، اخترت استخدام ChatWebSocket
و ChatModel
و PushNotificationController
للتوسط بين كائنات Chat
Message
ومصادر البيانات الخارجية وبقية التطبيق. ChatModel
هو مصدر الحقيقة داخل التطبيق ولا يعمل إلا في الذاكرة في هذا التطبيق التجريبي. في التطبيقات الواقعية ، من المحتمل أن تكون مدعومة بـ Core Data. أخيرًا ، يتعامل ChatEndpoint
مع جميع مكالمات HTTP.
رأي
طرق العرض كبيرة جدًا حيث يتعين عليها التعامل مع الكثير من المسؤوليات لأنني قمت بفصل جميع رموز العرض بعناية عن UIViewController
s. لقد قمت بما يلي:
- استخدم نمط
enum
الحالة (الموصى به للغاية) لتحديد الحالة التي يوجد بها العرض حاليًا. - تمت إضافة الوظائف التي يتم توصيلها بالأزرار وعناصر واجهة التشغيل الأخرى (مثل النقر على رجوع أثناء إدخال اسم جهة اتصال.)
- قم بإعداد القيود وعاود الاتصال بالمندوب في كل مرة.
بمجرد طرح UITableView
في المزيج ، أصبحت المشاهدات الآن أكبر بكثير من UIViewController
s ، مما يؤدي إلى أكثر من 300 سطر من التعليمات البرمجية والعديد من المهام المختلطة في ChatView
.
مراقب
نظرًا لأن كل منطق التعامل مع النموذج قد انتقل إلى ChatModel
. كل كود العرض - الذي قد يكمن هنا في المشاريع المنفصلة الأقل مثالية - يعيش الآن في العرض ، لذا فإن UIViewController
s ضئيلة جدًا. وحدة التحكم في العرض غافلة تمامًا عما تبدو عليه بيانات النموذج ، وكيف يتم جلبها ، أو كيف يجب عرضها - إنها مجرد إحداثيات. في مشروع المثال ، لا يتجاوز أي من UIViewController
s 150 سطرًا من التعليمات البرمجية.
ومع ذلك ، لا يزال ViewController يقوم بالأمور التالية:
- أن تكون مفوضًا للعرض وغيره من أدوات التحكم في العرض
- إنشاء وحدات تحكم العرض ودفعها (أو ظهورها) إذا لزم الأمر
- إرسال واستقبال المكالمات من وإلى
ChatModel
- بدء تشغيل WebSocket وإيقافه وفقًا لمرحلة دورة التحكم في العرض
- اتخاذ قرارات منطقية مثل عدم إرسال رسالة إذا كانت فارغة
- تحديث العرض
لا يزال هذا كثيرًا ، لكنه في الغالب يتعلق بالتنسيق ومعالجة كتل رد الاتصال وإعادة التوجيه.
فوائد
- هذا النمط يفهمه الجميع وتقوم شركة آبل بالترويج له
- يعمل مع جميع الوثائق
- لا حاجة لأطر إضافية
سلبيات
- عرض وحدات التحكم لديها الكثير من المهام ؛ يقوم الكثير منهم في الأساس بتمرير البيانات ذهابًا وإيابًا بين العرض وطبقة النموذج
- غير مناسب تمامًا للتعامل مع مصادر الأحداث المتعددة
- تميل الفصول إلى معرفة الكثير عن الفصول الأخرى
تعريف المشكلة
يعمل هذا بشكل جيد جدًا طالما أن التطبيق يتبع إجراءات المستخدم ويستجيب لها ، كما تتخيل أن تطبيقًا مثل Adobe Photoshop أو Microsoft Word سيعمل. يقوم المستخدم بإجراء ما ، يتم تحديث واجهة المستخدم ، كرر.
لكن التطبيقات الحديثة متصلة ، غالبًا بأكثر من طريقة. على سبيل المثال ، أنت تتفاعل من خلال واجهة برمجة تطبيقات REST ، وتتلقى إشعارات فورية ، وفي بعض الحالات ، تتصل بمقبس WebSocket أيضًا.
مع ذلك ، تحتاج وحدة التحكم في العرض فجأة إلى التعامل مع المزيد من مصادر المعلومات ، وكلما تم تلقي رسالة خارجية دون أن يقوم المستخدم بتشغيلها - مثل تلقي رسالة عبر WebSocket - تحتاج مصادر المعلومات إلى إيجاد طريقها إلى اليمين عرض وحدات التحكم. يحتاج هذا إلى الكثير من التعليمات البرمجية فقط من أجل لصق كل جزء معًا لأداء المهمة نفسها بشكل أساسي.
مصادر البيانات الخارجية
دعنا نلقي نظرة على ما يحدث عندما نتلقى رسالة دفع:
class PushNotificationController { class func received(notification: UNNotification, whenProcessed result: (_ shouldShow: Bool) -> Void) { let shouldShowNotification: Bool defer { result(shouldShowNotification) } let content = notification.request.content let date = DateParser.date(from: content.subtitle) ?? Date() let sender: Message.Sender = .other(name: content.title) let pushedMessage = Message(with: sender, message: content.body, state: .sent, sendDate: date) ChatModel.received(message: pushedMessage, by: content.title) if let chatViewController = chatViewController(handling: content.title) { chatViewController.received(message: pushedMessage) shouldShowNotification = false } else { shouldShowNotification = true } updateChats(for: content.title) } private static func updateChats(for contact: String) { guard let chat = ChatModel.loadedChats.first(where: { (chat) -> Bool in chat.contact == contact }) else { return assertionFailure("Chat for received message should always exist") } BaseNavigationViewController.navigationController?.viewControllers.forEach({ (viewController) in switch viewController { case let chatsViewController as UpdatedChatDelegate: chatsViewController.updated(chat: chat) default: break } }) } private static func chatViewController(handling contact: String) -> ChatViewController? { guard let lastViewController = BaseNavigationViewController.navigationController?.viewControllers.last as? ChatViewController, lastViewController.chat.contact == contact else { return nil } return lastViewController } }
يتعين علينا البحث في مجموعة وحدات التحكم في العرض يدويًا لمعرفة ما إذا كانت هناك وحدة تحكم في العرض تحتاج إلى تحديث نفسها بعد أن نتلقى إشعارًا بالدفع. في هذه الحالة ، نرغب أيضًا في تحديث الشاشات التي تقوم بتطبيق UpdatedChatDelegate
، والذي ، في هذه الحالة ، هو فقط ChatsViewController
. نقوم بذلك أيضًا لمعرفة ما إذا كان يجب علينا إيقاف الإشعار لأننا نبحث بالفعل في Chat
التي كان من المفترض أن يتم ذلك من أجلها. في هذه الحالة ، نسلم الرسالة أخيرًا إلى وحدة التحكم في العرض بدلاً من ذلك. من الواضح جدًا أن PushNotificationController
يحتاج إلى معرفة الكثير عن التطبيق حتى يتمكن من القيام بعمله.
إذا كان ChatWebSocket
الرسائل إلى أجزاء أخرى من التطبيق أيضًا ، فبدلاً من وجود علاقة فردية مع ChatViewController
، فسنواجه نفس المشكلة هناك.
من الواضح أننا يجب أن نكتب رمزًا جائرًا تمامًا في كل مرة نضيف فيها مصدرًا خارجيًا آخر. هذا الرمز هش أيضًا ، لأنه يعتمد بشكل كبير على بنية التطبيق ويقوم المفوضون بتمرير البيانات احتياطيًا إلى التسلسل الهرمي للعمل.
المندوبين
يضيف نمط MVC أيضًا تعقيدًا إضافيًا إلى المزيج بمجرد إضافة وحدات تحكم أخرى في العرض. وذلك لأن المتحكمات في العرض تميل إلى معرفة بعضها البعض من خلال المفوضين والمُبدعين و- في حالة القصص prepareForSegue
عند تمرير البيانات والمراجع. تتعامل كل وحدة تحكم في العرض مع اتصالاتها الخاصة بالنموذج أو وحدات التحكم الوسيطة ، ويقوم كلاهما بإرسال واستقبال التحديثات.
أيضًا ، تتواصل طرق العرض مرة أخرى مع وحدات التحكم في العرض من خلال المفوضين. في حين أن هذا يعمل ، فهذا يعني أن هناك الكثير من الخطوات التي نحتاج إلى اتخاذها لتمرير البيانات ، وأجد نفسي دائمًا أعيد بناء الكثير حول عمليات الاسترجاعات والتحقق مما إذا كان المفوضون قد تم تعيينهم بالفعل.
من الممكن كسر وحدة تحكم عرض واحدة عن طريق تغيير الرمز في آخر ، مثل البيانات التي لا معنى لها في ChatsListViewController
لأن ChatViewController
لم يعد يستدعي updated(chat: Chat)
بعد الآن. خاصة في السيناريوهات الأكثر تعقيدًا ، من الصعب إبقاء كل شيء في حالة تزامن.
الفصل بين العرض والنموذج
من خلال إزالة جميع الأكواد المتعلقة بالعرض من وحدة التحكم في العرض إلى customView
s ونقل جميع التعليمات البرمجية المتعلقة بالنموذج إلى وحدات تحكم متخصصة ، تكون وحدة التحكم في العرض ضعيفة جدًا ومنفصلة. ومع ذلك ، لا تزال هناك مشكلة واحدة متبقية: هناك فجوة بين ما تريد طريقة العرض عرضه والبيانات الموجودة في النموذج. وخير مثال على ذلك هو ChatListView
. ما نريد عرضه هو قائمة بالخلايا التي تخبرنا بمن نتحدث معه ، وما هي الرسالة الأخيرة ، وتاريخ آخر رسالة وعدد الرسائل غير المقروءة المتبقية في Chat
:
ومع ذلك ، فإننا نجتاز نموذجًا لا يعرف ما نريد رؤيته. بدلاً من ذلك ، إنها مجرد Chat
مع جهة اتصال تحتوي على رسائل:
class Chat { let contact: String var messages: [Message] init(with contact: String, messages: [Message] = []) { self.contact = contact self.messages = messages } }
من الممكن الآن إضافة بعض التعليمات البرمجية الإضافية التي ستنقل إلينا الرسالة الأخيرة وعدد الرسائل ، ولكن تنسيق التواريخ إلى السلاسل مهمة تنتمي بقوة إلى طبقة العرض:
var unreadMessages: Int { return messages.filter { switch ($0.sender, $0.state) { case (.user, _), (.other, .read): return false case (.other, .sending), (.other, .sent): return true } }.count } var lastMessage: Date? { return messages.last?.sendDate }
لذا أخيرًا قمنا بتنسيق التاريخ في ChatItemTableViewCell
عندما نعرضه:
func configure(with chat: Chat) { participant.text = chat.contact lastMessage.text = chat.messages.last?.message ?? "" lastMessageDate.text = chat.lastMessage.map { lastMessageDate in DateRenderer.string(from: lastMessageDate) } ?? "" show(unreadMessageCount: chat.unreadMessages) }
حتى في مثال بسيط إلى حد ما ، من الواضح تمامًا أن هناك توترًا بين ما تحتاجه طريقة العرض وما يقدمه النموذج.
MVVM الثابت المستند إلى الأحداث ، والمعروف أيضًا باسم العرض الثابت المستند إلى الأحداث على "نمط نموذج العرض"
يعمل Static MVVM مع نماذج العرض ، ولكن بدلاً من إنشاء حركة مرور ثنائية الاتجاه من خلالها - مثلما اعتدنا أن نفعل من خلال وحدة التحكم في العرض الخاصة بنا مع MVC - نقوم بإنشاء نماذج عرض ثابتة تعمل على تحديث واجهة المستخدم في كل مرة تحتاج واجهة المستخدم إلى التغيير استجابةً لحدث ما .
يمكن تشغيل أي حدث بواسطة أي جزء من الكود تقريبًا ، طالما أنه قادر على توفير البيانات المرتبطة التي يتطلبها enum
الحدث. على سبيل المثال ، يمكن بدء استقبال الحدث received(new: Message)
عن طريق إشعار الدفع ، أو WebSocket ، أو مكالمة شبكة عادية.
دعنا نراه في رسم تخطيطي:
للوهلة الأولى ، يبدو أنه أكثر تعقيدًا قليلاً من مثال MVC الكلاسيكي ، حيث توجد فئات أكثر بكثير لإنجاز نفس الشيء بالضبط. ولكن عند الفحص الدقيق ، لم تعد أي من العلاقات ثنائية الاتجاه.
والأهم من ذلك هو أن كل تحديث لواجهة المستخدم يتم تشغيله بواسطة حدث ما ، لذلك لا يوجد سوى مسار واحد عبر التطبيق لكل ما يحدث. من الواضح على الفور ما هي الأحداث التي يمكنك توقعها. من الواضح أيضًا المكان الذي يجب أن تضيف فيه واحدًا جديدًا إذا لزم الأمر ، أو تضيف سلوكًا جديدًا عند الرد على الأحداث الحالية.
بعد إعادة الهيكلة ، انتهى بي الأمر بالعديد من الفصول الجديدة ، كما أوضحت أعلاه. يمكنك العثور على تطبيقي لإصدار MVVM الثابت على GitHub. ومع ذلك ، عندما أقارن التغييرات بأداة cloc
، يتضح أنه لا يوجد في الواقع الكثير من التعليمات البرمجية الإضافية على الإطلاق:
نمط | الملفات | فارغ | تعليق | رمز |
---|---|---|---|---|
MVC | 30 | 386 | 217 | 1807 |
MVVM | 51 | 442 | 359 | 1981 |
هناك زيادة بنسبة 9 بالمائة فقط في سطور التعليمات البرمجية. والأهم من ذلك ، انخفض متوسط حجم هذه الملفات من 60 سطرًا من التعليمات البرمجية إلى 39 فقط.
بشكل حاسم أيضًا ، يمكن العثور على أكبر قطرات في الملفات التي تكون عادةً الأكبر في MVC: طرق العرض ووحدات التحكم في العرض. المشاهدات هي فقط 74 في المائة من أحجامها الأصلية ووحدات التحكم في العرض هي الآن 53 في المائة فقط من حجمها الأصلي.
وتجدر الإشارة أيضًا إلى أن الكثير من الكود الإضافي عبارة عن رمز مكتبة يساعد على إرفاق كتل بالأزرار والكائنات الأخرى في الشجرة المرئية ، دون الحاجة إلى أنماط @IBAction
الكلاسيكية الخاصة بـ MVC أو التفويض.
دعنا نستكشف الطبقات المختلفة لهذا التصميم واحدة تلو الأخرى.
هدف
يكون الحدث دائمًا عبارة عن enum
، وعادةً ما يكون مصحوبًا بالقيم المرتبطة. غالبًا سوف تتداخل مع أحد الكيانات في نموذجك ولكن ليس بالضرورة كذلك. في هذه الحالة ، يتم تقسيم التطبيق إلى ChatEvent
enum
MessageEvent
. ChatEvent
مخصص لجميع التحديثات على كائنات الدردشة نفسها:
enum ChatEvent { case started case loaded(chats: [Chat]) case creating(chat: Chat) case created(chat: Chat) case createChatFailed(reason: String) }
يتعامل الآخر مع جميع الأحداث المتعلقة بالرسالة:
enum MessageEvent { case sending(message: Message, contact: String, previousMessages: [Message]) case sent(message: Message, contact: String) case failedSending(message: Message, contact: String, reason: String) case received(message: Message, contact: String) case userReads(messagesSentBy: String) case userRead(othersMessages: [Message], sentBy: String) case otherRead(yourMessage: Message, reader: String) }
من المهم أن تقصر * enum
*Event
على حجم معقول. إذا كنت بحاجة إلى 10 حالات أو أكثر ، فعادةً ما تكون هذه علامة على أنك تحاول تغطية أكثر من موضوع واحد.
ملاحظة: مفهوم enum
قوي للغاية في Swift. أميل إلى استخدام enum
مع القيم المرتبطة كثيرًا ، لأنها يمكن أن تزيل الكثير من الغموض الذي قد يكون لديك مع القيم الاختيارية.
برنامج Swift MVVM التعليمي: جهاز توجيه الأحداث
جهاز توجيه الحدث هو نقطة الدخول لكل حدث يحدث في التطبيق. يمكن لأي فئة يمكنها توفير القيمة المرتبطة إنشاء حدث وإرساله إلى جهاز توجيه الأحداث. لذلك يمكن تشغيلها بواسطة أي نوع من المصادر ، على سبيل المثال:

- يدخل المستخدم إلى وحدة تحكم عرض معينة
- ينقر المستخدم على زر معين
- بدء التطبيق
- الأحداث الخارجية مثل:
- طلب شبكة يعود بفشل أو ببيانات جديدة
- دفع الإخطارات
- رسائل WebSocket
يجب أن يعرف جهاز توجيه الحدث أقل قدر ممكن عن مصدر الحدث ويفضل ألا يعرف شيئًا على الإطلاق. لا يحتوي أي من الأحداث في هذا التطبيق النموذجي على أي مؤشر من أين أتوا ، لذلك من السهل جدًا الخلط في أي نوع من مصادر الرسائل. على سبيل المثال ، يقوم WebSocket بتشغيل نفس الحدث - received(message: Message, contact: String)
- كإشعار دفع جديد.
يتم توجيه الأحداث (لقد خمنت ذلك بالفعل) إلى الفئات التي تحتاج إلى مزيد من معالجة هذه الأحداث. عادةً ما تكون الفئات الوحيدة التي يتم استدعاؤها هي طبقة النموذج (إذا كانت البيانات بحاجة إلى إضافة أو تغيير أو إزالة) ومعالج الحدث. سأناقش كلاهما أكثر قليلاً ، لكن الميزة الرئيسية لجهاز توجيه الأحداث هي إعطاء نقطة وصول واحدة سهلة لجميع الأحداث وإعادة توجيه العمل إلى الفئات الأخرى. إليك ChatEventRouter
كمثال:
class ChatEventRouter { static func route(event: ChatEvent) { switch event { case .loaded(let chats): ChatEventHandler.loaded(chats: chats) case .creatingChat(let contact): let chat = ChatModel.create(chatWith: contact) ChatEndpoint.create(chat: chat) ChatEventHandler.creatingChat() case .created(let chat): ChatEventHandler.created(chat: chat) case .createChatFailed(let reason): ChatEventHandler.failedCreatingChat(reason: reason) } } }
لا يوجد الكثير مما يحدث هنا: الشيء الوحيد الذي نقوم به هو تحديث النموذج وإعادة توجيه الحدث إلى ChatEventHandler
حتى يتم تحديث واجهة المستخدم.
برنامج Swift MVVM التعليمي: نموذج تحكم
هذه بالضبط نفس الفئة التي نستخدمها في MVC ، حيث كانت تعمل بشكل جيد بالفعل. يمثل حالة التطبيق وعادةً ما يتم دعمه بواسطة Core Data أو مكتبة تخزين محلية.
نادرًا ما تحتاج طبقات النموذج - إذا تم تنفيذها بشكل صحيح في MVC - إلى أي إعادة بناء لتناسب الأنماط المختلفة. التغيير الأكبر هو أن تغيير النموذج يحدث من عدد أقل من الفئات ، مما يجعل الأمر أكثر وضوحًا عند حدوث التغييرات.
في طريقة بديلة لهذا النمط ، يمكنك ملاحظة التغييرات في النموذج والتأكد من التعامل معها. في هذه الحالة ، اخترت ببساطة السماح *EventRouter
و *Endpoint
بتغيير النموذج ، لذلك توجد مسؤولية واضحة عن مكان ووقت تحديث النموذج. في المقابل ، إذا كنا نلاحظ التغييرات ، فسيتعين علينا كتابة رمز إضافي لنشر الأحداث غير المتغيرة للنموذج مثل الأخطاء من خلال ChatEventHandler
، مما يجعل كيفية تدفق الأحداث من خلال التطبيق أقل وضوحًا.
برنامج Swift MVVM التعليمي: معالج الأحداث
معالج الحدث هو المكان الذي يمكن أن تقوم فيه طرق العرض أو وحدات التحكم في العرض بتسجيل (وإلغاء تسجيل) نفسها كمستمعين لتلقي نماذج عرض محدثة ، والتي يتم إنشاؤها عندما يستدعي ChatEventRouter
وظيفة على ChatEventHandler
.
يمكنك أن ترى أنه يعكس تقريبًا جميع حالات العرض التي استخدمناها في MVC من قبل. إذا كنت تريد أنواعًا أخرى من تحديثات واجهة المستخدم - مثل الصوت أو تشغيل محرك Taptic - فيمكن إجراؤها من هنا أيضًا.
protocol ChatListListening: class { func updated(list: ChatListViewModel) } protocol CreateChatListening: class { func updated(create: CreateChatViewModel) } class ChatEventHandler { private static var chatListListening: [ChatListListening?] = [] private static var createChatListening: [CreateChatListening?] = [] class func add(listener: ChatListListening) { weak var weakListener = listener chatListListening.append(weakListener) } class func remove(listener: ChatListListening) { chatListListening = chatListListening.filter { $0 !== listener } } class func add(listener: CreateChatListening) { weak var weakListener = listener createChatListening.append(weakListener) listener.updated(create: CreateChatViewModelBuilder.build(isSending: false, error: nil)) } class func remove(listener: CreateChatListening) { createChatListening = createChatListening.filter { $0 !== listener } } class func started() { ChatEndpoint.fetchChats() let loadingViewModel = ChatListViewModelBuilder.buildLoading() chatListListening.forEach { $0?.updated(list: loadingViewModel) } } class func loaded(chats: [Chat]) { let chatList = ChatListViewModelBuilder.build(for: chats) chatListListening.forEach { $0?.updated(list: chatList) } } class func creatingChat() { let createChat = CreateChatViewModelBuilder.build(isSending: true, error: nil) createChatListening.forEach { $0?.updated(create: createChat) } } class func failedCreatingChat(reason: String) { let createChat = CreateChatViewModelBuilder.build(isSending: false, error: reason) createChatListening.forEach { $0?.updated(create: createChat) } } class func created(chat: Chat) { let createChat = CreateChatViewModelBuilder.build(isSending: false, error: nil) createChatListening.forEach { $0?.updated(create: createChat) } updateAllChatLists() let chatViewController = ChatViewController(for: chat) BaseNavigationViewController.pushViewController(chatViewController, animated: true, removePreviousFromStack: true) } class func updateAllChatLists() { let chatListViewModel = ChatListViewModelBuilder.build(for: ChatModel.allChats()) chatListListening.forEach { $0?.updated(list: chatListViewModel) } } }
لا تفعل هذه الفئة شيئًا أكثر من التأكد من أن المستمع المناسب يمكنه الحصول على نموذج العرض الصحيح عند حدوث حدث معين. يمكن للمستمعين الجدد الحصول على نموذج عرض على الفور عند إضافتهم إذا لزم الأمر لإعداد حالتهم الأولية. تأكد دائمًا من إضافة مرجع weak
إلى القائمة لمنع دورات الاستبقاء.
برنامج Swift MVVM التعليمي: عرض النموذج
فيما يلي أحد أكبر الاختلافات بين ما تفعله الكثير من أنماط MVVM مقابل ما يفعله المتغير الثابت. في هذه الحالة ، يكون نموذج العرض غير قابل للتغيير بدلاً من إعداد نفسه كوسيط دائم ثنائي الاتجاه بين النموذج والعرض. لماذا نفعل ذلك؟ دعونا نتوقف لشرح ذلك لحظة.
من أهم جوانب إنشاء تطبيق يعمل بشكل جيد في جميع الحالات الممكنة التأكد من صحة حالة التطبيق. إذا كانت واجهة المستخدم لا تتطابق مع النموذج أو تحتوي على بيانات قديمة ، فقد يؤدي كل ما نقوم به إلى حفظ بيانات خاطئة أو تعطل التطبيق أو التصرف بطريقة غير متوقعة.
أحد أهداف تطبيق هذا النمط هو أنه ليس لدينا حالة في التطبيق ما لم يكن ذلك ضروريًا للغاية. ما هي الدولة بالضبط؟ الولاية هي في الأساس كل مكان نخزن فيه تمثيلًا لنوع معين من البيانات. نوع خاص من الحالات هو الحالة التي تكون فيها واجهة المستخدم الخاصة بك حاليًا ، والتي بالطبع لا يمكننا منعها باستخدام تطبيق يحركه واجهة المستخدم. جميع أنواع الحالات الأخرى مرتبطة بالبيانات. إذا كان لدينا نسخة من مجموعة من الدردشات تقوم بعمل نسخة احتياطية من UITableView
الخاص بنا في شاشة قائمة Chat
، فهذا مثال على حالة مكررة. قد يكون نموذج العرض التقليدي ثنائي الاتجاه مثالًا آخر على نسخة مكررة من Chat
مستخدمينا.
من خلال تمرير نموذج عرض غير قابل للتغيير يتم تحديثه عند كل تغيير في النموذج ، فإننا نتخلص من هذا النوع من الحالات المكررة ، لأنه بعد أن يطبق نفسه على واجهة المستخدم ، لم يعد مستخدمًا. ثم لدينا فقط النوعان الوحيدان من الحالات التي لا يمكننا تجنبها - واجهة المستخدم والنموذج - وهما متزامنان تمامًا مع بعضهما البعض.
لذا فإن نموذج العرض هنا مختلف تمامًا عن بعض تطبيقات MVVM. إنه يعمل فقط كمخزن بيانات غير قابل للتغيير لجميع العلامات والقيم والكتل والقيم الأخرى التي تتطلبها طريقة العرض لتعكس حالة النموذج ، ولكن لا يمكن تحديثها بأي شكل من الأشكال بواسطة طريقة العرض.
لذلك يمكن أن يكون هيكلًا بسيطًا غير قابل struct
. للحفاظ على هذا struct
بسيطًا قدر الإمكان ، سنقوم بإنشاء مثيل له باستخدام منشئ نموذج العرض. أحد الأشياء المثيرة للاهتمام حول نموذج العرض هو أنه يحصل على إشارات سلوكية مثل shouldShowBusy
و shouldShowError
التي تحل محل آلية enum
الحالة التي تم العثور عليها مسبقًا في طريقة العرض. فيما يلي بيانات ChatItemTableViewCell
التي قمنا بتحليلها من قبل:
struct ChatListItemViewModel { let contact: String let message: String let lastMessageDate: String let unreadMessageCount: Int let itemTapped: () -> Void }
نظرًا لأن منشئ نموذج العرض يعتني بالفعل بالقيم والإجراءات الدقيقة التي يحتاجها العرض ، يتم تنسيق جميع البيانات مسبقًا. الجديد أيضًا هو الكتلة التي سيتم تشغيلها بمجرد النقر على العنصر. دعونا نرى كيف يتم صنعه بواسطة منشئ نموذج العرض.
مشاهدة نموذج Builder
يمكن لمنشئ نموذج العرض إنشاء مثيلات لنماذج العرض ، وتحويل المدخلات مثل Chat
أو Message
إلى نماذج عرض مصممة بشكل مثالي لعرض معين. أحد أهم الأشياء التي تحدث في منشئ نموذج العرض هو تحديد ما يحدث بالفعل داخل الكتل في نموذج العرض. يجب أن تكون الكتل المرفقة بواسطة منشئ نموذج العرض قصيرة للغاية ، وتستدعي وظائف أجزاء أخرى من العمارة في أسرع وقت ممكن. يجب ألا يكون لمثل هذه الكتل أي منطق عمل.
class ChatListItemViewModelBuilder { class func build(for chat: Chat) -> ChatListItemViewModel { let lastMessageText = chat.messages.last?.message ?? "" let lastMessageDate = (chat.messages.last?.sendDate).map { DateRenderer.string(from: $0) } ?? "" let unreadMessageCount = ChatModel.unreadMessages(for: chat.contact).count return ChatListItemViewModel(contact: chat.contact, message: lastMessageText, lastMessageDate: lastMessageDate, unreadMessageCount: unreadMessageCount, itemTapped: { show(chat: chat) }) } private class func show(chat: Chat) { let chatViewController = ChatViewController(for: chat) BaseNavigationViewController.pushViewController(chatViewController, animated: true) } }
الآن تحدث جميع عمليات التنسيق المسبق في نفس المكان ويتم تحديد السلوك هنا أيضًا. إنها فئة مهمة جدًا في هذا التسلسل الهرمي وقد يكون من المثير للاهتمام معرفة كيفية تنفيذ البناة المختلفين في التطبيق التجريبي والتعامل مع سيناريوهات أكثر تعقيدًا.
برنامج Swift MVVM التعليمي: عرض وحدة التحكم
لا تفعل وحدة التحكم في العرض في هذه البنية سوى القليل جدًا. سيقوم بإعداد كل ما يتعلق برؤيته وهدمه. من الأفضل القيام بذلك لأنه يحصل على جميع عمليات إعادة الاتصال الخاصة بدورة الحياة المطلوبة لإضافة مستمعين وإزالتهم في الوقت المناسب.
يحتاج أحيانًا إلى تحديث عنصر واجهة المستخدم الذي لا يشمله عرض الجذر ، مثل العنوان أو الزر في شريط التنقل. لهذا السبب لا أزال عادةً ما أسجل وحدة التحكم في العرض كمستمع إلى جهاز توجيه الأحداث إذا كان لدي نموذج عرض يغطي العرض الكامل لوحدة التحكم في العرض المحددة ؛ أحيل نموذج العرض إلى العرض بعد ذلك. ولكن من الجيد أيضًا تسجيل أي UIView
كمستمع مباشر إذا كان هناك جزء من الشاشة به معدل تحديث مختلف ، على سبيل المثال شريط أسهم مباشر أعلى صفحة عن شركة معينة.
أصبح رمز ChatsViewController
الآن قصيرًا جدًا بحيث يستغرق أقل من صفحة. ما تبقى هو تجاوز العرض الأساسي ، وإضافة وإزالة زر الإضافة من شريط التنقل ، وتعيين العنوان ، وإضافة نفسه كمستمع ، وتنفيذ بروتوكول ChatListListening
:
class ChatsViewController: UIViewController { private lazy var customView: ChatsView = { let customView = ChatsView() return customView }() private var addButton: UIBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: nil, action: nil) override func loadView() { view = customView } override func viewDidLoad() { super.viewDidLoad() ChatEventHandler.add(listener: self) ChatEventRouter.route(event: .started) title = "Chats" } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationItem.rightBarButtonItem = addButton } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) navigationItem.rightBarButtonItem = nil } } extension ChatsViewController: ChatListListening { func updated(list: ChatListViewModel) { addButton.action(block: { _ in list.addChat() }) customView.display(viewModel: list) } }
لم يتبق أي شيء يمكن القيام به في أي مكان آخر ، حيث تم تجريد ChatsViewController
إلى الحد الأدنى.
برنامج Swift MVVM التعليمي: عرض
يمكن أن يكون العرض في بنية MVVM غير القابلة للتغيير ثقيلًا جدًا ، حيث لا يزال يحتوي على قائمة من المهام ، لكنني تمكنت من تجريده من المسؤوليات التالية مقارنة بهندسة MVC:
- تحديد ما يجب تغييره استجابةً لحالة جديدة
- تنفيذ المندوبين ووظائف الإجراءات
- تعامل مع مشغلات العرض والعرض مثل الإيماءات والرسوم المتحركة المُشغّلة
- تحويل البيانات بطريقة يمكن عرضها (مثل
Date
إلىString
)
خاصة أن النقطة الأخيرة لها ميزة كبيرة. في MVC ، عندما تكون وحدة التحكم في العرض أو العرض مسؤولة عن تحويل البيانات للعرض ، فإنها ستفعل ذلك دائمًا على السلسلة الرئيسية لأنه من الصعب جدًا فصل التغييرات الحقيقية لواجهة المستخدم المطلوب حدوثها في سلسلة الرسائل هذه عن الأشياء الموجودة ليس مطلوبًا للتشغيل عليها. ويمكن أن يؤدي تشغيل رمز غير متغير في واجهة المستخدم على السلسلة الرئيسية إلى تطبيق أقل استجابة.
بدلاً من ذلك ، باستخدام نمط MVVM هذا ، كل شيء بدءًا من الكتلة التي يتم تشغيلها عن طريق النقر حتى اللحظة التي يتم فيها إنشاء نموذج العرض وسيتم تمريره إلى المستمع - يمكننا تشغيل كل هذا على مؤشر ترابط منفصل والغطس فقط في السلسلة الرئيسية في نهاية لإجراء تحديثات واجهة المستخدم. إذا كان تطبيقنا يقضي وقتًا أقل في الموضوع الرئيسي ، فسيتم تشغيله بشكل أكثر سلاسة.
بمجرد أن يطبق نموذج العرض الحالة الجديدة على العرض ، يُسمح له بالتبخر بدلاً من التباطؤ كطبقة أخرى من الحالة. كل ما قد يؤدي إلى تشغيل حدث ما يتم إرفاقه بعنصر في طريقة العرض ولن نتواصل مرة أخرى مع نموذج العرض.
هناك شيء واحد مهم يجب تذكره: لست مجبرًا على تعيين نموذج عرض من خلال وحدة تحكم العرض إلى طريقة عرض. كما ذكرنا سابقًا ، يمكن إدارة أجزاء من العرض بواسطة نماذج عرض أخرى ، خاصةً عندما تختلف معدلات التحديث. ضع في اعتبارك أن ورقة Google يتم تحريرها بواسطة أشخاص مختلفين مع الإبقاء على جزء محادثة مفتوحًا للمتعاونين - ليس من المفيد جدًا تحديث المستند عند وصول رسالة محادثة.
أحد الأمثلة المعروفة هو تطبيق type-to-find حيث يتم تحديث مربع البحث بنتائج أكثر دقة كلما أدخلنا المزيد من النص. هذه هي الطريقة التي يمكنني بها تنفيذ الإكمال التلقائي في فئة CreateAutocompleteView
: يتم تقديم الشاشة بأكملها بواسطة CreateViewModel
ولكن مربع النص يستمع إلى AutocompleteContactViewModel
بدلاً من ذلك.
مثال آخر هو استخدام أداة التحقق من صحة النموذج ، والتي يمكن إنشاؤها إما كـ "حلقة محلية" (إرفاق أو إزالة حالات الخطأ في الحقول وإعلان أن النموذج صالح) أو القيام به من خلال تشغيل حدث.
توفر نماذج العرض الثابتة الثابتة فصلًا أفضل
باستخدام تطبيق MVVM الثابت ، تمكنا أخيرًا من فصل جميع الطبقات تمامًا لأن نموذج العرض الآن يربط بين النموذج والعرض. لقد سهلنا أيضًا إدارة الأحداث التي لم تنتج عن إجراء المستخدم وأزلنا الكثير من التبعيات بين الأجزاء المختلفة لتطبيقنا. الشيء الوحيد الذي تقوم به وحدة التحكم في العرض هو تسجيل (وإلغاء تسجيل) نفسها في معالجات الأحداث كمستمع للأحداث التي تريد استقبالها.
فوائد:
- تميل تطبيقات العرض والعرض إلى أن تكون أخف بكثير
- الفصول أكثر تخصصًا ومنفصلة
- يمكن تشغيل الأحداث بسهولة من أي مكان
- تتبع الأحداث مسارًا يمكن التنبؤ به من خلال النظام
- يتم تحديث الولاية فقط من مكان واحد
- App can be more performant as it's easier to do work off the main thread
- Views receive tailor-made view models and are perfectly separated from the models
Downsides:
- A full view model is created and sent every time the UI needs to update, often overwriting the same button text with the same button text, and replacing blocks with blocks that do exactly the same
- Requires some helper extensions to make button taps and other UI events work well with the blocks in the view model
- Event
enum
s can easily grow pretty large in complex scenarios and might be hard to split up
The great thing is that this is a pure Swift pattern: It does not require a third-party Swift MVVM framework, nor does it exclude the use of classic MVC, so you can easily add new features or refactor problematic parts of your application today without being forced to rewrite your whole application.
There are other approaches to combat large view controllers that provide better separation as well. I couldn't include them all in full detail to compare them, but let's take a brief look at some of the alternatives:
- Some form of the MVVM pattern
- Some form of Reactive (using RxSwift, sometimes combined with MVVM)
- The model-view-presenter pattern (MVP)
- The view-interactor-presenter-entity-router pattern (VIPER)
Traditional MVVM replaces most of the view controller code with a view model that is just a regular class and can be tested more easily in isolation. Since it needs to be a bi-directional bridge between the view and the model it often implements some form of Observables. That's why you often see it used together with a framework like RxSwift.
MVP and VIPER deal with extra abstraction layers between the model and the view in a more traditional way, while Reactive really remodels the way data and events flow through your application.
The Reactive style of programming is gaining a lot of popularity lately and actually is pretty close to the static MVVM approach with events, as explained in this article. The major difference is that it usually requires a framework, and a lot of your code is specifically geared towards that framework.
MVP is a pattern where both the view controller and the view are considered to be the view layer. The presenter transforms the model and passes it to the view layer, while I transform the data into a view model first. Since the view can be abstracted to a protocol, it's much easier to test.
VIPER takes the presenter from MVP, adds a separate “interactor” for business logic, calls the model layer “entity,” and has a router for navigation purposes (and to complete the acronym). It can be considered a more detailed and decoupled form of MVP.
So there you have it: static event-driven MVVM explained. I look forward to hearing from you in the comments below!