compose map 源码解析

目录

TileCanvas

ZoomPanRotateState

ZoomPanRotate

布局,手势处理完了,就开始要计算tile了

MapState

TileCanvasState


telephoto的源码已经分析过了.它的封装好,扩展好,适用于各种view.

最近又看到一个用compose写的map,用不同的方式,有点意思.分析一下它的实现流程与原理.

https://github.com/p-lr/MapCompose.git 这是源码.

TileCanvas
复制代码
Canvas(
        modifier = modifier
            .fillMaxSize()
    ) {
        withTransform({
            translate(left = -zoomPRState.scrollX, top = -zoomPRState.scrollY)
            scale(scale = zoomPRState.scale, Offset.Zero)
        }) {
            for (tile in tilesToRender) {
                val bitmap = tile.bitmap ?: continue
                val scaleForLevel = visibleTilesResolver.getScaleForLevel(tile.zoom)
                    ?: continue
                val tileScaled = (tileSize / scaleForLevel).toInt()
                val l = tile.col * tileScaled
                val t = tile.row * tileScaled
                val r = l + tileScaled
                val b = t + tileScaled
                dest.set(l, t, r, b)

                drawIntoCanvas {
                    it.nativeCanvas.drawBitmap(bitmap, null, dest, null)
                }
            }
        }
    }

我把旋转的代码删除了.telephoto是在绘制前把偏移量计算好了,而这个绘制是得到tile后,在绘制的时候设置偏移量,使用的是bitmap,所以不支持多平台.

使用画布前要初始化一些数据:

复制代码
val zoomPRState = state.zoomPanRotateState
    key(state) {
        ZoomPanRotate(
            modifier = modifier
                .clipToBounds()
                .background(state.mapBackground),
            gestureListener = zoomPRState,
            layoutSizeChangeListener = zoomPRState,
        ) {
            TileCanvas(
                modifier = Modifier,
                zoomPRState = zoomPRState,
                visibleTilesResolver = state.visibleTilesResolver,
                tileSize = state.tileSize,
                tilesToRender = state.tileCanvasState.tilesToRender,
            )

            content()
        }
    }
复制代码
zoomPRState最重要的就是这个,保存了各种状态,变量.它的初始化就要追溯到mapstate了.因为它是用于map,所以它会先设置一个地图的总大小,层级.
复制代码
val state = MapState(4, 4096, 4096) {
                    scale(1.0f)
                }.apply {
                    addLayer(tileStreamProvider)
                    shouldLoopScale = true
                    //enableRotation()
                }
复制代码
TileStreamProvider就是根据这个缩放级别,行列去获取对应的图片,示例中是使用assets里面的图片,都是放好切片的.
ZoomPanRotateState

这个类管理了缩放,平移,旋转功能.

看一个缩放功能,先把缩放值作一个范围取值,避免超出最大与最小值.然后更新中心点,再通知监听者

复制代码
fun setScale(scale: Float, notify: Boolean = true) {
        this.scale = constrainScale(scale)
        updateCentroid()
        if (notify) notifyStateChanged()
    }

中心点的计算,取当前布局的大小的半的值,加上偏移滚动的量

复制代码
private fun updateCentroid() {
        pivotX = layoutSize.width.toDouble() / 2
        pivotY = layoutSize.height.toDouble() / 2

        centroidX = (scrollX + pivotX) / (fullWidth * scale)
        centroidY = (scrollY + pivotY) / (fullHeight * scale)
    }

这个state它不是自己更新的,是外部触发的,哪里触发先放着.

缩放有了,滚动的与缩放类似.需要确定边界值,然后更新中心点.

复制代码
fun setScroll(scrollX: Float, scrollY: Float) {
        this.scrollX = constrainScrollX(scrollX)
        this.scrollY = constrainScrollY(scrollY)
        updateCentroid()
        notifyStateChanged()
    }

这三个方法是这个类的核心方法,其它都是通过它们计算得到的.

ZoomPanRotate

这算是入口页面,自定义了Layout,它与普通的图片不同,它是多层级别的,所以要自定义一个.

它的布局大小改变的时,通过layoutSizeChangeListener,告诉zoomPanRotateState,

复制代码
override fun onSizeChanged(composableScope: CoroutineScope, size: IntSize) {
        scope = composableScope

        var newScrollX: Float? = null
        var newScrollY: Float? = null
        if (layoutSize != IntSize.Zero) {
            newScrollX = scrollX + (layoutSize.width - size.width) / 2
            newScrollY = scrollY + (layoutSize.height - size.height) / 2
        }

        layoutSize = size
        recalculateMinScale()
        if (newScrollX != null && newScrollY != null) {
            setScroll(newScrollX, newScrollY)
        }

        /* Layout was done at least once, resume continuations */
        for (ct in onLayoutContinuations) {
            ct.resume(Unit)
        }
        onLayoutContinuations.clear()
    }

这时,zoomPanRotateState的一切就开始了.

先设置size,这是屏幕的大小.fullwidth/fullheight,这是页面图片的高宽值.在初始化时,我们设置了4096.然后将屏幕大小与这个计算缩放值.

复制代码
gestureListener = zoomPRState,我们从这里面可以看出,layout里面的手势事件全部是通过这个传递给zoomPanRotateState, 这种方式与之前看过的一些库的源码思路确实不一太一样.

回到前面的zoomPanRotateState更新问题,现在解决了.

到这里,没有看到tile,只了解了整个画布,高宽,缩放平移这些.阶段总结一下:

ZoomPanRotate,这个类处理了布局.手势通过zoomPanRotateState来处理.包含缩放,平移.它有几个关键的属性,总大小与图片原始大小.layoutSize, fullWidth, fullHeight.

初始化时,它从ZoomPanRotate得到view的大小.

并根据图片原始大小计算出它与view的缩放比例.它的模式有三种

复制代码
when (mode) {
    Fit -> min(minScaleX, minScaleY)
    Fill -> max(minScaleX, minScaleY)
    is Forced -> mode.scale
}

然后ZoomPanRotate中如果触发了手势,则回调到zoomPanRotateState中,重新计算平移,缩放值.

布局,手势处理完了,就开始要计算tile了

zoomPanRotateState不管是首次布局,还是手势,都会触发下面的方法,而它触发了mapstate中的状态变化:

复制代码
private fun notifyStateChanged() {
        if (layoutSize != IntSize.Zero) {
            stateChangeListener.onStateChanged()
        }
    }
MapState

里面包含了zoomPanRotateState和tileCanvasState.

其它的state暂时不讨论,比如marker,path这些,这里只关注它如何管理缩放,平移这些.

复制代码
override fun onStateChanged() {
        consumeLateInitialValues()

        renderVisibleTilesThrottled()
        stateChangeListener?.invoke(this)
    }

它先处理延迟初始化的的值,然后就启动tile渲染工作,发送事件throttledTask.trySend(Unit)

最后如果它还有外部的回调则调用.

启动任务后,开始计算可见的tile:先更新viewport

复制代码
private suspend fun renderVisibleTiles() {
        val viewport = updateViewport()
        tileCanvasState.setViewport(viewport)
    }

这个是从zoomPanRotateState获取的偏移与view的大小,并处理了padding.viewport差不多是最大的视图区域大小了,并不是图片大小.

复制代码
private fun updateViewport(): Viewport {
        val padding = preloadingPadding
        return viewport.apply {
            left = zoomPanRotateState.scrollX.toInt() - padding
            top = zoomPanRotateState.scrollY.toInt() - padding
            right = left + zoomPanRotateState.layoutSize.width + padding * 2
            bottom = top + zoomPanRotateState.layoutSize.height + padding * 2
            angleRad = zoomPanRotateState.rotation.toRad()
        }
    }

mapstate其实是一个管家,它本身并不直接管理,而是通过其它类来管理.这样有利于它的扩展.

更新了viewport后,就可以计算tile了.它通过另一个类来计算:

TileCanvasState

来看它的计算方法:启动协程, 用VisibleTilesResolver计算

复制代码
suspend fun setViewport(viewport: Viewport) {
        /* Thread-confine the tileResolver to the main thread */
        val visibleTiles = withContext(Dispatchers.Main) {
            visibleTilesResolver.getVisibleTiles(viewport)
        }

        withContext(scope.coroutineContext) {
            setVisibleTiles(visibleTiles)
        }
    }

它的声明是在mapstate里面的:

复制代码
internal val visibleTilesResolver =
        VisibleTilesResolver(
            levelCount = levelCount,
            fullWidth = fullWidth,
            fullHeight = fullHeight,
            tileSize = tileSize,
            magnifyingFactor = initialValues.magnifyingFactor
        ) {
            zoomPanRotateState.scale
        }

回到计算tile:

复制代码
fun getVisibleTiles(viewport: Viewport): VisibleTiles {
        val scale = scaleProvider.getScale()
        val level = getLevel(scale, magnifyingFactor)
        val scaleAtLevel = scaleForLevel[level] ?: throw AssertionError()
        val relativeScale = scale / scaleAtLevel

        /* At the current level, row and col index have maximum values */
        val maxCol = max(0.0, ceil(fullWidth * scaleAtLevel / tileSize) - 1).toInt()
        val maxRow = max(0.0, ceil(fullHeight * scaleAtLevel / tileSize) - 1).toInt()

        fun Int.lowerThan(limit: Int): Int {
            return if (this <= limit) this else limit
        }

        val scaledTileSize = tileSize.toDouble() * relativeScale

        fun makeVisibleTiles(left: Int, top: Int, right: Int, bottom: Int): VisibleTiles {
            val colLeft = floor(left / scaledTileSize).toInt().lowerThan(maxCol).coerceAtLeast(0)
            val rowTop = floor(top / scaledTileSize).toInt().lowerThan(maxRow).coerceAtLeast(0)
            val colRight = (ceil(right / scaledTileSize).toInt() - 1).lowerThan(maxCol)
            val rowBottom = (ceil(bottom / scaledTileSize).toInt() - 1).lowerThan(maxRow)

            val tileMatrix = (rowTop..rowBottom).associateWith {
                colLeft..colRight
            }
            val count = (rowBottom - rowTop + 1) * (colRight - colLeft + 1)
            return VisibleTiles(level, tileMatrix, count, getSubSample(scale))
        }

        return if (viewport.angleRad == 0f) {
            makeVisibleTiles(viewport.left, viewport.top, viewport.right, viewport.bottom)
        } else {
            val xTopLeft = viewport.left
            val yTopLeft = viewport.top

            val xTopRight = viewport.right
            val yTopRight = viewport.top

            val xBotLeft = viewport.left
            val yBotLeft = viewport.bottom

            val xBotRight = viewport.right
            val yBotRight = viewport.bottom

            val xCenter = (viewport.right + viewport.left).toDouble() / 2
            val yCenter = (viewport.bottom + viewport.top).toDouble() / 2

            val xTopLeftRot =
                rotateX(xTopLeft - xCenter, yTopLeft - yCenter, viewport.angleRad) + xCenter
            val yTopLeftRot =
                rotateY(xTopLeft - xCenter, yTopLeft - yCenter, viewport.angleRad) + yCenter
            var xLeftMost = xTopLeftRot
            var yTopMost = yTopLeftRot
            var xRightMost = xTopLeftRot
            var yBotMost = yTopLeftRot

            val xTopRightRot =
                rotateX(xTopRight - xCenter, yTopRight - yCenter, viewport.angleRad) + xCenter
            val yTopRightRot =
                rotateY(xTopRight - xCenter, yTopRight - yCenter, viewport.angleRad) + yCenter
            xLeftMost = xLeftMost.coerceAtMost(xTopRightRot)
            yTopMost = yTopMost.coerceAtMost(yTopRightRot)
            xRightMost = xRightMost.coerceAtLeast(xTopRightRot)
            yBotMost = yBotMost.coerceAtLeast(yTopRightRot)

            val xBotLeftRot =
                rotateX(xBotLeft - xCenter, yBotLeft - yCenter, viewport.angleRad) + xCenter
            val yBotLeftRot =
                rotateY(xBotLeft - xCenter, yBotLeft - yCenter, viewport.angleRad) + yCenter
            xLeftMost = xLeftMost.coerceAtMost(xBotLeftRot)
            yTopMost = yTopMost.coerceAtMost(yBotLeftRot)
            xRightMost = xRightMost.coerceAtLeast(xBotLeftRot)
            yBotMost = yBotMost.coerceAtLeast(yBotLeftRot)

            val xBotRightRot =
                rotateX(xBotRight - xCenter, yBotRight - yCenter, viewport.angleRad) + xCenter
            val yBotRightRot =
                rotateY(xBotRight - xCenter, yBotRight - yCenter, viewport.angleRad) + yCenter
            xLeftMost = xLeftMost.coerceAtMost(xBotRightRot)
            yTopMost = yTopMost.coerceAtMost(yBotRightRot)
            xRightMost = xRightMost.coerceAtLeast(xBotRightRot)
            yBotMost = yBotMost.coerceAtLeast(yBotRightRot)

            makeVisibleTiles(
                xLeftMost.toInt(),
                yTopMost.toInt(),
                xRightMost.toInt(),
                yBotMost.toInt()
            )
        }
    }

