Horis hace 2 años
padre
commit
c227a74811
Se han modificado 23 ficheros con 734 adiciones y 307 borrados
  1. 16 1
      app/src/main/java/io/legado/app/lib/theme/ThemeStore.kt
  2. 5 0
      app/src/main/java/io/legado/app/model/ImageProvider.kt
  3. 20 8
      app/src/main/java/io/legado/app/model/ReadBook.kt
  4. 12 18
      app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt
  5. 55 174
      app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt
  6. 0 3
      app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt
  7. 25 5
      app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt
  8. 2 0
      app/src/main/java/io/legado/app/ui/book/read/page/api/DataSource.kt
  9. 6 4
      app/src/main/java/io/legado/app/ui/book/read/page/entities/TextChapter.kt
  10. 67 2
      app/src/main/java/io/legado/app/ui/book/read/page/entities/TextLine.kt
  11. 55 13
      app/src/main/java/io/legado/app/ui/book/read/page/entities/TextPage.kt
  12. 7 0
      app/src/main/java/io/legado/app/ui/book/read/page/entities/column/BaseColumn.kt
  13. 11 2
      app/src/main/java/io/legado/app/ui/book/read/page/entities/column/ButtonColumn.kt
  14. 43 1
      app/src/main/java/io/legado/app/ui/book/read/page/entities/column/ImageColumn.kt
  15. 13 0
      app/src/main/java/io/legado/app/ui/book/read/page/entities/column/ReviewColumn.kt
  16. 46 2
      app/src/main/java/io/legado/app/ui/book/read/page/entities/column/TextColumn.kt
  17. 113 62
      app/src/main/java/io/legado/app/ui/book/read/page/provider/ChapterProvider.kt
  18. 146 0
      app/src/main/java/io/legado/app/ui/book/read/page/provider/TextMeasure.kt
  19. 14 5
      app/src/main/java/io/legado/app/ui/book/read/page/provider/TextPageFactory.kt
  20. 2 7
      app/src/main/java/io/legado/app/ui/book/read/page/provider/ZhLayout.kt
  21. 27 0
      app/src/main/java/io/legado/app/utils/BitmapCache.kt
  22. 9 0
      app/src/main/java/io/legado/app/utils/CollectionExtensions.kt
  23. 40 0
      app/src/main/java/io/legado/app/utils/PictureMirror.kt

+ 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)

+ 5 - 0
app/src/main/java/io/legado/app/model/ImageProvider.kt

@@ -212,4 +212,9 @@ object ImageProvider {
         }.getOrDefault(errorBitmap)
     }
 
+    fun clear() {
+        bitmapLruCache.evictAll()
+        BitmapCache.clear()
+    }
+
 }

+ 20 - 8
app/src/main/java/io/legado/app/model/ReadBook.kt

@@ -204,7 +204,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 +213,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 +233,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 +243,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 +268,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)?.recyclePictures()
+            }
+            if (index < pageIndex) {
+                textChapter.getPage(index + 2)?.recyclePictures()
+            }
+        }
         durChapterPos = curTextChapter?.getReadLength(index) ?: index
         saveRead(true)
         curPageChanged(true)
@@ -595,6 +606,7 @@ object ReadBook : CoroutineScope by MainScope() {
         coroutineContext.cancelChildren()
         downloadedChapters.clear()
         downloadFailChapters.clear()
+        ImageProvider.clear()
     }
 
     interface CallBack {

+ 12 - 18
app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt

@@ -97,6 +97,7 @@ 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
@@ -218,9 +219,10 @@ class ReadBookActivity : BaseReadBookActivity(),
     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 var confirmRestoreProcess: Boolean? = null
@@ -265,22 +267,14 @@ class ReadBookActivity : BaseReadBookActivity(),
     override fun onPostCreate(savedInstanceState: Bundle?) {
         super.onPostCreate(savedInstanceState)
         viewModel.initData(intent) {
-            initDataSuccess()
+            upMenu()
         }
     }
 
     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)
+            upMenu()
         }
     }
 
