目录
桌面端不打算用kmp实现了,虽然已经完成了90%的工作,但运行效率是真不行.先是内存占用太大.其次是线程数太多,在mac上启动后53个线程,app本身就两三个,剩下的是jvm等的.最重要的是cpu居高不下,只要动一动就是立刻大涨,回落很慢.
相比同类的比如fbreader,pdf expert这些,内存占用小,cpu使用率低,目前只有rust写的可以与之相比,所以打算用rust+slint写了.完成了50%了.
android端还是进一步优化.
mapcompose
通过mapcompose,github.com/p-lr/MapCompose.git的源码来分析我的阅读器在实现方面与设计方面的优缺点.
mapcompose的作者在github也是多种实现,从最早的mapview是基于view系统的,到mapcompose是基于compose的,现在它也有kmp的多平台实现的mapview.
mapcompose的实现主要是瓦片金字塔方式.lod.
瓦片金字塔是地图瓦片的核心组织方式:将地图按缩放级别(Zoom Level)分层,每层按固定网格切割为瓦片,级别越高(Zoom 越大)瓦片越细、细节越丰富。MapCompose 对瓦片金字塔的处理如下:
- 标准 OSM 瓦片金字塔适配:严格遵循 OpenStreetMap 瓦片规范(EPSG:3857 投影、256x256 瓦片尺寸、Zoom 0-19 级别),可直接对接 OSM、Mapbox、高德 / 百度(需转换投影)等标准瓦片服务。
- 声明式层级配置:通过 Compose 组件参数(如
zoomRange)声明瓦片金字塔的缩放级别范围,底层自动匹配对应级别瓦片的 URL 模板。 - 瓦片坐标转换:内置经纬度 ↔ 瓦片行列号 ↔ 像素坐标的转换逻辑,适配瓦片金字塔的层级映射规则,无需开发者手动计算。
瓦片金字塔,LOD 核心实现特点
- 基于缩放级别的硬切换:LOD 完全绑定瓦片金字塔的缩放级别(Zoom Level),Zoom 每变化 1 级,直接切换到对应级别的瓦片,无平滑过渡。
- 简单的可视区域 LOD 过滤:仅加载当前视图可视区域内的瓦片,超出区域的瓦片会被销毁 / 不请求,基础减少资源占用。
- 无动态 LOD 调整:不会根据设备性能(如低功耗模式)、网络状态(如弱网)动态调整 LOD 策略(如降低细节级别减少请求)。
- 逻辑简单可靠:LOD 与瓦片级别强绑定,无复杂的决策逻辑,不易出现 LOD 切换异常。
- 低内存占用:仅保留可视区域的瓦片,基础 LOD 过滤减少了无效瓦片的内存占用。
它是把一个地图全部铺开,然后像放大镜一样,窗口镜头远近,相当于缩放去查看地图的方式.这个好处就是地图不缩放,放大镜移动到哪里,然后取其坐标,与当前的缩放值去映射地图上的坐标,然后解码.优势是在缩放的时候,canvas处理了,便于硬件加速.
KReader
而我的实现方式与其相反,我把所有的页面联合起来作为一张图片,然后缩放时,canvas不处理,这样利用不到硬件加速.比如放大2倍,相当于我的页面全部放大2倍,计算一次全部页面的高宽,移动到哪里,直接应用偏移量即可,后续的选中文字,链接,点击等就直接用上这个坐标点就可以了.
block计算与绘制
KReader实现有一个优势,就是块的大小是动态的,但因为没有层级,在缩放的过程比如1到2倍,每一个小点的放大,都需要计算页面的话,将是一个灾难.所以引入了一个块的大小范围这样的尺寸.比如最小512,最大2048,这样512*2,在1080的宽的屏幕上,横向两块就可以了.当放大到2倍的过程中,只要我的块先增加,增加到2048超出,那么才重新计算,同理高也是这样的.这就相当于有了层级,不是1.1,1.11都会重新计算所有的page中的块,只有达到变化的级别才重新计算.测试中,1到10倍,大概9次左右的计算层级变化,与瓦片金字塔差不多.但瓦片金字塔可以无限层级,后面20倍,30倍没有测试过.
block绘制
假设只有一个page的pdf,高宽是1024*1024,那么先缩放到屏幕的宽,这是基准.此时设置zoom=1.0,这时的page如果一块最小512,那么它的分块就是512*2,要4块正好填满整个page,我的pagenode就是这样的逻辑区,每一块是1/2的高与宽.
然后双指放大到2倍.就是2048了,那么它的宽可以分为512*4,整个page可以是16块,当然因为最大块是2048,那么也可以是1个块.或者1024一块大,就是4块.这取决于page的大小,现在正好是2048,先假设还是按512来计算,就是16块.
当绘制的时候,需要遍历这16块,然后计算它们的可见性,因为放大2倍了,必然不是所有的都可见,我需要根据当前的view画面的偏移量去计算.
如果放大到10倍,这时的块数量就不是16了,是上千.绘制每一帧都需要遍历,对cpu是重大的负担.这时如果不优化,性能受限了.
金字塔的方式不一样,它只要根据行列来取当前可见的放大镜窗口映射的区域就可以了.所以这里用的是空间索引来映射.快速定位当前哪些块.这就与金字塔一样的效率了.
drawnodes方法关键部分:
// 获取可视区域
val visibleRect = Rect(
left = -offset.x,
top = -offset.y,
right = drawScopeSize.width - offset.x,
bottom = drawScopeSize.height - offset.y
)
// 计算覆盖的block x/y indices范围
val minBlockX = floor(pageVisibleLeft * config.xBlocks).toInt().coerceIn(0, config.xBlocks - 1)
val maxBlockX = ceil(pageVisibleRight * config.xBlocks).toInt().coerceIn(0, config.xBlocks - 1)
val minBlockY = floor(pageVisibleTop * config.yBlocks).toInt().coerceIn(0, config.yBlocks - 1)
val maxBlockY = ceil(pageVisibleBottom * config.yBlocks).toInt().coerceIn(0, config.yBlocks - 1)
// 只遍历可见range内的block
for (x in minBlockX..maxBlockX) {
for (y in minBlockY..maxBlockY) {
val nodeIndex = y * config.xBlocks + x
nodes[nodeIndex].draw(
drawScope,
currentWidth,
currentHeight,
currentBounds.left,
currentBounds.top,
)
}
}
我打开一个tiff,放大10倍后可以达到18552块,这个tiff是清明上河图的一个缩略图.如果每次都要遍历这么多块,性能极低的,但这样一算,性能提升很多.
在这之前就是块的大小计算
不是所有的page都需要马上计算,这是优化点,当draw调用时,如果这个page没有node,才触发计算.这样在多页的文档中,不显示的page不维护node.
public fun invalidateNodes() {
val config = calculateTileConfig(width, height, totalScale)
//println("Page.invalidateNodes: currentConfig=$currentTileConfig, config=$config, ${aPage.index}, $width-$height, $yOffset")
if (config == currentTileConfig) {
return
}
// 先回收旧的nodes
val oldNodes = nodes
// 保存当前配置
currentTileConfig = config
// 如果是单个块,直接返回原始页面
if (config.isSingleBlock) {
nodes = listOf(pageViewState.nodePool.acquire(pageViewState, Rect(0f, 0f, 1f, 1f), aPage))
// 回收旧nodes
oldNodes.forEach { pageViewState.nodePool.release(it) }
return
}
// 创建分块节点,确保边界重叠以避免间隙
val newNodes = mutableListOf<PageNode>()
for (y in 0 until config.yBlocks) {
for (x in 0 until config.xBlocks) {
// 计算基础边界
val baseLeft = x / config.xBlocks.toFloat()
val baseTop = y / config.yBlocks.toFloat()
val baseRight = (x + 1) / config.xBlocks.toFloat()
val baseBottom = (y + 1) / config.yBlocks.toFloat()
val left = if (x == 0) baseLeft else baseLeft
val top = if (y == 0) baseTop else baseTop
val right = if (x == config.xBlocks - 1) baseRight else baseRight
val bottom = if (y == config.yBlocks - 1) baseBottom else baseBottom
val rect = Rect(left, top, right, bottom)
newNodes.add(pageViewState.nodePool.acquire(pageViewState, rect, aPage))
//println("Page[${aPage.index}], scaled.w-h:$width-$height, , orignal:${aPage.getWidth(false)}-${aPage.getHeight(false)}, tile:$rect")
}
}
nodes = newNodes
// 回收旧nodes
oldNodes.forEach { pageViewState.nodePool.release(it) }
//println("Page[${aPage.index}] total nodes.count=${nodes.size}, xBlocks=${config.xBlocks}, yBlocks=${config.yBlocks}")
}
这里舍弃了之前为了合并块有缝而多0.001的计算,这种合并会导致放大倍数很大的时候,缝会变的大,合并时重叠也很大.在绘制的时候+1给高宽,可以避免这种问题.
先计算新的缩放值的情况的块数与旧的是不是一样.一样不需要改变,
public const val MIN_BLOCK_SIZE: Float = 256f * 3f // 768
private const val BASE_MAX_BLOCK_SIZE = 256f * 4f // 1024
private const val MAX_CEILING = 256f * 8f // 2048
// 计算分块数的通用函数,接受动态 MAX_BLOCK_SIZE
private fun calcBlockCount(length: Float, maxBlockSize: Float): Int {
if (length <= MIN_BLOCK_SIZE) {
return 1
}
val blockCount = ceil(length / maxBlockSize).toInt()
val actualBlockSize = length / blockCount
if (actualBlockSize >= MIN_BLOCK_SIZE && actualBlockSize <= maxBlockSize) {
return blockCount
} else {
return ceil(length / MIN_BLOCK_SIZE).toInt()
}
}
private fun calculateTileConfig(width: Float, height: Float, totalScale: Float): TileConfig {
// 计算动态 MAX_BLOCK_SIZE
val adaptiveExtension = maxOf((totalScale - 1), 0.0f) * 512f // 每个放大级别的 512 增量
val effectiveMaxBlock = minOf(BASE_MAX_BLOCK_SIZE + adaptiveExtension, MAX_CEILING)
// 如果页面的宽或高都小于有效最大块大小,则不分块
if (width <= effectiveMaxBlock && height <= effectiveMaxBlock) {
return TileConfig(1, 1)
}
val xBlocks = calcBlockCount(width, effectiveMaxBlock)
val yBlocks = calcBlockCount(height, effectiveMaxBlock)
return TileConfig(xBlocks, yBlocks)
}
这里的关键就是块的大小不固定,假如是固定512*512,那么1.0到1.1过程,就会重新计算多次.但如果把最大块超过了2048,那么gpu绘制的时候也会受限.这在不同的分辨率机器上会有些区别的.如果机器gpu差,最大块可以小一些.
经过块的计算,与金字塔一样,只需要关注可见的区域.
Canvas绘制
translate(left = offset.x + centerOffsetX, top = offset.y + centerOffsetY) {
pageViewState.drawVisiblePages(this, offset, vZoom)
}
以前在drawVisiblePages里面还处理了可见性,这是不好的,现在的方法只处理绘制
public fun drawVisiblePages(
drawScope: androidx.compose.ui.graphics.drawscope.DrawScope,
offset: Offset,
vZoom: Float,
) {
pageToRender.forEach { page ->
page.draw(drawScope, offset, vZoom)
那么可见性就是在view的偏移量变化的时候来处理的
public fun updateOffset(newOffset: Offset) {
if (viewOffset != newOffset) {
this.viewOffset = newOffset
updateVisiblePages(newOffset, viewSize, vZoom) // 在offset改变时计算可见页面
}
}
性能杀手出现了
if (orientation == Vertical) {
val visibleTop = -offset.y
val visibleBottom = viewSize.height - offset.y
// 预加载区域:向下扩展一屏
val preloadBottom = visibleBottom + viewSize.height * preloadScreens
val first = findVerticalFirstVisible(visibleTop, currentVZoom)
val last = findVerticalLastVisible(preloadBottom, currentVZoom)
// pageToRender 包含可见页面 + 预加载页面
val tilesToRenderCopy = if (first <= last && first < pages.size && last >= 0) {
pages.subList(first, last + 1)
} else {
emptyList()
}
// 主动移除不再可见的页面图片缓存
val newPageKeys = tilesToRenderCopy.map { page ->
page.aPage.index
}.toSet()
val toRemove = lastPageKeys - newPageKeys
toRemove.forEach { key ->
val page = pages.getOrNull(key) ?: return@forEach
page.recycle()
}
lastPageKeys = newPageKeys
if (tilesToRenderCopy != pageToRender) {
pageToRender = tilesToRenderCopy
}
}
上面是优化后的代码,光看代码,似乎没什么问题.
val newPageKeys = tilesToRenderCopy.map { page ->
page.aPage.index
}.toSet()这是优化后的,优化前的是
val newPageKeys = tilesToRenderCopy.map { page ->
page.nodes这里来一个foreach.
}.toSet()
一张超大图片放大10倍后,有这么18552块,在每一次偏移的时候都要重新计算,想想会有多慢.中端机器,一次计算近200ms.平时没有注意到,放大后block会有这么多.普通的pdf文档,一页放大10倍也不至于这样大.因为我不只是一个pdf阅读器,还可以查看图片,超大的图片,20000*4000的这种.
当view有偏移时,pageToRender是在不断变化的,这个金字塔也没办法避免.但优化后,绘制时只关心pageToRender这个,与金字塔模式就一样了.
整个优化性能,都是对应着瓦片金字塔来处理的.
瓦片金字塔是否适合我的阅读器?
如果瓦片是固定的,文档的总高宽是固定的,那么非常适合.但是阅读器有了切边,实时切边,每次有切边就会触发这个page的高宽可能与原始大小不一样了,那么整个文档的总宽固定,高就会变.而这点瓦片金字塔不适用.因它先设置了总高宽,然后像用放大镜去查看,镜头可以拉远拉近,这样触发放大与缩小的效果.
与瓦片金字塔相比,还有什么缺点?
mapcompose在缩放话过程中,会先绘制最新层级的图片,对于老层级的不会马上消除,这样在整个缩放过程,可以看到过度效果好.缺点就是占用内存大.这种缺点是可接受的.
我现在这种,没有处理上一次的缩放的高清图片备份,一次全部清空,完全依赖底部page的缩略图,如果缩略图不够清晰,缩放过程会有不好的过度效果.总是先看到模糊的,然后再看到清晰的.这个有待优化.
https://www.pgyer.com/kreader-android 优化后的apk,现在放大10倍,20000宽的图片也轻松应对

610

被折叠的 条评论
为什么被折叠?



