compose multiplatform reader4

目录

mapcompose

LOD 核心实现特点

KReader

block计算与绘制

block绘制

在这之前就是块的大小计算

Canvas绘制

性能杀手出现了

瓦片金字塔是否适合我的阅读器?

与瓦片金字塔相比,还有什么缺点?


桌面端不打算用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宽的图片也轻松应对

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值