Архитектура Flutter: провайдер против BLoC

Опубликовано: 2020-04-17

Написание приложений с помощью Flutter открывает большие возможности для выбора архитектуры. Как это часто бывает, лучший ответ на вопрос «Что выбрать?» это "это зависит". Когда вы получите этот ответ, можете быть уверены, что нашли эксперта в программировании.

В этой статье мы пройдемся по самым популярным экранам в мобильных приложениях и реализуем их в двух самых популярных архитектурах Flutter: Provider и BLoC . В результате мы узнаем плюсы и минусы каждого решения, что поможет нам выбрать правильную архитектуру Flutter для нашего следующего модуля или приложения.

Краткое введение в архитектуру Flutter

Выбор архитектуры для проекта разработки Flutter имеет большое значение, в первую очередь из-за того, что мы имеем дело с менее часто используемой парадигмой декларативного программирования. Это полностью меняет подход к управлению состоянием, с которым были знакомы нативные разработчики Android или iOS, — императивное написание кода. Данные, доступные в одном месте приложения, не так просто получить в другом. У нас нет прямых ссылок на другие представления в дереве, из которых мы могли бы получить их текущее состояние.

Что такое провайдер во Flutter

Как следует из названия, Provider — это архитектура Flutter, которая предоставляет текущую модель данных там, где она нам в данный момент нужна. Он содержит некоторые данные и уведомляет наблюдателей, когда происходит изменение. Во Flutter SDK этот тип называется ChangeNotifier . Чтобы объект типа ChangeNotifier был доступен другим виджетам, нам нужен ChangeNotifierProvider . Он предоставляет наблюдаемые объекты для всех своих потомков. Объектом, который может получать текущие данные, является Consumer , у которого есть экземпляр ChangeNotifier в параметре его функции сборки , который можно использовать для подачи данных в последующие представления.

Что такое BLoC во Flutter

Компоненты бизнес-логики — это архитектура Flutter, гораздо более похожая на популярные мобильные решения, такие как MVP или MVVM. Он обеспечивает отделение уровня представления от правил бизнес-логики. Это прямое применение декларативного подхода, который сильно подчеркивает Flutter, т. е. UI = f (state) . BLoC — это место, куда попадают события из пользовательского интерфейса. На этом уровне в результате применения бизнес-правил к данному событию BLoC отвечает определенным состоянием, которое затем возвращается в пользовательский интерфейс. Когда уровень представления получает новое состояние, он перестраивает свое представление в соответствии с требованиями текущего состояния.

Значок службы разработки

Интересуетесь разработкой Flutter?

Посмотрите наши решения

Как создать список во Flutter

Прокручиваемый список, вероятно, является одним из самых популярных представлений в мобильных приложениях. Поэтому выбор правильной архитектуры Flutter может иметь здесь решающее значение. Теоретически отобразить сам список несложно. Ситуация усложняется, когда, например, мы добавляем возможность выполнять определенное действие над каждым элементом. Это должно привести к изменению в разных местах приложения. В нашем списке мы сможем выбрать каждый из элементов, и каждый из выбранных будет отображаться в отдельном списке на другом экране.

Создание списка во Flutter

Следовательно, мы должны сохранять выбранные элементы, чтобы их можно было отобразить на новом экране. Кроме того, нам нужно будет перестраивать представление каждый раз, когда нажимается флажок, чтобы фактически отображать отметку / снятие отметки.

Модель элемента списка выглядит очень просто:

 класс Социальные Медиа {
 внутренний идентификатор;
 Заголовок строки;
 Строка iconAsset;
 логическое значениеИзбранное;

 Социальные медиа(
     {@required this.id,
     @required this.title,
     @required this.iconAsset,
     this.isFavourite = ложь});

 void setFavourite (bool isFavourite) {
   this.isFavourite = этоИзбранное;
 }
}

Как создать список с провайдером