先获取缩放值,如果发生手势缩放,它就不再只是图片与view的比例,需要加上手势的缩放,这部分是在zoomPanRotateState里面计算过的.

然后处理层级.处理层级的目的是为了从asset中取图片.

接着计算行列数.

这里忽略了旋转,那么只关注不旋转的计算makeVisibleTiles.它得到的是一个取值范围,不像telephoto那样具体偏移.因为asset里面的图片是固定块大小的,名字是数字,只要有这个取值范围,然后根据这个去取就行了.

计算完tile的可见区,开始渲染:

复制代码
private val renderTask = scope.throttle(wait = 34) {
        /* Evict, then render */
        val (lastVisible, ids, opacities) = visibleStateFlow.value ?: return@throttle
        evictTiles(lastVisible, ids, opacities)

        renderTiles(lastVisible, ids)
    }

先是回收,如果缩放级别不同,应该回收旧的tile.

接着根据优先级排序,然后设置要渲染的tile

复制代码
private fun renderTiles(visibleTiles: VisibleTiles, layerIds: List<String>) {
        /* Right before sending tiles to the view, reorder them so that tiles from current level are
         * above others. */
        val tilesToRenderCopy = tilesCollected.sortedBy {
            val priority =
                if (it.zoom == visibleTiles.level && it.subSample == visibleTiles.subSample) 100 else 0
            priority + if (layerIds == it.layerIds) 1 else 0
        }

        tilesToRender = tilesToRenderCopy
    }
复制代码
tilesToRender,这个就是最外层的绘制部分的内容.

到这里渲染其实没有开始,只是监听渲染结果visibleStateFlow,这个是在当前类初始化的时候,启动了协程来监听.

collectNewTiles()中visibleStateFlow.collectLatest(),它变化的时候,才触发真正的图片解码

初始化还启动了其它的协程:

复制代码
init {
        /* Collect visible tiles and send specs to the TileCollector */
        scope.launch {
            collectNewTiles()
        }

        /* Launch the TileCollector */
        tileCollector = TileCollector(workerCount.coerceAtLeast(1), bitmapConfig, tileSize)
        scope.launch {
            _layerFlow.collectLatest { layers ->
                tileCollector.collectTiles(
                    tileSpecs = visibleTileLocationsChannel,
                    tilesOutput = tilesOutput,
                    layers = layers,
                    bitmapPool = bitmapPool
                )
            }
        }

        /* Launch a coroutine to consume the produced tiles */
        scope.launch {
            consumeTiles(tilesOutput)
        }

        scope.launch(Dispatchers.Main) {
            for (t in recycleChannel) {
                val b = t.bitmap
                t.bitmap = null
                b?.recycle()
            }
        }
    }

注释也比较清晰了,一个是收集tiles,一个是消费tiles.

复制代码
tileCollector.collectTiles,这里触发图片的获取.
复制代码
{
        val tilesToDownload = Channel<TileSpec>(capacity = Channel.RENDEZVOUS)
        val tilesDownloadedFromWorker = Channel<TileSpec>(capacity = 1)

        repeat(workerCount) {
            worker(
                tilesToDownload,
                tilesDownloadedFromWorker,
                tilesOutput,
                layers,
                bitmapPool
            )
        }
        tileCollectorKernel(tileSpecs, tilesToDownload, tilesDownloadedFromWorker)
    }

worker是具体的解码了.

整个过程比较复杂.在收集tile的协程里面它使用channel去接收tile的变化.

