
В 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 обновлений реализуется легко. Надеюсь, мне удалось это показать в статье, и вы решитесь добавить такую опцию в ваше приложение. Лично мне она кажется удобной.
Удачи вам! Надеюсь, был полезен.