Привет, Хабр! Меня зовут Андрей, я делаю приложение
Кошелёк для
Android. Уже больше полугода мы помогаем пользователям смартфонов
Huawei оплачивать покупки банковскими картами бесконтактно через
NFC. Для этого нам потребовалось добавить поддержку HMS: Push Kit,
Map Kit и Safety Detect. Под катом я расскажу, какие проблемы нам
пришлось решать при разработке, почему именно так и что из этого
вышло, а также поделюсь тестовым проектом для более быстрого
погружения в тему.
Для того, чтобы предоставить всем пользователям новых смартфонов
Huawei возможность
бесконтактной
оплаты из коробки и обеспечить лучший пользовательский опыт в
остальных сценариях, в январе 2020 года мы начали работы по
поддержке новых пушей, карт и проверок на безопасность. Результатом
должно было стать появление в AppGallery версии Кошелька с родными
для телефонов Huawei мобильными сервисами.
Вот что удалось выяснить на этапе первоначальной
проработки
- Huawei распространяет AppGallery и HMS без ограничений можно
скачать и установить их на устройства других производителей;
- После того, как мы установили AppGallery на Xiaomi Mi A1, все
обновления начали подтягиваться в первую очередь с новой площадки.
Сложилось впечатление, что AppGallery успевает обновлять приложения
быстрее конкурентов;
- Сейчас Huawei стремится как можно быстрее наполнить AppGallery
приложениями. Чтобы ускорить миграцию на HMS, они решили
предоставить разработчикам уже знакомый (похожий на GMS)
API;
- На первых порах, пока экосистема Huawei для разработчиков не
заработает на полную мощность, отсутствие Google-сервисов скорее
всего будет являться главной проблемой для пользователей новых
смартфонов Huawei, и они будут всеми способами пытаться
их установить.
Мы решили делать одну общую версию приложения для всех площадок
распространения. Она должна уметь определять и использовать
подходящий тип мобильных сервисов в рантайме. Этот вариант казался
медленнее в реализации, чем отдельная версия под каждый тип
сервисов, но мы рассчитывали выиграть в другом:
- Исключается риск попадания версии, предназначенной для Google
Play, на девайсы Huawei и наоборот;
- Можно внедрить любой алгоритм выбора мобильных сервисов, в том
числе с использованием feature toggle;
- Тестировать одно приложение проще, чем два;
- Каждый релиз можно выкладывать на все площадки
распространения;
- Не приходится переключаться с написания кода на управление
сборкой проекта при разработке/модификации.
Для работы с разными реализациями мобильных сервисов в одной версии
приложения необходимо:
- Спрятать все обращения за абстракцию, сохранив работу с
GMS;
- Добавить реализацию для HMS;
- Разработать механизм выбора реализации сервисов в
рантайме.
Методика внедрения поддержки Push Kit и Safety Detect значительно
отличается от Map Kit, поэтому рассмотрим их отдельно.
Поддержка Push Kit и Safety Detect
Как и положено в таких случаях, процесс интеграции начался с
изучения
документации. В разделе предостережений обнаружились вот такие
пункты:
- If the EMUI version is 10.0 or later on a Huawei device, a
token will be returned through the getToken method. If the getToken
method fails to be called, HUAWEI Push Kit automatically caches the
token request and calls the method again. A token will then be
returned through the onNewToken method.
- If the EMUI version on a Huawei device is earlier than 10.0 and
no token is returned using the getToken method, a token will be
returned using the onNewToken method.
- For an app with the automatic initialization capability, the
getToken method does not need to be called explicitly to apply for
a token. The HMS Core Push SDK will automatically apply for a token
and call the onNewToken method to return the token.
Главное, что нужно вынести из этих предостережений
существует разница в получении пуш-токена на разных версиях
EMUI.
После вызова метода getToken(), реальный токен может быть возвращен
через вызов метода onNewToken() сервиса. Наши испытания
на
реальных устройствах показали, что телефоны с EMUI < 10.0 на
вызов метода getToken возвращают null или пустую строку, после чего
происходит вызов метода onNewToken() сервиса. Телефоны с EMUI >=
10.0 всегда возвращали пуш-токен из метода getToken().
Можно реализовать вот такой источник данных, чтобы привести логику
работы к единому виду:
class HmsDataSource( private val hmsInstanceId: HmsInstanceId, private val agConnectServicesConfig: AGConnectServicesConfig) { private val currentPushToken = BehaviorSubject.create<String>() fun getHmsPushToken(): Single<String> = Maybe .merge( getHmsPushTokenFromSingleton(), currentPushToken.firstElement() ) .firstOrError() fun onPushTokenUpdated(token: String): Completable = Completable .fromCallable { currentPushToken.onNext(token) } private fun getHmsPushTokenFromSingleton(): Maybe<String> = Maybe .fromCallable<String> { val appId = agConnectServicesConfig.getString("client/app_id") hmsInstanceId.getToken(appId, "HCM").takeIf { it.isNotEmpty() } } .onErrorComplete()}
class AppHmsMessagingService : HmsMessageService() { val onPushTokenUpdated: OnPushTokenUpdated = Di.onPushTokenUpdated override fun onMessageReceived(remoteMessage: RemoteMessage?) { super.onMessageReceived(remoteMessage) Log.d(LOG_TAG, "onMessageReceived remoteMessage=$remoteMessage") } override fun onNewToken(token: String?) { super.onNewToken(token) Log.d(LOG_TAG, "onNewToken: token=$token") if (token?.isNotEmpty() == true) { onPushTokenUpdated(token, MobileServiceType.Huawei) .subscribe({},{ Log.e(LOG_TAG, "Error deliver updated token", it) }) } }}
Важные замечания:
- Предложенное решение работает не во всех случаях. При
тестировании на физических устройствах проблем выявлено не было, но
на пуле устройств, предоставляемых AppGallery для
онлайн-дебаггинга, подход не срабатывает. Причём не срабатывает из
за того, что вызова метода HmsMessageService.onNewToken() не
происходит, что, кажется, не соответствует документации. Причина
такого поведения по сей день остаётся для нас невыясненной;
- Оказалось, что на некоторых устройствах метод
HmsMessageService.onMessageReceived() может вызываться на main
потоке, поэтому будьте аккуратнее с походами в БД и сеть из
него;
- Как только вы добавите зависимость от библиотеки
com.huawei.hms:push, в манифесте проекта после сборки будет
объявлен сервис
com.huawei.hms.support.api.push.service.HmsMsgService,
сконфигурированный для работы в отдельном процессе :pushservice. С
этого момента, при порождении каждого процесса, в нём будет
создаваться свой экземпляр класса Application. Это
принципиально важно осознавать, если вы обращаетесь к файлам или БД
или, например, собираете данные о скорости инициализации приложения
через Firebase Performance. Мы встретились с порождением второго
процесса только на не-Huawei устройствах, куда были установлены
AppGallery и HMS.
Для случаев поддержки работы приложения с пуш-токеном и
проверки устройства на безопасность общий алгоритм будет
одинаковым:
- Создаём по отдельному источнику данных для каждого типа
сервисов;
- Добавляем по
репозиторию для пушей и безопасности, принимающих на вход тип
мобильных сервисов и выбирающих конкретный источник данных;
- Некая сущность бизнес-логики определяет, какой тип мобильных
сервисов (из доступных) уместно использовать в конкретном
случае.
Разработка механизма выбора реализации сервисов в рантайме
Как действовать, если на устройстве установлен всего один тип
сервисов или их нет вовсе, понятно, а вот что делать, если
одновременно установлены и Google-, и Huawei-сервисы?
Вот что мы обнаружили и из чего исходили:
- При внедрении любой новой технологии её нужно использовать в
приоритете, если устройство пользователя полностью соответствует
всем требованиям;
- На устройствах с EMUI >= 10.0 алгоритм получения пуш-токена
отличается от предыдущих версий;
- Подавляющее большинство устройств Huawei без Google-сервисов
будут иметь версию EMUI 10.0 и выше;
- На новые устройства Huawei пользователи будут пытаться
установить Google-сервисы, чтобы пользоваться всеми привычными
приложениями. Надёжного способа сделать это нет, поэтому мы не
должны рассчитывать на стабильную и корректную работу
Google-сервисов на таких устройствах;
- Технически пользователи смартфонов других вендоров могут
установить себе AppGallery и Huawei-сервисы, но мы предполагаем,
что на текущий момент таких пользователей очень мало.
Разработка алгоритма оказалась, наверное, самым выматывающим делом.
Здесь в одну точку сошлось множество технических и бизнесовых
факторов, но в конечном итоге нам удалось прийти к наилучшему
для нашего продукта решению. Сейчас даже немного странно,
что описание самой обсуждаемой части алгоритма помещается в одно
предложение, но я рад, что в конечном итоге получилось просто:
В случае, если на устройстве установлены оба типа
сервисов и удалось определить, что версия EMUI < 10 используем
Google, иначе используем Huawei.
Для реализации итогового алгоритма требуется найти способ
определить версию EMUI на устройстве пользователя.
Один из способов сделать это прочитать системные свойства:
class EmuiDataSource { @SuppressLint("PrivateApi") fun getEmuiApiLevel(): Maybe<Int> = Maybe .fromCallable<Int> { val clazz = Class.forName("android.os.SystemProperties") val get = clazz.getMethod("getInt", String::class.java, Int::class.java) val currentApiLevel = get.invoke( clazz, "ro.build.hw_emui_api_level", UNKNOWN_API_LEVEL ) as Int currentApiLevel.takeIf { it != UNKNOWN_API_LEVEL } } .onErrorComplete() private companion object { const val UNKNOWN_API_LEVEL = -1 }}
Для правильного выполнения проверок на безопасность
дополнительно нужно учесть, что состояние сервисов не должно
требовать обновления.
Итоговая реализация алгоритма, учитывающая тип операции, для
которой выбирается сервис, и определение версии EMUI устройства,
может выглядеть так:
sealed class MobileServiceEnvironment( val mobileServiceType: MobileServiceType) { abstract val isUpdateRequired: Boolean data class GoogleMobileServices( override val isUpdateRequired: Boolean ) : MobileServiceEnvironment(MobileServiceType.Google) data class HuaweiMobileServices( override val isUpdateRequired: Boolean, val emuiApiLevel: Int? ) : MobileServiceEnvironment(MobileServiceType.Huawei)}
class SelectMobileServiceType( private val mobileServicesRepository: MobileServicesRepository) { operator fun invoke( case: Case ): Maybe<MobileServiceType> = mobileServicesRepository .getAvailableServices() .map { excludeEnvironmentsByCase(case, it) } .flatMapMaybe { selectEnvironment(it) } .map { it.mobileServiceType } private fun excludeEnvironmentsByCase( case: Case, envs: Set<MobileServiceEnvironment> ): Iterable<MobileServiceEnvironment> = when (case) { Case.Push, Case.Map -> envs Case.Security -> envs.filter { !it.isUpdateRequired } } private fun selectEnvironment( envs: Iterable<MobileServiceEnvironment> ): Maybe<MobileServiceEnvironment> = Maybe .fromCallable { envs.firstOrNull { it is HuaweiMobileServices && (it.emuiApiLevel == null || it.emuiApiLevel >= 21) } ?: envs.firstOrNull { it is GoogleMobileServices } ?: envs.firstOrNull { it is HuaweiMobileServices } } enum class Case { Push, Map, Security }}
Поддержка Map Kit
После реализации алгоритма выбора сервисов в рантайме, алгоритм
добавления поддержки базового функционала карт выглядит
тривиально:
- Определить тип сервисов для отображения карт;
- Заинфлейтить соответствующий layout и работать с конкретной
реализацией карт.
Однако здесь есть одна особенность, о которой хочется рассказать.
Rx головного мозга позволяет практически куда угодно добавить любую
асинхронную операцию без риска переписать всё приложение, но
накладывает и свои ограничения. Например, в данном случае для
определения соответствующего лэйаута, скорее всего, потребуется
вызвать .blockingGet() где-нибудь на Main потоке, что совсем
нехорошо. Решить эту проблему можно, например, с помощью дочерних
фрагментов:
class MapFragment : Fragment(), OnGeoMapReadyCallback { override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) ViewModelProvider(this)[MapViewModel::class.java].apply { mobileServiceType.observe(viewLifecycleOwner, Observer { result -> val fragment = when (result.getOrNull()) { Google -> GoogleMapFragment.newInstance() Huawei -> HuaweiMapFragment.newInstance() else -> NoServicesMapFragment.newInstance() } replaceFragment(fragment) }) } } override fun onMapReady(geoMap: GeoMap) { geoMap.uiSettings.isZoomControlsEnabled = true }}
class GoogleMapFragment : Fragment(), OnMapReadyCallback { private var callback: OnGeoMapReadyCallback? = null override fun onAttach(context: Context) { super.onAttach(context) callback = parentFragment as? OnGeoMapReadyCallback } override fun onDetach() { super.onDetach() callback = null } override fun onMapReady(googleMap: GoogleMap?) { if (googleMap != null) { val geoMap = geoMapFactory.create(googleMap) callback?.onMapReady(geoMap) } }}
class HuaweiMapFragment : Fragment(), OnMapReadyCallback { private var callback: OnGeoMapReadyCallback? = null override fun onAttach(context: Context) { super.onAttach(context) callback = parentFragment as? OnGeoMapReadyCallback } override fun onDetach() { super.onDetach() callback = null } override fun onMapReady(huaweiMap: HuaweiMap?) { if (huaweiMap != null) { val geoMap = geoMapFactory.create(huaweiMap) callback?.onMapReady(geoMap) } }}
Теперь можно написать отдельную реализацию для работы с картой для
каждого отдельного фрагмента. Если потребуется реализовать
одинаковую логику, то можно поступить по знакомому алгоритму
подогнать работу с каждым типом карт под один интерфейс и передать
одну из реализаций этого интерфейса в родительский фрагмент, как
это сделано в MapFragment.onMapReady()
Что из этого вышло
В первые дни после релиза обновленной версии приложения число
установок достигло 1 млн. Мы связываем это отчасти с фичерингом со
стороны AppGallery, а отчасти с тем, что наш релиз подсветило
несколько СМИ и блогеров. А ещё со скоростью обновления приложений
ведь в AppGallery на протяжении двух недель лежала версия с самым
высоким versionCode.
Мы получаем полезные отзывы о работе приложения в общем и о
токенизации банковских карт в частности от пользователей в нашей
ветке на 4pda. После релиза Pay-функциональности для Huawei
посетителей на форуме прибавилось, и проблем, с которыми они
сталкиваются, тоже. Мы продолжаем работать над всеми обращениями,
но массовых проблем при этом не наблюдаем.
В целом, релиз приложения в AppGallery прошёл успешно и можно
сделать вывод, что наш подход к решению задачи оказался рабочим.
Благодаря выбранному методу реализации у нас сохранилась
возможность выкладывать все релизы приложения как в Google Play,
так и в AppGallery.
Пользуясь этим методом, мы уже добавили в приложение
Analytics Kit,
APM, работаем над поддержкой
Account Kit и не планируем на этом останавливаться, тем более,
что с каждой новой версией HMS становится доступно всё
больше
возможностей.
Послесловие
Регистрация аккаунта разработчика в AppGallery представляет собой
гораздо более сложную процедуру, чем в случае с Google. У меня,
например, этап проверки подтверждения личности занял 9 дней. Не
думаю что так происходит со всеми, но любая задержка способна
поубавить оптимизма. Поэтому вместе с полным кодом всего
демо-решения, описанного в статье, я закоммитил в репозиторий и все
ключи приложения, чтобы у вас была возможность не только оценить
решение целиком, но и прямо сейчас испытать и усовершенствовать
предложенный подход.
Пользуясь выходом в публичное пространство, хочу поблагодарить всю
команду Кошелька и особенно
umpteenthdev,
Артёма Кулакова и Егора Аганина за неоценимый вклад в интеграцию
HMS в Кошелёк!
Полезные ссылки
- Полный
код демонстрационного проекта на GitHub;
-
Скачать AppGallery на телефон любого производителя. Актуальную
версию приложения HMS-Core можно загрузить из AppGallery;
- Push Kit
codelab;
- Map Kit
codelab;
- Safety Detect
codelab;
-
Инструкция к сервису онлайн-дебаггинга своих приложений на
устройствах Huawei. Возможность использования появляется после
регистрации в
AppGallery Connect;
-
Ветка приложения Кошелёк на 4PDA.