diff --git a/app/src/main/java/io/legado/app/help/book/BookHelp.kt b/app/src/main/java/io/legado/app/help/book/BookHelp.kt index e6ca85370a14..c854b58d2dd7 100644 --- a/app/src/main/java/io/legado/app/help/book/BookHelp.kt +++ b/app/src/main/java/io/legado/app/help/book/BookHelp.kt @@ -13,9 +13,25 @@ import io.legado.app.data.entities.BookSource import io.legado.app.exception.NoStackTraceException import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.model.localBook.LocalBook -import io.legado.app.utils.* -import kotlinx.coroutines.* +import io.legado.app.utils.ArchiveUtils +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.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext import org.apache.commons.text.similarity.JaccardSimilarity import splitties.init.appCtx import java.io.ByteArrayInputStream @@ -23,20 +39,20 @@ import java.io.File import java.io.FileNotFoundException import java.io.FileOutputStream import java.io.IOException -import java.util.concurrent.CopyOnWriteArraySet +import java.util.concurrent.ConcurrentHashMap import java.util.regex.Pattern import java.util.zip.ZipFile import kotlin.math.abs import kotlin.math.max import kotlin.math.min -@Suppress("unused") +@Suppress("unused", "ConstPropertyName") object BookHelp { private val downloadDir: File = appCtx.externalFiles private const val cacheFolderName = "book_cache" private const val cacheImageFolderName = "images" private const val cacheEpubFolderName = "epub" - private val downloadImages = CopyOnWriteArraySet() + private val downloadImages = ConcurrentHashMap.newKeySet() val cachePath = FileUtils.getPath(downloadDir, cacheFolderName) diff --git a/app/src/main/java/io/legado/app/model/ImageProvider.kt b/app/src/main/java/io/legado/app/model/ImageProvider.kt index 7099175efe75..a1320d488ace 100644 --- a/app/src/main/java/io/legado/app/model/ImageProvider.kt +++ b/app/src/main/java/io/legado/app/model/ImageProvider.kt @@ -2,9 +2,11 @@ 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 @@ -17,16 +19,18 @@ 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 import io.legado.app.utils.toastOnUi import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.Dispatchers.Main 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 { @@ -46,7 +50,12 @@ object ImageProvider { } 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() val bitmapLruCache = object : LruCache(cacheSize) { override fun sizeOf(filePath: String, bitmap: Bitmap): Int { @@ -61,8 +70,8 @@ object ImageProvider { ) { //错误图片不能释放,占位用,防止一直重复获取图片 if (oldBitmap != errorBitmap) { - oldBitmap.recycle() - triggerRecycled = true + BitmapCache.add(oldBitmap) + //oldBitmap.recycle() //putDebug("ImageProvider: trigger bitmap recycle. URI: $filePath") //putDebug("ImageProvider : cacheUsage ${size()}bytes / ${maxSize()}bytes") } @@ -166,17 +175,27 @@ object ImageProvider { 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 { - val bitmap = BitmapUtils.decodeBitmap(vFile.absolutePath, width, height) + BitmapUtils.decodeBitmap(vFile.absolutePath, width, height) ?: SvgUtils.createBitmap(vFile.absolutePath, width, height) ?: throw NoStackTraceException(appCtx.getString(R.string.error_decode_bitmap)) - withContext(Main) { - bitmapLruCache.put(vFile.absolutePath, 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 @@ -193,17 +212,4 @@ object ImageProvider { }.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 - } - } diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt b/app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt index 746e1995941f..874b66024dcf 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt @@ -4,12 +4,10 @@ import android.content.Context import android.graphics.Canvas import android.graphics.Paint import android.graphics.RectF -import android.os.Build import android.util.AttributeSet import android.view.MotionEvent import android.view.View import io.legado.app.R -import io.legado.app.constant.AppLog import io.legado.app.constant.PreferKey import io.legado.app.data.entities.Bookmark import io.legado.app.help.book.isImage @@ -56,15 +54,7 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at var textPage: TextPage = TextPage() private set var isMainView = false - private var drawVisibleImageOnly = false - private var cacheIncreased = 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 reverseEndCursor = false @@ -119,8 +109,6 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at } canvas.clipRect(visibleRect) 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) 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 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( book, column.src, (column.end - column.start).toInt(), (lineBottom - lineTop).toInt() ) { - if (!drawVisibleImageOnly && isVisible) { - drawVisibleImageOnly = true - invalidate() - } + invalidate() } ?: return val rectF = if (textLine.isImage) { diff --git a/app/src/main/java/io/legado/app/ui/main/MainViewModel.kt b/app/src/main/java/io/legado/app/ui/main/MainViewModel.kt index 543b3d8c5c5b..af19338b0738 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainViewModel.kt @@ -31,7 +31,7 @@ import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import java.util.concurrent.CopyOnWriteArraySet +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executors import kotlin.math.min @@ -40,7 +40,7 @@ class MainViewModel(application: Application) : BaseViewModel(application) { private var upTocPool = Executors.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher() private val waitUpTocBooks = arrayListOf() - private val onUpTocBooks = CopyOnWriteArraySet() + private val onUpTocBooks = ConcurrentHashMap.newKeySet() val onUpBooksLiveData = MutableLiveData() private var upTocJob: Job? = null private var cacheBookJob: Job? = null diff --git a/app/src/main/java/io/legado/app/utils/BitmapCache.kt b/app/src/main/java/io/legado/app/utils/BitmapCache.kt new file mode 100644 index 000000000000..2f2e08411831 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/BitmapCache.kt @@ -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> = 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 + } + } + +} diff --git a/app/src/main/java/io/legado/app/utils/BitmapUtils.kt b/app/src/main/java/io/legado/app/utils/BitmapUtils.kt index 80c19e88f7ca..e962b5a4313d 100644 --- a/app/src/main/java/io/legado/app/utils/BitmapUtils.kt +++ b/app/src/main/java/io/legado/app/utils/BitmapUtils.kt @@ -34,6 +34,7 @@ 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) } }