رفرفة. كتلة ، مقدم ، غير متزامن - هندسة الجرف

المقدمة


عندما تحاول كتابة تطبيق ، فإن أول ما تصادفه هو كيفية تنظيم بنية التطبيق. وعندما يتعلق الأمر بـ Flutter ، يمكن للرأس أن يدور تمامًا حول ما تقدمه Google - Vanilla ، Scoped Model ، BLoC ، MVP ، MVC ، MVVM ، MVI ، إلخ. لنفترض أنك قررت الذهاب بالطريقة الأكثر عصرية (الطريقة التي نصحت بها Google في 2018) واستخدام BLOC. ما هذا؟ كيفية استخدامها؟ أو ربما Redux أو RxDart؟ - على الرغم من أن المحطة تدور حول "الآخر" ... ولكن ما هي الخطوة التالية؟ ما المكتبات للاتصال؟ كتلة ، Flutter_bloc ، bloc_pattern ، وما إلى ذلك؟

مثل هذا العدد من خيارات وأدوات الهندسة لتنفيذها يمكن أن يؤخر حقًا مرحلة الاختيار لفترة طويلة.

لمن المقالة


ستكون المقالة مفيدة في المقام الأول لأولئك الذين بدأوا للتو في تعلم Flutter ولا يعرفون من أين تبدأ. سأعرض أحد الخيارات لتنفيذ التطبيق على Flutter. سيتيح لك ذلك "الشعور" بالرفرفة ، ثم يقرر بنفسك كيف ستستخدم تطبيقاتك.

الأنماط والأدوات. موجز وبسيط


لذا ، لنبدأ. أول شيء جدير بالملاحظة هو أن هناك بنية تطبيق (نمط ، قالب ، بعض مفهوم البناء) - هذا هو نفسه تمامًا: BLoC ، MVP ، MVC ، MVVM ، MVI ، إلخ. يتم استخدام العديد من هذه البنيات ليس فقط في Flutter ، ولكن أيضًا في لغات البرمجة الأخرى. سؤال - ماذا تختار من؟ في رأيي ، أنت بحاجة إلى اختيار ما تعرفه بنفسك جيدًا ، ولكن فقط إذا كان هذا يعني ضمنيًا التفاعل وفصل صارم بين منطق الأعمال عن الواجهة (نعم ، نعم - "يمكن أن تكون السيارة أي لون إذا كانت سوداء"). بالنسبة للفصل بين الواجهة والمنطق التجاري ، أعتقد أنه ليست هناك حاجة للتوضيح ، ولكن بالنسبة للتفاعل - جرب ، إذا لم تجرب - في النهاية إنه مريح للغاية و "جميل". إذا لم تتمكن من اختياره بنفسك ، فدعنا نسمح بأن يتم ذلك من أجلنا ليس بأكثر الرجال غباء من Google - BLOC. اكتشفنا الهندسة المعمارية.

الآن الأدوات - هناك مكتبات جاهزة - Bloc ، Flutter_bloc ، bloc_pattern - أيهما أفضل؟ لا أعرف - الجميع بخير. يمكنك الاختيار والمقارنة لفترة طويلة ، ولكن هنا مرة أخرى ، كما هو الحال في الجيش - من الأفضل اتخاذ قرار خاطئ في الوقت الحالي بدلاً من عدم اتخاذ أي قرار. وفي الوقت الحالي ، أقترح العودة في أعقاب التعديل واستخدام الموفر (ما يوصي به نفس الأشخاص في عام 2019).

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

عدم التزامن والحوسبة الموازية في Dart


