بنية مكون واجهة المستخدم في تطبيق iOS



مرحبا يا هبر!

اسمي فاليرا ، ومنذ عامين وأنا أقوم بتطوير تطبيق iOS كجزء من فريق Badoo. من أولوياتنا الحفاظ على التعليمات البرمجية بسهولة. نظرًا للعدد الكبير من الميزات الجديدة التي تقع في أيدينا أسبوعيًا ، نحتاج أولاً إلى التفكير في بنية التطبيق ، وإلا سيكون من الصعب للغاية إضافة ميزة جديدة إلى المنتج دون كسر الميزات الموجودة. من الواضح أن هذا ينطبق أيضًا على تنفيذ واجهة المستخدم (UI) بغض النظر عما إذا كان هذا يتم باستخدام الكود أو Xcode (XIB) أو نهج مختلط. في هذه المقالة سأصف بعض تقنيات تنفيذ واجهة المستخدم التي تسمح لنا بتبسيط تطوير واجهة المستخدم ، مما يجعلها مرنة ومريحة للاختبار. هناك أيضًا نسخة إنجليزية من هذه المقالة.

قبل أن تبدأ ...


سأدرس تقنيات تنفيذ واجهة المستخدم باستخدام تطبيق مثال مكتوب في Swift. يعرض التطبيق قائمة الأصدقاء بالضغط على الزر.

وتتكون من ثلاثة أجزاء:

  1. المكونات عبارة عن مكونات مخصصة لواجهة المستخدم ، أي رمز يتعلق فقط بواجهة المستخدم.
  2. تطبيق تجريبي - عرض تجريبي للنماذج وكيانات واجهة المستخدم الأخرى التي لها تبعيات واجهة المستخدم فقط.
  3. التطبيق الحقيقي هو عرض النماذج والكيانات الأخرى التي قد تحتوي على تبعيات ومنطق معين.

لماذا يوجد مثل هذا الفصل؟ سأجيب عن هذا السؤال أدناه ، ولكن في الوقت الحالي ، تحقق من واجهة المستخدم لتطبيقنا:


هذا عرض منبثق مع محتوى أعلى عرض ملء الشاشة آخر. كل شيء بسيط.

كود المصدر الكامل للمشروع متاح على جيثب .

قبل الخوض في رمز واجهة المستخدم ، أود أن أقدمك إلى فئة المساعدة الملحوظة المستخدمة هنا. تبدو واجهته كما يلي:

var value: T func observe(_ closure: @escaping (_ old: T, _ new: T) -> Void) -> ObserverProtocol func observeNewAndCall(_ closure: @escaping (_ new: T) -> Void) -> ObserverProtocol 

إنه ببساطة يخطر جميع المراقبين الموقَّعين سابقًا بالتغييرات ، لذلك هذا نوع من بديل لـ KVO (مراقبة القيمة الرئيسية) أو ، إذا أردت ، البرمجة التفاعلية. هنا مثال للاستخدام:

 self.observers.append(self.viewModel.items.observe { [weak self] (_, newItems) in   self?.state = newItems.isEmpty ? .zeroCase(type: .empty) : .normal   self?.collectionView.reloadSections(IndexSet(integer: 0)) }) 

تشترك وحدة التحكم في التغييرات على خاصية self.viewModel.items ، وعندما يحدث التغيير ، يقوم المعالج بتنفيذ منطق العمل. على سبيل المثال ، يتم تحديث حالة العرض وإعادة تحميل عرض المجموعة بعناصر جديدة.

سترى المزيد من الأمثلة للاستخدام أدناه.

المنهجيات


في هذا القسم سوف أتحدث عن أربع تقنيات تطوير واجهة المستخدم المستخدمة في Badoo:

1. تنفيذ واجهة المستخدم في التعليمات البرمجية.

2. استخدام المراسي التخطيط.

3. مكونات - فرق تسد.

4. فصل واجهة المستخدم والمنطق.

رقم 1: تطبيق واجهة المستخدم في التعليمات البرمجية