@@ -1532,8 +1526,6 @@ class ReadBookActivity : BaseReadBookActivity(),
             if (it) { // 更新内容排版布局
                 if (isInitFinish) {
                     ReadBook.loadContent(resetPageOffset = false)
-                } else {
-                    reloadContent = true
                 }
             } else {
                 readView.upContent(resetPageOffset = false)
@@ -1582,6 +1574,9 @@ class ReadBookActivity : BaseReadBookActivity(),
         observeEvent<Boolean>(EventBus.UP_SEEK_BAR) {
             binding.readMenu.upSeekBar()
         }
+        observeEvent<String>(EventBus.RECREATE) {
+            binding.readView.invalidateTextPage()
+        }
     }
 
     private fun upScreenTimeOut() {
@@ -1594,17 +1589,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)
             }

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

@@ -3,22 +3,16 @@ 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 androidx.core.graphics.withTranslation
 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.entities.TextLine
 import io.legado.app.ui.book.read.page.entities.TextPage
@@ -31,8 +25,8 @@ 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.ui.book.read.page.provider.TextPageFactory
 import io.legado.app.ui.widget.dialog.PhotoDialog
+import io.legado.app.utils.PictureMirror
 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
@@ -44,8 +38,7 @@ 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 {
+    val selectedPaint by lazy {
         Paint().apply {
             color = context.getCompatColor(R.color.btn_bg_press_2)
             style = Paint.Style.FILL
@@ -65,13 +58,11 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
     //滚动参数
     private val pageFactory: TextPageFactory get() = callBack.pageFactory
     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 pictureMirror = PictureMirror()
     private val isNoAnim get() = ReadBook.pageAnim() == PageAnim.noAnim
 
     //绘制图片的paint
-    private val imagePaint by lazy {
+    val imagePaint by lazy {
         Paint().apply {
             isAntiAlias = AppConfig.useAntiAlias
         }
@@ -79,9 +70,6 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
 
     init {
         callBack = activity as CallBack
-        if (atLeastApi23) {
-            picture = Picture()
-        }
     }
 
     /**
@@ -108,7 +96,7 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
     override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
         super.onSizeChanged(w, h, oldw, oldh)
         if (!isMainView) return
-        ChapterProvider.upViewSize(w, h)
+        ChapterProvider.upViewSize(w, h, oldw, oldh)
         upVisibleRect()
         textPage.format()
     }
@@ -119,14 +107,10 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
             canvas.translate(0f, scrollY.toFloat())
         }
         canvas.clipRect(visibleRect)
-        if (atLeastApi23 && !callBack.isScroll && !isNoAnim) {
-            if (pictureIsDirty) {
-                pictureIsDirty = false
-                picture.record(width, height) {
-                    drawPage(this)
-                }
+        if (!callBack.isScroll && !isNoAnim) {
+            pictureMirror.draw(canvas, width, height) {
+                drawPage(this)
             }
-            canvas.drawPicture(picture)
         } else {
             drawPage(canvas)
         }
@@ -137,147 +121,34 @@ 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)
+        val view = this
+        canvas.withTranslation(0f, relativeOffset) {
+            textPage.draw(view, this)
         }
         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)
+        canvas.withTranslation(0f, relativeOffset) {
+            textPage1.draw(view, this)
         }
         if (!pageFactory.hasNextPlus()) return
         relativeOffset = relativeOffset(2)
         if (relativeOffset < ChapterProvider.visibleHeight) {
             val textPage2 = relativePage(2)
-            lines = textPage2.lines
-            for (i in lines.indices) {
-                drawLine(canvas, textPage2, lines[i], relativeOffset)
+            canvas.withTranslation(0f, relativeOffset) {
+                textPage2.draw(view, this)
             }
         }
     }
 
-    /**
-     * 绘制页面
-     */
-    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)
-        }
-    }
-
-    /**
-     * 绘制下划线
-     */
-    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)
-        }
-    }
-
     /**
      * 滚动事件
+     * pageOffset 向上滚动 减小 向下滚动 增大
+     * pageOffset 范围 0 ~ -textPage.height 大于0为上一页,小于-textPage.height为下一页
+     * 以内容显示区域顶端为界,pageOffset的绝对值为textPage上方的高度
+     * pageOffset + textPage.height 为 textPage 下方的高度
      */
     fun scroll(mOffset: Int) {
         if (mOffset == 0) return
@@ -294,28 +165,25 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
             val offset = (ChapterProvider.visibleHeight - textPage.height).toInt()
             pageOffset = min(0, offset)
         } 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