كما يستحق الأمر توضيحًا قليلاً لمفهوم التزامن في Dart ، لأننا نتحدث عن التفاعل. في كثير من الأحيان ، في المراحل الأولى من التعرّف على Dart ، لا يتم فهم معنى الوظائف غير المتزامنة (غير المتزامنة) بشكل صحيح. يجب أن تتذكر دائمًا أن البرنامج "افتراضيًا" يعمل في سلسلة محادثات واحدة ، ولا تسمح لك المزامنة بتغيير تسلسل الأوامر فقط ، بدلاً من تنفيذها في نفس الوقت. بمعنى ، إذا قمت ببساطة بتشغيل الوظيفة مع حسابات كبيرة فقط عن طريق وضع علامة عليها غير متزامنة ، فسيتم حظر الواجهة. لا يبدأ Async مؤشر ترابط جديد. كيفية عمل عدم التزامن والانتظار هناك الكثير من المعلومات على الإنترنت ، لذلك لن أتطرق إلى هذا أيضًا.

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

دعنا نبدأ في الممارسة


صياغة المشكلة


لنحاول كتابة أبسط تطبيق - فليكن نوعًا من دليل الهاتف. سنستخدم Firebase كمخزن - وهذا سيسمح لنا بإنشاء تطبيق "سحابي". سوف أتخطى كيفية توصيل Firebase بالمشروع (تمت كتابة أكثر من مقالة واحدة حول هذا الموضوع ولا أرى نقطة التكرار. ملاحظة: يتم استخدام Cloud Firestore في هذا المشروع.).

يجب أن يكون مثل هذا:





وصف التطبيق


سيحتوي طلبنا خارجيًا على:

  1. نافذة مصادقة Firebase (سيتم تضمين منطق هذه النافذة في MainBloc).
  2. نافذة المعلومات - ستعرض معلومات حول المستخدم الذي تم ترخيص البرنامج بموجبه (سيتم تضمين منطق هذه النافذة أيضًا في MainBloc).
  3. نافذة دليل على شكل قائمة بالهواتف (منطق هذه النافذة سيحتوي في PhonebookBloc منفصل).
  4. قائمة التطبيق التي ستبدل الشاشات.

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

سيعمل التطبيق على هذا النحو - عند فتح الشاشة ، تتم تهيئة الكتلة المقابلة بالقيمة الأولية للحالة ، وإذا لزم الأمر ، يتم استدعاء بعض الإجراءات الأولية في مُنشئ الكتلة. يتم بناء / إعادة بناء الشاشة بناءً على الحالة ، والتي تقوم بإرجاع الكتلة. يقوم المستخدم ببعض الإجراءات في التطبيق التي لها إجراءات مقابلة. يتم تمرير الإجراءات إلى فئة الكتلة ، حيث تتم معالجتها في وظيفة mapEventToState وتعيد الكتلة الحالة الجديدة إلى الشاشة ، بناءً على إعادة بناء الشاشة.

هيكل الملف


بادئ ذي بدء ، نقوم بإنشاء مشروع Flutter فارغ ونجعل هيكل المشروع من هذا النوع (ألاحظ أنه في المشروع التجريبي ستبقى بعض الملفات فارغة في النهاية):



نافذة التفويض. Mainbloc


تحتاج الآن إلى تنفيذ التفويض في Firebase.
لنبدأ بإنشاء فئات الأحداث (من المناسب نقل البيانات من خلال الأحداث في الكتلة) وحالات الكتلة الرئيسية:

file MainBloc \ Actions

abstract class MainBlocAction{
  String get password => null;
  String get email => null;
}

ملف MainBloc \ الدول

abstract class MainBlocState{
  bool busy;
  MainBlocState({this.busy = false});
  copy(bool busy) {
    return null;
  }
}

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

يصف ورثة فئة MainBlocState حالة الكتلة الرئيسية للتطبيق. يصف ورثة MainBlocAction الأحداث التي تحدث فيها.

تحتوي فئة MainBloc على 4 عناصر رئيسية - وظيفة "تحويل" الأحداث إلى حالات (mapEventToState المستقبلية) ، وحالة الكتلة هي _blocState ، ومستودع حالة الكتلة هو الريبو ، ودفق حالة "الإخراج" (الذي تتعقبه عناصر الواجهة) هو blocStream. بشكل أساسي ، هذه كلها عناصر توفر وظيفة الكتلة. في بعض الأحيان يُنصح باستخدام دفقتي خرج في كتلة واحدة - مثل هذا المثال سيكون أقل. لن أدرجه هنا - يمكنك رؤيته عن طريق تنزيل المشروع.