في Badoo ، يتم تنفيذ معظم اهتمامات المستخدم في التعليمات البرمجية. لماذا لا نستخدم XIBs أو القصص المصورة؟ سؤال عادل. السبب الرئيسي هو الراحة في الحفاظ على الرمز لفريق متوسط ​​الحجم ، وهو:

  • التغييرات في الكود مرئية بوضوح ، مما يعني أنه لا توجد حاجة لتحليل ملف القصة المصورة XML / ملف XIB للعثور على التغييرات التي أجراها زميل ؛
  • أنظمة التحكم في الإصدار (على سبيل المثال ، Git) أسهل بكثير في العمل مع التعليمات البرمجية مقارنة بملفات XLM "الثقيلة" ، خاصة أثناء التعارضات المعتدلة ؛ يؤخذ أيضًا في الاعتبار أن محتويات ملفات XIB / لوحة العمل تتغير في كل مرة يتم حفظها ، حتى إذا لم تتغير الواجهة (على الرغم من أنني سمعت أنه في Xcode 9 تم حل هذه المشكلة بالفعل) ؛
  • قد يكون من الصعب تغيير بعض الخصائص والحفاظ عليها في Interface Builder (IB) ، على سبيل المثال ، خصائص CALayer أثناء عملية ترحيل طرق العرض الفرعية (طرق العرض الفرعية للتخطيط) ، والتي يمكن أن تؤدي إلى العديد من مصادر الحقيقة لحالة العرض ؛
  • إن Interface Builder ليست الأداة الأسرع ، وأحيانًا يكون العمل مع الشفرة أسرع بكثير.

ألق نظرة على وحدة التحكم التالية (FriendsListViewController):

 final class FriendsListViewController: UIViewController { struct ViewConfig { let backgroundColor: UIColor let cornerRadius: CGFloat } private var infoView: FriendsListView! private let viewModel: FriendsListViewModelProtocol private let viewConfig: ViewConfig init(viewModel: FriendsListViewModelProtocol, viewConfig: ViewConfig) { self.viewModel = viewModel self.viewConfig = viewConfig super.init(nibName: nil, bundle: nil) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() self.setupContainerView() } private func setupContainerView() { self.view.backgroundColor = self.viewConfig.backgroundColor let infoView = FriendsListView( frame: .zero, viewModel: self.viewModel, viewConfig: .defaultConfig) infoView.backgroundColor = self.viewConfig.backgroundColor self.view.addSubview(infoView) self.infoView = infoView infoView.translatesAutoresizingMaskIntoConstraints = false infoView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor).isActive = true infoView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor).isActive = true infoView.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true infoView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true } // …. } 

يوضح هذا المثال أنه لا يمكنك إنشاء وحدة تحكم في العرض إلا من خلال توفير نموذج عرض وتكوين طريقة العرض. يمكنك قراءة المزيد عن نماذج العروض التقديمية ، أي نموذج تصميم MVVM (Model-View-ViewModel) هنا . نظرًا لأن تكوين العرض عبارة عن كيان هيكلي بسيط (كيان هيكلي) يحدد تخطيط ونمط العرض ، أي المسافات البادئة والأحجام والألوان والخطوط وما إلى ذلك ، أرى أنه من المناسب توفير تكوين قياسي مثل هذا:

 extension FriendsListViewController.ViewConfig {   static var defaultConfig: FriendsListViewController.ViewConfig {       return FriendsListViewController.ViewConfig(backgroundColor: .white,                                                   cornerRadius: 16)   } } 

تحدث جميع عمليات تهيئة العرض في طريقة setupContainerView ، والتي يتم استدعاؤها مرة واحدة فقط من viewDidLoad عندما يتم إنشاء طريقة العرض وتحميلها بالفعل ، ولكن لم يتم رسمها بعد على الشاشة ، أي تتم إضافة جميع العناصر الضرورية (طرق العرض الفرعية) ببساطة إلى التسلسل الهرمي للعرض ، ثم يتم تطبيق الترميز (تخطيط) وأنماط.

