Главная » Хабрахабр » Стейт-машины на службе у MVP. Лекция Яндекса

Стейт-машины на службе у MVP. Лекция Яндекса

Модель конечного автомата (finite-state machine, FSM) находит применение в написании кода для самых разных платформ, включая Android. Она позволяет сделать код менее громоздким, неплохо укладывается в парадигму Model-View-Presenter (MVP) и поддаётся несложному тестированию. Разработчик Владислав Кузнецов рассказал на Droid Party, как эта модель помогает в развитии приложения Яндекс.Диск.

— Вначале поговорим по теорию. Думаю, каждый из вас слышал и про MVP, и про стейт-машину, но повторим.

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

Стейт-машину и MVP или что-то похожее — наверное, MVI — использовали все.

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

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

По каждому пункту они используются и успешно применяются. Область применения стейт-машины довольно большая.

View — чаще всего Activity или Fragment, задача которой пробросить какое-то действие пользователю, идентифицировать Presenter о том, что пользователь что-то сделал. Как любой подход, MVP разделяет наше приложение на несколько слоев. Это может быть как БД, если мы говорим про clean architecture, или Interactor, что угодно может быть. Model мы рассматриваем как поставщик данных. Этого нам достаточно. И Presenter — посредник, который связывает View и модель, при этом может у модели что-то забрать и обновить View.

Исполняемый код? Кто может в одном предложении сказать, что такое программа? Алгоритм? Слишком общо, нужно детальнее. Алгоритм — это последовательность действий.

Неважно, кто этими данными манипулирует: пользователь или нет. Это набор данных и какие-то потоки управления. И чем данных в приложении больше, тем сложнее ими управлять, тем непредсказуемее может возникнуть ситуация, когда что-то пойдет не так. Из этого следует мысль, что в любой момент состояние приложение определяется совокупностью всех его данных.

Чтобы гарантировать, что вы покрыли все сценарии сочетания этих флагов, вам надо 2³ сценариев. Представьте простой класс, у которого три каких-то булевых флага. Если вы добавляете еще один флаг, это пропорционально увеличивается. Надо гарантированно покрыть восемь сценариев, чтобы сказать, что я точно все сочетания флагов обрабатываю.

Вроде была простая задача, но по мере разработки и работы над ней мы стали понимать, что что-то идет не так. Мы столкнулись с похожей проблемой. Она называется удаление локальных фото. Я буду рассказывать на примере фичи, которую мы запустили. Скорее всего, это фото и видео, которые он снял на свой телефон. Смысл в том, что пользователь отгружает какие-то данные в облако в автоматическом режиме. Зачем занимать драгоценное место на телефоне, когда можно эти фоточки удалить? Получается, что файлы вроде есть в облаке.

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

Диалог, две TextView, чек-бокс, кнопки. Мы посмотрели — вроде ничего сложного нет. Поэтому пользователю мы должны показать некую заглушку. Но когда мы начали детально работать над этой проблемой — поняли, что получить данные о том, сколько файлов мы можем удалить, это задача достаточно долговременная. Это псевдокод, в реальной жизни он выглядит по-другому, но смысл такой же.

Мы проверяем какое-то состояние, проверяем, что у нас идут вычисления, и рисуем заглушечку «Подождите».

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

В этом случае тоже есть два варианта. Потом есть ситуации, когда все-таки что-то отработало, и например, у пользователя стоит галочка не удалять свежие файлы. Либо файлы можно очистить, либо файлы очистить нельзя, то есть он уже очищал все файлы, поэтому мы предупреждаем, что все свежие файлы ты уже удалил.


Сняли галочку, и есть вариант, что можешь что-то удалить. Есть еще одно условие, когда действительно что-то можем удалить. Я еще не все перечислил, у нас есть проверка пермишинов, потому что без них ничего не работает, мы не можем трогать файлы на карточке, плюс должны проверять, что у пользователя в принципе включена автозагрузка, потому что фичи бесполезны без автозагрузки, что мы будем чистить. Смотришь на этот код, и кажется, что что-то не так. И блин, вроде такая простая вещь, а столько проблем из-за нее возникло. И еще несколько условий.

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

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

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

При помощи силы стейт-машины. Как же решать эту проблему?

Посидев, подумав, так как мы стараемся делать что-то MVP, как прикрутить ко всему этому наше состояние? Основная задача, с которой справляется стейт-машина, это берет большую сложную задачу и разбивает на маленькие дискретные состояния, с которыми проще взаимодействовать и управлять. Кто читал книгу GOF, это классический паттерн-стейт, просто то, что там называется контекстом, я обозвал стейт-оунером, и по факту это презентер. Мы пришли примерно к такой схеме. Презентер обладает этим состоянием, умеет их переключать, и еще может предоставлять какие-то данные нашим состояниям, если они хотят что-то знать, например, размер файлов или хотят запросить асинхронный запрос, выбрать.

Ничего тут суперского нет, скорее важен следующий слайд.