تحتوي فئة مستودع الكتلة على منطق للعمل مع Firebase وكائن (بيانات) يقوم بتخزين البيانات اللازمة لمنطق الأعمال الذي تنفذه هذه الكتلة.

ملف MainBloc \ data_model

class MainRepo{

  final MainData data = MainData();

  FirebaseAuth get firebaseInst => MainData.firebaseInst;

  FirebaseUser _currentUser;

  Future<bool> createUserWithEmailAndPassword(
      String email, String password) async {
    var dataUser;
      try {
        dataUser =
            (await firebaseInst.createUserWithEmailAndPassword(
                email: email, password: password))
                .user;
      } catch (e) {
        print(Error.safeToString(e));
        print(e.code);
        print(e.message);
      }
      if (dataUser == null){
        data.setState(IsNotLogged());
        return false;
      }

      _currentUser = dataUser;
      data.setState(IsLogged(),
          uid: _currentUser.uid,
          email: _currentUser.email);
    return true;
  }

  ...}

class MainData {
  static final firebaseInst = FirebaseAuth.instance;
  static MainBlocState _authState = IsNotLogged();
  static MainBlocState get authState => _authState;
  static String _uid;
  static String get uid => _uid;
  static String _email;
  static String get email => _email;

  void setState(MainBlocState newState,
      {String uid = '', String email = ''}) {
    _authState = newState;
    _uid = uid;
    _email = email;
  }
}


تخزن فئة MainData أيضًا الحالة ، ولكن حالة التفويض في Firebase ، وليس حالة الكتلة.

لقد كتبنا منطق الكتلة الرئيسية ، والآن يمكننا البدء في تنفيذ شاشة الترخيص / التسجيل.

تمت تهيئة MainBloc في الملف الرئيسي:

الملف الرئيسي

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return Provider(
        create: (context) => MainBloc(),
        dispose: (context, value) => value.dispose(),
        child: MaterialApp(
          routes: menuRoutes,
        ));
  }
}

حان الوقت لإجراء بحث صغير حول StreamBuilder وموفر و StreamProvider والمستهلك والمحدد.

تراجع عن مقدمي الخدمات


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

StreamBuilder - عنصر واجهة مستخدم يراقب الدفق ويتم إعادة بنائه بالكامل عندما يتلقى كائنًا جديدًا من الدفق.

StreamProvider - عنصر واجهة مستخدم يقوم بمراقبة الدفق وعند استلام كائن جديد ، يشير إلى أنه يجب إعادة إنشاء عناصر واجهة مستخدم تابعة (تلك التي تم تعريفها على أنها فئة منفصلة باستخدام طريقة البناء).

المستهلك و محدد و"نحوي السكر"، أي هذا هو في الواقع "غلاف" يحتوي على بناء ويخفي القطعة في الأسفل. في Selector-e ، يمكنك إجراء تصفية إضافية للتحديثات.

وبالتالي ، عندما تحتاج إلى إعادة بناء معظم الشاشة في كل حدث ، يمكنك استخدام الخيار مع Provider و StreamBuilder. عندما يكون من الضروري إعادة بناء أجزاء من شجرة عناصر واجهة المستخدم بالقرب من الأوراق ، فمن المستحسن استخدام StreamProvider بالاشتراك مع Consumer and Selector لاستبعاد عمليات إعادة البناء غير الضرورية للشجرة.

تفويض استمرار


عند دخول التطبيق ، يجب على المستخدم الوصول إلى نافذة التفويض / التسجيل ، وفي هذه اللحظة يجب ألا تكون قائمة التطبيق متاحة له بعد. النقطة الثانية - لتحديث هذه الشاشة جزئيًا لا معنى لها ، لذلك يمكننا استخدام StreamBuilder لبناء الواجهة. والنقطة الثالثة في المشروع هي استخدام Navigator للتنقل بين الشاشات. عند استلام حدث تفويض ناجح ، من الضروري استدعاء الانتقال إلى شاشة المعلومات. ولكن فقط داخل بناء StreamBuilder ، لن يعمل هذا - سيكون هناك خطأ. للتغلب على هذا ، يمكنك استخدام فئة الغلاف المساعدة StreamBuilderWithListener (Eugene Brusov - stackoverflow.com ).

