«Open Tracker: как разработать Android-приложение для автоматического трекинга коммерческих представителей. Часть 1»

от автора

«Нельзя управлять тем, что нельзя измерить.»— Питер Друкер

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

Работа коммерческого представителя обычно связана с ежедневным посещением клиентов по заранее спланированному маршруту. Для организации, в которой работают такие сотрудники, важно контролировать маршруты, анализировать посещения клиентов и вести отчётность. Одним из решений является разработка специализированного приложения, которое работает на мобильных устройствах представителей и осуществляет фоновый трекинг перемещений с последующей передачей данных на сервер. В текущей реализации серверная часть и API отсутствуют (используется заглушка для последующей доработки).

Архитектура и основные компоненты приложения

Приложение Open Tracker спроектировано для полностью автоматической работы после первоначальной установки и настройки, что минимизирует необходимость взаимодействия с пользователем. Центральным компонентом архитектуры является Service, обеспечивающий долговременную работу в фоне.

На структурной схеме представлены основные компоненты системы и их взаимодействие:

Структурная схема приложения

Структурная схема приложения

Ключевые компоненты:

  1. Service — реализует основную логику приложения и обеспечивает фоновую работу

  2. TimeManager — определяет рабочее время и дни, активируя трекинг только в рабочие периоды для экономии заряда батареи (с возможностью включения постоянного трекинга для тестирования)

  3. LogManager — объединяет логику сбора, хранения и отправки геоданных

  4. LocationManagerNetSenderFileSaver — отвечают соответственно за сбор координат, их отправку и локальное хранение

  5. BroadcastReceiver — обеспечивает автоматический запуск после перезагрузки устройства и обработку изменений системного времени

  6. Activity — реализует пользовательский интерфейс на основе Compose с архитектурой MVVM и навигацией через Compose Navigation

Реализация ключевых функций

Организация фоновой работы

Для долговременной работы в фоне используется ForegroundService, который отображает уведомление, позволяющее пользователю контролировать состояние трекинга. После окончания рабочего времени Service прекращает показ уведомления и может быть уничтожен системой.

Особенностью реализации является комбинированный подход: Service работает как в режиме Started (автономно), так и Bound (с возможностью связи с UI).

    private fun startForeground() {         val notification = NotificationUtils.getForegroundNotification(this)         val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {             ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION         } else {             0         }         ServiceCompat.startForeground(             this,             NotificationConstants.FOREGROUND_NOTIFICATION_ID,             notification,             type         )     }      private fun stopForeground() {         stopForeground(STOP_FOREGROUND_REMOVE)     }

Периодический запуск трекинга

Для обеспечения периодического выполнения трекинга используется AlarmManager внутри Service. Функция restartTimer принимает два параметра:

  • Время следующего запуска (в миллисекундах)

  • Интервал повторения (в миллисекундах)

Для стабильной работы в фоне используется режим ELAPSED_REALTIME_WAKEUP, который гарантирует пробуждение устройства через заданные интервалы времени. Важное требование — отключение режима оптимизации энергосбережения для приложения.

    private fun restartTimer(         futureTriggerTime: Long,         interval: Long = TRACKER_LOCATION_POINT_INTERVAL     ) {         val intent = Intent(this, TrackerReceiver::class.java).apply {             action = TRACKER_TIMER_ACTION         }          val tTime = SystemClock.elapsedRealtime() + futureTriggerTime - System.currentTimeMillis()         val pIntent = PendingIntent.getBroadcast(             this, TRACK_TIMER_CODE, intent,             PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE         )          (getSystemService(ALARM_SERVICE) as AlarmManager).setRepeating(             AlarmManager.ELAPSED_REALTIME_WAKEUP, tTime, interval, pIntent         )     }

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

    private val lock: WakeLock by lazy {         (getSystemService(POWER_SERVICE) as PowerManager).run {             newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_NAME).apply {                 setReferenceCounted(true)             }         }     }

Асинхронная обработка

Все ресурсоемкие операции в Service выполняются с использованием Kotlin Coroutines. Для перехода между обычным и suspend-кодом используется специально настроенный SharedFlow (commands), а для запуска корутин — CoroutineScope (serviceScope).

class TrackerService : Service() {     private val serviceScope = CoroutineScope(Dispatchers.Main.immediate)     private val _trackerHistory = MutableStateFlow(emptyList<PositionData>())     private val _trackerState = MutableStateFlow(TrackerState())     private val commands = MutableSharedFlow<String>(         extraBufferCapacity = 5,         onBufferOverflow = BufferOverflow.DROP_LATEST     )

Конфигурация SharedFlow обеспечивает контроль над частотой выполнения фоновых задач, предотвращая перегрузку системы. Команды (Action) поступают из Intent, полученного в методе onStartCommandи далее обрабатываются в handleAction.

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {         acquireWakeLock(WAKE_LOCK_TIMEOUT)         handleAction(intent?.action ?: "")         return START_NOT_STICKY     }
    private fun handleAction(action: String) = when (action) {         Intent.ACTION_BOOT_COMPLETED,         Intent.ACTION_MY_PACKAGE_REPLACED,         Intent.ACTION_DATE_CHANGED,         Intent.ACTION_TIME_CHANGED -> handleSystemAction(action)          TRACKER_CLIENT_BIND -> handleClientBind()         TRACKER_TIMER_ACTION -> handleTimerAction()         else -> Unit     }

В методе handleTimerAction происходит отправка команды через commands на запуск фоновой задачи

commands.tryEmit("start")

и далее выполнение в методе collect.

