Fix webtoon scroll stutter between pages

Closes #4026

Co-authored-by: Matheus William <matheuswr89@outlook.com>
This commit is contained in:
AntsyLich
2026-10-12 00:37:45 +06:00
co-authored by Matheus William
parent 039138ce99
commit b5856135c5
6 changed files with 266 additions and 69 deletions
+1
View File
@@ -154,6 +154,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
- Fixed actions started from a dialog sometimes not running when the dialog closed right away, Kitsu, Shikimori, MangaBaka, Bangumi and Hikka sometimes refreshing their login twice at once, adding an extension repo served over HTTP failing, and cancelling an extension install not stopping its download ([@AntsyLich](https://github.com/AntsyLich)) ([#4068](https://github.com/mihonapp/mihon/pull/4068))
- Fixed the next library update staying "Soon" after new chapters were found, and chapters that came out early, late or were dated earlier by the source pushing the next check back ([@AntsyLich](https://github.com/AntsyLich)) ([`ba44815`](https://github.com/mihonapp/mihon/commit/ba44815))
- Fixed long strip side padding being counted in zoom bounds ([@WizaHX](https://github.com/WizaHX)) ([#4072](https://github.com/mihonapp/mihon/pull/4072))
- Fixed long strip stuttering between pages and using a lot of memory with very tall pages ([@AntsyLich](https://github.com/AntsyLich), [@matheuswr89](https://github.com/matheuswr89))
### Other
- Show "Nightly" instead of "Beta" for preview builds and "FOSS" for FOSS builds in About ([@AntsyLich](https://github.com/AntsyLich)) ([#3760](https://github.com/mihonapp/mihon/pull/3760))
@@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.data.coil
import android.graphics.Bitmap
import android.graphics.Matrix
import androidx.core.graphics.createBitmap
import androidx.core.graphics.scale
import ca.mpreg.imagedecoder.ImageDecoder
@@ -14,8 +15,12 @@ import coil3.decode.Decoder
import coil3.decode.ImageSource
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import okio.BufferedSource
import tachiyomi.core.common.util.system.ImageUtil
import kotlin.math.min
import kotlin.math.sqrt
/**
* A [Decoder] that uses [ImageDecoder] to decode image formats not supported
@@ -43,7 +48,22 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
override fun draw(canvas: Canvas) {}
}
/**
* A page cut into immutable strips of about [STRIP_BYTES] each. The renderer uploads an
* immutable bitmap only when it is drawn, so only the strips on screen reach the GPU instead of
* the whole page as one texture.
*/
class StripsImage(val strips: List<Bitmap>) : Image {
override val size: Long = strips.sumOf { it.allocationByteCount.toLong() }
override val width: Int = strips.firstOrNull()?.width ?: 0
override val height: Int = strips.sumOf { it.height }
override val shareable: Boolean get() = false
override fun draw(canvas: Canvas) {}
}
override suspend fun decode(): DecodeResult {
if (options.webtoonStrips) return decodeStrips()
val res = resources.source().use {
ImageDecoder.open(it.inputStream()).use { dec ->
DecodeResultImage(
@@ -105,9 +125,67 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
)
}
/**
* Copies bands of rows straight from the native frame into strips, so the page never exists as
* one Java bitmap on top of the frame.
*
* Strips keep the resolution a power-of-two sample size leaves, like the other paths, so zooming
* in still shows the page's detail, unless that would make the page take more than
* [MAX_PAGE_BYTES]. They are never narrower than the view, so the page stays sharp unzoomed.
*/
private suspend fun decodeStrips(): DecodeResult = stripDecodes.withPermit {
val (frame, isHdr) = resources.source().use {
ImageDecoder.open(it.inputStream()).use { dec -> dec.decodeNext() to dec.isHdr }
}
frame.use {
val config = if (isHdr) Bitmap.Config.RGBA_F16 else Bitmap.Config.ARGB_8888
val bytesPerPixel = if (isHdr) 8 else 4
val rowBytes = frame.width * bytesPerPixel
val viewWidth = options.size.widthPx(options.scale) { frame.width }
val sampleSize = DecodeUtils.calculateInSampleSize(
srcWidth = frame.width,
srcHeight = frame.height,
dstWidth = viewWidth,
dstHeight = options.size.heightPx(options.scale) { frame.height },
scale = options.scale,
)
val budgetWidth = sqrt(MAX_PAGE_BYTES.toDouble() / bytesPerPixel * frame.width / frame.height).toInt()
val stripWidth = maxOf(viewWidth, min(frame.width / sampleSize, budgetWidth))
// Older GPUs can't draw a texture wider than this
.coerceAtMost(MAX_STRIP_WIDTH)
.coerceIn(1, frame.width)
val scale = stripWidth.toFloat() / frame.width
// Strips of about the same size in bytes, so each takes about as long to upload
val stripRows = (STRIP_BYTES / (stripWidth * bytesPerPixel)).coerceAtLeast(1)
val bandRows = (stripRows / scale).toInt().coerceIn(1, frame.height)
val matrix = Matrix().apply { setScale(scale, scale) }
val band = createBitmap(frame.width, bandRows, config)
val strips = (0..<frame.height step bandRows).map { top ->
val rows = min(bandRows, frame.height - top)
if (rows != band.height) band.reconfigure(frame.width, rows, config)
band.copyPixelsFromBuffer(
frame.image.duplicate().apply {
position(top * rowBytes)
limit((top + rows) * rowBytes)
},
)
val strip = if (stripWidth < frame.width) {
Bitmap.createBitmap(band, 0, 0, frame.width, rows, matrix, true)
} else {
band
}
// createBitmap returns a mutable bitmap despite its docs, and copy is the only way to an immutable one
strip.copy(config, false).also { if (strip !== band) strip.recycle() }
}
band.recycle()
DecodeResult(image = StripsImage(strips), isSampled = stripWidth < frame.width)
}
}
class Factory : Decoder.Factory {
override fun create(result: SourceFetchResult, options: Options, imageLoader: ImageLoader): Decoder? {
return if (options.newDecoder || options.customDecoder || isApplicable(result.source.source())) {
return if (options.newDecoder || options.webtoonStrips || isApplicable(result.source.source())) {
ImageDecoder(result.source, options)
} else {
null
@@ -134,3 +212,12 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
override fun hashCode() = javaClass.hashCode()
}
}
private const val STRIP_BYTES = 4 * 1024 * 1024
private const val MAX_PAGE_BYTES = 128 * 1024 * 1024
private const val MAX_STRIP_WIDTH = 4096
// Each decode holds the whole native frame until its strips are copied, and the reader loads pages ahead in parallel
private val stripDecodes = Semaphore(1)
@@ -34,14 +34,14 @@ val Options.cropBorders: Boolean
private val cropBordersKey = Extras.Key(default = false)
fun ImageRequest.Builder.customDecoder(enable: Boolean) = apply {
extras[customDecoderKey] = enable
fun ImageRequest.Builder.webtoonStrips(enable: Boolean) = apply {
extras[webtoonStripsKey] = enable
}
val Options.customDecoder: Boolean
get() = getExtra(customDecoderKey)
val Options.webtoonStrips: Boolean
get() = getExtra(webtoonStripsKey)
private val customDecoderKey = Extras.Key(default = false)
private val webtoonStripsKey = Extras.Key(default = false)
fun ImageRequest.Builder.newDecoder(enable: Boolean) = apply {
extras[newDecoderKey] = enable
@@ -11,6 +11,7 @@ import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout
import androidx.annotation.AttrRes
import androidx.annotation.CallSuper
@@ -18,11 +19,11 @@ import androidx.annotation.StyleRes
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.os.postDelayed
import androidx.core.view.isVisible
import coil3.BitmapImage
import coil3.asDrawable
import coil3.dispose
import coil3.imageLoader
import coil3.request.CachePolicy
import coil3.request.Disposable
import coil3.request.ImageRequest
import coil3.request.crossfade
import coil3.size.Precision
@@ -33,9 +34,10 @@ import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView.EASE_IN_OUT
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView.EASE_OUT_QUAD
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE
import com.github.chrisbanes.photoview.PhotoView
import eu.kanade.tachiyomi.data.coil.ImageDecoder
import eu.kanade.tachiyomi.data.coil.cropBorders
import eu.kanade.tachiyomi.data.coil.customDecoder
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonSubsamplingImageView
import eu.kanade.tachiyomi.data.coil.webtoonStrips
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonStripImageView
import eu.kanade.tachiyomi.util.system.animatorDurationScale
import eu.kanade.tachiyomi.util.view.isVisibleOnScreen
import okio.BufferedSource
@@ -45,7 +47,7 @@ import okio.BufferedSource
*
* Animated image will be drawn by [PhotoView] while [SubsamplingScaleImageView] will take non-animated image.
*
* @param isWebtoon if true, [WebtoonSubsamplingImageView] will be used instead of [SubsamplingScaleImageView]
* @param isWebtoon if true, [WebtoonStripImageView] will be used instead of [SubsamplingScaleImageView]
* and [AppCompatImageView] will be used instead of [PhotoView]
*/
open class ReaderPageImageView @JvmOverloads constructor(
@@ -58,6 +60,8 @@ open class ReaderPageImageView @JvmOverloads constructor(
private var pageView: View? = null
private var stripRequest: Disposable? = null
private var config: Config? = null
var onImageLoaded: (() -> Unit)? = null
@@ -155,19 +159,27 @@ open class ReaderPageImageView @JvmOverloads constructor(
if (isAnimated) {
prepareAnimatedImageView()
setAnimatedImage(source, config)
} else if (isWebtoon) {
prepareStripImageView()
setStripImage(source, config)
} else {
prepareNonAnimatedImageView()
setNonAnimatedImage(source, config)
}
}
fun recycle() = pageView?.let {
fun recycle() {
stripRequest?.dispose()
stripRequest = null
pageView?.let {
when (it) {
is SubsamplingScaleImageView -> it.recycle()
is AppCompatImageView -> it.dispose()
is WebtoonStripImageView -> it.recycle()
}
it.isVisible = false
}
}
/**
* Check if the image can be panned to the left
@@ -227,11 +239,7 @@ open class ReaderPageImageView @JvmOverloads constructor(
if (pageView is SubsamplingScaleImageView) return
removeView(pageView)
pageView = if (isWebtoon) {
WebtoonSubsamplingImageView(context)
} else {
SubsamplingScaleImageView(context)
}.apply {
pageView = SubsamplingScaleImageView(context).apply {
setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_CENTER)
setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE)
setMinimumTileDpi(180)
@@ -292,21 +300,44 @@ open class ReaderPageImageView @JvmOverloads constructor(
isVisible = true
}
is BufferedSource -> {
if (!isWebtoon) {
setImage(ImageSource.inputStream(data.inputStream()))
isVisible = true
return@apply
}
else -> {
throw IllegalArgumentException("Not implemented for class ${data::class.simpleName}")
}
}
}
ImageRequest.Builder(context)
.data(data)
private fun prepareStripImageView() {
if (pageView is WebtoonStripImageView) return
removeView(pageView)
pageView = WebtoonStripImageView(context)
addView(pageView, MATCH_PARENT, WRAP_CONTENT)
}
private fun setStripImage(data: BufferedSource, config: Config) {
val view = pageView as? WebtoonStripImageView ?: return
view.onReload = { requestStrips(view, data, config, isReload = true) }
requestStrips(view, data, config, isReload = false)
}
private fun requestStrips(view: WebtoonStripImageView, data: BufferedSource, config: Config, isReload: Boolean) {
// A recycled view is reused for another page, where a request still running for the old one must not land
stripRequest?.dispose()
stripRequest = ImageRequest.Builder(context)
// Peeking leaves the data whole for decoding again after the strips were freed off screen
.data(data.peek())
.memoryCachePolicy(CachePolicy.DISABLED)
.diskCachePolicy(CachePolicy.DISABLED)
.target(
onSuccess = { result ->
val image = result as BitmapImage
setImage(ImageSource.bitmap(image.bitmap))
isVisible = true
view.setImage(result as ImageDecoder.StripsImage)
if (!isReload) {
view.isVisible = true
onImageLoaded()
}
},
)
.listener(
@@ -314,19 +345,14 @@ open class ReaderPageImageView @JvmOverloads constructor(
onImageLoadError(result.throwable)
},
)
.size(ViewSizeResolver(this@ReaderPageImageView))
.size(ViewSizeResolver(this))
.precision(Precision.INEXACT)
.cropBorders(config.cropBorders)
.customDecoder(true)
.webtoonStrips(true)
.crossfade(false)
.build()
.let(context.imageLoader::enqueue)
}
else -> {
throw IllegalArgumentException("Not implemented for class ${data::class.simpleName}")
}
}
}
private fun prepareAnimatedImageView() {
if (pageView is AppCompatImageView) return
@@ -0,0 +1,103 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Rect
import android.view.MotionEvent
import android.view.View
import eu.kanade.tachiyomi.data.coil.ImageDecoder
import kotlin.math.roundToInt
/**
* Draws a webtoon page as a stack of strips, so a very tall page never becomes one huge GPU texture.
*
* Ignores all touch events, because the webtoon viewer handles all the gestures.
*/
class WebtoonStripImageView(context: Context) : View(context) {
private var strips: List<Bitmap> = emptyList()
private var imageWidth = 0
private var imageHeight = 0
private val paint = Paint(Paint.FILTER_BITMAP_FLAG)
private val clipRect = Rect()
private val dstRect = Rect()
/** Decodes the page again after its strips were freed off screen. */
var onReload: (() -> Unit)? = null
private var isReleasedOffScreen = false
fun setImage(image: ImageDecoder.StripsImage) {
releaseStrips()
isReleasedOffScreen = false
strips = image.strips
imageWidth = image.width
imageHeight = image.height
requestLayout()
invalidate()
}
fun recycle() {
releaseStrips()
isReleasedOffScreen = false
onReload = null
imageWidth = 0
imageHeight = 0
requestLayout()
}
// The viewer keeps a few pages off screen without recycling them, which adds up for huge pages.
// The size stays, so the layout doesn't move while the page decodes again.
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
if (strips.sumOf { it.allocationByteCount.toLong() } > LARGE_PAGE_BYTES) {
releaseStrips()
isReleasedOffScreen = true
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
if (isReleasedOffScreen) {
isReleasedOffScreen = false
onReload?.invoke()
}
}
// A page's strips can be hundreds of MB, which would otherwise wait for a garbage collection to be freed
private fun releaseStrips() {
val released = strips
strips = emptyList()
released.forEach(Bitmap::recycle)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val width = MeasureSpec.getSize(widthMeasureSpec)
val height = if (imageWidth > 0) (width.toLong() * imageHeight / imageWidth).toInt() else 0
setMeasuredDimension(width, height)
}
override fun onDraw(canvas: Canvas) {
if (imageWidth <= 0) return
canvas.getClipBounds(clipRect)
val scale = width.toFloat() / imageWidth
var srcTop = 0
for (strip in strips) {
val top = (srcTop * scale).roundToInt()
srcTop += strip.height
val bottom = (srcTop * scale).roundToInt()
if (bottom <= clipRect.top) continue
if (top >= clipRect.bottom) break
dstRect.set(0, top, width, bottom)
canvas.drawBitmap(strip, null, dstRect, paint)
}
}
override fun onTouchEvent(event: MotionEvent): Boolean = false
}
private const val LARGE_PAGE_BYTES = 64L * 1024 * 1024
@@ -1,20 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
/**
* Implementation of subsampling scale image view that ignores all touch events, because the
* webtoon viewer handles all the gestures.
*/
class WebtoonSubsamplingImageView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : SubsamplingScaleImageView(context, attrs) {
override fun onTouchEvent(event: MotionEvent): Boolean {
return false
}
}