目录
[UI 交互流程](#UI 交互流程)
桌面pdf阅读器的制作原因
之前用compose写pdf阅读器https://blog.csdn.net/archko/article/details/151873466?spm=1001.2014.3001.5501,把更早的android view取代了,顺便利用了compose的跨平台特性,写了桌面版.但遇到了几个问题.
- 桌面版发生了内存泄露,暂不清楚是不是框架的问题,解码这些共享代码,在手机上没有出现问题.
- 缩放无法做到之前的pdf阅读器的效果,因为布局渲染的原因,只能说接近,放大10倍后移动会有些卡.
缩放的问题还算好的,其它方面的流畅度已经比之前的强了.
mupdf解码是非常快,但要复制数据到jni,然后再传到compose,一层一层,导致最终的效果没那么好.
主要是桌面的版,内存泄露.还有就是windows的版本编译mupdf,tiff,heic太困难了,试了好久没有编译成功,mupdf成功了,但是jni调用失败.最终还是先放弃了,自己也没有windows电脑了.
用rust实现一个pdf阅读器,主要是因为看重rust的性能.
选择ui框架
rust也有一些ui框架,但能称得上框架的可能只有slint,完善的文档,支持四种风格,虽然material有点怪.其它的大概只是一个底层api的简单ui封装.
解码,页面处理等这些完成的情况下.主要是要绘制到ui上.所以ui框架的替换也容易,于是就试了几个.
egui
代码简洁,使用eframe,主要问题在于即时模式,静止的时候cpu就上来了,风扇一直转.放弃
floem
上次0.2.0还是24年11月发布的,代码有看到更新,但是似乎不太积极,还没发新版.文档一般.api还有所欠缺.grid可以用flexbox轻松搞定.中文支持没问题,ui不太美观.
在最后绘制阶段,image如何绘制为img,遇到了问题,下载源码,ai直接指出,有些api只能内部用,使用image先转png,bmp等再渲染出来,耗时无法忍受,一个页3秒了,有人提了push不要img,被拒绝了.于是放弃.
gpui
与floem一样,是在代码编辑器中产生的.没有试,因为电脑太老了,不太支持.官方文档相对要完善不少,界面也漂亮一些了.后续有可能还是尝试一下的.
slint
一年前就学习过,看过文档,运行过示例.当时觉得ide支持较差,还要学习一门新的语言,太费时了.还是收费,现在桌面端改为不收费了.
中文支持遇到了问题.在m2机器上,显示方块.只能加入默认字体,试了好多种,不能逻辑映射,只能用固定的字体,要么系统安装,要么打包.
grid就比较挫了,要提前算好行列.
slint与rust的交互有点不太直接,通过各种事件绑定.
slint不太适合那些高频交互,游戏类的,手势交互相对弱.适合一些工具类的.
其它的ui框架不太想试了.tauri试过,它基于webview,放弃.我不喜欢web.
阅读器设计
因为前面有提到,ui框架还有考虑要换,所以天然与其它的部分是分开的.尤其slint就触发了这种隔离了.所以现在分为两部分.
ui部分,slint的文档照着把一些元素像html那样声明出来就可以了.它已经处理好比如滚动,视图变化等,main.rs里监听就可以了.然后自己刷新视图.
阅读器的核心部分
我还是参考了之前compose的设计.documentview用于显示,剩下的全部view_state来管理所有的页面,它连接了解码器.
我用ai总结了一下现在的设计.
文档加载和初始化流程
- 用户选择文件 →
setup_open_handler调用rfd::FileDialog选择 PDF/EPUB/MOBI 等文件 - 打开文档 →
PageViewState::open_document()调用DecodeService::load_pdf() - 文档解析 →
DecodeService::handle_task(LoadDocument)创建PdfDecoder,调用PdfDecoder::open()使用 MuPDF 库解析文档 - 预加载页面信息 →
PdfDecoder::open()遍历所有页面,预加载PageInfo(尺寸、索引等) - 生成封面缩略图 → 保存到用户数据目录的
RReader/images/下 - 创建页面对象 → 将
PageInfo转换为Page对象,存储在PageViewState.pages中 - 加载大纲 → 获取 PDF outline 信息
视图布局和显示流程
- 视图大小更新 →
viewport_changed回调调用PageViewState::update_view_size() - 重新计算布局 → 根据滚动方向(垂直/水平)和缩放比例计算页面位置和尺寸
- 更新可见页面 →
PageViewState::update_visible_pages()使用二分查找确定可见页面列表 - 预加载区域 → 包含屏幕大小的预加载区域,避免加载延迟
解码和渲染流程
- 提交解码任务 →
DecodeService::render_pages()批量提交RenderPage任务到解码队列 - 后台解码 → 解码线程循环处理任务:
- 检查新任务(
task_rx.try_recv()) - 处理一个队列任务(
task_queue.pop_front()) - 验证任务是否还在可见页面中
- 调用
PdfDecoder::render_page(),或其它的比如djvudecoder,tiff等解码
- 检查新任务(
- 页面渲染 →
PdfDecoder::render_page()计算裁剪区域和缩放- 使用 MuPDF 的
Pixmap和Device渲染到 RGBA 像素缓冲区 - 调用
mupdf_to_pixels()转换为 Rust Image 格式
结果处理和显示流程
- 定时器轮询 → 每100ms 检查解码结果(
DecodeService::try_recv_result()) - 转换图像格式 → 将原始像素数据转换为
image::RgbaImage然后转换为slint::Image - 更新缓存 →
ImageCache::put_thumbnail()将图像存入 LRU 缓存 - 获取链接信息 → 同时提取页面链接信息,存储在
PageViewState::page_links - 刷新UI →
refresh_view()更新 UI 页面模型:- 从缓存获取图像或显示页码文本
- 更新滚动位置、大小、缩放等状态
- 通过 Slint 数据绑定刷新
DocumentView
UI 交互流程
- 滚动/缩放 → 触发
update_visible_pages()→ 提交新解码任务 - 翻页 →
jump_to_page()→ 更新偏移并刷新可见页面 - 点击链接 → 解析链接 → 可能跳转到指定页面
关键性能优化
- 异步解码: 多线程解码,不阻塞UI
- LRU缓存: 自动清理不常用图像
- 预加载: 提前解码一屏不可见页面
- 分块渲染: 大页面分块减少内存使用(未使用)
- 去重任务: 避免重复解码同一页面
其中最难的是多线程解码,因为mupdf只支持单线程的,其实就是ui线程与解码线程两个.而mupdf不允许在线程间传递,trait不满足,这是最恶心的点.就算用ai也是搞半天不成功.
最后的效果就是,打开app后,100mb内存占用,打开文档,在300mb左右.然后回收也快,没有内存泄露.运行效果是真的非常快,整个大图片解码20ms,渲染转换也是10ms以内.大概不到30ms就完成解码到转换slint可显示的图片了.然后还有预加载,这样实际的翻页效果与pdf expert这个mac上号称最快的对比,不落下风.
开发过程中遇到几个问题:
diesel这个数据库真恶心,花了好多时间,最后放弃,官方文档也是,看着好像可以,一步一步操作下来,结果报实体类找不到.然后换了sea.
解码线程的问题,最终是靠付费的ai搞定了.
中文,靠固定宋体ttf解决了.
mupdf字体链接失败,目前无法解决,等更新.除了一些特殊的非扫描版pdf与epub/mobi这些,都还好,多数非扫描版还是没问题的.
这次的代码没有花多少时间.主要有几个原因:
功能还略少一些,比如没有分块渲染,直接整个页面渲染图片.也不处理缩略图了.因为我发现,解码到渲染是真的太快了,可以直接省略这步.10年前的老机器了,依然很快.
没有了gradle,这个是真恶心,慢,还加载一堆看似无关的依赖.
没有实现tts朗读.
没有实现文字选择识别,复制功能.
虽然功能少了,但之前的相同核心功能的实现花费是这次的几倍.与有没有ai无关.
解码,渲染,缓存,布局这些流程相当于是从原来的compose直接翻译过来的,不会有太多的坑.加上ai的辅助,很快就完成了.
rust的ui框架写代码确实太快了,比compose的那套不知道快多少倍.代码量还少的多.虽然都是号称声明式的,但compose实在太耗时了.swift也是一样的.
后续文章再具体分析代码