Моя история реализации офлайн приложения Хабра
Создание своего приложения Хабра уже вошло в традицию среди хабрюзеров. Я решил не отставать и сделать своё.
В данной статье я расскажу в первую очередь о том, как создавался клиент для Хабра, архитектурные и технические решения, их предпосылки и анализ, какие трудности были и в последнюю очередь о функционале приложения.
Предыстория
Все началось с предложения друга попробовать Flutter, а я оказался не против.
Создавать приложение, но какое? На момент создания репозитория мне показалось, что мобильному клиенту Хабра крайне не хватает офлайн режима, собственно это и стало причиной и на этом строится идея всего приложения - смотреть статьи в офлайне и по возможности продолжить чтение с того момента где остановился и слегка настроить отображение текста под себя (задача минимум которую я постарался выполнить).
До этого у меня был опыт пет проектов на React+TypeScript и VanilaJs, поэтому было интересно сравнить React и Flutter.
В момент старта я предполагал, что html это легко и просто, а его отображение — это плевое дело, есть одно “но”: я захотел отображать html нативными виджетами, а не какими-то web-view. Вжух и проект усложнился, но не будем пока об этом.
Выделяем слои
Изначально я не предполагал чего-то сверх сложного. Да, об изолятах в Dart я слышал, но не думал, что буду их использовать (на что Flutter сказал: ты будешь использовать изоляты, т.к. тяжёлые вычисления, парсинг и сетевые запросы мешают ui потоку).
На момент старта я выделил три слоя:
ui
habr-storage - к нему мы обращаемся за информацией, а он сам решает, идти ли в бд или обратиться к апи хабра.
habr-api - обычные запросы по http с обработкой полученных данных.
В принципе, это оказался не такой уж и плохой вариант. Архитектурно похоже на MVVM.
UI
По API Хабр высылает html и иногда js для отображения красивых мегапостов. По этой причине перед мной встала задача отображения html и в идеале красиво.
Для отображения html на Flutter, на сколько мне известно, есть два варианта:
web-view
нативные (для Flutter) виджеты
Я решил пренебречь первым вариантом, так как если бы хотел его использовать, писал приложение на React Native, а хотелось ощутить всю мощь нового фреймворка.
Рендер HTML
Это, на мой взгляд, самая большая проблема. Чтобы отображать некоторые элементы вроде iframe, нужно поизвращаться, либо использовать web-view. На данный момент я не очень доволен, но я просто выдаю пользователю ссылку, и он спокойно может посмотреть, что внутри. Это компромисс, но без этого никуда.
Рендер я выполняю в три этапа:
Парсинг
Препроцессинг
Рендер
Первый и второй этап я стараюсь делать единожды, т.к. они достаточно трудоёмкие.
Парсинг html достаточно тривиальная задача, особенно если парсишь с помощью библиотеки, что я и сделал.
А вот препроцессинг не самый очевидный этап. После того как библиотека прошлась и выдала распарсенный html, хочется просто взять корневой div, пройтись по всем нодам и отобразить. На мой взгляд, это достаточно проблемно, т.к. придется поддерживать множество элементов как самого html, так и комбинаций с css классами. К тому же некоторые авторы иногда любят добавлять изюминки к оформлению своих статей, из-за чего все это выглядит не очень, если отображать "как есть".
Пример необычного html который приходится детектировать:
<div class="spoiler" role="button" tabindex="0">
<b class="spoiler_title">Заголовок спойлера</b>
<div class="spoiler_text">Контент спойлера</div>
</div>
Данный кусок часто встречается в статьях, как и тег details - приходится поддерживать обе версии.
Препроцессинг проходит в 2 этапа:
Конвертация html блоков в дерево смысловых элементов
Оптимизация дерева
Идейно дерево смысловых элементов не особо отличается от html5. В нем есть основной набор высокоуровневых элементов, которые привычны для верстальщика:
Изображение (с подписью)
Абзац
Заголовок
Список (с разделением на упорядоченный/неупорядоченный)
Таблица
Код
Iframe
Цитата
Спойлер
Комментарии
Отображение комментариев - один из самых интересных этапов. Комментариев может быть много и если реализовать их отображение не оптимально, то все будет лагать или рендериться овердофига лет.
Flutter для таких задач имеет аналог RecyclerView из мира Android, и называется он ListView. Каждый элемент он рендерит только когда он виден и не рендерит (и соответственно не тратит мощности аппарата), когда не виден.
Изначально я реализовал просто дерево комментариев, то есть у каждого комментария есть две части: контент и область дочерних элементов. От этой реализации я отказался из-за её нежизнеспособности - скрол лагает, т.к. дочерних элементов почти всегда много.
Текущая реализация, которой я вполне доволен - также отображаем дерево, но лишь один раз в памяти, трансформируем в массив нод-комментариев с известным уровнем вложенности, после строим по нему "плоское" отображение по вычисленной глубине. Получается, что ui выглядит так же, но теперь ListView не обязан отображать все дочерние элементы разом, да и отобразить N отступов не проблема, особенно, когда N известно.
Благодаря такому решению приложение способно отображать дерево комментариев популярных статей, 1000 комментариев - не проблема, главное - скорость интернета.
Статьи
Их отображением я пока не очень доволен. Дело в том, что при некотором количестве очень больших картинок видны лаги. При повседневном чтении обычно это не заметно, но бывают прожорливые статьи. В целом решение есть, и оно такое же, как с комментариями - использовать ListView и разбить статью на "плоские" ноды и отображать только те, которые видны. Работа над этим ведётся, но не спешно.
Habr-API
Я обращаюсь напрямую по адресу мобильной версии m.habr.com/kek/v2/
Из того что поддерживает мобильная версия хабра и не поддерживает приложение с точки зрения API:
Авторизация и доступ к информации пользователя
Хабы
В целом, тут нет ничего особо интересного, кроме моего небольшого возмущения, например, страницы основной ленты, если их не подгружать все разом, могут устареть, пока ты читаешь какой-то пост, и при подгрузке следующей страницы возникают дубликаты, либо посты теряются. Исправлять это приходится на клиенте, что так себе. Лично мое впечатление – API не самый удобный и построен так чтобы делать больше запросов чем нужно.
Да, и в целом Хабр любит обновлять API из-за чего приходится обновлять приложение. Я думал о прокси сервере, чтобы он обрабатывал API Хабра и посылал данные в том формате, которое ожидает приложение. От такого решения я отказался из-за его непрозрачности по отношению к пользователям приложения.
Habr-Storage
На старте я начал использовать Moor (Room, но для Dart) совместно с SQLite, но на каком-то из этапов разработки вдруг понял, что в проекте уже две БД. Вторая – Hive, NoSQL Key-Value база данных, и она чертовски удобна. Когда подключал её как зависимость, чтобы хранить пару настроек приложения (темы и опции отображения текста), я и не представлял, как удобно ей будет пользоваться.
В SQLite я храню html текст статьи, авторов (аватарка, никнейм и прочее) и изображения, остальное лежит в Hive. Отдельно хочу рассказать про изображения.
Изображения я храню лишь частично в SQLite. Сами изображения я храню на файловой системе. В базе хранится url, по которому запрашивается изображение, и path файловой системы. Path я формирую как хеш-код url`а и конкатенирую с текущим временем вставки в миллисекундах. Примерно вот так:
path = str(hash(url)) + str(datetime.now().millisecondsSinceEpoch)
Isolates
По своей природе Dart однопоточный язык, но однопотоком сыт не будешь, поэтому есть возможность работы нескольких потоков, работают они схоже с web-workers и называются Isolate. Обычно их стоит использовать в любой необычной ситуации, вроде HTTP запросов, парсинга JSON, сложные вычисления, так вы уменьшите количество подвисаний вашего приложения на Flutter.
Для организации вычислений вне основного потока вы можете использовать отдельный изолят с какой-то продуманной логикой общения с другими потоками, либо организовать пул потоков-изолятов.
Примечание: Flutter предоставляет возможность быстро запустить изолят выполнить нужную функцию и закрыть изолят. Метод называется compute.
Я пришел к компромиссу – выделил основные операции, которые занимают достаточно много времени, и создал для каждой операции отдельные воркеры. Преимущества данного подхода в двух вещах:
Простота. Она как у метода compute, но при этом производительность на уровне пула потоков, иногда чуть ниже, иногда чуть выше, тут зависит от реализации пула потоков.
Разные операции не мешают друг другу. Гипотетически есть вероятность, что у двух задач может быть одинаковый приоритет, например, middle, но при этом одна из них достаточно долгая, а вторая короткая, и в случае если нам важно, чтобы короткая не дожидалась долгой задачи, пул потоков придется подстраивать ради баланса. Отдельные воркеры в этом плане на мой взгляд удобнее.
Следующие операции я выделил в отдельные воркеры:
Хеширование
Загрузка изображений
Подсветка синтаксиса
Для операции парсинг и препроцессинг пока использую метод compute.
Заключение
На этом в принципе все, что я хотел бы рассказать о внутреннем устройстве приложения.
Данный пост я не задумывал как рекламу, но желание потыкать приложение и посмотреть код может возникнуть, поэтому ссылку на github прикладываю.
А дальше пара скриншотов приложения: