-
Нам в блоке тестирования советовали так сделать, по сути два стейта получается для экрана, один в редьюсере, а другой во фрагменте. Просто кажется удобным из-за того что не работаешь с делегатами в редьюсере, но при этом избыточным и менее оптимизированным из-за маппинга из одного стейта в другой
Второе для UI - это уже не совсем стейт, просто готовые данные полученные из оригинального стейта. Во всяком случае в реализации должно быть именно так - есть основной стейт в редьюсере, а для UI просто данные полученные из этого стейта. (т.е. "ui стейт" никогда не изменяется сам через copy(), всегда создается из стейта редьюсера)
А вообще, это более идеологический подход, мы в нашей команде так и делаем. По правильному стейт так и надо проектировать. Так решается куча проблем, когда сам экран становится очень сложным, на нем куча элементов и т.д. И профит он начинает приносить тоже на сложных экранах.
Тут возможно чтобы полностью понять идею, надо поработать с такими сложными экранами, где огромное состояние, много запросов, много действий происходит на экранах. Но попробую по шагам донести мысль, что оно дает)
Начнем последовательно делать стейт. Первый этап, чисто ui данные в стейте:
data class ScreenState( // показываем список каких-нибудь заявок val uiItems: List<OrderUiItem> = emptyList(), ) // reduce is ScreenEvent.Internal.DataLoaded -> state { copy( uiItems = event.order.toUiItems() ) }Чем оно неудобно? Допустим, сначала ты сделал UI модельку, оставил в ней только нужные для UI данные, а остальные данные из запроса выкидываешь. А потом потребовались другие данные, которые не отображаются сейчас в делегате, обычная ситуация:
- допустим есть еще другие блоки экрана, которым эти данные нужны
- или надо какие-то действия делать по кликам (передавать какие-нибудь идентификаторы дальше, проверки делать и т.п.)
Придется или в этот делегат пихать лишнее, что не будет отображаться и будет просто хранится или рядом в стейте ложить отдельно эти данные. Обычно идут вторым путем и добавляют рядом поля в стейте. (но и первый подход тоже плохо)
Добавим еще полей в стейт:
data class ScreenState( // показываем список каких-нибудь заявок val uiItems: List<OrderUiItem> = emptyList(), // прочие данные для логики val orderPrice: BigDecimal? = null, val orderId: String? = null, val orderDate: OffsetDateTime? = null )Но теперь в полях стейта куча данных раскидано из одного запроса. Вроде не особо проблема, но их теперь надо все обновлять в ивенте:
is ScreenEvent.Internal.DataLoaded -> state { copy( uiItems = event.order.toUiItems(), orderPrice = event.order.orderPrice, orderId = event.order.orderId, orderDate = event.order.orderDate, ) }Но способов обновления может быть много (и ивентов соотвественно тоже много) , например:
- просто запрос при открытии экрана
- long polling/polling
- еще у нас есть в приложениях "обновление при возврате". Т.е. пользователь был на экране, ушёл с него, вернулся и нужно обновить данные (это отличается обычно от просто загрузки при открытие экрана).
И тут легко забыть обновить поля/поле в каком-то из ивентов. Или если добавляешь новые поля нужно думать - в каких местах его обновить нужно (очень частая ситуация, когда вот так пишут стейты).
А ещё редко, но бывают такие кейсы, когда надо подкинуть данные самим, не от бэкенда. Например, в контексте этого стейта, тут пользователь может создать какую-нибудь "заявку" и её нужно сразу показать на ui. А значит нужно в стейте держать и уже логика обновления UI данных усложняется:
data class ScreenState( // показываем список каких-нибудь заявок val uiItems: List<OrderUiItem> = emptyList(), // прочие данные для логики val orderPrice: BigDecimal? = null, val orderId: String? = null, val orderDate: OffsetDateTime? = null, // заявка, которую создал пользователь val createdOrder: OrderUiItem? = null ) // reduce is ScreenEvent.Internal.DataLoaded -> state { copy( uiItems = if (createdOrder != null) { createdOrder + event.order.toUiItems() } else { event.order.toUiItems() }, orderPrice = event.data.orderPrice, orderId = event.data.orderId, orderDate = event.data.orderDate, ) }Или вот в чате, отправили сообщение, нужно его показывать на ui, пока не получим новый список сообщений от бэкенда.
Усложним задачу). Теперь эти ui данные должны зависеть не от одного запроса, а от двух:
// Теперь заявки показываются на основе 2-х методов: // 1. метод который возвращает список заявок // 2. метод который возвращает статусы заявок data class ScreenState( // показываем список каких-нибудь заявок val uiItems: List<OrderUiItem> = emptyList(), // прочие данные для логики val orderPrice: BigDecimal? = null, val orderId: String? = null, val orderDate: OffsetDateTime? = null, // заявка, которую создал пользователь val createdOrder: OrderUiItem? = null, // прочие данные по статусам, не отображаются на UI, но нужны в работе val statusId: String? = null, val statusDescription: String? = null, val statusDate: OffsetDateTime? = null, ) // reduce is ScreenEvent.Internal.DataLoaded -> state { copy( uiItems = if (createdOrder != null) { createdOrder + event.order.toUiItems(event.statuses) } else { event.order.toUiItems(event.statuses) }, orderPrice = event.data.orderPrice, orderId = event.data.orderId, orderDate = event.data.orderDate, statusId = event.status.statusId, statusDescription = event.status.statusDescription, statusDate = event.status.statusDate, ) }Еще больше усложним, теперь методы асинхронные, т.е. они не под шиммером загружаются, а в фоне и не ждут друг друга. Это тоже частый кейс, когда пользователю пытаемся показать экран как можно быстрее и часть запросов делаем в фоне:
// Теперь заявки показываются на основе 2-х методов: // 1. метод который возвращает список заявок // 2. метод который возвращает статусы заявок // 3. методы асинхронные, вызываются независимо и не ждут друг друга data class ScreenState( // показываем список каких-нибудь заявок val uiItems: List<OrderUiItem> = emptyList(), // прочие данные для логики val orderPrice: BigDecimal? = null, val orderId: String? = null, val orderDate: OffsetDateTime? = null, // заявка, которую создал пользователь val createdOrder: OrderUiItem? = null, // прочие данные по статусам, не отображаются на UI, но нужны в работе val statusId: String? = null, val statusDescription: String? = null, val statusDate: OffsetDateTime? = null, val lastOrder: Order? = null, val lastStatus: OrderStatus? = null, ) // reduce order is ScreenEvent.Internal.OrderLoaded -> state { copy( uiItems = createUiItemsIfCan(), lastOrder = event.order, orderPrice = event.data.orderPrice, orderId = event.data.orderId, orderDate = event.data.orderDate, ) } // reduce status is ScreenEvent.Internal.StatusLoaded -> state { copy( uiItems = createUiItemsIfCan(), lastStatus = event.status, statusId = event.status.statusId, statusDescription = event.status.statusDescription, statusDate = event.status.statusDate, ) } fun createUiItemsIfCan() : List<OrderUiItem> { return if (lastOrder == null || lastStatus) { emptyList() } else { if (createdOrder != null) { createdOrder + event.order.toUiItems(lastStatus) } else { event.order.toUiItems(lastStatus) }, } }Уже стейт усложнился на ровном месте. И его сложность будет расти в N раз, где N количество запросов/данных на экране. (у нас некоторых по 10-15 запросов)
Вот здесь уже разделение стейта начинает давать сильные преимущества. Проще в стейте положить только ответы, а для UI маппить отдельный стейт из этих ответов:
// В стейте лежат только исходные данные data class ScreenState( // данные от запросов val lastOrder: Order? = null, val lastStatus: OrderStatus? = null, // заявка, которую создал пользователь val createdOrder: OrderUiItem? = null, ) // reduce order is ScreenEvent.Internal.OrderLoaded -> state { copy( lastOrder = event.order, ) } // reduce status is ScreenEvent.Internal.StatusLoaded -> state { copy( lastStatus = event.status, ) } // Данные чисто для UI, все что маппится из стейта и показывается на UI data class ScreenUiData( val uiItems: List<OrderUiItem> ) class UiDataMapper { fun mapToUiData(state: ScreenState): ScreenUiData { return ScreenUiData( uiItems = if (state.lastOrder == null || state.lastStatus) { emptyList() } else { if (state.createdOrder != null) { state.createdOrder + state.lastOrder.toUiItems(state.lastStatus) } else { state.lastOrder.toUiItems(state.lastStatus) } } ) } } // Где нибудь во фрагменте fun render(state: ScreenState) { val uiData = uiDataMapper.mapToUiData(state) adapter.render(uiData.uiItems) }Edited by Igor Pokrovskiy -
Вот пример экрана, на котором работаю, почти на каждый день:
Красным обвёл все видимые данные которые от зависят от одного запроса (от него много еще невидимого зависит). Но в стейте это просто буквально:
copy(data = newData)Все остальное вынесено в UI маппинг.
Соотвественно это позволяет разом все обновлять и не получить нигде багов, старых, неактуальных данных.
Edited by Igor Pokrovskiy -
Про оптимизацию - нужно смотреть уже по ситуации. Маппинг можно делать на main потоке вполне, если он не часто происходит. Тут важнее не сам факт маппинга и какой он, а насколько часто он происходит:
- если маппинг занимает условно 30-50 мс, но один раз за время жизни экрана - то пофиг. При открытии экрана пролагают несколько кадров и ладно))
- а если маппинг будет занимать 5-10 мс, но каждые 100-200 мс, то уже часто и заметно кадры будет тормозить (кадр 16 мс/60 фпс, 5-10 мс часто отнимать уже неприятно) - то тогда уже надо с этим бороться. (но до такого обновления стейта надо ещё дойти)
Можно корутинами вынести маппинг в фон, но делать это надо только когда будут проблемы)
Edited by Igor Pokrovskiy
Please register or sign in to comment