Вы садитесь за свой компьютер или где-то за столом, и либо на бумажке, либо в специальных инструментах рисуете диаграмму состояний. С этого надо начинать разработку, когда вы начинаете делать стейт-машину. Во-первых, на раннем этапе вы можете сразу задетектить какие-то неконсистентности в бизнес-логике. Тоже ничего сложного нет, но у этого этапа очень много плюсов. Думаю, с каждым была такая ситуация. Ваши продакты могут прийти, изъявить свое желание, вроде все хорошо, но когда вы беретесь писать код, вы понимаете, что что-то не стыкуется. Рисуется она достаточно просто, есть специальные инструменты типа PlantUML, в которых даже рисовать не надо уметь, надо уметь писать псевдокод, и она сама генерит графики. Но когда вы делаете диаграммку, вы на раннем этапе можете увидеть, что что-то не стыкуется.

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

Сам State, тут ничего важного, главное то, что у него три метода: onEnter, который при входе вызывает в первую очередь invalidateView. Перейдем к коду. Чтобы как только мы приходим в состояние, обновился UI. Для чего это сделано? Плюс есть метод invalidateView, который мы перегружаем, если надо что-то с UI сделать, и метод onExit, в котором можем что-то сделать, если выходим из состояния.

Интерфейс, который предоставляет возможность перещелкивания состояний. StateOwner. И это методы, которые дают дополнительный доступ к данным. Как мы выяснили, это будет будущий презентер. В данном случае мы можем отдать размер файлов, которые мы можем очистить, и предоставляем возможность сделать какой-то запрос. Если какие-то данные шарятся между состояниями, мы можем их держать в презентере, и через этот интерфейс отдавать. Мы перешли в состояние, хотим что-то запросить и через StateOwner можем позвать метод.

Это сделано для того, что если у вас состояние и прилетают какие-то данные, вы не хотите переходить в новое состояние, просто это избыточно, вы можете напрямую обновить вьюшку, текст. Еще такая полезность, что он тоже может вернуть ссылку на вьюшку. У нас в runtime идет загрузка файлов, он смотрит на диалог, и цифры обновляются. У нас это используется для того, чтобы обновлять количество цифр, которые пользователь видит, когда смотрит на диалог. Мы не переходим в новое состояние, мы просто обновляем текущую View.

Я придерживаюсь этой концепции. Тут стандартное MVP, все должно быть предельно простым, никакой логике, простые методы, которые что-то рисуют. Чисто берем какой-то Text View, меняем его, не более. Не должно быть никакой логики, по минимуму каких-то действий.

Тут побольше интересных вещей. Presenter. Кто использовал Icepick, знаком с ней. В первую очередь мы можем данные шарить через него для каких-то состояний, у нас есть две переменные, помеченные аннотацией State. Мы не пишем руками сериализацию в Partible, используем готовую библиотеку.

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

Нам не надо ничего синхронизировать, просто в какой-то момент, когда случается detach, мы подменяем вьюшку на пустую, и презентер может где-то задетаченный переключаться в состояниях, думать, что вьюшка есть, он ее обновляет, но на самом деле он работает с пустой имплементацией. Я сторонник того, чтобы как можно меньше было проверок на Null и прочее, поэтому здесь держу ссылку на простую имплементацию вьюшки.

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

Можете руками писать. Пробросить надо saveState, если кто-то работал с подобными библиотеками, все довольно тривиально. И два метода очень важных: attach, вызываемый в onStart, и detach, вызываемый в onStop.

Изначально мы планировали аттачиться и детачиться в onCreateView, onDestroyView, но этого оказалось не совсем достаточно. В чем их важность? И если вы не задетачитесь в onStop, и потом попытаетесь показать фрагмент, вы словите всем известное исключение про то, что нельзя коммитить транзакцию, когда у нас сохранилось состояние. Если у вас View, у вас может обновляться текст, а может показаться диалог-фрагмент. Поэтому мы детачимся в onStop, при этом там презентер продолжит работать, переключать состояния, отлавливать события. Либо используйте commit state loss, либо не делайте так. И в тот момент, когда случится старт, мы вызовем событие view attached, и презентер обновит UI в соответствие текущему состоянию.


Есть метод release, вызывается обычно в onDestroy, делаете детач и дополнительно освобождаете ресурсы.

Так как мы в onEnter и onExit планируем изменять UI, то тут есть проверка на главный поток. Еще важный метод setState. Плюсом этого места является то, что здесь мы можем залогировать вход и выход из состояния, очень полезно бывает при отладке, например, когда что-то идет не так, вы можете посмотреть, как система перещелкивалась и понять, что же было не так. Это создает нам ограничение, что мы здесь не делаем ничего тяжеловесного, все запросы должны быть либо к UI, либо должны быть асинхронными.

Есть состояние Initial, просто триггерит вычисления, сколько места нужно освободить в тот момент, когда вьюшка стала доступна. Пара примеров состояний. Как только случается onStart, мы переходим в новое состояние, и система начинает запрашивать данные. Это произойдет после onStart.

