В 2019 году Google выпустила In-app Updates — возможность обновлять Android-приложения без перехода в Google Play. Однако до сих пор довольно мало приложений поддерживают этот способ обновления.

Когда я внедрял In-app Updates в приложение Профи для специалистов — без сложностей не обошлось. Пришлось покопаться в документации, статьях и даже пару раз переписать реализацию.

Чтобы меньше людей наступали на мои грабли, я сделал пошаговую инструкцию по интеграции In-app Updates в Android-приложение на React Native. Если следовать ей — сможете внедрить эту опцию за день.

Оговорка

Уже разработали несколько библиотек, инкапсулирующих реализацию In-app Updates. Например, эту или эту. Моя статья для тех, кто хочет добавить интеграцию самостоятельно.

Что будем делать

  1. Разберёмся, как тестировать эту фичу. Ведь она требует взаимодействия с Google Play.

  2. Поддержим immediate-обновления.

  3. Добавим поддержку flexible-обновлений.

Подготовка к тестированию

Нам придётся неоднократно тестировать код. Поэтому сперва разберёмся, как это делать.

Для проверки In-app обновлений Google разработала специальную тестовую среду, в которую можно загрузить apk-файл или bundle приложения и затем установить на устройство. Через неё же будем имитировать появление обновления.

  1. Готовим устройство.

Устанавливать приложения из тестовой среды можно как на реальные устройства, так и на эмуляторы. В любом случае нужно настроить Google Play (названия кнопок на вашем устройстве могут отличаться, но суть одна):

  • Заходим в Google Play, кликаем на своё фото, выбираем «Настройки».

  • В разделе «Сведения» кликаем много раз подряд на «Версию Google Play», пока не станем разработчиком.

  • Включаем тогл «Внутренний доступ к приложениям» в разделе «Личные» или «Общее».

  1. Собираем apk- или aab-файл приложения.

Для тестирования подойдёт обычная debug-сборка, без каких-либо подписей и js-бандлов. В Android Studio это Build -> Build Bundle(s) / APK(s) -> <предпочитаемый тип сборки>. Такое приложение после установки будет при запуске подключаться к js-бандлеру и загружать js-код.

Ещё к нему можно подключить дебаггер Android Studio, чтобы отлаживать нативный код. Для этого нужно нажать на «жучка со стрелочкой» в панели инструментов Android Studio (Attach debugger to Android Process) и выбрать запущенное приложение.

Уточню, что устройство до��жно быть подключено к компьютеру, а тип подключения должен разрешать обмен данными. Чтобы избежать проблем с подключением, советую пользоваться эмулятором.

  1. Загружаем полученный файл в Internal App Sharing и указываем название версии.

Советую добавлять к названию инкрементируемое число, чтобы не путаться. Тестирование вряд ли ограничится двумя-тремя сборками.

  1. Копируем ссылку на приложение, открываем на устройстве и устанавливаем.

  1. Готовим обновление.

Чтобы имитировать обновление, нужно повторить шаги 2–4 со следующими нюансами:

  • номер сборки (versionCode) должен быть больше номера установленной сборки;

  • желательно добавить изменения, которые заметны сразу при запуске. Так проще понять, что приложение обновилось;

  • устанавливать сборку не нужно. Надо перейти по ссылке и попасть на экран с кнопкой «Обновить» или Update.

Если не использовать Internal App Sharing — обновление будет недоступно.

Поддержка immediate-обновлений

Immediate-обновления почти полностью реализует Google. Нужно только запросить проверку на наличие новой версии. Если она есть, Google покажет пользователю полноэкранный баннер, загрузит, установит обновление и перезапустит приложение.

  1. Создадим нативный модуль с методом проверки наличия обновления.

(Здесь и далее код на 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.

  1. Вызываем написанный метод из JS.

// AppUpdate — название нашего нативного модуля.
NativeModules.AppUpdate.checkForAppUpdate();

И вуаля — всё работает.

  1. Поддержим установку обновления после сворачивания.

В процессе установки пользователь может свернуть приложение. Установка при этом должна продолжиться. А если пользователь вновь развернёт приложение, нужно убедиться, что процесс не остановился. Для этого добавим метод в нативном модуле:

@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-обновлений, здесь придётся написать чуть больше кода.

  1. Модифицируем написанные ранее методы, чтобы при их вызове можно было указать требуемый тип обновления.

Достаточно добавить аргумент для типа обновления в нативные методы 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
  1. Объявим слушателя для наблюдения за процессом.

Слушатель должен реализовывать интерфейс 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)
  1. Добавим трансляцию процесса обновления на уровень 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
}
  1. Добавим слушателя событий на стороне 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 и следим за его статусом. Но после успешной загрузки ничего не произойдёт. Нужно инициировать установку.

  1. Предложим установить обновление.

Для этого добавим сообщение в слушателя событий в JS:

console.log(‘Update status’, {
  updateStatusCode,
  bytesDownloaded,
  totalBytesToDownload,
  errorCode
});
// для сокращения кода использую здесь числовую константу, но лучше дать ей осмысленное название
if (updateStatusCode === 11) {
  showAlert({
    title: 'Обновление готово к установке!',
    message: 'Желаете установить?',
    positiveButtonText: 'Установить',
    onPressPositiveButton: completeUpdate,
  });
}

Если пользователь согласится, нужно снова передать управление в натив и запустить установку обновления. Для этого добавляем и вызываем метод нативного модуля:

@ReactMethod
fun completeUpdate() {
  appUpdateManager.completeUpdate()
}
  1. Обработаем отказ от установки.

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

Удачи вам! Надеюсь, был полезен.