
Привет, Хабр! Меня зовут Дима, я фронтенд‑разработчик Яндекса. В этой статье я расскажу о том, как мы переписали наш ридер для электронных книг, создав универсальное решение для веба и нативных приложений.
Статья будет интересна фронтенд‑разработчикам. Из неё вы узнаете, как создать универсальное ядро для веба и натива, получить вместо запутанных асинхронных вызовов чёткие последовательности действий и убрать визуальные артефакты при одновременных пользовательских действиях.
База: EPUB и CFI
Прежде чем мы погрузимся в детали реализации, хочется ввести базовые понятия любого ридера.
EPUB (Electronic Publication) — это ZIP‑архив, содержащий HTML, CSS, изображения и метаданные, организованные по определённым правилам. По сути, это миниатюрный веб‑сайт.
CFI (Canonical Fragment Identifier) — это стандарт, который решает проблему, как точно указать на конкретное место в тексте.
Проблемы старой архитектуры
Исторически сложилось, что у нас было два отдельных приложения для чтения книг — для натива и веба. Это было неудобно, и мы точно знали, что можем сделать лучше.
Логика нативного приложения была разделена между нативным кодом и фронтенд‑приложением, что создавало существенные проблемы для разработки и поддержки приложения/сервиса. Для работы с таким приложением требовались специалисты, способные программировать для обеих платформ одновременно.
У нативного и веб‑приложения с ростом функциональности возникала проблема поддержания консистентности состояния при одновременных пользовательских действиях. Например, пользователь может быстро пролистать 10 страниц, затем вернуться назад, открыть оглавление, перейти к закладке — и всё это должно работать без задержек и сбоев.
Старый ридер
Новый ридер
Тогда мы поняли: чтобы всё это решить, нужна новая архитектура, и начали с формулировки требований к ней.
Требования к новой архитектуре приложения
Анализируя проблемы старого приложения, мы сформулировали ключевые требования к новому решению:
Прежде всего, нам необходимо было устранить состояние гонки при быстрых пользовательских действиях, чтобы убрать все визуальные артефакты.
Также нужно было объединить логику, которая раньше была распределена между нативным кодом и фронтенд‑приложением, что создавало проблемы в разработке нативного ридера.
Отдельно стоит отметить, почему мы решили отказаться от React в ядре ридера. Несмотря на то, что React с его виртуальным DOM отлично подходит для большинства интерфейсов, для ридера он создаёт определённые трудности. Главная проблема — в отсутствии точного контроля над DOM. В React нет возможности сразу после отрисовки DOM‑элемента вызвать колбэк, что необходимо для таких операций, как восстановление скролла в вертикальном режиме без визуальных артефактов.
Вместо React мы решили использовать Web Components для рендеринга контента. Он позволяет нативно работать с DOM, сохраняя при этом преимущества компонентного подхода с жизненными циклами, необходимыми для отображения состояния контента, загрузки и обработки ошибок. React по‑прежнему используется на веб‑платформе, но весь движок ридера работает на чистом JavaScript с Web Components.
Пересобираем архитектуру приложения
Универсальное ядро для веб-версии и нативных приложений
Первое, что мы сделали, — создали универсальное ядро ридера для веб‑версии и нативных приложений. Ядро инкапсулирует всю основную логику: загрузку и парсинг EPUB‑файлов, а также управление отрисовкой контента.
Натив в таком подходе отвечает за платформенную функциональность: скачивание книг, отрисовку системных UI‑компонентов (тулбаров, меню настроек, поповеров) и реализацию нативных анимаций перелистывания. Веб‑версия использует то же ядро, но без промежуточного слоя WebView, взаимодействуя с ним через идентичный API.
Почему мы выбрали WebView для нативных приложений? Браузерные движки изначально оптимизированы для работы с текстом и стилями. К тому же сам формат EPUB представляет собой ZIP‑архив с HTML, CSS и другими веб‑ресурсами — фактически, это маленький веб‑сайт. Аналогичный подход используют и другие популярные ридеры: Kindle, Google Play Books и Apple Books.
Общая кодовая база даёт плюсы: логика работы приложения становится консистентной на всех устройствах, а функциональность реализуется один раз и переиспользуется везде. Это не только гарантирует одинаковый пользовательский опыт на разных платформах, но и значительно сокращает время разработки и количество потенциальных ошибок.
Декларативные последствия вместо хаоса
Вторая задача — перейти от запутанных асинхронных вызовов к чётким последовательностям действий. Наш подход позволяет описать каждое пользовательское взаимодействие как упорядоченную цепочку шагов, гарантируя предсказуемый порядок выполнения операций даже в сложных асинхронных сценариях.
Пример:
typescript
[Sequence.VERTICAL_TO_CFI]: {
before: [ActionsUniversal.RENDER_START],
actions: [
ActionsUniversal.START_PREVENT_INFINITY_SCROLL,
ActionsUniversal.START_PREVENT_SAVE_CFI,
ActionsUniversal.CLEAR,
ActionsVertical.LOAD_FILES_WITH_CFI,
ActionsVertical.APPEND_MULTIPLE_CONTENT,
ActionsVertical.SCROLL_TO_CFI,
ActionsVertical.UPDATE_PROGRESS,
ActionsUniversal.END_PREVENT_SAVE_CFI,
ActionsUniversal.END_PREVENT_INFINITY_SCROLL,
ActionsUniversal.SYNC_READING_PROGRESS,
ActionsUniversal.SAVE_NAVIGATION_CFI,
ActionsUniversal.FIND_ACTIVE_TOC,
ActionsUniversal.FIND_ACTIVE_BOOKMARK_UUIDS,
],
finally: [ActionsUniversal.RENDER_END],
}
Каждая последовательность включает три этапа: before, actions и final. Это обеспечивает стабильную работу приложения и предотвращает его краши даже при ошибках.
Этот подход даёт нам несколько критических преимуществ:
Предсказуемость выполнения. Мы точно знаем, в каком порядке будут выполняться операции.
Отмену устаревших операций. Если пользователь быстро листает книгу, мы можем отменить ненужные загрузки.
Изоляцию ошибок. Проблема в одном действии не приводит к краху всей системы.
Очереди и предотвращение конфликтов
Третья задача — сохранить консистентность состояния при одновременных пользовательских действиях.
Что происходит, если пользователь быстро нажимает «вперёд», а затем «назад», пока предыдущая операция ещё не завершилась?
В старой архитектуре это могло привести к состоянию гонки и непредсказуемым результатам. В новой мы реализовали контроллер для очереди сценариев, в который отправляется любой вызов отрисовки, в себе же выполняет их последовательно, а также может отменять ненужные повторяющиеся действия. Это особенно важно для мобильных устройств, где пользователь может быстро использовать несколько жестов подряд. Особенно хорошо это видно на видео, которые я показывал в начале статьи.
Пример работы: вертикальный режим чтения
Для полноты понимания новой архитектуры приложения можно рассмотреть один из сценариев — переход по CFI в вертикальном режиме. Он используется при открытии ридера и переходе к последнему месту чтения, а также при смене режима чтения.
Разберём пошагово, как работает отрисовка.
[Sequence.VERTICAL_TO_CFI]: {
before: [ActionsUniversal.RENDER_START],
actions: [
ActionsUniversal.START_PREVENT_INFINITY_SCROLL,
ActionsUniversal.START_PREVENT_SAVE_CFI,
ActionsUniversal.CLEAR,
ActionsVertical.LOAD_FILES_WITH_CFI,
ActionsVertical.APPEND_MULTIPLE_CONTENT,
ActionsVertical.SCROLL_TO_CFI,
ActionsVertical.UPDATE_PROGRESS,
ActionsUniversal.END_PREVENT_SAVE_CFI,
ActionsUniversal.END_PREVENT_INFINITY_SCROLL,
ActionsUniversal.SYNC_READING_PROGRESS,
ActionsUniversal.SAVE_NAVIGATION_CFI,
ActionsUniversal.FIND_ACTIVE_TOC,
ActionsUniversal.FIND_ACTIVE_BOOKMARK_UUIDS,
],
finally: [ActionsUniversal.RENDER_END],
}
Шаг 1. Подготовка к отрисовке
[Sequence.VERTICAL_TO_CFI]: {
before: [ActionsUniversal.RENDER_START],
actions: [
ActionsUniversal.START_PREVENT_INFINITY_SCROLL,
ActionsUniversal.START_PREVENT_SAVE_CFI,
ActionsUniversal.CLEAR,
...
],
}
Здесь мы показываем спиннер (RENDER_START)
, блокируем подгрузку новых глав книги при скролле (START_PREVENT_INFINITY_SCROLL)
, блокируем сохранение CFI(START_PREVENT_SAVE_CFI)
и очищаем страницу (CLEAR)
.
Шаг 2. Загрузка нужного файла
[Sequence.VERTICAL_TO_CFI]: {
actions: [
...
ActionsVertical.LOAD_FILES_WITH_CFI,
...
]
}
Запрашиваем файл, в котором содержится нужная координата CFI, а также следующий и предыдущий. Это нужно, чтобы при стыке глав мог произойти подскролл к нужному месту.
Шаг 3. Отрисовка контента и цитат
[Sequence.VERTICAL_TO_CFI]: {
actions: [
...
ActionsVertical.APPEND_MULTIPLE_CONTENT,
...
]
}
На этом шаге мы отрисовываем контент, используя кастомные элементы, в которых инкапсулируется вся логика отрисовки глав. Также на этом этапе происходит подготовка контента перед его отрисовкой:
выставляем нужные размеры для изображений;
обрезаем главы для бесплатного фрагмента, если у пользователя нет подписки;
обрабатываем специальные элементы (сноски, цитаты).
Шаг 4. Прокрутка к позиции CFI
[Sequence.VERTICAL_TO_CFI]: {
actions: [
...
ActionsVertical.SCROLL_TO_CFI,
...
]
}
Декодим CFI и получаем элемент, к которому нужно проскроллить.
Шаг 5. Актуализируем состояние
[Sequence.VERTICAL_TO_CFI]: {
actions: [
...
ActionsVertical.UPDATE_PROGRESS,
ActionsUniversal.END_PREVENT_SAVE_CFI,
ActionsUniversal.END_PREVENT_INFINITY_SCROLL,
ActionsUniversal.SYNC_READING_PROGRESS,
...
],
}
Актуализируем прогресс в процентах по текущему CFI(UPDATE_PROGRESS), убираем блокировки, сохраняем CFI и включаем подгрузку контента по скроллу.
Шаг 6. Завершаем рендеринг
[Sequence.VERTICAL_TO_CFI]: {
actions: [
...
ActionsUniversal.SAVE_NAVIGATION_CFI,
ActionsUniversal.FIND_ACTIVE_TOC,
ActionsUniversal.FIND_ACTIVE_BOOKMARK_UUIDS,
],
finally: [ActionsUniversal.RENDER_END],
}
Сохраняем состояние навигации (SAVE_NAVIGATION_CFI)
для истории переходов (навигация позволяет отменять и восстанавливать действия пользователя — Undo/Redo), ищем заголовок, который соответствует текущей главе. Также проверяем, есть ли закладки на текущем экране, на RENDER_END
убираем лоадер.
По аналогии работает отрисовка по CFI в горизонтальном режиме.
[Sequence.HORIZONTAL_INIT_TO_CFI]: {
before: [ActionsUniversal.RENDER_START],
actions: [
ActionsUniversal.START_PREVENT_SAVE_CFI,
ActionsUniversal.CLEAR,
ActionsHorizontal.LOAD_FILE_WITH_CFI,
ActionsHorizontal.APPEND_SINGLE_CONTENT_CFI,
ActionsHorizontal.SCROLL_TO_CFI,
ActionsHorizontal.RENDER_PAGE,
ActionsHorizontal.UPDATE_PROGRESS,
ActionsUniversal.END_PREVENT_SAVE_CFI,
ActionsUniversal.FIND_ACTIVE_TOC,
ActionsUniversal.FIND_ACTIVE_BOOKMARK_UUIDS,
ActionsUniversal.SAVE_NAVIGATION_INIT_CFI,
],
finally: [ActionsUniversal.RENDER_END],
}
Выводы
Как в итоге нам удалось улучшить ридер:
Универсальность — мы пришли к единой архитектуре, которая одинаково хорошо работает и в вебе, и в нативных приложениях. Это избавило нас от дублирования и повысило переиспользуемость кода.
Управляемость и предсказуемость — благодаря контроллеру сценариев и декларативной последовательности шагов мы добились предсказуемого поведения интерфейса даже при быстрых одновременных пользовательских действиях. Вместо разрозненных асинхронных вызовов теперь у нас упорядоченный процесс, что не только устраняет визуальные глитчи и гонки состояний, но и делает код чище, логичнее и проще в сопровождении.
Иногда, чтобы решить нетривиальные продуктовые задачи, приходится выйти за рамки привычных инструментов и переосмыслить архитектуру с нуля. Так и получилось в нашем случае:декларативный подход к описанию действий и прямой контроль над DOM помогли нам создать удобный, стабильный и масштабируемый ридер.
Комментарии (5)
IgDem
17.06.2025 07:37Скажите, у меня одного в Я.Книгах на iPad периодически:
- перестают перелистываться страницы. Т.е. анимация перелистывания есть, но страница остается старой
- сбивается верстка и книга показывается в 4 колонки
От той и другой проблемы спасает перезагрузка приложения. Происходит примерно раз в день.
ganzmavag
17.06.2025 07:37О, у вас, кстати, очень хорошее приложение. Я когда его ставил, ожидал увидеть самый минимум, как у некоторых других. Обычно если сервис дает книги, то функционал приложения стоит на втором месте и сильно уступает специализированным читалкам. А у вас оказалась полноценная читалка с довольно большим количеством функций. Был приятно удивлён.
Vitaly_js
17.06.2025 07:37actions: [ ActionsUniversal.START_PREVENT_INFINITY_SCROLL, ActionsUniversal.START_PREVENT_SAVE_CFI, ActionsUniversal.CLEAR, ActionsVertical.LOAD_FILES_WITH_CFI, ActionsVertical.APPEND_MULTIPLE_CONTENT, ActionsVertical.SCROLL_TO_CFI, ActionsVertical.UPDATE_PROGRESS, ActionsUniversal.END_PREVENT_SAVE_CFI, ActionsUniversal.END_PREVENT_INFINITY_SCROLL, ActionsUniversal.SYNC_READING_PROGRESS, ActionsUniversal.SAVE_NAVIGATION_CFI, ActionsUniversal.FIND_ACTIVE_TOC, ActionsUniversal.FIND_ACTIVE_BOOKMARK_UUIDS, ],
А как вы во всем этом не путаетесь? Иными словами как проверяется корректность порядка действий?
grokinn
Привет, Дима. Скажи, есть ли какая то причина, по которой в мобильном приложении есть поиск по тексту книги, а в веб-версии его нет?