Преглед изворни кода

Merge pull request #3731 from gedoor/master

Sync changes from branch master
Xwite пре 2 година
родитељ
комит
5975156a58
64 измењених фајлова са 1833 додато и 955 уклоњено
  1. 3 0
      .github/ISSUE_TEMPLATE/config.yml
  2. 16 2
      .github/workflows/test.yml
  3. 1 1
      .github/workflows/web.yml
  4. 9 0
      app/src/main/AndroidManifest.xml
  5. 0 1
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  6. 6 0
      app/src/main/java/io/legado/app/help/ExecutorService.kt
  7. 3 2
      app/src/main/java/io/legado/app/help/config/AppConfig.kt
  8. 0 1
      app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt
  9. 16 1
      app/src/main/java/io/legado/app/lib/theme/ThemeStore.kt
  10. 7 44
      app/src/main/java/io/legado/app/model/ImageProvider.kt
  11. 50 33
      app/src/main/java/io/legado/app/model/ReadBook.kt
  12. 3 3
      app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeRule.kt
  13. 5 4
      app/src/main/java/io/legado/app/service/HttpReadAloudService.kt
  14. 21 0
      app/src/main/java/io/legado/app/ui/book/read/BaseReadBookActivity.kt
  15. 53 99
      app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt
  16. 32 3
      app/src/main/java/io/legado/app/ui/book/read/ReadMenu.kt
  17. 9 1
      app/src/main/java/io/legado/app/ui/book/read/SearchMenu.kt
  18. 1 1
      app/src/main/java/io/legado/app/ui/book/read/config/BgAdapter.kt
  19. 30 9
      app/src/main/java/io/legado/app/ui/book/read/config/BgTextConfigDialog.kt
  20. 14 4
      app/src/main/java/io/legado/app/ui/book/read/config/MoreConfigDialog.kt
  21. 14 14
      app/src/main/java/io/legado/app/ui/book/read/config/PaddingConfigDialog.kt
  22. 13 13
      app/src/main/java/io/legado/app/ui/book/read/config/ReadStyleDialog.kt
  23. 20 15
      app/src/main/java/io/legado/app/ui/book/read/config/TipConfigDialog.kt
  24. 148 0
      app/src/main/java/io/legado/app/ui/book/read/page/AutoPager.kt
  25. 115 208
      app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt
  26. 30 28
      app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt
  27. 47 41
      app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt
  28. 3 0
      app/src/main/java/io/legado/app/ui/book/read/page/api/DataSource.kt
  29. 15 56
      app/src/main/java/io/legado/app/ui/book/read/page/delegate/CoverPageDelegate.kt
  30. 11 16
      app/src/main/java/io/legado/app/ui/book/read/page/delegate/HorizontalPageDelegate.kt
  31. 10 1
      app/src/main/java/io/legado/app/ui/book/read/page/delegate/PageDelegate.kt
  32. 13 3
      app/src/main/java/io/legado/app/ui/book/read/page/delegate/ScrollPageDelegate.kt
  33. 42 2
      app/src/main/java/io/legado/app/ui/book/read/page/delegate/SimulationPageDelegate.kt
  34. 10 63
      app/src/main/java/io/legado/app/ui/book/read/page/delegate/SlidePageDelegate.kt
  35. 21 4
      app/src/main/java/io/legado/app/ui/book/read/page/entities/TextChapter.kt
  36. 68 2
      app/src/main/java/io/legado/app/ui/book/read/page/entities/TextLine.kt
  37. 67 14
      app/src/main/java/io/legado/app/ui/book/read/page/entities/TextPage.kt
  38. 7 0
      app/src/main/java/io/legado/app/ui/book/read/page/entities/column/BaseColumn.kt
  39. 11 2
      app/src/main/java/io/legado/app/ui/book/read/page/entities/column/ButtonColumn.kt
  40. 40 1
      app/src/main/java/io/legado/app/ui/book/read/page/entities/column/ImageColumn.kt
  41. 13 0
      app/src/main/java/io/legado/app/ui/book/read/page/entities/column/ReviewColumn.kt
  42. 46 2
      app/src/main/java/io/legado/app/ui/book/read/page/entities/column/TextColumn.kt
  43. 161 111
      app/src/main/java/io/legado/app/ui/book/read/page/provider/ChapterProvider.kt
  44. 143 0
      app/src/main/java/io/legado/app/ui/book/read/page/provider/TextMeasure.kt
  45. 20 12
      app/src/main/java/io/legado/app/ui/book/read/page/provider/TextPageFactory.kt
  46. 1 7
      app/src/main/java/io/legado/app/ui/book/read/page/provider/ZhLayout.kt
  47. 37 25
      app/src/main/java/io/legado/app/ui/widget/BatteryView.kt
  48. 1 0
      app/src/main/java/io/legado/app/ui/widget/DetailSeekBar.kt
  49. 7 3
      app/src/main/java/io/legado/app/ui/widget/TitleBar.kt
  50. 3 1
      app/src/main/java/io/legado/app/ui/widget/dialog/TextDialog.kt
  51. 0 77
      app/src/main/java/io/legado/app/utils/BitmapCache.kt
  52. 0 1
      app/src/main/java/io/legado/app/utils/BitmapUtils.kt
  53. 10 17
      app/src/main/java/io/legado/app/utils/ChineseUtils.kt
  54. 39 0
      app/src/main/java/io/legado/app/utils/CollectionExtensions.kt
  55. 33 0
      app/src/main/java/io/legado/app/utils/ViewExtensions.kt
  56. 36 0
      app/src/main/java/io/legado/app/utils/canvasrecorder/BaseCanvasRecorder.kt
  57. 27 0
      app/src/main/java/io/legado/app/utils/canvasrecorder/CanvasRecorder.kt
  58. 39 0
      app/src/main/java/io/legado/app/utils/canvasrecorder/CanvasRecorderApi23Impl.kt
  59. 64 0
      app/src/main/java/io/legado/app/utils/canvasrecorder/CanvasRecorderApi29Impl.kt
  60. 35 0
      app/src/main/java/io/legado/app/utils/canvasrecorder/CanvasRecorderExtensions.kt
  61. 23 0
      app/src/main/java/io/legado/app/utils/canvasrecorder/CanvasRecorderFactory.kt
  62. 59 0
      app/src/main/java/io/legado/app/utils/canvasrecorder/CanvasRecorderImpl.kt
  63. 52 0
      app/src/main/java/io/legado/app/utils/canvasrecorder/CanvasRecorderLocked.kt
  64. 0 7
      app/src/main/res/xml/pref_config_read.xml

+ 3 - 0
.github/ISSUE_TEMPLATE/config.yml

@@ -1,5 +1,8 @@
 blank_issues_enabled: false
 contact_links:
+  - name: 简繁转化
+    url: https://github.com/liuyueyi/quick-chinese-transfer/issues/new
+    about: 简繁转化问题请优先到quick-chinese-transfer反馈
   - name: 讨论 / Discussions
     url: https://github.com/gedoor/legado/discussions
     about: Please ask and answer questions here.

+ 16 - 2
.github/workflows/test.yml

@@ -78,6 +78,7 @@ jobs:
         uses: gradle/actions/setup-gradle@v3
 
       - name: Build With Gradle
+        continue-on-error: true
         run: |
           if [ ${{ env.type }} == 'release' ]; then
             typeName="原包名"
@@ -91,15 +92,22 @@ jobs:
           echo "开始${{ env.product }}$typeName构建"
           chmod +x gradlew
           ./gradlew assemble${{ env.product }}release --build-cache --parallel --daemon --warning-mode all
-          echo "修改文件名"
+      - name: Rename outputs apks And Move files
+        run: |
+          echo "修改APK文件名"
           mkdir -p ${{ github.workspace }}/apk/
-          mkdir -p ${{ github.workspace }}/mapping/
           for file in `ls ${{ github.workspace }}/app/build/outputs/apk/*/*/*.apk`; do
             mv "$file" ${{ github.workspace }}/apk/legado_${{ env.product }}_${{ env.VERSIONL }}_$typeName.apk
           done
+          echo "移动mapping文件"
+          mkdir -p ${{ github.workspace }}/mapping/
           for file in `ls ${{ github.workspace }}/app/build/outputs/mapping/*/mapping.txt`; do
             mv "$file" ${{ github.workspace }}/mapping/mapping.txt
           done
+          echo "移动missing_rules.txt文件"
+          for file in `ls ${{ github.workspace }}/app/build/outputs/mapping/*/missing_rules.txt`; do
+            mv "$file" ${{ github.workspace }}/mapping/missing_rules.txt
+          done
       - name: Upload App To Artifact
         uses: actions/upload-artifact@v4
         with:
@@ -112,6 +120,12 @@ jobs:
           name: legado.${{ env.product }}.${{ env.type }}.mapping
           path: ${{ github.workspace }}/mapping/mapping.txt
 
+      - name: Upload Missing Rules File To Artifact
+        uses: actions/upload-artifact@v4
+        with:
+          name: legado.${{ env.product }}.${{ env.type }}.mapping.missing_rules
+          path: ${{ github.workspace }}/mapping/missing_rules.txt
+
   lanzou:
     needs: [ prepare, build ]
     if: ${{ github.event_name != 'pull_request' && needs.prepare.outputs.lanzou == 'yes' }}

+ 1 - 1
.github/workflows/web.yml

@@ -27,7 +27,7 @@ jobs:
         with:
           node-version: 16
 
-      - uses: pnpm/action-setup@v2
+      - uses: pnpm/action-setup@v3
         name: Install pnpm
         id: pnpm-install
         with:

+ 9 - 0
app/src/main/AndroidManifest.xml

@@ -473,6 +473,15 @@
                 android:name="android.support.FILE_PROVIDER_PATHS"
                 android:resource="@xml/file_paths" />
         </provider>
+        <provider
+            android:name="androidx.startup.InitializationProvider"
+            android:authorities="${applicationId}.androidx-startup"
+            android:exported="false"
+            tools:node="merge">
+            <meta-data
+                android:name="androidx.emoji2.text.EmojiCompatInitializer"
+                tools:node="remove" />
+        </provider>
 
         <meta-data
             android:name="channel"

+ 0 - 1
app/src/main/java/io/legado/app/constant/PreferKey.kt

@@ -117,7 +117,6 @@ object PreferKey {
     const val welcomeShowIconDark = "welcomeShowIconDark"
     const val pageTouchSlop = "pageTouchSlop"
     const val showAddToShelfAlert = "showAddToShelfAlert"
-    const val asyncLoadImage = "asyncLoadImage"
     const val ignoreAudioFocus = "ignoreAudioFocus"
     const val parallelExportBook = "parallelExportBook"
     const val progressBarBehavior = "progressBarBehavior"

+ 6 - 0
app/src/main/java/io/legado/app/help/ExecutorService.kt

@@ -0,0 +1,6 @@
+package io.legado.app.help
+
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+
+val globalExecutor: ExecutorService by lazy { Executors.newSingleThreadExecutor() }

+ 3 - 2
app/src/main/java/io/legado/app/help/config/AppConfig.kt

@@ -137,6 +137,9 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
             }
         }
 
+    val textSelectAble: Boolean
+        get() = appCtx.getPrefBoolean(PreferKey.textSelectAble, true)
+
     val isTransparentStatusBar: Boolean
         get() = appCtx.getPrefBoolean(PreferKey.transparentStatusBar, true)
 
@@ -457,8 +460,6 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
 
     val showAddToShelfAlert get() = appCtx.getPrefBoolean(PreferKey.showAddToShelfAlert, true)
 
-    val asyncLoadImage get() = appCtx.getPrefBoolean(PreferKey.asyncLoadImage, false)
-
     val ignoreAudioFocus get() = appCtx.getPrefBoolean(PreferKey.ignoreAudioFocus, false)
 
     val onlyLatestBackup get() = appCtx.getPrefBoolean(PreferKey.onlyLatestBackup, true)

+ 0 - 1
app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt

@@ -567,7 +567,6 @@ object ReadBookConfig {
                     textColorInt = color
                 }
             }