复制代码
for (spec in tilesToDownload) {
            if (layers.isEmpty()) {
                tilesDownloaded.send(spec)
                continue
            }

            val bitmapForLayers = layers.mapIndexed { index, layer ->
                async {
                    val bitmap = createBitmap(512, 512, Config.ARGB_8888)
                    val canvas = Canvas(bitmap)
                    val paint = Paint()
                    paint.textSize = 60f
                    paint.strokeWidth = 4f
                    paint.isAntiAlias = true
                    paint.style = Paint.Style.STROKE
                    canvas.drawARGB(255, 0, 255, 0)
                    paint.setColor(Color.WHITE)
                    val rect = Rect(0, 0, bitmap.getWidth(), bitmap.getHeight())
                    paint.setColor(Color.YELLOW)
                    canvas.drawRect(rect, paint)
                    paint.setColor(Color.RED)
                    canvas.drawText(index.toString(), 130f, 130f, paint)
                    BitmapForLayer(bitmap, layer)
                    /*val i = layer.tileStreamProvider.getTileStream(spec.row, spec.col, spec.zoom)
                    if (i != null) {
                        getBitmap(
                            subSamplingRatio = subSamplingRatio,
                            layer = layer,
                            inputStream = i,
                            isPrimaryLayer = index == 0
                        )
                    } else BitmapForLayer(null, layer)*/
                }
            }.awaitAll()

            val resultBitmap = bitmapForLayers.firstOrNull()?.bitmap ?: run {
                tilesDownloaded.send(spec)
                /* When the decoding failed or if there's nothing to decode, then send back the Tile
                 * just as in normal processing, so that the actor which submits tiles specs to the
                 * collector knows that this tile has been processed and does not immediately
                 * re-sends the same spec. */
                tilesOutput.send(
                    Tile(
                        spec.zoom,
                        spec.row,
                        spec.col,
                        spec.subSample,
                        layerIds,
                        layers.map { it.alpha }
                    )
                )
                null
            } ?: continue // If the decoding of the first layer failed, skip the rest

            if (layers.size > 1) {
                canvas.setBitmap(resultBitmap)

                for (result in bitmapForLayers.drop(1)) {
                    paint.alpha = (255f * result.layer.alpha).toInt()
                    if (result.bitmap == null) continue
                    canvas.drawBitmap(result.bitmap, 0f, 0f, paint)
                }
            }

            println("getBitmap.Tile:zoom:${spec.zoom}, row-col:${spec.row}-${spec.col}, ${spec.subSample}")
            val tile = Tile(
                spec.zoom,
                spec.row,
                spec.col,
                spec.subSample,
                layerIds,
                layers.map { it.alpha }
            ).apply {
                this.bitmap = resultBitmap
            }
            tilesOutput.send(tile)
            tilesDownloaded.send(spec)
        }

这里截取解码的部分代码.我取消了asset的图片获取,转为创建一个空的bitmap.解码的过程并不复杂,但它的代码看着没有那么舒服.

tilesToDownload,是要解码的tile.根据参数 解码,然后创建tile,其中还处理了layer,因为地图总是与缩放的层级相关的.

解码完就通过channel发送出去.然后就到了外面的接收方:

复制代码
TileCanvasState.consumeTiles(tilesOutput)

如果layoerid相同才有用,否则要回收.renderthrottled前面有介绍过,就是最后触发最外层的渲染了.

复制代码
private suspend fun consumeTiles(tileChannel: ReceiveChannel<Tile>) {
        for (tile in tileChannel) {
            val lastVisible = lastVisible
            if (
                (lastVisible == null || lastVisible.contains(tile))
                && !tilesCollected.contains(tile)
                && tile.layerIds == visibleStateFlow.value?.layerIds
            ) {
                tile.prepare()
                tilesCollected.add(tile)
                renderThrottled()
            } else {
                tile.recycle()
            }
            fullEvictionDebounced()
        }
    }

整个流程看着没那么舒服.也比较复杂,它不像图片查看器,只有一层,都需要关注layer.

不管是渲染,解码都是channel来通信.

先这样吧,下次再补充

相关推荐
stevenzqzq6 小时前
android中dp和px的关系
android
一一Null8 小时前
Token安全存储的几种方式
android·java·安全·android studio
JarvanMo9 小时前
flutter工程化之动态配置
android·flutter·ios
时光少年12 小时前
Android 副屏录制方案
android·前端
时光少年12 小时前
Android 局域网NIO案例实践
android·前端
alexhilton12 小时前
Jetpack Compose的性能优化建议
android·kotlin·android jetpack
流浪汉kylin12 小时前
Android TextView SpannableString 如何插入自定义View
android
火柴就是我14 小时前
git rebase -i,执行 squash 操作 进行提交合并
android
你说你说你来说14 小时前
安卓广播接收器(Broadcast Receiver)的介绍与使用
android·笔记
你说你说你来说14 小时前
安卓Content Provider介绍及使用
android·笔记