قائمة هذه الشاشة هي auth_screen نفسها (سأعطيها هنا جزئيًا):

ملف auth_screen

Widget build(BuildContext context) {
  var bloc = Provider.of<MainBloc>(context, listen: false);
  return StreamBuilderWithListener<MainBlocState>(
      stream: bloc.blocStream.stream,
      listener: (value) {
        //not allowed call navigator push in build
        if (value is IsLogged) {
          Navigator.of(context).pushReplacementNamed(InfoScreen.nameMenuItem);
        }
      },
      initialData: bloc.state,
      builder: (context, snappShot) {
        if (snappShot.data is IsLoggedOnStart) {
          return LoggedWidget();
        } else if (snappShot.data is IsLogged) {
          //not allowed call navigator push in build
          return ModalProgressHUD(
              inAsyncCall: true,
          child: Text(''),);
        } else if (snappShot.data is IsNotLogged) {
          return SignInAndSignUpWidget();
        }
        return Scaffold(body: Text("                Unknown event"));
      });
}

أولاً ، يتم إنشاء StreamBuilderWithListener للاستماع إلى الدفق من الكتلة. وبناءً على الحالة الحالية ، يتم استدعاء عنصر واجهة مستخدم LoggedWidget (إذا كان المستخدم قد قام بتسجيل الدخول بالفعل) أو SignInAndSignUpWidget (إذا لم يقم المستخدم بتسجيل الدخول بعد). إذا أعادت الكتلة حالة IsLogged ، فإن التبديل إلى شاشة جديدة باستخدام المستكشف لا يحدث في المُنشئ (مما قد يؤدي إلى حدوث خطأ) ، ولكن في المستمع. في عناصر واجهة التعامل الأساسية ، يتم بناء الواجهة بناء على البيانات التي يتم ارجاعها هنا. هنا ، يتم استخدام حزمة Provider + StreamBuilder بالفعل ، لأنه عندما تتغير حالة الكتلة ، تتغير الواجهة بأكملها تقريبًا.

لنقل البيانات إلى الكتلة ، يتم استخدام TextEditingController ومعلمات الإجراء:

ملف auth_screen

class _SignUpWidgetWidgetState extends State {
  String _email, _password;

  final TextEditingController _emailController = TextEditingController();
  final TextEditingController _passwordController = TextEditingController();

  @override
  void initState() {
    super.initState();
    _emailController.addListener(_onEmailChanged);
    _passwordController.addListener(_onPasswordChanged);
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        TextFormField(
          controller: _emailController,
          decoration: InputDecoration(
              labelText: 'email'),
        ),
        TextFormField(
          controller: _passwordController,
          obscureText: true,
          decoration: InputDecoration(
              labelText: 'password'),
        ),
        RaisedButton(
            child: Text('sign up'),
            onPressed: () {
              Provider.of<MainBloc>(context, listen: false).mapEventToState(
                  Registration(_email, _password));
            })
      ],
    );
  }

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  void _onEmailChanged() {
    _email = _emailController.text;
  }

  void _onPasswordChanged() {
    _password = _passwordController.text;
  }
}
 

PhoneBookScreen Window


والآن دعونا نتحدث قليلاً عن نافذة PhoneBookScreen. هذه هي النافذة الأكثر إثارة للاهتمام - هنا تم بناء الواجهة على أساس تيارين من الكتلة ، وهناك أيضًا قائمة بالتمرير وترقيم الصفحات (ترقيم الصفحات).

PhonebookScreen \ screen file

class PhonebookTopPart extends StatelessWidget {

  StatefulWidget caseWidget(PhonebookState state) {
    if (state is PhonebookListOpened) {
      return PhonebookList();
    //} else if (data is PhonebookCardToViewOpened) {
    }else ModalProgressHUD(
      inAsyncCall: true,
      child: Text(''),);
    return null;
  }