إليك ما تبدو عليه وحدة التحكم في العرض الآن:

 final class FriendsListPresenter: FriendsListPresenterProtocol {   // …   func presentFriendsList(from presentingViewController: UIViewController) {       let controller = Class.createFriendsListViewController( presentingViewController: presentingViewController,           headerViewModel: self.headerViewModel,           contentViewModel: self.contentViewModel)       controller.modalPresentationStyle = .overCurrentContext       controller.modalTransitionStyle = .crossDissolve       presentingViewController.present(controller, animated: true, completion: nil)   }   private class func createFriendsListViewController( presentingViewController: UIViewController, headerViewModel: FriendsListHeaderViewModelProtocol,       contentViewModel: FriendsListContentViewModelProtocol) -> FriendsListContainerViewController {      let dismissViewControllerBlock: VoidBlock = { [weak presentingViewController] in           presentingViewController?.dismiss(animated: true, completion: nil)       }       let infoViewModel = FriendsListViewModel( headerViewModel: headerViewModel,           contentViewModel: contentViewModel)       let containerViewModel = FriendsListContainerViewModel(onOutsideContentTapAction: dismissViewControllerBlock)       let friendsListViewController = FriendsListViewController( viewModel: infoViewModel, viewConfig: .defaultConfig)       let controller = FriendsListContainerViewController( contentViewController: friendsListViewController,           viewModel: containerViewModel,           viewConfig: .defaultConfig)       return controller   } } 

يمكنك أن ترى فصلًا واضحًا للمسؤوليات ، وهذا المفهوم ليس أكثر تعقيدًا بكثير من استدعاء segage على لوحة العمل.

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

 let friendsListViewController = FriendsListViewController( viewModel: infoViewModel, viewConfig: .defaultConfig) 

# 2: استخدام مثبتات التخطيط


إليك رمز التخطيط:

 self.view.addSubview(infoView) self.infoView = infoView infoView.translatesAutoresizingMaskIntoConstraints = false infoView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor).isActive = true infoView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor).isActive = true infoView.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true infoView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true 

ببساطة ، يضع هذا الرمز infoView داخل العرض الرئيسي (superview) ، في الإحداثيات (0 ، 0) بالنسبة للحجم الأصلي من superview.

لماذا نستخدم مراسي التخطيط؟ إنه سريع وسهل. بالطبع ، يمكنك تعيين UIView.frame يدويًا وحساب جميع المواضع والأحجام على الطاير ، ولكن في بعض الأحيان يمكن أن يتحول إلى رمز مربك للغاية و / أو ضخم.

يمكنك أيضًا استخدام تنسيق نصي للترميز ، كما هو موضح هنا ، ولكن غالبًا ما يؤدي ذلك إلى أخطاء ، لأنك تحتاج إلى اتباع التنسيق بدقة ، ولا يتحقق Xcode من نص وصف الترميز في مرحلة كتابة / تجميع الرمز ، ولا يمكنك استخدام دليل تخطيط المنطقة الآمنة:

 NSLayoutConstraint.constraints( withVisualFormat: "V:|-(\(topSpace))-[headerView(headerHeight@200)]-[collectionView(collectionViewHeight@990)]|",   options: [],   metrics: metrics,   views: views) 

من السهل جدًا ارتكاب خطأ أو خطأ مطبعي في السلسلة النصية التي تحدد الترميز ، أليس كذلك؟

# 3: المكونات - فرق تسد


تنقسم واجهة مستخدم المثال الخاصة بنا إلى مكونات ، كل منها يؤدي وظيفة واحدة محددة ، لا أكثر.

على سبيل المثال:

  1. FriendsListHeaderView - يعرض معلومات حول الأصدقاء وزر الإغلاق.
  2. FriendsListContentView - يعرض قائمة الأصدقاء الذين لديهم خلايا قابلة للنقر ، يتم تحميل المحتوى ديناميكيًا عندما يصل إلى نهاية القائمة.
  3. FriendsListView - حاوية FriendsListView سابقتين.

كما ذكرنا سابقًا ، نحن في Badoo نحب مبدأ المسؤولية الوحيدة عندما يكون كل مكون مسؤولًا عن وظيفة منفصلة. هذا لا يساعد فقط في عملية إصلاح الأخطاء (التي ربما ، ليست الجزء الأكثر إثارة للاهتمام من عمل مطور iOS) ، ولكن أيضًا أثناء تطوير وظائف جديدة ، لأن هذا النهج يوسع بشكل كبير إمكانيات إعادة استخدام التعليمات البرمجية في المستقبل.

# 4: فصل واجهة المستخدم والمنطق


والنقطة الأخيرة ، ولكن ليس أقل أهمية ، هي فصل واجهة المستخدم والمنطق. تقنية يمكنها توفير الوقت والأعصاب لفريقك. بالمعنى الحرفي: مشروع منفصل لواجهة المستخدم ومشروع منفصل لمنطق الأعمال.

