Как мы сделали миграцию пользовательских данных с нативного приложения на Flutter
Всем привет! Меня зовут Дмитрий Андриянов, я Flutter-разработчик в Surf.
В этой статье я расскажу про бесшовную миграцию данных при установке новой версии приложения, написанного на Flutter, поверх предыдущей версии, написанной на нативе. Это решение реализовано моим коллегой по Flutter-отделу в Surf Александром Трущинским.
Статья будет полезна, если вы:
- Пишете на Flutter и хотите увидеть пример работы с платформенным кодом.
- Пишете для Android/iOS и хотите перенести свой проект на Flutter.
- Переживаете, что оценки в сторах могут просесть, потому что пользователи будут недовольны барьерами из-за обновления приложения: например, из-за того, что придётся заново регистрироваться или авторизоваться.
Задача: обновить приложение банка с переносом пользовательских данных
В 2019 году к нам пришёл заказчик с запросом на обновление банковского B2B приложения. Прежняя версия была написана на нативных Android и iOS технологиях. Заказчик решил отказаться от них в пользу Flutter, чтобы сократить затраты на разработку и поддержку.
Flutter — это не React Native где »7 раз отмерь и в итоге откажись», а эффективный инструмент, способный конкурировать с нативной разработкой.
При разработке приложения пришлось столкнуться с рядом нетривиальных задач. Одна из них — реализовать автоматический перенос пользовательских данных при установке новой версии на Flutter поверх нативной существующей.
Перенести имеющиеся данные было критически важно: существующие пользователи не должны входить заново в, по сути, новое приложение. Если у вас возник вопрос: «Разве повторный вход в приложение — проблема?», опишу этот занимательный процесс:
- Установить специальное расширения для браузера, отвечающее за безопасность. Его поставляет сам банк.
- Зарегистрироваться или войти на сайт.
- Сгенерировать ключ для электронной подписи и дождаться его активации. Он используется при каждом входе на сайт.
- Добавить через сайт мобильное устройство, с которого пользователь будет работать с приложением.
- Получить сгенерированный временный логин и пароль, ввести их и подтвердить добавление устройства.
Сразу замечу, что в приложении нет всем знакомого «пользователя». Его роль выполняет сущность «компания» — холдинг, способный объединять несколько разных организаций. Их количество не ограничено, и каждую нужно регистрировать. Также есть режим «мультиаккаунта» со множеством несвязанных организаций — им пользуются, например, бухгалтеры, которые обслуживают на аутсорсе разные фирмы.
Чтобы пользователю понравилась новая версия, и он в порыве гнева не пошёл ставить малоприятные отзывы, требовалось сделать перенос данных тихо и быстро.
Решение
Решение задачи мы видели таким:
- Определить, где в нативном приложении хранятся данные для каждой из платформ.
- На сплэше проверять наличие пин-кода в новом хранилище со стороны обновлённой версии приложения на Flutter.
- Если данные есть, направлять на авторизацию.
- Если данных нет, проверять хранилище в старом нативном коде через MethodChannel. При их наличии также отправлять на авторизацию и запускать миграцию в случае успеха. Миграция здесь — это извлечение и перенос данных в другое хранилище с более простым доступом со стороны Flutter, а также очистка старого места хранения.
- В случае отсутствия данных — регистрация.
Мы решили перенести данные в новое хранилище с прямым доступом из Flutter, чтобы не тянуть легаси и не выстраивать вокруг него логику со всеми вытекающими.
Проблемы при решении
Когда пришло время выполнения задачи по миграции, Александр ринулся в бой, а остальная команда занималась UI и прочими делами. Тут-то и началось самое интересное. Мы понятия не имели, как работает под капотом текущая авторизация и как лучше подступиться к этому монолиту. Для понимания нужны были исходники.
Получив желанные файлы, мы не смогли собрать их. Оказалось, что у подрядчика, который разрабатывал предыдущую версию приложения, была своя билд-система и библиотека для авторизации — доступа к ним у нас не оказалось.
Пройдя стадию принятия, мы начали реализовывать задачу.
На Android в исходниках приложения оказалось много абстракций, поэтому мы решили выделить необходимые части нативного кода в отдельный модуль с реализацией аналогичной логики извлечения данных пользователя. Этот модуль и подключили к нашему Flutter-приложению через MethodChannel.
Вменяемой документации не было, и нам пришлось потратить время, чтобы понять механизм работы и определить, какой код брать. Когда разобрались, создали отдельный Android-проект, чтобы отдебажить изъятые куски и привести их к виду удобоваримого модуля.
Целую неделю мы превращали изъятый код в интерфейс, с которым можно продуктивно работать. Но баги всё равно преследовали нас, потому что часто приходилось править и пересобирать локально чужой код на Kotlin и библиотеки, от которых он зависел.
Миграцию разрабатывали в дебажной версии приложения, а в релизной сборке появились новые проблемы. В предыдущей версии приложения использовалась утилита ProGuard — она удаляет неиспользуемый код, изменяет имена переменных и методов для усложнения реверс-инжиниринга приложения, а также позволяет уменьшить размер файлов.
Использование ProGuard вызывало несоответствие классов, и приложение крашилось. Для решения проблемы сравнивали каждый падающий класс в apk-файле старого и нового приложения и приводили их к общему виду. Это тоже замедляло разработку.
В iOS, в отличие от Android, всё оказалось просто: нашли нужные сертификаты для доступа к Keychain — специализированной базе данных Apple, где в защищённом виде хранятся метаданные и конфиденциальная информация пользователя. Из Keychain достали новые данные.
Так мы побороли нативного зверя. Дальше нужно было интегрировать его в наш Flutter-проект.
Интеграция
Интегрировали, интегрировали, да заинтегрировали
На данном этапе у нас уже имелся сплэш, экран регистрации и экран входа по пин-коду/биометрии.
Первое, что нам было необходимо, — понять, какой экран открывать. Для этого нужно знать, существуют ли данные. Если да — экран входа. На нём миграция и запустится. Если данных нет — отправляем на экран регистрации пользователя.
При открытии приложения на сплеше проверяем Flutter-хранилище: вдруг это не первый вход и данные уже перенесены, либо пользователь регистрировался через новую версию приложения. Тогда миграция не нужна.
Если во Flutter-хранилище нас ожидает пустота, идём в наш сервис и ищем данные там. Обнаружились — значит, мы писали код не зря, и теперь их нужно переносить. В противном случае никакой миграции — нужно направлять на регистрацию.
Свидетельством того, что пользователь есть хоть где-то, является сохранённый пин-код. Точнее, его зашифрованный хэш: данные можно достать только с его помощью. Всё ради безопасности.
Запускаем и смотрим, есть ли пин-код на 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);
}
}
В качестве архитектуры выбрали, как и во всех других наших проектах, собственное проверенное решение mwwm из пакета SurfGear и пакет relation для более эффективного управления состоянием.
Подробнее о mwwm можно посмотреть в презентации на YouTube.
Дополнительно используем Clean architecture. Входной точкой в логику авторизации является AuthInteractor.
Работу с данными поделили на классы:
- DataProvider для работы с данными на уровне Flutter.
- PlatformAuthDataProvider для работы с данными в прежнем хранилище на уровне платформы.
Мы точно знаем, что при первом входе в обновлённое приложение данные существующих компаний хранятся в старом хранилище, ведь мы их ещё не вытягивали.
Если данные есть только на нативном уровне, наконец-то начинаем миграцию.
Один из нюансов миграции данных — она происходит в тандеме с сервером, а не только локально. Поэтому есть риск, что запрос обвалится.
В таком случае не хотелось бы снова лезть в старый код. Чтобы избежать этого, миграцию можно условно разделить на два этапа.
Сначала просто копируем компании из старого нативного хранилища в новое на Flutter. Эти компании не имеют подтверждённых сертификатов и пользователь не сможет полноценно работать с ними, но они уже хранятся в нужном нам месте.
На втором этапе для каждой компании запускается сетевой запрос о начале и окончании миграции — это нужно для работы с ключами и безопасного переноса данных.
После успешной миграции очищаем данные в старом хранилище и забываем об устаревшем легаси. Если на этом этапе миграции компании что-то пойдёт не так, её можно продолжить из приложения после авторизации.
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-приложение поверх существующего нативного. Так мы сократили команду разработки в будущем и избавились от легаси в проекте.
Такая незаурядная задача была очень увлекательным вызовом. Но это было только начало. Проект принес немало интересных задач и сложных кейсов.
Хочется сказать спасибо всем, кто был причастен к нему. И отдельное спасибо Саше Трущинскому за реализацию такого непростого кейса с принятием нативного удара на себя.