Connect with us

Разработка

Большинство приложений для Android нарушают это правило чистого кода!

Чистая архитектура начинается с одного правила: делайте компоненты тупыми.

Опубликовано

/

     
     

Многие Android-разработчики неосознанно попадают в одну и ту же архитектурную ловушку — они помещают слишком много логики в классы фреймворков, такие как FirebaseMessagingService, BroadcastReceiver, Activity или Service. Поначалу это кажется быстрым и легким. Но вскоре код становится хрупким, его становится трудно тестировать и почти невозможно поддерживать.

В этой статье мы покажем, как избежать этой ловушки, применяя одно простое правило:

Делайте компоненты Android «тупыми».

Мы будем использовать Firebase Cloud Messaging (FCM) в качестве примера, но этот принцип применим ко всей архитектуре вашего приложения.

Пример FCM Push Notifications

Настройка Firebase Cloud Messaging (FCM) на Android очень проста. Вы настраиваете AndroidManifest.xml, запрашиваете разрешения и переопределяете два ключевых метода:

Большинство приложений для Android нарушают это правило чистого кода!

  • onNewToken(token: String) — вызывается при генерации нового токена FCM
  • onMessageReceived(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

Чистая архитектура начинается с одного правила: делайте компоненты тупыми.

Источник

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: