Introduction в проблему

Сервис, прежде всего, должен решать задачу пользователя. Часто приложения должны делать то, что, казалось бы, должно ложиться на плечи сервера и других публичных API. Так, например, произошло в нашем проекте. Сервис помогает составлять отчеты, удобно их менеджерить, автоматизирует задачи генерации и отправки клиентам. В рамках этой статьи хочу рассказать про наш опыт переноса функционала генерации PDF-отчетов на Android-устройство. Приятного чтения!

Как все было…

Работа сервиса до
Работа сервиса до изменений

Кейс мы решили следующим способом: клиент заполняет форму в приложении (дополнительно заранее получая актуальную форму от сервера); отправляется JSON и MediaPart фотографии на сервер; сервер генерирует отчет PDF и отправляет это клиенту/показывает в админке.

Что не так?

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

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

Новая схема работы проекта выглядит так:

Работа сервиса после изменений
Работа сервиса после изменений

Реализация

Ниже пробежимся по реализации подобного решения. Расскажу, как это сделали мы, что использовали и какие есть альтернативы.

Что использовать для генерации PDF?

Альтернативы

Для тех, кто хочет рассмотреть другие варианты, вот, наверное, самые базовые, известные инструменты.

PDFDocument

PDFDocument – входящий в android.graphics базовый инструмент для работы с PDF. Без сомнений, удобен для быстрого надежного редактирования PDF, создания простых документов. В нашем кейсе неактуален, так как предоставляются низкоуровневые возможности, тяжело работать с большими файлами и тем более быстро менять их формат.

PDFBox

PDFBox – инструмент от Apache. В теории они г@вна не сделают, но в нашем кейсе это too much. Удобная работа с таблицами и текстом. Не очень мне нравится работа с добавлением картинок, которых в отчете может быть очень много (свыше 200).

iText

iText – новее предыдущих решений, с хорошим комьюнити. Не выбрали его по тем же причинам, что и PDFBox. В целом довольно удобная, целостная библиотека, в других кейсах я бы начинал с неё.

Наш выбор

А вот наш выбор пал на более костыльное (возможно), но максимально простое и быстрое в реализации решение. А именно, по кускам темплейтов создаем HTML документ, прогоняем через WebView для печати и сохраняем, полученный PDF.

Почему именно так?

  1. Не с нуля. У нас уже был налаженный похожий механизм на сервере (читай introduction). Соответственно, уже были шаблоны HTML и CSS.

  2. Удобство и гибкость. HTML предоставляет более простой и гибкий способ структурирования и стилизации контента. Он сам адаптируется под печать на разных форматах бумаги. При изменении дизайна компании можно легко поправить CSS.

  3. Работа с изображениями. В HTML просто удобнее работать с картинками: просто вставлять, знакомая стилизация, адаптив. Удобно оставлять URI до картинки, всю остальную работу берет на себя WebView.

  4. Быстрый старт. Проект уже находился в проде, а функционал нужен срочно. Работа с ранее знакомым WebView и HTML сократит трудочасы.

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

В целом совокупность этих факторов повлияла на выбор технологии.

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

Начинаем с подготовки HTML

Реализуем PdfGenerator, который будет принимать Context для работы с WebView, сохранением и ресурсами.

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

class PdfGenerator(private val context: Context) {
  private val htmlToPdfConvertor = HtmlToPdfConvertor(context)
  // Функция генерации и сохранения
  suspend fun generateExpertAct(
      act: ActSave,
      images: List<File>,
      outFile: File
  ): Result<Unit> { <..> }
  
  // Функция генерации html контента
  private fun generateExpertActHtml(
      act: ActSave,
      images: List<File>
  ): String { <..> }
  
  <..>
  
  companion object {
      const val TAG: String = "PdfGenerator"
  
      private val PREFIX = """
            <!DOCTYPE html>
            <html lang="ru">
            <head>
                <meta charset="UTF-8">
                <title>Title</title>
            </head>
            
        """.trimIndent()
  
      private val SUFFIX = """
            </html>
            
        """.trimIndent()
        
      <..>
  }
}

Пример кода на Gist

Генерируем PDF

В коде выше есть создание инстанса HtmlToPdfConvertor, который будет выполнять работу с WebView и сохранением. Написан он с использованием колбеков, поэтому нужно превратить его в suspend функцию с помощью suspendCoroutine. Пример ниже:

suspend fun generateExpertAct(
  act: ActSave,
  images: List<File>,
  outFile: File
): Result<Unit> {
  return suspendCoroutine { continuation ->
    Handler(Looper.getMainLooper()).post {
      htmlToPdfConvertor.convert(
        pdfLocation = outFile,
        htmlString = generateExpertActHtml(
          act = act,
          images = images
        ),
        onPdfGenerationFailed = { exception ->
          continuation.resume(Result.failure(exception))
        },
        onPdfGenerated = {
          continuation.resume(Result.success(Unit))
        }
      )
    }
  }
}

Пример кода на Gist

Оборачиваем все в Worker

Для проекта стало важно, чтобы задачи по отправке и генерации выполнялись и запускались самостоятельно, для этого стоит сделать Worker.

В коде примера работа с Repository дополнена комментариями, чтобы было понятно, что происходит. Подробнее про фоновую работу можно почитать в документации.

Пример кода на Gist

Работа WebView

В случае, если вы не отображаете WebView (в качестве предпросмотра) и хотите вызывать генерацию PDF в фоне (Worker), то необходимо "создать для этого условия".

Работа с WebView выполняется в блоке Handler(Looper.getMainLooper()).post, который используется для выполнения кода в основном потоке приложения. В Android, основной поток также называется UI потоком.

Результат

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

В нашем проекте этот подход работает стабильно и довольно быстро. Отчеты включают более 100 фотографий, все добавляется без ошибок. Это решение позволило нам быстро поменять вид документов под новый стиль компании, а также добавить инфографику Plotly, подключив JS.

Комментарии (5)


  1. up7
    20.04.2024 12:50

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

    Вы переложили генерацию из одного места, которое можете контролировать, на множество мест, которые не можете контролировать)


    1. Stakancheck Автор
      20.04.2024 12:50

      Спасибо за комментарий!

      Наверное, стоит уточнить суть задачи в статье. Получение pdf без доступа к интернету это главная просьба клиента. Поэтому функционал перенесен на телефон.

      Сервер действительно может не отработать корректно запросы даже так. Поэтому мы храним отчеты в приложении и отправляем их при появлении интернета в фоне. В случае, если какой-то отчет не дошел, задача планируется заново. И так до тех пор, пока все отчеты не будут отправлены или не сработает ограничение. А до тех пор пользователь сможет самостоятельно поделиться файлом PDF физически или отправить по Интернету (если он есть).

      Надеюсь, получилось объяснить


    1. kozlov_de
      20.04.2024 12:50

      Видимо, клиент может отправить PDF по почте, соцсетям и другим каналам


  1. ImagineTables
    20.04.2024 12:50
    +1

    А вот наш выбор пал на более костыльное (возможно), но максимально простое и быстрое в реализации решение. А именно, по кускам темплейтов создаем HTML документ, прогоняем через WebView для печати и сохраняем, полученный PDF.

    Всё правильно сделали. Только я бы сказал, это очевидное решение. Я подобное делал давным-давно. Лет десять назад, так точно. И куски шаблонов — всё это тоже само напрашивается. Только тогда ещё были актуальны MS Office'ные форматы.

    Если посмотреть глобально, всё, что связано с бумажной печатью — пережиток прошлого. Чем дальше в будущее, тем меньше с этим сталкиваться. А уж инвестировать в технологии, заточенные под печать — а pdf с привязкой к геометрии страницы А4 именно такая технология — тем более глупо. Поддерживается pdf в виртуальной печати в webView — ну и чудненько. Пусть у его разработчиков голова болит. А вы в нужный момент сможете остаться с документом на изначальном HTML, он-то в будущем уж точно будет везде поддерживаться.


  1. TheFoxDK
    20.04.2024 12:50
    +1

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