From 361ee6b11cd989369fda0ac3da53b06f60b3f489 Mon Sep 17 00:00:00 2001 From: "Darryl L. Pierce" Date: Mon, 14 Jul 2025 16:02:18 -0400 Subject: [PATCH] Added caching pages and covers to improve performance [#125] --- .../view/comics/ComicBookListItemView.kt | 22 +++++- gradle/libs.versions.toml | 1 + iosVariant/iosVariant/Data/ImageLoader.swift | 23 +++++- iosVariant/iosVariant/HomeView.swift | 13 ++-- .../Views/Comics/ComicBooksView.swift | 71 +++++++++++-------- shared/build.gradle.kts | 1 + .../variant/adaptor/ArchiveAPI.kt | 38 +++++++++- .../variant/model/cache/ImageCache.kt | 26 +++++++ .../variant/viewmodel/VariantViewModel.kt | 1 - 9 files changed, 156 insertions(+), 40 deletions(-) create mode 100644 shared/src/commonMain/kotlin/org/comixedproject/variant/model/cache/ImageCache.kt diff --git a/androidVariant/src/main/java/org/comixedproject/variant/android/view/comics/ComicBookListItemView.kt b/androidVariant/src/main/java/org/comixedproject/variant/android/view/comics/ComicBookListItemView.kt index 0fab71c9..d04e57fa 100644 --- a/androidVariant/src/main/java/org/comixedproject/variant/android/view/comics/ComicBookListItemView.kt +++ b/androidVariant/src/main/java/org/comixedproject/variant/android/view/comics/ComicBookListItemView.kt @@ -1,3 +1,21 @@ +/* + * Variant - A digital comic book reading application for the iPad and Android tablets. + * Copyright (C) 2025, The ComiXed Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + */ + package org.comixedproject.variant.android.view.comics import android.graphics.BitmapFactory @@ -72,12 +90,12 @@ fun ComicBookListItemView( contentDescription = title ) - coroutineScope.launch(Dispatchers.IO) { + coroutineScope.launch(Dispatchers.Main) { Log.debug( TAG, "Loading content for ${comicBook.filename}:${cover.filename}" ) - coverContent = ArchiveAPI.loadPage( + coverContent = ArchiveAPI.loadCover( comicBook.path, cover.filename ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9c23c31c..7bd9239f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ kmp-io = { group = "io.github.skolson", name = "kmp-io", version = "0.2.1" } kfs-watch = { group = "io.github.irgaly.kfswatch", name = "kfswatch", version = "1.3.0" } kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version = "0.6.2" } multiplatform-settings = { group = "com.russhwolf", name = "multiplatform-settings", version = "1.3.0" } +stately-concurrent-collections = { group = "co.touchlab", name = "stately-concurrent-collections", version = "2.0.0" } # koin koin-core = { group = "io.insert-koin", name = "koin-core", version.ref = "koin-version" } diff --git a/iosVariant/iosVariant/Data/ImageLoader.swift b/iosVariant/iosVariant/Data/ImageLoader.swift index 0dfa8817..4d358ecb 100644 --- a/iosVariant/iosVariant/Data/ImageLoader.swift +++ b/iosVariant/iosVariant/Data/ImageLoader.swift @@ -33,7 +33,7 @@ class ImageLoader: ObservableObject { if comicBook.pages.count > 0 { self.pageFilename = (comicBook.pages[0] as! ComicPage).filename - doLoadPage() + doLoadCover() } } @@ -43,13 +43,32 @@ class ImageLoader: ObservableObject { doLoadPage() } - private func doLoadPage() { + private func doLoadCover() { Task { Log().debug( tag: TAG, message: "Loading cover image: \(self.comicFilename):\(self.pageFilename)" ) + let imageData = try await ArchiveAPI().loadCover( + comicFilename: self.comicFilename, + pageFilename: self.pageFilename + ) + + if imageData != nil { + self.image = imageData!.toUIImage() + } + } + + } + + private func doLoadPage() { + Task { + Log().debug( + tag: TAG, + message: + "Loading page image: \(self.comicFilename):\(self.pageFilename)" + ) let imageData = try await ArchiveAPI().loadPage( comicFilename: self.comicFilename, pageFilename: self.pageFilename diff --git a/iosVariant/iosVariant/HomeView.swift b/iosVariant/iosVariant/HomeView.swift index 31e84202..ba755eac 100644 --- a/iosVariant/iosVariant/HomeView.swift +++ b/iosVariant/iosVariant/HomeView.swift @@ -29,6 +29,7 @@ struct HomeView: View { var body: some View { TabView(selection: $currentDestination) { ComicBooksView( + comicBook: variantViewModel.comicBook, comicBookList: variantViewModel.comicBookList, selectionMode: variantViewModel.selectionMode, selectionList: variantViewModel.selectionList, @@ -44,22 +45,26 @@ struct HomeView: View { Log().info( tag: TAG, message: - "Toggling comic book select: \(comicBook.path)" + "Toggling comic book select: \(comicBook!.path)" ) variantViewModel.updateSelectionList( - filename: comicBook.path + filename: comicBook!.path ) } else { Log().info( tag: TAG, - message: "Reading comic book: \(comicBook.filename)" + message: "Reading comic book: \(comicBook?.filename)" ) variantViewModel.readComicBook(comicBook: comicBook) currentDestination = .comics } }, onDeleteComics: { - Log().info(tag: TAG, message: "Deleting \(variantViewModel.selectionList.count) comic book(s)") + Log().info( + tag: TAG, + message: + "Deleting \(variantViewModel.selectionList.count) comic book(s)" + ) Task { try await variantViewModel.deleteSelections() } diff --git a/iosVariant/iosVariant/Views/Comics/ComicBooksView.swift b/iosVariant/iosVariant/Views/Comics/ComicBooksView.swift index 90eb6f92..63b30905 100644 --- a/iosVariant/iosVariant/Views/Comics/ComicBooksView.swift +++ b/iosVariant/iosVariant/Views/Comics/ComicBooksView.swift @@ -23,45 +23,53 @@ import shared private let TAG = "ComicBooksView" struct ComicBooksView: View { + let comicBook: ComicBook? let comicBookList: [ComicBook] let selectionMode: Bool let selectionList: [String] var onSetSelectionMode: (Bool) -> Void - var onComicClicked: (ComicBook) -> Void + var onComicClicked: (ComicBook?) -> Void var onDeleteComics: () -> Void var body: some View { - NavigationStack { - ComicBookListView( - comicBookList: comicBookList, - selectionList: selectionList, - onClick: { comicBook in onComicClicked(comicBook) } + if comicBook != nil { + ReadingView( + comicBook: comicBook!, + onStopReading: { onComicClicked(nil) } ) - .toolbar { - ToolbarItemGroup(placement: .bottomBar) { - if selectionMode { - Button { - onSetSelectionMode(false) - } label: { - Image("selection_mode_on") - } + } else { + NavigationStack { + ComicBookListView( + comicBookList: comicBookList, + selectionList: selectionList, + onClick: { comicBook in onComicClicked(comicBook) } + ) + .toolbar { + ToolbarItemGroup(placement: .bottomBar) { + if selectionMode { + Button { + onSetSelectionMode(false) + } label: { + Image("selection_mode_on") + } - Button { - onDeleteComics() - } label: { - Image(systemName: "trash.fill") - } - .disabled(selectionList.isEmpty) - } else { - Button { - onSetSelectionMode(true) - } label: { - Image("selection_mode_off") + Button { + onDeleteComics() + } label: { + Image(systemName: "trash.fill") + } + .disabled(selectionList.isEmpty) + } else { + Button { + onSetSelectionMode(true) + } label: { + Image("selection_mode_off") + } } - } - Spacer() + Spacer() + } } } } @@ -70,33 +78,36 @@ struct ComicBooksView: View { #Preview { ComicBooksView( + comicBook: nil, comicBookList: COMIC_BOOK_LIST, selectionMode: false, selectionList: [], onSetSelectionMode: { _ in }, onComicClicked: { _ in }, - onDeleteComics: { } + onDeleteComics: {} ) } #Preview("selection mode on") { ComicBooksView( + comicBook: nil, comicBookList: COMIC_BOOK_LIST, selectionMode: true, selectionList: [COMIC_BOOK_LIST[0].path], onSetSelectionMode: { _ in }, onComicClicked: { _ in }, - onDeleteComics: { } + onDeleteComics: {} ) } #Preview("selection mode on no selections") { ComicBooksView( + comicBook: nil, comicBookList: COMIC_BOOK_LIST, selectionMode: true, selectionList: [], onSetSelectionMode: { _ in }, onComicClicked: { _ in }, - onDeleteComics: { } + onDeleteComics: {} ) } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 00342aad..97559110 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -54,6 +54,7 @@ kotlin { implementation(libs.kotlinx.datetime) implementation(libs.multiplatform.settings) implementation(libs.bundles.metadata) + implementation(libs.stately.concurrent.collections) api(libs.kmp.viewmodel.core) } diff --git a/shared/src/commonMain/kotlin/org/comixedproject/variant/adaptor/ArchiveAPI.kt b/shared/src/commonMain/kotlin/org/comixedproject/variant/adaptor/ArchiveAPI.kt index 3d5c2e70..b4ade01e 100644 --- a/shared/src/commonMain/kotlin/org/comixedproject/variant/adaptor/ArchiveAPI.kt +++ b/shared/src/commonMain/kotlin/org/comixedproject/variant/adaptor/ArchiveAPI.kt @@ -18,6 +18,7 @@ package org.comixedproject.variant.adaptor +import co.touchlab.stately.collections.ConcurrentMutableMap import com.oldguy.common.io.File import com.oldguy.common.io.ZipFile import org.comixedproject.variant.model.library.ComicBook @@ -29,6 +30,10 @@ private const val TAG = "ArchiveAPI" private const val UNKNOWN_METADATA = "Unknown" public object ArchiveAPI { + val coverCache = ConcurrentMutableMap() + var cachedComic = "" + val pageCache = ConcurrentMutableMap() + suspend fun loadComicBook(archive: File): ComicBook { val pages = mutableListOf() var metadata = ComicBookMetadata() @@ -71,8 +76,39 @@ public object ArchiveAPI { return result } + suspend fun loadCover(comicFilename: String, pageFilename: String): ByteArray? { + val key = "${comicFilename}:${pageFilename}" + + if (coverCache.contains(key)) { + Log.debug(TAG, "Retrieving cached cover for comic: ${comicFilename}") + return coverCache.get(key) + } + + val image = loadImageFromFile(comicFilename, pageFilename) + Log.debug(TAG, "Caching cover image: ${key}") + coverCache.put(key, image) + Log.debug(TAG, "Returning ${image?.size} bytes for ${key}") + return image + } + suspend fun loadPage(comicFilename: String, pageFilename: String): ByteArray? { - Log.debug(TAG, "Loading page entry: ${comicFilename}:${pageFilename}") + if (!cachedComic.equals(comicFilename)) { + Log.debug(TAG, "Resetting page caching for comic: ${comicFilename}") + pageCache.clear() + cachedComic = comicFilename + } else if (pageCache.contains(pageFilename)) { + Log.debug(TAG, "Retrieving cached page for comic: ${comicFilename}:${pageFilename}") + return pageCache.get(pageFilename) + } + + val image = loadImageFromFile(comicFilename, pageFilename) + Log.debug(TAG, "Caching page for comic: ${pageFilename}") + pageCache.put(pageFilename, image) + return image + } + + private suspend fun loadImageFromFile(comicFilename: String, pageFilename: String): ByteArray? { + Log.debug(TAG, "Loading image from file: ${comicFilename}:${pageFilename}") var result: ByteArray? = null try { ZipFile(File(comicFilename)).use { zip -> diff --git a/shared/src/commonMain/kotlin/org/comixedproject/variant/model/cache/ImageCache.kt b/shared/src/commonMain/kotlin/org/comixedproject/variant/model/cache/ImageCache.kt new file mode 100644 index 00000000..1f76cbd2 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/comixedproject/variant/model/cache/ImageCache.kt @@ -0,0 +1,26 @@ +/* + * Variant - A digital comic book reading application for the iPad and Android tablets. + * Copyright (C) 2025, The ComiXed Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + */ + +package org.comixedproject.variant.model.cache + +import kotlinx.serialization.Serializable + +@Serializable +data class ImageCache( + val entries: MutableMap = HashMap() +) diff --git a/shared/src/commonMain/kotlin/org/comixedproject/variant/viewmodel/VariantViewModel.kt b/shared/src/commonMain/kotlin/org/comixedproject/variant/viewmodel/VariantViewModel.kt index affa7977..6fc47978 100644 --- a/shared/src/commonMain/kotlin/org/comixedproject/variant/viewmodel/VariantViewModel.kt +++ b/shared/src/commonMain/kotlin/org/comixedproject/variant/viewmodel/VariantViewModel.kt @@ -63,7 +63,6 @@ open class VariantViewModel( Log.debug(TAG, "_libraryDirectory=${directory}") _libraryDirectory = directory viewModelScope.coroutineScope.launch { - if (!File(_libraryDirectory).exists) { Log.info(TAG, "Creating library directory: ${_libraryDirectory}") File(_libraryDirectory).makeDirectory()