Создание читалки PDF, устойчивой к сбоям, для больших документов в React Native
Изучите архитектуру для отображения огромных PDF-файлов объемом в несколько тысяч страниц в React Native с использованием ленивых деревьев закладок, поиска и стабильной навигации на устройствах с ограниченными ресурсами.
Источники: Rendering and Navigating Large PDFs in React Native Without Crashing the App
Показать пятистраничный PDF на экране в приложении React Native — дело простое. Однако документ, состоящий из сотен или тысяч страниц, содержащий глубоко вложенные закладки и внутренние ссылки между разделами, представляет собой совершенно другую проблему: на менее мощном оборудовании он может быстро стать медленным, неотзывчивым или даже часто выходить из строя. В этом руководстве рассматривается архитектура, направленная непосредственно на решение этой проблемы: эффективная отрисовка крупных PDF, переход на произвольные страницы, просмотр и поиск вложенных деревьев закладок, а также поддержание стабильности всего интерфейса даже на устройствах с ограниченными ресурсами.
В одном из тестовых случаев PDF содержал более 1 900 страниц и имел дерево закладок с множеством уровней вложенности. Попытка отобразить всё это дерево сразу создавала серьезную нагрузку на память. В следующих разделах описан подход, позволивший решить эти проблемы.
Что предоставляет, а чего не предоставляет react-native-pdf
Прежде чем выбирать дополнительные библиотеки, полезно точно знать, на чём заканчиваются встроенные возможности react-native-pdf, поскольку именно эти границы определяют дальнейшую концепцию решения:
- Благодаря обратному вызову
onLoadCompleteон действительно предоставляет необработанную информацию о закладках (через параметрtableContents) на iOS и Android — но не на Windows, и уж точно не в формате, который можно было бы непосредственно отобразить на экране. Это неструктурированные данные в сыром виде, которые всё равно требуют обработки и отрисовки с вашей стороны. - Он действительно предлагает универсальный обратный вызов для нажатий на ссылки (
onPressLink), однако этот обратный вызов не может отличить внутренний переход внутри документа от внешней URL, к тому же он не всегда корректно указывает номер целевой страницы для внутренних ссылок.
Учитывая это, практический способ получения надежных данных закладок и ссылок, совместимых с разными платформами, — это извлекать их независимо, вместо того чтобы полагаться на необработанный результат обратного вызова рендерера; именно эту роль выполняет pdf-lib, о чем будет рассказано далее. Эта архитектура не предназначена для поиска текста внутри самого содержимого PDF; она решает задачу поиска названий закладок, что является значительно проще и полностью осуществимо с помощью инструментов с открытым исходным кодом.
Выбор пакетов
Для настройки требуются три пакета, каждый из которых отвечает за одну конкретную задачу:
react-native-pdf обрабатывает отображение с использованием встроенного движка PDF платформы вместо преобразования страниц в изображения на основе JavaScript — именно этот нативный подход позволяет сохранять разумное потребление памяти при работе с большими файлами. Он отвечает за отображение страниц, поддержку увеличения масштаба, генерацию событий изменения страницы и возможность программного перехода на определённую страницу.
react-native-blob-util — это зависимость, от которой сам react-native-pdf зависит для доступа к файлам; она занимается загрузкой, кэшированием и чтением отображаемого PDF. Это не дополнительный элемент, а обязательное условие, встроенное в внутреннюю работу react-native-pdf.
pdf-lib устраняет вышеописанный недостаток. Вместо отрендерования какого-либо контента он позволяет напрямую читать и манипулировать внутренней структурой PDF: каталогом документа, деревом страниц и — что критически важно для данного случая — словарем оглавления, где на самом деле хранятся закладки внутри PDF, а также объектами аннотаций ссылок, привязанными к отдельным страницам, где хранятся как внутренние, так и внешние ссылки. Поскольку react-native-pdf не предоставляет всей этой информации в структурированном и надежном виде, pdf-lib используется для однократного просмотра внутренней структуры и создания чистого JSON-представления дерева закладок — с заголовком, целевой страницей и вложенными дочерними элементами — а также списка разрешенных путей ссылок, причем все это не зависит от специфического поведения нативного рендерера на конкретной платформе. Этот шаг извлечения данных должен выполняться только один раз.
Примечание к Expo:
react-native-pdfзависит от нативных модулей, поэтому он не будет работать в стандартном клиенте Expo Go. Требуется кастомная сборка для разработки или проект в нативном формате.
Архитектура
Система состоит из трёх слоев, каждый из которых изолирован от внутренних механизмов остальных:
- Просмотрщик PDF — отслеживает текущую страницу, общее количество страниц, статус загрузки и любые ошибки.
- Панель инструментов читателя — отображает информацию о текущей странице и предоставляет доступ к панели закладок.
- Панель закладок — управляет полем поиска, деревом закладок с возможностью расширения (созданным на основе данных, полученных с помощью
pdf-lib), и передаёт обратно одну информацию: какую страницу выбрал пользователь.
Панель закладок не имеет представления о том, как сам PDF отображается, а просмотрщик PDF не знает, как реализован поиск закладок. Именно такой однонаправленный процесс — панель выбирает страницу, а экран указывает просмотрщику на необходимость перехода туда — мешает таким действиям, как ввод текста в поле поиска или раскрытие узла дерева, вызывать дорогостоящую переработку самого компонента PDF.
Шаг 1: Структурирование данных закладок
Каждая закладка, с которой вы работаете, требует трёх элементов: названия, целевой страницы и необязательного массива дочерних закладок — простой рекурсивной структуры, которая естественным образом соответствует результатам этапа извлечения данных с помощью pdf-lib. Самым важным моментом здесь является то, что каждая закладка должна иметь стабильный уникальный идентификатор, причём этот идентификатор не может быть просто её позицией в массиве. Как только пользователь начинает вводить текст в поиске или открывать/закрывать разделы, позиция конкретной закладки в отображаемом списке будет меняться. Если вы будете использовать индекс массива в качестве идентификатора, React потеряет возможность отличать одну строку от другой, состояние расширения/сжатия будет привязано к неверному узлу, а FlatList начнёт неправильно перерабатывать строки.
Шаг 2: Поиск без потери контекста
Поскольку соответствующая закладка часто находится в глубоких уровнях структуры дерева, логика поиска должна обрабатывать её рекурсивно, а не просто сканировать заголовки верхнего уровня. Не менее важно то, что когда дочерний узел соответствует запросу, необходимо включать в результаты и его родительский узел, даже если сам заголовок родителя не совпадает ни с чем. Если этого не сделать, результат поиска на глубоком уровне будет отображаться без указания того, где он на самом деле находится в документе.
Чтобы обеспечить эффективную работу, поместите логику фильтрации в механизм мемоизации, чтобы она запускалась заново только при изменении строки поиска или данных закладки. Для деревьев с большим количеством элементов также добавьте функцию дебаунсинга для ввода текста, чтобы не перефильтровывать всю структуру при каждом нажатии клавиши.
Шаг 3: Распрямлять только видимые узлы
FlatList — подходящий инструмент в данном случае из-за проблем с производительностью, но он ожидает плоский массив, а не вложенную древовую структуру. Поэтому необходимо пройтись по дереву закладок и сгенерировать плоский массив, содержащий только те узлы, которые в настоящее время должны быть видимыми; дети узла включаются только тогда, когда сам узел развернут. Каждая запись в плоском массиве также должна содержать число depth, чтобы можно было определить глубину внедрения элемента на экране.
Этот шаг, пожалуй, является самым важным решением с точки зрения производительности во всей реализации. Если бы вы развернули весь дерево и просто скрыли строки, относящиеся к свёрнутым ветвям, FlatList всё равно хранил бы тысячи строк, которые никогда не отображаются на экране. Благодаря тому, что разворачивается только текущая ветвь, дерево закладок с тысячами записей может в любой момент передавать FlatList всего лишь несколько десятков строк.
Шаг 4: Создание панели закладок
Реализуйте панель в виде модального окна, расположенного сверху над просмотрщиком PDF, а не в качестве экрана, заменяющего его, и ограничьте её высоту примерно 80% размера экрана, чтобы она не занимала весь экран. Это позволит видеть часть PDF под панелью для ориентира, а также освободит место для заметной кнопки закрытия, вместо того чтобы панель казалась полностью отдельным экраном.
Внутри модального окна необходимо: поле поиска, подключенное к логике из шага 2; компонент FlatList, отображающий развернутые строки, полученные на шаге 3, настроенный с параметрами окна — initialNumToRender, maxToRenderPerBatch, windowSize и removeClippedSubviews — чтобы отображались только строки, находящиеся рядом с видимой областью; а также состояние пустоты в качестве запасного варианта, когда поиск не возвращает результатов. Каждая строка должна содержать два отдельных элемента для нажатия: нажатие на тело строки выбирает соответствующую страницу и закрывает панель, в то время как нажатие на маленькую иконку расширения включает/выключает ее содержимое, чтобы открытие подраздела случайно не запускало навигацию. Каждый компонент строки следует обернуть в memo, чтобы расширение одного подраздела не приводило к перерисовке всего списка. Кроме того, каждый раз, когда поле поиска не пустое, автоматически расширяется любой соответствующий подраздел.
Шаг 5: Создание экрана читателя PDF
Именно на этом экране происходит фактическое отображение контента с помощью компонента Pdf. Используйте обратный вызов при смене страницы для отслеживания текущей страницы и общего количества страниц, отображайте встроенный индикатор загрузки библиотеки во время загрузки документа, а также настройте обратный вызов при возникновении ошибок, чтобы в случае неудачи на экране появилось понятное сообщение «невозможно открыть этот документ» с опцией повторной попытки — не допускайте, чтобы индикатор загрузки крутился бесконечно без возможности возврата к началу.
Каждый раз, когда вы переходите на страницу из закладки, ограничьте её номер значением от 1 до известного общего количества страниц. Это защищает от ситуаций, когда некорректная или устаревшая цель — будь то данные самой закладки или приведённая внутренняя ссылка — пытается отправить пользователя на номер страницы, который просто не существует. Также стоит включить кэширование исходного PDF-документа, чтобы документ, уже скачанный, не загружался заново каждый раз при отображении этого экрана.
Что касается визуального стиля, он, конечно, будет зависеть от собственной системы дизайна вашего приложения, но с точки зрения структуры для удобства использования важно держать высоту панели около 80%, предусмотреть очевидный способ её закрытия, сделать каждую строку (особенно иконку расширения) удобной для нажатия, а также спроектировать спокойное и чётко различимое состояние ошибки.
Шаг 6: Извлекать данные один раз, а не при каждой отрисовке
Шаг извлечения с использованием pdf-lib, описанный ранее, должен выполняться один раз, вне цикла отрисовки — а не внутри компонента, который перерисовывается каждый раз при поиске пользователя или раскрытии ветки. Храните результат в виде JSON-файла в кэше, вместо того чтобы пересчитывать его многократно; сохраняйте в состоянии React только незначительные поля (идентификаторы, заголовки, номера страниц), избегайте хранения сырых байтовых данных PDF в состоянии и удаляйте временные ресурсы после того, как экран будет удаляться. Если ваш бэкенд может выполнять этот шаг извлечения, это обычно более правильный подход — тогда мобильное приложение будет получать только небольшой JSON-пакет.
Шаг 7: Сохранять положение пользователя
Закрытие панели закладок или поворот устройства не должны сбрасывать читалку на первую страницу. Как минимум, необходимо сохранять текущую страницу, самый недавно раскрытый раздел закладок и смещение прокрутки панели. Храните эти значения на уровне родительского экрана или состояния навигации, а не внутри самого компонента панели закладок — так они сохранятся даже в случае удаления панели.
Шаг 8: Грамотное обработка ошибок
Планируйте ситуации реальных сбоев, а не только идеальный ход событий: отсутствие или истечение срока действия URL-адресов файлов, поврежденные или защищенные паролем документы, устройства с недостатком места для хранения, прерывание сетевых соединений во время загрузки, закладки, указывающие на недействительные страницы, или ссылки, которые просто не открываются. В любом случае избегайте ситуаций, когда пользователь вынужден смотреть на индикатор загрузки, который так и не завершается. Если у документа нет закладок, четко об этом сообщите — например: «В этом документе нет закладок». Если поисковая операция ничего не возвращает, покажите сообщение «Закладки не найдены». Четко определенное состояние пустоты или ошибки гораздо лучше, чем экран, который кажется застрявшим.
Шаг 9: Тестирование реальных сценариев сбоев
Не проверяйте свою реализацию только на одном удобном PDF-файле. Расширьте тестирование, учитывая следующие аспекты:
- Документы: файлы, в которых совсем нет закладок, файлы объемом в сотни страниц, глубоко вложенные иерархии закладок, крупные встроенные изображения, защита паролем, умышленное повреждение данных, внутренние ссылки, внешние ссылки и заголовки с специальными символами
- Навигация: переход к закладкам корневого уровня и глубоко вложенным закладкам, на первую и последнюю страницы, на номер страницы вне диапазона, повторное открытие панели после ее закрытия, поиск вложенной записи и поиск, не дающий результатов
- Устройства: смартфон Android с небольшим объемом памяти, современное флагманское устройство, iPhone и iPad, режимы вертикального и горизонтального форматирования, а также переходы между фоновым и передним планом
Здесь тестирование на физическом оборудовании имеет большее значение, чем при разработке большинства функций — эмулятор просто не сможет воссоздать нагрузку на память и ограничения по хранению, с которыми сталкиваешься на реальном устройстве низкого класса.
Часто задаваемые вопросы
Почему бы просто не отображать каждую страницу в виде изображения? Нативный движок отрисовки рисует только тот контент, который в данный момент нужен на экране, вместо того чтобы заставлять React управлять отдельным компонентом изображения для каждой из тысячи страниц.
Почему использовать pdf-lib вместо данных закладок, уже предоставляемых react-native-pdf? Потому что эти встроенные данные необработаны, ведут себя неконсистентно на разных платформах (в Windows они вообще не возвращаются), и не предоставляют надежного способа для определения местоположения внутренних ссылок. pdf-lib анализирует структуры описания содержимого и аннотаций ссылок PDF напрямую, за один проход, обеспечивая чистые и предсказуемые данные, полностью находящиеся под вашим контролем.
Возможен ли поиск полного текста внутри самого документа? В настоящее время это невозможно с использованием исключительно инструментов с открытым исходным кодом — такая функция в настоящее время предназначена только для коммерческих SDK. Описанная здесь техника позволяет выполнять поиск по названиям закладок, что является более узкой задачей, которую можно полностью решить самостоятельно.
Должна ли каждая ветвь дерева закладок раскрываться автоматически? Нет — раскрывайте только ту ветвь, которую пользователь активно открывает. На устройствах с ограниченной памятью целесообразно ограничить интерфейс так, чтобы в любой момент времени раскрыта была только одна ветвь корневого уровня.
Гарантирует ли что-либо из этого, что приложение никогда не упадет? Ни один подход не может дать такую гарантию для всех возможных комбинаций документов и устройств. Цель — снизить риски: избегать преждевременной обработки данных, минимизировать объем сохраняемого состояния, извлекать данные только один раз, виртуализировать список закладок и явно обрабатывать каждый возможный сценарий сбоя.
Заключение
Для достижения оптимальной производительности недостаточно просто выбрать одну библиотеку — react-native-pdf отвечает за отрисовку, pdf-lib предоставляет надежную информацию о закладках и ссылках, а настоящая стабильность достигается за счет правильного управления состоянием, виртуализацией и обработкой ошибок в связи с использованием обеих библиотек.
Практические правила, которых следует придерживаться:
- Выполняйте извлечение закладок и ссылок один раз с помощью
pdf-lib, никогда не включайте это в процесс отрисовки. - Никогда не отрисовывайте всю структуру закладок сразу — упростите ее и отображайте только раскрытые части.
- Используйте виртуализированный элемент
FlatListдля хранения списка закладок. - Совершенно не включайте сырые байтовые данные PDF в состояние React.
- Проверяйте целевую страницу перед переходом на нее.
- Сохраняйте только минимальное количество данных, необходимое для восстановления позиции читателя.