Эффективный бэкграунд: организация стабильной фоновой работы в связке двух мобильных медтех приложений

9878444a456162215f5f440abfe799c4.png

Всем привет! Меня зовут Антон, я — ведущий мобильный разработчик в компании 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. 

Получение данных из основного приложения

Одной из основных задач, которую нужно было решить в приложении-компаньоне, было получение данных из основного приложения.  Мы рассматривали различные варианты ее реализации:

  1. Межпроцессное взаимодействие (IPC)

    • IPC (Inter Process Communication) позволяет приложениям обмениваться данными и управлять ресурсами.

  2. Контент провайдер (Content Provider)

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

    • Пример: приложение «Контакты» в Android, предоставляющее другим приложениям (например, банковским) доступ к контактам.

Нам требовалось асинхронное бэкграунд решение, чтобы процесс передачи данных происходил незаметно для пользователя и не требовал от него каких-либо действий в интерфейсе.

Требования к реализации:

  • Поддержка частого обращения к данным.

  • Быстрые и асинхронные запросы.

  • Возможность выборки из большого объема данных.

В итоге мы остановились на варианте с использованием единого источника данных для обоих приложений — локальной базы данных SQLite.

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

Так как база данных доступна во внешнем хранилище, для обеспечения безопасности данных мы решили ее зашифровать. Для этого мы использовали библиотеку Sqlite-net-sqlcipher, которая позволяет создавать подключения к зашифрованной базе данных. 

Чтобы получить доступ ко внешнему хранилищу в Android, добавляем в AndroidManifest.xml следующие разрешения:



По умолчанию приложение имеет доступ только к своему внутреннему хранилищу, например, по пути «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 — это специальный экран, задача которого запросить все необходимые доступы на платформенной части и закрыться. Для пользователя это происходит незаметно.

Переопределяем метод 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-уведомления выполняют две задачи:

  1. Информационные пуши.

  2. Сервисные пуши. Перезапускают фоновый сервис основного приложения для синхронизации данных с медицинским устройством.

С информационными пушами всё понятно — это видимые пользователю пуши с заголовком и текстом. Тогда как перезапуск сервиса с помощью невидимого сервисного пуша — это наше решение на случай, если ОС 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 приложения-компаньона добавляем элемент с явным указанием идентификатора (package) основного приложения:


    

Иначе при попытке отправки будем получать вот такую ошибку:

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), которое он будет обрабатывать:


    
        
    

Для возможности запуска активити из фона и его отображения поверх других приложений, необходимо добавить следующую логику при старте приложения на экране 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 могут применять для экономии заряда батареи смартфона.

Для этой задачи подходят несколько типов сервисов:

  1. Service — используется для выполнения долгосрочных фоновых задач, которые требуют постоянной работы.

  2. IntentService — сервис, который запускается при получении Intent. После завершения задачи он автоматически останавливается.

  3. JobService — сервис, для которого можно запланировать периодический запуск.

  4. ForegroundService — выполняется в фоновом режиме, при этом отображает постоянное уведомление. Преимущество сервиса в том, что он продолжает работать даже при ограничениях на фоновые задачи, введённых еще в Android 8.0 (Oreo) и выше. Начиная с Android 10, также были введены типы foreground сервисов, которые классифицируют задачи, выполняемые в фоне (например, синхронизация данных или воспроизведение мультимедиа). В основном приложении мы используем ForegroundService для обмена данными с подключёнными по Bluetooth устройствами.

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

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

В AndroidManifest добавим информацию о сервисе:

Теперь создадим класс в проекте:

[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 _exampleRepositoryLazy =
    new(() => Mvx.IoCProvider.Resolve());

При завершении работы фонового сервиса отменяем выполняемую задачу 

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(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 устройств. Однако, благодаря интересным решениям, мы смогли преодолеть эти вызовы и создать мобильное медтех приложение, удовлетворяющее всем требованиям заказчика.

© Habrahabr.ru