+            }
         } 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()
+            }
         }
         invalidate()
     }
 
     override fun invalidate() {
         super.invalidate()
-        invalidatePicture()
-    }
-
-    private fun invalidatePicture() {
-        pictureIsDirty = true
+        pictureMirror.invalidate()
     }
 
     /**
@@ -517,9 +385,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 +409,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 +434,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 +459,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

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

@@ -57,9 +57,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) {

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

@@ -21,16 +21,25 @@ 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.screenshot
+import io.legado.app.utils.showDialogFragment
+import io.legado.app.utils.visible
 import java.text.BreakIterator
-import java.util.*
+import java.util.Locale
 import kotlin.math.abs
 
 /**
@@ -49,7 +58,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) }
@@ -529,7 +538,9 @@ class ReadView(context: Context, attrs: AttributeSet) :
      * @param resetPageOffset 滚动阅读是是否重置位置
      */
     override fun upContent(relativePosition: Int, resetPageOffset: Boolean) {
-        curPage.setContentDescription(pageFactory.curPage.text)
+        post {
+            curPage.setContentDescription(pageFactory.curPage.text)
+        }
         if (isScroll && !callBack.isAutoPage) {
             curPage.setContent(pageFactory.curPage, resetPageOffset)
         } else {
@@ -643,6 +654,15 @@ class ReadView(context: Context, attrs: AttributeSet) :
         nextPageBitmap = null
     }
 
+    fun invalidateTextPage() {
+        pageFactory.run {
+            prevPage.invalidateAll()
+            curPage.invalidateAll()
+            nextPage.invalidateAll()
+            nextPlusPage.invalidateAll()
+        }
+    }
+
     override val currentChapter: TextChapter?
         get() {
             return if (callBack.isInitFinish) ReadBook.textChapter(0) else null

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

@@ -13,6 +13,8 @@ interface DataSource {
 
     val prevChapter: TextChapter?
 
+    val isScroll: Boolean
+
     fun hasNextChapter(): Boolean
 
     fun hasPrevChapter(): Boolean

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

@@ -186,17 +186,19 @@ data class TextChapter(
      */
     fun getPageIndexByCharIndex(charIndex: Int): Int {
         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

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

@@ -1,9 +1,17 @@
 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.PictureMirror
+import io.legado.app.utils.dpToPx
 
 /**
  * 行信息
@@ -22,8 +30,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 +38,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 pictureMirror: PictureMirror = PictureMirror()
+    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 +121,50 @@ data class TextLine(
         return visible
     }
 
+    fun draw(view: ContentTextView, canvas: Canvas) {
+        pictureMirror.draw(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() {
+        pictureMirror.invalidate()
+    }
+
+    fun recyclePicture() {
+        pictureMirror.recycle()
+    }
+
+    companion object {
+        val emptyTextLine = TextLine()
+    }
+
 }

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

@@ -1,13 +1,17 @@
 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.PictureMirror
 import splitties.init.appCtx
 import java.text.DecimalFormat
 import kotlin.math.min
@@ -31,6 +35,7 @@ data class TextPage(
 
     companion object {
         val readProgressFormatter = DecimalFormat("0.0%")
+        val emptyTextPage = TextPage()
     }
 
     val lines: List<TextLine> get() = textLines
@@ -38,25 +43,29 @@ data class TextPage(
     val charSize: Int get() = text.length.coerceAtLeast(1)
     val searchResult = hashSetOf<TextColumn>()
     var isMsgPage: Boolean = false
+    var pictureMirror: PictureMirror = PictureMirror()
+    var doublePage = false
 
     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,7 +156,7 @@ data class TextPage(
                     )
                     x = x1
                 }
-                textLines.add(textLine)
+                addLine(textLine)
             }
             height = ChapterProvider.visibleHeight.toFloat()
         }
@@ -158,8 +167,8 @@ data class TextPage(
      * 移除朗读标志
      */
     fun removePageAloudSpan(): TextPage {
-        textLines.forEach { textLine ->
-            textLine.isReadAloud = false
+        for (i in textLines.indices) {
+            textLines[i].isReadAloud = false
         }
         return this
     }
@@ -254,6 +263,39 @@ data class TextPage(
         return null
     }
 
+    fun draw(view: ContentTextView, canvas: Canvas) {
+        pictureMirror.draw(canvas, view.width, height.toInt()) {
+            drawPage(view, this)
+        }
+    }
+
+    private fun drawPage(view: ContentTextView, canvas: Canvas) {
+        for (i in lines.indices) {
+            val line = lines[i]
+            canvas.withTranslation(0f, line.lineTop) {
+                line.draw(view, this)
+            }
+        }
+    }
+
+    fun invalidate() {
+        pictureMirror.invalidate()
+    }
+
+    fun invalidateAll() {
+        for (i in lines.indices) {
+            lines[i].invalidateSelf()
+        }
+        invalidate()
+    }
+
+    fun recyclePictures() {
+        pictureMirror.recycle()
+        for (i in lines.indices) {
+            lines[i].recyclePicture()
+        }
+    }
+
     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) {
+
+    }
+}

+ 43 - 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,37 @@ 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()
+        ) {
+            textLine.invalidate()
+            view.invalidate()
+        } ?: return
+
+        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
+        }
+        val textColor = if (textLine.isReadAloud || isSearchResult) {
+            ThemeStore.accentColor
+        } else {
+            ReadBookConfig.textColor
+        }
+        if (textPaint.color != textColor) {
+            textPaint.color = textColor
+        }
+        canvas.drawText(charData, start, textLine.lineBase - textLine.lineTop, textPaint)
+        if (selected) {
+            canvas.drawRect(start, 0f, end, textLine.height, view.selectedPaint)
+        }
+    }
+
+}

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

