Всем привет! Меня зовут Антон, я — ведущий мобильный разработчик в компании DD Planet. В статье я поделюсь опытом нашей команды по организации стабильной фоновой работы в мобильном медтех-приложении, предназначенном для взаимодействия с медицинским оборудованием.
Введение
К нам в компанию поступил запрос на разработку мобильного приложения для медицинского устройства, которое в режиме реального времени записывает данные с датчиков, расположенных на теле пациента, и может передавать эти данные сторонним устройствам посредством Bluetooth. Приложение должно обеспечивать расчет по исходным данным, а также отображение интерактивной кардиограммы и графика активности пациента (на основе данных с датчиков акселерометра) в режиме реального времени и в архивном режиме за выбранный период.
После завершения разработки заказчик планировал получить государственный патент на приложение, и из-за этого с самого начала у нас появилось требование: в дальнейшем менять что-либо в исходном коде будет нельзя. При этом, после получения патента, от нас требовалось обеспечить дальнейшее развитие проекта и разработать дополнительные функции, которые мы не могли реализовать изначально из-за сжатых сроков и планов заказчика.
Нашим решением стала разработка двух приложений. Первое приложение предназначено для получения данных от медицинского устройства, их обработки и визуализации. После разработки обновления для него не требовались.
Второе приложение, которое мы назвали приложением-компаньоном, должно было забирать данные у первого приложения и передавать их на бэкенд. В нем была предусмотрена авторизация пользователя, а передача данных на бэкенд должна была происходить в фоновом режиме без его явного участия. Наличие второго приложения позволило нам обойти ограничения, накладываемые патентом, и обеспечить дальнейшее развитие функционала проекта.
Приложение-компаньон было оптимизировано для периодической работы в фоновом режиме и минимального потребления ресурсов, чтобы ОС Android не заглушала его при повышенной нагрузке на систему. В статье речь пойдет о тех приемах оптимизации, которые позволили нам организовать стабильную работу в фоне и выполнить поставленные бизнес-задачи. Все примеры кода были упрощены для понимания, чтобы передать их суть и уместить в рамках статьи.
Представленные в статье подходы к оптимизации фоновых процессов будут полезны разработчикам, решающим схожие задачи по созданию энергоэффективных приложений с длительной фоновой работой в условиях ограниченных системных ресурсов.
Выбор технологического стека
В требованиях заказчика изначально был выбран основной технологический стек – фреймворк для разработки мобильных приложений Xamarin на базе .NET. Этот выбор был обусловлен наличием экспертизы и уже написанных ранее приложений на Xamarin и .NET у команды заказчика.
Несмотря на то, что с 1 мая 2024 года "классический" Xamarin больше не поддерживается и не обновляется корпорацией Майкрософт, мобильная разработка на платформе .NET по-прежнему доступна. В настоящий момент Xamarin окончательно интегрировался в платформу .NET и по факту превратился в .NET for iOS и .NET for Android. Также развивается кроссплатформенная разработка .NET MAUI – эволюция Xamarin.Forms.
К моменту начала разработки наша команда уже имела опыт создания приложений на Xamarin.iOS и Xamarin.Android, а с недавних пор мы также перешли на последние версии .NET. В мобильном приложении-компаньоне мы использовали версию платформы .NET 8.
Получение данных из основного приложения
Одной из основных задач, которую нужно было решить в приложении-компаньоне, было получение данных из основного приложения. Мы рассматривали различные варианты ее реализации:
Межпроцессное взаимодействие (IPC)
IPC (Inter Process Communication) позволяет приложениям обмениваться данными и управлять ресурсами.
Контент провайдер (Content Provider)
Позволяет приложению предоставлять другим приложениям доступ к его данным через абстрактный интерфейс.
Пример: приложение “Контакты” в Android, предоставляющее другим приложениям (например, банковским) доступ к контактам.
Нам требовалось асинхронное бэкграунд решение, чтобы процесс передачи данных происходил незаметно для пользователя и не требовал от него каких-либо действий в интерфейсе.
Требования к реализации:
Поддержка частого обращения к данным.
Быстрые и асинхронные запросы.
Возможность выборки из большого объема данных.
В итоге мы остановились на варианте с использованием единого источника данных для обоих приложений — локальной базы данных SQLite.
При таком подходе базу данных нам пришлось расположить не во внутреннем хранилище основного приложения, а во внешнем, чтобы обеспечить доступ к ней из приложения-компаньона.
Так как база данных доступна во внешнем хранилище, для обеспечения безопасности данных мы решили ее зашифровать. Для этого мы использовали библиотеку Sqlite-net-sqlcipher, которая позволяет создавать подключения к зашифрованной базе данных.
Чтобы получить доступ ко внешнему хранилищу в Android, добавляем в AndroidManifest.xml следующие разрешения:
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
По умолчанию приложение имеет доступ только к своему внутреннему хранилищу, например, по пути «data/user/0/имя пакета». Для доступа к внешнему хранилищу нужно запросить разрешения.
На главном экране приложения переопределяем метод ViewAppeared
public override void ViewAppeared() { InitializationProcess().Forget(); base.ViewAppeared(); }
И добавляем проверку на наличие разрешений. Если разрешения не были предоставлены, уведомляем пользователя о необходимости их предоставить или не даем возможности пользоваться приложением. Без предоставления разрешений основная фича приложения – фоновая передача данных на бэкенд – не сможет функционировать.
private Task InitializationProcess() { if (_initializeTask != null) return _initializeTask; _initializeTask = CheckAndRequestPermissions() .ContinueWith(_ => _initializeTask = null); return _initializeTask; } private async Task CheckAndRequestPermissions() { if (permissionsHelper.CheckPermission(PermissionType.ExternalStorage)) return; // отображаем диалоговое окно с текстом: «на следующем экране необходимо предоставить Доступы» await _navigationService.Navigate<StartupViewModel>(); }
StartupViewModel – это специальный экран, задача которого запросить все необходимые доступы на платформенной части и закрыться. Для пользователя это происходит незаметно.
Переопределяем метод OnCreate, наследуемый от MvxActivity
protected override void OnCreate(Bundle savedInstanceState) { base.OnCreate(savedInstanceState); RequestPermissions(); }
Проверяем версию Android API и выполняем запрос разрешений на получение доступа
private void RequestPermissions() { if (OperatingSystem.IsAndroidVersionAtLeast(30)) { if (!Environment.IsExternalStorageManager) OpenExternalStorageSettings(); } else { this.CheckAndRequestPermissions( Constants.EXTERNAL_STORAGE_PERMISSIONS, Constants.REQUEST_EXTERNAL_STORAGE_PERMISSION_CODE); } } public static string[] EXTERNAL_STORAGE_PERMISSIONS = { Permission.ReadExternalStorage, Permission.WriteExternalStorage, Permission.ManageExternalStorage }; public const int REQUEST_EXTERNAL_STORAGE_PERMISSION_CODE = 102; public static void CheckAndRequestPermissions(this Activity activity, string[] permissions, int requestCode) { if (permissions.All(p => ContextCompat.CheckSelfPermission(activity, p) == Permission.Granted)) { // log here return; } ActivityCompat.RequestPermissions(activity, permissions, requestCode); }
Следующий код подходит для версий Android API, начиная с 30
private void OpenExternalStorageSettings() { var intent = new Intent(Settings.ActionManageAppAllFilesAccessPermission); intent.AddCategory(Intent.CategoryDefault); intent.SetData(Uri.FromParts("package", PackageName, null)); StartActivityForResult(intent, Constants.REQUEST_EXTERNAL_STORAGE_SETTINGS_CODE); }
Переопределение метода OnActivityResult позволяет нам отследить, предоставлены ли разрешения и незаметно для пользователя закрыть экран StartupView
protected override void OnActivityResult(int requestCode, Result resultCode, Intent data) { base.OnActivityResult(requestCode, resultCode, data); if (requestCode == Constants.REQUEST_EXTERNAL_STORAGE_SETTINGS_CODE) { if (Environment.IsExternalStorageManager) ClosePageCommand?.Execute(); else OpenExternalStorageSettings(); } else if (requestCode == Constants.REQUEST_EXTERNAL_STORAGE_PERMISSION_CODE) { if (resultCode == Result.Ok) ClosePageCommand?.Execute(); } }
Для создания защищенного подключения используем разные перегрузки конструктора SQLiteConnectionString
private SQLiteConnection? GetConnection() { if (!File.Exists(_databasePathProvider.DatabasePath) && _isReadOnly) return null; try { SQLiteConnectionString connectionString = _isReadOnly ? new SQLiteConnectionString( _databasePathProvider.DatabasePath, openFlags: SQLiteOpenFlags.ReadOnly | SQLiteOpenFlags.SharedCache, storeDateTimeAsTicks: true, key: _isEncrypted ? [Ключ] : null) : new SQLiteConnectionString( _databasePathProvider.DatabasePath, storeDateTimeAsTicks: true, key: _isEncrypted ? [Ключ] : null); return new SQLiteConnection(connectionString); } catch (Exception ex) { // log here return null; } }
Стоит отметить что одновременные обращения к базе данных SQLite из нескольких разных приложений (процессов) на чтение допустимы. Однако, во время обращения на запись база блокируется и доступна только одному обратившемуся процессу.
Перезапуск сервиса синхронизации данных в основном приложении с помощью push-уведомления в приложении-компаньоне
В нашем проекте push-уведомления выполняют две задачи:
Информационные пуши.
Сервисные пуши. Перезапускают фоновый сервис основного приложения для синхронизации данных с медицинским устройством.
С информационными пушами всё понятно – это видимые пользователю пуши с заголовком и текстом. Тогда как перезапуск сервиса с помощью невидимого сервисного пуша – это наше решение на случай, если ОС Android заглушит фоновую передачу данных. Если это произойдет, то бэкенд отследит отсутствие данных о синхронизации за некоторый промежуток времени и отправит пуш на устройство, тем самым перезапустив сервис.
Запуск по пушу без явных действий со стороны пользователя стороннего сервиса, реализованного и объявленного в другом Android приложении, – это интересная задача, которая потребовала некоторого ресерча и попутного решения некоторых проблем.
Одна из проблем заключалась в том, что на современных, актуальных версиях Android нельзя отправить Intent для запуска сервиса из фона. При попытке такого запуска возникает ошибка. Решение заключается в том, чтобы отправлять Intent из состояния Foreground, то есть из какого-либо запущенного Activity приложения.
Для того, чтобы со стороны пользователя этот процесс прошел незаметно, мы запускаем полностью прозрачное активити поверх других приложений и отправляем Intent на перезапуск сервиса из его метода жизненного цикла OnResume.
private void StartService() { Intent intent = new Intent(Application.Context, typeof(StartServiceActivity)); intent.SetFlags(ActivityFlags.NewTask | ActivityFlags.ClearTask); _applicationContext.StartActivity(intent); } private Task EnsureSetupInitialized() { // получаем зависимости на сервисы IPushTokenProvider и ILogger<> }
Чтобы визуально запуск активити был незаметным для пользователя сделаем его прозрачным:
[Activity(Theme = "@style/AppTheme.Transparent")] public class StartServiceActivity : Activity
А в методе OnResume отправляем Intent на перезапуск (остановку и старт) фонового сервиса с указанием идентификатора (package) основного приложения "com.mainapp" и кастомного действия (action) "com.mainapp.START_FOREGROUND_SERVICE":
Intent serviceIntent = new Intent("com.mainapp.START_FOREGROUND_SERVICE"); serviceIntent.SetPackage("com.mainapp"); Application.Context.StopService(serviceIntent); Application.Context.StartService(serviceIntent);
Чтобы интент отправился, в AndroidManifest приложения-компаньона добавляем элемент <queries> с явным указанием идентификатора (package) основного приложения:
<queries> <package android:name="com.mainapp"/> </queries>
Иначе при попытке отправки будем получать вот такую ошибку:
W/ActivityManager( 1454): Unable to start service Intent { act=com.mainapp.START_FOREGROUND_SERVICE pkg=com.mainapp } U=0: not found
В AndroidManifest основного приложения добавляем информацию о фоновом foreground сервисе с указанием типа сервиса (connectedDevice) и кастомного действия (action), которое он будет обрабатывать:
<service android:name="com.mainapp.имясервиса" android:foregroundServiceType="connectedDevice" android:exported="true"> <intent-filter> <action android:name="com.mainapp.START_FOREGROUND_SERVICE" /> </intent-filter> </service>
Для возможности запуска активити из фона и его отображения поверх других приложений, необходимо добавить следующую логику при старте приложения на экране StartupView. С помощью специального интента Settings.ActionManageOverlayPermission пользователь будет перенаправлен на экран Настроек для выдачи необходимого разрешения:
public static void CheckAndRequestDrawOverlay(this Activity activity) { if (Settings.CanDrawOverlays(activity)) return; var intent = new Intent(Settings.ActionManageOverlayPermission); intent.SetData(Android.Net.Uri.Parse("package:" + activity.PackageName)); activity.StartActivity(intent); }
Настройка фоновых задач
Итак, для выполнения фоновых задач нам потребуется Android сервис. Начнём с выбора типа сервиса – их существует несколько, и для каждого из них есть свои условия работы.
Подробнее можно ознакомиться в официальной документации Android.
Задача заключалась в обеспечении фоновой синхронизации данных, которая должна работать незаметно для пользователя, минимизировать нагрузку на устройство и преодолевать агрессивные ограничения на работу в фоне, которые системы некоторых вендоров Android могут применять для экономии заряда батареи смартфона.
Для этой задачи подходят несколько типов сервисов:
Service – используется для выполнения долгосрочных фоновых задач, которые требуют постоянной работы.
IntentService – сервис, который запускается при получении Intent. После завершения задачи он автоматически останавливается.
JobService — сервис, для которого можно запланировать периодический запуск.
ForegroundService – выполняется в фоновом режиме, при этом отображает постоянное уведомление. Преимущество сервиса в том, что он продолжает работать даже при ограничениях на фоновые задачи, введённых еще в Android 8.0 (Oreo) и выше. Начиная с Android 10, также были введены типы foreground сервисов, которые классифицируют задачи, выполняемые в фоне (например, синхронизация данных или воспроизведение мультимедиа). В основном приложении мы используем ForegroundService для обмена данными с подключёнными по Bluetooth устройствами.
По нашему опыту работы с Service мы стремились обойти проблемы, связанные с ограничениями на работу в фоне, и учли последние изменения в Android 15, направленные на экономию заряда батареи, которые могли бы привести к новым проблемам при дальнейшей поддержке приложения.
В итоге мы выбрали JobService с периодическим запуском в фоновом режиме как оптимальное решение для этой задачи.
В AndroidManifest добавим информацию о сервисе:
<service android:name="com.companion.datasyncservice" android:foregroundServiceType="dataSync" android:permission="android.permission.BIND_JOB_SERVICE" android:exported="false"/>
Теперь создадим класс в проекте:
[Service(Name = "com.companion.datasyncservice", Permission = "android.permission.BIND_JOB_SERVICE", ForegroundServiceType = ForegroundService.TypeDataSync, Enabled = true, Exported = false)] public class ExampleService : JobService
android.permission.BIND_JOB_SERVICE – необходимое разрешение для запуска сервиса через JobScheduler.
ForegroundService.TypeDataSync – данный тип указывает, что сервис выполняет задачи синхронизации данных.
Enabled: true – сервис доступен и может быть запущен системой.
Exported: false – сервис не может быть запущен другими приложениями. Например, для сервиса синхронизации данных с медицинским устройством из основного приложения параметр указан в True для того, чтобы мы могли его перезапускать из приложения-компаньона.
Переопределяем метод запуска сервиса:
public override bool OnStartJob(JobParameters? @params) { _syncCancellationTokenSource?.Cancel(); _syncCancellationTokenSource = new CancellationTokenSource(); _workerTask ??= SyncData(_syncCancellationTokenSource.Token).ContinueWith(_ => { if (Build.VERSION.SdkInt >= BuildVersionCodes.Q && Build.VERSION.SdkInt < BuildVersionCodes.Tiramisu) StopForeground(true); StopSelf(); _workerTask = null; }); return true; }
Для управления состоянием задачи используем переменную _workerTask
private static bool IsStarted => _workerTask != null; private static Task? _workerTask;
Методы репозиториев, выполняющих логику синхронизации, вызываем в методе SyncData
private Task SyncData(CancellationToken cancellationToken = default) { // add repository calls // end task cancellationToken.ThrowIfCancellationRequested(); return Task.CompletedTask; }
Получение зависимостей на репозитории выполняем по необходимости, обернув в конструктор Lazy
private Lazy<IExampleRepository> _exampleRepositoryLazy = new(() => Mvx.IoCProvider.Resolve<IExampleRepository>());
При завершении работы фонового сервиса отменяем выполняемую задачу
public override bool OnStopJob(JobParameters? @params) { _syncCancellationTokenSource?.Cancel(); _syncCancellationTokenSource = null; return true; // Return true to reschedule the job }
Для добавления в планировщик фоновой задачи обращаемся к сервису JobSchedulerService
public static Task RequestDataSyncWork(Context context) { if (IsStarted) return Task.CompletedTask; var jobScheduler = (JobScheduler)context.GetSystemService(JobSchedulerService); var jobInfo = new JobInfo.Builder(1, new ComponentName(context, Java.Lang.Class.FromType(typeof(ExampleService)))) .SetPeriodic(15 * 60 * 1000) // Set interval (e.g., 15 minutes) ?.SetRequiredNetworkType(Android.App.Job.NetworkType.Any) // Require network connection ?.SetPersisted(true) // Persist across reboots ?.Build(); jobScheduler.Schedule(jobInfo); return Task.CompletedTask; }
Минимальная периодичность запуска сервиса – 15 минут, однако во время тестов на устройствах нескольких вендоров мы воспроизводили периодичность перезапуска не в фиксированное время, а в интервале – 15-30 минут.
Добавление фонового сервиса в планировщик выполняем после авторизации.
ExampleService.RequestDataSyncWork(Application.Context);
Для остановки текущего фонового процесса, например, при деавторизации (Logout) подписываемся на LogoutMessage в классе фонового сервиса и отменяем текущее выполнение задачи
mvxMessenger.Subscribe<LogoutMessage>(LogoutMessageHandler); private void LogoutMessageHandler(LogoutMessage obj) { _syncCancellationTokenSource?.Cancel(); _syncCancellationTokenSource = null; }
Также удаляем из планировщика последующие плановые перезапуски задачи
var jobScheduler = (JobScheduler)context.GetSystemService(JobSchedulerService); jobScheduler.CancelAll();
Заключение
В результате проведенной работы нам удалось успешно реализовать архитектурное решение, позволяющее обойти ограничения, связанные с патентованием основного приложения, и обеспечить дальнейшее развитие проекта. Разработанное приложение-компаньон эффективно решает задачу фоновой синхронизации данных с сервером, при этом потребляя минимальное количество системных ресурсов.
Описанные в статье приемы оптимизации фоновых процессов позволили достичь стабильной работы приложения даже в условиях повышенной нагрузки на систему Android. Настройка фоновых задач с использованием JobService обеспечила надежную синхронизацию данных и минимизировала нагрузку на устройство. Реализация сервисных push-уведомлений с помощью Firebase Cloud Messaging добавила дополнительный уровень надёжности и автономности.
В процессе разработки мы столкнулись с рядом проблем: ограничениями Android на фоновые задачи и особенностями работы с различными вендорами-производителями Android устройств. Однако, благодаря интересным решениям, мы смогли преодолеть эти вызовы и создать мобильное медтех приложение, удовлетворяющее всем требованиям заказчика.
