В этой статье я бы хотел поговорить об одном из классических шаблонов проектирования в Android-разработке: фабричном методе (Fabric method). Изучать его мы будем на примере работы с Firebase Cloud Messaging (далее FCM). Цель — донести до начинающих разработчиков, пока не овладевших в полной мере всеми достоинствами ООП, важность применения приёмов объектно-ориентированного проектирования, как мы это сделали в Live Typing
Почему на примере FCM?
FCM — один из готовых сервисов передачи сообщений (так называемых пушей), работающий по модели «издатель-подписчик». Если вы в очередной раз получаете нотификацию на свой девайс о новом сообщении (новости/скидке/новом товаре и многом другом), то велика вероятность, что эта функциональность реализована посредством FCM или его аналога. На данный момент FCM позиционируется Google как эталонное решение. Поэтому и статья написана из расчёта, что читатель либо уже знаком с этим сервисом, либо ему, скорее всего, выпадет возможность с ним познакомиться.
А почему именно про push-сообщения?
Написать обработку push-сообщений в приложении, применяя фабричный метод — отличный повод раз и навсегда разобраться с этим шаблоном. Проектируя UI-объекты или объекты бизнес-логики, новичку простительно сделать ошибку: не предусмотреть расширение количества объектов и/или не заложить возможность легко изменять логику работы каждого из них. Но, как показывает опыт, обработка пушей зачастую усложняется и расширяется в течение всего периода разработки проекта. Представьте, если бы вам пришлось писать приложение ВКонтакте. Пользователь получает кучу различных нотификаций по пушу, которые выглядят по-разному и по нажатию открывают разные экраны. И если модуль, отвечающий за обработку пуш-уведомлений, изначально был спроектирован неправильно, то каждый новый пуш — это привет, новые if else, привет, регрессионное тестирование и вам, новые баги, тоже привет.
Пример проекта с использованием FCM
Идея
Представим стартап, идея которого — наладить коммуникацию между воспитателями детского сада и родителями детей, которые этот детский сад посещают. Единое приложение и для воспитателей, и для родителей не подходит, потому что через учительское приложение контент создаётся, а через родительское — потребляется. Учтём и третий вид пользователей сервиса — администрацию детского сада. Им мобильное приложение не нужно в силу того, что основная часть их рабочего дня проходит за рабочим столом, но им нужен удобный способ оповещать родителей и воспитателей о важных новостях.
Итого:
- мобильное приложение для родителей (далее «родительское»);
- мобильное приложение для воспитателей (далее «учительское»);
- web приложение для администрации детского сада (далее «админка»);
Структура Android-проекта
Проект в Android Studio будет иметь следующую структуру
Модуль core — общий для двух приложений. Он содержит модули parent и teacher — модули родительского и учительского приложений соответственно.
Задачи на этапе подключения push-уведомлений — показать нотификацию пользователю при изменении данных на сервере вне зависимости от того, открыто приложение у пользователя или нет.
Для учительского и родительского приложений приходят различные виды пушей. Поэтому у нотификаций могут быть разные иконки, а по нажатию на нотификацию открываются разные экраны.
Примеры нотификаций
Для учительского приложения:
- в группу добавили нового ученика (после добавления ребёнка в админке);
- день рождения одного из детей в группе.
Для родительского приложения:
- ребенок получил оценку (после добавления оценки в учительском приложении);
- не забыть забрать ребенка в сокращённый день (push инициируется из админки);
Обработка пушей
После всех подготовительных работ по подключению и настройке работы FCM в Android-проекте обработка push-уведомлений сводится к реализации одного класса, наследника FirebaseMessagingService.
public class MyFirebaseMessagingService extends FirebaseMessagingService {
@Override
public void onMessageReceived(RemoteMessage remoteMessage) {
super.onMessageReceived(remoteMessage);
}
}
О подключении и настройке FCM хорошо написано здесь:
> Официальная документация по подключению FCM в Android проект
> Репозиторий с примером на аккаунте Firebase
Метод onMessageReceived() принимает объект класса RemoteMessage, который и содержит все, что отправил сервер: текст и заголовок сообщения, Map кастомных данных, время отправки push-уведомления и другие данные.
Есть одно важное условие. Если push был отправлен с текстом и заголовком для нотификации, то в момент, когда приложение свернуто или не запущено, метод onMessageReceived() не сработает. В таком случае библиотека firebase-messaging сама сконфигурирует нотификацию из полученных в push`е параметров и покажет её в статусбаре. На этот процесс разработчик не может повлиять. Но если передавать все необходимые данные (в том числе текст и заголовок для нотификации) через объект data, то все сообщения будут обрабатываться классом MyFirebaseMessagingService. Примеры кода ниже подразумевают именно такое использование FCM. Вся информация о событии передается в объекте data.
> Подробнее о видах сообщений Firebase
Проблема
Итак, если бы мы ничего не знали про паттерны проектирования, то реализация поставленной задачи для учительского приложения выглядела бы примерно следующим образом:
public class TeacherFirebaseMessagingService extends FirebaseMessagingService {
private static final String KEY_PUSH_TYPE = "type";
private static final String KEY_PUSH_TITLE = "title";
private static final String KEY_PUSH_CONTENT = "content";
private static final String TYPE_NEW_CHILD = "add_child";
private static final String TYPE_BIRTHDAY = "birthday";
private static final String EMPTY_STRING = "";
private static final int DEFAULT_NOTIFICATION_ID = 15;
@Override
public void onMessageReceived(RemoteMessage remoteMessage) {
super.onMessageReceived(remoteMessage);
Map<String, String> data = remoteMessage.getData();
if (data.containsKey(KEY_PUSH_TYPE)) {
NotificationCompat.Builder notificationBuilder;
String notificationTitle = null;
if (data.containsKey(KEY_PUSH_TITLE)) {
notificationTitle = data.get(KEY_PUSH_TITLE);
}
String notificationContent = null;
if (data.containsKey(KEY_PUSH_CONTENT)) {
notificationContent = data.get(KEY_PUSH_CONTENT);
}
NotificationCompat.Builder builder = new NotificationCompat.Builder(this);
String pushType = data.get(KEY_PUSH_TYPE);
if (pushType.equals(TYPE_NEW_CHILD)) {
builder.setSmallIcon(R.drawable.ic_add_child)
.setContentTitle(notificationTitle != null ? notificationTitle : EMPTY_STRING)
.setContentText(notificationContent != null ? notificationContent : EMPTY_STRING);
} else if (pushType.equals(TYPE_BIRTHDAY)) {
// notificationBuilder = ....
// тут будут задаваться параметры нотификации о дне рождения
}
NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.notify(DEFAULT_NOTIFICATION_ID, builder.build());
}
}
Ключи для получения title и content из данных remoteMessage:
private static final String KEY_TITLE = "title";
private static final String KEY_CONTENT = "content";
Типы пушей для учительского приложения:
private static final String TYPE_NEW_CHILD = "add_child";
private static final String TYPE_BIRTHDAY = "birthday";
Конечно, для каждого типа нотификации можно сделать свой private метод который бы возвращал объект класса NotificationCompat.Builder. Но если вспомнить о приложении VK с его большим количеством разных нотификаций и разнообразии действий при нажатии на них, то становятся очевидны огрехи в таком дизайне класса:
- В примере нет работы с PendingIntent для реализации открытия разных экранов приложения по нажатию на нотификацию. Добавление этого функционала ведет к увеличению кода в классе.
- С каждым пушом отдельного типа приходит дополнительная информация, присущая только ему. Ее тоже необходимо извлечь из data, обработать и передать в приложение при переходе по нажатию. Этот код будет для каждого типа нотификации разным, что тоже значительно увеличит объём нашего класса.
При подобном подходе объект класса TeacherFirebaseMessagingReceiver за считанные часы разработки становится огромным неповоротным god object`ом. И поддержка его кода рискует превратиться в сгусток боли ещё до первого релиза приложения. И самое интересное, что что-то подобное придется наворотить в родительском приложении.
Решение
Теперь о реализации этого функционала более элегантным способом с помощью паттерна «Фабричный метод».
Базовый класс, который находится в модуле core:
public class CoreFirebaseMessagingService extends FirebaseMessagingService {
@Override
public void onMessageReceived(RemoteMessage remoteMessage) {
super.onMessageReceived(remoteMessage);
}
}
Субклассы CoreFirebaseMessagingService будут зарегистрированы в манифестах двух модулей приложений.
Теперь спроектируем объект CoreNotification. Он будет содержать реализацию внешнего вида нотификации в статус баре в зависимости от того, какой тип пуша пришел.
public abstract class CoreNotification {
public static final String KEY_FROM_PUSH = "CoreNotification.FromNotification";
private static final String KEY_TITLE = "title";
private static final String KEY_CONTENT = "body";
protected static final String STRING_EMPTY = "";
protected RemoteMessage remoteMessage;
public CoreNotification(RemoteMessage remoteMessage) {
this.remoteMessage = remoteMessage;
}
protected String getTitleFromMessage() {
Map<String, String> data = remoteMessage.getData();
if (data.containsKey(KEY_TITLE)) {
return data.get(KEY_TITLE);
} else {
return STRING_EMPTY;
}
}
protected String getContentFromMessage() {
Map<String, String> data = remoteMessage.getData();
if (data.containsKey(KEY_CONTENT)) {
return data.get(KEY_CONTENT);
} else {
return STRING_EMPTY;
}
}
public String getTitle() {
return getTitleFromMessage();
}
public String getContent() {
return getContentFromMessage();
}
protected abstract PendingIntent configurePendingIntent(Context context);
protected abstract @DrawableRes int largeIcon();
protected abstract String getNotificationTag();
}
Объект принимает в конструктор и хранит в себе полученный RemoteMessage.
Все абстрактные методы будут переопределены для конкретных нотификаций. STRING_EMPTY может понадобиться в реализациях, поэтому делаем его protected.
Если следовать вышеупомянутой книге «Приёмы объектно-ориентированного проектирования. Паттерны проектирования» или очень, на мой взгляд, доступной для понимания книге для Java-разработчиков Паттерны проектирования, то CoreNotification должен быть интерфейсом, а не классом. Такой подход более гибок. Но тогда бы нам пришлось писать код для получения title и content для каждой нотификации во всех реализациях этого интерфейса. Поэтому было принято решение избежать дублирования кода через абстрактный класс, который содержит методы getTitleFromMessage() и getContentFromMessage(). Ведь эти значения для каждого пуша извлекаются одинаково (поля title и content в RemoteMessage.getData() будет присутствовать всегда, так реализован бэкенд). На всякий случай эти методы оставили protected, если title и content для какой-нибудь нотификации необходимо будет получать другим способом.
Далее проектируем абстрактный класс CoreNotificationCreator. Объект этого класса будет создавать и отображать нотификации в статусбаре. Он и будет работать с наследниками класса CoreNotification.
public abstract class CoreNotificationCreator {
private static final String KEY_NOTIFICATION_TAG = "CoreNotificationCreator.TagKey";
private static final String DEFAULT_TAG = "CoreNotificationCreator.DefaultTag";
private static final String KEY_TYPE = "type";
private NotificationManager notificationManager;
public CoreNotificationCreator(Context context) {
notificationManager = ((NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE));
}
public void showNotification(Context context, RemoteMessage remoteMessage) {
String notificationType = getNotificationType(remoteMessage);
CoreNotification notification = factoryMethod(notificationType, remoteMessage);
if (notification != null) {
NotificationCompat.Builder builder = builderFromPushNotification(context, notification);
notify(builder);
}
}
private String getNotificationType(RemoteMessage remoteMessage) {
Map<String, String> data = remoteMessage.getData();
if (data.containsKey(KEY_TYPE)) {
return data.get(KEY_TYPE);
}
return "";
}
@Nullable
protected abstract CoreNotification factoryMethod(String messageType, RemoteMessage remoteMessage);
private final static int DEFAULT_NOTIFICATION_ID = 15;
private static final
@DrawableRes
int SMALL_ICON_RES_ID = R.drawable.ic_notification_small;
protected NotificationCompat.Builder builderFromPushNotification(Context context, CoreNotification notification) {
Bitmap largeIcon = BitmapFactory.decodeResource(context.getResources(), notification.largeIcon());
NotificationCompat.Builder builder = new NotificationCompat.Builder(context)
.setSmallIcon(SMALL_ICON_RES_ID)
.setAutoCancel(true)
.setDefaults(NotificationCompat.DEFAULT_ALL)
.setContentTitle(notification.getTitle())
.setContentText(notification.getContent())
.setLargeIcon(largeIcon);
builder.getExtras().putString(KEY_NOTIFICATION_TAG, notification.getNotificationTag());
builder.setContentIntent(notification.configurePendingIntent(context));
return builder;
}
private void notify(@NonNull NotificationCompat.Builder builder) {
final String notificationTag = getNotificationTag(builder);
notificationManager.cancel(notificationTag, DEFAULT_NOTIFICATION_ID);
notificationManager.notify(notificationTag, DEFAULT_NOTIFICATION_ID, builder.build());
}
private String getNotificationTag(NotificationCompat.Builder builder) {
Bundle extras = builder.getExtras();
if (extras.containsKey(KEY_NOTIFICATION_TAG)) {
return extras.getString(KEY_NOTIFICATION_TAG);
} else {
return DEFAULT_TAG;
}
}
}
Метод showNotification() — единственный public метод. Его и будем вызывать при получении новых пушей для отображения нотификаций. Все остальные методы — внутренняя реализация создания и отображения нотификации.
Создание нотификаций в Android
public void showNotification(Context context, RemoteMessage remoteMessage) {
String notificationType = getNotificationType(remoteMessage);
CoreNotification notification = factoryMethod(notificationType, remoteMessage);
if (notification != null) {
NotificationCompat.Builder builder = builderFromPushNotification(context, notification);
notify(builder);
}
}
В свою очередь в showNotification() определяется тип пуша, который содержится в данных remoteMessage. И далее тип пуша и объект remoteMessage передаётся фабричному методу, который создаст для нас нужный объект класса CoreNotification.
factoryMethod() @Nullable потому, что может прийти тип пуша, о котором приложение ничего не знает. В теории. Страховка.
Итак, реализация одного класса для двух приложений, который работает с пушами, готова. Дело остается за малым: реализовать для обоих приложений свой конкретный NotificationCreator.
Пример из учительского приложения
public class TeacherNotificationCreator extends CoreNotificationCreator {
public TeacherNotificationCreator(Context context) {
super(context);
}
@Nullable
@Override
protected CoreNotification factoryMethod(String messageType, RemoteMessage remoteMessage) {
switch (messageType) {
case NewChildNotification.TYPE:
return new NewChildNotification(remoteMessage);
case BirthdayNotification.TYPE:
return new BirthdayNotification(remoteMessage);
}
return null;
}
}
В фабричном методе по переменной messageType определяем, какой именно субкласс CoreNotification будет возвращён.
Например, для учительского приложения один из видов нотификаций мог бы быть реализован так:
class NewChildNotification extends CoreNotification {
static final String TYPE = "add_child";
private static final String KEY_CHILD_NAME = "child_name";
NewChildNotification(RemoteMessage remoteMessage) {
super(remoteMessage);
}
@Override
protected PendingIntent configurePendingIntent(Context context) {
Intent intent = new Intent(context, MainActivity.class)
.setPackage(context.getApplicationContext().getPackageName())
.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
intent.putExtra(CoreNotification.KEY_FROM_PUSH, getAddChildInfo());
return PendingIntent.getActivity(context, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT);
}
@Override
protected int largeIcon() {
return R.drawable.ic_add_child;
}
@Override
protected String getNotificationTag() {
return getClass().getName() + getChildName();
}
private String getChildName() {
Map<String, String> data = remoteMessage.getData();
if (data.containsKey(KEY_CHILD_NAME)) {
return data.get(KEY_CHILD_NAME);
}
return STRING_EMPTY;
}
private String getAddChildInfo() {
return "New child " + getChildName() + " was added to your group";
}
}
Метод configurePendingIntent() выносится в реализацию конкретной нотификации для того, чтобы оставалась возможность открывать разные экраны с параметрами конкретного push-сообщения
Абсолютно аналогичный подход в родительском приложении:
public class ParentNotificationCreator extends CoreNotificationCreator {
public ParentNotificationCreator(Context context) {
super(context);
}
@Nullable
@Override
protected CoreNotification factoryMethod(String messageType, RemoteMessage remoteMessage) {
switch (messageType) {
case PickUpNotification.TYPE:
return new PickUpNotification(remoteMessage);
case GradeNotification.TYPE:
return new GradeNotification(remoteMessage);
default:
return null;
}
}
}
Аналогичным учительскому приложению создаются уникальные нотификации для родительского приложения со своей реализацией и своим уникальным типом.
В этом репозитории вы найдёте исходный код проекта. Если возникнет желание его собрать, то необходимо будет создать свой проект Firebase. Процесс несложный и бесплатный, а Android Studio во многом его упрощает: Tools > Firebase > Cloud Messaging — удобная генерация необходимых зависимостей в Gradle-скриптах и настройка проекта firebase из студии. И ещё раз официальная пошаговая инструкция для добавления FCM в Android-проект
Что мы в итоге получили
При добавлении новых пуш уведомлений в конкретное приложение меняется реализация наследника абстрактного CoreNotificationCreator (в две строки) + создаётся новый класс, реализующий абстрактный CoreNotification. При этом логика формирования и отображения существующих нотификаций не меняется. Вероятность реализовать новый субкласс CoreNotification так, что он как-то повлияет на работу остальной рабочей функциональности, стремится к нулю. И каждый субкласс CoreNotification самостоятельно решает:
- какую largeIcon использовать для нотификации;
- каким Intent`ом какое activity открыть;
- за счёт переопределения getNotificationTag() можно решить, будут ли нотификации подменять друг друга или будут создаваться на каждую нотификацию этого класса отдельно.
И самое ценное, на мой взгляд, то, что если заказчик захочет разработать новое приложение для, например, администратора детского сада, то реализация пушей для него никак не затронет работу всей системы уведомлений о push-сообщениях и сводится к наследованию и переопределению по примеру нескольких классов.
Еще раз о литературе
Более детальное понимание паттерна "Фабричный метод" и других классических паттернов проектирования даст вам литература, ставшая "настольной" для отдела Android-разработки в нашей компании
- Очень доступная для понимания книга "Паттерны проектирования". Знакомство с темой классических приемов в ООП и с фабричным методом в частности. Советую начать с неё, особенно Android-разработчикам, потому-что все примеры на Java
- "Приемы объектно-ориентированного проектирования. Паттерны проектирования". Классический труд. Нет воды, только
хардкортрезвое лаконичное описание паттерна и пример реализации на C++.
Постскриптум
Писать в конце статьи о полезности классических паттернов проектирования в работе программиста было бы крайне банально. Я бы даже сказал, пошло. Наверняка на хабре эту полезность доказали уже десятки раз. А вот пример применения в разработке под конкретную платформу, надеюсь, будет полезен Android-джуниорам в этом нелегком, но увлекательном деле.
Выбор конкретного шаблона проектирования для реализации конкретной задачи — тема для жаркой дискуссии. Поэтому добро пожаловать в комментарии. Всем пока!