@@ -1,6 +1,5 @@
 package io.legado.app.ui.book.read.page.provider
 
-import android.graphics.Paint
 import android.graphics.Paint.FontMetrics
 import android.graphics.Typeface
 import android.net.Uri
@@ -25,13 +24,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 +46,8 @@ object ChapterProvider {
     //用于评论按钮的替换
     private const val reviewChar = "▨"
 
+    private const val indentChar = " "
+
     @JvmStatic
     var viewWidth = 0
         private set
@@ -104,7 +105,7 @@ object ChapterProvider {
     private var indentCharWidth = 0f
 
     @JvmStatic
-    private var titlePaintTextHeight = 0f
+    var titlePaintTextHeight = 0f
 
     @JvmStatic
     var contentPaintTextHeight = 0f
@@ -132,6 +133,9 @@ object ChapterProvider {
     var doublePage = false
         private set
 
+    private val titleMeasureHelper = TextMeasure(titlePaint)
+    private val contentMeasureHelper = TextMeasure(contentPaint)
+
     init {
         upStyle()
     }
@@ -161,6 +165,7 @@ object ChapterProvider {
                     textPages,
                     stringBuilder,
                     titlePaint,
+                    titleMeasureHelper,
                     titlePaintTextHeight,
                     titlePaintFontMetrics,
                     isTitle = true,
@@ -196,6 +201,7 @@ object ChapterProvider {
                     textPages,
                     stringBuilder,
                     contentPaint,
+                    contentMeasureHelper,
                     contentPaintTextHeight,
                     contentPaintFontMetrics,
                     srcList = srcList
@@ -217,6 +223,7 @@ object ChapterProvider {
                             textPages,
                             stringBuilder,
                             contentPaint,
+                            contentMeasureHelper,
                             contentPaintTextHeight,
                             contentPaintFontMetrics
                         ).let {
@@ -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) {
@@ -239,6 +255,7 @@ object ChapterProvider {
                             textPages,
                             stringBuilder,
                             contentPaint,
+                            contentMeasureHelper,
                             contentPaintTextHeight,
                             contentPaintFontMetrics
                         ).let {
@@ -249,14 +266,22 @@ object ChapterProvider {
                 }
             }
         }
-        textPages.last().height = durY + 20.dpToPx()
-        textPages.last().text = stringBuilder.toString()
+        val textPage = textPages.last()
+        val endPadding = 20.dpToPx()
+        val durYPadding = durY + endPadding
+        if (textPage.height < durYPadding) {
+            textPage.height = durYPadding
+        } else {
+            textPage.height += endPadding
+        }
+        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.upLinesPosition()
         }
 
@@ -280,15 +305,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 +342,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
                     }
                 }
@@ -336,7 +378,7 @@ object ChapterProvider {
             )
             textPages.last().addLine(textLine)
         }
-        return durY + paragraphSpacing / 10f
+        return absStartX to durY + textHeight * paragraphSpacing / 10f
     }
 
     /**
@@ -350,6 +392,7 @@ object ChapterProvider {
         textPages: ArrayList<TextPage>,
         stringBuilder: StringBuilder,
         textPaint: TextPaint,
+        measureHelper: TextMeasure,
         textHeight: Float,
         fontMetrics: FontMetrics,
         isTitle: Boolean = false,
@@ -358,14 +401,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, measureHelper)
         } 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 +438,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,32 +447,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) = measureHelper.measureTextSplit(lineText)
+            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.text = lineText
                     textLine.isParagraphEnd = true
                     //标题x轴居中
                     val startX = if (
@@ -443,8 +486,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,20 +500,23 @@ 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
                         )
                     }
                 }
             }
+            if (doublePage) {
+                textLine.isLeftLine = absStartX < viewWidth / 2
+            }
             val sbLength = stringBuilder.length
-            stringBuilder.append(words)
+            stringBuilder.append(lineText)
             if (textLine.isParagraphEnd) {
                 stringBuilder.append("\n")
             }
@@ -487,10 +533,13 @@ object ChapterProvider {
                     chapterPosition + charSize + if (isParagraphEnd) 1 else 0
                 } ?: 0) + sbLength
             textLine.pagePosition = sbLength
-            textPages.last().addLine(textLine)
             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)
@@ -503,8 +552,7 @@ object ChapterProvider {
         book: Book,
         absStartX: Int,
         textLine: TextLine,
-        text: String,
-        textPaint: TextPaint,
+        words: List<String>,
         /**自然排版长度**/
         desiredWidth: Float,
         textWidths: List<Float>,
