В 2019 году Google выпустила In-app Updates — возможность обновлять Android-приложения без перехода в Google Play. Однако до сих пор довольно мало приложений поддерживают этот способ обновления.
Когда я внедрял In-app Updates в приложение Профи для специалистов — без сложностей не обошлось. Пришлось покопаться в документации, статьях и даже пару раз переписать реализацию.
Чтобы меньше людей наступали на мои грабли, я сделал пошаговую инструкцию по интеграции In-app Updates в Android-приложение на React Native. Если следовать ей — сможете внедрить эту опцию за день.
Оговорка
Уже разработали несколько библиотек, инкапсулирующих реализацию In-app Updates. Например, эту или эту. Моя статья для тех, кто хочет добавить интеграцию самостоятельно.
Что будем делать
Разберёмся, как тестировать эту фичу. Ведь она требует взаимодействия с Google Play.
Поддержим immediate-обновления.
Добавим поддержку flexible-обновлений.
Подготовка к тестированию
Нам придётся неоднократно тестировать код. Поэтому сперва разберёмся, как это делать.
Для проверки In-app обновлений Google разработала специальную тестовую среду, в которую можно загрузить apk-файл или bundle приложения и затем установить на устройство. Через неё же будем имитировать появление обновления.
Готовим устройство.
Устанавливать приложения из тестовой среды можно как на реальные устройства, так и на эмуляторы. В любом случае нужно настроить Google Play (названия кнопок на вашем устройстве могут отличаться, но суть одна):
Заходим в Google Play, кликаем на своё фото, выбираем «Настройки».
В разделе «Сведения» кликаем много раз подряд на «Версию Google Play», пока не станем разработчиком.
Включаем тогл «Внутренний доступ к приложениям» в разделе «Личные» или «Общее».
Собираем apk- или aab-файл приложения.
Для тестирования подойдёт обычная debug-сборка, без каких-либо подписей и js-бандлов. В Android Studio это Build -> Build Bundle(s) / APK(s) -> <предпочитаемый тип сборки>. Такое приложение после установки будет при запуске подключаться к js-бандлеру и загружать js-код.
Ещё к нему можно подключить дебаггер Android Studio, чтобы отлаживать нативный код. Для этого нужно нажать на «жучка со стрелочкой» в панели инструментов Android Studio (Attach debugger to Android Process) и выбрать запущенное приложение.
Уточню, что устройство должно быть подключено к компьютеру, а тип подключения должен разрешать обмен данными. Чтобы избежать проблем с подключением, советую пользоваться эмулятором.
Загружаем полученный файл в Internal App Sharing и указываем название версии.
Советую добавлять к названию инкрементируемое число, чтобы не путаться. Тестирование вряд ли ограничится двумя-тремя сборками.
Копируем ссылку на приложение, открываем на устройстве и устанавливаем.
Готовим обновление.
Чтобы имитировать обновление, нужно повторить шаги 2–4 со следующими нюансами:
номер сборки (versionCode) должен быть больше номера установленной сборки;
желательно добавить изменения, которые заметны сразу при запуске. Так проще понять, что приложение обновилось;
устанавливать сборку не нужно. Надо перейти по ссылке и попасть на экран с кнопкой «Обновить» или Update.
Если не использовать Internal App Sharing — обновление будет недоступно.
Поддержка immediate-обновлений
Immediate-обновления почти полностью реализует Google. Нужно только запросить проверку на наличие новой версии. Если она есть, Google покажет пользователю полноэкранный баннер, загрузит, установит обновление и перезапустит приложение.
Создадим нативный модуль с методом проверки наличия обновления.
(Здесь и далее код на Kotlin и JavaScript)
@ReactMethod
fun checkForAppUpdate() {
val appUpdateInfoTask = appUpdateManager.appUpdateInfo
appUpdateInfoTask.addOnSuccessListener { appUpdateInfo ->
if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE) {
try {
currentActivity?.let {
appUpdateManager.startUpdateFlowForResult(
appUpdateInfo,
AppUpdateType.IMMEDIATE,
it,
APP_UPDATE_REQUEST_CODE
)
}
} catch (e: IntentSender.SendIntentException) {
e.printStackTrace()
}
}
}
}
Проверяем наличие новой версии. Если она есть, то запускаем обновление. APP_UPDATE_REQUEST_CODE
— это числовая константа, определяющая наш запрос. С её помощью мы идентифицируем сигнал, если в процессе обновления произойдёт ошибка.
override fun onActivityResult(
activity: Activity?,
requestCode: Int,
resultCode: Int,
data: Intent?
) {
if (requestCode == APP_UPDATE_REQUEST_CODE) {
if (resultCode != Activity.RESULT_OK) {
// Сделать что-нибудь с ошибкой
}
}
}
Чтобы иметь возможность переопределить метод onActivityResult
в нативном модуле, нужно реализовать интерфейс ActivityEventListener
.
Позже добавим шлюз для передачи событий из натива в JS, чтобы в случае ошибки обновления можно было отправить событие и обрабатывать его в React Native.
Вызываем написанный метод из JS.
// AppUpdate — название нашего нативного модуля.
NativeModules.AppUpdate.checkForAppUpdate();
И вуаля — всё работает.
Поддержим установку обновления после сворачивания.
В процессе установки пользователь может свернуть приложение. Установка при этом должна продолжиться. А если пользователь вновь развернёт приложение, нужно убедиться, что процесс не остановился. Для этого добавим метод в нативном модуле:
@ReactMethod
fun resumeUpdate() {
appUpdateManager
.appUpdateInfo
.addOnSuccessListener { appUpdateInfo ->
try {
if (appUpdateInfo.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS) {
// If an in-app update is already running, resume the update.
currentActivity?.let {
appUpdateManager.startUpdateFlowForResult(
appUpdateInfo,
AppUpdateType.IMMEDIATE,
it,
APP_UPDATE_REQUEST_CODE
)
}
}
} catch (e: IntentSender.SendIntentException) {
e.printStackTrace()
}
}
}
Нужно вызывать его каждый раз, когда приложение становится активным. Для этого в React Native зарегистрируем слушателя изменений AppState
:
componentDidMount() {
AppState.addEventListener('change', this.handleAppStateChange);
}
componentWillUnmount() {
AppState.removeEventListener('change', this.handleAppStateChange);
}
handleAppStateChange = (nextAppState) => {
if (nextAppState === 'active') {
NativeModules.AppUpdate.resumeUpdate();
}
}
Поддержка flexible-обновлений
Процесс flexible-обновлений выглядит так:
Google Play предлагает пользователю обновить приложение;
если пользователь соглашается, начинается фоновая загрузка обновления;
после загрузки мы предлагаем установить обновление с полноэкранной заставкой от Google Play и перезапуском приложения в конце;
eсли пользователь отказывается установить обновление в моменте, предлагаем ему повторно. Google рекомендует делать это на каждое разворачивание приложения, но тут воля ваша. Кстати, если приложение будет в фоне на момент завершения загрузки, то установка обновления произойдёт автоматически.
В отличие от immediate-обновлений, здесь придётся написать чуть больше кода.
Модифицируем написанные ранее методы, чтобы при их вызове можно было указать требуемый тип обновления.
Достаточно добавить аргумент для типа обновления в нативные методы checkForAppUpdate
и resumeUpdate
.
@ReactMethod
fun checkForAppUpdate(type: String) {
val updateType = type.toUpdateType()
...
currentActivity?.let {
appUpdateManager.startUpdateFlowForResult(
appUpdateInfo,
updateType,
it,
APP_UPDATE_REQUEST_CODE
)
}
...
}
@ReactMethod
fun resumeUpdate(type: String) {
val updateType = type.toUpdateType()
...
currentActivity?.let {
appUpdateManager.startUpdateFlowForResult(
appUpdateInfo,
updateType,
it,
APP_UPDATE_REQUEST_CODE
)
}
...
}
private fun String.toUpdateType() =
if (toLowerCase(Locale.getDefault()) == "immediate")
AppUpdateType.IMMEDIATE
else
AppUpdateType.FLEXIBLE
Объявим слушателя для наблюдения за процессом.
Слушатель должен реализовывать интерфейс InstallStateUpdatedListener
.
private val appUpdatedListener: InstallStateUpdatedListener by lazy {
object : InstallStateUpdatedListener {
override fun onStateUpdate(installState: InstallState) {
// здесь пока пусто, но скоро мы это исправим
if (installState.installStatus() == InstallStatus.INSTALLED) {
appUpdateManager.unregisterListener(this)
}
}
}
}
Зарегистрируем его в методе checkForAppUpdate
перед вызовом startUpdateFlowForResult
:
if (updateType == AppUpdateType.FLEXIBLE)
appUpdateManager.registerListener(appUpdatedListener)
Добавим трансляцию процесса обновления на уровень JS.
Мы хотим следить за ходом обновления на стороне React Native, чтобы иметь возможность показать пользователю прогресс загрузки или предложить установить обновление, когда оно скачается. Для этого будем транслировать статус из натива, используя EventEmitter
.
Добавим в наш модуль:
private val eventEmitter: DeviceEventManagerModule.RCTDeviceEventEmitter by lazy {
context.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
}
...
private fun sendUpdateStatus(
updateStatus: Int,
bytesDownloaded: Long = -1,
totalBytesToDownload: Long = -1,
errorCode: Int = InstallErrorCode.NO_ERROR
) {
val bundle = Bundle().apply {
putInt(KEY_UPDATE_STATUS, updateStatus)
putLong(KEY_BYTES_DOWNLOADED, bytesDownloaded)
putLong(KET_TOTAL_BYTES_TO_DOWNLOAD, totalBytesToDownload)
putInt(KEY_ERROR_CODE, errorCode)
}
eventEmitter.emit(IN_APP_UPDATE_STATUS_EVENT, bundle.toMap())
}
...
companion object {
private const val IN_APP_UPDATE_STATUS_EVENT = "IN_APP_UPDATE_STATUS_EVENT"
private const val KEY_UPDATE_STATUS = "UPDATE_STATUS"
private const val KEY_BYTES_DOWNLOADED = "BYTES_DOWNLOADED"
private const val KET_TOTAL_BYTES_TO_DOWNLOAD = "TOTAL_BYTES_TO_DOWNLOAD"
private const val KEY_ERROR_CODE = "ERROR_CODE"
}
И вызовем новый метод в слушателе:
override fun onStateUpdate(installState: InstallState) {
sendUpdateStatus(
installState.installStatus(),
installState.bytesDownloaded(),
installState.totalBytesToDownload(),
installState.installErrorCode()
)
...
}
bundle.toMap()
bundle.toMap()
— extension-функция, конвертирующая Bundle
в WritableMap
, который можно передавать в React Native
fun Bundle.toMap(): WritableMap {
val map = Arguments.createMap()
keySet().forEach { key ->
when (val value = get(key)) {
null -> map.putNull(key)
is String -> map.putString(key, value)
is Boolean -> map.putBoolean(key, value)
is Int -> map.putInt(key, value)
is Long -> map.putDouble(key, value.toDouble())
is Double -> map.putDouble(key, value)
is Float -> map.putDouble(key, value.toDouble())
}
}
return map
}
Добавим слушателя событий на стороне JS.
В JS-коде подпишемся на сообщения перед вызовом checkForAppUpdate
:
DeviceEventEmitter.addListener(IN_APP_UPDATE_STATUS_EVENT, data => {
const updateStatusCode = data[KEY_UPDATE_STATUS];
const bytesDownloaded = data[KEY_BYTES_DOWNLOADED];
const totalBytesToDownload = data[KET_TOTAL_BYTES_TO_DOWNLOAD];
const errorCode = data[KEY_ERROR_CODE];
console.log(‘Update status’, {
updateStatusCode,
bytesDownloaded,
totalBytesToDownload,
errorCode
});
});
NativeModules.AppUpdate.checkForAppUpdate(updateType);
Использованные здесь константы дублируются из нативного кода.
Отлично! Теперь мы запрашиваем обновление из React Native и следим за его статусом. Но после успешной загрузки ничего не произойдёт. Нужно инициировать установку.
Предложим установить обновление.
Для этого добавим сообщение в слушателя событий в JS:
console.log(‘Update status’, {
updateStatusCode,
bytesDownloaded,
totalBytesToDownload,
errorCode
});
// для сокращения кода использую здесь числовую константу, но лучше дать ей осмысленное название
if (updateStatusCode === 11) {
showAlert({
title: 'Обновление готово к установке!',
message: 'Желаете установить?',
positiveButtonText: 'Установить',
onPressPositiveButton: completeUpdate,
});
}
Если пользователь согласится, нужно снова передать управление в натив и запустить установку обновления. Для этого добавляем и вызываем метод нативного модуля:
@ReactMethod
fun completeUpdate() {
appUpdateManager.completeUpdate()
}
Обработаем отказ от установки.
Как я писал выше, в случае отказа от установки нужно напоминать пользователю об обновлении. Проще всего это делать при каждом переходе в приложение (будь то запуск или разворачивание). Тем более что в предыдущем разделе мы уже использовали этот триггер для проверки состояния immediate-обновления. Давайте добавим в нативный метод resumeUpdate
проверку статуса обновления и, если оно остановится после загрузки, предложим обновиться:
@ReactMethod
fun resumeUpdate(type: String) {
...
appUpdateManager
.appUpdateInfo
.addOnSuccessListener { appUpdateInfo ->
if (appUpdateInfo.installStatus() == InstallStatus.DOWNLOADED) {
sendUpdateStatus(InstallStatus.DOWNLOADED)
}
...
}
}
Вот и всё. Обработка события на стороне React Native уже реализована, поэтому при получении статуса InstallStatus.DOWNLOADED
пользователь вновь увидит предложение установить загруженное обновление.
Закругляюсь
Мы поддержали In-app обновления приложения двух видов — immediate, когда весь процесс под контролем Google, и flexible, в котором мы сами решаем, как показать пользователю прогресс и показывать ли вообще. Теперь можете решить, какой вид обновления лучше подходит вашему приложению. А может, вы захотите использовать оба варианта. Как бы то ни было, вся конфигурация управляется на стороне React Native, и писать нативный код больше не придётся.
Я рассказал про минимальную функциональность In-app обновлений. Ещё есть возможность управлять частотой показа баннера и приоритетами обновлений. Это легко поддержать на базе написанного нативного модуля. Просто эти опции выходят за рамки статьи.
Если захотите поддержать обязательные обновления в своём приложении, потребуется немного доработать код. Google рекомендует при отказе пользователя в стандартных баннерах показывать ему информационное сообщение, после которого запускать процесс обновления повторно.
Я намеренно не стал усложнять код в статье. Но рекомендую все методы взаимодействия с нативным модулем из React Native вынести в отдельный сервис, чтобы инкапсулировать всю логику в одном месте.
Несмотря на обилие кода, поддержка In-app обновлений реализуется легко. Надеюсь, мне удалось это показать в статье, и вы решитесь добавить такую опцию в ваше приложение. Лично мне она кажется удобной.
Удачи вам! Надеюсь, был полезен.
artyomeg
В первом листинге ошибка - @ReactMathod вместо
@ReactMethod
sc_pro_ion Автор
Да, действительно. Спасибо, поправил! Интересно, как она просочилась, потому что я копировал куски реального кода)