fix: avoid ANR during live text style updates
Build & Test / Build & Test App (push) Waiting to run

This commit is contained in:
2026-09-19 13:42:50 +09:00
parent 1b01c894ad
commit dcec339477
11 changed files with 150 additions and 46 deletions
@@ -223,6 +223,16 @@ class ReaderActivity : BaseActivity() {
ReaderViewModel.Event.ReloadViewerChapters -> { ReaderViewModel.Event.ReloadViewerChapters -> {
viewModel.state.value.viewerChapters?.let(::setChapters) viewModel.state.value.viewerChapters?.let(::setChapters)
} }
ReaderViewModel.Event.RefreshCurrentTextPage -> {
viewModel.state.value.viewer?.refreshCurrentPage()
}
ReaderViewModel.Event.ReloadTextViewerChapters -> {
val state = viewModel.state.value
val chapters = state.viewerChapters
if (chapters != null) {
state.viewer?.refreshTextChapters(chapters)
}
}
ReaderViewModel.Event.PageChanged -> { ReaderViewModel.Event.PageChanged -> {
displayRefreshHost.flash() displayRefreshHost.flash()
} }
@@ -254,7 +264,8 @@ class ReaderActivity : BaseActivity() {
readerState = viewModel.state, readerState = viewModel.state,
onChangeReadingMode = viewModel::setMangaReadingMode, onChangeReadingMode = viewModel::setMangaReadingMode,
onChangeOrientation = viewModel::setMangaOrientationType, onChangeOrientation = viewModel::setMangaOrientationType,
onTextSettingsChanged = viewModel::reloadTextReaderStyle, onTextSettingsPreview = viewModel::previewTextReaderStyle,
onTextSettingsCommit = viewModel::commitTextReaderStyle,
preferences = readerPreferences, preferences = readerPreferences,
) )
} }
@@ -40,6 +40,7 @@ import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.reader.loader.ChapterLoader import eu.kanade.tachiyomi.ui.reader.loader.ChapterLoader
import eu.kanade.tachiyomi.ui.reader.loader.DownloadPageLoader import eu.kanade.tachiyomi.ui.reader.loader.DownloadPageLoader
import eu.kanade.tachiyomi.ui.reader.loader.TextReaderPage
import eu.kanade.tachiyomi.ui.reader.model.InsertPage import eu.kanade.tachiyomi.ui.reader.model.InsertPage
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
@@ -56,6 +57,7 @@ import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.cacheImageDir import eu.kanade.tachiyomi.util.storage.cacheImageDir
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -90,7 +92,6 @@ import tachiyomi.domain.source.service.SourceManager
import tachiyomi.source.local.image.LocalCoverManager import tachiyomi.source.local.image.LocalCoverManager
import tachiyomi.source.local.isLocal import tachiyomi.source.local.isLocal
import java.util.Date import java.util.Date
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.time.Clock import kotlin.time.Clock
@@ -824,41 +825,36 @@ class ReaderViewModel(
mutableState.update { it.copy(dialog = null) } mutableState.update { it.copy(dialog = null) }
} }
private val textReaderStyleReloading = AtomicBoolean(false) fun previewTextReaderStyle() {
private val textReaderStyleReloadPending = AtomicBoolean(false)
fun reloadTextReaderStyle() {
val chapter = state.value.currentChapter ?: return val chapter = state.value.currentChapter ?: return
if (chapter.pageLoader?.usesTextReaderStyle != true) return if (chapter.pageLoader?.usesTextReaderStyle != true) return
val chapterLoader = loader ?: return val style = readerPreferences.textReaderStyle()
val textPages = chapter.pages?.filterIsInstance<TextReaderPage>().orEmpty()
if (textPages.isEmpty()) return
if (!textReaderStyleReloading.compareAndSet(false, true)) { textPages.forEach { it.updateStyle(style) }
textReaderStyleReloadPending.set(true) eventChannel.trySend(Event.RefreshCurrentTextPage)
return
} }
fun commitTextReaderStyle() {
val chapter = state.value.currentChapter ?: return
if (chapter.pageLoader?.usesTextReaderStyle != true) return
if (chapter.pages?.any { it is TextReaderPage } != true) return
val chapterLoader = loader ?: return
val requestedPage = (state.value.currentPage - 1).coerceAtLeast(0) val requestedPage = (state.value.currentPage - 1).coerceAtLeast(0)
val oldLastIndex = (chapter.pages?.lastIndex ?: 0).coerceAtLeast(0) val oldLastIndex = (chapter.pages?.lastIndex ?: 0).coerceAtLeast(0)
val readingProgress = if (oldLastIndex == 0) 0f else requestedPage.toFloat() / oldLastIndex val readingProgress = if (oldLastIndex == 0) 0f else requestedPage.toFloat() / oldLastIndex
viewModelScope.launchIO { viewModelScope.launchIO {
try { try {
chapter.pageLoader?.recycle() val oldLoader = chapterLoader.reloadTextChapter(chapter, readingProgress) ?: return@launchIO
chapter.pageLoader = null eventChannel.send(Event.ReloadTextViewerChapters)
chapter.state = ReaderChapter.State.Wait // Give old visible holders time to detach before closing resources such as EpubReader.
chapter.requestedPage = requestedPage delay(2000)
chapterLoader.loadChapter(chapter) oldLoader.recycle()
val newLastIndex = (chapter.pages?.lastIndex ?: 0).coerceAtLeast(0)
chapter.requestedPage = (readingProgress * newLastIndex).roundToInt().coerceIn(0, newLastIndex)
eventChannel.send(Event.ReloadViewerChapters)
} catch (e: Throwable) { } catch (e: Throwable) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
logcat(LogPriority.ERROR, e) { "Failed to reload text reader style" } logcat(LogPriority.ERROR, e) { "Failed to repaginate text reader style" }
} finally {
textReaderStyleReloading.set(false)
if (textReaderStyleReloadPending.getAndSet(false)) {
reloadTextReaderStyle()
}
} }
} }
} }
@@ -1052,6 +1048,8 @@ class ReaderViewModel(
sealed interface Event { sealed interface Event {
data object ReloadViewerChapters : Event data object ReloadViewerChapters : Event
data object RefreshCurrentTextPage : Event
data object ReloadTextViewerChapters : Event
data object PageChanged : Event data object PageChanged : Event
data class SetOrientation(val orientation: Int) : Event data class SetOrientation(val orientation: Int) : Event
data class SetCoverResult(val result: SetAsCoverResult) : Event data class SetCoverResult(val result: SetAsCoverResult) : Event
@@ -22,6 +22,7 @@ import tachiyomi.domain.source.model.StubSource
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.source.local.LocalSource import tachiyomi.source.local.LocalSource
import tachiyomi.source.local.io.Format import tachiyomi.source.local.io.Format
import kotlin.math.roundToInt
/** /**
* Loader used to retrieve the [PageLoader] for a given chapter. * Loader used to retrieve the [PageLoader] for a given chapter.
@@ -73,6 +74,36 @@ class ChapterLoader(
} }
} }
suspend fun reloadTextChapter(chapter: ReaderChapter, readingProgress: Float): PageLoader? {
return withIOContext {
val newLoader = getPageLoader(chapter)
if (!newLoader.usesTextReaderStyle) {
newLoader.recycle()
return@withIOContext null
}
try {
val pages = newLoader.getPages()
.onEach { it.chapter = chapter }
if (pages.isEmpty()) {
throw Exception(context.stringResource(MR.strings.page_list_empty_error))
}
val oldLoader = chapter.pageLoader
chapter.pageLoader = newLoader
chapter.requestedPage = (readingProgress * pages.lastIndex)
.roundToInt()
.coerceIn(0, pages.lastIndex)
chapter.state = ReaderChapter.State.Loaded(pages)
oldLoader
} catch (e: Throwable) {
newLoader.recycle()
throw e
}
}
}
/** /**
* Checks [chapter] to be loaded based on present pages and loader in addition to state. * Checks [chapter] to be loaded based on present pages and loader in addition to state.
*/ */
@@ -26,10 +26,7 @@ internal class EpubPageLoader(
if (textPages.isNotEmpty()) { if (textPages.isNotEmpty()) {
return textPages.mapIndexed { index, text -> return textPages.mapIndexed { index, text ->
ReaderPage(index).apply { TextReaderPage(index, text, style)
stream = { TextPageRenderer.render(text, style) }
status = Page.State.Ready
}
} }
} }
@@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.ui.reader.loader package eu.kanade.tachiyomi.ui.reader.loader
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
import java.nio.ByteBuffer import java.nio.ByteBuffer
@@ -22,10 +21,7 @@ internal class TextPageLoader(
val bytes = file.openInputStream().use { it.readBytes() } val bytes = file.openInputStream().use { it.readBytes() }
val text = decodeText(bytes) val text = decodeText(bytes)
return TextPageRenderer.paginate(text, style).mapIndexed { index, pageText -> return TextPageRenderer.paginate(text, style).mapIndexed { index, pageText ->
ReaderPage(index).apply { TextReaderPage(index, pageText, style)
stream = { TextPageRenderer.render(pageText, style) }
status = Page.State.Ready
}
} }
} }
@@ -0,0 +1,29 @@
package eu.kanade.tachiyomi.ui.reader.loader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
/**
* A rendered text page whose style can be changed without rebuilding the chapter.
* Page boundaries remain stable during the live preview; a full repagination is done
* once when the settings dialog is dismissed.
*/
internal class TextReaderPage(
index: Int,
private val pageText: String,
initialStyle: TextReaderStyle,
) : ReaderPage(index) {
@Volatile
private var style: TextReaderStyle = initialStyle
init {
stream = { TextPageRenderer.render(pageText, style) }
status = Page.State.Ready
}
fun updateStyle(newStyle: TextReaderStyle) {
style = newStyle
}
}
@@ -16,7 +16,8 @@ class ReaderSettingsViewModel(
readerState: StateFlow<ReaderViewModel.State>, readerState: StateFlow<ReaderViewModel.State>,
val onChangeReadingMode: (ReadingMode) -> Unit, val onChangeReadingMode: (ReadingMode) -> Unit,
val onChangeOrientation: (ReaderOrientation) -> Unit, val onChangeOrientation: (ReaderOrientation) -> Unit,
private val onTextSettingsChanged: () -> Unit, private val onTextSettingsPreview: () -> Unit,
private val onTextSettingsCommit: () -> Unit,
val preferences: ReaderPreferences, val preferences: ReaderPreferences,
) : ViewModel() { ) : ViewModel() {
@@ -31,20 +32,24 @@ class ReaderSettingsViewModel(
.stateIn(viewModelScope, SharingStarted.Lazily, null) .stateIn(viewModelScope, SharingStarted.Lazily, null)
private var textSettingsReloadJob: Job? = null private var textSettingsReloadJob: Job? = null
private var textSettingsDirty = false
fun requestTextSettingsReload() { fun requestTextSettingsReload() {
textSettingsDirty = true
textSettingsReloadJob?.cancel() textSettingsReloadJob?.cancel()
textSettingsReloadJob = viewModelScope.launch { textSettingsReloadJob = viewModelScope.launch {
delay(150) delay(100)
onTextSettingsChanged() onTextSettingsPreview()
textSettingsReloadJob = null textSettingsReloadJob = null
} }
} }
fun flushTextSettingsReload() { fun flushTextSettingsReload() {
if (textSettingsReloadJob == null) return if (!textSettingsDirty) return
textSettingsReloadJob?.cancel() textSettingsReloadJob?.cancel()
textSettingsReloadJob = null textSettingsReloadJob = null
onTextSettingsChanged() onTextSettingsPreview()
textSettingsDirty = false
onTextSettingsCommit()
} }
} }
@@ -26,6 +26,14 @@ interface Viewer {
*/ */
fun setChapters(chapters: ViewerChapters) fun setChapters(chapters: ViewerChapters)
/** Rebinds only the currently visible page(s), used for lightweight text-style preview. */
fun refreshCurrentPage() {}
/** Replaces repaginated text pages without expensive per-item diffing where possible. */
fun refreshTextChapters(chapters: ViewerChapters) {
setChapters(chapters)
}
/** /**
* Tells this viewer to move to the given [page]. * Tells this viewer to move to the given [page].
*/ */
@@ -380,6 +380,17 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
pager.setCurrentItem(currentItem, false) pager.setCurrentItem(currentItem, false)
} }
override fun refreshCurrentPage() {
refreshAdapter()
}
override fun refreshTextChapters(chapters: ViewerChapters) {
setChaptersInternal(chapters)
val pages = chapters.currChapter.pages ?: return
val page = pages[min(chapters.currChapter.requestedPage, pages.lastIndex)]
moveToPage(page)
}
/** /**
* Called from the containing activity when a key [event] is received. It should return true * Called from the containing activity when a key [event] is received. It should return true
* if the event was handled, false otherwise. * if the event was handled, false otherwise.
@@ -35,7 +35,7 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : RecyclerView.Adapter<RecyclerV
* Updates this adapter with the given [chapters]. It handles setting a few pages of the * Updates this adapter with the given [chapters]. It handles setting a few pages of the
* next/previous chapter to allow seamless transitions. * next/previous chapter to allow seamless transitions.
*/ */
fun setChapters(chapters: ViewerChapters, forceTransition: Boolean) { fun setChapters(chapters: ViewerChapters, forceTransition: Boolean, useDiff: Boolean = true) {
val newItems = mutableListOf<Any>() val newItems = mutableListOf<Any>()
// Forces chapter transition if there is missing chapters // Forces chapter transition if there is missing chapters
@@ -65,13 +65,18 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : RecyclerView.Adapter<RecyclerV
chapters.nextChapter?.pages?.let(newItems::addAll) chapters.nextChapter?.pages?.let(newItems::addAll)
updateItems(newItems) updateItems(newItems, useDiff)
} }
private fun updateItems(newItems: List<Any>) { private fun updateItems(newItems: List<Any>, useDiff: Boolean) {
if (useDiff) {
val result = DiffUtil.calculateDiff(Callback(items, newItems)) val result = DiffUtil.calculateDiff(Callback(items, newItems))
items = newItems items = newItems
result.dispatchUpdatesTo(this) result.dispatchUpdatesTo(this)
} else {
items = newItems
notifyDataSetChanged()
}
} }
fun refresh() { fun refresh() {
@@ -353,6 +353,19 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
min(position + 3, adapter.itemCount - 1), min(position + 3, adapter.itemCount - 1),
) )
} }
override fun refreshCurrentPage() {
refreshAdapter()
}
override fun refreshTextChapters(chapters: ViewerChapters) {
val forceTransition = config.alwaysShowChapterTransition || currentPage is ChapterTransition
adapter.setChapters(chapters, forceTransition, useDiff = false)
val pages = chapters.currChapter.pages ?: return
val page = pages[min(chapters.currChapter.requestedPage, pages.lastIndex)]
moveToPage(page)
currentPage = page
}
} }
// Double the cache size to reduce rebinds/recycles incurred by the extra layout space on scroll direction changes // Double the cache size to reduce rebinds/recycles incurred by the extra layout space on scroll direction changes