دعونا نعود إلى مثالنا. كما تتذكر ، يبدو جوهر العرض التقديمي (مقدم) كما يلي:

 func presentFriendsList(from presentingViewController: UIViewController) {   let controller = Class.createFriendsListViewController( presentingViewController: presentingViewController,       headerViewModel: self.headerViewModel,       contentViewModel: self.contentViewModel)   controller.modalPresentationStyle = .overCurrentContext   controller.modalTransitionStyle = .crossDissolve   presentingViewController.present(controller, animated: true, completion: nil) } 

ما عليك سوى تقديم نماذج عرض للعنوان والمحتوى. والباقي مخفي داخل التنفيذ السابق لمكونات واجهة المستخدم.

يبدو بروتوكول نموذج عرض الرأس كما يلي:

 protocol FriendsListHeaderViewModelProtocol {   var friendsCountIcon: UIImage? { get }   var closeButtonIcon: UIImage? { get }   var friendsCount: Observable<String> { get }   var onCloseAction: VoidBlock? { get set } } 

تخيل الآن أنك تضيف اختبارات مرئية لواجهة المستخدم - الأمر بسيط مثل اجتياز نماذج كعب الروتين لمكونات واجهة المستخدم.

 final class FriendsListHeaderDemoViewModel: FriendsListHeaderViewModelProtocol {   var friendsCountIcon: UIImage? = UIImage(named: "ic_friends_count")   var closeButtonIcon: UIImage? = UIImage(named: "ic_close_cross")   var friendsCount: Observable<String>   var onCloseAction: VoidBlock?   init() {       let friendsCountString = "\(Int.random(min: 1, max: 5000))"       self.friendsCount = Observable(friendsCountString)   } } 

يبدو بسيطا ، أليس كذلك؟ نريد الآن إضافة منطق الأعمال إلى مكونات تطبيقنا ، والتي قد تتطلب موفري البيانات ونماذج البيانات ، وما إلى ذلك:

 final class FriendsListHeaderViewModel: FriendsListHeaderViewModelProtocol {   let friendsCountIcon: UIImage?   let closeButtonIcon: UIImage?   let friendsCount: Observable<String> = Observable("0")   var onCloseAction: VoidBlock?   private let dataProvider: FriendsListDataProviderProtocol   private var observers: [ObserverProtocol] = []   init(dataProvider: FriendsListDataProviderProtocol,        friendsCountIcon: UIImage?,        closeButtonIcon: UIImage?) {       self.dataProvider = dataProvider       self.friendsCountIcon = friendsCountIcon       self.closeButtonIcon = closeButtonIcon       self.setupDataObservers()   }   private func setupDataObservers() {       self.observers.append(self.dataProvider.totalItemsCount.observeNewAndCall { [weak self] (newCount) in           self?.friendsCount.value = "\(newCount)"       })   } } 

ما يمكن أن يكون أسهل؟ مجرد تنفيذ مزود البيانات - وانطلق!

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

 private func presentRealFriendsList(sender: Any) {   let avatarPlaceholderImage = UIImage(named: "avatar-placeholder")   let itemFactory = FriendsListItemFactory(avatarPlaceholderImage: avatarPlaceholderImage)   let dataProvider = FriendsListDataProvider(itemFactory: itemFactory)   let viewModelFactory = FriendsListViewModelFactory(dataProvider: dataProvider)   var headerViewModel = viewModelFactory.makeHeaderViewModel()   headerViewModel.onCloseAction = { [weak self] in       self?.dismiss(animated: true, completion: nil)   }   let contentViewModel = viewModelFactory.makeContentViewModel()   let presenter = FriendsListPresenter( headerViewModel: headerViewModel,       contentViewModel: contentViewModel)   presenter.presentFriendsList(from: self) } 

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

الخلاصة


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

هناك طرق أخرى ، على سبيل المثال ، مكونات واجهة المستخدم القابلة للتكوين XIB باستخدام Interface Builder (تم وصفها في مقالتنا الأخرى) ، ولكن لأسباب مختلفة لا يتم استخدامها في Badoo. تذكر أن كل شخص لديه رأيه ورؤيته الخاصة للصورة الكبيرة ، لذلك ، من أجل تطوير مشروع ناجح ، يجب أن تتوصل إلى إجماع في الفريق واختيار النهج الأنسب لمعظم السيناريوهات.

قد يكون سويفت معك!

مصادر

Source: https://habr.com/ru/post/ar421559/


All Articles