В шаблоне Provider указанная выше модель должна храниться в объекте. Объект должен расширять ChangeNotifier , чтобы иметь возможность доступа к SocialMedia из другого места в приложении.

 класс SocialMediaModel расширяет ChangeNotifier {
 final List<SocialMedia> _socialMedia = [ /* некоторые объекты социальных сетей */ ];

 НемодифицируемыйListView<SocialMedia> получить избранное {
   вернуть немодифицируемыйListView(_socialMedia.where((item) => item.isFavourite));
 }

 НемодифицируемыйListView<SocialMedia> получить все {
   вернуть немодифицируемыйListView(_socialMedia);
 }


void setFavourite (int itemId, bool isChecked) {
 _социальные медиа
     .firstWhere((item) => item.id == itemId)
     .setFavourite (проверено);
 уведомитьслушателей();
}

Любое изменение в этом объекте, которое потребует перестроения в представлении, должно сигнализироваться с помощью notifyListeners() . В случае использования метода setFavourite() для указания Flutter повторно отобразить фрагмент пользовательского интерфейса, который будет наблюдать за изменением этого объекта.

Теперь мы можем перейти к созданию списка. Чтобы заполнить ListView элементами, нам нужно будет добраться до объекта SocialMediaModel , в котором хранится список всех элементов. Вы можете сделать это двумя способами:

  • Provider.of<ModelType>(контекст, прослушивание: ложь)
  • Потребитель

Первый предоставляет наблюдаемый объект и позволяет нам решить, должно ли действие, выполняемое над объектом, перестроить текущий виджет, используя параметр listen . Такое поведение будет полезно в нашем случае.

 класс SocialMediaListScreen расширяет StatelessWidget {
 Социальный МедиаСписокЭкран();

 @переопределить
 Сборка виджета (контекст BuildContext) {
   var socialMedia = Provider.of<SocialMediaModel>(контекст, прослушивание: ложь);

   вернуть ListView(
     дети: socialMedia.all
         .map((item) => CheckboxSocialMediaItem(item: item))
         .к списку(),
   );
 }
}

Нам нужен список всех социальных сетей, но нет необходимости перестраивать весь список. Давайте посмотрим, как выглядит виджет элемента списка.

 класс CheckboxSocialMediaItem расширяет StatelessWidget {
 последний элемент SocialMedia;

 CheckboxSocialMediaItem({Key key, @required this.item}) : super(key: key);

 @переопределить
 Сборка виджета (контекст BuildContext) {
   вернуть заполнение(
     заполнение: const EdgeInsets.all(Dimens.paddingDefault),
     ребенок: Строка(
       дети: [
         Потребитель<SocialMediaModel>(
           построитель: (контекст, модель, дочерний элемент) {
             вернуть флажок (
               значение: item.isFavourite,
               onChanged: (проверено) =>
                   model.setFavourite(item.id, isChecked),
             );
           },
         ),
         SocialMediaItem(
           предмет: предмет,
         )
       ],
     ),
   );
 }
}

Мы слушаем изменение значения флажка и обновляем модель на основе состояния флажка. Само значение флажка устанавливается с использованием свойства из модели данных. Это означает, что после выбора модель изменит поле isFavourite на true . Однако представление не будет отображать это изменение, пока мы не перестроим флажок. Здесь на помощь приходит объект Consumer . Он предоставляет наблюдаемый объект и перестраивает всех его потомков после получения информации об изменении модели.

Размещать Consumer стоит только там, где необходимо обновить виджет, чтобы избежать лишнего ребилда представлений. Обратите внимание, что если, например, установка флажка вызовет дополнительное действие, такое как изменение названия элемента, Потребитель должен быть перемещен выше в дереве виджетов, чтобы стать родителем виджета, отвечающего за отображение заголовка. . В противном случае вид заголовка не будет обновлен.

Создание любимого экрана в социальных сетях будет выглядеть аналогично. Мы получим список избранных элементов с помощью Provider .

 класс FavoritesListScreen расширяет StatelessWidget {
 ЭкранСпискаИзбранного();

 @переопределить
 Сборка виджета (контекст BuildContext) {
   var list = Provider.of<SocialMediaModel>(контекст, прослушивание: ложь).избранное;

   вернуть ListView(
     дети: список
         .map((item) => Заполнение(
             заполнение: const EdgeInsets.all(Dimens.paddingDefault),
             дочерний элемент: SocialMediaItem (элемент: элемент)))
         .к списку(),
   );
 }
}

При вызове метода сборки провайдер вернет текущий список избранных социальных сетей.

Как создать список с BLoC

В нашем простом приложении у нас пока два экрана. У каждого из них будет свой объект BLoC . Однако имейте в виду, что выбранные элементы на главном экране должны появиться в списке избранных социальных сетей. Поэтому мы должны каким-то образом выносить события выбора флажка за пределы экрана. Решение состоит в том, чтобы создать дополнительный объект BLoC , который будет обрабатывать события, влияющие на состояние многих экранов. Назовем это глобальным BLoC . Затем объекты BLoC , назначенные отдельным экранам, будут прослушивать изменения в глобальных состояниях BLoC и реагировать соответствующим образом.

Прежде чем создавать объект BLoC , вы должны сначала подумать о том, какие события представление сможет отправлять на уровень BLoC и на какие состояния оно будет реагировать. В случае с глобальным BLoC события и состояния будут такими:

 абстрактный класс SocialMediaEvent {}

класс CheckboxChecked расширяет SocialMediaEvent {
 последний логический параметр проверен;
 конечный целочисленный itemId;

 CheckboxChecked(this.isChecked, this.itemId);
}


абстрактный класс SocialMediaState {}

класс ListPresented расширяет SocialMediaState {
 окончательный список List<SocialMedia>;

 СписокПредставлен(этот.список);
}

Событие CheckboxChecked должно быть в глобальном BLoC , потому что оно повлияет на состояние многих экранов, а не только одного. Что касается состояний, у нас есть одно, в котором список готов к отображению. С точки зрения глобального представления BLoC нет необходимости создавать дополнительные состояния. Оба экрана должны отображать список, и об этом должны заботиться отдельные BLoC , предназначенные для конкретного экрана. Сама реализация глобального BLoC будет выглядеть так:

 класс SocialMediaBloc расширяет Bloc<SocialMediaEvent, SocialMediaState> {
 окончательный репозиторий SimpleSocialMediaRepository;

 SocialMediaBloc(этот.репозиторий);

 @переопределить
 SocialMediaState получить InitialState => ListPresented (repository.getSocialMedia);

 @переопределить
 Stream<SocialMediaState> mapEventToState (событие SocialMediaEvent) async* {
   если (событие CheckboxChecked) {
     выход _mapCheckboxCheckedToState (событие);
   }
 }

 SocialMediaState _mapCheckboxCheckedToState (событие CheckboxChecked) {
   окончательный updatedList = (состояние как ListPresented).list;
   обновленный список
       .firstWhere((item) => item.id == event.itemId)
       .setFavourite(event.isChecked);
   вернуть ListPresented (updatedList);
 }
}

Исходное состояние — ListPresented — мы предполагаем, что уже получили данные из репозитория. Нам нужно отреагировать только на одно событие — CheckboxChecked . Таким образом, мы обновим выбранный элемент с помощью метода setFavourite и отправим новый список в состоянии ListPresented .

Теперь нам нужно отправить событие CheckboxChecked при нажатии на флажок. Для этого нам понадобится экземпляр SocialMediaBloc в месте, где мы можем прикрепить обратный вызов onChanged . Мы можем получить этот экземпляр с помощью BlocProvider — он похож на Provider из рассмотренного выше шаблона. Для работы такого BlocProvider выше в дереве виджетов необходимо инициализировать нужный объект BLoC . В нашем примере это будет сделано в основном методе:

 void main() => runApp(BlocProvider(
   создать: (контекст) {
     вернуть SocialMediaBloc(SimpleSocialMediaRepository());
   },
   дочерний элемент: ArchitectureSampleApp()));

Благодаря этому в коде основного списка мы легко можем вызвать BLoC с помощью BlocProvider.of() и отправить ему событие с помощью метода add :

 класс SocialMediaListScreen расширяет StatefulWidget {
 _SocialMediaListState createState() => _SocialMediaListState();
}

класс _SocialMediaListState расширяет State<SocialMediaListScreen> {
 @переопределить
 Сборка виджета (контекст BuildContext) {
   вернуть BlocBuilder<SocialMediaListBloc, SocialMediaListState>(
     строитель: (контекст, состояние) {
       если (состояние MainListLoaded) {
         вернуть ListView(
           дети: state.socialMedia
               .map((item) => CheckboxSocialMediaItem(
                     предмет: предмет,
                     onCheckboxChanged: (isChecked) =>
                         BlocProvider.of<SocialMediaBloc>(контекст)
                             .add(CheckboxChecked(isChecked, item.id)),
                   ))
               .к списку(),
         );
       } еще {
         вернуть Центр (дочерний элемент: Текст (Strings.emptyList));
       }
     },
   );
 }
}

У нас уже есть распространение события CheckboxChecked на BLoC , мы также знаем, как BLoC отреагирует на такое событие. Но на самом деле… что заставит список перестроиться с уже установленным флажком? Глобальный BLoC не поддерживает изменение состояния списка, поскольку оно обрабатывается отдельными объектами BLoC , назначенными экранам. Решение заключается в ранее упомянутом прослушивании глобального BLoC для изменения состояния и реагировании в соответствии с этим состоянием. Ниже BLoC , посвященный основному списку социальных сетей, с флажком:

 класс SocialMediaListBloc
   расширяет Bloc<SocialMediaListEvent, SocialMediaListState> {
 окончательный SocialMediaBloc mainBloc;

 SocialMediaListBloc({@required this.mainBloc}) {
   mainBloc.listen((состояние) {
     если (состояние ListPresented) {
       добавить (ScreenStart (state.list));
     }
   });
 }

 @переопределить
 SocialMediaListState получить InitialState => MainListEmpty();

 @переопределить
 Stream<SocialMediaListState> mapEventToState(
     событие SocialMediaListEvent) async* {
   переключатель (event.runtimeType) {
     случай ScreenStart:
       yield MainListLoaded((событие как ScreenStart).list);
       ломать;
   }
 }
}

Когда SocialMediaBloc возвращает состояние ListPresented , SocialMediaListBloc будет уведомлен. Обратите внимание, что ListPresented передает список. Это тот, который содержит обновленную информацию о проверке элемента флажком.

Точно так же мы можем создать BLoC , посвященный экрану избранных социальных сетей:

 класс FavoritesListBloc расширяет Bloc<FavouritesListEvent, FavouritesListSate> {
 окончательный SocialMediaBloc mainBloc;

 ИзбранноеListBloc({@required this.mainBloc}) {
   mainBloc.listen((состояние) {
     если (состояние ListPresented) {
       добавить (ИзбранноеScreenStart (state.list));
     }
   });
 }

 @переопределить
 FavouritesListSate get initialState => FavouritesListEmpty();

 @переопределить
 Stream<FavouritesListSate> mapEventToState(событие FavouritesListEvent) async* {
   если (событие - ИзбранноеScreenStart) {
     var favouritesList = event.list.where((item) => item.isFavourite).toList();
     выход ИзбранноеСписокЗагружен(избранноеСписок);
   }
 }
}

Изменение состояния в глобальном BLoC приводит к срабатыванию события FavoritesScreenStart с текущим списком. Затем элементы, отмеченные как избранные, фильтруются, и такой список отображается на экране.

Как создать форму с большим количеством полей во Flutter

Длинные формы могут быть непростыми, особенно когда требования предполагают разные варианты проверки или какие-то изменения на экране после ввода текста. На примере экрана у нас есть форма, состоящая из нескольких полей и кнопки «ДАЛЕЕ». Поля будут автоматически проверяться, а кнопка будет отключена до тех пор, пока форма не станет полностью действительной. После нажатия на кнопку откроется новый экран с данными, введенными в форму.

Мы должны проверить каждое поле и проверить всю коррекцию формы, чтобы правильно установить состояние кнопки. Затем собранные данные нужно будет сохранить для следующего экрана.

Создание формы с множеством полей во Flutter

Как создать форму с большим количеством полей с помощью Provider

В нашем приложении нам понадобится второй ChangeNotifier , посвященный экранам личной информации. Поэтому мы можем использовать MultiProvider , где мы предоставляем список объектов ChangeNotifier . Они будут доступны всем потомкам MultiProvider .

 class ArchitecturesSampleApp расширяет StatelessWidget {
 окончательный репозиторий SimpleSocialMediaRepository;

 ArchitecturesSampleApp({Key key, this.repository}): super(key: key);

 @переопределить
 Сборка виджета (контекст BuildContext) {
   вернуть мультипровайдер(
     провайдеры: [
       ChangeNotifierProvider<SocialMediaModel>(
         создать: (контекст) => SocialMediaModel (репозиторий),
       ),
       ChangeNotifierProvider<PersonalDataNotifier>(
         создать: (контекст) => PersonalDataNotifier(),
       )
     ],
     дочерний элемент: MaterialApp(
       название: Strings.architecturesSampleApp,
       debugShowCheckedModeBanner: ложь,
       дома: Стартовый Экран(),
       маршруты: <String, WidgetBuilder>{
         Routes.socialMedia: (контекст) => SocialMediaScreen(),
         Routes.favourites: (контекст) => ИзбранноеЭкран(),
         Routes.personalDataForm: (контекст) => PersonalDataScreen(),
         Routes.personalDataInfo: (контекст) => PersonalDataInfoScreen()
       },
     ),
   );
 }
}

В этом случае PersonalDataNotifier будет выступать в роли слоя бизнес-логики — он будет валидировать поля, иметь доступ к модели данных для ее обновления и обновлять поля, от которых будет зависеть представление.

Сама форма представляет собой очень хороший API от Flutter, где мы можем автоматически прикреплять проверки с помощью валидатора свойств и сохранять данные из формы в модель с помощью обратного вызова onSaved . Мы делегируем правила проверки в PersonalDataNotifier и, когда форма будет корректной, мы передадим ей введенные данные.

Самым важным на этом экране будет прослушивание изменений в каждом поле и включение или отключение кнопки в зависимости от результата проверки. Мы будем использовать обратный вызов onChange из объекта Form . В нем мы сначала проверим статус валидации, а затем передадим его в PersonalDataNotifier .

 Форма(
 ключ: _formKey,
 автопроверка: правда,
 onChanged: () => _onFormChanged(personalDataNotifier),
 ребенок:

void _onFormChanged (PersonalDataNotifier personalDataNotifier) ​​{
 var isValid = _formKey.currentState.validate();
 PersonalDataNotifier.onFormChanged(isValid);
}

В PersonalDataNotifier мы подготовим переменную isFormValid . Мы изменим его (не забудьте вызвать notifyListeners() ) и в представлении изменим состояние кнопки в зависимости от ее значения. Не забудьте получить экземпляр Notifier с параметром listen: true — иначе наше представление не перестроится и состояние кнопки останется неизменным.

 var personalDataNotifier = Provider.of<PersonalDataNotifier>(контекст, прослушивание: true);

На самом деле, учитывая тот факт, что мы используем personalDataNotifier в других местах, где перезагрузка представления не требуется, приведенная выше строка не оптимальна и должна иметь параметр listen , установленный в false . Единственное, что мы хотим перезагрузить, это кнопка, поэтому мы можем обернуть ее в классический Consumer :

 Потребитель<PersonalDataNotifier>(
 построитель: (контекст, уведомитель, дочерний элемент) {
   вернуть RaisedButton(
     ребенок: Текст (Strings.addressNext),
     onPressed: notifier.isFormValid
         ? /* действие при активации кнопки */
         : нулевой,
     цвет: Цвета.синий,
     disabledColor: Colors.grey,
   );
 },
)

Благодаря этому мы не заставляем другие компоненты перезагружаться каждый раз, когда используем уведомитель.

В представлении с отображением личных данных проблем больше не будет — у нас есть доступ к PersonalDataNotifier и оттуда мы можем загрузить обновленную модель.

Как создать форму с большим количеством полей с помощью BLoC

Для предыдущего экрана нам понадобились два объекта BLoC . Поэтому, когда мы добавим еще один «двойной экран», у нас будет всего четыре. Как и в случае с Provider , мы можем справиться с MultiBlocProvider , который работает почти идентично.

 void main() => runApp(
     MultiBlocProvider(поставщики: [
       БлокПровайдер(
         создать: (контекст) => SocialMediaBloc(SimpleSocialMediaRepository()),
       ),
       БлокПровайдер(
           создать: (контекст) => SocialMediaListBloc(
               mainBloc: BlocProvider.of<SocialMediaBloc>(контекст))),
       БлокПровайдер(
         создать: (контекст) => PersonalDataBloc(),
       ),
       БлокПровайдер(
         создать: (контекст) => PersonalDataInfoBloc(
             mainBloc: BlocProvider.of<PersonalDataBloc>(контекст)),
       )
     ], дочерний элемент: ArchitectureSampleApp()),
   );

Как и в шаблоне BLoC , лучше всего начать с возможных состояний и действий.

 абстрактный класс PersonalDataState {}

класс NextButtonDisabled расширяет PersonalDataState {}

класс NextButtonEnabled расширяет PersonalDataState {}

класс InputFormCorrect расширяет PersonalDataState {
 окончательная модель PersonalData;

 InputFormCorrect(эта.модель);
}

На этом экране меняется состояние кнопки. Поэтому нам нужны отдельные состояния для него. Кроме того, состояние InputFormCorrect позволит нам отправлять данные, собранные формой.

 абстрактный класс PersonalDataEvent {}

класс FormInputChanged расширяет PersonalDataEvent {
 окончательный логический допустимый;
 FormInputChanged(this.isValid);
}

класс FormCorrect расширяет PersonalDataEvent {
 окончательные данные формы персональных данных;

 ФормаПравильная(эта.formData);
}

Прослушивание изменений в форме имеет решающее значение, поэтому событие FormInputChanged . Когда форма верна, будет отправлено событие FormCorrect .

Когда дело доходит до проверок, здесь есть большая разница, если сравнивать с Provider. Если бы мы хотели заключить всю логику проверки в слой BLoC , у нас было бы много событий для каждого из полей. Кроме того, во многих состояниях представление должно отображать сообщения проверки.

Это, конечно, возможно, но это будет похоже на борьбу с TextFormField API, а не на использование его преимуществ. Поэтому, если нет четких причин, можно оставить валидации в слое представления.

Состояние кнопки будет зависеть от состояния, отправленного в представление BLoC :

 BlocBuilder<PersonalDataBloc, PersonalDataState>(
   строитель: (контекст, состояние) {
 вернуть RaisedButton(
   ребенок: Текст (Strings.addressNext),
   onPressed: состояние NextButtonEnabled
       ? /* действие при активации кнопки */
       : нулевой,
   цвет: Цвета.синий,
   disabledColor: Colors.grey,
 );
})

Обработка событий и отображение состояний в PersonalDataBloc будут следующими:

 @переопределить
Stream<PersonalDataState> mapEventToState (событие PersonalDataEvent) async* {
 если (событие FormCorrect) {
   выход InputFormCorrect (event.formData);
 } иначе если (событие FormInputChanged) {
   доходность mapFormInputChangedToState (событие);
 }
}

PersonalDataState mapFormInputChangedToState (событие FormInputChanged) {
 если (событие.isValid) {
   вернуть NextButtonEnabled();
 } еще {
   вернуть NextButtonDisabled();
 }
}

Что касается экрана со сводкой личных данных, то здесь ситуация аналогична предыдущему примеру. BLoC, прикрепленный к этому экрану, будет извлекать информацию о модели из BLoC экрана формы.

 класс PersonalDataInfoBloc
   расширяет Bloc<PersonalDataInfoEvent, PersonalDataInfoState> {
 окончательный блок PersonalDataBloc mainBloc;

 PersonalDataInfoBloc({@required this.mainBloc}) {
   mainBloc.listen((состояние) {
     если (состояние — InputFormCorrect) {
       добавить (PersonalDataInfoScreenStart (state.model));
     }
   });
 }

 @переопределить
 PersonalDataInfoState получить InitialState => InfoEmpty();

 @переопределить
 Stream<PersonalDataInfoState> mapEventToState (событие PersonalDataInfoEvent) async* {
   если (событие PersonalDataInfoScreenStart) {
     выход InfoLoaded(event.model);
   }
 }
}

Архитектура Flutter: заметки на память

Приведенных выше примеров достаточно, чтобы показать, что между двумя архитектурами существуют явные различия. BLoC очень хорошо отделяет уровень представления от бизнес-логики. Это влечет за собой лучшее повторное использование и тестируемость. Кажется, что для обработки простых случаев нужно писать больше кода, чем в Provider . Как вы знаете, в этом случае эта архитектура Flutter станет более полезной по мере увеличения сложности приложения.

Значок "Убери еду"

Хотите создать перспективное приложение для своего бизнеса?

Давайте свяжемся

Провайдер также хорошо отделяет UI от логики и не навязывает создание отдельных состояний при каждом взаимодействии с пользователем, а это значит, что зачастую вам не придется писать большой объем кода для обработки простого случая. Но это может вызвать проблемы в более сложных случаях.

Нажмите здесь, чтобы ознакомиться со всем проектом.