目录
zoomable-image
zoomable的流程,事件分析过了,它作用于任何view,zoomable-image主要是针对图片的.
ZoomableImageSource
数据源,定义了两类interface ImageDelegate
PainterDelegate和SubSamplingDelegate.一个是普通的view,一个是根据采样率加载的.
比如coil,则在coil的包下面实现加载数据
有Coil3ImageSource,GlideImageSource,都是返回ResolveResult.
Coil3ImageSource
分析一下它是如何加载的.
override fun resolve(canvasSize: Flow<Size>): ResolveResult {
val context = LocalContext.current
val resolver = remember(this) {
val requests = models.map { model ->
model as? ImageRequest
?: ImageRequest.Builder(context)
.data(model)
.build()
}
Resolver(
requests = requests,
imageLoaders = imageLoaders,
sizeResolver = { canvasSize.first().toCoilSize() },
)
}
return resolver.resolved
}
建一个ImageRequest,这是coil的请求方式.然后返回Resolver(把request作为参数传入).它由RememberWorker的协程启动,调用work()方法.
///work又调用私有的work(),进入加载图片
private suspend fun work(request: ImageRequest, imageLoader: ImageLoader, skipMemoryCache: Boolean) {
val result = imageLoader.execute(
request.newBuilder()
.size(request.defined.sizeResolver ?: sizeResolver)
// There's no easy way to be certain whether an image will require sub-sampling in
// advance so assume it'll be needed and force Coil to write this image to disk.
.diskCachePolicy(
when (request.diskCachePolicy) {
CachePolicy.ENABLED -> CachePolicy.ENABLED
CachePolicy.READ_ONLY -> CachePolicy.ENABLED
CachePolicy.WRITE_ONLY,
CachePolicy.DISABLED -> CachePolicy.WRITE_ONLY
}
)
.memoryCachePolicy(
if (skipMemoryCache) CachePolicy.WRITE_ONLY else request.memoryCachePolicy
)
// This will unfortunately replace any existing target, but it is also the only
// way to read placeholder images set using ImageRequest#placeholderMemoryCacheKey.
// Placeholder images should be small in size so sub-sampling isn't needed here.
.target(
onStart = {
resolved = resolved.copy(
placeholder = it?.asPainter(request.context),
)
}
)
// Increase memory cache hit rate because the image will anyway fit the canvas
// size at draw time.
.precision(
when (request.defined.precision) {
Precision.EXACT -> request.precision
else -> Precision.INEXACT
}
)
// While telephoto will take care of loading the full-sized image, let Coil downsize
// this image since there is still a possibility that the image may not be saved to
// disk if (e.g., if Cache-Control HTTP headers prevent disk caching).
.maxBitmapSize(CoilSize.ORIGINAL)
.build()
)
val imageSource = when (val it = result.toSubSamplingImageSource(imageLoader)) {
null -> null
is EligibleForSubSampling -> it.source
is ImageDeletedOnlyFromDiskCache -> {
if (skipMemoryCache) {
error("Coil returned an image that is missing from both its memory and disk caches")
} else {
// The app's disk cache was possibly deleted, but the image is
// still cached in memory. Reload the image from the network.
work(request, imageLoader, skipMemoryCache = true)
}
return
}
}
resolved = resolved.copy(
crossfadeDuration = result.crossfadeDuration(),
delegate = if (result is SuccessResult && imageSource != null) {
ZoomableImageSource.SubSamplingDelegate(
source = imageSource,
imageOptions = ImageBitmapOptions(from = (result.image as BitmapImage).bitmap)
)
} else {
ZoomableImageSource.PainterDelegate(
painter = result.image?.asPainter(request.context)
)
},
)
}
这个加载过程,就是imageLoader.execute()加载数据,然后,判断是哪种类型,返回SubSamplingDelegate或PainterDelegate.
ZoomableImage
数据源有了,数据加载完成后,就是应用
when (val delegate = resolved.delegate) {
null -> {
Box(Modifier) //没有图片
}
is ZoomableImageSource.PainterDelegate -> {
...
Image(
modifier = zoomable,
painter = animatedPainter(painter),
contentDescription = contentDescription,
alignment = Alignment.Center,
contentScale = ContentScale.Inside,
alpha = alpha * animatedAlpha,
colorFilter = colorFilter,
)
}
is ZoomableImageSource.SubSamplingDelegate -> {
...
SubSamplingImage(
modifier = zoomable,
state = subSamplingState,
contentDescription = contentDescription,
alpha = alpha * animatedAlpha,
colorFilter = colorFilter,
)
}
}
根据不同的数据,显示不同的image.
@Composable
fun ZoomableImage(
image: ZoomableImageSource,
contentDescription: String?,
modifier: Modifier = Modifier,
state: ZoomableImageState = rememberZoomableImageState(rememberZoomableState()),
alpha: Float = DefaultAlpha,
colorFilter: ColorFilter? = null,
alignment: Alignment = Alignment.Center,
contentScale: ContentScale = ContentScale.Fit,
gesturesEnabled: Boolean = true,
onClick: ((Offset) -> Unit)? = null,
onLongClick: ((Offset) -> Unit)? = null,
clipToBounds: Boolean = true,
onDoubleClick: DoubleClickToZoomListener = DoubleClickToZoomListener.cycle(),
)
这个参数真不少.
开始先应用对齐与缩放的方式.
state.zoomableState.also {
it.contentAlignment = alignment
it.contentScale = contentScale
}
然后触发加载数据,它最终会触发上面的source里面的协程来加载.
val resolved = key(image) {
image.resolve(
canvasSize = remember {
snapshotFlow { canvasSize }.filter { it.isSpecified && !it.isEmpty() }
}
)
}
然后就是Box中监听图片的加载状态.
state.isImageDisplayed.
state.isPlaceholderDisplayed 处理了占位图
展示方面相对要简单一些.image就是整张图片展示.
复杂的是SubSamplingImage
SubSamplingImage
SubSamplingImage
这是根据采样率来加载图片的,如果采样为1,加载原图时会分块加载
zoomableimage可以根据缩放与图片的大小来采取是用哪种,当缩放或图片过大,它会使用SubSamplingImage,或者可以忽略zoomableimage,直接使用SubSamplingImage更简单
sealed interface SubSamplingImageState {
/** Raw size of the image, without any scaling applied. */
val imageSize: IntSize?
val isImageDisplayed: Boolean
/** Whether the image is loaded and displayed in its full quality. */
val isImageDisplayedInFullQuality: Boolean
...
}
图片的原始大小与是否高质量显示.两个属性.
@Composable
fun rememberSubSamplingImageState(
imageSource: SubSamplingImageSource,
zoomableState: ZoomableState,
imageOptions: ImageBitmapOptions = ImageBitmapOptions.Default,
errorReporter: SubSamplingImageErrorReporter = SubSamplingImageErrorReporter.NoOpInRelease
)
zoomableState.autoApplyTransformations = false先将自动应用转换设置为false,避免view的应用.由SubSamplingImage来控制.
接着:
SubSamplingImageState {
val transformation by rememberUpdatedState(transformation)
val state = remember(imageSource) {
RealSubSamplingImageState(imageSource, transformation)
}.also {
it.imageRegionDecoder = createImageRegionDecoder(imageSource, imageOptions, errorReporter)
}
state.LoadImageTilesEffect()
DisposableEffect(imageSource) {
onDispose {
imageSource.close()
}
}
return state
}
创建transformation,创建state,再创建decoder.
使用方式可以从示例中找到:
val zoomableState = rememberZoomableState()
val imageState = rememberSubSamplingImageState(
zoomableState = zoomableState,
imageSource = SubSamplingImageSource.asset("fox.jpg")
)
SubSamplingImage(
modifier = Modifier
.fillMaxSize()
.zoomable(zoomableState),
state = imageState,
contentDescription = ...,
)
SubSamplingImage中使用
drawBehind(onDraw)来绘制,ondraw是通过遍历state中的tiles去绘制.
val onDraw: DrawScope.() -> Unit = {
if (state.isImageDisplayed) {
state.viewportImageTiles.fastForEach { tile ->
drawImageTile(
tile = tile,
alpha = alpha,
colorFilter = colorFilter,
)
if (state.showTileBounds) {
drawRect(
color = Color.Red,
topLeft = tile.bounds.topLeft.toOffset(),
size = tile.bounds.size.toSize(),
style = Stroke(width = 6.dp.toPx()),
)
}
}
}
}
在view的大小改变时.onSizeChanged { state.viewportSize = it },将当前的view大小传到state里面.
drawImageTile就是把tile画出来,但是会带偏移量.
withTransform(
transformBlock = {
translate(
left = tile.bounds.topLeft.x.toFloat(),
top = tile.bounds.topLeft.y.toFloat(),
)
},
drawBlock = {
with(painter) {
draw(
size = tile.bounds.size.toSize(),
alpha = alpha,
colorFilter = colorFilter,
)
}
}
)
绘制部分不复杂,关键是tile的计算,偏移量.主要逻辑在RealSubSamplingImageState
RealSubSamplingImageState
它继承SubSamplingImageState,要实现两个属性的计算,imageSize与isImageDisplayed.
imageSize: IntSize?
get() = imageRegionDecoder?.imageSize 直接取解码器的值就可以得到原图大小.
override val isImageDisplayed: Boolean by derivedStateOf {
isReadyToBeDisplayed && viewportImageTiles.isNotEmpty() &&
(viewportImageTiles.fastAny { it.isBase } || viewportImageTiles.fastAll { it.painter != null })
}
图片是否显示,它是由多个状态合并的,tile不能为空,状态为准备好了,才能显示
除此,还有预览图相关的操作.
imageRegionDecoder是在创建这个state后立刻创建的.
这个类创建后,执行了LoadImageTilesEffect(),tile的计算工作就开始了.
@Composable
fun LoadImageTilesEffect() {
val imageRegionDecoder = imageRegionDecoder ?: return
val scope = rememberCoroutineScope()
val imageCache = remember(this, imageRegionDecoder) {
ImageCache(scope, imageRegionDecoder)
}
LaunchedEffect(imageCache) {
snapshotFlow { viewportTiles }.collect { tiles ->
imageCache.loadOrUnloadForTiles(
regions = tiles.fastMapNotNull { if (it.isVisible) it.region else null }
)
}
}
LaunchedEffect(imageCache) {
imageCache.observeCachedImages().collect {
loadedImages = it
}
}
}
创建缓存,加载或卸载tile.这里是监控viewportTiles的变化,这是view的tile.除了图片会被划分,先将view划分.当图还没加载的时候,需要去占着位置,所以它是必要的.
viewportImageTiles,这个就是图片的tile,它是根据viewportTiles的变化而变化的.计算原则也不复杂,可见并且不是基础块或可以显示的.
首先产生tile:
private val tileGrid by derivedStateOf {
if (isReadyToBeDisplayed) {
ImageRegionTileGrid.generate(
viewportSize = viewportSize!!,
unscaledImageSize = imageOrPreviewSize!!,
)
} else null
}
它有两个值,一个是view的大小,一个是图片的大小.根据这两个值,让图片适应到view中,如果图片过大,会计算sample,缩放到合适的采样.
val baseTile = ImageRegionTile(
sampleSize = baseSampleSize,
bounds = IntRect(IntOffset.Zero, unscaledImageSize)
)
得到一个基础tile,是整张图片的原始tile与采样.还有一个foregroundTiles.
val foregroundTiles = possibleSampleSizes.associateWith { sampleSize ->
val tileSize: IntSize = (unscaledImageSize.toSize() * (sampleSize.size / baseSampleSize.size.toFloat()))
.discardFractionalParts()
.coerceIn(min = minTileSize, max = unscaledImageSize.coerceAtLeast(minTileSize))
// Number of tiles can be fractional. To avoid this, the fractional
// part is discarded and the last tiles on each axis are stretched
// to cover any remaining space of the image.
val xTileCount: Int = (unscaledImageSize.width / tileSize.width).coerceAtLeast(1)
val yTileCount: Int = (unscaledImageSize.height / tileSize.height).coerceAtLeast(1)
val tileGrid = ArrayList<ImageRegionTile>(xTileCount * yTileCount)
for (x in 0 until xTileCount) {
for (y in 0 until yTileCount) {
val isLastXTile = x == xTileCount - 1
val isLastYTile = y == yTileCount - 1
val tile = ImageRegionTile(
sampleSize = sampleSize,
bounds = IntRect(
left = x * tileSize.width,
top = y * tileSize.height,
// Stretch the last tiles to cover any remaining space.
right = if (isLastXTile) unscaledImageSize.width else (x + 1) * tileSize.width,
bottom = if (isLastYTile) unscaledImageSize.height else (y + 1) * tileSize.height,
)
)
tileGrid.add(tile)
}
}
return@associateWith tileGrid
}
它的tile,先根据固定的大小一块一块排列,然后剩下的如果不是一个tile的大小,会把它与前面的合并成为一个tile.每一个tile,除了自己的大小,还有它对应的采样,因为缩放后这些采样有可能不一样.
计算完tile,就要把对应的图片通过decoder加载出来.
前面提到的isbase 就是它是不是基础的完整图片的tile. 只有一个.
viewportTiles的计算:
(listOf(tileGrid.base) + foregroundRegions)
.sortedByDescending { it.bounds.contains(transformation.centroid) }
.fastMapNotNull { region ->
val isBaseTile = region == tileGrid.base
val drawBounds = region.bounds.scaledAndOffsetBy(transformation.scale, transformation.offset)
ViewportTile(
region = region,
bounds = drawBounds,
isBase = isBaseTile,
isVisible = drawBounds.overlaps(viewportSize!!),
)
}
.toImmutableList()
它不只是取上面的分块tile,还加上基础块.生成一个不可变列表.然后通过imagecache去加载这些块.
tile的计算,绘制工作就是这些了.最后个复杂的就是如何加载这些图片.
ImageCache
private val visibleRegions = Channel<List<ImageRegionTile>>(capacity = 10)
private val cachedImages = MutableStateFlow(emptyMap<ImageRegionTile, LoadingState>())
充分利用协程的特性.又看到channel了.
visibleRegions.trySend(regions)发送可见的tile,它通过协程监听可见区的变化:
scope.launch {
visibleRegions.consumeAsFlow()
.distinctUntilChanged()
.throttleLatest(throttleEvery) // In case the image is animating its zoom.
.collect { tiles ->
val tilesToLoad = tiles.fastFilter { it !in cachedImages.value }
tilesToLoad.fastForEach { tile ->
launch(start = CoroutineStart.UNDISPATCHED) {
cachedImages.update {
check(tile !in it)
it + (tile to InFlight(currentCoroutineContext().job))
}
val painter = decoder.decodeRegion(tile.bounds, tile.sampleSize.size)
cachedImages.update {
it + (tile to Loaded(painter))
}
}
}
val tilesToUnload = cachedImages.value.keys.filter { it !in tiles }
tilesToUnload.fastForEach { region ->
val inFlight = cachedImages.value[region] as? InFlight
inFlight?.job?.cancel()
}
cachedImages.update { it - tilesToUnload.toSet() }
}
}
检查是否在缓存中.it !in cachedImages.value, 只有不在的才加载.
decoder.decodeRegion(tile.bounds, tile.sampleSize.size)具体解码,只需要知道tile大小与采样.
除了加载解码,还要处理不在tile中的it !in tiles, 避免浪费资源,如果任务没有完成就停止.已经完成的就删除.
解码部分是AndroidImageRegionDecoder,这个没有什么特别需要注意的,它处理了旋转.最后返回的是RotatedBitmapPainter,而不是普通的painter,onDraw()时处理了旋转,
SubSamplingImage的流程总结一下:
根据view的大小,得到可用空间大小.
根据解码器得到图片的大小.
计算tile,整张图片的与分块的.
调用LoadImageTilesEffect()去加载tile的图片
绘制是监听viewportImageTiles的变化.
SubSamplingImage是基于ZoomableImage,它的缩放功能ZoomableImage已经实现了.ZoomableContentTransformation中有内容的大小,缩放的级别,centroid等信息.tile的可见是与这个centroid匹配的.
每当移动或其它手势产生时.调用
RealZoomableContentTransformation.calculateFrom(
gestureStateInputs = gestureStateInputs,
gestureState = gestureState.calculate(gestureStateInputs),
)重新计算,触发viewtiles的变化.
官方文档说,不只是支持图片,可以支持pdf等文档.要支持pdf的话,需要实现自己的source与解码.pdf似乎只能支持单页.