        serviceScope.launch(handler) {             commands.collect { startLogging(logManager) }         }

Сбор и обработка геоданных

Логика работы с геоданными вынесена в отдельный класс LocationManager что позволяет модифицировать её без изменения базовой логики Service. Для сбора координат используется FusedLocationProvider как наиболее энергоэффективное решение.

Основной метод getPoints является suspend-функцией и поддерживает отмену операций, возвращая список собранных геоданных.

    override suspend fun getPoints() = suspendCancellableCoroutine { continuation ->         start { positions ->             if (continuation.isActive) {                 continuation.resume(positions)             }         }         continuation.invokeOnCancellation {             stop()         }     }

Процесс сбора данных начинается с вызова метода start, который:

  1. Запускает подписку на обновления местоположения

  2. Ограничивает время сбора константой COLLECT_TIMEOUT (1 минута)

  3. Возвращает накопленные данные по истечении времени или при достижении лимита точек (POSITIONS_LIMIT)

    private fun start(callBack: (List<PositionData>) -> Unit) {         resultCallback = callBack         positionList.clear()          val permResult = runCatching {             val builder = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000).apply {                 setMinUpdateDistanceMeters(0f)                 setWaitForAccurateLocation(true)             }             fusedManager.requestLocationUpdates(builder.build(), locListener, Looper.getMainLooper())         }          if (permResult.isSuccess) {             handler.postDelayed(handleStop, COLLECT_TIMEOUT)         } else {             stop()             sendResult()         }     }

Для обеспечения качества данных реализована фильтрация:

  • Отсеивание подложных координат

  • Игнорирование устаревших данных

  • Возможность использования последних известных координат при отсутствии новых

  • Резервный вариант сбора данных GSM вышек (не реализован, но предусмотрена архитектура)

    private fun handleLocationPoint(it: Location) {         if (isLocationTooOld(it) || isFake(it)) return         positionList.add(PositionGpsData(gpsLocation = it))         if (positionList.size > POSITIONS_LIMIT) stopWork()     }

Сохранение геоданных: надежность и аналитика

Реализация логики сохранения геоданных в классе FileSaver обеспечивает гибкость и возможность адаптации под специфические требования проекта.

Ключевые особенности:

  1. Формат данных

    • Основной метод save является suspend-функцией, принимающей:

      • log — список координат и системных событий (например, изменение системного времени, ошибки GPS и тд.).

      • dirPath — путь для сохранения файлов.

      • makeNow  — признак формирования пакета для отправки.

    • Сохранение не только координат, но и системных событий позволяет:

      • Анализировать работу приложения в фоне.

      • Выявлять потенциальные проблемы (например, отключение GPS пользователем).

  2. Механизм записи

    • Данные сохраняются в файлы с контролируемым жизненным циклом:

      1. Сначала создается временный файл с расширением .wrk.

      2. После интервала TIME_INTERVAL_TO_CHANGE_FILE файл переименовывается в .txt — это гарантирует целостность данных при передаче на сервер.

    • Возвращаемое значение — список готовых к отправке файлов (пакетов).

    override suspend fun save(dirPath: String, log: List<String>, makeNow: Boolean): Result<List<String>> {         return runCatching {             if (currentLogFile == null) {                 currentLogFile = makeNewLogFile(dirPath)             }             saveToFile(log)              if (makeNow || System.currentTimeMillis() > nextRenameTime || abs(System.currentTimeMillis() - nextRenameTime) > TIME_INTERVAL_TO_CHANGE_FILE) {                 renameLogFile(dirPath)                 nextRenameTime = System.currentTimeMillis() + TIME_INTERVAL_TO_CHANGE_FILE             }             currentLogFile = null              // Возвращаем список файлов с расширением .txt в директории             File(dirPath).listFiles()?.filter { it.name.endsWith(DATA_FILE_EXT_TXT) }?.map { it.absolutePath } ?: emptyList()         }     }

Отправка пакетов на сервер: архитектура и заглушка

Логика передачи данных на сервер реализована в классе NetSender. В текущей версии проекта API сервера не определён, поэтому класс имитирует отправку пакетов — после обработки файлы удаляются, что позволяет тестировать основной функционал приложения без реального бэкенда.

Ключевые особенности реализации

  1. Гибкость интеграции

    • Класс NetSender спроектирован так, чтобы в будущем его можно было адаптировать под любой протокол (REST, WebSocket, gRPC) или формат данных (JSON, Protobuf).

    • В текущей реализации метод send принимает список файлов и «успешно отправляет» их, после чего удаляет с устройства.

  2. Заглушка вместо реального API

    • Такой подход позволяет:

      • Проверять работу цепочки LoсationManager → FileSaver → NetSender.

      • Тестировать обработку ошибок (например, отсутствие интернета) в будущем.

    Заключение по первой части

    В этой части статьи рассмотрено ядро приложения Service — от сбора геоданных до их сохранения и отправки на сервер. Ключевые особенности решения:

    • Энергоэффективность: Фоновый Service, AlarmManager и оптимизация GPS-запросов.

    • Гибкость: Модульная архитектура (LoсationManager, FileSaver, NetSender) позволяет адаптировать логику под разные бизнес-задачи.

    • Масштабируемость: Заглушки вместо реального API упрощают тестирование и будущую интеграцию с бэкендом.

    Исходный код открыт для доработки

В части 2 статьи будет рассмотрена реализация пользовательского интерфейса приложения.


ссылка на оригинал статьи https://habr.com/ru/articles/929436/