Привет, Хабр! Меня зовут Георгий Гигаури, я разрабатываю Android-приложение Delivery Club. Эта статья появилась после доклада на конференции Mobius 2020, где мы выступали вместе с Павлом Борзиковым. Для тех, кто любит видео, ищите его в конце статьи.
Почему мы вообще обратили внимание на Huawei-устройства? Всё началось с того, что Huawei теперь не может распространять свои устройства с сервисами Google Play. Да, они могут использовать ОС Android, так как это открытая операционная система, но чтобы распространять устройства с сервисами Google Play, необходимо иметь лицензию. К сожалению, Huawei не может получить её из-за разногласий между Китаем и США. Поэтому Huawei приходится разрабатывать свои собственные Mobile Services. Справедливости ради, они этим занимались уже давно, но теперь им приходится расширять кодовую базу, активно увеличивать количество сервисов.
Почему стоит обратить внимание на экосистему Huawei
Смартфоны Huawei очень популярны: в 2020 году в России они занимали почти 18% рынка (Рис.1), а в мире 11% (Рис.2), (источник). Huawei заявила, что более 490 млн человек в более чем в 170 странах мира пользуются AppGallery (источник). Поскольку аудитория у Huawei-устройств огромная, мы не можем это игнорировать и решили поддержать пользователей нашего приложения. Далее поэтапно рассмотрим, что же нужно сделать.
Рис.1
Рис.2
Этап 1: проверка наличия Services
Если у вас в приложении при входе есть проверка наличия Google Services, то придётся от этого отказаться, и проверять наличие соответствующих сервисов только по мере необходимости.
fun Context.getMobileServiceSource(): MobileServicesSource { val googleApi = GoogleApiAvailability.getInstance() if (googleApi.isGooglePlayServicesAvailable(this) == com.google.android.gms.common.ConnectionResult.SUCCESS) { return MobileServicesSource.GOOGLE } val huaweiApi = HuaweiApiAvailability.getInstance() if (huaweiApi.isHuaweiMobileServicesAvailable(this) == com.huawei.hms.api.ConnectionResult.SUCCESS) { return MobileServicesSource.HMS } return MobileServicesSource.NONE}enum class MobileServicesSource { GOOGLE, HMS, NONE}
Пример реализации: добавляем extension, возвращающий конкретный доступный сервис, а в момент использования функциональности проверяем, что на устройстве есть нужные нам сервисы.
Этап 2: карты
В приложении Delivery Club три основные страницы:
- карта с местоположением клиента;
- информация о ресторане, который будет готовить заказ, с указанием его местоположения на карте;
- карта с отслеживанием местоположения курьера, везущего заказ.
На устройствах Huawei все эти карты не работают. Чтобы это исправить, можно просто заменить зависимости: вместо пакета
com.google.android.gms
использовать
com.huawei.hms
:Конечно, есть нюансы, но мы уже сделали большую часть работы. Huawei сделала Maps SDK с контрактами, по большей части соответствующий Google Maps SDK. Однако у Google есть deprecated-методы, если вы их используете, то аналогов у Huawei может и не найтись. Например, для получения местоположения пользователя мы используем:
LocationServices.FusedLocationApi.getLastLocation(googleApiClien)
Такой подход считается deprecated, и если мы просто скопируем код для Huawei Maps и заменим зависимости, то работать не будет. Нужно поменять так:
LocationServices.getFusedLocationProviderClient().getLastLocation().addOnSuccessListener()
PolyUtil. Расшифровка с помощью Polyline
Теперь нам нужно отображать перемещение курьера. Для этого нам нужно расшифровывать строку, которая приходит с сервера. Для этого воспользуемся тем же алгоритмом от Google, с помощью которого строка кодировалась.
После расшифровки мы получили список координат курьера.
Реализация поддержки двух карт
Для поддержки нескольких карт необходимо создать обёртку для самих карт и для объекта.
Добавляем общий интерфейс, например,
IMapWidget
. Не
забываем сделать общий класс для LatLng
список
координат курьера. У Google он лежит в пакете
com.google.android.gms.maps.model.LatLng
, а у Huawei в
com.huawei.hms.maps.model.LatLng
. Кладём список в
PolyLineOptions
и задаём ширину и цвет линии
маршрута.
interface IMapWidget { void animateCamera(...); void setListener(OnMapEventListener listener); void setMapPadding(...); MapMarker addMarker(...); ...}
Добавляем Custom Map View реализующего интерфейс
IMapWidget
:Добавляем обёртку, которая позволяет нам указать, где мы хотим отрисовывать карту:
class MapWrapper : FrameLayout() { fun setupMap(widget: IMapWidget) { removeAllViews() addView(widget as View) }}
И в нужном месте вызываем метод добавления карты.
override fun onCreateView(...) { ... val map: IMapWidget = MapFactory.createMap() viewMapWrapper.setupMap(map) ...}
Такие обёртки класса нужно создать для всего: объектов, маркеров,
PolyUtil
, PolyLine
и т.д.Проблема: Карта не работает
Однажды нам сообщили о баге. Пользователь с устройством Huawei, находившийся в центре Москвы (Рис.3), открыл приложение, нажал на кнопку Переместиться на своё местоположение, и его перенесло в пустоту (Рис.4). Пользователь не видит, ни улиц, ни зданий, и он решил, что карта не работает.
Мы попробовали воспроизвести у себя эту проблему. И действительно попадали в неопределённое пространство. Когда попробовали чуть-чуть уменьшить масштаб карты, то оказалось, что мы попали в пригород Мариуполя (Рис.5). То есть из московских координат (55.819207, 37.493424) перенеслись в мариупольские (47.187447, 37.593137). Мы были в полном недоумении. Может быть, где-то у нас с числами что-то не то происходит. Возможно, происходят некие вычитания наших координат. Очень долго искали решение этой проблемы или хотя бы причину. Оказалось, что мы заменили импорты из Google-карт, и поэтому всё перестало работать. В конце концов мы добрались до
padding
а.Давайте быстро вспомним, что такое padding у карты. На (Рис.6) показан экран авторизации, карта занимает всю область экрана, даже под плашкой ручного ввода адреса. В таком случае, если мы не добавим
padding
карте, её центр будет находиться на
месте зелёного треугольника, но мы хотим, чтобы он был в центре
рабочей области карты. Padding
сужает рабочую область
(Рис.7). Не видимую, а именно рабочую. Карта будет по-прежнему
занимать весь экран, но размер её рабочей области изменится. И
когда вы будете переходить в новую координату, она будет принимать
положение новой рабочей карты. Как оказалось, баг был именно из-за
этого.Первое решение: убрать
padding
. Как вы
понимаете, такой вариант нам не подошёл. Мы хотели, чтобы всё
отображалось красиво.Второе решение проблемы: использовать анимированное перемещение, но с масштабированием.
val zoom = map.cameraPosition.zoommap.animateCamera(CameraUpdateFactory.newLatLngZoom(position, zoom))
При переходе с изменением масштаба карты всё работало правильно. Здорово! Мы подумали, что это нам подходит. На самом деле нет. У нас ещё есть третий экран, на котором нужно увеличивать карту относительно двух маркеров, чтобы
zoom
сам
рассчитывался, поэтому мы не можем задать какое-то константное
масштабирование. То есть такой вариант нам тоже не подошёл. Начали
думать дальше и нашли новое решение.Третье решение проблемы: вообще отказаться от анимации. Как оказалось, если вместо
animateCamera
сделать просто
move
, то перемещение будет происходить правильно. Так
мы и сделали. Надеемся, в скором времени Huawei устранит эту
проблему.Этап 3: push-сервис
Идём дальше. На Huawei-устройства не приходят уведомления нашего приложения. Дело в том, что мы не можем получить токен. Давайте его получим. В Google мы получаем задачу и извлекаем токены так:
FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> if (task.isSuccessful) { val token = task.result }}
Наше решение:
class ImplementationHuaweiMessagingService : HmsMessageService() { override fun onNewToken(token: String?) { val commonApi = getComponentFactory().get(CommonApi::class.java) commonApi.settingsManager().setPushToken(token) } override fun onMessageReceived(message: RemoteMessage?) { message?.let { val appManagersComponent = getComponentFactory().get(AppManagersApi::class.java) appManagersComponent.pushManager().handle(it.dataOfMap) } }
Выглядит всё так же, как и с реализацией
FirebaseMessagingService()
, даже есть
callback
и onNewToken
и
onMessageReceived
. Однако без нюансов не обойтись.
Случается, что на некоторых редких устройствах
onMessageReceived
вызывается в главном потоке, поэтому
лучше не использовать здесь долго выполняющиеся задачи.Получаем токены на Huawei:
val token = HmsInstanceId.getInstance(context) .getToken(appId, com.huawei.hms.push.HmsMessaging.DEFAULT_TOKEN_SCOPE)public static final String DEFAULT_TOKEN_SCOPE = "HCM";
Обратите внимание, что метод выполняется в главном потоке. И для получения токена нужно отдельно реализовать поток. У Google такой подход уже считается устаревшим, возможно, Huawei придёт к тому же.
Мы можем вообще не использовать
getToken
, а прописать
в манифесте автоматическую инициализацию или в коде методом
setAutoInitEnabled()
и всегда получать token в
onNewToken
(подробнее). Это решит ещё одну проблему:
getToken
в версиях EMUI ниже 10 вообще возвращает
null
.
<meta-data android:name="push_kit_auto_init_enabled" android:value="true"/>
Этап 4: Chrome Custom Tabs
Наше приложение при запуске регулярно вылетает с ошибкой
ActivityNotFoundException
. Чтобы от этого избавиться,
нужно обработать отсутствие Chrome Tabs.
fun Context.openLink(url: String, customTabsSession: CustomTabsSession? = null): Boolean { try { openLinkInCustomTab(url, customTabsSession) return true } catch (throwable: Throwable) { Timber.tag("Context::openLink").e(throwable, "CustomTabsIntent error on url: $url") } return openLinkInBrowser(url)}@Throws(Throwable::class)fun Context.openLinkInCustomTab(url: String, customTabsSession: CustomTabsSession? = null) { CustomTabsIntent.Builder(customTabsSession) .build() .launchUrl(this, Uri.parse(url))}private fun Context.openLinkInBrowser(url: String): Boolean { val intent: Intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY or Intent.FLAG_ACTIVITY_NEW_DOCUMENT) } if (intent.resolveActivity(packageManager) != null) { startActivity(intent) return true } return false}
Мы просто обернули
openLinkInCustomTab()
в try
catch
и в случае ошибки пытаемся открыть в браузере. Но
бывает такого, чтобы на устройстве не было подходящего браузера,
способного обработать наш неявный intent
. Поэтому если
метод openLinkInBrowser()
возвращает
false
, мы открываем страницу в
webview
.Этап 5: аналитика
Аналитика у Huawei похожа на Google Analytics. Покажу замену на примере Firebase. Сначала инициализируем:
HiAnalytics.getInstance(context)
. Затем с помощью
HAEventType.STARTCHECKOUT
копируем все наши события из
Firebase в отдельный файл huaweiAnalytics
:
huaweiAnalytics.onEvent(name, bundle)
Системные параметры:
HAParamType.PRICE
,
HAParamType.CURRNAME
Даже если у вас нет Firebase, добавить аналитику в Huawei очень просто. У них отличная документация, контракт соблюдается. Также у Huawei есть отличные инструменты для исследования аудитории.
Этап 6: crashlytics
Следующий инструмент, который нам тоже стало интересно попробовать, это Crashlytics от Huawei, которая называется
AGConnectCrash
. Она позволяет с минимальными усилиями
собирать и анализировать информацию о падении приложения.Инициализируем crashlytics:
AGConnectCrash.getInstance().enableCrashCollection(true)
Добавляем свои ключи и журналируем нужные события:
AGConnectCrash.getInstance().setUserId("testuser")AGConnectCrash.getInstance().log(Log.DEBUG, "set debug log.")AGConnectCrash.getInstance().log(Log.INFO, "set info log.")AGConnectCrash.getInstance().log(Log.WARN, "set warning log.")AGConnectCrash.getInstance().log(Log.ERROR, "set error log.")AGConnectCrash.getInstance().setCustomKey("stringKey", "Hello world")AGConnectCrash.getInstance().setCustomKey("booleanKey", false)AGConnectCrash.getInstance().setCustomKey("doubleKey", 1.1)AGConnectCrash.getInstance().setCustomKey("floatKey", 1.1f)AGConnectCrash.getInstance().setCustomKey("intKey", 0)AGConnectCrash.getInstance().setCustomKey("longKey", 11L)
Этап 7: покупки в приложении
Если в вашем приложении есть встроенные покупки, вы должны подписать согласие на передачу персональных данных, и отослать письмом в конверте или через курьера в московский офис Huawei. И только через пару дней они вам дадут доступ для реализации этой функциональности.
Всё очень похоже на реализацию Google. При запуске приложения запрашиваем все прошлые покупки пользователя:
fun getOwnedPurchases( activity: Activity, ownedPurchasesResultOnSuccessListener: OnSuccessListener<OwnedPurchasesResult>, failureListener: OnFailureListener) { val ownedPurchasesReq = OwnedPurchasesReq() // priceType: 0: consumable; 1: non-consumable; 2: auto-renewable subscription ownedPurchasesReq.priceType = IapClient.PriceType.IN_APP_SUBSCRIPTION // To get the Activity instance that calls this API. val task: Task<OwnedPurchasesResult> = Iap.getIapClient(activity) .obtainOwnedPurchases(ownedPurchasesReq) task.addOnSuccessListener(ownedPurchasesResultOnSuccessListener) .addOnFailureListener(failureListener)}
Если какой-то товар был куплен, мы разблокируем его функциональность. Потом запрашиваем подробности по товарам, доступным для продажи цену и описание:
fun loadProduct( context: Context, productInfoResultOnSuccessListener: OnSuccessListener<ProductInfoResult>, onFailureListener: OnFailureListener) { // obtain in-app product details configured in AppGallery Connect, and then show the products val iapClient: IapClient = Iap.getIapClient(context) val task: Task<ProductInfoResult> = iapClient.obtainProductInfo(createProductInfoReq()) task.addOnSuccessListener(productInfoResultOnSuccessListener) .addOnFailureListener(onFailureListener)}private fun createProductInfoReq(): ProductInfoReq { val req = ProductInfoReq() // 0: consumable ; 1: non-consumable ; 2: auto-renewable subscription req.priceType = IapClient.PriceType.IN_APP_SUBSCRIPTION val productIds = ArrayList<String>() productIds.add("PRODUCT_ID") req.productIds = productIds return req}
Когда пользователь кликает на товар, мы открываем страницу с оплатой. Она не такая красивая, как у Google, и не выезжает снизу.
fun gotoPay(activity: Activity, productId: String, type: Int) { val client: IapClient = Iap.getIapClient(activity) val task: Task<PurchaseIntentResult> = client.createPurchaseIntent(createPurchaseIntentReq(type, productId)) task.addOnSuccessListener { result -> result?.let { val status: Status = result.status if (status.hasResolution()) { try { status.startResolutionForResult(activity, PAY_RESULT_ARG) } catch (exception: SendIntentException) { Timber.e(exception) } } else { Timber.d("intent is null") } } }.addOnFailureListener { exception -> Timber.e(exception) }}
Так как это Activity, мы передаём ему аргумент, по которому можно отловить
OnActivityResult
и понять, успешно ли прошла
оплата и как закончилась транзакция:
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (resultCode == PAY_RESULT_ARG) { val purchaseResultInfo: PurchaseResultInfo = Iap.getIapClient(this).parsePurchaseResultInfoFromIntent(data) when (purchaseResultInfo.returnCode) { OrderStatusCode.ORDER_STATE_SUCCESS -> { successResult(purchaseResultInfo) } OrderStatusCode.ORDER_STATE_CANCEL -> { } OrderStatusCode.ORDER_PRODUCT_OWNED -> { } } }}
У нас есть специальные статусы:
ORDER_SUCCESS
,
CANCEL
, OWNED
. Первый означает успешную
оплату. Второй пользователь просто закрыл страницу без покупки,
тогда мы обрабатываем этот callback и предлагаем скидку, чтобы
уговорить на покупку. А третий статус означает, что товар уже
куплен пользователем. Если товар разовый или подписочный, то на
этом моменте нужно остановиться, в противном случае виртуально
доставить покупку.В случае успешной оплаты доставляем пользователю купленный товар:
private fun successResult(purchaseResultInfo: PurchaseResultInfo) { val inAppPurchaseData = InAppPurchaseData(purchaseResultInfo.inAppPurchaseData) val req = ConsumeOwnedPurchaseReq() req.purchaseToken = inAppPurchaseData.purchaseToken val client: IapClient = Iap.getIapClient(this) val task: Task<ConsumeOwnedPurchaseResult> = client.consumeOwnedPurchase(req) task.addOnSuccessListener { // Consume success }.addOnFailureListener { exception -> Timber.e(exception) }}
Если не сделать доставку, то функциональность товара будет у пользователя заблокирована, а деньги возвращены. В Google Play Billing Library до третьей версии этого делать не нужно было, но потом Google тоже это добавил, и если мы не доставим товар, через 48 часов покупка отменится, а деньги вернутся пользователю. То есть в Huawei покупки реализованы как в третьей версии Google Play Billing.
Выводы
На реализацию поддержки Huawei-устройств не уйдёт много времени. Даже без реальных устройств вы сможете проверить работоспособность вашего приложение: у Huawei есть своя тестовая лаборатория с виртуальными устройствами наподобие Samsung Remote test lab. Количество пользователей быстро растёт, и бизнесу может оказаться выгодным вложиться в доработку продуктов, а отличная документация поможет разработчикам всё сделать быстро. Поддержка HMS активно отвечает на любые вопросы, если вы не сможете в документации что-то найти.
Видеозапись доклада с конференции Mobius 2020.
Полезные ссылки
- In-App Purchases: developer.huawei.com/consumer/en/hms/huawei-iap
- Huawei HMS Core: developer.huawei.com/consumer/en/doc/overview/HMS-4-0
- Руководство по интеграции библиотек: developer.huawei.com/consumer/en/codelabsPortal