This commit is contained in:
Horis 2023-12-26 22:56:09 +08:00
parent d96de11b12
commit e895223638
6 changed files with 136 additions and 65 deletions

View File

@ -13,9 +13,25 @@ import io.legado.app.data.entities.BookSource
import io.legado.app.exception.NoStackTraceException import io.legado.app.exception.NoStackTraceException
import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.model.localBook.LocalBook import io.legado.app.model.localBook.LocalBook
import io.legado.app.utils.* import io.legado.app.utils.ArchiveUtils
import kotlinx.coroutines.* import io.legado.app.utils.FileUtils
import io.legado.app.utils.ImageUtils
import io.legado.app.utils.MD5Utils
import io.legado.app.utils.NetworkUtils
import io.legado.app.utils.StringUtils
import io.legado.app.utils.SvgUtils
import io.legado.app.utils.UrlUtil
import io.legado.app.utils.exists
import io.legado.app.utils.externalFiles
import io.legado.app.utils.getFile
import io.legado.app.utils.isContentScheme
import io.legado.app.utils.postEvent
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import org.apache.commons.text.similarity.JaccardSimilarity import org.apache.commons.text.similarity.JaccardSimilarity
import splitties.init.appCtx import splitties.init.appCtx
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
@ -23,20 +39,20 @@ import java.io.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.util.concurrent.CopyOnWriteArraySet import java.util.concurrent.ConcurrentHashMap
import java.util.regex.Pattern import java.util.regex.Pattern
import java.util.zip.ZipFile import java.util.zip.ZipFile
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
@Suppress("unused") @Suppress("unused", "ConstPropertyName")
object BookHelp { object BookHelp {
private val downloadDir: File = appCtx.externalFiles private val downloadDir: File = appCtx.externalFiles
private const val cacheFolderName = "book_cache" private const val cacheFolderName = "book_cache"
private const val cacheImageFolderName = "images" private const val cacheImageFolderName = "images"
private const val cacheEpubFolderName = "epub" private const val cacheEpubFolderName = "epub"
private val downloadImages = CopyOnWriteArraySet<String>() private val downloadImages = ConcurrentHashMap.newKeySet<String>()
val cachePath = FileUtils.getPath(downloadDir, cacheFolderName) val cachePath = FileUtils.getPath(downloadDir, cacheFolderName)

View File

@ -2,9 +2,11 @@ package io.legado.app.model
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.os.Build
import android.util.Size import android.util.Size
import androidx.collection.LruCache import androidx.collection.LruCache
import io.legado.app.R import io.legado.app.R
import io.legado.app.constant.AppLog
import io.legado.app.constant.AppLog.putDebug import io.legado.app.constant.AppLog.putDebug
import io.legado.app.constant.PageAnim import io.legado.app.constant.PageAnim
import io.legado.app.data.entities.Book import io.legado.app.data.entities.Book
@ -17,16 +19,18 @@ import io.legado.app.help.config.AppConfig
import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.coroutine.Coroutine
import io.legado.app.model.localBook.EpubFile import io.legado.app.model.localBook.EpubFile
import io.legado.app.model.localBook.PdfFile import io.legado.app.model.localBook.PdfFile
import io.legado.app.utils.BitmapCache
import io.legado.app.utils.BitmapUtils import io.legado.app.utils.BitmapUtils
import io.legado.app.utils.FileUtils import io.legado.app.utils.FileUtils
import io.legado.app.utils.SvgUtils import io.legado.app.utils.SvgUtils
import io.legado.app.utils.toastOnUi import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import splitties.init.appCtx import splitties.init.appCtx
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.min
object ImageProvider { object ImageProvider {
@ -46,7 +50,12 @@ object ImageProvider {
} }
return AppConfig.bitmapCacheSize * M return AppConfig.bitmapCacheSize * M
} }
var triggerRecycled = false 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) { val bitmapLruCache = object : LruCache<String, Bitmap>(cacheSize) {
override fun sizeOf(filePath: String, bitmap: Bitmap): Int { override fun sizeOf(filePath: String, bitmap: Bitmap): Int {
@ -61,8 +70,8 @@ object ImageProvider {
) { ) {
//错误图片不能释放,占位用,防止一直重复获取图片 //错误图片不能释放,占位用,防止一直重复获取图片
if (oldBitmap != errorBitmap) { if (oldBitmap != errorBitmap) {
oldBitmap.recycle() BitmapCache.add(oldBitmap)
triggerRecycled = true //oldBitmap.recycle()
//putDebug("ImageProvider: trigger bitmap recycle. URI: $filePath") //putDebug("ImageProvider: trigger bitmap recycle. URI: $filePath")
//putDebug("ImageProvider : cacheUsage ${size()}bytes / ${maxSize()}bytes") //putDebug("ImageProvider : cacheUsage ${size()}bytes / ${maxSize()}bytes")
} }
@ -166,17 +175,27 @@ object ImageProvider {
val cacheBitmap = getNotRecycled(vFile.absolutePath) val cacheBitmap = getNotRecycled(vFile.absolutePath)
if (cacheBitmap != null) return cacheBitmap if (cacheBitmap != null) return cacheBitmap
if (height != null && AppConfig.asyncLoadImage && ReadBook.pageAnim() == PageAnim.scrollPageAnim) { if (height != null && AppConfig.asyncLoadImage && ReadBook.pageAnim() == PageAnim.scrollPageAnim) {
if (asyncLoadingImages.contains(vFile.absolutePath)) {
return null
}
asyncLoadingImages.add(vFile.absolutePath)
Coroutine.async { Coroutine.async {
val bitmap = BitmapUtils.decodeBitmap(vFile.absolutePath, width, height) BitmapUtils.decodeBitmap(vFile.absolutePath, width, height)
?: SvgUtils.createBitmap(vFile.absolutePath, width, height) ?: SvgUtils.createBitmap(vFile.absolutePath, width, height)
?: throw NoStackTraceException(appCtx.getString(R.string.error_decode_bitmap)) ?: throw NoStackTraceException(appCtx.getString(R.string.error_decode_bitmap))
withContext(Main) { }.onSuccess {
bitmapLruCache.put(vFile.absolutePath, bitmap) 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 { }.onError {
//错误图片占位,防止重复获取 //错误图片占位,防止重复获取
bitmapLruCache.put(vFile.absolutePath, errorBitmap) bitmapLruCache.put(vFile.absolutePath, errorBitmap)
}.onFinally { }.onFinally {
asyncLoadingImages.remove(vFile.absolutePath)
block?.invoke() block?.invoke()
} }
return null return null
@ -193,17 +212,4 @@ object ImageProvider {
}.getOrDefault(errorBitmap) }.getOrDefault(errorBitmap)
} }
fun isImageAlive(book: Book, src: String): Boolean {
val vFile = BookHelp.getImage(book, src)
if (!vFile.exists()) return true // 使用 errorBitmap
val cacheBitmap = bitmapLruCache.get(vFile.absolutePath)
return cacheBitmap != null
}
fun isTriggerRecycled(): Boolean {
val tmp = triggerRecycled
triggerRecycled = false
return tmp
}
} }

View File

@ -4,12 +4,10 @@ import android.content.Context
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Paint import android.graphics.Paint
import android.graphics.RectF import android.graphics.RectF
import android.os.Build
import android.util.AttributeSet import android.util.AttributeSet
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import io.legado.app.R import io.legado.app.R
import io.legado.app.constant.AppLog
import io.legado.app.constant.PreferKey import io.legado.app.constant.PreferKey
import io.legado.app.data.entities.Bookmark import io.legado.app.data.entities.Bookmark
import io.legado.app.help.book.isImage import io.legado.app.help.book.isImage
@ -56,15 +54,7 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
var textPage: TextPage = TextPage() var textPage: TextPage = TextPage()
private set private set
var isMainView = false var isMainView = false
private var drawVisibleImageOnly = false
private var cacheIncreased = false
private var longScreenshot = false private var longScreenshot = false
private val increaseSize = 8 * 1024 * 1024
private val maxCacheSize = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N_MR1) {
min(128 * 1024 * 1024, Runtime.getRuntime().maxMemory())
} else {
256 * 1024 * 1024
}
var reverseStartCursor = false var reverseStartCursor = false
var reverseEndCursor = false var reverseEndCursor = false
@ -119,8 +109,6 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
} }
canvas.clipRect(visibleRect) canvas.clipRect(visibleRect)
drawPage(canvas) drawPage(canvas)
drawVisibleImageOnly = false
cacheIncreased = false
} }
/** /**
@ -212,7 +200,13 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
} }
canvas.drawText(column.charData, column.start, lineBase, textPaint) canvas.drawText(column.charData, column.start, lineBase, textPaint)
if (column.selected) { if (column.selected) {
canvas.drawRect(column.start, lineTop, column.end, lineBottom, selectedPaint) canvas.drawRect(
column.start,
lineTop,
column.end,
lineBottom,
selectedPaint
)
} }
} }
@ -236,37 +230,14 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
) { ) {
val book = ReadBook.book ?: return val book = ReadBook.book ?: return
val isVisible = when {
lineTop > 0 -> lineTop < height
lineTop < 0 -> lineBottom > 0
else -> true
}
if (drawVisibleImageOnly && !isVisible) {
return
}
if (drawVisibleImageOnly &&
!cacheIncreased &&
ImageProvider.isTriggerRecycled() &&
!ImageProvider.isImageAlive(book, column.src)
) {
val newSize = ImageProvider.bitmapLruCache.maxSize() + increaseSize
if (newSize < maxCacheSize) {
ImageProvider.bitmapLruCache.resize(newSize)
AppLog.put("图片缓存不够大,自动扩增至${(newSize / 1024 / 1024)}MB。")
cacheIncreased = true
}
return
}
val bitmap = ImageProvider.getImage( val bitmap = ImageProvider.getImage(
book, book,
column.src, column.src,
(column.end - column.start).toInt(), (column.end - column.start).toInt(),
(lineBottom - lineTop).toInt() (lineBottom - lineTop).toInt()
) { ) {
if (!drawVisibleImageOnly && isVisible) {
drawVisibleImageOnly = true
invalidate() invalidate()
}
} ?: return } ?: return
val rectF = if (textLine.isImage) { val rectF = if (textLine.isImage) {

View File

@ -31,7 +31,7 @@ import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.CopyOnWriteArraySet import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors import java.util.concurrent.Executors
import kotlin.math.min import kotlin.math.min
@ -40,7 +40,7 @@ class MainViewModel(application: Application) : BaseViewModel(application) {
private var upTocPool = private var upTocPool =
Executors.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher() Executors.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher()
private val waitUpTocBooks = arrayListOf<String>() private val waitUpTocBooks = arrayListOf<String>()
private val onUpTocBooks = CopyOnWriteArraySet<String>() private val onUpTocBooks = ConcurrentHashMap.newKeySet<String>()
val onUpBooksLiveData = MutableLiveData<Int>() val onUpBooksLiveData = MutableLiveData<Int>()
private var upTocJob: Job? = null private var upTocJob: Job? = null
private var cacheBookJob: Job? = null private var cacheBookJob: Job? = null

View File

@ -0,0 +1,77 @@
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
}
}
}

View File

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