Как стать автором
Поиск
Написать публикацию
Обновить
m2_tech
Строим лучшую PropTech-компанию в России

Технический гайд по сторис ч.2: багфиксы, оптимизация, новые фичи и +350% к переходам

Уровень сложностиСредний
Время на прочтение28 мин
Количество просмотров420

Привет! Меня зовут Владислав Фальзан, я работаю android-разработчиком в М2. Наша команда мобильной разработки развивает приложение — онлайн-платформу для решения вопросов с недвижимостью. Основные пользователи приложения — физические лица (B2C) и риелторы (B2B2C). Эта статья — продолжение технического гайда для android-разработчиков, которые хотят реализовать и внедрить полный цикл сторис у себя в приложении с использованием: Compose, MVVM, Coroutines flow и правил чистой архитектуры.

Почему решил написать статью

Эта статья является продолжением первой части и состоит из внедрения доработок/исправления ошибок предыдущей итерации. Она будет актуальна тем, кого интересуют детальное погружение в фичу: модернизация, расширение функционала, работа над ошибками. Если вы хотите понимать, как эта фича появилась, то советую прочитать первую часть перед тем, как начать эту. Также участки кода, которые не изменились, будут опускаться в этой статье, чтобы не перегружать ее. Вместо этого я буду делать референсы на участки первой части, чтобы было удобно сравнивать.

Эволюция сторис: баги, рефакторинг и новые фичи 

Итак, сторис — это эффективный инструмент для коммуникации с пользователями, их вовлечением в продукты. Через сторис пользователь охотнее переходит дальше: количество открытий выше, чем, например, через email-оповещение. Так как гипотеза о том, что сторис принесут большую конверсию, подтвердилась, то мы решили развивать эту фичу. 

Мы увеличили количество сторис, а также поработали над качеством: пофиксили баги, провели рефакторинг кода. Например, пофиксили поведение сторис при свайпе, и теперь оно между всеми сторис одинаковое. Раньше экран закрывался по достижению свайпа в половину экрана. Добавили логику просмотренных слайдов как в известной запрещенной соцсети, усложнили UI и сделали его более гибким к настройкам извне: добавили параметров для отображения картинки на экране, вторую кнопку с действием, научились открывать bottom sheet и при этом ставить сторис на паузу& Сделали удаление старых неиспользуемых сторис, и даже вынесли создание/редактирование сторис на бекенд (если быть точным, в ремоут конфиг) вместо хардкода внутри приложения, ну и просто сделали код проще и понятнее, где это возможно.

Для удобства изучения статьи я решил разбить ее на блоки:

  1. Почему решил написать статью

  2. Эволюция сторис: баги, рефакторинг и новые фичи

  3. Удобная навигация в сторис: принципы и реализация

  4. Логика сторис: модель, репозиторий и работа с памятью

  5. Управление границами сторис: решение с фейковыми элементами

  6. Гибкость сторис: как админка заменила бекенд

  7. Доработка сторис: UI и синхронизация с админкой       

  8. Что в итоге

Удобная навигация в сторис: принципы и реализация  

Мы решили реализовать систему просмотра слайдов в сторис для улучшения пользовательского опыта. Не стали придумывать ничего нового, просто взяли идею у известной запрещенной соцсети. Данный механизм навигации проверен временем, удобен и понятен пользователю. Какие там принципы:

  • если сторис не просмотрена:

    • рамка должна быть отрисована;

    • при нажатии нужно показать 1-ый слайд;

  • если сторис просмотрена частично:

    • рамка должна быть отрисована;

    • при нажатии нужно показать последний непросмотренный слайд;

  • если сторис просмотрена полностью:

    • рамка должна исчезнуть;

    • при нажатии нужно всегда показывать 1-ый слайд;

  • Непросмотренные сторис должны быть в начале списка, просмотренные — в конце.

Логика сторис: модель, репозиторий и работа с памятью  

Начнем с domain-слоя, а точнее, с модели:

data class ShownStories(
   val storiesId: String,
   val maxShownSlideIndex: Int,
   val shown: Boolean
) : Serializable {
   companion object {


       private const val serialVersionUID: Long = 4535840374138443524L
   }
}

Ранее мы хранили только id сторис для отслеживания, просмотрена она или нет. Теперь нам необходимо знать id отслеживаемой сторис, последний просмотренный слайд (слайд считается просмотренным, когда начали его воспроизводить), а также флаг того, что сторис просмотрена. Класс реализует интерфейс Serializable для хранения в памяти — ниже объясню почему.

Теперь посмотрим на репозиторий:

Код репозитория
private const val STORIES_SHOWN_KEY = "stories_shown_key_"


class StoriesShownRepositoryImpl(
   private val keyValueStorage: KeyValueStorage
) : StoriesShownRepository {


   private val shownStoriesMap = mutableMapOf<String, MutableStateFlow<Set<ShownStories>>>()


   override suspend fun set(userId: String, shownStories: ShownStories) {
       if (userId.isEmpty()) return
       val set = getShownStories(userId).toMutableSet()
       set.removeIf { it.storiesId == shownStories.storiesId }
       set.add(shownStories)
       keyValueStorage.putSerializableSet(STORIES_SHOWN_KEY + userId, set)
       getFlow(userId).emit(set)
   }


   override fun observe(userId: String): Flow<Set<ShownStories>> =
       if (userId.isEmpty()) {
           flowOf(emptySet())
       } else {
           getFlow(userId).asStateFlow()
       }


   override fun get(userId: String): Set<ShownStories> =
       if (userId.isEmpty()) {
           emptySet()
       } else {
           getFlow(userId).value
       }


   override fun actualize(userId: String, storiesIds: Set<String>) {
       if (userId.isEmpty()) return
       val current = getShownStories(userId)
       val actual = current.filter { storiesIds.contains(it.storiesId) }.toSet()
       if (actual.isEmpty()) return
       keyValueStorage.putSerializableSet(STORIES_SHOWN_KEY + userId, actual)
   }


   private fun getFlow(userId: String): MutableStateFlow<Set<ShownStories>> =
       shownStoriesMap[userId] ?: createFlow(userId)


   private fun createFlow(userId: String): MutableStateFlow<Set<ShownStories>> {
       val showStories = getShownStories(userId)
       return MutableStateFlow(showStories).also { shownStoriesMap[userId] = it }
   }


   private fun getShownStories(userId: String): Set<ShownStories> =
       runCatching {
           keyValueStorage.getSerializableSet(STORIES_SHOWN_KEY + userId)
               .map { it as ShownStories }
               .toSet()
       }.getOrDefault(emptySet())
}

Давайте сразу условимся: хранение этой информации в префсах — не самое удачное решение. При изменении модели (миграции) прошлые данные будут потеряны. Базы данных и времени для ее проработки у нас в приложении не было, как и у бекенда ресурсов заниматься отдельной ручкой. Поэтому пришлось работать с подручными средствами. Однако ничто не мешает в будущем просто поменять один репозиторий на другой, с уже правильной реализацией хранения данных. А пока что могу сослаться на известный мем:

Класс не сильно изменился со времен первой итерации (раньше было так) — просто теперь работа ведется не просто с id сторис (String), а с полноценным классом ShownStories. Внутри KeyValueStorage набор ShownStories (точнее, Serializable) конвертируются в строки, и в префсы попадает уже Set<String> по заданному ключу. За уникальность взяли идентификатор пользователя, чтобы у разных юзеров был свой прогресс. 

Также добавилась функция actualize. Она нужна для удаления старых неактуальных сторис, находящихся в памяти. На вход передаются id пользователя и список id актуальных сторис. Далее мы получаем список ShownStories из памяти и исключаем те, что отсутствуют в списке актуальных id. Полученный список сохраняется в память.

Заглянем в самое сердце получения сторис — это юзкейс:

Код юзкейса
@Suppress("unused")
private val REGISTRATION_DATE_THRESHOLD = Date(year = 2024, month = 6, dayOfMonth = 24)


class ObserveStoriesUseCaseImpl(
   private val storiesRepository: StoriesRepository,
   private val storiesShownRepository: StoriesShownRepository,
   private val abTestsRepository: ABTestsRepository,
   private val userRepository: UserRepository,
   @Suppress("unused") private val personalDataRepository: UserPersonalDataRepository
) : ObserveStoriesUseCase {


   @OptIn(ExperimentalCoroutinesApi::class)
   override operator fun invoke(): Flow<List<Stories>> =
       userRepository.observeUser()
           .flatMapConcat { user ->
               val stories = getStories(user)


               val actualStoriesIds = stories.map { it.id }.toSet()
               storiesShownRepository.actualize(user.id, actualStoriesIds)


               storiesShownRepository.observe(user.id)
                   .map { shownStories ->
                       stories
                           .map { story ->
                               story.copy(
                                   shown = shownStories.any {
                                       it.storiesId == story.id && it.shown
                                   }
                               )
                           }
                           .sortedBy { it.shown }
                           .toList()
                   }
                   .catch {
                       emit(emptyList())
                   }
           }


   private suspend fun getStories(user: User): List<Stories> =
       runCatching {
           val userRole = user.role.roleType
           val stories = storiesRepository.get()
               .filter { story -> story.visible }
               .filter { story ->
                   story.role.any { it.uppercase() == userRole.name }
               }


           if (userRole == RoleType.PROFESSIONAL) {
               val inExperiment =
                   abTestsRepository.getBoolean(
                       ABTest.MOB8916AmbassadorStories.feature,
                       ABTest.MOB8916AmbassadorStories.defaultValue
                   ).value
               stories.toMutableList().run {
                   removeIf {
                       it.id == if (inExperiment) {
                           AMBASSADOR_INTERVIEW_A_ID_VALUE
                       } else {
                           AMBASSADOR_INTERVIEW_B_ID_VALUE
                       }
                   }
                   this
               }
           } else {
               stories
           }


           // Стори с промо скрыта в рамках MOB-8789. Удалять не надо, в будущем вернем.
           /*if (userRole.isRealtorOrProfessional()) {
                       uiStories.toMutableList().run {
                           val registrationDate = withContext(dispatchers.io()) {
                               try {
                                   getUserPersonalDataUseCase().registrationDate
                               } catch (expected: Exception) {
                                   logger.error(expected)
                                   Date()
                               }
                           }
                           if (userRole != RoleType.PROFESSIONAL || registrationDate <= REGISTRATION_DATE_THRESHOLD) {
                               removeIf { it.id == UiStoriesId.PROMO_STORIES_ID }
                           }
                           this
                       }
                   }*/
       }.getOrElse {
           logger.error(it)
           emptyList()
       }
}

Во-первых, мы перенесли бизнес-логику по получению сторис из вью модели витрины в место, где ей положено быть, — в юзкейс, про структуру entity мы поговорим ниже.

Здесь мы получаем информацию о пользователе, далее сторис, затем актуализируем информацию о сторис в префсах и следим за обновлениями о просмотренных сторис. Из того, что поменялось, — теперь мы смотрим, какие сторис необходимо отображать. Это происходит через новый параметр доменной модели visible, через A/B-тест для сторис с амбассадорами. Метод AB-тестирования - исследование, где пользователей делят на 2 группы с целью понять, какой из решений является более эффективным. Целевой метрикой было исследовать конверсию из открытия сторис в нажатие на любую кнопку. 

Еще фильтруем список сторис в зависимости от роли пользователя — физлица или риелторы. Также есть временно закомментированный код (но дополнительный пример работы с бизнес-логикой) — если пользователь не риэлтор или дата регистрации юзера раньше, чем 24 июня 24 года (так бизнес решил ограничить количество людей для промокода), то не показываем сторис с промокодом. При очередном эмите ShownStories обновляем информацию, является ли каждая из имеющихся сторис просмотренной, — это так, если ее id сторис есть в списке ShownStories и параметр shown = true. Также мы сортируем сторис по принципу: непросмотренные в начале, просмотренные — в конце.


Таким образом, вызов данного юзкейса будет очень простым:

private fun observeShownStories() {
   observeStoriesUseCase()
       .flowOn(dispatchers.io())
       .onEach { stories ->
           setState {
               stories(stories.map { it.map() })
           }
       }
       .launchSerially(OBSERVE_SHOWN_STORIES_JOB_TAG)
}

Поговорим о вью модели экрана сторис:

Код вью модели
class StoriesViewModel(
   stories: List<UiStories>,
   storiesId: UiStoriesId,
   storiesConfig: StoriesConfig,
   private val getShownStoriesUseCase: GetShownStoriesUseCase,
   private val setStoriesShownUseCase: SetStoriesShownUseCase,
   private val router: Router,
   private val oneClickDealScreenFactory: OneClickDealScreenFactory,
   private val analytics: Analytics,
   private val aboutFindProfScreenFactory: AboutFindProfScreenFactory,
   private val dealProtectionScreenFactory: DealProtectionScreenFactory,
   private val walletScreenFactory: WalletScreenFactory,
   private val getUserUseCase: GetUserUseCase,
   private val ambassadorRequestScreenFactory: AmbassadorRequestScreenFactory
) : StatefulViewModel<StoriesState>(
   StoriesState.initial(
       durationInSec = storiesConfig.getDuration().toInt(),
       stories = stories,
       storiesId = storiesId
   )
) {
   init {
       launch(
           block = {
               val user = withContext(dispatchers.io()) {
                   getUserUseCase.invoke()
               }
               val shownStories = withContext(dispatchers.io()) {
                   getShownStoriesUseCase(user.id)
               }


               setState {
                   shownStories(shownStories)
                       .user(user)
                       .also { state ->
                           analytics.send(
                               CoreMainStoriesTap(
                                   id = state.currentStories.id.value,
                                   page = state.currentSlideIndex + 1
                               )
                           )
                       }
               }
           },
           onError = {
               logger.error(it)
               setState { shownStories(emptySet()) }
           }
       )
   }

...

private fun setStoriesShown(page: Int) {
   val stories = stateFlow.value.stories[page]
   launch(
       block = {
           with(stories) {
               val shownStories = withContext(dispatchers.io()) {
                   getShownStoriesUseCase(stateFlow.value.user.id)
               }
               val currentSlideIndex =
                   slides.indexOfFirst { it.current }
               val shownStory =
                   shownStories.find { it.storiesId == id }
               // выбираем последний просмотренный слайд - необходимо для
               // актуализации рамки на превью и при повторном просмотре
               // если сторис не просмотрен ни один слайд (shownStory == null) -
               // 1ый слайд
               // если сторис текущий слайд больше последнего просмотренного ранее -
               // текущий слайд
               // если сторис текущий слайд меньше последнего просмотренного ранее -
               // последний просмотренный, тк все слайды могут быть просмотрены,
               // но пользователь переключится на предыдущие и выйдет
               val maxShownSlideIndex =
                   when {
                       shownStory == null -> 0
                       currentSlideIndex > shownStory.maxShownSlideIndex -> currentSlideIndex
                       else -> shownStory.maxShownSlideIndex
                   }


               withContext(dispatchers.io()) {
                   setStoriesShownUseCase(
                       userId = stateFlow.value.user.id,
                       shownStories = ShownStories(
                           storiesId = id,
                           maxShownSlideIndex = maxShownSlideIndex,
                           // сторис просмотрена, если она ранее уже была просмотрена
                           // или последний просмотренный слайд ==
                           // последнему слайду в сторис
                           shown =
                               shownStory?.shown == true ||
                                   maxShownSlideIndex == slides.lastIndex
                       ),
                   )
               }
           }
       },
       onError = {
           logger.error(it)
       }
   )
}

fun setResumed() {
   with(stateFlow.value) {
       val validStates = listOf(
           UiSlide.ProgressState.START,
           UiSlide.ProgressState.PAUSE
       )
       if (currentSlide.progressState !in (validStates)) return
       setStoriesShown(currentStoriesIndex)
       setState { resume() }
   }
}

...

Давайте обратим внимание на функцию setStoriesShown. Она вызывается в момент, когда пользователь увидел слайд какой-либо сторис. Нас интересует получение maxShownSlideIndex — максимального последнего просмотренного индекса слайда. 

Для управления просмотром сторис мы используем следующий алгоритм:

1. Начало просмотра:  

Если сторис отсутствует в списке просмотренных (пользователь только начал смотреть с первого слайда), фиксируется просмотр первого слайда.

2. Просмотр следующего слайда:  

Если сторис уже есть в списке просмотренных, и текущий слайд меньше последнего просмотренного (например, пользователь перешел к следующему слайду), выбирается текущий слайд.

3. Возврат к предыдущему слайду:  

Если сторис есть в списке, но текущий слайд больше последнего просмотренного (например, пользователь вернулся с слайда 2 на слайд 1), выбирается слайд из списка просмотренных.

4. Сохранение результата:  

После определения текущего слайда результат сохраняется для данной сторис.

Нас интересует параметр shown. Он true тогда, когда сторис либо уже ранее является просмотренной, либо выбранный нами слайд является последним в данной сторис. Однажды поставленным в true (сторис просмотрена), данный параметр будет с таким значением всегда.

Глянем стейт:

Код стейта
data class StoriesState(
   val duration: Int,
   val stories: List<UiStories>,
   val shownStories: Set<ShownStories>?,
   val user: User = User.NULL_OBJECT
) {

...

   fun shownStories(
       shownStories: Set<ShownStories>
   ): StoriesState =
       copy(
           stories = stories.map { story ->
               val shownStory = shownStories.find { it.storiesId == story.id.value }
               story.copy(
                   shown = shownStory?.shown ?: false,
                   slides = story.slides.mapIndexed { index, slide ->
                       // выбираем текущий слайд для просмотра
                       // если сторис не просмотрена и не просмотрен ни один слайд
                       // или сторис просмотрена - выбираем 1ый слайд
                       // если сторис не просмотрена, но просмотрены слайды -
                       // выбираем следующий непросмотренный слайд
                       slide.copy(
                           current = index == if (shownStory == null || shownStory.shown) {
                               0
                           } else {
                               shownStory.maxShownSlideIndex + 1
                           }
                       )
                   }
               )
           },
           shownStories = shownStories
       )

...

   companion object {


       fun initial(
           durationInSec: Int,
           stories: List<UiStories>,
           storiesId: UiStoriesId
       ): StoriesState =
           StoriesState(
               duration = durationInSec * 1000,
               stories = stories.map { uiStories ->
                   uiStories.copy(
                       slides = uiStories.slides.mapIndexed { slideIndex, uiSlide ->
                           uiSlide.copy(current = slideIndex == 0)
                       },
                       current = uiStories.id == storiesId
                   )
               },
               shownStories = null
           )
   }
}

Из интересного — изначально список просмотренных сторис нам неизвестен, его нужно получать запросом, поэтому в initial состоянии стейта мы ставим ему null. При получении и установки списка мы также должны понять, какой слайд у сторис необходимо открыть, ведь пользователь мог открывать сторис ранее. Поэтому если в списке просмотренных мы не находим нашей сторис, это значит, что мы ее не смотрели и нужно показывать первый слайд, иначе берем слайд, следующий за последним просмотренным.

Глянем использование данного класса на экране сторис:

@Composable
private fun StoriesContent(
   stateFlow: StateFlow<StoriesState>,
   onToolbarClick: () -> Unit,
   onMainButtonClick: (UiStoriesId, Int) -> Unit,
   onSecondButtonClick: (UiStoriesId, Int) -> Unit,
   onPaused: () -> Unit,
   onResumed: () -> Unit,
   onPrevious: () -> Unit,
   onNext: () -> Unit,
   onProgress: (Float) -> Unit,
   onFinished: () -> Unit
) {
   AppMaterialTheme {
       val storiesState = stateFlow.collectAsStateWithLifecycle().value
       if (storiesState.shownStories == null) return@AppMaterialTheme

...

Здесь все просто — пока нет списка просмотренных сторис, не отображаем сторис на экране, ведь непонятно, какой слайд нужно показывать. В будущем мы планируем добавить состояние загрузки при получении списка, чтобы улучшить пользовательский опыт.

Отрисовку превью и рамки к ней вы можете найти в предыдущей части статьи здесь.

Управление границами сторис: решение с фейковыми элементами  

В первой итерации была проблема, связанная со свайпом на крайних элементах сторис. Подробнее о смене сторис здесь. Необходимо было реализовать закрытие сторис на крайних элементах в случае дальнейшего свайпа вправо и влево соответственно. Для этого мы придумали класс StoriesType, который позволяет отличать реальную сторис от фейковой.

sealed class StoriesType {


   data class Content(val content: UiStories) : StoriesType()


   data object Fake : StoriesType()
}

Фейковая сторис — это наше самостоятельное решение, оно нужно для свайпов на крайних элементах, чтобы закрыть экран сторис, так как стандартное поведение compose pager не включает в себя этого. 

По сути это пустышка, переход на которую будет сигнализировать о том, что контент кончился, и экран сторис можно закрыть Так вот, в предыдущей итерации, если мы делали свайп вправо на первом и влево на последнем элементах, то независимо от того, отпустил пользователь палец или нет, по достижению свайпа в половину экрана (когда фейковая сторис станет видимой), экран сторис закрывается. Как говорится:

Вот как это выглядело:

Ожидаемое поведение — свайп на всех элементах должен быть одинаковым, а значит, что сторис должна запускаться (закрываться экран в случае с крайними элементами) только по отпусканию пальца, вне зависимости от того, насколько осуществлен свайп. 

Почему так получилось? Вкратце вспомним, как меняется сторис при свайпе:

  • во фрагменте мы слушаем изменение состояния пейджера:

       // смена сторис при свайпе
       LaunchedEffect(pagerState) {
           snapshotFlow {
               pagerState.currentPage
           }.collect {
               viewModel.setStoriesShown(it)
               // вызывается для синхронизации стейта вм и pager'а
               viewModel.setStories(it)
           }
       }
  • во ВМ проверяем синхронизацию состояний ВМ и пейджера по актуальной странице (сторис):

   fun setStories(page: Int) {
       with(stateFlow.value) {
           when {
               page > currentStoriesIndex -> {
                   setNextStories()
               }


               page < currentStoriesIndex -> {
                   setPreviousStories()
               }


               else -> {
               }
           }
       }
   }
  • и если они отличаются — меняем стейт ВМ:

Код стейта ВМ
   private fun setNextStories() {
       with(stateFlow.value) {
           val newStoriesIndex = currentStoriesIndex + 1
           when (val storiesType = storiesType[newStoriesIndex]) {
               is StoriesType.Content -> {
                   val storiesId = storiesType.content.id
                   val slideIndex = storiesType.content.slides.indexOfFirst { it.current }
                   analytics.send(
                       CoreMainStoriesTap(
                           id = storiesId,
                           page = slideIndex + 1
                       )
                   )
                   setState {
                       nextStories(newStoriesIndex = newStoriesIndex, newSlideIndex = slideIndex)
                   }
               }
               StoriesType.Fake -> {
                   setState { finish() }
                   back()
               }
           }
       }
   }
  • и уже во фрагменте слушаем изменение стейта ВМ и синхронизируем его с пейджером (меняем сторис):

       // смена сторис, слайда при тапе, а также их запуск
       LaunchedEffect(
           storiesState.currentStoriesIndex,
           storiesState.currentSlideIndex,
           pagerState.isScrollInProgress
       ) {
           if (storiesState.currentStoriesIndex != pagerState.currentPage) {
               pagerState.animateScrollToPage(storiesState.currentStoriesIndex)
           }


           if (pagerState.isScrollInProgress) {
               onPaused()
           } else {
               onResumed()
           }
       }

Проблема здесь в том, что во ВМ при обновлении стейта мы смотрим на то, какой тип у нашей новой сторис — Content или Fake (мок для определения захода за границы контента), и если она Fake, то мы закрываем экран сторис.

Мы хотим, чтобы поведение было таким же, как и на обычных элементах — экран должен закрываться только когда:

  • свайп достиг половины экрана (фейковая сторис стала видимой)

  • мы отпустили палец

Также можно заметить, что из-за проверок на тип сторис наши ВМ и стейт стали тяжелыми для чтения и понимания. Подумав, я решил, что вся эта история с улавливанием свайпа на крайних элементах — это недостаток пейджера (а не мой), так что:

На самом деле было принято решение перенести работу с типами сторис во фрагмент. Тем самым мы:

  • реализуем фикс

  • сильно упрощаем чтение стейта/вью модели, незначительно усложняя фрагмент

Посмотрим, как теперь выглядит код:

  • вью модель:

Код вью модели
fun setFinish() {
   setState { finish() }
}


fun setNextSlide() {
   with(stateFlow.value) {
       val nextSlideIndex = currentSlideIndex + 1
       if (slidesCount == nextSlideIndex) {
           val newStoriesIndex = currentStoriesIndex + 1
           if (newStoriesIndex == stories.size) {
               setFinish()
               back()
           } else {
               setNextStories()
           }
       } else {
           analytics.send(
               CoreMainStoriesTap(
                   id = currentStories.id.value,
                   page = nextSlideIndex + 1
               )
           )
           setState { slide(nextSlideIndex) }
       }
   }
}


fun setPreviousSlide() {
   with(stateFlow.value) {
       if (currentSlideIndex == 0) {
           if (currentStoriesIndex == 0) {
               analytics.send(
                   CoreMainStoriesTap(
                       id = currentStories.id.value,
                       page = 1
                   )
               )
               setState { refreshSlide() }
           } else {
               setPreviousStories()
           }
       } else {
           val previousSlideIndex = currentSlideIndex - 1
           analytics.send(
               CoreMainStoriesTap(
                   id = currentStories.id.value,
                   page = previousSlideIndex + 1
               )
           )
           setState { slide(previousSlideIndex) }
       }
   }
}


private fun setNextStories() {
   with(stateFlow.value) {
       val newStoriesIndex = currentStoriesIndex + 1
       val storiesId = stories[newStoriesIndex].id
       val slideIndex = stories[newStoriesIndex].slides.indexOfFirst { it.current }
       analytics.send(
           CoreMainStoriesTap(
               id = storiesId.value,
               page = slideIndex + 1
           )
       )
       setState {
           stories(newStoriesIndex = newStoriesIndex, newSlideIndex = slideIndex)
       }
   }
}


private fun setPreviousStories() {
   with(stateFlow.value) {
       val newStoriesIndex = currentStoriesIndex - 1
       val storiesId = stories[newStoriesIndex].id
       val slideIndex = stories[newStoriesIndex].slides.indexOfFirst { it.current }
       analytics.send(
           CoreMainStoriesTap(
               id = storiesId.value,
               page = slideIndex + 1
           )
       )
       setState {
           stories(
               newStoriesIndex = newStoriesIndex,
               newSlideIndex = slideIndex
           )
       }
   }
}

Сразу бросается в глаза как упростился код вью модели по сравнению с первой итерацией — здесь теперь нет избыточного разделения сторис на настоящую и фейк (где то оно не нужно, например, в случае смены слайда, приходилось фильтровать список по наличию контента). 

Код стал читабельнее. Из нового — в setNextSlide мы проверяем, является ли сторис последней в списке, и если да, то закрываем экран. Также добавилась функция finish, которая выставляет стейт в конечное значение.

  • стейт:

Код стейта
data class StoriesState(
   val duration: Int,
   val stories: List<UiStories>,
   val shownStories: Set<ShownStories>?,
   val user: User = User.NULL_OBJECT
) {


   val currentStories = stories.first { it.current }
   val currentStoriesIndex = stories.indexOfFirst { it.current }
...
private fun currentSlide(newCurrentIndex: Int): StoriesState =
   copy(
       stories = stories.mapIndexed { storiesIndex, uiStories ->
           if (storiesIndex == currentStoriesIndex) {
               uiStories.copy(
                   slides = uiStories.slides.mapIndexed { slideIndex, uiSlide ->
                       uiSlide.copy(current = slideIndex == newCurrentIndex)
                   }
               )
           } else {
               uiStories
           }
       }
   )


private fun currentStory(newStoriesIndex: Int): StoriesState =
   copy(
       stories = stories.mapIndexed { index, uiStories ->
           uiStories.copy(current = index == newStoriesIndex)
       }
   )


private fun progress(
   progressState: UiSlide.ProgressState,
   progress: Float,
   newStoriesIndex: Int = currentStoriesIndex,
   newSlideIndex: Int = currentSlideIndex
): StoriesState =
   copy(
       stories = stories.mapIndexed { storiesIndex, uiStories ->
           if (storiesIndex == newStoriesIndex) {
               uiStories.copy(
                   slides = uiStories.slides.mapIndexed { slideIndex, uiSlide ->
                       when {
                           slideIndex < newSlideIndex -> {
                               uiSlide.copy(
                                   progressState = UiSlide.ProgressState.COMPLETE,
                                   progress = 1f
                               )
                           }


                           slideIndex == newSlideIndex -> {
                               uiSlide.copy(
                                   progressState = progressState,
                                   progress = progress
                               )
                           }


                           else -> {
                               uiSlide.copy(
                                   progressState = UiSlide.ProgressState.START,
                                   progress = 0f
                               )
                           }
                       }
                   }
               )
           } else {
               uiStories.copy(
                   slides = uiStories.slides.mapIndexed { slideIndex, uiSlide ->
                       val currentSlideIndex = uiStories.slides.indexOfFirst { it.current }
                       if (slideIndex < currentSlideIndex) {
                           uiSlide.copy(
                               progressState = UiSlide.ProgressState.COMPLETE,
                               progress = 1f
                           )
                       } else {
                           uiSlide.copy(
                               progressState = UiSlide.ProgressState.START,
                               progress = 0f
                           )
                       }
                   }
               )
           }
       }
   )

...

Код стал проще. Стейт теперь хранит не StoriesType, а непосредственно контент. Это правильное решение, так как действий с моковыми сторис у нас не было и нет. Создание и добавление фейковых сторис переехало во фрагмент.

  • фрагмент:

Код фрагмента
onFinished = {
   viewModel.setFinish()
   viewModel.back()
}

...

@Composable
private fun StoriesContent(
   stateFlow: StateFlow<StoriesState>,
   onToolbarClick: () -> Unit,
   onMainButtonClick: (UiStoriesId, Int) -> Unit,
   onSecondButtonClick: (UiStoriesId, Int) -> Unit,
   onPaused: () -> Unit,
   onResumed: () -> Unit,
   onPrevious: () -> Unit,
   onNext: () -> Unit,
   onProgress: (Float) -> Unit,
   onFinished: () -> Unit
) {
   AppMaterialTheme {
       val storiesState = stateFlow.collectAsStateWithLifecycle().value
       if (storiesState.shownStories == null) return@AppMaterialTheme
       val storiesTypes = storiesState.stories.addFakeStories()


       val pagerState =
           rememberPagerState(
               pageCount = { storiesTypes.size },
               initialPage = storiesTypes.indexOf(
                   StoriesType.Content(storiesState.currentStories)
               )
           )


       // аналог pagerState.isScrollInProgress, сделан по 3 причинам:
       // 1. isScrollInProgress периодически отрабатывает с задержкой
       // 2. лишние вызовы onPaused() и onResumed()
       // 3. синхронизация гонки состояний - финиша onPress у detectTapGestures
       // и изменения currentPage пейджера на Fake сторис, для успешного закрытия экрана
       var tapInProgress by remember { mutableStateOf(false) }
       // смена сторис, слайда при тапе, а также их запуск
       LaunchedEffect(
           storiesState.currentStoriesIndex,
           storiesState.currentSlideIndex,
           tapInProgress
       ) {
           // если сторис Fake, то: если пальцы пользователя отжаты - закрываем экран.
           // также игнорируем смену сторис
           if (storiesTypes[pagerState.currentPage] is StoriesType.Fake) {
               if (!tapInProgress) {
                   onFinished()
               }
               return@LaunchedEffect
           }
           val index = storiesTypes.indexOf(StoriesType.Content(storiesState.currentStories))
           if (index != pagerState.currentPage) {
               pagerState.animateScrollToPage(index)
           }


           if (tapInProgress) {
               onPaused()
           } else {
               onResumed()
           }
       }


       // ставим на паузу, если открыт какой-нибудь диалог и фрагмен не в фокусе
       WindowFocusLaunchedEffect()


       // смена сторис при свайпе
       LaunchedEffect(pagerState) {
           snapshotFlow {
               pagerState.currentPage
           }.collect {
               // дублирование логики из-за гонки состояний onPress у detectTapGestures
               // и изменения pagerState.currentPage
               // если сторис Fake, то: если пальцы пользователя отжаты - закрываем экран.
               // также игнорируем смену сторис
               if (storiesTypes[it] is StoriesType.Fake) {
                   if (!tapInProgress) {
                       onFinished()
                   }
                   return@collect
               }
               val contentStoriesIndex = storiesState.stories.indexOf(
                   (storiesTypes[it] as StoriesType.Content).content
               )
               // вызывается для синхронизации стейта вм и pager'а
               viewModel.setStories(contentStoriesIndex)
           }
       }
       @SuppressLint("ConfigurationScreenWidthHeight")
       val screenWidthDp = LocalConfiguration.current.screenWidthDp.dp
       val screenWidthPx = with(LocalDensity.current) { screenWidthDp.toPx() }


       @SuppressLint("ConfigurationScreenWidthHeight")
       val screenHeightDp = LocalConfiguration.current.screenHeightDp.dp
       val screenHeightPx = with(LocalDensity.current) { screenHeightDp.toPx() }


       val offsetY = remember { Animatable(0f) }
       val scope = rememberCoroutineScope()


       HorizontalPager(
           state = pagerState,
           modifier = Modifier
               .fillMaxSize()
               .background(ComposeColor.Black)
               .offset { IntOffset(0, offsetY.value.roundToInt()) }
               .pointerInput(Unit) {
                   detectTapGestures(
                       onPress = {
                           tapInProgress = true
                           // ждем tap up событие от пользователя, на результат не смотрим
                           tryAwaitRelease()
                           tapInProgress = false
                       },
                       onTap = { offset ->
                           if (offset.x < screenWidthPx / 2) {
                               onPrevious()
                           } else {
                               onNext()
                           }
                       }
                   )
               }

...

private fun List<UiStories>.addFakeStories(): List<StoriesType> {
   val list = this.map {
       StoriesType.Content(it)
   } as List<StoriesType>
   return list.toMutableList().apply {
       add(0, StoriesType.Fake)
       add(StoriesType.Fake)
   }
}

...

Давайте по порядку. У нас добавился callback onFinished, который закроет экран сторис и выставит стейт в соответствующее состояние. Функция addFakeStories() переехала из стейта во фрагмент и теперь вызывается при создании экрана сторис. 

Главная сложность здесь — то, что теперь ответственность за разделение сторис между настоящей и моковой легла на фрагмент, и тут нужно быть внимательным. 

Обратите внимание — на rememberPageState, в pageCount (суммарное количество страниц) мы записываем число всех сторис, в том числе добавленных моковых. Однако в initialPage мы ищем индекс нужной нам сторис-контент, ведь мы не хотим стартовать с пустого экрана. 

Далее мы вводим переменную tapInProgress. Данное поле — аналог pagerState.isScrollInProgress. Если это аналог, то зачем она нужна? Нужна она по трем причинам:

  • isScrollInProgress периодически отрабатывает с задержкой — при переключении сторис может запуститься позже, это видно на экране и портит пользовательский опыт

  • лишние вызовы onPaused() и onResumed() — сначала в коллбэке onPress внутри detectTapGestures, затем в LaunchedEffect, изменяясь по ключу pagerState.isScrollInProgress

  • синхронизация гонки состояний — финиша между двумя LaunchedEffect, которые обрабатывают состояние вью модели и пейджера. Это нужно в случае с Fake сторис для успешного закрытия экрана. Дело в том, что иногда callbacks отрабатывают в разном порядке, tapInProgress служит для того, чтобы в одной из этих точек гарантированно произошел вызов onFinished и экран закрылся. Почему это работает так — сказать пока что не могу, так как сильно не углублялся в этот вопрос. Напишите в комментариях, если вы знаете/предполагаете, почему так происходит, буду благодарен!

Добавляем это поле как key в LaunchedEffect, чтобы отслеживать изменение значения. Если сторис окажется фейковой и пользователь закончит свайп — финишируем сторис и закрываем экран. Иначе ищем сторис согласно значению из стейта, если говорим про тап, либо обновленным page, если говорим про свайп, и синхронизируем значения стейта и ВМ. Затем, опираясь на эту переменную, мы либо запускаем, либо приостанавливаем сторис. Про WindowFocusLaunchedEffect расскажу ниже. В onPress callback detectTapGestures при касании экрана мы устанавливаем значение true для переменной, далее ждем отпускания всех пальцев пользователя, и уже после ставим значение false.

Результат:

Гибкость сторис: как админка заменила бекенд  

Нас долго преследовало желание автоматизировать создание/редактирование/удаление сторис, чтобы на это не приходилось каждый раз заводить задачи. 

Мы хотели оперативно работать с ними независимо от релиза приложения. Однако ресурсов у бекенда не оказалось. И хочется, и колется, как говорится. Мы решили обойтись своим решением, а именно — заводить сторис через нашу корпоративную админку. 

Из плюсов — наконец-то удален хардкод по созданию сторис. Права на редактирование есть как у разработки, так и у менеджмента.

Из минусов — у нас в арсенале только get-ручка, значит, что сохранять измененные данные по сторис через админку не получится. Поэтому пришлось снова обращаться к старой доброй памяти устройства через префсы. Решение громоздкое, более сложное для понимания, требует некоторой компетенции для того, чтобы сторис создать: надо знать точные названия используемых цветов, параметры изображения и т.д. Однако это были наши реалии на тот момент, и нужно было искать решение. 

Плюс это возможность применить правила чистой архитектуры. Результаты оказались положительными, о них мы расскажем в итогах. 

Итак, как это выглядит. Вот пример структуры сторис на админке:

Пример
{
      "id": "stories_id",
      "role": [
        "professional"
      ],
      "previewImageUrl": "https://www.site.ru/assets/preview.svg",
      "previewTitle": "Пройдите\nопрос",
      "slides": [
        {
          "backgroundColor": "BLACK",
          "slideImage": {
            "size": {
              "width": 320,
              "height": 280
            },
            "url": "https://www.site.ru/assets/slide.svg",
            "paddings": {
              "start": 16,
              "top": 16,
              "end": 16,
              "bottom": 16
            },
            "scale": "fit"
          },
          "title": "Развивайте личный бренд вместе с нами",
          "textColor": "SYSTEM_WHITE",
          "subtitle": "Поможем делать контент, а также вы получите другие привилегии",
          "buttons": {
            "main": {
              "text": "Подробнее",
              "colors": "PURPLE_BRAND",
              "action": "https://site.ru/"
            }
          }
        }
      ]
    }

Из приятного здесь то, что у нас при копировании текста сохранились NBSP-символы, а также мы смогли поддержать работу с svg-изображениями.

Так выглядит доменная модель:

Доменная модель
@JsonClass(generateAdapter = true)
data class Stories(
   val id: String,
   val role: List<String>,
   val previewImageUrl: String,
   val previewTitle: String,
   val slides: List<Slide>,
   val visible: Boolean = true,
   val shown: Boolean = false
)


@JsonClass(generateAdapter = true)
data class Slide(
   val backgroundColor: String,
   val slideImage: Image,
   val title: String,
   val textColor: String,
   val subtitle: String,
   val buttons: Buttons
) {


   @JsonClass(generateAdapter = true)
   data class Image(
       val size: Size,
       val url: String,
       val scale: String,
       val offset: Offset = Offset(),
       val paddings: Paddings = Paddings(),
       val background: Background? = null
   ) {


       @JsonClass(generateAdapter = true)
       data class Size(
           val width: Float,
           val height: Float
       )


       @JsonClass(generateAdapter = true)
       data class Paddings(
           val start: Int = 0,
           val top: Int = 0,
           val end: Int = 0,
           val bottom: Int = 0
       )


       @JsonClass(generateAdapter = true)
       data class Offset(
           val x: Int = 0,
           val y: Int = 0
       )


       @JsonClass(generateAdapter = true)
       data class Background(
           val url: String,
           val size: Size,
           val offset: Offset = Offset()
       )
   }


   @JsonClass(generateAdapter = true)
   data class Buttons(
       val main: Button,
       val second: Button? = null
   ) {


       @JsonClass(generateAdapter = true)
       data class Button(
           val text: String,
           val colors: String,
           val action: String = ""
       )
   }
}

Из нового — появилось поле visible, с помощью которого мы можем динамически скрывать сторис на проде. Все, что связано с изображениями, раньше имело тип @DrawableRes Int, теперь же это url, а значит — String. 

Все необходимые изображения мы заранее подгрузили на админку. Далее мы расширили класс Slide, а именно — добавили классы/поля по работе с кнопками/изображениями. Каждому изображению теперь должны задаваться параметры, такие как размер, url, масштабирование, отступы (padding здесь — отступ контейнера, а offset — самого изображения относительно контейнера), и даже задний фон. Да, мы захотели позади картинки нарисовать еще и задний фон, ниже будет пример. С кнопками ничего особенного: есть обязательная первая и опциональная вторая, у каждой — текст и его цвет, а также обработка действия по нажатию. 

Тут два нюанса: первый — согласно текущему плану бизнеса, для новых сторис достаточно открывать ссылку через веб вью. Второй — это дефолт-значение поля пустая строка. У нас есть старый код с переходом на нативные экраны, который бизнес попросил пока что оставить, поэтому дефолт-значение необходимо в рамках миграции на новые требования. В скором времени старые сторис исчезнут и код можно будет удалить. Однако ничто не мешает изменить код под обработку более сложных кейсов, таких как открытие нативного экрана, стороннего приложения и т.д. — нужно будет расширить модель, но суть останется прежней.

Для сериализации полей мы используем moshi, здесь все базово. Dto создавать не имело смысла, так как поля идентичные, и все они являются частью бизнес требований. Напишите в комментариях, если у вас иное мнение на этот счет.

Доработка сторис: UI и синхронизация с админкой  

Так как фича зашла бизнесу, просьба добавить новых сторис не заставила себя долго ждать. Однако помимо контента появилась необходимость и в более сложном UI, чтобы сделать их более интересными и разнообразными. Поэтому в рамках улучшения нашей фичи мы видоизменили структуру наших доменных и презентационных моделей.

Я бы не хотел сильно заострять внимание на UI-моделях Stories и Slide, так разницы по сравнению с предыдущей версией нет (взглянуть на них можно здесь), за исключением двух вещей:

  • имплементация Parcelable, чтобы можно было передавать модели как аргумент

  • наличие у UiSlide следующих enum’ов

/**
* Используются для взаимодействия с админкой cdn, чтобы удобнее было описывать цвета.
* При изменении класса нужно также поменять содержимое на админке, иначе будет Exception при вызове valueOf.
*/
@Parcelize
enum class SlideImageScale : Parcelable {
   FIT, CROP
}
enum class ButtonColorsEnum(val colors: ButtonColors) {


   PRIMARY(ButtonColors.primary),
   SECONDARY(ButtonColors.secondary),
   PURPLE_BRAND(ButtonColors.purpleBrand),
   WHITE(ButtonColors.white)
}
enum class ColorsEnum(val color: Color) {


   BRAND_M2_PURPLE_2(Colors.brandM2Purple2),
   BLACK(Colors.black),
   ADDITIONAL_M2_ORANGE_1(Colors.additionalM2Orange1),
   ADDITIONAL_M2_MINT_2(Colors.additionalM2Mint2),
   STORY_MAKE_MONEY_COLOR(Color(0xFFFFE6AA)),
   BRAND_M2_BLUE(Colors.brandM2Blue),
   SYSTEM_WHITE(Colors.systemWhite),
   ADDITIONAL_M2_MINT(Colors.additionalM2Mint)
}

Тут нужно иметь ввиду, что при изменении названий цвета нужно поменять название и на админке, иначе будет Exception. И наоборот — в случае, если добавляем в админке цвет, которого нет в приложении. 

Неприятный момент: было два варианта — этот и описывать точный цвет через hex (например, 0xFFFFE6AA). С одной стороны, название через hex не поменяется со временем, с другой — это очень громоздкое занятие. Например, ButtonColors — это набор цветов:

val primary = ButtonColors(
   backgroundColor = Colors.blueAccentPrimary,
   titleAndIconsColor = Colors.blueAccentOnPrimary,
   subtitleColor = Colors.blueAccentOnPrimarySoft,
   disabledBackgroundColor = Colors.blueAccentSecondary,
   disabledTitleAndIconsColor = Colors.blueAccentOnSecondary,
   disabledSubtitleColor = Colors.blueAccentOnSecondarySoft
)

Теперь представьте, что на каждую кнопку надо будет через админку описывать все цвета напрямую — это слишком. Проще описать в коде все имеющиеся цвета и использовать их названия в качестве идентификатора, ну и вероятность изменения названия со временем также невелика.

Давайте теперь глянем примеры использования:

Пример использования
onMainButtonClick = { id, index, action ->
   viewModel.sendCoreMainStoriesPrimaryButtonTapEvent(
       id = id,
       index = index
   )


   if (action.isEmpty()) {
       // легаси код
   } else {
       viewModel.handleAction(action)
   }
}

...

   @Composable
   private fun HorizontalPagerContent(
       storiesTypes: List<StoriesType>,
       pagerState: PagerState,
       preloadedStoriesIndex: Int,
       storiesState: StoriesState,
       onToolbarClick: () -> Unit,
       onMainButtonClick: (UiStoriesId, Int) -> Unit,
       onSecondButtonClick: (UiStoriesId, Int) -> Unit,
       onNext: () -> Unit,
       onProgress: (Float) -> Unit
   ) {

...

               preloadedSlide.slideImage.background?.let {
                   AsyncImage(
                       modifier = Modifier
                           .offset(x = it.offset.x.dp, y = it.offset.y.dp)
                           .width(it.size.width.dp)
                           .height(it.size.height.dp),
                       model = ImageRequest.Builder(LocalContext.current)
                           .data(it.url)
                           .build(),
                       contentDescription = null,
                       contentScale = ContentScale.Crop
                   )
               }
               Column {
                   ...
                   Illustration(
                       slideImage = preloadedSlide.slideImage
                   )
                   ...
                   if (!preloadedSlide.buttons.areEmpty()) {
                       Column(
                           modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp),
                           verticalArrangement = Arrangement.spacedBy(8.dp)
                       ) {
                           preloadedSlide.buttons.second?.let {
                               Button(
                                   modifier = Modifier
                                       .fillMaxWidth(),
                                   buttonStyle = ButtonStyle.xLargeStyle,
                                   buttonColors = it.colorsULong.map(),
                                   title = it.text,
                                   onClick = {
                                       onSecondButtonClick(preloadedStory.id, preloadedSlideIndex)
                                   }
                               )
                           }
                           preloadedSlide.buttons.main?.let {
                               Button(
                                   modifier = Modifier
                                       .fillMaxWidth(),
                                   buttonStyle = ButtonStyle.xLargeStyle,
                                   buttonColors = it.colorsULong.map(),
                                   title = it.text,
                                   onClick = {
                                       onMainButtonClick(preloadedStory.id, preloadedSlideIndex)
                                   }
                               )
                           }
                       }
                   }
               }
           }
       }
   }

...

@Composable
fun Illustration(
   slideImage: SlideImage,
   modifier: Modifier = Modifier
) {
   Box(
       modifier = modifier
           .fillMaxWidth()
           .padding(
               start = slideImage.paddings.start.dp,
               top = slideImage.paddings.top.dp,
               end = slideImage.paddings.end.dp,
               bottom = slideImage.paddings.bottom.dp
           ),
       contentAlignment = Alignment.Center
   ) {
       AsyncImage(
           modifier = Modifier
               .width(slideImage.size.width.dp)
               .height(slideImage.size.height.dp)
               .offset(x = slideImage.offset.x.dp, y = slideImage.offset.y.dp),
           model = ImageRequest.Builder(LocalContext.current)
               .data(slideImage.url)
               .build(),
           contentDescription = null,
           contentScale = when (slideImage.scale) {
               SlideImageScale.FIT -> ContentScale.Fit
               SlideImageScale.CROP -> ContentScale.Crop
           }
       )
   }
}

В качестве работы с асинхронными изображениями мы решили использовать Coil (его рекомендует использовать сам гугл). Из интересного — при создании model внутри AsyncImage в качестве data можно прокидывать как url, так и, например, путь к файлу изображения из памяти (uri). Coil скушает и обработает — это удобно. 

Сами недавно с этим столкнулись: на экране 1 было получение и отображение изображения через url, однако позже из экрана 2 на экран 1 появилась необходимость вернуть uri cropped изображения. Вообще вариативность Coil’а здесь помогла. Он также умеет работать с svg (необходимо добавить дополнительную зависимость), что позволило нам не загружать море jpeg изображений для разных размеров.

Вот как это выглядит на экране:

Как видите, расположение изображений, наличие заднего фона (слайд “Обеспечим безопасность”), масштабирование картинки, количество кнопок варьируется в зависимости от настроек сторис.

Обратите внимание, что при нажатии на кнопку и открытии bottom sheet сторис ставится на паузу. Это происходит благодаря функции WindowFocusLaunchedEffect:

@Composable
private fun WindowFocusLaunchedEffect() {
   val windowInfo = LocalWindowInfo.current
   LaunchedEffect(windowInfo) {
       snapshotFlow { windowInfo.isWindowFocused }.collect { isWindowFocused ->
           if (isWindowFocused) {
               viewModel.setResumed()
           } else {
               viewModel.setPaused()
           }
       }
   }
}

Все просто — если сторис не в фокусе, то ставим ее на паузу, иначе — воспроизводим.

Что в итоге

Мы сравнили конверсию между способом оповещения через email и сторис в мобильном приложении за один месяц. В выборке учитывались уникальные просмотры и переходы, так как один пользователь может несколько раз посмотреть письмо/сторис или совершить переход.

Мы взяли email-оповещения по всем продуктам компании и сравнили с нашей сторис «сделка онлайн». Так вот у email-оповещений лишь 3.8% пользователей прочитали письмо и перешли на продукт, в то время как у сторис «сделка онлайн» этот показатель составил более 17% пользователей, то есть почти в 4.5 раза больше. 

Это подтверждает тот факт, что сторис — более эффективный вариант коммуникации с пользователями. С другой стороны, стоимость трудозатрат на оформление одной сторис оказалась аж в 233 раза дороже, чем на email-оповещение.

На самом деле такая разница не потому, что стоимость сторис — космос, а потому что email-рассылка стоит очень дешево. Тем не менее, нужно взвешивать, какое решение будет удобно конкретно вам.

Собственно мы добавили количество новых сторис. Поработали над фиксами, завезли новых фичей, провели исследование об интересе со стороны риелторов на бонусы, которые предоставляет наша программа амбассадоров.

Что дальше?
Дальше — больше. Количество сторис будет только увеличиваться, а с автоматизацией процесса удалось сократить время добавления до минимума – пара строк в админке и пара минут тестирования, что все хорошо. Более того, добавлять сторис теперь может не только разработка, но и даже менеджмент в любой момент.

В планах все так же бодро: 

  • Завезти кучу новых сторис, среди них будут с видео-форматом. 

  • Поработать с их архивацией. Место, куда они будут попадать спустя определенное время для дальнейших действий и будут видны только пользователю.

  • Добавить состояние загрузки при их получении, провести рефакторинг. В частности, я хочу провести пару манипуляций с нашими модулями, где хранится код сторис для того, чтобы он лучше следовал принципам клина. После этого я планирую сделать наши сторис отдельной библиотекой, которая будет в открытом доступе. А это уже — отдельная статья. Так что stay tuned — то ли еще будет.

Что можно сказать о текущей реализации? Сторис стали сложнее. Местами используются спорные решения, где то код все еще требует рефакторинга, но мы не стоим на месте и активно работаем над оптимизацией кодовой базы. 

Автоматизацию сторис, например, мы сделали на чистом энтузиазме в рамках эксперимента, из разряда «А что будет если?». Как результат, она заработала.

Далее была подготовлена дока в Confluence. Теперь фича стала кросс-командной — команда iOS также стала использовать этот ресурс. С одной стороны код нетривиальный, с другой стороны — хороший пример того, как любое, даже сложное и запутанное решение можно реализовать и контролировать с помощью правил чистой архитектуры.


Надеюсь, данная статья помогла вам приоткрыть завесу тайны, связанные с деталями реализации сторис в приложении. Как всегда напоминаю, что это лишь интерпретация, которых может быть множество, и это хорошо. А как бы решили проблемы, изложенные выше? Что сделали бы по-другому? Удается ли у себя в компании выделять время на рефакторинг существующей кодовой базы? Есть ли такие фичи, которые еще вчера были на «пару строк кода» и просты в чтении, а теперь они гигантские и сложные, что «смотри не запутайся»? Поделитесь своим мнением в комментариях. Буду рад услышать ваши отзывы/предложения. 

Давайте накидаем годного контента по сторис. Ведь чем больше примеров/обсуждений, тем ближе истина и лучше для всего Android-сообщества. Успехов вам и спасибо за внимание!

Теги:
Хабы:
+8
Комментарии4

Публикации

Информация

Сайт
tech.m2.ru
Дата регистрации
Дата основания
Численность
501–1 000 человек
Местоположение
Россия