Разработка
Большинство приложений для Android нарушают это правило чистого кода!
Чистая архитектура начинается с одного правила: делайте компоненты тупыми.
Многие Android-разработчики неосознанно попадают в одну и ту же архитектурную ловушку — они помещают слишком много логики в классы фреймворков, такие как FirebaseMessagingService, BroadcastReceiver, Activity или Service. Поначалу это кажется быстрым и легким. Но вскоре код становится хрупким, его становится трудно тестировать и почти невозможно поддерживать.
В этой статье мы покажем, как избежать этой ловушки, применяя одно простое правило:
Делайте компоненты Android «тупыми».
Мы будем использовать Firebase Cloud Messaging (FCM) в качестве примера, но этот принцип применим ко всей архитектуре вашего приложения.
Пример FCM Push Notifications
Настройка Firebase Cloud Messaging (FCM) на Android очень проста. Вы настраиваете AndroidManifest.xml, запрашиваете разрешения и переопределяете два ключевых метода:
onNewToken(token: String)
— вызывается при генерации нового токена FCMonMessageReceived(message: RemoteMessage)
— вызывается при получении push-сообщения
Тогда у вас получится что-то вроде:
override fun onMessageReceived(remoteMessage: RemoteMessage) { val title = remoteMessage.data["title"] val body = remoteMessage.data["body"] val deeplink = remoteMessage.data["deeplink"] val intent = Intent(Intent.ACTION_VIEW, Uri.parse(deeplink)) val notification = NotificationCompat.Builder(this, "channel_id") .setContentTitle(title) .setContentText(body) .setContentIntent(PendingIntent.getActivity(this, 0, intent, 0)) .build() NotificationManagerCompat.from(this).notify(1, notification) }
Все работает… пока не перестает. Почему?
- Вы не можете провести модульное тестирование
- Вы не можете повторно использовать логику
- Вам нужны настоящие пуши для тестирования
Давайте наведем порядок.
Лучший способ: делегировать все
1. Сохраняйте FirebaseMessagingService тонким
Ваш сервис не должен содержать логику. Он должен передавать работу инжектируемым, тестируемым компонентам.
@AndroidEntryPoint class MyFirebaseMessagingService : FirebaseMessagingService() { @Inject lateinit var pushHandler: dagger.Lazy<PushHandler> @Inject lateinit var pushMessageMapper: dagger.Lazy<PushMessageMapper> override fun onNewToken(token: String) { //Communicate with your server to update the token } override fun onMessageReceived(message: RemoteMessage) { val mapped = pushMessageMapper.get().map(message) pushHandler.get().handle(mapped) } }
Это дает вам возможность использовать инъекции зависимостей (например, Hilt или Koin) и изолировать логику для повторного использования и тестирования.
2. Сопоставляйте с чистой доменной моделью
Не передавайте по кругу RemoteMessage от Firebase. Вместо этого создайте модель, не зависящую от платформы, которая отражает потребности вашего приложения.
data class PushMessage( val title: String?, val body: String?, val deeplink: String?, val type: String? )
Сопоставьте данные FCM с этим классом:
class PushMessageMapper @Inject constructor() { fun map(message: RemoteMessage): PushMessage { val data = message.data return PushMessage( title = data["title"], body = data["body"], deeplink = data["deeplink"], type = data["type"] ) } }
Абстрагируя RemoteMessage, вы отделяете свою основную логику от Firebase.
3. Внедряйте и тестируйте бизнес-логику
Теперь создайте PushHandler, который принимает вашу доменную модель и решает, что делать.
interface PushHandler { fun handle(message: PushMessage) } class DefaultPushHandler @Inject constructor( private val notificationFactory: NotificationFactory, private val deeplinkParser: DeeplinkParser, @ApplicationContext private val context: Context ) : PushHandler { override fun handle(message: PushMessage) { val notification = notificationFactory.createNotification(context, message) if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { NotificationManagerCompat.from(context).notify(1001, notification) } } }
Теперь ваша логика проверяема, расширяема и проста в обслуживании.
Тот же шаблон для BroadcastReceivers
Эта ошибка не ограничивается push-уведомлениями. BroadcastReceiver — еще один классический нарушитель.
Плохой паттерн — логика внутри приемника:
class BootCompletedReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent?) { val workManager = WorkManager.getInstance(context) workManager.enqueue(...) // logic here } }
Лучший паттерн — делегирование логики:
class BootCompletedReceiver : BroadcastReceiver() { private val rescheduler: TaskRescheduler by inject() override fun onReceive(context: Context, intent: Intent?) { if (intent?.action == Intent.ACTION_BOOT_COMPLETED) { rescheduler.reschedule() } } } interface TaskRescheduler { fun reschedule() } class DefaultTaskRescheduler @Inject constructor( private val workManager: WorkManager ) : TaskRescheduler { override fun reschedule() { workManager.enqueue(...) // clean and testable } }
Это позволяет тестировать измененную логику с помощью модульных тестов — нет необходимости симулировать трансляции.
Юнит-тестирование с помощью Robolectric
Вот здесь все становится еще веселей. Поскольку ваша основная логика больше не привязана к компонентам Android, тестирование становится простым:
@RunWith(RobolectricTestRunner::class) class DefaultPushHandlerTest { private lateinit var context: Context private val posted = mutableListOf<Notification>() @Before fun setup() { context = ApplicationProvider.getApplicationContext() } @Test fun `push handler posts notification`() { val handler = DefaultPushHandler( FakeNotificationFactory(context, posted), FakeDeeplinkParser(), context ) handler.handle(PushMessage("Hello", "World", null, null)) assertEquals("Hello", posted.first().extras.getString(Notification.EXTRA_TITLE)) } }
Вам не нужно устройство или эмулятор — только Kotlin и Robolectric.
Сквозная верификация с помощью UIAutomator
С помощью UIAutomator вы также можете протестировать реальное поведение системы:
@RunWith(AndroidJUnit4::class) class NotificationUITest { @Test fun notificationIsDisplayed() { val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) val context = ApplicationProvider.getApplicationContext<Context>() val handler = DefaultPushHandler( FakeNotificationFactoryThatShowsRealNotif(context), FakeDeeplinkParser(), context ) handler.handle(PushMessage("Test Title", "Test Body", null, null)) device.openNotification() device.wait(Until.hasObject(By.textContains("Test Title")), 5000) val notif = device.findObject(UiSelector().textContains("Test Title")) assertTrue("Notification not found", notif.exists()) } }
class FakeNotificationFactoryThatShowsRealNotif( private val context: Context ) : NotificationFactory { init { createNotificationChannel() } override fun createNotification(context: Context, message: PushMessage): Notification { return NotificationCompat.Builder(context, TEST_CHANNEL_ID) .setContentTitle(message.title ?: "Test Title") .setContentText(message.body ?: "Test Body") .setSmallIcon(android.R.drawable.ic_dialog_info) .setPriority(NotificationCompat.PRIORITY_HIGH) .build().also { NotificationManagerCompat.from(context).notify(TEST_NOTIFICATION_ID, it) } } private fun createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val name = "Test Channel" val importance = NotificationManager.IMPORTANCE_HIGH val channel = NotificationChannel(TEST_CHANNEL_ID, name, importance) NotificationManagerCompat.from(context).createNotificationChannel(channel) } } companion object { private const val TEST_CHANNEL_ID = "test_channel" private const val TEST_NOTIFICATION_ID = 42 } }
Идеально подходит для проверки поведения на реальных устройствах в CI или предрелизных тестах.
Заключительный совет
Держите свои компоненты Android тупыми, а логику чистой.
- FirebaseMessagingService не должен содержать код уведомлений
- BroadcastReceiver не должен заниматься заданиями
- Активити не должна содержать бизнес-логику
Используйте @Inject
, @Bind
и interface
, чтобы сделать каждую часть вашей системы тестируемой и модульной.
TL;DR
- Компоненты Android должны делегировать, а не делать
- Переносите логику в инжектируемые классы
- Создавайте доменные модели, чтобы отвязаться от SDK
- Юнит-тестируйте с помощью фейков, проверяйте UI с помощью UIAutomator
Чистая архитектура начинается с одного правила: делайте компоненты тупыми.
-
Новости3 недели назад
Видео и подкасты о мобильной разработке 2025.22
-
Новости2 недели назад
Видео и подкасты о мобильной разработке 2025.24
-
Вовлечение пользователей4 недели назад
Небольшое изменение в интерфейсе Duolingo, которое меняет все
-
Маркетинг и монетизация4 недели назад
Институциональные покупки: понимание и обнаружение