目录
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来通信.
先这样吧,下次再补充