И viewAttached вызовется в том случае, если вьюшка заново зааттачится. Пример состояния Calculating, мы у stateOwner реквестим размер файлов, он как-то лезет в базу, и потом еще есть inValidateView, мы обновляем текущий UI пользователя. Если мы были в фоне, Calculating был в фоне, мы опять возвращаемся в нашу Activity, вызовется этот метод и актуализирует все данные.

Тут я поленился, можно было написать три отдельных метода, типа обновилось, старых файлов столько-то, как разные события разделить. Пример события, мы запросили у stateOwner сколько файлов можно освободить, и он вызывает метод filesSizeUpdated. Не надо в оверинжениринг впадать, что каждое событие — отдельный метод. Но надо понимать, когда-то у вас это сложно будет, когда-то гораздо проще. Можно вполне обойтись простым if, ничего страшного я в этом не вижу.

Мне не нравится, что мы вынуждены пробрасывать руками эти методы, типа onStart, on Stop, onCreate, onSave и прочее. Я вижу несколько потенциальных улучшений. Есть идея, например, сделать презентер фрагментом. Можно привязаться к Lifecycle, но непонятно, как быть с saveState. Фрагмент без UI, который ловит жизненный цикл, и вообще нам тогда ничего не надо будет, все само будет к нам прилетать. Почему нет?

Поэтому можно кэшировать презентер, как это делает, например, ViewModule из Architecture Components, сделать какой-то фрагмент, который будет держать в себе кэш презентеров и возвращать их для каждой вьюшки. Еще интересный момент: этот презентер каждый раз пересоздается, и если у вас в презентере хранятся большие данные, вы в БД сходили, держите огромный курсор, то это недопустимо каждый раз запрашивать при повороте экрана.

Хотя бы пустую. Можно использовать табличный способ задания стейт-машин, потому что у тот паттерн стейт, который мы используем, обладает одним существенным минусом: как только вам нужно добавить один метод в новое событие, вы должны добавить имплементацию во все наследники. Это не очень удобно. Либо сделать это в базовом состоянии. Расширять и поддерживать такую стейт-машину гораздо проще. Так что табличный способ задания стейт-машин используется во всех библиотеках — если поискать на GitHub по слову FSM, вы найдете большое количество библиотек, которые предоставляют вам некий билдер, где вы задаете начальное состояние, ивент и конечное состояние.

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

У вас появляются состояния, которые зависят не от уровня наследования — просто строится дерево состояний, которые передают обработчик выше. Как мы знаем, наследование надо заменять делегированием, и иерархические стейт-машины помогают такую проблему решить. В Android, например, иерархические стейт-машины используются в WatchDog Wi-Fi, который мониторит состояние сети, там они есть, прямо в исходниках Android. Тоже можете почитать отдельно, очень полезная штука.

Как это можно тестировать? Последний, но не менее важный момент. Есть отдельное состояние, мы создаем экземпляр, дергаем метод onEnter и смотрим, что у вьюшки вызвались соответствующие значения. В первую очередь, можно тестировать детерминированные состояния. Если у вас View ничего серьезного не делает, то, скорее всего, вы покроете огромное количество сценариев. Таким образом валидируем, что наше состояние правильно обновляет View.

В данном случае при возникновении события filesSizeUpdated и когда AllFilesSize больше нуля, мы должны перейти в новое состояние CleanAllFiles. Вы можете замокать какие-то методы функцией, которая возвращает размер, позвать после onEnter еще какое-то событие и посмотреть, как конкретное состояние реагирует на конкретные события. При помощи макета мы все это проверяем.

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

Под 80% инструкций было покрыто такими тестами. Мы переписывали видеоплеер на такую концепцию, и получили покрытие больше 70%. Мне кажется, это очень крутой показатель.

В первую очередь — тестирование. Что мы получили, используя такую концепцию? Стейт-машину и наш презентер несложно подружить с жизненным циклом.

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

Они могут легко понять, что они делают, и вариантов ошибиться гораздо меньше. Порог вхождения в понятие стейт-машины достаточно низкий, и люди, которые приходят в команду, видят отдельные детерминированные состояния. Разные библиотеки для стейт-машин позволяют не только понять, какое состояние было старым, а какое новым. Плюс за счет того, что у нас есть единая точка переключения состояний, мы можем это все логировать. Вы можете поставить триггер lock на определенный переход. Они также позволяют вам, например, отлавливать определенные переходы. Если вы что-то отлаживаете, это может быть очень важно.

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


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Как открыть ИП в Германии, если ты программист, и не набить шишек

В последние недели Берлин — популярная тема на Хабре. Не случайно — в Германии много возможностей для работы в ИТ с комфортным переездом. Но бюрократия она и в Африке бюрократия, а если встают вопросы об индивидуальном предпринимательстве за рубежом — ...

4 октября, Москва — Backend Stories 2.0

В четверг, 4 октября, на площадке Deworkacy (ул. Большая Полянка, д.2/10, стр 1) мы проведём митап для backend-разработчиков. 00, подробности — под катом.19:20–19:30, Павел Дерендяев, вступительное слово. Начинаем в 19. 30–20:10, Иван Походня, «Как мы хотели в Java 11 и ...