目录
阅读器已经初步完成了核心功能.两三周的时间奋斗,看到效果还不错的.后续添加功能没那么复杂了.
先说说解码服务
解码器目前有mupdf,用于pdf,epub,mobi这些解码.性能高,基本没有看到更强的免费产品了.
tiff解码,需要自己编译,android的tiff解码器编译容易,然后已经用上了.这个tiff并不是市面上容易看到的,解码一张几mb大小的,crate中是有,但不能解码大图,而是能轻松快速地解码4gb的图片的.所以要自己编译,优化解码.一般的最多300mb就会内存溢出了.更别说支持4gb以上的.mupdf手机上测试,最多也是600mb就会停止解码,代码里面写的很清楚,这是一个压缩炸弹.我测试过最大3gb,清明上河图tiff还是看压缩方式,lzw就慢.
djvu,heic之前桌面端是自己编译的,我看是有相关的crate的,后面看能不能直接用了.
针对不同的解码需求,那么是根据文档类型来判断应该加载什么解码器,这些解码器,估计都不能在线程间传递,所以一律在解码线程中初始化了.
所以需要用到use crossbeam_channel::{unbounded, Sender, Receiver}.crossbeam_channel这个库,实现向线程发送数据,在线程外接收数据.避免了解码器在不同的线程间传递.
先看一下数据结构
rust
#[derive(Clone, Debug)]
pub struct RenderPage {
pub key: String,
pub page_info: PageInfo,
pub crop: i32,
pub priority: Priority,
}
impl Hash for RenderPage {
fn hash<H: Hasher>(&self, state: &mut H) {
self.key.hash(state);
}
}
impl PartialEq for RenderPage {
fn eq(&self, other: &Self) -> bool {
self.key == other.key
&& self.page_info.index == other.page_info.index
&& (self.page_info.width - other.page_info.width).abs() < 0.1
&& (self.page_info.height - other.page_info.height).abs() < 0.1
&& (self.page_info.scale - other.page_info.scale).abs() < 0.001
&& self.crop == other.crop
}
}
impl Eq for RenderPage {}
/// 解码任务
pub enum DecodeTask {
/// 加载文档
LoadDocument {
path: PathBuf,
response_tx: Sender<Result<Vec<PageInfo>>>,
},
/// 批量渲染页面
RenderPages {
pages: Vec<RenderPage>,
},
/// 获取大纲
GetOutline {
response_tx: Sender<Result<Vec<crate::entity::OutlineItem>>>,
},
/// 关闭服务
Shutdown,
}
/// 解码结果(原始数据,可以跨线程传递)
pub struct DecodeResult {
pub key: String,
pub page_info: PageInfo,
pub image_data: Vec<u8>,
pub image_width: u32,
pub image_height: u32,
pub links: Vec<Link>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Priority {
Thumbnail = 0, // 最高优先级
FullImage = 1, // 中优先级
Cropped = 2, // 低优先级
}
/// 解码服务 - 单线程解码,通过channel通信
pub struct DecodeService {
task_sender: Sender<DecodeTask>,
result_receiver: Mutex<Receiver<DecodeResult>>,
decode_thread: Option<JoinHandle<()>>,
}
然后就是解码服务的实现
rust
impl DecodeService {
pub fn new() -> Self {
let (task_tx, task_rx) = unbounded::<DecodeTask>();
let (result_tx, result_rx) = unbounded::<DecodeResult>();
// 启动解码线程
let decode_thread = thread::spawn(move || {
Self::decode_loop(task_rx, result_tx);
});
Self {
task_sender: task_tx,
result_receiver: Mutex::new(result_rx),
decode_thread: Some(decode_thread),
}
}
在这里建立一个线程,然后循环读取任务.建立两个通道,可以与线程通信.外部要向线程发送任务,线程解码完成后返回数据可以通过通道向外发送任务.这个设计不复杂,用ai搞半天,它还是乱搞一通.
解码循环:
rust
fn decode_loop(task_rx: Receiver<DecodeTask>, result_tx: Sender<DecodeResult>) {
let mut decoder: Option<Box<dyn Decoder>> = None;
let mut task_queue: VecDeque<RenderPage> = VecDeque::new();
let mut current_visible: HashSet<RenderPage> = HashSet::new();
loop {
// 1. 先检查是否有新任务(非阻塞)
while let Ok(task) = task_rx.try_recv() {
if Self::handle_task(
task,
&mut decoder,
&mut task_queue,
&mut current_visible,
) {
return;
}
}
// 2. 处理队列中的一个任务
if let Some(render_page) = task_queue.pop_front() {
if !current_visible.contains(&render_page) {
info!("[DecodeService] 跳过不可见页: page={}, key={}",
render_page.page_info.index, render_page.key);
continue;
}
if let Some(ref dec) = decoder {
let start_time = Instant::now();
match dec.render_page(&render_page.page_info, render_page.crop != 0) {
Ok((image_data, width, height)) => {
let links = dec.get_page_links(render_page.page_info.index)
.unwrap_or_default();
let duration = start_time.elapsed();
info!(
"[DecodeService] 页面 {} 解码完成,耗时: {:?}, links: {}",
render_page.page_info.index, duration, links.len()
);
let result = DecodeResult {
key: render_page.key.clone(),
page_info: render_page.page_info.clone(),
image_data,
image_width: width,
image_height: height,
links,
};
if result_tx.send(result).is_err() {
info!("[DecodeService] Result channel closed");
return;
}
}
Err(e) => {
info!("[DecodeService] 页面 {} 解码失败: {}", render_page.page_info.index, e);
}
}
}
continue;
}
// 3. 队列为空,阻塞等待新任务
match task_rx.recv() {
Ok(task) => {
if Self::handle_task(
task,
&mut decoder,
&mut task_queue,
&mut current_visible,
) {
break;
}
}
Err(_) => {
info!("[DecodeService] Task channel closed");
break;
}
}
}
}
| Channel | 用途 | 发送者 | 接收者 | 数据类型 |
|---------|------|--------|--------|----------|
| task_sender | 发送管理任务 | UI线程 | 解码线程 | DecodeTask |
| result_receiver | 接收解码结果 | 解码线程 | UI线程 | DecodeResult |
| response_tx | 同步响应 | 解码线程 | UI线程(临时) | 各种结果 |
这里的循环关键,接收到一个请求,然后将请求放入队列handle_task,接着开始针对 队列进行解码处理.解码一个完成后,continue;就会回到循环开始,这样便于优先处理外部的ui解码请求.这个设计是考虑到,当我滚动的时候,不断计算页面可见性,提交任务,快速滚动过后,需要优先处理这些入队列,而不是解码.因为有可能之前可见的现在不可见了.
队列处理
rust
fn handle_task(
task: DecodeTask,
decoder: &mut Option<Box<dyn Decoder>>,
task_queue: &mut VecDeque<RenderPage>,
current_visible: &mut HashSet<RenderPage>,
) -> bool {
match task {
DecodeTask::LoadDocument { path, response_tx } => {
info!("[DecodeService] Loading document: {:?}", path);
match PdfDecoder::open(&path) {
Ok(pdf_decoder) => {
let boxed_decoder = Box::new(pdf_decoder);
let pages_result = boxed_decoder.get_all_pages();
*decoder = Some(boxed_decoder);
let first_page = if pages_result.is_ok() && !pages_result.as_ref().unwrap().is_empty() {
Some(pages_result.as_ref().unwrap()[0].clone())
} else {
None
};
let _ = response_tx.send(pages_result);
if let Some(fp) = first_page {
if let Some(ref dec) = decoder {
Self::save_cover_thumbnail(&path, dec, &fp);
}
}
}
Err(e) => {
let _ = response_tx.send(Err(e));
}
}
false
}
DecodeTask::RenderPages { pages } => {
debug!("[DecodeService] 收到批量渲染任务: {} 页", pages.len());
// 1. 更新当前可见页集合(用于后续验证)
current_visible.clear();
current_visible.extend(pages.iter().cloned());
// 2. 将新任务加入队列(去重:检查队列中是否已存在相同key的任务)
for page in pages {
let already_queued = task_queue.iter().any(|p| p.key == page.key);
if !already_queued {
debug!("[DecodeService] 加入队列: page={}, key={}", page.page_info.index, page.key);
task_queue.push_back(page);
} else {
info!("[DecodeService] 跳过重复任务: page={}, key={}", page.page_info.index, page.key);
}
}
info!("[DecodeService] 当前队列长度: {}, 可见页数: {}",
task_queue.len(), current_visible.len());
false
}
DecodeTask::GetOutline { response_tx } => {
if let Some(ref dec) = decoder {
let outline_result = dec.get_outline_items();
let _ = response_tx.send(outline_result);
} else {
let _ = response_tx.send(Ok(Vec::new()));
}
false
}
DecodeTask::Shutdown => {
info!("[DecodeService] Shutting down decode thread");
true
}
}
}
这里的注释写的清楚了,循环开始,先处理任务,这里有几种任务类型,加载文档,获取大纲,与解码数据入队三类.
重点在解码入队,就是上面的循环中的重点了.入队是把此次可见的列表更新,这样如果之前还有队列的数据未解码,不是通过回调到ui线程去判断是不是可见,而是依据这个列表来判断是不是可见.这点还有待考虑.
把这次的解码请求的数据入队,然后在循环中按顺序解码,或按优先级(目前未实现)
解码成功,向外发送结果
rust
pub fn try_recv_result(&self) -> Option<DecodeResult> {
self.result_receiver.lock().unwrap().try_recv().ok()
}
这里只管发,不管谁接收.
加载文档
采取的方式是阻塞ui的方式,就是与ui线程一样,等加载结果
rust
pub fn load_pdf<P: AsRef<Path>>(&self, path: P) -> Result<Vec<PageInfo>> {
let (response_tx, response_rx) = unbounded();
self.task_sender
.send(DecodeTask::LoadDocument {
path: path.as_ref().to_path_buf(),
response_tx,
})
.map_err(|e| anyhow::anyhow!("Failed to send load task: {}", e))?;
response_rx
.recv()
.map_err(|e| anyhow::anyhow!("Failed to receive load response: {}", e))?
}
虽然是阻塞,但不是在ui线程解码,依然是发送任务给解码线程,任务的类型是DecodeTask::LoadDocument,上面处理完这种类型的结果,再通过通道发送出来,这里建立了一个临时的通道,接收是阻塞的.然后返回给外部.
解码的请求方法:
rust
pub fn render_pages(&self, pages: Vec<RenderPage>) {
if !pages.is_empty() {
let _ = self.task_sender.send(DecodeTask::RenderPages { pages });
}
}
到这里,解码服务就差不多了,还有销毁,析构等.相对于之前compose,这个服务要简单不少,之前的设计是三个队列,这样不需要优先队列,而是优先级高的队列处理完了才处理低的.然后分配任务的线程一个,解码线程一个,共两个线程.使用了kotlin协程的通道实现的类似操作.
解码器
解码器是要设计成多类型的支持的.
rust
pub trait Decoder {
/// 获取文档页数
fn page_count(&self) -> usize;
/// 获取页面原始尺寸
fn get_page_size(&self, index: usize) -> anyhow::Result<(f32, f32)>;
/// 获取所有页面信息
fn get_all_pages(&self) -> anyhow::Result<Vec<PageInfo>>;
/// 渲染完整页面,返回原始RGBA像素数据
/// - page: 页面信息
/// - crop: 是否使用切边
fn render_page(&self, page: &PageInfo, crop: bool) -> anyhow::Result<(Vec<u8>, u32, u32)>;
/// 渲染页面区域(用于分块渲染),返回原始RGBA像素数据
/// - page_index: 页面索引
/// - region: 要渲染的区域(PDF坐标系)
/// - scale: 缩放比例
fn render_region(
&self,
page_index: usize,
region: Rect,
scale: f32,
) -> anyhow::Result<(Vec<u8>, u32, u32)>;
/// 获取页面链接
fn get_page_links(&self, page_index: usize) -> anyhow::Result<Vec<Link>>;
/// 获取页面文本(用于搜索/TTS)
fn get_page_text(&self, page_index: usize) -> anyhow::Result<String>;
fn get_outline_items(&self) -> anyhow::Result<Vec<OutlineItem>>;
/// 关闭文档
fn close(&mut self);
}
fn render_page(&self, page: &PageInfo, crop: bool) -> anyhow::Result<(Vec<u8>, u32, u32)>这个结果是考虑到,解码成功后需要转为slint或其它ui框架可用的数据,不如直接这样.原来是DynamicImage,再转换多次,性能有损耗.
解码页面
这部分就是调用mupdf
rust
fn render_page(&self, page: &PageInfo, crop: bool) -> Result<(Vec<u8>, u32, u32)> {
debug!("[PDF] Rendering page {} with crop={}", page.index, crop);
let document = self.document.borrow();
let mupdf_page = document.load_page(page.index as i32)?;
let bounds = if crop && page.crop_bounds.is_some() {
page.crop_bounds.unwrap()
} else {
let b = mupdf_page.bounds()?;
Rect::new(b.x0, b.y0, b.x1, b.y1)
};
let scale = page.scale * 2.0; // DPI scale for retina
let matrix = Matrix::new(scale, 0.0, 0.0, scale, 0.0, 0.0);
let width = ((bounds.width()) * scale) as i32;
let height = ((bounds.height()) * scale) as i32;
let colorspace = Colorspace::device_rgb();
let mut pixmap = Pixmap::new(&colorspace, 0, 0, width, height, true)?;
pixmap.clear()?;
let mut device = Device::from_pixmap(&pixmap)?;
mupdf_page.run(&mut device, &matrix)?;
Ok(mupdf_to_pixels(&pixmap))
}
这是直接将一个page解码为一张图片了.然后转像素
rust
pub fn mupdf_to_pixels(pixmap: &Pixmap) -> (Vec<u8>, u32, u32) {
let width = pixmap.width() as u32;
let height = pixmap.height() as u32;
let samples = pixmap.samples();
let n = pixmap.n() as usize; // 每个像素的组件数
let mut buffer = vec![0u8; (width * height * 4) as usize];
for y in 0..height {
for x in 0..width {
let src_idx = ((y * width + x) as usize) * n;
let dst_idx = ((y * width + x) as usize) * 4;
if src_idx + n <= samples.len() && dst_idx + 4 <= buffer.len() {
if n == 4 {
// RGBA
buffer[dst_idx] = samples[src_idx];
buffer[dst_idx + 1] = samples[src_idx + 1];
buffer[dst_idx + 2] = samples[src_idx + 2];
buffer[dst_idx + 3] = samples[src_idx + 3];
} else if n == 3 {
// RGB
buffer[dst_idx] = samples[src_idx];
buffer[dst_idx + 1] = samples[src_idx + 1];
buffer[dst_idx + 2] = samples[src_idx + 2];
buffer[dst_idx + 3] = 255;
} else {
// 灰度或其他,复制到所有通道
buffer[dst_idx] = if n > 0 { samples[src_idx] } else { 255 };
buffer[dst_idx + 1] = buffer[dst_idx];
buffer[dst_idx + 2] = buffer[dst_idx];
buffer[dst_idx + 3] = if n > 1 { samples[src_idx + 1] } else { 255 };
}
}
}
}
(buffer, width, height)
}
这些没什么特殊的.
解码器的结构
我觉得比上面的重要一些.关系到相关的设计,滚动,缩放等
rust
pub struct PdfDecoder {
document: RefCell<Document>,
page_count: usize,
pages_info: Vec<PageInfo>,
}
rust
impl PdfDecoder {
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
info!("[PDF] Opening document: {:?}", path.as_ref());
let mut document = Document::open(path.as_ref().to_str().unwrap())?;
let path_str = path.as_ref().to_string_lossy().to_lowercase();
if path_str.ends_with(".epub") || path_str.ends_with(".mobi") {
document.layout(1024.0, 1280.0, 25.0)?;
}
let page_count = document.page_count()? as usize;
info!("[PDF] Document opened with {} pages", page_count);
// 预加载所有页面尺寸
let mut pages_info = Vec::with_capacity(page_count);
for i in 0..page_count {
let page = document.load_page(i as i32)?;
let bounds = page.bounds()?;
let width = bounds.x1 - bounds.x0;
let height = bounds.y1 - bounds.y0;
pages_info.push(PageInfo::new(i, width, height));
}
Ok(Self {
document: RefCell::new(document),
page_count,
pages_info,
})
}
}
这个设计是沿用vudroid当年的方式,一性次把所有的页面高宽全取出来.然后页面也是根据这个来计算的.后续所有的缩放,排列,都在这个基础上组织新页面.
解码器其它的部分就调用api就好了.
状态管理类
我也不太确定这么叫是不是合适.至少我在compose的实现上是这样的,所以直接翻译过来相应的实现就可以了
rust
pub struct PageViewState {
/// 页面缓存
pub cache: Rc<PageCache>,
/// 所有页面
pub pages: Vec<Page>,
/// 解码服务
pub decode_service: Arc<DecodeService>,
/// 滚动方向
pub orientation: Orientation,
/// 视图偏移 (x, y)
pub view_offset: (f32, f32),
/// 缩放比例
pub zoom: f32,
/// 是否启用切边
pub crop: i32,
/// 文档总宽度
pub total_width: f32,
/// 文档总高度
pub total_height: f32,
/// 视图尺寸 (width, height)
pub view_size: (f32, f32),
/// 预加载距离(屏幕数)
pub preload_screens: f32,
/// 当前可见页面索引列表
pub visible_pages: Vec<usize>,
/// 页面链接缓存,以页码为键存储链接列表
pub page_links: Rc<RefCell<HashMap<usize, Vec<Link>>>>,
pub outline_items: Vec<OutlineItem>,
}
属性
pub cache: Rc<PageCache> 这是用于判断当前页有没有在内存中缓存了图片了
pub pages: Vec<Page>,这个是与上面解码器返回的所有的页面生成的对应ui上的逻辑分页
pub view_offset: (f32, f32) 这是视图的偏移量,一旦滚动,比如向上拖动,y方向就是负的.通过这个计算当前应该显示哪页
上面几个比较重要.接着就是
打开文档,初始化pages
rust
pub fn open_document<P: AsRef<Path>>(&mut self, path: P) -> anyhow::Result<()> {
Self::reset(self);
// 加载文档并获取页面信息
let pages_info = self.decode_service.load_pdf(path)?;
let pages: Vec<Page> = pages_info
.into_iter()
.map(|info: crate::decoder::PageInfo| Page::new(info, 0.0, 0.0, 0.0, 0.0))
.collect();
self.pages = pages;
// 加载outline
self.outline_items = self.decode_service.get_outline()?;
Ok(())
}
这里的page比较重要,生成这个后,后面的滚动,缩放,显示都必须依赖它.试想一下.一个page比如是1024*1024的,然后这时放大2,就是2048*2048,那么我直接在这个page上放大,后面的滚动就容易操作了.解码我只要根据2048*2048,得到它与原始页面的缩放就好处理了.compose还有分块,也是我根据这个页面逻辑切分块,比如512*512为一块,原来是4块,放大2倍后就是16块.解码的时候,只要知道块的相对当前page的位置,然后与缩放值计算出它的偏移就行了.
打开文档后,有了这些数据.那么如何触发开始渲染.
渲染
当然是由view触发的,这个类只是管理一些数据,状态,分发等.
由slint的on_viewport_changed触发视图的大小变化,还有打开文档on_open_file,缩放,滚动条等事件,发送到main.rs的监听.这些地方,调用
borrowed_state.update_view_size(width, height, zoom, false);//视图窗口变化触发的
borrowed_state.update_visible_pages();
我先设置整个视图窗口的大小,就是文档最多的渲染区域.然后更新文档的可见页.
update_view_size就是设置大小,然后就是是否有变化,有变化的话,先触发布局,这里不是真的ui布局,是逻辑上的排列
如果是滚动,或点击动条,可能是
page_view_state.update_offset(offset_x, offset_y);//更新偏移量.
page_view_state.update_visible_pages();
update_view_size
rust
pub fn update_view_size(&mut self, width: f32, height: f32, zoom: f32, force: bool) {
let size_changed = self.view_size.0 != width || self.view_size.1 != height;
let zoom_changed = (self.zoom - zoom).abs() > 0.001;
if !size_changed && !zoom_changed && !force {
info!(
"[PageViewState] don't update_view_size. w-h:{:?}-{:?}, zoom:{:?}",
width, height, zoom
);
return;
}
self.view_size = (width, height);
self.zoom = zoom;
info!(
"[PageViewState] update_view_size. w-h:{:?}-{:?}, zoom:{:?}, view:{:?}-{:?}",
width, height, zoom, self.view_size.0, self.view_size.1
);
self.recalculate_layout();
}
rust
recalculate_layout就是判断方向是横向还是竖向的
match self.orientation {
Orientation::Vertical => self.layout_vertical(),
Orientation::Horizontal => self.layout_horizontal(),
}
垂直布局
rust
fn layout_vertical(&mut self) {
let view_width = self.view_size.0;
let scaled_width = view_width * self.zoom;
let mut current_y = 0.0;
for page in &mut self.pages {
let page_width = page.info.get_width(self.crop == 1);
let page_height = page.info.get_height(self.crop == 1);
// 计算缩放比例
let scale = scaled_width / page_width;
let scaled_height = page_height * scale;
// 更新页面
let bounds = Rect::new(0.0, current_y, scaled_width, current_y + scaled_height);
page.update(scaled_width, scaled_height, bounds);
page.info.scale = scale;
current_y += scaled_height;
}
debug!(
"[PageViewState] layout_vertical.end:total_width:{:?}-total_height:{:?}",
scaled_width, current_y
);
self.total_width = scaled_width;
self.total_height = current_y;
}
这个方式就是通过从页面0开始到结束,根据缩放,然后得到垂直的所有高度,与每一个page对应的bound,其实就是它的图片最终应该渲染在什么位置.只要每次缩放等影响属性变化,就要重新调整这些数据一次,可以想像为,所有的页面都有了占位的空间大小了.剩下的事就是滚动,偏移到这里的时候,把它对应的页面图片解码出来,然后显示.这就是所谓的布局了.
到这里未触发解码.
可见页的计算
rust
pub fn update_visible_pages(&mut self) {
self.visible_pages.clear();
let (offset_x, offset_y) = self.view_offset;
let (view_width, view_height) = self.view_size;
// 计算预加载区域
let preload_distance = match self.orientation {
Orientation::Vertical => view_height * self.preload_screens,
Orientation::Horizontal => view_width * self.preload_screens,
};
// 可见区域(包含预加载)
let visible_rect = match self.orientation {
Orientation::Vertical => Rect::new(
-offset_x,
-offset_y,
-offset_x + view_width,
-offset_y + view_height + preload_distance,
),
Orientation::Horizontal => Rect::new(
-offset_x,
-offset_y,
-offset_x + view_width + preload_distance,
-offset_y + view_height,
),
};
// 使用二分查找优化
let first = self.find_first_visible(&visible_rect);
let last = self.find_last_visible(&visible_rect);
debug!("[PageViewState] update_visible_pages: first={}, last={}, total_pages={}",
first, last, self.pages.len());
let mut render_pages = Vec::new();
if first <= last && first < self.pages.len() {
for i in first..=last.min(self.pages.len() - 1) {
self.visible_pages.push(i);
let page = &self.pages[i];
let key = generate_thumbnail_key(page);
if page.width > 0.0 && page.height > 0.0 {
// 先检查缓存中是否已有该页面
if self.cache.get_thumbnail(&key).is_none() {
debug!("[PageViewState] 需要解码: page={}, key={}", page.info.index, key);
render_pages.push(RenderPage {
key,
page_info: page.info.clone(),
crop: self.crop,
priority: Priority::Thumbnail,
});
} else {
debug!("[PageViewState] 页面已在缓存中: page={}, key={}", page.info.index, key);
}
}
}
}
info!("[PageViewState] update_visible_pages完成: visible_pages={:?}", self.visible_pages);
// 批量提交解码任务
if !render_pages.is_empty() {
debug!("[PageViewState] 批量提交 {} 个解码任务:", render_pages.len());
self.decode_service.render_pages(render_pages);
}
}
这里的设计思路就是根据视图窗口的大小,目前滚动偏移,得到当前显示的页面与预加载的页面是哪些.然后组成一个列表,向解码服务发送请求.这里的列表会过滤已经解码过的数据.所以还有一个列表是当前可见页的页码,用于ui的显示处理.
偏移量,根据二分查找,可以快速定位到当前向上,或向下查找可见页,时间是logn,还算可以的,即使上千页的书,也不慢.
可以看到,对ui暴露的重要的几个方法.当ui上滚动,缩放,页面跳转,先触发偏移量,是否重新布局,然后计算可见页,最后refresh_view.就可以了
refresh_view已经涉及到了slint了.
到这里,发现,解码没有回调,前面的解码发送的数据到哪里去了.如何最后渲染到ui上?
ui显示
要显示,先要接收解码通过通道发送的数据.这里采用一个定时器去检测.因为通道接收是阻塞的
所以在main函数里面有一个定时器
rust
let decode_timer = {
let weak_app = app.as_weak();
let state_clone = Rc::clone(&page_view_state);
let timer = slint::Timer::default();
let timer_count = Rc::new(RefCell::new(0));
let timer_count_clone = Rc::clone(&timer_count);
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(100),
move || {
let mut count = timer_count_clone.borrow_mut();
*count += 1;
if *count % 10 == 0 {
debug!("[Main] 定时器运行中... count={}", *count);
}
if let Some(app) = weak_app.upgrade() {
// 处理所有待处理的结果
let mut had_results = false;
let mut result_count = 0;
{
let mut state = state_clone.borrow_mut();
// 检查是否有结果(不消费)
while let Some(result) = state.decode_service.try_recv_result() {
had_results = true;
result_count += 1;
debug!("[Main] 收到解码结果: page={}, key={}, size={}x{}",
result.page_info.index, result.key, result.image_width, result.image_height);
// 将原始数据转换为Slint图像
let image = image::RgbaImage::from_raw(
result.image_width,
result.image_height,
result.image_data,
);
if let Some(rgba_image) = image {
let dynamic_image = image::DynamicImage::ImageRgba8(rgba_image);
let slint_image = convert_to_slint_image(&dynamic_image);
// 更新缓存
state.cache.put_thumbnail(result.key.clone(), slint_image);
info!("[Main] 已更新缓存: key={}", result.key);
// 更新链接
state.page_links
.borrow_mut()
.insert(result.page_info.index, result.links);
} else {
error!("[Main] 创建RgbaImage失败: page={}", result.page_info.index);
}
}
}
if had_results {
debug!("[Main] 处理了 {} 个解码结果,刷新视图", result_count);
refresh_view(&app, &state_clone.borrow());
}
}
},
);
timer // 返回timer以保持其存活
};
由于解码的数据不能转为slint可显示的数据再传出来,不符合trait,所以只能在ui接收到相关的数据后convert_to_slint_image转为slint可用的,这个转换耗时还能接受,所以并不太卡.
转换后,放到缓存中备用.这时未产生渲染.
渲染
rust
fn refresh_view(app: &MainWindow, page_view_state: &PageViewState) {
let state = page_view_state;
if state.pages.is_empty() {
debug!("[Main] No pages to refresh");
return;
}
debug!("[Main] refresh_view: visible_pages={:?}", page_view_state.visible_pages);
let rendered_pages = page_view_state.visible_pages
.iter()
.filter_map(|&idx| page_view_state.pages.get(idx))
.map(|page| {
// 尝试从缓存获取图像,如果不存在则使用默认图像
let key = generate_thumbnail_key(page);
let image = {
if let Some(cached_image) = page_view_state.cache.get_thumbnail(&key) {
debug!("[Main] 从缓存获取图像: key={}, page={}", key, page.info.index);
cached_image.as_ref().clone()
} else {
debug!("[Main] 缓存中没有图像,显示页码: key={}, page={}", key, page.info.index);
slint::Image::default()
}
};
PageData {
x: page.bounds.left,
y: page.bounds.top,
width: page.width,
height: page.height,
image,
page_index: page.info.index as i32,
}
})
.collect::<Vec<_>>();
info!(
"[Main] refresh_view {} page_models",
rendered_pages.len()
);
let model = Rc::new(VecModel::from(rendered_pages));
app.set_document_pages(ModelRc::from(model));
app.set_page_count(page_view_state.pages.len() as i32);
app.set_zoom(page_view_state.zoom);
if let Some(first_visible) = page_view_state.get_first_visible_page() {
app.set_current_page((first_visible + 1) as i32); // UI expects 1-based page numbers
}
let (total_width, total_height) = (page_view_state.total_width, page_view_state.total_height);
app.set_total_width(total_width);
app.set_total_height(total_height);
let (offset_x, offset_y) = (page_view_state.view_offset.0, page_view_state.view_offset.1);
app.set_scroll_events_enabled(false);
app.set_offset_x(offset_x);
app.set_offset_y(offset_y);
app.set_scroll_events_enabled(true);
debug!(
"[Main] refresh_view.offset: ({}, {}), total.w-h: ({}, {})",
offset_x, offset_y, total_width, total_height
);
}
这个方法就是在前面监听的各种事件完成后,然后再都要调用的方法.它就是根据state里面计算的可见页,去查询内存中接收到的数据,形成一个列表,然后发送给slint去显示.
in property <[PageData]> document-pages;这是在slint声明的的,对应这里的set_document_pages
PageData就是最终要显示的数据了.
rust
DocumentView {
horizontal-stretch: 1;
pages: root.document-pages;
total-width: root.total-width;
total-height: root.total-height;
offset-x <=> root.offset-x;
offset-y <=> root.offset-y;
enable-scroll-events: root.scroll-events-enabled;
viewport-height <=> root.viewport-height;
viewport-changed(width, height) => { root.viewport-changed(width, height); }
scroll-changed(x, y) => { root.scroll-changed(x, y); }
page-down() => { root.page-down(); }
page-up() => { root.page-up(); }
page-clicked(x, y, page_index) => { root.page-clicked(x, y, page_index); }
}
documentview
它里面包含了一个滚动的view,然后就是page的显示
rust
ScrollView {
width: root.width;
height: root.height;
viewport-width: max(root.total-width, self.width);
viewport-height: max(root.total-height, self.height);
viewport-x <=> root.offset-x;
viewport-y <=> root.offset-y;
mouse-drag-pan-enabled: true;
content := Rectangle {
width: max(root.total-width, scroller.visible-width);
height: max(root.total-height, scroller.visible-height);
background: #ffffff;
clip: true;
for page in pages: Rectangle {
x: page.x * 1px;
y: page.y * 1px;
width: page.width * 1px;
height: page.height * 1px;
border-width: 1px;
border-color: #d0d0d0;
clip: true;
//background: #f5f5f5;
if page.image.width > 0 && page.image.height > 0: Image {
width: parent.width;
height: parent.height;
source: page.image;
image-fit: fill;
}
// 显示页码(如果没有图片)
if !(page.image.width > 0 && page.image.height > 0): Text {
text: "Page " + (page.page_index + 1);
font-size: 24px;
color: #999999;
horizontal-alignment: center;
vertical-alignment: center;
}
TouchArea {
pointer-event(event) => {
if event.kind == PointerEventKind.down && event.button == PointerEventButton.left {
//debug("down.event", (self.mouse-x / 1px), (self.mouse-y / 1px), event);
root.page-clicked(self.mouse-x / 1px, self.mouse-y/ 1px, page.page_index);
}
}
}
}
}
scrolled => {
if (root.enable-scroll-events) {
root.desired_offset_x = scroller.viewport-x;
root.desired_offset_y = scroller.viewport-y;
if (!scroll_timer.running) {
scroll_timer.running = true;
} else {
scroll_timer.restart();
}
}
}
}
重要的应该是x: page.x * 1px;y: page.y * 1px; 这样可以在它具体显示的时候知道偏移量.具体slint如何处理这些东西的,要看它的文档了.
对每一页设置了一个TouchArea,这样可以处理链接等的点击事件了.
这里省事的地方是滚动这些slint处理好了,不像compose要自己处理所有的手势.省不少力.当然slint用ai助手也写不明白,字体,鼠标事件它总是找不到正确的方法.
总结
整个完成下来的感觉就是快.
写代码少,完成的功能多.
运行速度快.
解码服务只关心解码,完成后通道发出去后不管了.
状态管理只管页面可见性计算,布局,缓存这些,剩下怎么显示不管了.
slint只管数据来了我显示,其它通过各种事件,通知给main中的rust监听代码去触发偏移,可见性计算.
这几部分分的真开.你想耦合都不太容易.
做compose阅读器的时候,很容易把这几部分的东西耦合在一起.我想主要是由于slint而被迫拆分的,如果是其它的ui框架,纯rust实现,代码又耦合在一起了.另一部分也是由于rust.
还有不少功能没有实现.
比如分块布局,如果没有这个,超大的tiff图片就无法显示,虽然tiff解码器有了,可以快速解码,但依然无法一次性渲染出整块图片的.如果再放大就会内存爆炸.所以要上大的tiff,就要先完成分块解码的逻辑,分块就会影响布局和解码,可见性的计算等等.在compose里面是实现了的.只是现在没有迁移过来.
tts,jvm的实现是通过调用系统的命令,mac/windows/linux都有各自的朗读功能,写一个适配命令就可以了,虽然效果没有android那么好,还算可用.
切边没有实现,在电脑上看文档切边不是那么迫切,手机上是比较迫切的,因为屏幕小.
webdav也没那么迫切的需求,尤其epub目前还做不到页码同步,因为layout的高宽不一样,会导致整个文档的页数变化.我又不想用相同的高宽.
后续准备实现的更大优先级的是工具,tts.然后才是图片查看器
文档加密/解码,还有文档的拆分,合并,这些mupdf已经有了,compose也实现了.然后就是mobi/azw3转为epub,这个需要编译库,rust再看看有没有现成的.
最后就是等mupdf-rs的作者搞写了字体,这样就可以打开features,显示epub这些内容了.然后可以放弃pdf expert了,这是收费应用,贵的很.