Как мы сделали миграцию пользовательских данных с нативного приложения на Flutter

Всем привет! Меня зовут Дмитрий Андриянов, я Flutter-разработчик в Surf.

В этой статье я расскажу про бесшовную миграцию данных при установке новой версии приложения, написанного на Flutter, поверх предыдущей версии, написанной на нативе. Это решение реализовано моим коллегой по Flutter-отделу в Surf Александром Трущинским.
Статья будет полезна, если вы:

  1. Пишете на Flutter и хотите увидеть пример работы с платформенным кодом.
  2. Пишете для Android/iOS и хотите перенести свой проект на Flutter.
  3. Переживаете, что оценки в сторах могут просесть, потому что пользователи будут недовольны барьерами из-за обновления приложения: например, из-за того, что придётся заново регистрироваться или авторизоваться.


kheot5oe8rdfxw3wcg5kvzll3jo.png

Задача: обновить приложение банка с переносом пользовательских данных


В 2019 году к нам пришёл заказчик с запросом на обновление банковского B2B приложения. Прежняя версия была написана на нативных Android и iOS технологиях. Заказчик решил отказаться от них в пользу Flutter, чтобы сократить затраты на разработку и поддержку.

Flutter — это не React Native где »7 раз отмерь и в итоге откажись», а эффективный инструмент, способный конкурировать с нативной разработкой.


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

Перенести имеющиеся данные было критически важно: существующие пользователи не должны входить заново в, по сути, новое приложение. Если у вас возник вопрос: «Разве повторный вход в приложение — проблема?», опишу этот занимательный процесс:

  1. Установить специальное расширения для браузера, отвечающее за безопасность. Его поставляет сам банк.
  2. Зарегистрироваться или войти на сайт.
  3. Сгенерировать ключ для электронной подписи и дождаться его активации. Он используется при каждом входе на сайт.
  4. Добавить через сайт мобильное устройство, с которого пользователь будет работать с приложением.
  5. Получить сгенерированный временный логин и пароль, ввести их и подтвердить добавление устройства.


image
Сразу замечу, что в приложении нет всем знакомого «пользователя». Его роль выполняет сущность «компания» — холдинг, способный объединять несколько разных организаций. Их количество не ограничено, и каждую нужно регистрировать. Также есть режим «мультиаккаунта» со множеством несвязанных организаций — им пользуются, например, бухгалтеры, которые обслуживают на аутсорсе разные фирмы.

Чтобы пользователю понравилась новая версия, и он в порыве гнева не пошёл ставить малоприятные отзывы, требовалось сделать перенос данных тихо и быстро.

Решение


Решение задачи мы видели таким:

  • Определить, где в нативном приложении хранятся данные для каждой из платформ.
  • На сплэше проверять наличие пин-кода в новом хранилище со стороны обновлённой версии приложения на Flutter.
  • Если данные есть, направлять на авторизацию.
  • Если данных нет, проверять хранилище в старом нативном коде через MethodChannel. При их наличии также отправлять на авторизацию и запускать миграцию в случае успеха. Миграция здесь — это извлечение и перенос данных в другое хранилище с более простым доступом со стороны Flutter, а также очистка старого места хранения.
  • В случае отсутствия данных — регистрация.


Мы решили перенести данные в новое хранилище с прямым доступом из Flutter, чтобы не тянуть легаси и не выстраивать вокруг него логику со всеми вытекающими.

image

Проблемы при решении


Когда пришло время выполнения задачи по миграции, Александр ринулся в бой, а остальная команда занималась UI и прочими делами. Тут-то и началось самое интересное. Мы понятия не имели, как работает под капотом текущая авторизация и как лучше подступиться к этому монолиту. Для понимания нужны были исходники.

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

Пройдя стадию принятия, мы начали реализовывать задачу.

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

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

Целую неделю мы превращали изъятый код в интерфейс, с которым можно продуктивно работать. Но баги всё равно преследовали нас, потому что часто приходилось править и пересобирать локально чужой код на Kotlin и библиотеки, от которых он зависел.

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

Использование ProGuard вызывало несоответствие классов, и приложение крашилось. Для решения проблемы сравнивали каждый падающий класс в apk-файле старого и нового приложения и приводили их к общему виду. Это тоже замедляло разработку.

В iOS, в отличие от Android, всё оказалось просто: нашли нужные сертификаты для доступа к Keychain — специализированной базе данных Apple, где в защищённом виде хранятся метаданные и конфиденциальная информация пользователя. Из Keychain достали новые данные.

Так мы побороли нативного зверя. Дальше нужно было интегрировать его в наш Flutter-проект.

Интеграция


Интегрировали, интегрировали, да заинтегрировали

На данном этапе у нас уже имелся сплэш, экран регистрации и экран входа по пин-коду/биометрии.

Первое, что нам было необходимо, — понять, какой экран открывать. Для этого нужно знать, существуют ли данные. Если да — экран входа. На нём миграция и запустится. Если данных нет — отправляем на экран регистрации пользователя.

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

Если во Flutter-хранилище нас ожидает пустота, идём в наш сервис и ищем данные там. Обнаружились — значит, мы писали код не зря, и теперь их нужно переносить. В противном случае никакой миграции — нужно направлять на регистрацию.
image

Свидетельством того, что пользователь есть хоть где-то, является сохранённый пин-код. Точнее, его зашифрованный хэш: данные можно достать только с его помощью. Всё ради безопасности.

Запускаем и смотрим, есть ли пин-код на Flutter или в нативном уровне. Решаем, куда пустить: на регистрацию или авторизацию.

Future IsPinCorrect(String pin) async {
 if (pin == null || pin.isEmpty) return false;

 String pinHash = CryptoUtils.getHash(pin);

 if (await _migrator.needLoadAuthDataFromPlatform) {
   return _platformAuthDataProvider.isPinCorrect(pin);
 } else {
   return _dataProvider.isPinCorrect(pinHash);
 }
}


image
В качестве архитектуры выбрали, как и во всех других наших проектах, собственное проверенное решение mwwm из пакета SurfGear и пакет relation для более эффективного управления состоянием.

Подробнее о mwwm можно посмотреть в презентации на YouTube.

Дополнительно используем Clean architecture. Входной точкой в логику авторизации является AuthInteractor.

Работу с данными поделили на классы:

  • DataProvider для работы с данными на уровне Flutter.
  • PlatformAuthDataProvider для работы с данными в прежнем хранилище на уровне платформы.


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

Если данные есть только на нативном уровне, наконец-то начинаем миграцию.
Один из нюансов миграции данных — она происходит в тандеме с сервером, а не только локально. Поэтому есть риск, что запрос обвалится.

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

Сначала просто копируем компании из старого нативного хранилища в новое на Flutter. Эти компании не имеют подтверждённых сертификатов и пользователь не сможет полноценно работать с ними, но они уже хранятся в нужном нам месте.

На втором этапе для каждой компании запускается сетевой запрос о начале и окончании миграции — это нужно для работы с ключами и безопасного переноса данных.

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

image

Future migrate(String pinHash, NavigatorState navigator) async {
 final deviceInfo = await _deviceInfoInteractor.getDeviceInfo();
 final List companies = await _dataProvider.getCompanies(pinHash);

 for (Company company in companies) {
   try {
/// Сетевой запрос на начало миграции выбранной компании
     final startMigration = await _migrationRepository.migrationStart(
       deviceInfo,
     );

/// На этом месте в реальном коде
/// локальная логика работы с сертификатами компании

/// Сетевой запрос на окончание миграции выбранной компании
         await _migrationRepository.migrationFinish(
       startMigration.migrationId,
       … передача параметров шифрования
     );

     await _confirmMigrate(
         startMigration,
         company,
         publicPrivateKeys,
       );
     company.needMigrate = false;
   } on Exception catch (e) {
     Logger.e(e.toString());
   }
 }

/// Удаление данных из старого  хранилища
 await _platformDataProvider.clearData();
 await _dataProvider.saveCompanies(pinHash, companies);
 await _dataProvider.setPin(pinHash);
}


Итог


Путём доработок старого нативного кода мы бесшовно установили Flutter-приложение поверх существующего нативного. Так мы сократили команду разработки в будущем и избавились от легаси в проекте.

Такая незаурядная задача была очень увлекательным вызовом. Но это было только начало. Проект принес немало интересных задач и сложных кейсов.

Хочется сказать спасибо всем, кто был причастен к нему. И отдельное спасибо Саше Трущинскому за реализацию такого непростого кейса с принятием нативного удара на себя.

© Habrahabr.ru