  @override
  Widget build(BuildContext context) {
    var bloc = Provider.of<PhonebookBloc>(context, listen: false);
    return StreamProvider<PhonebookState>(
        create: (context) => bloc.blocStream.stream,
        initialData: bloc.state,
        child: Selector<PhonebookState,PhonebookState>(
            selector: (_,state)=>state,
            shouldRebuild: (previous, next){return (previous.runtimeType!=next.runtimeType);},
            builder: (_, state, __) { return ModalProgressHUD(
                inAsyncCall: state.busy,
                child: Scaffold(
                  appBar: AppBar(
                    title: Text("Phones list"),
                  ),
                  drawer: MenuWidget(),
                  body: caseWidget(state),
                ));}
        ));
  }
}

هناك حاجة إلى أول StreamProvider للتبديل بين الشاشات المختلفة للدليل - القائمة ، بطاقة جهة الاتصال ، بطاقة جهة الاتصال للتحرير ، إلخ. يتم تحديد عنصر واجهة مستخدم الشاشة في وظيفة caseWidget (ولكن في هذا المثال يتم تنفيذ طريقة عرض القائمة فقط - يمكنك محاولة تنفيذ طريقة العرض لبطاقة الاتصال - وهذا أمر بسيط للغاية ولن يكون بداية سيئة).

على هذه الشاشة ، يتم استخدام مجموعة من StreamProvider + Selector / Consumer بالفعل هناك تمرير من القائمة ولا يُنصح بإعادة بناء الشاشة بأكملها معها (أي إعادة بناء الحاجيات تأتي من المحدد / المستهلك المقابل وأدنى في الشجرة).

وهنا يتم تنفيذ القائمة نفسها:

PhonebookScreen \ screen file

class _PhonebookListState extends State<PhonebookList> {
  ScrollController _scrollController = ScrollController();

  @override
  void initState() {
    super.initState();
    _scrollController.addListener(_scrollListener);
  }

  @override
  Widget build(BuildContext context) {
    var bloc = Provider.of<PhonebookBloc>(context, listen: false);
    var list = bloc.repo.data.list;
    return Container(
        child: StreamProvider<PhonebookState>(
            create: (context) => bloc.scrollStream.stream,
            initialData: bloc.scrollState,
            child: Consumer<PhonebookState>(
              builder: (_, state, __) {
                return ListView.builder(
                    controller: _scrollController,
                    itemCount: list.length,
                    itemBuilder: (BuildContext context, int index) {
                      return ListTile(
                        title: Text(list[index].data['name']),
                        subtitle: Text(list[index].data['phone']),
                      );
                    });
              },
            )));
  }

  void _scrollListener() {
    double delta = MediaQuery
        .of(context)
        .size
        .height * 3;
    double maxScroll = _scrollController.position.maxScrollExtent;
    double currentScroll = _scrollController.position.pixels;
    if (maxScroll - currentScroll <= delta) {
      Provider.of<PhonebookBloc>(context, listen: false)
          .mapEventToState(ScrollPhonebook());
    }
  }

  @override
  void dispose() {
    _scrollController.removeListener(_scrollListener);
    super.dispose();
  }
}

نرى هنا StreamProvider الثاني ، الذي يراقب التدفق الثاني للكتلة ، المسؤول عن التمرير. يتم تنظيم ترقيم الصفحات كمعيار عبر _scrollListener (وحدة التحكم: _scrollController). على الرغم من أن النافذة مثيرة للاهتمام ، ولكن نظرًا للوصف التفصيلي للنافذة الأولى ، فلا يوجد شيء آخر يمكن قوله هنا. لذلك ، هذا كل شيء اليوم.

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

يمكن تنزيل المشروع في (للتسجيل يمكنك استخدام أي بريد بكلمة مرور لا تقل عن 6 أحرف. عند إعادة الترخيص ، يجب أن تكون كلمة المرور هي نفسها المستخدمة أثناء التسجيل).

All Articles