-            ChapterProvider.upStyle()
         }
 
         fun curTextColor(): Int {

+ 16 - 1
app/src/main/java/io/legado/app/lib/theme/ThemeStore.kt

@@ -163,7 +163,22 @@ private constructor(private val mContext: Context) : ThemeStoreInterface {
             .apply()
     }
 
-    companion object {
+    companion object : SharedPreferences.OnSharedPreferenceChangeListener {
+
+        init {
+            prefs(appCtx).registerOnSharedPreferenceChangeListener(this)
+        }
+
+        var accentColor = accentColor()
+
+        override fun onSharedPreferenceChanged(
+            sharedPreferences: SharedPreferences?,
+            key: String?
+        ) {
+            when (key) {
+                ThemeStorePrefKeys.KEY_ACCENT_COLOR -> accentColor = accentColor()
+            }
+        }
 
         fun editTheme(context: Context): ThemeStore {
             return ThemeStore(context)

+ 7 - 44
app/src/main/java/io/legado/app/model/ImageProvider.kt

@@ -2,13 +2,10 @@ package io.legado.app.model
 
 import android.graphics.Bitmap
 import android.graphics.BitmapFactory
-import android.os.Build
 import android.util.Size
 import androidx.collection.LruCache
 import io.legado.app.R
-import io.legado.app.constant.AppLog
 import io.legado.app.constant.AppLog.putDebug
-import io.legado.app.constant.PageAnim
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookSource
 import io.legado.app.exception.NoStackTraceException
@@ -16,10 +13,8 @@ import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.isEpub
 import io.legado.app.help.book.isPdf
 import io.legado.app.help.config.AppConfig
-import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.model.localBook.EpubFile
 import io.legado.app.model.localBook.PdfFile
-import io.legado.app.utils.BitmapCache
 import io.legado.app.utils.BitmapUtils
 import io.legado.app.utils.FileUtils
 import io.legado.app.utils.SvgUtils
@@ -29,8 +24,6 @@ import kotlinx.coroutines.withContext
 import splitties.init.appCtx
 import java.io.File
 import java.io.FileOutputStream
-import java.util.concurrent.ConcurrentHashMap
-import kotlin.math.min
 
 object ImageProvider {
 
@@ -50,12 +43,6 @@ object ImageProvider {
             }
             return AppConfig.bitmapCacheSize * M
         }
-    private val maxCacheSize = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N_MR1) {
-        min(128 * M, Runtime.getRuntime().maxMemory().toInt())
-    } else {
-        256 * M
-    }
-    private val asyncLoadingImages = ConcurrentHashMap.newKeySet<String>()
     val bitmapLruCache = object : LruCache<String, Bitmap>(cacheSize) {
 
         override fun sizeOf(filePath: String, bitmap: Bitmap): Int {
@@ -70,8 +57,7 @@ object ImageProvider {
         ) {
             //错误图片不能释放,占位用,防止一直重复获取图片
             if (oldBitmap != errorBitmap) {
-                BitmapCache.add(oldBitmap)
-                //oldBitmap.recycle()
+                oldBitmap.recycle()
                 //putDebug("ImageProvider: trigger bitmap recycle. URI: $filePath")
                 //putDebug("ImageProvider : cacheUsage ${size()}bytes / ${maxSize()}bytes")
             }
@@ -160,9 +146,8 @@ object ImageProvider {
         book: Book,
         src: String,
         width: Int,
-        height: Int? = null,
-        block: (() -> Unit)? = null
-    ): Bitmap? {
+        height: Int? = null
+    ): Bitmap {
         //src为空白时 可能被净化替换掉了 或者规则失效
         if (book.getUseReplaceRule() && src.isBlank()) {
             book.setUseReplaceRule(false)
@@ -174,32 +159,6 @@ object ImageProvider {
         //bitmapLruCache的key同一改成缓存文件的路径
         val cacheBitmap = getNotRecycled(vFile.absolutePath)
         if (cacheBitmap != null) return cacheBitmap
-        if (height != null && AppConfig.asyncLoadImage && ReadBook.pageAnim() == PageAnim.scrollPageAnim) {
-            if (asyncLoadingImages.contains(vFile.absolutePath)) {
-                return null
-            }
-            asyncLoadingImages.add(vFile.absolutePath)
-            Coroutine.async {
-                BitmapUtils.decodeBitmap(vFile.absolutePath, width, height)
-                    ?: SvgUtils.createBitmap(vFile.absolutePath, width, height)
-                    ?: throw NoStackTraceException(appCtx.getString(R.string.error_decode_bitmap))
-            }.onSuccess {
-                bitmapLruCache.run {
-                    if (maxSize() < maxCacheSize && size() + it.byteCount > maxSize() && putCount() - evictionCount() < 5) {
-                        resize(min(maxCacheSize, maxSize() + it.byteCount))
-                        AppLog.put("图片缓存太小,自动扩增至${(maxSize() / M)}MB。")
-                    }
-                }
-                bitmapLruCache.put(vFile.absolutePath, it)
-            }.onError {
-                //错误图片占位,防止重复获取
-                bitmapLruCache.put(vFile.absolutePath, errorBitmap)
-            }.onFinally {
-                asyncLoadingImages.remove(vFile.absolutePath)
-                block?.invoke()
-            }
-            return null
-        }
         return kotlin.runCatching {
             val bitmap = BitmapUtils.decodeBitmap(vFile.absolutePath, width, height)
                 ?: SvgUtils.createBitmap(vFile.absolutePath, width, height)
@@ -212,4 +171,8 @@ object ImageProvider {
         }.getOrDefault(errorBitmap)
     }
 
+    fun clear() {
+        bitmapLruCache.evictAll()
+    }
+
 }

+ 50 - 33
app/src/main/java/io/legado/app/model/ReadBook.kt

@@ -14,6 +14,7 @@ import io.legado.app.help.book.isLocal
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.help.coroutine.Coroutine
+import io.legado.app.help.globalExecutor
 import io.legado.app.model.localBook.TextFile
 import io.legado.app.model.webBook.WebBook
 import io.legado.app.service.BaseReadAloudService
@@ -63,6 +64,7 @@ object ReadBook : CoroutineScope by MainScope() {
     val downloadFailChapters = hashMapOf<Int, Int>()
     var contentProcessor: ContentProcessor? = null
     val downloadScope = CoroutineScope(SupervisorJob() + IO)
+    val executor = globalExecutor
 
     //暂时保存跳转前进度
     fun saveCurrentBookProcess() {
@@ -158,10 +160,10 @@ object ReadBook : CoroutineScope by MainScope() {
     }
 
     fun upReadTime() {
-        if (!AppConfig.enableReadRecord) {
-            return
-        }
-        Coroutine.async(executeContext = IO) {
+        executor.execute {
+            if (!AppConfig.enableReadRecord) {
+                return@execute
+            }
             readRecord.readTime = readRecord.readTime + System.currentTimeMillis() - readStartTime
             readStartTime = System.currentTimeMillis()
             readRecord.lastRead = System.currentTimeMillis()
@@ -204,7 +206,7 @@ object ReadBook : CoroutineScope by MainScope() {
         return hasPrevPage
     }
 
-    fun moveToNextChapter(upContent: Boolean): Boolean {
+    fun moveToNextChapter(upContent: Boolean, upContentInPlace: Boolean = true): Boolean {
         if (durChapterIndex < chapterSize - 1) {
             durChapterPos = 0
             durChapterIndex++
@@ -213,11 +215,11 @@ object ReadBook : CoroutineScope by MainScope() {
             nextTextChapter = null
             if (curTextChapter == null) {
                 AppLog.putDebug("moveToNextChapter-章节未加载,开始加载")
-                callBack?.upContent()
+                if (upContentInPlace) callBack?.upContent(resetPageOffset = false)
                 loadContent(durChapterIndex, upContent, resetPageOffset = false)
-            } else if (upContent) {
+            } else if (upContent && upContentInPlace) {
                 AppLog.putDebug("moveToNextChapter-章节已加载,刷新视图")
-                callBack?.upContent()
+                callBack?.upContent(resetPageOffset = false)
             }
             loadContent(durChapterIndex.plus(1), upContent, false)
             saveRead()
@@ -233,7 +235,8 @@ object ReadBook : CoroutineScope by MainScope() {
 
     fun moveToPrevChapter(
         upContent: Boolean,
-        toLast: Boolean = true
+        toLast: Boolean = true,
+        upContentInPlace: Boolean = true
     ): Boolean {
         if (durChapterIndex > 0) {
             durChapterPos = if (toLast) prevTextChapter?.lastReadLength ?: Int.MAX_VALUE else 0
@@ -242,10 +245,10 @@ object ReadBook : CoroutineScope by MainScope() {
             curTextChapter = prevTextChapter
             prevTextChapter = null
             if (curTextChapter == null) {
-                callBack?.upContent()
+                if (upContentInPlace) callBack?.upContent(resetPageOffset = false)
                 loadContent(durChapterIndex, upContent, resetPageOffset = false)
-            } else if (upContent) {
-                callBack?.upContent()
+            } else if (upContent && upContentInPlace) {
+                callBack?.upContent(resetPageOffset = false)
             }
             loadContent(durChapterIndex.minus(1), upContent, false)
             saveRead()
@@ -267,6 +270,16 @@ object ReadBook : CoroutineScope by MainScope() {
     }
 
     fun setPageIndex(index: Int) {
+        val textChapter = curTextChapter
+        if (textChapter != null) {
+            val pageIndex = durPageIndex
+            if (index > pageIndex) {
+                textChapter.getPage(index - 2)?.recycleRecorders()
+            }
+            if (index < pageIndex) {
+                textChapter.getPage(index + 3)?.recycleRecorders()
+            }
+        }
         durChapterPos = curTextChapter?.getReadLength(index) ?: index
         saveRead(true)
         curPageChanged(true)
@@ -525,8 +538,8 @@ object ReadBook : CoroutineScope by MainScope() {
     }
 
     fun saveRead(pageChanged: Boolean = false) {
-        Coroutine.async(executeContext = IO) {
-            val book = book ?: return@async
+        executor.execute {
+            val book = book ?: return@execute
             book.lastCheckCount = 0
             book.durChapterTime = System.currentTimeMillis()
             val chapterChanged = book.durChapterIndex != durChapterIndex
@@ -549,26 +562,29 @@ object ReadBook : CoroutineScope by MainScope() {
      */
     private fun preDownload() {
         if (book?.isLocal == true) return
-        if (AppConfig.preDownloadNum < 2) {
-            return
-        }
-        preDownloadTask?.cancel()
-        preDownloadTask = Coroutine.async(executeContext = IO) {
-            //预下载
-            launch {
-                val maxChapterIndex = min(durChapterIndex + AppConfig.preDownloadNum, chapterSize)
-                for (i in durChapterIndex.plus(2)..maxChapterIndex) {
-                    if (downloadedChapters.contains(i)) continue
-                    if ((downloadFailChapters[i] ?: 0) >= 3) continue
-                    downloadIndex(i)
-                }
+        executor.execute {
+            if (AppConfig.preDownloadNum < 2) {
+                return@execute
             }
-            launch {
-                val minChapterIndex = durChapterIndex - min(5, AppConfig.preDownloadNum)
-                for (i in durChapterIndex.minus(2) downTo minChapterIndex) {
-                    if (downloadedChapters.contains(i)) continue
-                    if ((downloadFailChapters[i] ?: 0) >= 3) continue
-                    downloadIndex(i)
+            preDownloadTask?.cancel()
+            preDownloadTask = Coroutine.async(executeContext = IO) {
+                //预下载
+                launch {
+                    val maxChapterIndex =
+                        min(durChapterIndex + AppConfig.preDownloadNum, chapterSize)
+                    for (i in durChapterIndex.plus(2)..maxChapterIndex) {
+                        if (downloadedChapters.contains(i)) continue
+                        if ((downloadFailChapters[i] ?: 0) >= 3) continue
+                        downloadIndex(i)
+                    }
+                }
+                launch {
+                    val minChapterIndex = durChapterIndex - min(5, AppConfig.preDownloadNum)
+                    for (i in durChapterIndex.minus(2) downTo minChapterIndex) {
+                        if (downloadedChapters.contains(i)) continue
+                        if ((downloadFailChapters[i] ?: 0) >= 3) continue
+                        downloadIndex(i)
+                    }
                 }
             }
         }
@@ -595,6 +611,7 @@ object ReadBook : CoroutineScope by MainScope() {
         coroutineContext.cancelChildren()
         downloadedChapters.clear()
         downloadFailChapters.clear()
+        ImageProvider.clear()
     }
 
     interface CallBack {

+ 3 - 3
app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeRule.kt

@@ -197,7 +197,7 @@ class AnalyzeRule(
                         }
                         if (sourceRule.replaceRegex.isNotEmpty() && result is List<*>) {
                             val newList = ArrayList<String>()
-                            for (item in result as List<*>) {
+                            for (item in result) {
                                 newList.add(replaceRegex(item.toString(), sourceRule))
                             }
                             result = newList
@@ -210,12 +210,12 @@ class AnalyzeRule(
         }
         if (result == null) return null
         if (result is String) {
-            result = (result as String).split("\n")
+            result = result.split("\n")
         }
         if (isUrl) {
             val urlList = ArrayList<String>()
             if (result is List<*>) {
-                for (url in result as List<*>) {
+                for (url in result) {
                     val absoluteURL = NetworkUtils.getAbsoluteURL(redirectUrl, url.toString())
                     if (absoluteURL.isNotEmpty() && !urlList.contains(absoluteURL)) {
                         urlList.add(absoluteURL)

+ 5 - 4
app/src/main/java/io/legado/app/service/HttpReadAloudService.kt

@@ -91,7 +91,7 @@ class HttpReadAloudService : BaseReadAloudService(),
         playIndexJob?.cancel()
     }
 
-    private fun playNext() {
+    private fun updateNextPos() {
         readAloudNumber += contentList[nowSpeak].length + 1 - paragraphStartPos
         paragraphStartPos = 0
         if (nowSpeak < contentList.lastIndex) {
@@ -355,14 +355,14 @@ class HttpReadAloudService : BaseReadAloudService(),
             Player.STATE_ENDED -> {
                 // 结束
                 playErrorNo = 0
-                playNext()
+                updateNextPos()
             }
         }
     }
 
     override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
         if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED) return
-        playNext()
+        updateNextPos()
         upPlayPos()
     }
 
@@ -375,7 +375,8 @@ class HttpReadAloudService : BaseReadAloudService(),
             AppLog.put("朗读连续5次错误, 最后一次错误代码(${error.localizedMessage})", error)
             ReadAloud.pause(this)
         } else {
-            playNext()
+            updateNextPos()
+            exoPlayer.seekToNextMediaItem()
         }
     }
 

+ 21 - 0
app/src/main/java/io/legado/app/ui/book/read/BaseReadBookActivity.kt

@@ -58,6 +58,12 @@ abstract class BaseReadBookActivity :
     override val binding by viewBinding(ActivityBookReadBinding::inflate)
     override val viewModel by viewModels<ReadBookViewModel>()
     var bottomDialog = 0
+        set(value) {
+            if (field != value) {
+                field = value
+                onBottomDialogChange()
+            }
+        }
     private val selectBookFolderResult = registerForActivityResult(HandleFileContract()) {
         it.uri?.let { uri ->
             ReadBook.book?.let { book ->
@@ -94,6 +100,21 @@ abstract class BaseReadBookActivity :
         }
     }
 
+    private fun onBottomDialogChange() {
+        when (bottomDialog) {
+            0 -> onMenuHide()
+            1 -> onMenuShow()
+        }
+    }
+
+    open fun onMenuShow() {
+
+    }
+
+    open fun onMenuHide() {
+
+    }
+
     fun showPaddingConfig() {
         showDialogFragment<PaddingConfigDialog>()
     }

+ 53 - 99
app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt

@@ -6,6 +6,7 @@ import android.content.Intent
 import android.content.res.Configuration
 import android.net.Uri
 import android.os.Bundle
+import android.os.Looper
 import android.view.Gravity
 import android.view.InputDevice
 import android.view.KeyEvent
@@ -76,7 +77,7 @@ import io.legado.app.ui.book.read.config.TipConfigDialog.Companion.TIP_DIVIDER_C
 import io.legado.app.ui.book.read.page.ContentTextView
 import io.legado.app.ui.book.read.page.ReadView
 import io.legado.app.ui.book.read.page.entities.PageDirection
-import io.legado.app.ui.book.read.page.provider.TextPageFactory
+import io.legado.app.ui.book.read.page.provider.ChapterProvider
 import io.legado.app.ui.book.searchContent.SearchContentActivity
 import io.legado.app.ui.book.searchContent.SearchResult
 import io.legado.app.ui.book.source.edit.BookSourceEditActivity
@@ -95,8 +96,8 @@ import io.legado.app.utils.ACache
 import io.legado.app.utils.Debounce
 import io.legado.app.utils.LogUtils
 import io.legado.app.utils.StartActivityContract
-import io.legado.app.utils.SyncedRenderer
 import io.legado.app.utils.applyOpenTint
+import io.legado.app.utils.buildMainHandler
 import io.legado.app.utils.getPrefBoolean
 import io.legado.app.utils.getPrefString
 import io.legado.app.utils.hexString
@@ -191,7 +192,6 @@ class ReadBookActivity : BaseReadBookActivity(),
     }
     private var menu: Menu? = null
     private var backupJob: Job? = null
-    private var keepScreenJon: Job? = null
     private var tts: TTS? = null
     val textActionMenu: TextActionMenu by lazy {
         TextActionMenu(this, this)
@@ -201,8 +201,7 @@ class ReadBookActivity : BaseReadBookActivity(),
     }
     override val isInitFinish: Boolean get() = viewModel.isInitFinish
     override val isScroll: Boolean get() = binding.readView.isScroll
-    override var autoPageProgress = 0
-    override var isAutoPage = false
+    private val isAutoPage get() = binding.readView.isAutoPage
     override var isShowingSearchResult = false
     override var isSelectingSearchResult = false
         set(value) {
@@ -211,16 +210,17 @@ class ReadBookActivity : BaseReadBookActivity(),
     private val timeBatteryReceiver = TimeBatteryReceiver()
     private var screenTimeOut: Long = 0
     private var loadStates: Boolean = false
-    override val pageFactory: TextPageFactory get() = binding.readView.pageFactory
+    override val pageFactory get() = binding.readView.pageFactory
+    override val pageDelegate get() = binding.readView.pageDelegate
     override val headerHeight: Int get() = binding.readView.curPage.headerHeight
     private val menuLayoutIsVisible get() = bottomDialog > 0 || binding.readMenu.isVisible
     private val nextPageDebounce by lazy { Debounce { keyPage(PageDirection.NEXT) } }
     private val prevPageDebounce by lazy { Debounce { keyPage(PageDirection.PREV) } }
     private var bookChanged = false
     private var pageChanged = false
-    private var reloadContent = false
-    private val autoPageRenderer by lazy { SyncedRenderer { doAutoPage(it) } }
-    private var autoPageScrollOffset = 0.0
+    private val handler by lazy { buildMainHandler() }
+    private val screenOffRunnable by lazy { Runnable { keepScreenOn(false) } }
+    private val executor = ReadBook.executor
 
     //恢复跳转前进度对话框的交互结果
     private var confirmRestoreProcess: Boolean? = null
@@ -264,23 +264,9 @@ class ReadBookActivity : BaseReadBookActivity(),
 
     override fun onPostCreate(savedInstanceState: Bundle?) {
         super.onPostCreate(savedInstanceState)
-        viewModel.initData(intent) {
-            initDataSuccess()
-        }
-    }
-
-    override fun onNewIntent(intent: Intent?) {
-        super.onNewIntent(intent)
-        viewModel.initData(intent ?: return) {
-            initDataSuccess()
-        }
-    }
-
-    private fun initDataSuccess() {
-        upMenu()
-        if (reloadContent) {
-            reloadContent = false
-            ReadBook.loadContent(resetPageOffset = false)
+        Looper.myQueue().addIdleHandler {
+            viewModel.initData(intent) { upMenu() }
+            false
         }
     }
 
@@ -401,6 +387,11 @@ class ReadBookActivity : BaseReadBookActivity(),
         }
     }
 
+    override fun onNightModeChanged(mode: Int) {
+        super.onNightModeChanged(mode)
+        binding.readView.invalidateTextPage()
+    }
+
     /**
      * 菜单
      */
@@ -904,7 +895,7 @@ class ReadBookActivity : BaseReadBookActivity(),
     }
 
     override fun upMenuView() {
-        lifecycleScope.launch {
+        handler.post {
             binding.readMenu.upBookView()
         }
     }
@@ -937,9 +928,6 @@ class ReadBookActivity : BaseReadBookActivity(),
         success: (() -> Unit)?
     ) {
         lifecycleScope.launch {
-            if (relativePosition == 0) {
-                autoPageProgress = 0
-            }
             binding.readView.upContent(relativePosition, resetPageOffset)
             upSeekBarProgress()
             loadStates = false
@@ -962,9 +950,11 @@ class ReadBookActivity : BaseReadBookActivity(),
      */
     override fun pageChanged() {
         pageChanged = true
-        lifecycleScope.launch {
-            autoPageProgress = 0
+        binding.readView.onPageChange()
+        handler.post {
             upSeekBarProgress()
+        }
+        executor.execute {
             startBackupJob()
         }
     }
@@ -1045,8 +1035,7 @@ class ReadBookActivity : BaseReadBookActivity(),
         if (isAutoPage) {
             autoPageStop()
         } else {
-            isAutoPage = true
-            autoPagePlus()
+            binding.readView.autoPager.start()
             binding.readMenu.setAutoPage(true)
             screenTimeOut = -1L
             screenOffTimerStart()
@@ -1055,53 +1044,12 @@ class ReadBookActivity : BaseReadBookActivity(),
 
     override fun autoPageStop() {
         if (isAutoPage) {
-            isAutoPage = false
-            autoPageRenderer.stop()
-            binding.readView.invalidate()
-            binding.readView.clearNextPageBitmap()
+            binding.readView.autoPager.stop()
             binding.readMenu.setAutoPage(false)
             upScreenTimeOut()
         }
     }
 
-    private fun autoPagePlus() {
-        autoPageProgress = 0
-        autoPageScrollOffset = 0.0
-        autoPageRenderer.start()
-    }
-
-    private fun doAutoPage(frameTime: Double) {
-        if (menuLayoutIsVisible) {
-            return
-        }
-        if (binding.readView.run { isScroll && pageDelegate?.isRunning == true }) {
-            return
-        }
-        val readTime = ReadBookConfig.autoReadSpeed * 1000.0
-        val height = binding.readView.height
-        autoPageScrollOffset += height / readTime * frameTime
-        if (autoPageScrollOffset < 1) {
-            return
-        }
-        val scrollOffset = autoPageScrollOffset.toInt()
-        autoPageScrollOffset -= scrollOffset
-        if (binding.readView.isScroll) {
-            binding.readView.curPage.scroll(-scrollOffset)
-        } else {
-            autoPageProgress += scrollOffset
-            if (autoPageProgress >= height) {
-                autoPageProgress = 0
-                if (!binding.readView.fillPage(PageDirection.NEXT)) {
-                    autoPageStop()
-                } else {
-                    binding.readView.clearNextPageBitmap()
-                }
-            } else {
-                binding.readView.invalidate()
-            }
-        }
-    }
-
     override fun openSourceEditActivity() {
         ReadBook.bookSource?.let {
             sourceEditActivity.launch {
@@ -1350,24 +1298,24 @@ class ReadBookActivity : BaseReadBookActivity(),
         when (dialogId) {
             TEXT_COLOR -> {
                 setCurTextColor(color)
-                postEvent(EventBus.UP_CONFIG, false)
+                postEvent(EventBus.UP_CONFIG, arrayOf(2, 6))
             }
 
             BG_COLOR -> {
                 setCurBg(0, "#${color.hexString}")
-                postEvent(EventBus.UP_CONFIG, false)
+                postEvent(EventBus.UP_CONFIG, arrayOf(1))
             }
 
             TIP_COLOR -> {
                 ReadTipConfig.tipColor = color
                 postEvent(EventBus.TIP_COLOR, "")
-                postEvent(EventBus.UP_CONFIG, false)
+                postEvent(EventBus.UP_CONFIG, arrayOf(2))
             }
 
             TIP_DIVIDER_COLOR -> {
                 ReadTipConfig.tipDividerColor = color
                 postEvent(EventBus.TIP_COLOR, "")
-                postEvent(EventBus.UP_CONFIG, false)
+                postEvent(EventBus.UP_CONFIG, arrayOf(2))
             }
         }
     }
@@ -1406,6 +1354,14 @@ class ReadBookActivity : BaseReadBookActivity(),
         skipToSearch(searchResult)
     }
 
+    override fun onMenuShow() {
+        binding.readView.autoPager.pause()
+    }
+
+    override fun onMenuHide() {
+        binding.readView.autoPager.resume()
+    }
+
     /* 全文搜索跳转 */
     private fun skipToSearch(searchResult: SearchResult) {
         val previousResult = binding.searchMenu.previousSearchResult
@@ -1523,22 +1479,21 @@ class ReadBookActivity : BaseReadBookActivity(),
                 ReadBook.readAloud(!BaseReadAloudService.pause)
             }
         }
-        observeEvent<Boolean>(EventBus.UP_CONFIG) {
-            upSystemUiVisibility()
-            readView.upPageSlopSquare()
-            readView.upBg()
-            readView.upStyle()
-            readView.upBgAlpha()
-            if (it) { // 更新内容排版布局
-                if (isInitFinish) {
-                    ReadBook.loadContent(resetPageOffset = false)
-                } else {
-                    reloadContent = true
+        observeEvent<Array<Int>>(EventBus.UP_CONFIG) {
+            it.forEach { value ->
+                when (value) {
+                    0 -> upSystemUiVisibility()
+                    1 -> readView.upBg()
+                    2 -> readView.upStyle()
+                    3 -> readView.upBgAlpha()
+                    4 -> readView.upPageSlopSquare()
+                    5 -> if (isInitFinish) ReadBook.loadContent(resetPageOffset = false)
+                    6 -> readView.upContent(resetPageOffset = false)
+                    8 -> ChapterProvider.upStyle()
+                    9 -> binding.readView.invalidateTextPage()
+                    10 -> ChapterProvider.upLayout()
                 }
-            } else {
-                readView.upContent(resetPageOffset = false)
             }
-            binding.readMenu.reset()
         }
         observeEvent<Int>(EventBus.ALOUD_STATE) {
             if (it == Status.STOP || it == Status.PAUSE) {
@@ -1594,17 +1549,16 @@ class ReadBookActivity : BaseReadBookActivity(),
      * 重置黑屏时间
      */
     override fun screenOffTimerStart() {
-        keepScreenJon?.cancel()
-        keepScreenJon = lifecycleScope.launch {
+        handler.post {
             if (screenTimeOut < 0) {
                 keepScreenOn(true)
-                return@launch
+                return@post
             }
             val t = screenTimeOut - sysScreenOffTime
             if (t > 0) {
                 keepScreenOn(true)
-                delay(screenTimeOut)
-                keepScreenOn(false)
+                handler.removeCallbacks(screenOffRunnable)
+                handler.postDelayed(screenOffRunnable, screenTimeOut)
             } else {
                 keepScreenOn(false)
             }

+ 32 - 3
app/src/main/java/io/legado/app/ui/book/read/ReadMenu.kt

@@ -27,13 +27,38 @@ import io.legado.app.help.config.LocalConfig
 import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.help.config.ThemeConfig
 import io.legado.app.lib.dialogs.alert
-import io.legado.app.lib.theme.*
+import io.legado.app.lib.theme.Selector
+import io.legado.app.lib.theme.accentColor
+import io.legado.app.lib.theme.bottomBackground
+import io.legado.app.lib.theme.buttonDisabledColor
+import io.legado.app.lib.theme.getPrimaryTextColor
+import io.legado.app.lib.theme.primaryColor
+import io.legado.app.lib.theme.primaryTextColor
 import io.legado.app.model.ReadBook
 import io.legado.app.ui.book.info.BookInfoActivity
 import io.legado.app.ui.browser.WebViewActivity
 import io.legado.app.ui.widget.seekbar.SeekBarChangeListener
-import io.legado.app.utils.*
-import splitties.views.*
+import io.legado.app.utils.ColorUtils
+import io.legado.app.utils.ConstraintModify
+import io.legado.app.utils.activity
+import io.legado.app.utils.dpToPx
+import io.legado.app.utils.getPrefBoolean
+import io.legado.app.utils.gone
+import io.legado.app.utils.invisible
+import io.legado.app.utils.loadAnimation
+import io.legado.app.utils.modifyBegin
+import io.legado.app.utils.navigationBarGravity
+import io.legado.app.utils.navigationBarHeight
+import io.legado.app.utils.openUrl
+import io.legado.app.utils.putPrefBoolean
+import io.legado.app.utils.startActivity
+import io.legado.app.utils.visible
+import splitties.views.bottomPadding
+import splitties.views.leftPadding
+import splitties.views.onClick
+import splitties.views.onLongClick
+import splitties.views.padding
+import splitties.views.rightPadding
 
 /**
  * 阅读界面菜单
@@ -273,6 +298,7 @@ class ReadMenu @JvmOverloads constructor(
     }
 
     fun runMenuIn(anim: Boolean = !AppConfig.isEInkMode) {
+        callBack.onMenuShow()
         this.visible()
         binding.titleBar.visible()
         binding.bottomMenu.visible()
@@ -286,6 +312,7 @@ class ReadMenu @JvmOverloads constructor(
     }
 
     fun runMenuOut(anim: Boolean = !AppConfig.isEInkMode, onMenuOutEnd: (() -> Unit)? = null) {
+        callBack.onMenuHide()
         this.onMenuOutEnd = onMenuOutEnd
         if (this.isVisible) {
             if (anim) {
@@ -558,6 +585,8 @@ class ReadMenu @JvmOverloads constructor(
         fun payAction()
         fun disableSource()
         fun skipToChapter(index: Int)
+        fun onMenuShow()
+        fun onMenuHide()
     }
 
 }

+ 9 - 1
app/src/main/java/io/legado/app/ui/book/read/SearchMenu.kt

@@ -18,7 +18,13 @@ import io.legado.app.lib.theme.bottomBackground
 import io.legado.app.lib.theme.getPrimaryTextColor
 import io.legado.app.model.ReadBook
 import io.legado.app.ui.book.searchContent.SearchResult
-import io.legado.app.utils.*
+import io.legado.app.utils.ColorUtils
+import io.legado.app.utils.activity
+import io.legado.app.utils.invisible
+import io.legado.app.utils.loadAnimation
+import io.legado.app.utils.navigationBarGravity
+import io.legado.app.utils.navigationBarHeight
+import io.legado.app.utils.visible
 import splitties.views.bottomPadding
 import splitties.views.leftPadding
 import splitties.views.padding
@@ -235,6 +241,8 @@ class SearchMenu @JvmOverloads constructor(
         fun exitSearchMenu()
         fun showMenuBar()
         fun navigateToSearch(searchResult: SearchResult, index: Int)
+        fun onMenuShow()
+        fun onMenuHide()
     }
 
 }

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/read/config/BgAdapter.kt

@@ -41,7 +41,7 @@ class BgAdapter(context: Context, val textColor: Int) :
             this.setOnClickListener {
                 getItemByLayoutPosition(holder.layoutPosition)?.let {
                     ReadBookConfig.durConfig.setCurBg(1, it)
-                    postEvent(EventBus.UP_CONFIG, false)
+                    postEvent(EventBus.UP_CONFIG, arrayOf(1))
                 }
             }
         }

+ 30 - 9
app/src/main/java/io/legado/app/ui/book/read/config/BgTextConfigDialog.kt

@@ -33,11 +33,32 @@ import io.legado.app.lib.theme.getSecondaryTextColor
 import io.legado.app.ui.book.read.ReadBookActivity
 import io.legado.app.ui.file.HandleFileContract
 import io.legado.app.ui.widget.seekbar.SeekBarChangeListener
-import io.legado.app.utils.*
+import io.legado.app.utils.ColorUtils
+import io.legado.app.utils.FileUtils
+import io.legado.app.utils.GSON
+import io.legado.app.utils.MD5Utils
+import io.legado.app.utils.SelectImageContract
 import io.legado.app.utils.compress.ZipUtils
+import io.legado.app.utils.createFileReplace
+import io.legado.app.utils.createFolderReplace
+import io.legado.app.utils.externalCache
+import io.legado.app.utils.externalFiles
+import io.legado.app.utils.getFile
+import io.legado.app.utils.inputStream
+import io.legado.app.utils.isContentScheme
+import io.legado.app.utils.launch
+import io.legado.app.utils.longToast
+import io.legado.app.utils.openOutputStream
+import io.legado.app.utils.outputStream
+import io.legado.app.utils.parseToUri
+import io.legado.app.utils.postEvent
+import io.legado.app.utils.printOnDebug
+import io.legado.app.utils.readBytes
+import io.legado.app.utils.readUri
+import io.legado.app.utils.stackTraceStr
+import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.viewbindingdelegate.viewBinding
 import splitties.init.appCtx
-
 import java.io.File
 import java.io.FileOutputStream
 
@@ -168,7 +189,7 @@ class BgTextConfigDialog : BaseDialogFragment(R.layout.dialog_read_bg_text) {
                 if (i >= 0) {
                     ReadBookConfig.durConfig = defaultConfigs[i].copy()
                     initData()
-                    postEvent(EventBus.UP_CONFIG, true)
+                    postEvent(EventBus.UP_CONFIG, arrayOf(1, 2, 5))
                 }
             }
         }
@@ -178,7 +199,7 @@ class BgTextConfigDialog : BaseDialogFragment(R.layout.dialog_read_bg_text) {
         }
         binding.swUnderline.setOnCheckedChangeListener { _, isChecked ->
             underline = isChecked
-            postEvent(EventBus.UP_CONFIG, true)
+            postEvent(EventBus.UP_CONFIG, arrayOf(9))
         }
         binding.tvTextColor.setOnClickListener {
             ColorPickerDialog.newBuilder()
@@ -214,7 +235,7 @@ class BgTextConfigDialog : BaseDialogFragment(R.layout.dialog_read_bg_text) {
         }
         binding.ivDelete.setOnClickListener {
             if (ReadBookConfig.deleteDur()) {
-                postEvent(EventBus.UP_CONFIG, true)
+                postEvent(EventBus.UP_CONFIG, arrayOf(1, 2, 5))
                 dismissAllowingStateLoss()
             } else {
                 toastOnUi("数量已是最少,不能删除.")
@@ -223,11 +244,11 @@ class BgTextConfigDialog : BaseDialogFragment(R.layout.dialog_read_bg_text) {
         binding.sbBgAlpha.setOnSeekBarChangeListener(object : SeekBarChangeListener {
             override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
                 ReadBookConfig.bgAlpha = progress
-                postEvent(EventBus.UP_CONFIG, false)
+                postEvent(EventBus.UP_CONFIG, arrayOf(3))
             }
 
             override fun onStopTrackingTouch(seekBar: SeekBar) {
-                postEvent(EventBus.UP_CONFIG, false)
+                postEvent(EventBus.UP_CONFIG, arrayOf(3))
             }
         })
     }
@@ -357,7 +378,7 @@ class BgTextConfigDialog : BaseDialogFragment(R.layout.dialog_read_bg_text) {
             ReadBookConfig.import(byteArray).getOrThrow()
         }.onSuccess {
             ReadBookConfig.durConfig = it
-            postEvent(EventBus.UP_CONFIG, true)
+            postEvent(EventBus.UP_CONFIG, arrayOf(1, 2, 5))
             toastOnUi("导入成功")
         }.onError {
             it.printOnDebug()
@@ -378,7 +399,7 @@ class BgTextConfigDialog : BaseDialogFragment(R.layout.dialog_read_bg_text) {
                     inputStream.copyTo(outputStream)
                 }
                 ReadBookConfig.durConfig.setCurBg(2, fileName)
-                postEvent(EventBus.UP_CONFIG, false)
+                postEvent(EventBus.UP_CONFIG, arrayOf(1))
             }.onFailure {
                 appCtx.toastOnUi(it.localizedMessage)
             }

+ 14 - 4
app/src/main/java/io/legado/app/ui/book/read/config/MoreConfigDialog.kt

@@ -107,39 +107,48 @@ class MoreConfigDialog : DialogFragment() {
                 PreferKey.readBodyToLh -> activity?.recreate()
                 PreferKey.hideStatusBar -> {
                     ReadBookConfig.hideStatusBar = getPrefBoolean(PreferKey.hideStatusBar)
-                    postEvent(EventBus.UP_CONFIG, true)
+                    postEvent(EventBus.UP_CONFIG, arrayOf(0))
                 }
+
                 PreferKey.hideNavigationBar -> {
                     ReadBookConfig.hideNavigationBar = getPrefBoolean(PreferKey.hideNavigationBar)
-                    postEvent(EventBus.UP_CONFIG, true)
+                    postEvent(EventBus.UP_CONFIG, arrayOf(0))
                 }
+
                 PreferKey.keepLight -> postEvent(key, true)
                 PreferKey.textSelectAble -> postEvent(key, getPrefBoolean(key))
                 PreferKey.screenOrientation -> {
                     (activity as? ReadBookActivity)?.setOrientation()
                 }
+
                 PreferKey.textFullJustify,
                 PreferKey.textBottomJustify,
                 PreferKey.useZhLayout -> {
-                    postEvent(EventBus.UP_CONFIG, true)
+                    postEvent(EventBus.UP_CONFIG, arrayOf(5))
                 }
+
                 PreferKey.showBrightnessView -> {
                     postEvent(PreferKey.showBrightnessView, "")
                 }
+
                 PreferKey.expandTextMenu -> {
                     (activity as? ReadBookActivity)?.textActionMenu?.upMenu()
                 }
+
                 PreferKey.doublePageHorizontal -> {
                     ChapterProvider.upLayout()
                     ReadBook.loadContent(false)
                 }
+
                 PreferKey.showReadTitleAddition,
                 PreferKey.readBarStyleFollowPage -> {
                     postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
                 }
+
                 PreferKey.progressBarBehavior -> {
                     postEvent(EventBus.UP_SEEK_BAR, true)
                 }
+
                 PreferKey.noAnimScrollPage -> {
                     ReadBook.callBack?.upPageAnim()
                 }
@@ -152,6 +161,7 @@ class MoreConfigDialog : DialogFragment() {
                 "clickRegionalConfig" -> {
                     (activity as? ReadBookActivity)?.showClickRegionalConfig()
                 }
+
                 PreferKey.pageTouchSlop -> {
                     NumberPickerDialog(requireContext())
                         .setTitle(getString(R.string.page_touch_slop_dialog_title))
@@ -160,7 +170,7 @@ class MoreConfigDialog : DialogFragment() {
                         .setValue(AppConfig.pageTouchSlop)
                         .show {
                             AppConfig.pageTouchSlop = it
-                            postEvent(EventBus.UP_CONFIG, false)
+                            postEvent(EventBus.UP_CONFIG, arrayOf(4))
                         }
                 }
             }

+ 14 - 14
app/src/main/java/io/legado/app/ui/book/read/config/PaddingConfigDialog.kt

@@ -63,61 +63,61 @@ class PaddingConfigDialog : BaseDialogFragment(R.layout.dialog_read_padding) {
         //正文
         dsbPaddingTop.onChanged = {
             ReadBookConfig.paddingTop = it
-            postEvent(EventBus.UP_CONFIG, true)
+            postEvent(EventBus.UP_CONFIG, arrayOf(10, 5))
         }
         dsbPaddingBottom.onChanged = {
             ReadBookConfig.paddingBottom = it
-            postEvent(EventBus.UP_CONFIG, true)
+            postEvent(EventBus.UP_CONFIG, arrayOf(10, 5))
         }
         dsbPaddingLeft.onChanged = {
             ReadBookConfig.paddingLeft = it
-            postEvent(EventBus.UP_CONFIG, true)
+            postEvent(EventBus.UP_CONFIG, arrayOf(10, 5))
         }
         dsbPaddingRight.onChanged = {
             ReadBookConfig.paddingRight = it
-            postEvent(EventBus.UP_CONFIG, true)
+            postEvent(EventBus.UP_CONFIG, arrayOf(10, 5))
         }
         //页眉
         dsbHeaderPaddingTop.onChanged = {
             ReadBookConfig.headerPaddingTop = it
-            postEvent(EventBus.UP_CONFIG, true)
+            postEvent(EventBus.UP_CONFIG, arrayOf(2))
         }
         dsbHeaderPaddingBottom.onChanged = {
             ReadBookConfig.headerPaddingBottom = it
-            postEvent(EventBus.UP_CONFIG, true)
+            postEvent(EventBus.UP_CONFIG, arrayOf(2))
         }
         dsbHeaderPaddingLeft.onChanged = {
             ReadBookConfig.headerPaddingLeft = it
-            postEvent(EventBus.UP_CONFIG, true)
+            postEvent(EventBus.UP_CONFIG, arrayOf(2))
         }
         dsbHeaderPaddingRight.onChanged = {
             ReadBookConfig.headerPaddingRight = it
-            postEvent(EventBus.UP_CONFIG, true)
+            postEvent(EventBus.UP_CONFIG, arrayOf(2))
         }
         //页脚
         dsbFooterPaddingTop.onChanged = {
             ReadBookConfig.footerPaddingTop = it
-            postEvent(EventBus.UP_CONFIG, true)
+            postEvent(EventBus.UP_CONFIG, arrayOf(2))
         }
         dsbFooterPaddingBottom.onChanged = {
             ReadBookConfig.footerPaddingBottom = it
-            postEvent(EventBus.UP_CONFIG, true)
+            postEvent(EventBus.UP_CONFIG, arrayOf(2))
         }
         dsbFooterPaddingLeft.onChanged = {
             ReadBookConfig.footerPaddingLeft = it
-            postEvent(EventBus.UP_CONFIG, true)
+            postEvent(EventBus.UP_CONFIG, arrayOf(2))
         }
         dsbFooterPaddingRight.onChanged = {
             ReadBookConfig.footerPaddingRight = it
-            postEvent(EventBus.UP_CONFIG, true)
+            postEvent(EventBus.UP_CONFIG, arrayOf(2))
         }
         cbShowTopLine.onCheckedChangeListener = { _, isChecked ->
             ReadBookConfig.showHeaderLine = isChecked
-            postEvent(EventBus.UP_CONFIG, true)
+            postEvent(EventBus.UP_CONFIG, arrayOf(2))
         }
         cbShowBottomLine.onCheckedChangeListener = { _, isChecked ->
             ReadBookConfig.showFooterLine = isChecked
-            postEvent(EventBus.UP_CONFIG, true)
+            postEvent(EventBus.UP_CONFIG, arrayOf(2))
         }
     }
 

+ 13 - 13
app/src/main/java/io/legado/app/ui/book/read/config/ReadStyleDialog.kt

@@ -108,10 +108,10 @@ class ReadStyleDialog : BaseDialogFragment(R.layout.dialog_read_book_style),
     private fun initViewEvent() = binding.run {
         chineseConverter.onChanged {
             ChineseUtils.unLoad(*TransType.entries.toTypedArray())
-            postEvent(EventBus.UP_CONFIG, true)
+            postEvent(EventBus.UP_CONFIG, arrayOf(5))
         }
         textFontWeightConverter.onChanged {
-            postEvent(EventBus.UP_CONFIG, true)
+            postEvent(EventBus.UP_CONFIG, arrayOf(8))
         }
         tvTextFont.setOnClickListener {
             showDialogFragment<FontSelectDialog>()
@@ -122,7 +122,7 @@ class ReadStyleDialog : BaseDialogFragment(R.layout.dialog_read_book_style),
                 items = resources.getStringArray(R.array.indent).toList()
             ) { _, index ->
                 ReadBookConfig.paragraphIndent = " ".repeat(index)
-                postEvent(EventBus.UP_CONFIG, true)
+                postEvent(EventBus.UP_CONFIG, arrayOf(5))
             }
         }
         tvPadding.setOnClickListener {
@@ -141,40 +141,40 @@ class ReadStyleDialog : BaseDialogFragment(R.layout.dialog_read_book_style),
         cbShareLayout.onCheckedChangeListener = { _, isChecked ->
             ReadBookConfig.shareLayout = isChecked
             upView()
-            postEvent(EventBus.UP_CONFIG, true)
+            postEvent(EventBus.UP_CONFIG, arrayOf(1, 2, 5))
         }
         dsbTextSize.onChanged = {
             ReadBookConfig.textSize = it + 5
-            postEvent(EventBus.UP_CONFIG, true)
+            postEvent(EventBus.UP_CONFIG, arrayOf(8, 5))
         }
         dsbTextLetterSpacing.onChanged = {
             ReadBookConfig.letterSpacing = (it - 50) / 100f
-            postEvent(EventBus.UP_CONFIG, true)
+            postEvent(EventBus.UP_CONFIG, arrayOf(8, 5))
         }
         dsbLineSize.onChanged = {
             ReadBookConfig.lineSpacingExtra = it
-            postEvent(EventBus.UP_CONFIG, true)
+            postEvent(EventBus.UP_CONFIG, arrayOf(8, 5))
         }
         dsbParagraphSpacing.onChanged = {
             ReadBookConfig.paragraphSpacing = it
-            postEvent(EventBus.UP_CONFIG, true)
+            postEvent(EventBus.UP_CONFIG, arrayOf(8, 5))
         }
     }
 
-    private fun changeBg(index: Int) {
+    private fun changeBgTextConfig(index: Int) {
         val oldIndex = ReadBookConfig.styleSelect
         if (index != oldIndex) {
             ReadBookConfig.styleSelect = index
             upView()
             styleAdapter.notifyItemChanged(oldIndex)
             styleAdapter.notifyItemChanged(index)
-            postEvent(EventBus.UP_CONFIG, true)
+            postEvent(EventBus.UP_CONFIG, arrayOf(1, 2, 5))
         }
     }
 
     private fun showBgTextConfig(index: Int): Boolean {
         dismissAllowingStateLoss()
-        changeBg(index)
+        changeBgTextConfig(index)
         callBack?.showBgTextConfig()
         return true
     }
@@ -200,7 +200,7 @@ class ReadStyleDialog : BaseDialogFragment(R.layout.dialog_read_book_style),
     override fun selectFont(path: String) {
         if (path != ReadBookConfig.textFont) {
             ReadBookConfig.textFont = path
-            postEvent(EventBus.UP_CONFIG, true)
+            postEvent(EventBus.UP_CONFIG, arrayOf(8, 5))
         }
     }
 
@@ -235,7 +235,7 @@ class ReadStyleDialog : BaseDialogFragment(R.layout.dialog_read_book_style),
             binding.apply {
                 ivStyle.setOnClickListener {
                     if (ivStyle.isInView) {
-                        changeBg(holder.layoutPosition)
+                        changeBgTextConfig(holder.layoutPosition)
                     }
                 }
                 ivStyle.onLongClick(ivStyle.isInView) {

+ 20 - 15
app/src/main/java/io/legado/app/ui/book/read/config/TipConfigDialog.kt

@@ -11,7 +11,12 @@ import io.legado.app.databinding.DialogTipConfigBinding
 import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.help.config.ReadTipConfig
 import io.legado.app.lib.dialogs.selector
-import io.legado.app.utils.*
+import io.legado.app.utils.checkByIndex
+import io.legado.app.utils.getIndexById
+import io.legado.app.utils.hexString
+import io.legado.app.utils.observeEvent
+import io.legado.app.utils.postEvent
+import io.legado.app.utils.setLayout
 import io.legado.app.utils.viewbindingdelegate.viewBinding
 
 
@@ -89,26 +94,26 @@ class TipConfigDialog : BaseDialogFragment(R.layout.dialog_tip_config) {
     private fun initEvent() = binding.run {
         rgTitleMode.setOnCheckedChangeListener { _, checkedId ->
             ReadBookConfig.titleMode = rgTitleMode.getIndexById(checkedId)
-            postEvent(EventBus.UP_CONFIG, true)
+            postEvent(EventBus.UP_CONFIG, arrayOf(5))
         }
         dsbTitleSize.onChanged = {
             ReadBookConfig.titleSize = it
-            postEvent(EventBus.UP_CONFIG, true)
+            postEvent(EventBus.UP_CONFIG, arrayOf(8, 5))
         }
         dsbTitleTop.onChanged = {
             ReadBookConfig.titleTopSpacing = it
-            postEvent(EventBus.UP_CONFIG, true)
+            postEvent(EventBus.UP_CONFIG, arrayOf(8, 5))
         }
         dsbTitleBottom.onChanged = {
             ReadBookConfig.titleBottomSpacing = it
-            postEvent(EventBus.UP_CONFIG, true)
+            postEvent(EventBus.UP_CONFIG, arrayOf(8, 5))
         }
         llHeaderShow.setOnClickListener {
             val headerModes = ReadTipConfig.getHeaderModes(requireContext())
             context?.selector(items = headerModes.values.toList()) { _, i ->
                 ReadTipConfig.headerMode = headerModes.keys.toList()[i]
                 tvHeaderShow.text = headerModes[ReadTipConfig.headerMode]
-                postEvent(EventBus.UP_CONFIG, true)
+                postEvent(EventBus.UP_CONFIG, arrayOf(2))
             }
         }
         llFooterShow.setOnClickListener {
@@ -116,7 +121,7 @@ class TipConfigDialog : BaseDialogFragment(R.layout.dialog_tip_config) {
             context?.selector(items = footerModes.values.toList()) { _, i ->
                 ReadTipConfig.footerMode = footerModes.keys.toList()[i]
                 tvFooterShow.text = footerModes[ReadTipConfig.footerMode]
-                postEvent(EventBus.UP_CONFIG, true)
+                postEvent(EventBus.UP_CONFIG, arrayOf(2))
             }
         }
         llHeaderLeft.setOnClickListener {
@@ -125,7 +130,7 @@ class TipConfigDialog : BaseDialogFragment(R.layout.dialog_tip_config) {
                 clearRepeat(tipValue)
                 ReadTipConfig.tipHeaderLeft = tipValue
                 tvHeaderLeft.text = ReadTipConfig.tipNames[i]
-                postEvent(EventBus.UP_CONFIG, true)
+                postEvent(EventBus.UP_CONFIG, arrayOf(2))
             }
         }
         llHeaderMiddle.setOnClickListener {
@@ -134,7 +139,7 @@ class TipConfigDialog : BaseDialogFragment(R.layout.dialog_tip_config) {
                 clearRepeat(tipValue)
                 ReadTipConfig.tipHeaderMiddle = tipValue
                 tvHeaderMiddle.text = ReadTipConfig.tipNames[i]
-                postEvent(EventBus.UP_CONFIG, true)
+                postEvent(EventBus.UP_CONFIG, arrayOf(2))
             }
         }
         llHeaderRight.setOnClickListener {
@@ -143,7 +148,7 @@ class TipConfigDialog : BaseDialogFragment(R.layout.dialog_tip_config) {
                 clearRepeat(tipValue)
                 ReadTipConfig.tipHeaderRight = tipValue
                 tvHeaderRight.text = ReadTipConfig.tipNames[i]
-                postEvent(EventBus.UP_CONFIG, true)
+                postEvent(EventBus.UP_CONFIG, arrayOf(2))
             }
         }
         llFooterLeft.setOnClickListener {
@@ -152,7 +157,7 @@ class TipConfigDialog : BaseDialogFragment(R.layout.dialog_tip_config) {
                 clearRepeat(tipValue)
                 ReadTipConfig.tipFooterLeft = tipValue
                 tvFooterLeft.text = ReadTipConfig.tipNames[i]
-                postEvent(EventBus.UP_CONFIG, true)
+                postEvent(EventBus.UP_CONFIG, arrayOf(2))
             }
         }
         llFooterMiddle.setOnClickListener {
@@ -161,7 +166,7 @@ class TipConfigDialog : BaseDialogFragment(R.layout.dialog_tip_config) {
                 clearRepeat(tipValue)
                 ReadTipConfig.tipFooterMiddle = tipValue
                 tvFooterMiddle.text = ReadTipConfig.tipNames[i]
-                postEvent(EventBus.UP_CONFIG, true)
+                postEvent(EventBus.UP_CONFIG, arrayOf(2))
             }
         }
         llFooterRight.setOnClickListener {
@@ -170,7 +175,7 @@ class TipConfigDialog : BaseDialogFragment(R.layout.dialog_tip_config) {
                 clearRepeat(tipValue)
                 ReadTipConfig.tipFooterRight = tipValue
                 tvFooterRight.text = ReadTipConfig.tipNames[i]
-                postEvent(EventBus.UP_CONFIG, true)
+                postEvent(EventBus.UP_CONFIG, arrayOf(2))
             }
         }
         llTipColor.setOnClickListener {
@@ -179,7 +184,7 @@ class TipConfigDialog : BaseDialogFragment(R.layout.dialog_tip_config) {
                     0 -> {
                         ReadTipConfig.tipColor = 0
                         upTvTipColor()
-                        postEvent(EventBus.UP_CONFIG, true)
+                        postEvent(EventBus.UP_CONFIG, arrayOf(2))
                     }
                     1 -> ColorPickerDialog.newBuilder()
                         .setShowAlphaSlider(false)
@@ -195,7 +200,7 @@ class TipConfigDialog : BaseDialogFragment(R.layout.dialog_tip_config) {
                     0, 1 -> {
                         ReadTipConfig.tipDividerColor = i - 1
                         upTvTipDividerColor()
-                        postEvent(EventBus.UP_CONFIG, true)
+                        postEvent(EventBus.UP_CONFIG, arrayOf(2))
                     }
                     2 -> ColorPickerDialog.newBuilder()
                         .setShowAlphaSlider(false)

+ 148 - 0
app/src/main/java/io/legado/app/ui/book/read/page/AutoPager.kt

@@ -0,0 +1,148 @@
+package io.legado.app.ui.book.read.page
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.Picture
+import android.graphics.Rect
+import android.os.Build
+import android.os.SystemClock
+import androidx.core.graphics.withClip
+import io.legado.app.help.config.AppConfig
+import io.legado.app.help.config.ReadBookConfig
+import io.legado.app.lib.theme.ThemeStore
+import io.legado.app.ui.book.read.page.entities.PageDirection
+import io.legado.app.utils.screenshot
+
+/**
+ * 自动翻页
+ */
+class AutoPager(private val readView: ReadView) {
+    private var progress = 0
+    var isRunning = false
+    private var isPausing = false
+    private var scrollOffsetRemain = 0.0
+    private var scrollOffset = 0
+    private var lastTimeMillis = 0L
+    private var bitmap: Bitmap? = null
+    private var picture: Picture? = null
+    private var pictureIsDirty = true
+    private val atLeastApi23 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
+    private val rect = Rect()
+    private val paint by lazy { Paint() }
+
+    fun start() {
+        isRunning = true
+        paint.color = ThemeStore.accentColor
+        lastTimeMillis = SystemClock.uptimeMillis()
+        readView.curPage.upSelectAble(false)
+        readView.invalidate()
+    }
+
+    fun stop() {
+        if (!isRunning) {
+            return
+        }
+        isRunning = false
+        isPausing = false
+        readView.curPage.upSelectAble(AppConfig.textSelectAble)
+        readView.invalidate()
+        reset()
+        picture = null
+    }
+
+    fun pause() {
+        if (!isRunning) {
+            return
+        }
+        isPausing = true
+    }
+
+    fun resume() {
+        if (!isRunning) {
+            return
+        }
+        isPausing = false
+        lastTimeMillis = SystemClock.uptimeMillis()
+        readView.invalidate()
+    }
+
+    fun reset() {
+        progress = 0
+        scrollOffsetRemain = 0.0
+        scrollOffset = 0
+        bitmap?.recycle()
+        bitmap = null
+        pictureIsDirty = true
+    }
+
+    fun onDraw(canvas: Canvas) {
+        if (!isRunning) {
+            return
+        }
+
+        if (readView.isScroll) {
+            if (!isPausing) readView.curPage.scroll(-scrollOffset)
+        } else {
+            val bottom = progress
+            val width = readView.width
+            if (atLeastApi23) {
+                if (picture == null) {
+                    picture = Picture()
+                }
+                if (pictureIsDirty) {
+                    pictureIsDirty = false
+                    readView.nextPage.screenshot(picture!!)
+                }
+                canvas.withClip(0, 0, width, bottom) {
+                    drawPicture(picture!!)
+                }
+            } else {
+                if (bitmap == null) {
+                    bitmap = readView.nextPage.screenshot()
+                }
+                rect.set(0, 0, width, bottom)
+                canvas.drawBitmap(bitmap!!, rect, rect, null)
+            }
+            canvas.drawRect(
+                0f,
+                bottom.toFloat() - 1,
+                width.toFloat(),
+                bottom.toFloat(),
+                paint
+            )
+            if (!isPausing) readView.postInvalidate()
+        }
+
+    }
+
+    fun computeOffset() {
+        if (!isRunning) {
+            return
+        }
+
+        val currentTime = SystemClock.uptimeMillis()
+        val elapsedTime = currentTime - lastTimeMillis
+        lastTimeMillis = currentTime
+
+        val readTime = ReadBookConfig.autoReadSpeed * 1000.0
+        val height = readView.height
+        scrollOffsetRemain += height / readTime * elapsedTime
+        if (scrollOffsetRemain < 1) {
+            return
+        }
+        scrollOffset = scrollOffsetRemain.toInt()
+        this.scrollOffsetRemain -= scrollOffset
+        if (!readView.isScroll) {
+            progress += scrollOffset
+            if (progress >= height) {
+                if (!readView.fillPage(PageDirection.NEXT)) {
+                    stop()
+                } else {
+                    reset()
+                }
+            }
+        }
+    }
+
+}

+ 115 - 208
app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt

@@ -3,23 +3,14 @@ package io.legado.app.ui.book.read.page
 import android.content.Context
 import android.graphics.Canvas
 import android.graphics.Paint
-import android.graphics.Picture
-import android.graphics.RectF
-import android.os.Build
 import android.util.AttributeSet
 import android.view.MotionEvent
 import android.view.View
-import androidx.core.graphics.record
 import io.legado.app.R
-import io.legado.app.constant.PageAnim
-import io.legado.app.constant.PreferKey
 import io.legado.app.data.entities.Bookmark
-import io.legado.app.help.book.isImage
 import io.legado.app.help.config.AppConfig
-import io.legado.app.help.config.ReadBookConfig
-import io.legado.app.lib.theme.accentColor
-import io.legado.app.model.ImageProvider
 import io.legado.app.model.ReadBook
+import io.legado.app.ui.book.read.page.delegate.PageDelegate
 import io.legado.app.ui.book.read.page.entities.TextLine
 import io.legado.app.ui.book.read.page.entities.TextPage
 import io.legado.app.ui.book.read.page.entities.TextPos
@@ -32,27 +23,25 @@ import io.legado.app.ui.book.read.page.provider.ChapterProvider
 import io.legado.app.ui.book.read.page.provider.TextPageFactory
 import io.legado.app.ui.widget.dialog.PhotoDialog
 import io.legado.app.utils.activity
-import io.legado.app.utils.dpToPx
 import io.legado.app.utils.getCompatColor
-import io.legado.app.utils.getPrefBoolean
 import io.legado.app.utils.showDialogFragment
 import io.legado.app.utils.toastOnUi
+import java.util.concurrent.Executors
 import kotlin.math.min
 
 /**
  * 阅读内容视图
  */
 class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
-    var selectAble = context.getPrefBoolean(PreferKey.textSelectAble, true)
-    var upView: ((TextPage) -> Unit)? = null
-    private val selectedPaint by lazy {
+    var selectAble = AppConfig.textSelectAble
+    val selectedPaint by lazy {
         Paint().apply {
             color = context.getCompatColor(R.color.btn_bg_press_2)
             style = Paint.Style.FILL
         }
     }
     private var callBack: CallBack
-    private val visibleRect = RectF()
+    private val visibleRect = ChapterProvider.visibleRect
     val selectStart = TextPos(0, 0, 0)
     private val selectEnd = TextPos(0, 0, 0)
     var textPage: TextPage = TextPage()
@@ -63,15 +52,15 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
     var reverseEndCursor = false
 
     //滚动参数
-    private val pageFactory: TextPageFactory get() = callBack.pageFactory
+    private val pageFactory get() = callBack.pageFactory
+    private val pageDelegate get() = callBack.pageDelegate
     private var pageOffset = 0
-    private lateinit var picture: Picture
-    private var pictureIsDirty = true
-    private val atLeastApi23 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
-    private val isNoAnim get() = ReadBook.pageAnim() == PageAnim.noAnim
+    private var autoPager: AutoPager? = null
+    private var isScroll = false
+    private val renderRunnable by lazy { Runnable { preRenderPage() } }
 
     //绘制图片的paint
-    private val imagePaint by lazy {
+    val imagePaint by lazy {
         Paint().apply {
             isAntiAlias = AppConfig.useAntiAlias
         }
@@ -79,9 +68,6 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
 
     init {
         callBack = activity as CallBack
-        if (atLeastApi23) {
-            picture = Picture()
-        }
     }
 
     /**
@@ -89,47 +75,29 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
      */
     fun setContent(textPage: TextPage) {
         this.textPage = textPage
-        imagePaint.isAntiAlias = AppConfig.useAntiAlias
-        invalidate()
-    }
-
-    /**
-     * 更新绘制区域
-     */
-    fun upVisibleRect() {
-        visibleRect.set(
-            ChapterProvider.paddingLeft.toFloat(),
-            ChapterProvider.paddingTop.toFloat(),
-            ChapterProvider.visibleRight.toFloat(),
-            ChapterProvider.visibleBottom.toFloat()
-        )
+        // 非滑动翻页动画需要同步重绘,不然翻页可能会出现闪烁
+        if (isScroll) {
+            postInvalidate()
+        } else {
+            invalidate()
+        }
     }
 
     override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
         super.onSizeChanged(w, h, oldw, oldh)
-        if (!isMainView) return
         ChapterProvider.upViewSize(w, h)
-        upVisibleRect()
         textPage.format()
     }
 
     override fun onDraw(canvas: Canvas) {
         super.onDraw(canvas)
+        autoPager?.onDraw(canvas)
         if (longScreenshot) {
             canvas.translate(0f, scrollY.toFloat())
         }
+        check(!visibleRect.isEmpty) { "visibleRect 为空" }
         canvas.clipRect(visibleRect)
-        if (atLeastApi23 && !callBack.isScroll && !isNoAnim) {
-            if (pictureIsDirty) {
-                pictureIsDirty = false
-                picture.record(width, height) {
-                    drawPage(this)
-                }
-            }
-            canvas.drawPicture(picture)
-        } else {
-            drawPage(canvas)
-        }
+        drawPage(canvas)
     }
 
     /**
@@ -137,185 +105,94 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
      */
     private fun drawPage(canvas: Canvas) {
         var relativeOffset = relativeOffset(0)
-        var lines = textPage.lines
-        for (i in lines.indices) {
-            drawLine(canvas, textPage, lines[i], relativeOffset)
-        }
+        textPage.draw(this, canvas, relativeOffset)
         if (!callBack.isScroll) return
         //滚动翻页
         if (!pageFactory.hasNext()) return
         val textPage1 = relativePage(1)
-        relativeOffset = relativeOffset(1)
-        lines = textPage1.lines
-        for (i in lines.indices) {
-            drawLine(canvas, textPage1, lines[i], relativeOffset)
-        }
+        relativeOffset += textPage.height
+        textPage1.draw(this, canvas, relativeOffset)
         if (!pageFactory.hasNextPlus()) return
-        relativeOffset = relativeOffset(2)
+        relativeOffset += textPage1.height
         if (relativeOffset < ChapterProvider.visibleHeight) {
             val textPage2 = relativePage(2)
-            lines = textPage2.lines
-            for (i in lines.indices) {
-                drawLine(canvas, textPage2, lines[i], relativeOffset)
-            }
-        }
-    }
-
-    /**
-     * 绘制页面
-     */
-    private fun drawLine(
-        canvas: Canvas,
-        textPage: TextPage,
-        textLine: TextLine,
-        relativeOffset: Float,
-    ) {
-        val lineTop = textLine.lineTop + relativeOffset
-        val lineBase = textLine.lineBase + relativeOffset
-        val lineBottom = textLine.lineBottom + relativeOffset
-        drawChars(canvas, textPage, textLine, lineTop, lineBase, lineBottom)
-        if (ReadBookConfig.underline && ReadBook.book?.isImage != true) {
-            drawUnderline(canvas, textLine, relativeOffset)
+            textPage2.draw(this, canvas, relativeOffset)
         }
     }
 
-    /**
-     * 绘制下划线
-     */
-    private fun drawUnderline(canvas: Canvas, textLine: TextLine, relativeOffset: Float) {
-        val lineY = relativeOffset + textLine.lineBottom - 1.dpToPx()
-        canvas.drawLine(
-            textLine.lineStart + textLine.indentWidth,
-            lineY,
-            textLine.lineEnd,
-            lineY,
-            ChapterProvider.contentPaint
-        )
-    }
-
-    /**
-     * 绘制文字
-     */
-    private fun drawChars(
-        canvas: Canvas,
-        textPage: TextPage,
-        textLine: TextLine,
-        lineTop: Float,
-        lineBase: Float,
-        lineBottom: Float,
-    ) {
-        val textPaint = if (textLine.isTitle) {
-            ChapterProvider.titlePaint
-        } else {
-            ChapterProvider.contentPaint
-        }
-        val textColor = if (textLine.isReadAloud) context.accentColor else ReadBookConfig.textColor
-        val columns = textLine.columns
-        for (i in columns.indices) {
-            when (val column = columns[i]) {
-                is TextColumn -> {
-                    if (column.isSearchResult) {
-                        textPaint.color = context.accentColor
-                    } else if (textPaint.color != textColor) {
-                        textPaint.color = textColor
-                    }
-                    canvas.drawText(column.charData, column.start, lineBase, textPaint)
-                    if (column.selected) {
-                        canvas.drawRect(
-                            column.start,
-                            lineTop,
-                            column.end,
-                            lineBottom,
-                            selectedPaint
-                        )
-                    }
-                }
-
-                is ImageColumn -> drawImage(canvas, textPage, textLine, column, lineTop, lineBottom)
-                is ReviewColumn -> column.drawToCanvas(canvas, lineBase, textPaint.textSize)
-            }
-        }
-    }
-
-    /**
-     * 绘制图片
-     */
-    @Suppress("UNUSED_PARAMETER")
-    private fun drawImage(
-        canvas: Canvas,
-        textPage: TextPage,
-        textLine: TextLine,
-        column: ImageColumn,
-        lineTop: Float,
-        lineBottom: Float
-    ) {
-
-        val book = ReadBook.book ?: return
-
-        val bitmap = ImageProvider.getImage(
-            book,
-            column.src,
-            (column.end - column.start).toInt(),
-            (lineBottom - lineTop).toInt()
-        ) {
-            invalidate()
-        } ?: return
-
-        val rectF = if (textLine.isImage) {
-            RectF(column.start, lineTop, column.end, lineBottom)
-        } else {
-            /*以宽度为基准保持图片的原始比例叠加,当div为负数时,允许高度比字符更高*/
-            val h = (column.end - column.start) / bitmap.width * bitmap.height
-            val div = (lineBottom - lineTop - h) / 2
-            RectF(column.start, lineTop + div, column.end, lineBottom - div)
-        }
-        kotlin.runCatching {
-            canvas.drawBitmap(bitmap, null, rectF, imagePaint)
-        }.onFailure { e ->
-            context.toastOnUi(e.localizedMessage)
-        }
+    override fun computeScroll() {
+        pageDelegate?.computeScroll()
+        autoPager?.computeOffset()
     }
 
     /**
      * 滚动事件
+     * pageOffset 向上滚动 减小 向下滚动 增大
+     * pageOffset 范围 0 ~ -textPage.height 大于0为上一页,小于-textPage.height为下一页
+     * 以内容显示区域顶端为界,pageOffset的绝对值为textPage上方的高度
+     * pageOffset + textPage.height 为 textPage 下方的高度
      */
     fun scroll(mOffset: Int) {
-        if (mOffset == 0) return
         pageOffset += mOffset
         if (longScreenshot) {
             scrollY += -mOffset
         }
         if (!pageFactory.hasPrev() && pageOffset > 0) {
             pageOffset = 0
+            pageDelegate?.abortAnim()
         } else if (!pageFactory.hasNext()
             && pageOffset < 0
             && pageOffset + textPage.height < ChapterProvider.visibleHeight
         ) {
             val offset = (ChapterProvider.visibleHeight - textPage.height).toInt()
             pageOffset = min(0, offset)
+            pageDelegate?.abortAnim()
         } else if (pageOffset > 0) {
-            pageFactory.moveToPrev(true)
-            textPage = pageFactory.curPage
-            pageOffset -= textPage.height.toInt()
-            upView?.invoke(textPage)
-            contentDescription = textPage.text
+            if (pageFactory.moveToPrev(true)) {
+                pageOffset -= textPage.height.toInt()
+            } else {
+                pageOffset = 0
+                pageDelegate?.abortAnim()
+            }
         } else if (pageOffset < -textPage.height) {
-            pageOffset += textPage.height.toInt()
-            pageFactory.moveToNext(true)
-            textPage = pageFactory.curPage
-            upView?.invoke(textPage)
-            contentDescription = textPage.text
+            val height = textPage.height
+            if (pageFactory.moveToNext(upContent = true)) {
+                pageOffset += height.toInt()
+            } else {
+                pageOffset = -height.toInt()
+                pageDelegate?.abortAnim()
+            }
         }
-        invalidate()
+        postInvalidate()
     }
 
-    override fun invalidate() {
-        super.invalidate()
-        invalidatePicture()
+    fun submitRenderTask() {
+        renderThread.submit(renderRunnable)
     }
 
-    private fun invalidatePicture() {
-        pictureIsDirty = true
+    private fun preRenderPage() {
+        val view = this
+        var invalidate = false
+        pageFactory.run {
+            if (hasPrev() && prevPage.render(view)) {
+                invalidate = true
+            }
+            if (curPage.render(view)) {
+                invalidate = true
+            }
+            if (hasNext() && nextPage.render(view) && callBack.isScroll) {
+                invalidate = true
+            }
+            if (hasNextPlus() && nextPlusPage.render(view) && callBack.isScroll
+                && relativeOffset(2) < ChapterProvider.visibleHeight
+            ) {
+                invalidate = true
+            }
+            if (invalidate) {
+                postInvalidate()
+                pageDelegate?.postInvalidate()
+            }
+        }
     }
 
     /**
@@ -385,7 +262,6 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
         touchRough(x, y) { _, textPos, _, _, column ->
             if (column is TextColumn) {
                 column.selected = true
-                invalidate()
                 select(textPos)
             }
         }
@@ -517,9 +393,21 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
                 if (relativeOffset >= ChapterProvider.visibleHeight) return
             }
             val textPage = relativePage(relativePos)
-            for ((lineIndex, textLine) in textPage.lines.withIndex()) {
+            for (lineIndex in textPage.lines.indices) {
+                val textLine = textPage.getLine(lineIndex)
                 if (textLine.isTouchY(y, relativeOffset)) {
-                    for ((charIndex, textColumn) in textLine.columns.withIndex()) {
+                    if (textPage.doublePage) {
+                        val halfWidth = width / 2
+                        if (textLine.isLeftLine && x > halfWidth) {
+                            continue
+                        }
+                        if (!textLine.isLeftLine && x < halfWidth) {
+                            continue
+                        }
+                    }
+                    val columns = textLine.columns
+                    for (charIndex in columns.indices) {
+                        val textColumn = columns[charIndex]
                         if (textColumn.isTouch(x)) {
                             touched.invoke(
                                 relativeOffset,
@@ -529,12 +417,9 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
                             return
                         }
                     }
-                    val isLast = textLine.columns.first().start < x
-                    val (charIndex, textColumn) = if (isLast) {
-                        textLine.columns.withIndex().last()
-                    } else {
-                        textLine.columns.withIndex().first()
-                    }
+                    val isLast = columns.first().start < x
+                    val charIndex = if (isLast) columns.lastIndex else 0
+                    val textColumn = if (isLast) columns.last() else columns.first()
                     touched.invoke(
                         relativeOffset,
                         TextPos(relativePos, lineIndex, charIndex, false, isLast),
@@ -557,7 +442,9 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
                 if (relativeOffset >= ChapterProvider.visibleHeight) break
             }
             val textPage = relativePage(relativePos)
-            for (textLine in textPage.lines) {
+            val lines = textPage.lines
+            for (i in lines.indices) {
+                val textLine = lines[i]
                 if (textLine.isVisible(relativeOffset)) {
                     val visibleLine = textLine.copy().apply {
                         lineTop += relativeOffset
@@ -580,7 +467,9 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
                 if (relativeOffset >= ChapterProvider.visibleHeight) break
             }
             val textPage = relativePage(relativePos)
-            for (textLine in textPage.lines) {
+            val lines = textPage.lines
+            for (i in lines.indices) {
+                val textLine = lines[i]
                 if (textLine.isVisible(relativeOffset)) {
                     val visibleLine = textLine.copy().apply {
                         lineTop += relativeOffset
@@ -675,7 +564,8 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
                 }
             }
         }
-        invalidate()
+        // 由后台线程完成渲染后通知视图重绘
+        submitRenderTask()
     }
 
     private fun upSelectedStart(x: Float, y: Float, top: Float) {
@@ -795,6 +685,14 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
         }
     }
 
+    fun setAutoPager(autoPager: AutoPager?) {
+        this.autoPager = autoPager
+    }
+
+    fun setIsScroll(value: Boolean) {
+        isScroll = value
+    }
+
     override fun canScrollVertically(direction: Int): Boolean {
         return callBack.isScroll && pageFactory.hasNext()
     }
@@ -814,9 +712,18 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
         return callBack.onLongScreenshotTouchEvent(event)
     }
 
+    companion object {
+        private val renderThread by lazy {
+            Executors.newSingleThreadExecutor {
+                Thread(it, "TextPageRender")
+            }
+        }
+    }
+
     interface CallBack {
         val headerHeight: Int
         val pageFactory: TextPageFactory
+        val pageDelegate: PageDelegate?
         val isScroll: Boolean
         var isSelectingSearchResult: Boolean
         fun upSelectedStart(x: Float, y: Float, top: Float)

+ 30 - 28
app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt

@@ -22,6 +22,7 @@ import io.legado.app.ui.widget.BatteryView
 import io.legado.app.utils.activity
 import io.legado.app.utils.dpToPx
 import io.legado.app.utils.gone
+import io.legado.app.utils.setTextIfNotEqual
 import io.legado.app.utils.statusBarHeight
 import splitties.views.backgroundColor
 import java.util.Date
@@ -45,6 +46,8 @@ class PageView(context: Context) : FrameLayout(context) {
     private var tvBookName: BatteryView? = null
     private var tvTimeBattery: BatteryView? = null
     private var tvTimeBatteryP: BatteryView? = null
+    private var isMainView = false
+    var isScroll = false
 
     val headerHeight: Int
         get() {
@@ -57,9 +60,6 @@ class PageView(context: Context) : FrameLayout(context) {
         if (!isInEditMode) {
             upStyle()
         }
-        binding.contentTextView.upView = {
-            setProgress(it)
-        }
     }
 
     override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
@@ -104,7 +104,6 @@ class PageView(context: Context) : FrameLayout(context) {
             vwTopDivider.gone(llHeader.isGone || !it.showHeaderLine)
             vwBottomDivider.gone(llFooter.isGone || !it.showFooterLine)
         }
-        contentTextView.upVisibleRect()
         upTime()
         upBattery(battery)
     }
@@ -276,7 +275,13 @@ class PageView(context: Context) : FrameLayout(context) {
      * 设置内容
      */
     fun setContent(textPage: TextPage, resetPageOffset: Boolean = true) {
-        setProgress(textPage)
+        if (isMainView && !isScroll) {
+            setProgress(textPage)
+        } else {
+            post {
+                setProgress(textPage)
+            }
+        }
         if (resetPageOffset) {
             resetPageOffset()
         }
@@ -302,30 +307,26 @@ class PageView(context: Context) : FrameLayout(context) {
      */
     @SuppressLint("SetTextI18n")
     fun setProgress(textPage: TextPage) = textPage.apply {
-        tvBookName?.apply {
-            if (text != ReadBook.book?.name) {
-                text = ReadBook.book?.name
-            }
-        }
-        tvTitle?.apply {
-            if (text != textPage.title) {
-                text = textPage.title
-            }
-        }
-        tvPage?.text = "${index.plus(1)}/$pageSize"
+        tvBookName?.setTextIfNotEqual(ReadBook.book?.name)
+        tvTitle?.setTextIfNotEqual(textPage.title)
+        tvPage?.setTextIfNotEqual("${index.plus(1)}/$pageSize")
         val readProgress = readProgress
-        tvTotalProgress?.apply {
-            if (text != readProgress) {
-                text = readProgress
-            }
-        }
-        tvTotalProgress1?.apply {
-            val progress = "${chapterIndex.plus(1)}/${chapterSize}"
-            if (text != progress) {
-                text = progress
-            }
-        }
-        tvPageAndTotal?.text = "${index.plus(1)}/$pageSize  $readProgress"
+        tvTotalProgress?.setTextIfNotEqual(readProgress)
+        tvTotalProgress1?.setTextIfNotEqual("${chapterIndex.plus(1)}/${chapterSize}")
+        tvPageAndTotal?.setTextIfNotEqual("${index.plus(1)}/$pageSize  $readProgress")
+    }
+
+    fun setAutoPager(autoPager: AutoPager?) {
+        binding.contentTextView.setAutoPager(autoPager)
+    }
+
+    fun submitPreRenderTask() {
+        binding.contentTextView.submitRenderTask()
+    }
+
+    fun setIsScroll(value: Boolean) {
+        isScroll = value
+        binding.contentTextView.setIsScroll(value)
     }
 
     /**
@@ -379,6 +380,7 @@ class PageView(context: Context) : FrameLayout(context) {
     }
 
     fun markAsMainView() {
+        isMainView = true
         binding.contentTextView.isMainView = true
     }
 

+ 47 - 41
app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt

@@ -2,10 +2,7 @@ package io.legado.app.ui.book.read.page
 
 import android.annotation.SuppressLint
 import android.content.Context
-import android.graphics.Bitmap
 import android.graphics.Canvas
-import android.graphics.Paint
-import android.graphics.Rect
 import android.graphics.RectF
 import android.os.Build
 import android.util.AttributeSet
@@ -16,21 +13,27 @@ import android.widget.FrameLayout
 import io.legado.app.constant.PageAnim
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.ReadBookConfig
-import io.legado.app.lib.theme.accentColor
 import io.legado.app.model.ReadAloud
 import io.legado.app.model.ReadBook
 import io.legado.app.ui.book.read.ContentEditDialog
 import io.legado.app.ui.book.read.page.api.DataSource
-import io.legado.app.ui.book.read.page.delegate.*
+import io.legado.app.ui.book.read.page.delegate.CoverPageDelegate
+import io.legado.app.ui.book.read.page.delegate.NoAnimPageDelegate
+import io.legado.app.ui.book.read.page.delegate.PageDelegate
+import io.legado.app.ui.book.read.page.delegate.ScrollPageDelegate
+import io.legado.app.ui.book.read.page.delegate.SimulationPageDelegate
+import io.legado.app.ui.book.read.page.delegate.SlidePageDelegate
 import io.legado.app.ui.book.read.page.entities.PageDirection
 import io.legado.app.ui.book.read.page.entities.TextChapter
 import io.legado.app.ui.book.read.page.entities.TextPage
 import io.legado.app.ui.book.read.page.entities.TextPos
 import io.legado.app.ui.book.read.page.provider.ChapterProvider
 import io.legado.app.ui.book.read.page.provider.TextPageFactory
-import io.legado.app.utils.*
+import io.legado.app.utils.activity
+import io.legado.app.utils.invisible
+import io.legado.app.utils.showDialogFragment
 import java.text.BreakIterator
-import java.util.*
+import java.util.Locale
 import kotlin.math.abs
 
 /**
@@ -49,7 +52,7 @@ class ReadView(context: Context, attrs: AttributeSet) :
             field = value
             upContent()
         }
-    var isScroll = false
+    override var isScroll = false
     val prevPage by lazy { PageView(context) }
     val curPage by lazy { PageView(context) }
     val nextPage by lazy { PageView(context) }
@@ -95,16 +98,16 @@ class ReadView(context: Context, attrs: AttributeSet) :
     private val blRect = RectF()
     private val bcRect = RectF()
     private val brRect = RectF()
-    private val autoPageRect by lazy { Rect() }
-    private val autoPagePint by lazy { Paint().apply { color = context.accentColor } }
     private val boundary by lazy { BreakIterator.getWordInstance(Locale.getDefault()) }
-    private var nextPageBitmap: Bitmap? = null
+    val autoPager = AutoPager(this)
+    val isAutoPage get() = autoPager.isRunning
 
     init {
         addView(nextPage)
         addView(curPage)
         addView(prevPage)
         prevPage.invisible()
+        nextPage.invisible()
         curPage.markAsMainView()
         if (!isInEditMode) {
             upBg()
@@ -140,26 +143,12 @@ class ReadView(context: Context, attrs: AttributeSet) :
     override fun dispatchDraw(canvas: Canvas) {
         super.dispatchDraw(canvas)
         pageDelegate?.onDraw(canvas)
-        if (!isInEditMode && callBack.isAutoPage && !isScroll) {
-            // 自动翻页
-            val bitmap = nextPageBitmap ?: nextPage.screenshot()?.also { nextPageBitmap = it }
-            bitmap?.let {
-                val bottom = callBack.autoPageProgress
-                autoPageRect.set(0, 0, width, bottom)
-                canvas.drawBitmap(it, autoPageRect, autoPageRect, null)
-                canvas.drawRect(
-                    0f,
-                    bottom.toFloat() - 1,
-                    width.toFloat(),
-                    bottom.toFloat(),
-                    autoPagePint
-                )
-            }
-        }
+        autoPager.onDraw(canvas)
     }
 
     override fun computeScroll() {
-        pageDelegate?.scroll()
+        pageDelegate?.computeScroll()
+        autoPager.computeOffset()
     }
 
     override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
@@ -253,6 +242,7 @@ class ReadView(context: Context, attrs: AttributeSet) :
                     pageDelegate?.onTouch(event)
                 }
                 pressOnTextSelected = false
+                autoPager.resume()
             }
         }
         return true
@@ -516,11 +506,12 @@ class ReadView(context: Context, attrs: AttributeSet) :
         }
         (pageDelegate as? ScrollPageDelegate)?.noAnim = AppConfig.noAnimScrollPage
         pageDelegate?.setViewSize(width, height)
-        if (pageDelegate is NoAnimPageDelegate) {
-            nextPage.invisible()
+        if (isScroll) {
+            curPage.setAutoPager(autoPager)
         } else {
-            nextPage.visible()
+            curPage.setAutoPager(null)
         }
+        curPage.setIsScroll(isScroll)
     }
 
     /**
@@ -529,13 +520,12 @@ class ReadView(context: Context, attrs: AttributeSet) :
      * @param resetPageOffset 滚动阅读是是否重置位置
      */
     override fun upContent(relativePosition: Int, resetPageOffset: Boolean) {
-        curPage.setContentDescription(pageFactory.curPage.text)
-        if (isScroll && !callBack.isAutoPage) {
+        post {
+            curPage.setContentDescription(pageFactory.curPage.text)
+        }
+        if (isScroll && !isAutoPage) {
             curPage.setContent(pageFactory.curPage, resetPageOffset)
         } else {
-            if (callBack.isAutoPage && relativePosition >= 0) {
-                clearNextPageBitmap()
-            }
             when (relativePosition) {
                 -1 -> prevPage.setContent(pageFactory.prevPage)
                 1 -> nextPage.setContent(pageFactory.nextPage)
@@ -638,9 +628,27 @@ class ReadView(context: Context, attrs: AttributeSet) :
         return curPage.getCurVisibleFirstLine()?.pagePosition ?: 0
     }
 
-    fun clearNextPageBitmap() {
-        nextPageBitmap?.recycle()
-        nextPageBitmap = null
+    fun invalidateTextPage() {
+        pageFactory.run {
+            prevPage.invalidateAll()
+            curPage.invalidateAll()
+            nextPage.invalidateAll()
+            nextPlusPage.invalidateAll()
+        }
+        upContent()
+    }
+
+    fun onScrollAnimStart() {
+        autoPager.pause()
+    }
+
+    fun onScrollAnimStop() {
+        autoPager.resume()
+    }
+
+    fun onPageChange() {
+        autoPager.reset()
+        curPage.submitPreRenderTask()
     }
 
     override val currentChapter: TextChapter?
@@ -668,8 +676,6 @@ class ReadView(context: Context, attrs: AttributeSet) :
 
     interface CallBack {
         val isInitFinish: Boolean
-        val isAutoPage: Boolean
-        val autoPageProgress: Int
         fun showActionMenu()
         fun screenOffTimerStart()
         fun showTextActionMenu()

+ 3 - 0
app/src/main/java/io/legado/app/ui/book/read/page/api/DataSource.kt

@@ -13,9 +13,12 @@ interface DataSource {
 
     val prevChapter: TextChapter?
 
+    val isScroll: Boolean
+
     fun hasNextChapter(): Boolean
 
     fun hasPrevChapter(): Boolean
 
     fun upContent(relativePosition: Int = 0, resetPageOffset: Boolean = true)
+
 }

+ 15 - 56
app/src/main/java/io/legado/app/ui/book/read/page/delegate/CoverPageDelegate.kt

@@ -1,10 +1,7 @@
 package io.legado.app.ui.book.read.page.delegate
 
 import android.graphics.Canvas
-import android.graphics.Matrix
-import android.graphics.Picture
 import android.graphics.drawable.GradientDrawable
-import android.os.Build
 import androidx.core.graphics.withClip
 import androidx.core.graphics.withTranslation
 import io.legado.app.ui.book.read.page.ReadView
@@ -12,26 +9,14 @@ import io.legado.app.ui.book.read.page.entities.PageDirection
 import io.legado.app.utils.screenshot
 
 class CoverPageDelegate(readView: ReadView) : HorizontalPageDelegate(readView) {
-    private val bitmapMatrix = Matrix()
     private val shadowDrawableR: GradientDrawable
 
-    private lateinit var curPicture: Picture
-    private lateinit var prevPicture: Picture
-    private lateinit var nextPicture: Picture
-
-    private val atLeastApi23 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
-
     init {
         val shadowColors = intArrayOf(0x66111111, 0x00000000)
         shadowDrawableR = GradientDrawable(
             GradientDrawable.Orientation.LEFT_RIGHT, shadowColors
         )
         shadowDrawableR.gradientType = GradientDrawable.LINEAR_GRADIENT
-        if (atLeastApi23) {
-            curPicture = Picture()
-            prevPicture = Picture()
-            nextPicture = Picture()
-        }
     }
 
     override fun onDraw(canvas: Canvas) {
@@ -47,42 +32,21 @@ class CoverPageDelegate(readView: ReadView) : HorizontalPageDelegate(readView) {
         val distanceX = if (offsetX > 0) offsetX - viewWidth else offsetX + viewWidth
         if (mDirection == PageDirection.PREV) {
             if (offsetX <= viewWidth) {
-                if (!atLeastApi23) {
-                    bitmapMatrix.setTranslate(distanceX, 0.toFloat())
-                    prevBitmap?.let { canvas.drawBitmap(it, bitmapMatrix, null) }
-                } else {
-                    canvas.withTranslation(distanceX) {
-                        drawPicture(prevPicture)
-                    }
+                canvas.withTranslation(distanceX) {
+                    prevRecorder.draw(canvas)
                 }
                 addShadow(distanceX, canvas)
             } else {
-                if (!atLeastApi23) {
-                    prevBitmap?.let { canvas.drawBitmap(it, 0f, 0f, null) }
-                } else {
-                    canvas.drawPicture(prevPicture)
-                }
+                prevRecorder.draw(canvas)
             }
         } else if (mDirection == PageDirection.NEXT) {
-            if (!atLeastApi23) {
-                bitmapMatrix.setTranslate(distanceX - viewWidth, 0.toFloat())
-                nextBitmap?.let {
-                    val width = it.width.toFloat()
-                    val height = it.height.toFloat()
-                    canvas.withClip(width + offsetX, 0f, width, height) {
-                        drawBitmap(it, 0f, 0f, null)
-                    }
-                }
-                curBitmap?.let { canvas.drawBitmap(it, bitmapMatrix, null) }
-            } else {
-                val width = nextPicture.width.toFloat()
-                val height = nextPicture.height.toFloat()
-                canvas.withClip(width + offsetX, 0f, width, height) {
-                    drawPicture(nextPicture)
-                }
-                canvas.withTranslation(distanceX - viewWidth) {
-                    drawPicture(curPicture)
-                }
+            val width = nextRecorder.width.toFloat()
+            val height = nextRecorder.height.toFloat()
+            canvas.withClip(width + offsetX, 0f, width, height) {
+                nextRecorder.draw(this)
+            }
+            canvas.withTranslation(distanceX - viewWidth) {
+                curRecorder.draw(this)
             }
             addShadow(distanceX, canvas)
         }
@@ -90,18 +54,13 @@ class CoverPageDelegate(readView: ReadView) : HorizontalPageDelegate(readView) {
 
     override fun setBitmap() {
         when (mDirection) {
-            PageDirection.PREV -> if (!atLeastApi23) {
-                prevBitmap = prevPage.screenshot(prevBitmap, canvas)
-            } else {
-                prevPage.screenshot(prevPicture)
+            PageDirection.PREV -> {
+                prevPage.screenshot(prevRecorder)
             }
 
-            PageDirection.NEXT -> if (!atLeastApi23) {
-                nextBitmap = nextPage.screenshot(nextBitmap, canvas)
-                curBitmap = curPage.screenshot(curBitmap, canvas)
-            } else {
-                nextPage.screenshot(nextPicture)
-                curPage.screenshot(curPicture)
+            PageDirection.NEXT -> {
+                nextPage.screenshot(nextRecorder)
+                curPage.screenshot(curRecorder)
             }
 
             else -> Unit

+ 11 - 16
app/src/main/java/io/legado/app/ui/book/read/page/delegate/HorizontalPageDelegate.kt

@@ -1,18 +1,16 @@
 package io.legado.app.ui.book.read.page.delegate
 
-import android.graphics.Bitmap
-import android.graphics.Canvas
 import android.view.MotionEvent
 import io.legado.app.ui.book.read.page.ReadView
 import io.legado.app.ui.book.read.page.entities.PageDirection
+import io.legado.app.utils.canvasrecorder.CanvasRecorderFactory
 import io.legado.app.utils.screenshot
 
 abstract class HorizontalPageDelegate(readView: ReadView) : PageDelegate(readView) {
 
-    protected var curBitmap: Bitmap? = null
-    protected var prevBitmap: Bitmap? = null
-    protected var nextBitmap: Bitmap? = null
-    protected var canvas: Canvas = Canvas()
+    protected val curRecorder = CanvasRecorderFactory.create()
+    protected val prevRecorder = CanvasRecorderFactory.create()
+    protected val nextRecorder = CanvasRecorderFactory.create()
     private val slopSquare get() = readView.pageSlopSquare2
 
     override fun setDirection(direction: PageDirection) {
@@ -23,13 +21,13 @@ abstract class HorizontalPageDelegate(readView: ReadView) : PageDelegate(readVie
     open fun setBitmap() {
         when (mDirection) {
             PageDirection.PREV -> {
-                prevBitmap = prevPage.screenshot(prevBitmap, canvas)
-                curBitmap = curPage.screenshot(curBitmap, canvas)
+                prevPage.screenshot(prevRecorder)
+                curPage.screenshot(curRecorder)
             }
 
             PageDirection.NEXT -> {
-                nextBitmap = nextPage.screenshot(nextBitmap, canvas)
-                curBitmap = curPage.screenshot(curBitmap, canvas)
+                nextPage.screenshot(nextRecorder)
+                curPage.screenshot(curRecorder)
             }
 
             else -> Unit
@@ -140,12 +138,9 @@ abstract class HorizontalPageDelegate(readView: ReadView) : PageDelegate(readVie
 
     override fun onDestroy() {
         super.onDestroy()
-        prevBitmap?.recycle()
-        prevBitmap = null
-        curBitmap?.recycle()
-        curBitmap = null
-        nextBitmap?.recycle()
-        nextBitmap = null
+        prevRecorder.recycle()
+        curRecorder.recycle()
+        nextRecorder.recycle()
     }
 
 }

+ 10 - 1
app/src/main/java/io/legado/app/ui/book/read/page/delegate/PageDelegate.kt

@@ -96,7 +96,7 @@ abstract class PageDelegate(protected val readView: ReadView) {
         viewHeight = height
     }
 
-    fun scroll() {
+    open fun computeScroll() {
         if (scroller.computeScrollOffset()) {
             readView.setTouchPoint(scroller.currX.toFloat(), scroller.currY.toFloat())
         } else if (isStarted) {
@@ -190,6 +190,15 @@ abstract class PageDelegate(protected val readView: ReadView) {
         }
     }
 
+    fun postInvalidate() {
+        if (isRunning && this is HorizontalPageDelegate) {
+            readView.post {
+                setBitmap()
+                readView.invalidate()
+            }
+        }
+    }
+
     open fun onDestroy() {
         // run on destroy
     }

+ 13 - 3
app/src/main/java/io/legado/app/ui/book/read/page/delegate/ScrollPageDelegate.kt

@@ -9,7 +9,6 @@ import io.legado.app.model.ReadBook
 import io.legado.app.ui.book.read.page.ReadView
 import io.legado.app.ui.book.read.page.provider.ChapterProvider
 
-@Suppress("UnnecessaryVariable")
 class ScrollPageDelegate(readView: ReadView) : PageDelegate(readView) {
 
     // 滑动追踪的时间
@@ -22,6 +21,7 @@ class ScrollPageDelegate(readView: ReadView) : PageDelegate(readView) {
     var noAnim: Boolean = false
 
     override fun onAnimStart(animationSpeed: Int) {
+        readView.onScrollAnimStart()
         //惯性滚动
         fling(
             0, touchY.toInt(), 0, mVelocity.yVelocity.toInt(),
@@ -30,7 +30,7 @@ class ScrollPageDelegate(readView: ReadView) : PageDelegate(readView) {
     }
 
     override fun onAnimStop() {
-        // nothing
+        readView.onScrollAnimStop()
     }
 
     override fun onTouch(event: MotionEvent) {
@@ -79,7 +79,7 @@ class ScrollPageDelegate(readView: ReadView) : PageDelegate(readView) {
         val pointX = event.getX(event.pointerCount - 1)
         val pointY = event.getY(event.pointerCount - 1)
         if (isMoved) {
-            readView.setTouchPoint(pointX, pointY)
+            readView.setTouchPoint(pointX, pointY, false)
         }
         if (!isMoved) {
             val deltaX = (pointX - startX).toInt()
@@ -95,12 +95,22 @@ class ScrollPageDelegate(readView: ReadView) : PageDelegate(readView) {
         }
     }
 
+    override fun computeScroll() {
+        if (scroller.computeScrollOffset()) {
+            readView.setTouchPoint(scroller.currX.toFloat(), scroller.currY.toFloat(), false)
+        } else if (isStarted) {
+            onAnimStop()
+            stopScroll()
+        }
+    }
+
     override fun onDestroy() {
         super.onDestroy()
         mVelocity.recycle()
     }
 
     override fun abortAnim() {
+        readView.onScrollAnimStop()
         isStarted = false
         isMoved = false
         isRunning = false

+ 42 - 2
app/src/main/java/io/legado/app/ui/book/read/page/delegate/SimulationPageDelegate.kt

@@ -1,13 +1,27 @@
 package io.legado.app.ui.book.read.page.delegate
 
-import android.graphics.*
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.ColorMatrix
+import android.graphics.ColorMatrixColorFilter
+import android.graphics.Matrix
+import android.graphics.Paint
+import android.graphics.Path
+import android.graphics.PointF
+import android.graphics.Region
 import android.graphics.drawable.GradientDrawable
 import android.os.Build
 import android.view.MotionEvent
 import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.ui.book.read.page.ReadView
 import io.legado.app.ui.book.read.page.entities.PageDirection
-import kotlin.math.*
+import io.legado.app.utils.screenshot
+import kotlin.math.abs
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.hypot
+import kotlin.math.min
+import kotlin.math.sin
 
 @Suppress("DEPRECATION")
 class SimulationPageDelegate(readView: ReadView) : HorizontalPageDelegate(readView) {
@@ -86,6 +100,11 @@ class SimulationPageDelegate(readView: ReadView) : HorizontalPageDelegate(readVi
 
     private val mPaint: Paint = Paint().apply { style = Paint.Style.FILL }
 
+    private var curBitmap: Bitmap? = null
+    private var prevBitmap: Bitmap? = null
+    private var nextBitmap: Bitmap? = null
+    private var canvas: Canvas = Canvas()
+
     init {
         //设置颜色数组
         val color = intArrayOf(0x333333, -0x4fcccccd)
@@ -122,6 +141,22 @@ class SimulationPageDelegate(readView: ReadView) : HorizontalPageDelegate(readVi
         mFrontShadowDrawableHBT.gradientType = GradientDrawable.LINEAR_GRADIENT
     }
 
+    override fun setBitmap() {
+        when (mDirection) {
+            PageDirection.PREV -> {
+                prevBitmap = prevPage.screenshot(prevBitmap, canvas)
+                curBitmap = curPage.screenshot(curBitmap, canvas)
+            }
+
+            PageDirection.NEXT -> {
+                nextBitmap = nextPage.screenshot(nextBitmap, canvas)
+                curBitmap = curPage.screenshot(curBitmap, canvas)
+            }
+
+            else -> Unit
+        }
+    }
+
     override fun setViewSize(width: Int, height: Int) {
         super.setViewSize(width, height)
         mMaxLength = hypot(viewWidth.toDouble(), viewHeight.toDouble()).toFloat()
@@ -133,6 +168,7 @@ class SimulationPageDelegate(readView: ReadView) : HorizontalPageDelegate(readVi
             MotionEvent.ACTION_DOWN -> {
                 calcCornerXY(event.x, event.y)
             }
+
             MotionEvent.ACTION_MOVE -> {
                 if ((startY > viewHeight / 3 && startY < viewHeight * 2 / 3)
                     || mDirection == PageDirection.PREV
@@ -159,10 +195,12 @@ class SimulationPageDelegate(readView: ReadView) : HorizontalPageDelegate(readVi
                 } else {
                     calcCornerXY(viewWidth - startX, viewHeight.toFloat())
                 }
+
             PageDirection.NEXT ->
                 if (viewWidth / 2 > startX) {
                     calcCornerXY(viewWidth - startX, startY)
                 }
+
             else -> Unit
         }
     }
@@ -216,6 +254,7 @@ class SimulationPageDelegate(readView: ReadView) : HorizontalPageDelegate(readVi
                 drawCurrentPageShadow(canvas)
                 drawCurrentBackArea(canvas, curBitmap)
             }
+
             PageDirection.PREV -> {
                 calcPoints()
                 drawCurrentPageArea(canvas, prevBitmap)
@@ -223,6 +262,7 @@ class SimulationPageDelegate(readView: ReadView) : HorizontalPageDelegate(readVi
                 drawCurrentPageShadow(canvas)
                 drawCurrentBackArea(canvas, prevBitmap)
             }
+
             else -> return
         }
     }

+ 10 - 63
app/src/main/java/io/legado/app/ui/book/read/page/delegate/SlidePageDelegate.kt

@@ -1,51 +1,12 @@
 package io.legado.app.ui.book.read.page.delegate
 
 import android.graphics.Canvas
-import android.graphics.Matrix
-import android.graphics.Picture
-import android.os.Build
 import androidx.core.graphics.withTranslation
 import io.legado.app.ui.book.read.page.ReadView
 import io.legado.app.ui.book.read.page.entities.PageDirection
-import io.legado.app.utils.screenshot
 
 class SlidePageDelegate(readView: ReadView) : HorizontalPageDelegate(readView) {
 
-    private val bitmapMatrix = Matrix()
-
-    private lateinit var curPicture: Picture
-    private lateinit var prevPicture: Picture
-    private lateinit var nextPicture: Picture
-
-    private val atLeastApi23 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
-
-    init {
-        if (atLeastApi23) {
-            curPicture = Picture()
-            prevPicture = Picture()
-            nextPicture = Picture()
-        }
-    }
-
-    override fun setBitmap() {
-        if (!atLeastApi23) {
-            return super.setBitmap()
-        }
-        when (mDirection) {
-            PageDirection.PREV -> {
-                prevPage.screenshot(prevPicture)
-                curPage.screenshot(curPicture)
-            }
-
-            PageDirection.NEXT -> {
-                nextPage.screenshot(nextPicture)
-                curPage.screenshot(curPicture)
-            }
-
-            else -> Unit
-        }
-    }
-
     override fun onAnimStart(animationSpeed: Int) {
         val distanceX: Float
         when (mDirection) {
@@ -79,32 +40,18 @@ class SlidePageDelegate(readView: ReadView) : HorizontalPageDelegate(readView) {
         val distanceX = if (offsetX > 0) offsetX - viewWidth else offsetX + viewWidth
         if (!isRunning) return
         if (mDirection == PageDirection.PREV) {
-            if (!atLeastApi23) {
-                bitmapMatrix.setTranslate(distanceX + viewWidth, 0.toFloat())
-                curBitmap?.let { canvas.drawBitmap(it, bitmapMatrix, null) }
-                bitmapMatrix.setTranslate(distanceX, 0.toFloat())
-                prevBitmap?.let { canvas.drawBitmap(it, bitmapMatrix, null) }
-            } else {
-                canvas.withTranslation(distanceX + viewWidth) {
-                    drawPicture(curPicture)
-                }
-                canvas.withTranslation(distanceX) {
-                    drawPicture(prevPicture)
-                }
+            canvas.withTranslation(distanceX + viewWidth) {
+                curRecorder.draw(this)
+            }
+            canvas.withTranslation(distanceX) {
+                prevRecorder.draw(this)
             }
         } else if (mDirection == PageDirection.NEXT) {
-            if (!atLeastApi23) {
-                bitmapMatrix.setTranslate(distanceX, 0.toFloat())
-                nextBitmap?.let { canvas.drawBitmap(it, bitmapMatrix, null) }
-                bitmapMatrix.setTranslate(distanceX - viewWidth, 0.toFloat())
-                curBitmap?.let { canvas.drawBitmap(it, bitmapMatrix, null) }
-            } else {
-                canvas.withTranslation(distanceX) {
-                    drawPicture(nextPicture)
-                }
-                canvas.withTranslation(distanceX - viewWidth) {
-                    drawPicture(curPicture)
-                }
+            canvas.withTranslation(distanceX) {
+                nextRecorder.draw(this)
+            }
+            canvas.withTranslation(distanceX - viewWidth) {
+                curRecorder.draw(this)
             }
         }
     }

+ 21 - 4
app/src/main/java/io/legado/app/ui/book/read/page/entities/TextChapter.kt

@@ -4,6 +4,8 @@ package io.legado.app.ui.book.read.page.entities
 import androidx.annotation.Keep
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.ReplaceRule
+import io.legado.app.utils.fastBinarySearchBy
+import kotlin.math.abs
 import kotlin.math.min
 
 /**
@@ -85,12 +87,15 @@ data class TextChapter(
      * @return 已读长度
      */
     fun getReadLength(pageIndex: Int): Int {
+        return pages[min(pageIndex, lastIndex)].lines.first().chapterPosition
+        /*
         var length = 0
         val maxIndex = min(pageIndex, pages.size)
         for (index in 0 until maxIndex) {
             length += pages[index].charSize
         }
         return length
+        */
     }
 
     /**
@@ -185,18 +190,30 @@ data class TextChapter(
      * @return 根据索引位置获取所在页
      */
     fun getPageIndexByCharIndex(charIndex: Int): Int {
+        val index = pages.fastBinarySearchBy(charIndex) {
+            it.lines.first().chapterPosition
+        }
+        return if (index >= 0) {
+            index
+        } else {
+            abs(index + 1) - 1
+        }
+        /* 相当于以下实现
         var length = 0
-        pages.forEach {
-            length += it.charSize
+        for (i in pages.indices) {
+            val page = pages[i]
+            length += page.charSize
             if (length > charIndex) {
-                return it.index
+                return page.index
             }
         }
         return pages.lastIndex
+        */
     }
 
     fun clearSearchResult() {
-        pages.forEach { page ->
+        for (i in pages.indices) {
+            val page = pages[i]
             page.searchResult.forEach {
                 it.selected = false
                 it.isSearchResult = false

+ 68 - 2
app/src/main/java/io/legado/app/ui/book/read/page/entities/TextLine.kt

@@ -1,9 +1,18 @@
 package io.legado.app.ui.book.read.page.entities
 
+import android.graphics.Canvas
 import android.graphics.Paint.FontMetrics
 import androidx.annotation.Keep
+import io.legado.app.help.book.isImage
+import io.legado.app.help.config.ReadBookConfig
+import io.legado.app.model.ReadBook
+import io.legado.app.ui.book.read.page.ContentTextView
+import io.legado.app.ui.book.read.page.entities.TextPage.Companion.emptyTextPage
 import io.legado.app.ui.book.read.page.entities.column.BaseColumn
 import io.legado.app.ui.book.read.page.provider.ChapterProvider
+import io.legado.app.utils.canvasrecorder.CanvasRecorderFactory
+import io.legado.app.utils.canvasrecorder.recordIfNeededThenDraw
+import io.legado.app.utils.dpToPx
 
 /**
  * 行信息
@@ -22,8 +31,7 @@ data class TextLine(
     var pagePosition: Int = 0,
     val isTitle: Boolean = false,
     var isParagraphEnd: Boolean = false,
-    var isReadAloud: Boolean = false,
-    var isImage: Boolean = false
+    var isImage: Boolean = false,
 ) {
 
     val columns: List<BaseColumn> get() = textColumns
@@ -31,8 +39,20 @@ data class TextLine(
     val lineStart: Float get() = textColumns.firstOrNull()?.start ?: 0f
     val lineEnd: Float get() = textColumns.lastOrNull()?.end ?: 0f
     val chapterIndices: IntRange get() = chapterPosition..chapterPosition + charSize
+    val height: Float inline get() = lineBottom - lineTop
+    val canvasRecorder = CanvasRecorderFactory.create()
+    var isReadAloud: Boolean = false
+        set(value) {
+            if (field != value) {
+                invalidate()
+            }
+            field = value
+        }
+    var textPage: TextPage = emptyTextPage
+    var isLeftLine = true
 
     fun addColumn(column: BaseColumn) {
+        column.textLine = this
         textColumns.add(column)
     }
 
@@ -102,4 +122,50 @@ data class TextLine(
         return visible
     }
 
+    fun draw(view: ContentTextView, canvas: Canvas) {
+        canvasRecorder.recordIfNeededThenDraw(canvas, view.width, height.toInt()) {
+            drawTextLine(view, this)
+        }
+    }
+
+    private fun drawTextLine(view: ContentTextView, canvas: Canvas) {
+        for (i in columns.indices) {
+            columns[i].draw(view, canvas)
+        }
+        if (ReadBookConfig.underline && !isImage && ReadBook.book?.isImage != true) {
+            drawUnderline(canvas)
+        }
+    }
+
+    /**
+     * 绘制下划线
+     */
+    private fun drawUnderline(canvas: Canvas) {
+        val lineY = height - 1.dpToPx()
+        canvas.drawLine(
+            lineStart + indentWidth,
+            lineY,
+            lineEnd,
+            lineY,
+            ChapterProvider.contentPaint
+        )
+    }
+
+    fun invalidate() {
+        invalidateSelf()
+        textPage.invalidate()
+    }
+
+    fun invalidateSelf() {
+        canvasRecorder.invalidate()
+    }
+
+    fun recycleRecorder() {
+        canvasRecorder.recycle()
+    }
+
+    companion object {
+        val emptyTextLine = TextLine()
+    }
+
 }

+ 67 - 14
app/src/main/java/io/legado/app/ui/book/read/page/entities/TextPage.kt

@@ -1,13 +1,18 @@
 package io.legado.app.ui.book.read.page.entities
 
+import android.graphics.Canvas
 import android.text.Layout
 import android.text.StaticLayout
 import androidx.annotation.Keep
+import androidx.core.graphics.withTranslation
 import io.legado.app.R
 import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.model.ReadBook
+import io.legado.app.ui.book.read.page.ContentTextView
 import io.legado.app.ui.book.read.page.entities.column.TextColumn
 import io.legado.app.ui.book.read.page.provider.ChapterProvider
+import io.legado.app.utils.canvasrecorder.CanvasRecorderFactory
+import io.legado.app.utils.canvasrecorder.recordIfNeeded
 import splitties.init.appCtx
 import java.text.DecimalFormat
 import kotlin.math.min
@@ -31,6 +36,7 @@ data class TextPage(
 
     companion object {
         val readProgressFormatter = DecimalFormat("0.0%")
+        val emptyTextPage = TextPage()
     }
 
     val lines: List<TextLine> get() = textLines
@@ -38,25 +44,30 @@ data class TextPage(
     val charSize: Int get() = text.length.coerceAtLeast(1)
     val searchResult = hashSetOf<TextColumn>()
     var isMsgPage: Boolean = false
+    var canvasRecorder = CanvasRecorderFactory.create(true)
+    var doublePage = false
+    var paddingTop = 0
 
     val paragraphs by lazy {
         paragraphsInternal
     }
 
-    val paragraphsInternal: ArrayList<TextParagraph> get() {
-        val paragraphs = arrayListOf<TextParagraph>()
-        val lines = textLines.filter { it.paragraphNum > 0 }
-        val offset = lines.first().paragraphNum - 1
-        lines.forEach { line ->
-            if (paragraphs.lastIndex < line.paragraphNum - offset - 1) {
-                paragraphs.add(TextParagraph(0))
+    val paragraphsInternal: ArrayList<TextParagraph>
+        get() {
+            val paragraphs = arrayListOf<TextParagraph>()
+            val lines = textLines.filter { it.paragraphNum > 0 }
+            val offset = lines.first().paragraphNum - 1
+            lines.forEach { line ->
+                if (paragraphs.lastIndex < line.paragraphNum - offset - 1) {
+                    paragraphs.add(TextParagraph(0))
+                }
+                paragraphs[line.paragraphNum - offset - 1].textLines.add(line)
             }
-            paragraphs[line.paragraphNum - offset - 1].textLines.add(line)
+            return paragraphs
         }
-        return paragraphs
-    }
 
     fun addLine(line: TextLine) {
+        line.textPage = this
         textLines.add(line)
     }
 
@@ -147,9 +158,10 @@ data class TextPage(
                     )
                     x = x1
                 }
-                textLines.add(textLine)
+                addLine(textLine)
             }
             height = ChapterProvider.visibleHeight.toFloat()
+            invalidate()
         }
         return this
     }
@@ -158,8 +170,8 @@ data class TextPage(
      * 移除朗读标志
      */
     fun removePageAloudSpan(): TextPage {
-        textLines.forEach { textLine ->
-            textLine.isReadAloud = false
+        for (i in textLines.indices) {
+            textLines[i].isReadAloud = false
         }
         return this
     }
@@ -171,7 +183,8 @@ data class TextPage(
     fun upPageAloudSpan(aloudSpanStart: Int) {
         removePageAloudSpan()
         var lineStart = 0
-        for ((index, textLine) in textLines.withIndex()) {
+        for (index in textLines.indices) {
+            val textLine = textLines[index]
             val lineLength = textLine.text.length + if (textLine.isParagraphEnd) 1 else 0
             if (aloudSpanStart > lineStart && aloudSpanStart < lineStart + lineLength) {
                 for (i in index - 1 downTo 0) {
@@ -254,6 +267,46 @@ data class TextPage(
         return null
     }
 
+    fun draw(view: ContentTextView, canvas: Canvas, relativeOffset: Float) {
+        render(view)
+        canvas.withTranslation(0f, relativeOffset + paddingTop) {
+            canvasRecorder.draw(this)
+        }
+    }
+
+    private fun drawPage(view: ContentTextView, canvas: Canvas) {
+        for (i in lines.indices) {
+            val line = lines[i]
+            canvas.withTranslation(0f, line.lineTop - paddingTop) {
+                line.draw(view, this)
+            }
+        }
+    }
+
+    fun render(view: ContentTextView): Boolean {
+        return canvasRecorder.recordIfNeeded(view.width, height.toInt()) {
+            drawPage(view, this)
+        }
+    }
+
+    fun invalidate() {
+        canvasRecorder.invalidate()
+    }
+
+    fun invalidateAll() {
+        for (i in lines.indices) {
+            lines[i].invalidateSelf()
+        }
+        invalidate()
+    }
+
+    fun recycleRecorders() {
+        canvasRecorder.recycle()
+        for (i in lines.indices) {
+            lines[i].recycleRecorder()
+        }
+    }
+
     fun hasImageOrEmpty(): Boolean {
         return textLines.any { it.isImage } || textLines.isEmpty()
     }

+ 7 - 0
app/src/main/java/io/legado/app/ui/book/read/page/entities/column/BaseColumn.kt

@@ -1,11 +1,18 @@
 package io.legado.app.ui.book.read.page.entities.column
 
+import android.graphics.Canvas
+import io.legado.app.ui.book.read.page.ContentTextView
+import io.legado.app.ui.book.read.page.entities.TextLine
+
 /**
  * 列基类
  */
 interface BaseColumn {
     var start: Float
     var end: Float
+    var textLine: TextLine
+
+    fun draw(view: ContentTextView, canvas: Canvas)
 
     fun isTouch(x: Float): Boolean {
         return x > start && x < end

+ 11 - 2
app/src/main/java/io/legado/app/ui/book/read/page/entities/column/ButtonColumn.kt

@@ -1,6 +1,10 @@
 package io.legado.app.ui.book.read.page.entities.column
 
+import android.graphics.Canvas
 import androidx.annotation.Keep
+import io.legado.app.ui.book.read.page.ContentTextView
+import io.legado.app.ui.book.read.page.entities.TextLine
+import io.legado.app.ui.book.read.page.entities.TextLine.Companion.emptyTextLine
 
 
 /**
@@ -9,5 +13,10 @@ import androidx.annotation.Keep
 @Keep
 data class ButtonColumn(
     override var start: Float,
-    override var end: Float
-) : BaseColumn
+    override var end: Float,
+) : BaseColumn {
+    override var textLine: TextLine = emptyTextLine
+    override fun draw(view: ContentTextView, canvas: Canvas) {
+
+    }
+}

+ 40 - 1
app/src/main/java/io/legado/app/ui/book/read/page/entities/column/ImageColumn.kt

@@ -1,6 +1,15 @@
 package io.legado.app.ui.book.read.page.entities.column
 
+import android.graphics.Canvas
+import android.graphics.RectF
 import androidx.annotation.Keep
+import io.legado.app.model.ImageProvider
+import io.legado.app.model.ReadBook
+import io.legado.app.ui.book.read.page.ContentTextView
+import io.legado.app.ui.book.read.page.entities.TextLine
+import io.legado.app.ui.book.read.page.entities.TextLine.Companion.emptyTextLine
+import io.legado.app.utils.toastOnUi
+import splitties.init.appCtx
 
 /**
  * 图片列
@@ -10,4 +19,34 @@ data class ImageColumn(
     override var start: Float,
     override var end: Float,
     var src: String
-) : BaseColumn
+) : BaseColumn {
+
+    override var textLine: TextLine = emptyTextLine
+    override fun draw(view: ContentTextView, canvas: Canvas) {
+        val book = ReadBook.book ?: return
+
+        val height = textLine.height
+
+        val bitmap = ImageProvider.getImage(
+            book,
+            src,
+            (end - start).toInt(),
+            height.toInt()
+        )
+
+        val rectF = if (textLine.isImage) {
+            RectF(start, 0f, end, height)
+        } else {
+            /*以宽度为基准保持图片的原始比例叠加,当div为负数时,允许高度比字符更高*/
+            val h = (end - start) / bitmap.width * bitmap.height
+            val div = (height - h) / 2
+            RectF(start, div, end, height - div)
+        }
+        kotlin.runCatching {
+            canvas.drawBitmap(bitmap, null, rectF, view.imagePaint)
+        }.onFailure { e ->
+            appCtx.toastOnUi(e.localizedMessage)
+        }
+    }
+
+}

+ 13 - 0
app/src/main/java/io/legado/app/ui/book/read/page/entities/column/ReviewColumn.kt

@@ -4,6 +4,9 @@ import android.graphics.Canvas
 import android.graphics.Paint
 import android.graphics.Path
 import androidx.annotation.Keep
+import io.legado.app.ui.book.read.page.ContentTextView
+import io.legado.app.ui.book.read.page.entities.TextLine
+import io.legado.app.ui.book.read.page.entities.TextLine.Companion.emptyTextLine
 import io.legado.app.ui.book.read.page.provider.ChapterProvider
 
 /**
@@ -16,6 +19,16 @@ data class ReviewColumn(
     val count: Int = 0
 ) : BaseColumn {
 
+    override var textLine: TextLine = emptyTextLine
+    override fun draw(view: ContentTextView, canvas: Canvas) {
+        val textPaint = if (textLine.isTitle) {
+            ChapterProvider.titlePaint
+        } else {
+            ChapterProvider.contentPaint
+        }
+        drawToCanvas(canvas, textLine.lineBase, textPaint.textSize)
+    }
+
     val countText by lazy {
         if (count > 999) {
             return@lazy "999"

+ 46 - 2
app/src/main/java/io/legado/app/ui/book/read/page/entities/column/TextColumn.kt

@@ -1,6 +1,13 @@
 package io.legado.app.ui.book.read.page.entities.column
 
+import android.graphics.Canvas
 import androidx.annotation.Keep
+import io.legado.app.help.config.ReadBookConfig
+import io.legado.app.lib.theme.ThemeStore
+import io.legado.app.ui.book.read.page.ContentTextView
+import io.legado.app.ui.book.read.page.entities.TextLine
+import io.legado.app.ui.book.read.page.entities.TextLine.Companion.emptyTextLine
+import io.legado.app.ui.book.read.page.provider.ChapterProvider
 
 /**
  * 文字列
@@ -10,6 +17,43 @@ data class TextColumn(
     override var start: Float,
     override var end: Float,
     val charData: String,
-    var selected: Boolean = false,
+) : BaseColumn {
+
+    override var textLine: TextLine = emptyTextLine
+
+    var selected: Boolean = false
+        set(value) {
+            if (field != value) {
+                textLine.invalidate()
+            }
+            field = value
+        }
     var isSearchResult: Boolean = false
-) : BaseColumn
+        set(value) {
+            if (field != value) {
+                textLine.invalidate()
+            }
+            field = value
+        }
+
+    override fun draw(view: ContentTextView, canvas: Canvas) {
+        val textPaint = if (textLine.isTitle) {
+            ChapterProvider.titlePaint
+        } else {
+            ChapterProvider.contentPaint
+        }
+        if (textLine.isReadAloud || isSearchResult) {
+            synchronized(textPaint) {
+                textPaint.color = ThemeStore.accentColor
+                canvas.drawText(charData, start, textLine.lineBase - textLine.lineTop, textPaint)
+                textPaint.color = ReadBookConfig.textColor
+            }
+        } else {
+            canvas.drawText(charData, start, textLine.lineBase - textLine.lineTop, textPaint)
+        }
+        if (selected) {
+            canvas.drawRect(start, 0f, end, textLine.height, view.selectedPaint)
+        }
+    }
+
+}

+ 161 - 111
app/src/main/java/io/legado/app/ui/book/read/page/provider/ChapterProvider.kt

@@ -1,7 +1,7 @@
 package io.legado.app.ui.book.read.page.provider
 
-import android.graphics.Paint
 import android.graphics.Paint.FontMetrics
+import android.graphics.RectF
 import android.graphics.Typeface
 import android.net.Uri
 import android.os.Build
@@ -25,13 +25,13 @@ import io.legado.app.ui.book.read.page.entities.column.ReviewColumn
 import io.legado.app.ui.book.read.page.entities.column.TextColumn
 import io.legado.app.utils.RealPathUtil
 import io.legado.app.utils.dpToPx
+import io.legado.app.utils.fastSum
 import io.legado.app.utils.isContentScheme
 import io.legado.app.utils.isPad
 import io.legado.app.utils.postEvent
 import io.legado.app.utils.spToPx
 import io.legado.app.utils.splitNotBlank
 import io.legado.app.utils.textHeight
-import io.legado.app.utils.toStringArray
 import splitties.init.appCtx
 import java.util.LinkedList
 import java.util.Locale
@@ -47,6 +47,8 @@ object ChapterProvider {
     //用于评论按钮的替换
     private const val reviewChar = "▨"
 
+    private const val indentChar = " "
+
     @JvmStatic
     var viewWidth = 0
         private set
@@ -104,7 +106,7 @@ object ChapterProvider {
     private var indentCharWidth = 0f
 
     @JvmStatic
-    private var titlePaintTextHeight = 0f
+    var titlePaintTextHeight = 0f
 
     @JvmStatic
     var contentPaintTextHeight = 0f
@@ -132,6 +134,9 @@ object ChapterProvider {
     var doublePage = false
         private set
 
+    @JvmStatic
+    var visibleRect = RectF()
+
     init {
         upStyle()
     }
@@ -171,6 +176,8 @@ object ChapterProvider {
                     durY = it.second
                 }
             }
+            textPages.last().lines.last().isParagraphEnd = true
+            stringBuilder.append("\n")
             durY += titleBottomSpacing
         }
         contents.forEach { content ->
@@ -224,10 +231,19 @@ object ChapterProvider {
                             durY = it.second
                         }
                     }
-                    durY = setTypeImage(
-                        book, matcher.group(1)!!,
-                        absStartX, durY, textPages, stringBuilder, book.getImageStyle()
-                    )
+                    setTypeImage(
+                        book,
+                        matcher.group(1)!!,
+                        absStartX,
+                        durY,
+                        textPages,
+                        contentPaintTextHeight,
+                        stringBuilder,
+                        book.getImageStyle()
+                    ).let {
+                        absStartX = it.first
+                        durY = it.second
+                    }
                     start = matcher.end()
                 }
                 if (start < content.length) {
@@ -248,15 +264,26 @@ object ChapterProvider {
                     }
                 }
             }
+            textPages.last().lines.last().isParagraphEnd = true
+            stringBuilder.append("\n")
+        }
+        val textPage = textPages.last()
+        val endPadding = 20.dpToPx()
+        val durYPadding = durY + endPadding
+        if (textPage.height < durYPadding) {
+            textPage.height = durYPadding
+        } else {
+            textPage.height += endPadding
         }
-        textPages.last().height = durY + 20.dpToPx()
-        textPages.last().text = stringBuilder.toString()
+        textPage.text = stringBuilder.toString()
         textPages.forEachIndexed { index, item ->
             item.index = index
             item.pageSize = textPages.size
             item.chapterIndex = bookChapter.index
             item.chapterSize = chapterSize
             item.title = displayTitle
+            item.doublePage = doublePage
+            item.paddingTop = paddingTop
             item.upLinesPosition()
         }
 
@@ -280,15 +307,19 @@ object ChapterProvider {
         x: Int,
         y: Float,
         textPages: ArrayList<TextPage>,
+        textHeight: Float,
         stringBuilder: StringBuilder,
         imageStyle: String?,
-    ): Float {
+    ): Pair<Int, Float> {
+        var absStartX = x
         var durY = y
         val size = ImageProvider.getImageSize(book, src, ReadBook.bookSource)
         if (size.width > 0 && size.height > 0) {
             if (durY > visibleHeight) {
                 val textPage = textPages.last()
-                textPage.height = durY
+                if (textPage.height < durY) {
+                    textPage.height = durY
+                }
                 textPage.text = stringBuilder.toString().ifEmpty { "本页无文字内容" }
                 stringBuilder.clear()
                 textPages.add(TextPage())
@@ -313,10 +344,23 @@ object ChapterProvider {
                     }
                     if (durY + height > visibleHeight) {
                         val textPage = textPages.last()
-                        textPage.height = durY
-                        textPage.text = stringBuilder.toString().ifEmpty { "本页无文字内容" }
-                        stringBuilder.clear()
-                        textPages.add(TextPage())
+                        if (doublePage && absStartX < viewWidth / 2) {
+                            //当前页面左列结束
+                            textPage.leftLineSize = textPage.lineSize
+                            absStartX = viewWidth / 2 + paddingLeft
+                        } else {
+                            //当前页面结束
+                            if (textPage.leftLineSize == 0) {
+                                textPage.leftLineSize = textPage.lineSize
+                            }
+                            textPage.text = stringBuilder.toString().ifEmpty { "本页无文字内容" }
+                            stringBuilder.clear()
+                            textPages.add(TextPage())
+                        }
+                        // 双页的 durY 不正确,可能会小于实际高度
+                        if (textPage.height < durY) {
+                            textPage.height = durY
+                        }
                         durY = 0f
                     }
                 }
@@ -334,9 +378,11 @@ object ChapterProvider {
             textLine.addColumn(
                 ImageColumn(start = x + start, end = x + end, src = src)
             )
+            calcTextLinePosition(textPages, textLine, stringBuilder.length)
+            stringBuilder.append(" ") // 确保翻页时索引计算正确
             textPages.last().addLine(textLine)
         }
-        return durY + paragraphSpacing / 10f
+        return absStartX to durY + textHeight * paragraphSpacing / 10f
     }
 
     /**
@@ -358,14 +404,11 @@ object ChapterProvider {
         srcList: LinkedList<String>? = null
     ): Pair<Int, Float> {
         var absStartX = x
-        val widthsArray = FloatArray(text.length)
         val layout = if (ReadBookConfig.useZhLayout) {
-            ZhLayout(text, textPaint, visibleWidth, widthsArray)
+            ZhLayout(text, textPaint, visibleWidth)
         } else {
-            textPaint.getTextWidths(text, widthsArray)
             StaticLayout(text, textPaint, visibleWidth, Layout.Alignment.ALIGN_NORMAL, 0f, 0f, true)
         }
-        val widthsList = widthsArray.asList()
         var durY = when {
             //标题y轴居中
             emptyContent && textPages.size == 1 -> {
@@ -398,6 +441,7 @@ object ChapterProvider {
             if (durY + textHeight > visibleHeight) {
                 val textPage = textPages.last()
                 if (doublePage && absStartX < viewWidth / 2) {
+                    //当前页面左列结束
                     textPage.leftLineSize = textPage.lineSize
                     absStartX = viewWidth / 2 + paddingLeft
                 } else {
@@ -406,33 +450,34 @@ object ChapterProvider {
                         textPage.leftLineSize = textPage.lineSize
                     }
                     textPage.text = stringBuilder.toString()
-                    textPage.height = durY
                     //新建页面
                     textPages.add(TextPage())
                     stringBuilder.clear()
                     absStartX = paddingLeft
                 }
+                if (textPage.height < durY) {
+                    textPage.height = durY
+                }
                 durY = 0f
             }
             val lineStart = layout.getLineStart(lineIndex)
             val lineEnd = layout.getLineEnd(lineIndex)
-            val words = text.substring(lineStart, lineEnd)
-            val textWidths = widthsList.subList(lineStart, lineEnd)
-            val desiredWidth = textWidths.sum()
+            val lineText = text.substring(lineStart, lineEnd)
+            val (words, widths) = measureTextSplit(lineText, textPaint)
+            val desiredWidth = widths.fastSum()
             when {
                 lineIndex == 0 && layout.lineCount > 1 && !isTitle -> {
                     //第一行 非标题
-                    textLine.text = words
+                    textLine.text = lineText
                     addCharsToLineFirst(
                         book, absStartX, textLine, words,
-                        textPaint, desiredWidth, textWidths, srcList
+                        desiredWidth, widths, srcList
                     )
                 }
 
                 lineIndex == layout.lineCount - 1 -> {
                     //最后一行
-                    textLine.text = words
-                    textLine.isParagraphEnd = true
+                    textLine.text = lineText
                     //标题x轴居中
                     val startX = if (
                         isTitle &&
@@ -443,8 +488,8 @@ object ChapterProvider {
                         0f
                     }
                     addCharsToLineNatural(
-                        book, absStartX, textLine, words, textPaint,
-                        startX, !isTitle && lineIndex == 0, textWidths, srcList
+                        book, absStartX, textLine, words,
+                        startX, !isTitle && lineIndex == 0, widths, srcList
                     )
                 }
 
@@ -457,45 +502,55 @@ object ChapterProvider {
                         val startX = (visibleWidth - desiredWidth) / 2
                         addCharsToLineNatural(
                             book, absStartX, textLine, words,
-                            textPaint, startX, false, textWidths, srcList
+                            startX, false, widths, srcList
                         )
                     } else {
                         //中间行
-                        textLine.text = words
+                        textLine.text = lineText
                         addCharsToLineMiddle(
                             book, absStartX, textLine, words,
-                            textPaint, desiredWidth, 0f, textWidths, srcList
+                            desiredWidth, 0f, widths, srcList
                         )
                     }
                 }
             }
-            val sbLength = stringBuilder.length
-            stringBuilder.append(words)
-            if (textLine.isParagraphEnd) {
-                stringBuilder.append("\n")
-            }
-            val lastLine = textPages.last().lines.lastOrNull { it.paragraphNum > 0 }
-                ?: textPages.getOrNull(textPages.lastIndex - 1)?.lines?.lastOrNull { it.paragraphNum > 0 }
-            val paragraphNum = when {
-                lastLine == null -> 1
-                lastLine.isParagraphEnd -> lastLine.paragraphNum + 1
-                else -> lastLine.paragraphNum
+            if (doublePage) {
+                textLine.isLeftLine = absStartX < viewWidth / 2
             }
-            textLine.paragraphNum = paragraphNum
-            textLine.chapterPosition =
-                (textPages.getOrNull(textPages.lastIndex - 1)?.lines?.lastOrNull()?.run {
-                    chapterPosition + charSize + if (isParagraphEnd) 1 else 0
-                } ?: 0) + sbLength
-            textLine.pagePosition = sbLength
-            textPages.last().addLine(textLine)
+            calcTextLinePosition(textPages, textLine, stringBuilder.length)
+            stringBuilder.append(lineText)
             textLine.upTopBottom(durY, textHeight, fontMetrics)
+            val textPage = textPages.last()
+            textPage.addLine(textLine)
             durY += textHeight * lineSpacingExtra
-            textPages.last().height = durY
+            if (textPage.height < durY) {
+                textPage.height = durY
+            }
         }
         durY += textHeight * paragraphSpacing / 10f
         return Pair(absStartX, durY)
     }
 
+    private fun calcTextLinePosition(
+        textPages: ArrayList<TextPage>,
+        textLine: TextLine,
+        sbLength: Int
+    ) {
+        val lastLine = textPages.last().lines.lastOrNull { it.paragraphNum > 0 }
+            ?: textPages.getOrNull(textPages.lastIndex - 1)?.lines?.lastOrNull { it.paragraphNum > 0 }
+        val paragraphNum = when {
+            lastLine == null -> 1
+            lastLine.isParagraphEnd -> lastLine.paragraphNum + 1
+            else -> lastLine.paragraphNum
+        }
+        textLine.paragraphNum = paragraphNum
+        textLine.chapterPosition =
+            (textPages.getOrNull(textPages.lastIndex - 1)?.lines?.lastOrNull()?.run {
+                chapterPosition + charSize + if (isParagraphEnd) 1 else 0
+            } ?: 0) + sbLength
+        textLine.pagePosition = sbLength
+    }
+
     /**
      * 有缩进,两端对齐
      */
@@ -503,8 +558,7 @@ object ChapterProvider {
         book: Book,
         absStartX: Int,
         textLine: TextLine,
-        text: String,
-        textPaint: TextPaint,
+        words: List<String>,
         /**自然排版长度**/
         desiredWidth: Float,
         textWidths: List<Float>,
@@ -513,17 +567,17 @@ object ChapterProvider {
         var x = 0f
         if (!ReadBookConfig.textFullJustify) {
             addCharsToLineNatural(
-                book, absStartX, textLine, text, textPaint,
+                book, absStartX, textLine, words,
                 x, true, textWidths, srcList
             )
             return
         }
         val bodyIndent = ReadBookConfig.paragraphIndent
-        for (char in bodyIndent.toStringArray()) {
+        for (i in bodyIndent.indices) {
             val x1 = x + indentCharWidth
             textLine.addColumn(
                 TextColumn(
-                    charData = char,
+                    charData = indentChar,
                     start = absStartX + x,
                     end = absStartX + x1
                 )
@@ -531,12 +585,12 @@ object ChapterProvider {
             x = x1
             textLine.indentWidth = x
         }
-        if (text.length > bodyIndent.length) {
-            val text1 = text.substring(bodyIndent.length, text.length)
+        if (words.size > bodyIndent.length) {
+            val text1 = words.subList(bodyIndent.length, words.size)
             val textWidths1 = textWidths.subList(bodyIndent.length, textWidths.size)
             addCharsToLineMiddle(
                 book, absStartX, textLine, text1,
-                textPaint, desiredWidth, x, textWidths1, srcList
+                desiredWidth, x, textWidths1, srcList
             )
         }
     }
@@ -548,8 +602,7 @@ object ChapterProvider {
         book: Book,
         absStartX: Int,
         textLine: TextLine,
-        text: String,
-        textPaint: TextPaint,
+        words: List<String>,
         /**自然排版长度**/
         desiredWidth: Float,
         /**起始x坐标**/
@@ -559,19 +612,19 @@ object ChapterProvider {
     ) {
         if (!ReadBookConfig.textFullJustify) {
             addCharsToLineNatural(
-                book, absStartX, textLine, text, textPaint,
+                book, absStartX, textLine, words,
                 startX, false, textWidths, srcList
             )
             return
         }
         val residualWidth = visibleWidth - desiredWidth
-        val spaceSize = text.count { it == ' ' }
-        val (words, widths) = getStringArrayAndTextWidths(text, textWidths, textPaint)
+        val spaceSize = words.count { it == " " }
         if (spaceSize > 1) {
             val d = residualWidth / spaceSize
             var x = startX
-            words.forEachIndexed { index, char ->
-                val cw = widths[index]
+            for (index in words.indices) {
+                val char = words[index]
+                val cw = textWidths[index]
                 val x1 = if (char == " ") {
                     if (index != words.lastIndex) (x + cw + d) else (x + cw)
                 } else {
@@ -587,8 +640,9 @@ object ChapterProvider {
             val gapCount: Int = words.lastIndex
             val d = residualWidth / gapCount
             var x = startX
-            words.forEachIndexed { index, char ->
-                val cw = widths[index]
+            for (index in words.indices) {
+                val char = words[index]
+                val cw = textWidths[index]
                 val x1 = if (index != words.lastIndex) (x + cw + d) else (x + cw)
                 addCharToLine(
                     book, absStartX, textLine, char,
@@ -607,8 +661,7 @@ object ChapterProvider {
         book: Book,
         absStartX: Int,
         textLine: TextLine,
-        text: String,
-        textPaint: TextPaint,
+        words: List<String>,
         startX: Float,
         hasIndent: Boolean,
         textWidths: List<Float>,
@@ -616,9 +669,9 @@ object ChapterProvider {
     ) {
         val indentLength = ReadBookConfig.paragraphIndent.length
         var x = startX
-        val (words, widths) = getStringArrayAndTextWidths(text, textWidths, textPaint)
-        words.forEachIndexed { index, char ->
-            val cw = widths[index]
+        for (index in words.indices) {
+            val char = words[index]
+            val cw = textWidths[index]
             val x1 = x + cw
             addCharToLine(book, absStartX, textLine, char, x, x1, index + 1 == words.size, srcList)
             x = x1
@@ -629,39 +682,6 @@ object ChapterProvider {
         exceed(absStartX, textLine, words)
     }
 
-    fun getStringArrayAndTextWidths(
-        text: String,
-        textWidths: List<Float>,
-        textPaint: TextPaint
-    ): Pair<List<String>, List<Float>> {
-        val charArray = text.toCharArray()
-        val strList = ArrayList<String>(text.length)
-        val textWidthList = ArrayList<Float>(text.length)
-        val lastIndex = charArray.lastIndex
-        var ca: CharArray? = null
-        for (i in textWidths.indices) {
-            if (charArray[i].isLowSurrogate()) {
-                continue
-            }
-            val char = if (i + 1 <= lastIndex && charArray[i + 1].isLowSurrogate()) {
-                if (ca == null) ca = CharArray(2)
-                System.arraycopy(charArray, i, ca, 0, 2)
-                String(ca)
-            } else {
-                charArray[i].toString()
-            }
-            val w = textWidths[i]
-            if (w == 0f && textWidthList.size > 0) {
-                textWidthList[textWidthList.lastIndex] = textPaint.measureText(strList.last())
-                textWidthList.add(textPaint.measureText(char))
-            } else {
-                textWidthList.add(w)
-            }
-            strList.add(char)
-        }
-        return strList to textWidthList
-    }
-
     /**
      * 添加字符
      */
@@ -723,6 +743,28 @@ object ChapterProvider {
         }
     }
 
+    fun measureTextSplit(
+        text: String,
+        paint: TextPaint
+    ): Pair<ArrayList<String>, ArrayList<Float>> {
+        val length = text.length
+        val widthsArray = FloatArray(length)
+        paint.getTextWidths(text, widthsArray)
+        val clusterCount = widthsArray.count { it > 0f }
+        val widths = ArrayList<Float>(clusterCount)
+        val stringList = ArrayList<String>(clusterCount)
+        var i = 0
+        while (i < length) {
+            val clusterBaseIndex = i++
+            widths.add(widthsArray[clusterBaseIndex])
+            while (i < length && widthsArray[i] == 0f) {
+                i++
+            }
+            stringList.add(text.substring(clusterBaseIndex, i))
+        }
+        return stringList to widths
+    }
+
     /**
      * 更新样式
      */
@@ -731,9 +773,9 @@ object ChapterProvider {
         getPaints(typeface).let {
             titlePaint = it.first
             contentPaint = it.second
-            reviewPaint.color = contentPaint.color
-            reviewPaint.textSize = contentPaint.textSize * 0.45f
-            reviewPaint.textAlign = Paint.Align.CENTER
+//            reviewPaint.color = contentPaint.color
+//            reviewPaint.textSize = contentPaint.textSize * 0.45f
+//            reviewPaint.textAlign = Paint.Align.CENTER
         }
         //间距
         lineSpacingExtra = ReadBookConfig.lineSpacingExtra / 10f
@@ -825,7 +867,7 @@ object ChapterProvider {
             viewWidth = width
             viewHeight = height
             upLayout()
-            postEvent(EventBus.UP_CONFIG, true)
+            postEvent(EventBus.UP_CONFIG, arrayOf(5))
         }
     }
 
@@ -862,6 +904,14 @@ object ChapterProvider {
             visibleRight = viewWidth - paddingRight
             visibleBottom = paddingTop + visibleHeight
         }
+
+        visibleRect.set(
+            paddingLeft.toFloat(),
+            paddingTop.toFloat(),
+            visibleRight.toFloat(),
+            visibleBottom.toFloat()
+        )
+
     }
 
 }

+ 143 - 0
app/src/main/java/io/legado/app/ui/book/read/page/provider/TextMeasure.kt

@@ -0,0 +1,143 @@
+package io.legado.app.ui.book.read.page.provider
+
+import android.text.TextPaint
+import android.util.SparseArray
+import androidx.core.util.getOrDefault
+import kotlin.math.ceil
+
+class TextMeasure(private var paint: TextPaint) {
+
+    private var chineseCommonWidth = paint.measureText("一")
+    private val asciiWidths = FloatArray(128) { -1f }
+    private val codePointWidths = SparseArray<Float>()
+
+    private fun measureCodePoint(codePoint: Int): Float {
+        if (codePoint < 128) {
+            return asciiWidths[codePoint]
+        }
+        // 中文 Unicode 范围 U+4E00 - U+9FA5
+        if (codePoint in 19968 .. 40869) {
+            return chineseCommonWidth
+        }
+        return codePointWidths.getOrDefault(codePoint, -1f)
+    }
+
+    private fun measureCodePoints(codePoints: List<Int>) {
+        val charArray = String(codePoints.toIntArray(), 0, codePoints.size).toCharArray()
+        val widths = FloatArray(charArray.size)
+        paint.getTextWidths(charArray, 0, charArray.size, widths)
+        val widthsList = ArrayList<Float>(charArray.size)
+        val buf = IntArray(1)
+        for (i in charArray.indices) {
+            if (charArray[i].isLowSurrogate()) continue
+            val width = ceil(widths[i])
+            widthsList.add(width)
+            // 可能需要检查是否不可见字符
+            if (width == 0f && widthsList.size > 1) {
+                val lastIndex = widthsList.lastIndex
+                buf[0] = codePoints[lastIndex - 1]
+                widthsList[lastIndex - 1] = paint.measureText(String(buf, 0, 1))
+                buf[0] = codePoints[lastIndex]
+                widthsList[lastIndex] = paint.measureText(String(buf, 0, 1))
+            }
+        }
+        for (i in codePoints.indices) {
+            val codePoint = codePoints[i]
+            val width = widthsList[i]
+            if (codePoint < 128) {
+                asciiWidths[codePoint] = width
+            } else {
+                codePointWidths[codePoint] = width
+            }
+        }
+    }
+
+
+    fun measureTextSplit(text: String): Pair<ArrayList<String>, ArrayList<Float>> {
+        var needMeasureCodePoints: HashSet<Int>? = null
+        val codePoints = text.toCodePoints()
+        val size = codePoints.size
+        val widths = ArrayList<Float>(size)
+        val stringList = ArrayList<String>(size)
+        val buf = IntArray(1)
+        for (i in codePoints.indices) {
+            val codePoint = codePoints[i]
+            val width = measureCodePoint(codePoint)
+            widths.add(width)
+            if (width == -1f) {
+                if (needMeasureCodePoints == null) {
+                    needMeasureCodePoints = hashSetOf()
+                }
+                needMeasureCodePoints.add(codePoint)
+            }
+            buf[0] = codePoint
+            stringList.add(String(buf, 0, 1))
+        }
+        if (!needMeasureCodePoints.isNullOrEmpty()) {
+            measureCodePoints(needMeasureCodePoints.toList())
+            for (i in codePoints.indices) {
+                if (widths[i] == -1f) {
+                    widths[i] = measureCodePoint(codePoints[i])
+                }
+            }
+        }
+        return stringList to widths
+    }
+
+    fun measureText(text: String): Float {
+        var textWidth = 0f
+        var needMeasureCodePoints: ArrayList<Int>? = null
+        val codePoints = text.toCodePoints()
+        for (i in codePoints.indices) {
+            val codePoint = codePoints[i]
+            val width = measureCodePoint(codePoint)
+            if (width == -1f) {
+                if (needMeasureCodePoints == null) {
+                    needMeasureCodePoints = ArrayList()
+                }
+                needMeasureCodePoints.add(codePoint)
+                continue
+            }
+            textWidth += width
+        }
+        if (!needMeasureCodePoints.isNullOrEmpty()) {
+            measureCodePoints(needMeasureCodePoints.toHashSet().toList())
+            for (i in needMeasureCodePoints.indices) {
+                textWidth += measureCodePoint(needMeasureCodePoints[i])
+            }
+        }
+        return textWidth
+    }
+
+    private fun String.toCodePoints(): List<Int> {
+        val codePoints = ArrayList<Int>(length)
+        val charArray = toCharArray()
+        val size = length
+        var i = 0
+        while (i < size) {
+            val c1 = charArray[i++]
+            var cp = c1.code
+            if (c1.isHighSurrogate() && i < size) {
+                val c2 = charArray[i]
+                if (c2.isLowSurrogate()) {
+                    i++
+                    cp = Character.toCodePoint(c1, c2)
+                }
+            }
+            codePoints.add(cp)
+        }
+        return codePoints
+    }
+
+    fun setPaint(paint: TextPaint) {
+        this.paint = paint
+        invalidate()
+    }
+
+    private fun invalidate() {
+        chineseCommonWidth = paint.measureText("一")
+        codePointWidths.clear()
+        asciiWidths.fill(-1f)
+    }
+
+}

+ 20 - 12
app/src/main/java/io/legado/app/ui/book/read/page/provider/TextPageFactory.kt

@@ -12,7 +12,7 @@ class TextPageFactory(dataSource: DataSource) : PageFactory<TextPage>(dataSource
     }
 
     override fun hasNext(): Boolean = with(dataSource) {
-        return hasNextChapter() || currentChapter?.isLastIndex(pageIndex) != true
+        return hasNextChapter() || (currentChapter != null && currentChapter?.isLastIndex(pageIndex) != true)
     }
 
     override fun hasNextPlus(): Boolean = with(dataSource) {
@@ -34,9 +34,12 @@ class TextPageFactory(dataSource: DataSource) : PageFactory<TextPage>(dataSource
     }
 
     override fun moveToNext(upContent: Boolean): Boolean = with(dataSource) {
-        return if (hasNext() && currentChapter != null) {
-            if (currentChapter?.isLastIndex(pageIndex) == true) {
-                ReadBook.moveToNextChapter(upContent)
+        return if (hasNext()) {
+            if (currentChapter == null || currentChapter?.isLastIndex(pageIndex) == true) {
+                if ((currentChapter == null || isScroll) && nextChapter == null) {
+                    return@with false
+                }
+                ReadBook.moveToNextChapter(upContent, false)
             } else {
                 ReadBook.setPageIndex(pageIndex.plus(1))
             }
@@ -47,10 +50,16 @@ class TextPageFactory(dataSource: DataSource) : PageFactory<TextPage>(dataSource
     }
 
     override fun moveToPrev(upContent: Boolean): Boolean = with(dataSource) {
-        return if (hasPrev() && currentChapter != null) {
+        return if (hasPrev()) {
             if (pageIndex <= 0) {
-                ReadBook.moveToPrevChapter(upContent)
+                if (currentChapter == null && prevChapter == null) {
+                    return@with false
+                }
+                ReadBook.moveToPrevChapter(upContent, upContentInPlace = false)
             } else {
+                if (currentChapter == null) {
+                    return@with false
+                }
                 ReadBook.setPageIndex(pageIndex.minus(1))
             }
             if (upContent) upContent(resetPageOffset = false)
@@ -76,14 +85,12 @@ class TextPageFactory(dataSource: DataSource) : PageFactory<TextPage>(dataSource
                 return@with TextPage(text = it).format()
             }
             currentChapter?.let {
+                val pageIndex = pageIndex
                 if (pageIndex < it.pageSize - 1) {
                     return@with it.getPage(pageIndex + 1)?.removePageAloudSpan()
                         ?: TextPage(title = it.title).format()
                 }
             }
-            if (!hasNextChapter()) {
-                return@with TextPage(text = "")
-            }
             nextChapter?.let {
                 return@with it.getPage(0)?.removePageAloudSpan()
                     ?: TextPage(title = it.title).format()
@@ -96,8 +103,9 @@ class TextPageFactory(dataSource: DataSource) : PageFactory<TextPage>(dataSource
             ReadBook.msg?.let {
                 return@with TextPage(text = it).format()
             }
-            if (pageIndex > 0) {
-                currentChapter?.let {
+            currentChapter?.let {
+                val pageIndex = pageIndex
+                if (pageIndex > 0) {
                     return@with it.getPage(pageIndex - 1)?.removePageAloudSpan()
                         ?: TextPage(title = it.title).format()
                 }
@@ -112,6 +120,7 @@ class TextPageFactory(dataSource: DataSource) : PageFactory<TextPage>(dataSource
     override val nextPlusPage: TextPage
         get() = with(dataSource) {
             currentChapter?.let {
+                val pageIndex = pageIndex
                 if (pageIndex < it.pageSize - 2) {
                     return@with it.getPage(pageIndex + 2)?.removePageAloudSpan()
                         ?: TextPage(title = it.title).format()
@@ -124,7 +133,6 @@ class TextPageFactory(dataSource: DataSource) : PageFactory<TextPage>(dataSource
                     return@with nc.getPage(1)?.removePageAloudSpan()
                         ?: TextPage(text = "继续滑动以加载下一章…").format()
                 }
-
             }
             return TextPage().format()
         }

+ 1 - 7
app/src/main/java/io/legado/app/ui/book/read/page/provider/ZhLayout.kt

@@ -16,7 +16,6 @@ class ZhLayout(
     text: CharSequence,
     textPaint: TextPaint,
     width: Int,
-    widthsArray: FloatArray
 ) : Layout(text, textPaint, width, Alignment.ALIGN_NORMAL, 0f, 0f) {
     companion object {
         private val postPanc = hashSetOf(
@@ -50,12 +49,7 @@ class ZhLayout(
 
     init {
         var line = 0
-        curPaint.getTextWidths(text as String, widthsArray)
-        val (words, widths) = ChapterProvider.getStringArrayAndTextWidths(
-            text,
-            widthsArray.asList(),
-            curPaint
-        )
+        val (words, widths) = ChapterProvider.measureTextSplit(text as String, textPaint)
         var lineW = 0f
         var cwPre = 0f
         var length = 0

+ 37 - 25
app/src/main/java/io/legado/app/ui/widget/BatteryView.kt

@@ -10,6 +10,8 @@ import android.text.StaticLayout
 import android.util.AttributeSet
 import androidx.annotation.ColorInt
 import androidx.appcompat.widget.AppCompatTextView
+import io.legado.app.utils.canvasrecorder.CanvasRecorderFactory
+import io.legado.app.utils.canvasrecorder.recordIfNeededThenDraw
 import io.legado.app.utils.dpToPx
 
 class BatteryView @JvmOverloads constructor(
@@ -22,6 +24,7 @@ class BatteryView @JvmOverloads constructor(
     private val batteryPaint = Paint()
     private val outFrame = Rect()
     private val polar = Rect()
+    private val canvasRecorder = CanvasRecorderFactory.create()
     var isBattery = false
         set(value) {
             field = value
@@ -62,31 +65,40 @@ class BatteryView @JvmOverloads constructor(
     }
 
     override fun onDraw(canvas: Canvas) {
-        super.onDraw(canvas)
-        if (!isBattery) return
-        layout.getLineBounds(0, outFrame)
-        val batteryStart = layout
-            .getPrimaryHorizontal(text.length - battery.toString().length)
-            .toInt() + 2.dpToPx()
-        val batteryEnd = batteryStart +
-                StaticLayout.getDesiredWidth(battery.toString(), paint).toInt() + 4.dpToPx()
-        outFrame.set(
-            batteryStart,
-            2.dpToPx(),
-            batteryEnd,
-            height - 2.dpToPx()
-        )
-        val dj = (outFrame.bottom - outFrame.top) / 3
-        polar.set(
-            batteryEnd,
-            outFrame.top + dj,
-            batteryEnd + 2.dpToPx(),
-            outFrame.bottom - dj
-        )
-        batteryPaint.style = Paint.Style.STROKE
-        canvas.drawRect(outFrame, batteryPaint)
-        batteryPaint.style = Paint.Style.FILL
-        canvas.drawRect(polar, batteryPaint)
+        canvasRecorder.recordIfNeededThenDraw(canvas, width, height) {
+            super.onDraw(this)
+            if (!isBattery) return@recordIfNeededThenDraw
+            layout.getLineBounds(0, outFrame)
+            val batteryStart = layout
+                .getPrimaryHorizontal(text.length - battery.toString().length)
+                .toInt() + 2.dpToPx()
+            val batteryEnd = batteryStart +
+                    StaticLayout.getDesiredWidth(battery.toString(), paint).toInt() + 4.dpToPx()
+            outFrame.set(
+                batteryStart,
+                2.dpToPx(),
+                batteryEnd,
+                height - 2.dpToPx()
+            )
+            val dj = (outFrame.bottom - outFrame.top) / 3
+            polar.set(
+                batteryEnd,
+                outFrame.top + dj,
+                batteryEnd + 2.dpToPx(),
+                outFrame.bottom - dj
+            )
+            batteryPaint.style = Paint.Style.STROKE
+            drawRect(outFrame, batteryPaint)
+            batteryPaint.style = Paint.Style.FILL
+            drawRect(polar, batteryPaint)
+        }
+    }
+
+    override fun invalidate() {
+        super.invalidate()
+        kotlin.runCatching {
+            canvasRecorder.invalidate()
+        }
     }
 
 }

+ 1 - 0
app/src/main/java/io/legado/app/ui/widget/DetailSeekBar.kt

@@ -30,6 +30,7 @@ class DetailSeekBar @JvmOverloads constructor(
         get() = binding.seekBar.progress
         set(value) {
             binding.seekBar.progress = value
+            upValue()
         }
     var max: Int
         get() = binding.seekBar.max

+ 7 - 3
app/src/main/java/io/legado/app/ui/widget/TitleBar.kt

@@ -36,13 +36,17 @@ class TitleBar @JvmOverloads constructor(
     var title: CharSequence?
         get() = toolbar.title
         set(title) {
-            toolbar.title = title
+            if (toolbar.title != title) {
+                toolbar.title = title
+            }
         }
 
     var subtitle: CharSequence?
         get() = toolbar.subtitle
         set(subtitle) {
-            toolbar.subtitle = subtitle
+            if (toolbar.subtitle != subtitle) {
+                toolbar.subtitle = subtitle
+            }
         }
 
     private val displayHomeAsUp: Boolean
@@ -198,7 +202,7 @@ class TitleBar @JvmOverloads constructor(
         toolbar.setSubtitleTextAppearance(context, resId)
     }
 
-    fun setTextColor(@ColorInt color: Int){
+    fun setTextColor(@ColorInt color: Int) {
         setTitleTextColor(color)
         setSubTitleTextColor(color)
     }

+ 3 - 1
app/src/main/java/io/legado/app/ui/widget/dialog/TextDialog.kt

@@ -12,6 +12,7 @@ import io.legado.app.lib.theme.primaryColor
 import io.legado.app.utils.applyTint
 import io.legado.app.utils.setHtml
 import io.legado.app.utils.setLayout
+import io.legado.app.utils.setTextAsync
 import io.legado.app.utils.viewbindingdelegate.viewBinding
 import io.noties.markwon.Markwon
 import io.noties.markwon.ext.tables.TablePlugin
@@ -75,8 +76,9 @@ class TextDialog() : BaseDialogFragment(R.layout.dialog_text_view) {
                         .build()
                         .setMarkdown(binding.textView, content)
                 }
+
                 Mode.HTML.name -> binding.textView.setHtml(content)
-                else -> binding.textView.text = content
+                else -> binding.textView.setTextAsync(content)
             }
             time = it.getLong("time", 0L)
         }

+ 0 - 77
app/src/main/java/io/legado/app/utils/BitmapCache.kt

@@ -1,77 +0,0 @@
-package io.legado.app.utils
-
-import android.graphics.Bitmap
-import android.graphics.BitmapFactory
-import java.lang.ref.SoftReference
-import java.util.concurrent.ConcurrentHashMap
-
-object BitmapCache {
-
-    private val reusableBitmaps: MutableSet<SoftReference<Bitmap>> = ConcurrentHashMap.newKeySet()
-
-    fun add(bitmap: Bitmap) {
-        reusableBitmaps.add(SoftReference(bitmap))
-    }
-
-    fun addInBitmapOptions(options: BitmapFactory.Options) {
-        // inBitmap only works with mutable bitmaps, so force the decoder to
-        // return mutable bitmaps.
-        options.inMutable = true
-
-        // Try to find a bitmap to use for inBitmap.
-        getBitmapFromReusableSet(options)?.also { inBitmap ->
-            // If a suitable bitmap has been found, set it as the value of
-            // inBitmap.
-            options.inBitmap = inBitmap
-        }
-    }
-
-
-    private fun getBitmapFromReusableSet(options: BitmapFactory.Options): Bitmap? {
-        if (reusableBitmaps.isEmpty()) {
-            return null
-        }
-        val iterator = reusableBitmaps.iterator()
-        while (iterator.hasNext()) {
-            val item = iterator.next().get() ?: continue
-            if (item.isMutable) {
-                // Check to see it the item can be used for inBitmap.
-                if (canUseForInBitmap(item, options)) {
-                    // Remove from reusable set so it can't be used again.
-                    iterator.remove()
-                    return item
-                }
-            } else {
-                // Remove from the set if the reference has been cleared.
-                iterator.remove()
-            }
-        }
-        return null
-    }
-
-    private fun canUseForInBitmap(
-        candidate: Bitmap,
-        targetOptions: BitmapFactory.Options
-    ): Boolean {
-        // From Android 4.4 (KitKat) onward we can re-use if the byte size of
-        // the new bitmap is smaller than the reusable bitmap candidate
-        // allocation byte count.
-        val width: Int = targetOptions.outWidth / targetOptions.inSampleSize
-        val height: Int = targetOptions.outHeight / targetOptions.inSampleSize
-        val byteCount: Int = width * height * getBytesPerPixel(candidate.config)
-        return byteCount <= candidate.allocationByteCount
-    }
-
-    /**
-     * A helper function to return the byte usage per pixel of a bitmap based on its configuration.
-     */
-    private fun getBytesPerPixel(config: Bitmap.Config): Int {
-        return when (config) {
-            Bitmap.Config.ARGB_8888 -> 4
-            Bitmap.Config.RGB_565, Bitmap.Config.ARGB_4444 -> 2
-            Bitmap.Config.ALPHA_8 -> 1
-            else -> 1
-        }
-    }
-
-}

+ 0 - 1
app/src/main/java/io/legado/app/utils/BitmapUtils.kt

@@ -34,7 +34,6 @@ object BitmapUtils {
             BitmapFactory.decodeFileDescriptor(fis.fd, null, op)
             op.inSampleSize = calculateInSampleSize(op, width, height)
             op.inJustDecodeBounds = false
-            BitmapCache.addInBitmapOptions(op)
             BitmapFactory.decodeFileDescriptor(fis.fd, null, op)
         }
     }

+ 10 - 17
app/src/main/java/io/legado/app/utils/ChineseUtils.kt

@@ -32,26 +32,19 @@ object ChineseUtils {
     fun fixT2sDict() {
         val dict = DictionaryContainer.getInstance().getDictionary(TransType.TRADITIONAL_TO_SIMPLE)
         dict.run {
-            remove("劈")
-            remove("脊")
-            remove("支援")
-            remove("沈默")
-            remove("類比")
-            remove("模擬")
-            remove("划槳")
-            remove("列根")
-            remove("路易斯")
-            remove("非同步")
-            remove("出租车")
-            remove("周杰倫")
+            remove("劈", "脊")
+            remove("支援", "沈默", "類比", "模擬", "划槳", "列根", "先進")
+            remove("路易斯", "非同步", "出租车", "周杰倫")
         }
     }
 
-    fun BasicDictionary.remove(key: String) {
-        if (key.length == 1) {
-            chars.remove(key[0])
-        } else {
-            dict.remove(key)
+    fun BasicDictionary.remove(vararg keys: String) {
+        for (key in keys) {
+            if (key.length == 1) {
+                chars.remove(key[0])
+            } else {
+                dict.remove(key)
+            }
         }
     }
 

+ 39 - 0
app/src/main/java/io/legado/app/utils/CollectionExtensions.kt

@@ -0,0 +1,39 @@
+package io.legado.app.utils
+
+fun List<Float>.fastSum(): Float {
+    var sum = 0f
+    for (i in indices) {
+        sum += this[i]
+    }
+    return sum
+}
+
+inline fun <T> List<T>.fastBinarySearch(
+    fromIndex: Int = 0,
+    toIndex: Int = size,
+    comparison: (T) -> Int
+): Int {
+    var low = fromIndex
+    var high = toIndex - 1
+
+    while (low <= high) {
+        val mid = (low + high).ushr(1) // safe from overflows
+        val midVal = get(mid)
+        val cmp = comparison(midVal)
+
+        if (cmp < 0)
+            low = mid + 1
+        else if (cmp > 0)
+            high = mid - 1
+        else
+            return mid // key found
+    }
+    return -(low + 1)  // key not found
+}
+
+inline fun <T, K : Comparable<K>> List<T>.fastBinarySearchBy(
+    key: K?,
+    fromIndex: Int = 0,
+    toIndex: Int = size,
+    crossinline selector: (T) -> K?
+): Int = fastBinarySearch(fromIndex, toIndex) { compareValues(selector(it), key) }

+ 33 - 0
app/src/main/java/io/legado/app/utils/ViewExtensions.kt

@@ -25,14 +25,20 @@ import android.widget.TextView
 import androidx.annotation.ColorInt
 import androidx.appcompat.app.AppCompatActivity
 import androidx.appcompat.view.menu.MenuPopupHelper
+import androidx.appcompat.widget.AppCompatTextView
 import androidx.appcompat.widget.PopupMenu
 import androidx.core.graphics.record
 import androidx.core.graphics.withTranslation
+import androidx.core.text.PrecomputedTextCompat
 import androidx.core.view.get
+import androidx.core.widget.TextViewCompat
 import androidx.recyclerview.widget.RecyclerView
 import androidx.viewpager.widget.ViewPager
 import io.legado.app.help.config.AppConfig
+import io.legado.app.help.globalExecutor
 import io.legado.app.lib.theme.TintHelper
+import io.legado.app.utils.canvasrecorder.CanvasRecorder
+import io.legado.app.utils.canvasrecorder.record
 import splitties.systemservices.inputMethodManager
 import java.lang.reflect.Field
 
@@ -176,6 +182,16 @@ fun View.screenshot(picture: Picture) {
     }
 }
 
+fun View.screenshot(canvasRecorder: CanvasRecorder) {
+    if (width > 0 && height > 0) {
+        canvasRecorder.record(width, height) {
+            withTranslation(-scrollX.toFloat(), -scrollY.toFloat()) {
+                draw(this)
+            }
+        }
+    }
+}
+
 fun View.setPaddingBottom(bottom: Int) {
     setPadding(paddingLeft, paddingTop, paddingRight, bottom)
 }
@@ -216,6 +232,23 @@ fun TextView.setHtml(html: String) {
     }
 }
 
+fun AppCompatTextView.setTextAsync(charSequence: CharSequence) {
+    globalExecutor.execute {
+        val precomputedText = PrecomputedTextCompat.create(
+            charSequence, TextViewCompat.getTextMetricsParams(this),
+        )
+        post {
+            setPrecomputedText(precomputedText)
+        }
+    }
+}
+
+fun TextView.setTextIfNotEqual(charSequence: CharSequence?) {
+    if (text != charSequence) {
+        text = charSequence
+    }
+}
+
 @SuppressLint("RestrictedApi")
 fun PopupMenu.show(x: Int, y: Int) {
     kotlin.runCatching {

+ 36 - 0
app/src/main/java/io/legado/app/utils/canvasrecorder/BaseCanvasRecorder.kt

@@ -0,0 +1,36 @@
+package io.legado.app.utils.canvasrecorder
+
+import androidx.annotation.CallSuper
+
+abstract class BaseCanvasRecorder : CanvasRecorder {
+
+    @JvmField
+    protected var isDirty = true
+
+    override fun invalidate() {
+        isDirty = true
+    }
+
+    @CallSuper
+    override fun recycle() {
+        isDirty = true
+    }
+
+    @CallSuper
+    override fun endRecording() {
+        isDirty = false
+    }
+
+    override fun isDirty(): Boolean {
+        return isDirty
+    }
+
+    override fun isLocked(): Boolean {
+        return false
+    }
+
+    override fun needRecord(): Boolean {
+        return isDirty() && !isLocked()
+    }
+
+}

+ 27 - 0
app/src/main/java/io/legado/app/utils/canvasrecorder/CanvasRecorder.kt

@@ -0,0 +1,27 @@
+package io.legado.app.utils.canvasrecorder
+
+import android.graphics.Canvas
+
+interface CanvasRecorder {
+
+    val width: Int
+
+    val height: Int
+
+    fun beginRecording(width: Int, height: Int): Canvas
+
+    fun endRecording()
+
+    fun draw(canvas: Canvas)
+
+    fun invalidate()
+
+    fun recycle()
+
+    fun isDirty(): Boolean
+
+    fun isLocked(): Boolean
+
+    fun needRecord(): Boolean
+
+}

+ 39 - 0
app/src/main/java/io/legado/app/utils/canvasrecorder/CanvasRecorderApi23Impl.kt

@@ -0,0 +1,39 @@
+package io.legado.app.utils.canvasrecorder
+
+import android.graphics.Canvas
+import android.graphics.Picture
+
+class CanvasRecorderApi23Impl : BaseCanvasRecorder() {
+
+    private var picture: Picture? = null
+
+    override val width get() = picture?.width ?: -1
+    override val height get() = picture?.height ?: -1
+
+    private fun initPicture() {
+        if (picture == null) {
+            picture = Picture()
+        }
+    }
+
+    override fun beginRecording(width: Int, height: Int): Canvas {
+        initPicture()
+        return picture!!.beginRecording(width, height)
+    }
+
+    override fun endRecording() {
+        picture!!.endRecording()
+        super.endRecording()
+    }
+
+    override fun draw(canvas: Canvas) {
+        if (picture == null) return
+        canvas.drawPicture(picture!!)
+    }
+
+    override fun recycle() {
+        super.recycle()
+        picture = null
+    }
+
+}

+ 64 - 0
app/src/main/java/io/legado/app/utils/canvasrecorder/CanvasRecorderApi29Impl.kt

@@ -0,0 +1,64 @@
+package io.legado.app.utils.canvasrecorder
+
+import android.graphics.Canvas
+import android.graphics.Picture
+import android.graphics.RenderNode
+import android.os.Build
+import androidx.annotation.RequiresApi
+
+@RequiresApi(Build.VERSION_CODES.Q)
+class CanvasRecorderApi29Impl : BaseCanvasRecorder() {
+
+    private var renderNode: RenderNode? = null
+    private var picture: Picture? = null
+
+    override val width get() = renderNode?.width ?: -1
+    override val height get() = renderNode?.height ?: -1
+
+    private fun init() {
+        if (renderNode == null) {
+            renderNode = RenderNode("CanvasRecorder")
+        }
+        if (picture == null) {
+            picture = Picture()
+        }
+    }
+
+    private fun flushRenderNode() {
+        val rc = renderNode!!.beginRecording()
+        rc.drawPicture(picture!!)
+        renderNode!!.endRecording()
+    }
+
+    override fun beginRecording(width: Int, height: Int): Canvas {
+        init()
+        renderNode!!.setPosition(0, 0, width, height)
+        return picture!!.beginRecording(width, height)
+    }
+
+    override fun endRecording() {
+        picture!!.endRecording()
+        flushRenderNode()
+        super.endRecording()
+    }
+
+    override fun draw(canvas: Canvas) {
+        if (renderNode == null || picture == null) return
+        if (canvas.isHardwareAccelerated) {
+            if (!renderNode!!.hasDisplayList()) {
+                flushRenderNode()
+            }
+            canvas.drawRenderNode(renderNode!!)
+        } else {
+            canvas.drawPicture(picture!!)
+        }
+    }
+
+    override fun recycle() {
+        super.recycle()
+        renderNode?.discardDisplayList()
+        renderNode = null
+        picture = null
+    }
+
+}

+ 35 - 0
app/src/main/java/io/legado/app/utils/canvasrecorder/CanvasRecorderExtensions.kt

@@ -0,0 +1,35 @@
+package io.legado.app.utils.canvasrecorder
+
+import android.graphics.Canvas
+import androidx.core.graphics.withSave
+
+inline fun CanvasRecorder.recordIfNeeded(
+    width: Int,
+    height: Int,
+    block: Canvas.() -> Unit
+): Boolean {
+    if (!needRecord()) return false
+    record(width, height, block)
+    return true
+}
+
+inline fun CanvasRecorder.record(width: Int, height: Int, block: Canvas.() -> Unit) {
+    val canvas = beginRecording(width, height)
+    try {
+        canvas.withSave {
+            block()
+        }
+    } finally {
+        endRecording()
+    }
+}
+
+inline fun CanvasRecorder.recordIfNeededThenDraw(
+    canvas: Canvas,
+    width: Int,
+    height: Int,
+    block: Canvas.() -> Unit
+) {
+    recordIfNeeded(width, height, block)
+    draw(canvas)
+}

+ 23 - 0
app/src/main/java/io/legado/app/utils/canvasrecorder/CanvasRecorderFactory.kt

@@ -0,0 +1,23 @@
+package io.legado.app.utils.canvasrecorder
+
+import android.os.Build
+
+object CanvasRecorderFactory {
+
+    private val atLeastApi23 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
+    private val atLeastApi29 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
+
+    fun create(locked: Boolean = false): CanvasRecorder {
+        val impl = when {
+            atLeastApi29 -> CanvasRecorderApi29Impl()
+            atLeastApi23 -> CanvasRecorderApi23Impl()
+            else -> CanvasRecorderImpl()
+        }
+        return if (locked) {
+            CanvasRecorderLocked(impl)
+        } else {
+            impl
+        }
+    }
+
+}

+ 59 - 0
app/src/main/java/io/legado/app/utils/canvasrecorder/CanvasRecorderImpl.kt

@@ -0,0 +1,59 @@
+package io.legado.app.utils.canvasrecorder
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+
+class CanvasRecorderImpl : BaseCanvasRecorder() {
+
+    var bitmap: Bitmap? = null
+    var canvas: Canvas? = null
+
+    override val width get() = bitmap?.width ?: -1
+    override val height get() = bitmap?.height ?: -1
+
+    private fun init(width: Int, height: Int) {
+        if (bitmap == null) {
+            bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+        }
+        if (canvas == null) {
+            canvas = Canvas(bitmap!!)
+        }
+        if (bitmap!!.width != width || bitmap!!.height != height) {
+            if (canReconfigure(width, height)) {
+                bitmap!!.reconfigure(width, height, Bitmap.Config.ARGB_8888)
+            } else {
+                bitmap!!.recycle()
+                bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+            }
+            canvas!!.setBitmap(bitmap!!)
+        }
+    }
+
+    private fun canReconfigure(width: Int, height: Int): Boolean {
+        return bitmap!!.allocationByteCount >= width * height * 4
+    }
+
+    override fun beginRecording(width: Int, height: Int): Canvas {
+        init(width, height)
+        bitmap!!.eraseColor(Color.TRANSPARENT)
+        return canvas!!
+    }
+
+    override fun endRecording() {
+        bitmap!!.prepareToDraw()
+        super.endRecording()
+    }
+
+    override fun draw(canvas: Canvas) {
+        if (bitmap == null) return
+        canvas.drawBitmap(bitmap!!, 0f, 0f, null)
+    }
+
+    override fun recycle() {
+        super.recycle()
+        bitmap?.recycle()
+        bitmap = null
+    }
+
+}

+ 52 - 0
app/src/main/java/io/legado/app/utils/canvasrecorder/CanvasRecorderLocked.kt

@@ -0,0 +1,52 @@
+package io.legado.app.utils.canvasrecorder
+
+import android.graphics.Canvas
+import java.util.concurrent.locks.ReentrantLock
+
+class CanvasRecorderLocked(private val delegate: CanvasRecorder) :
+    CanvasRecorder by delegate {
+
+    var lock: ReentrantLock? = ReentrantLock()
+
+    private fun initLock() {
+        if (lock == null) {
+            synchronized(this) {
+                if (lock == null) {
+                    lock = ReentrantLock()
+                }
+            }
+        }
+    }
+
+    override fun beginRecording(width: Int, height: Int): Canvas {
+        initLock()
+        lock!!.lock()
+        return delegate.beginRecording(width, height)
+    }
+
+    override fun endRecording() {
+        delegate.endRecording()
+        lock!!.unlock()
+    }
+
+    override fun draw(canvas: Canvas) {
+        if (lock == null) return
+        if (!lock!!.tryLock()) return
+        try {
+            delegate.draw(canvas)
+        } finally {
+            lock!!.unlock()
+        }
+    }
+
+    override fun isLocked(): Boolean {
+        if (lock == null) return false
+        return lock!!.isLocked
+    }
+
+    override fun recycle() {
+        delegate.recycle()
+        lock = null
+    }
+
+}

+ 0 - 7
app/src/main/res/xml/pref_config_read.xml

@@ -136,13 +136,6 @@
         app:iconSpaceReserved="false"
         app:isBottomBackground="true" />
 
-    <io.legado.app.lib.prefs.SwitchPreference
-        android:defaultValue="false"
-        android:key="asyncLoadImage"
-        android:title="@string/async_load_image"
-        app:iconSpaceReserved="false"
-        app:isBottomBackground="true" />
-
     <io.legado.app.lib.prefs.SwitchPreference
         android:defaultValue="false"
         android:key="noAnimScrollPage"