@@ -513,17 +561,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 +579,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 +596,7 @@ object ChapterProvider {
         book: Book,
         absStartX: Int,
         textLine: TextLine,
-        text: String,
-        textPaint: TextPaint,
+        words: List<String>,
         /**自然排版长度**/
         desiredWidth: Float,
         /**起始x坐标**/
@@ -559,19 +606,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 +634,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 +655,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 +663,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
@@ -731,9 +778,11 @@ 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
+            titleMeasureHelper.setPaint(titlePaint)
+            contentMeasureHelper.setPaint(contentPaint)
+//            reviewPaint.color = contentPaint.color
+//            reviewPaint.textSize = contentPaint.textSize * 0.45f
+//            reviewPaint.textAlign = Paint.Align.CENTER
         }
         //间距
         lineSpacingExtra = ReadBookConfig.lineSpacingExtra / 10f
@@ -820,12 +869,14 @@ object ChapterProvider {
     /**
      * 更新View尺寸
      */
-    fun upViewSize(width: Int, height: Int) {
+    fun upViewSize(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
         if (width > 0 && height > 0 && (width != viewWidth || height != viewHeight)) {
             viewWidth = width
             viewHeight = height
             upLayout()
-            postEvent(EventBus.UP_CONFIG, true)
+            if (oldWidth > 0 && oldHeight > 0) {
+                postEvent(EventBus.UP_CONFIG, true)
+            }
         }
     }
 

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

@@ -0,0 +1,146 @@
+package io.legado.app.ui.book.read.page.provider
+
+import android.text.TextPaint
+import android.util.SparseArray
+import androidx.core.util.getOrDefault
+import java.util.BitSet
+import kotlin.math.ceil
+
+class TextMeasure(private var paint: TextPaint) {
+
+    private var chineseCommonWidth = paint.measureText("一")
+    private val chineseCommonWidthBitSet = BitSet()
+    private val asciiWidths = FloatArray(128) { -1f }
+    private val codePointWidths = SparseArray<Float>()
+
+    private fun measureCodePoint(codePoint: Int): Float {
+        if (codePoint < 128) {
+            return asciiWidths[codePoint]
+        }
+        if (chineseCommonWidthBitSet[codePoint]) {
+            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 > 0) {
+                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 if (width == chineseCommonWidth) {
+                chineseCommonWidthBitSet.set(codePoint)
+            } 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("一")
+        chineseCommonWidthBitSet.clear()
+        codePointWidths.clear()
+        asciiWidths.fill(-1f)
+    }
+
+}

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

@@ -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)

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

@@ -16,7 +16,7 @@ class ZhLayout(
     text: CharSequence,
     textPaint: TextPaint,
     width: Int,
-    widthsArray: FloatArray
+    measureHelper: TextMeasure,
 ) : Layout(text, textPaint, width, Alignment.ALIGN_NORMAL, 0f, 0f) {
     companion object {
         private val postPanc = hashSetOf(
@@ -50,12 +50,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) = measureHelper.measureTextSplit(text as String)
         var lineW = 0f
         var cwPre = 0f
         var length = 0

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

@@ -11,6 +11,33 @@ object BitmapCache {
 
     fun add(bitmap: Bitmap) {
         reusableBitmaps.add(SoftReference(bitmap))
+        trimSize()
+    }
+
+    fun clear() {
+        if (reusableBitmaps.isEmpty()) {
+            return
+        }
+        val iterator = reusableBitmaps.iterator()
+        while (iterator.hasNext()) {
+            val item = iterator.next().get() ?: continue
+            item.recycle()
+            iterator.remove()
+        }
+    }
+
+    private fun trimSize() {
+        var byteCount = 0
+        val iterator = reusableBitmaps.iterator()
+        while (iterator.hasNext()) {
+            val item = iterator.next().get() ?: continue
+            if (byteCount > 128 * 1024 * 1024) {
+                item.recycle()
+                iterator.remove()
+            } else {
+                byteCount += item.byteCount
+            }
+        }
     }
 
     fun addInBitmapOptions(options: BitmapFactory.Options) {

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

@@ -0,0 +1,9 @@
+package io.legado.app.utils
+
+fun List<Float>.fastSum(): Float {
+    var sum = 0f
+    for (i in indices) {
+        sum += this[i]
+    }
+    return sum
+}

+ 40 - 0
app/src/main/java/io/legado/app/utils/PictureMirror.kt

@@ -0,0 +1,40 @@
+package io.legado.app.utils
+
+import android.graphics.Canvas
+import android.graphics.Picture
+import android.os.Build
+import androidx.core.graphics.record
+
+class PictureMirror {
+
+    var picture: Picture? = null
+    var isDirty = true
+
+    inline fun draw(canvas: Canvas, width: Int, height: Int, block: Canvas.() -> Unit) {
+        if (atLeastApi23) {
+            if (picture == null) picture = Picture()
+            val picture = picture!!
+            if (isDirty) {
+                isDirty = false
+                picture.record(width, height, block)
+            }
+            canvas.drawPicture(picture)
+        } else {
+            canvas.block()
+        }
+    }
+
+    fun invalidate() {
+        isDirty = true
+    }
+
+    fun recycle() {
+        picture = null
+        isDirty = true
+    }
+
+    companion object {
+        val atLeastApi23 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
+    }
+
+}