Procházet zdrojové kódy

fix: 修复自定义字体切换与清除不生效

rememberCustomFont 用 produceState 时,key 变化只会重启 producer 而不会重置
value,加上 value == null 的守卫,第一次加载成功后任何路径变化都被跳过,
表现为只有第一次选字体生效、后续切换和清除都无反应。改为 path 参数直接决定
清除、cachedFont 跟随 path、loadedFont 跨 path 保留,切换时旧字体一直用到
新字体就位,中途不回落默认字体。

字体复制原本每次都起独立协程,快速切换后再清除时,未完成的复制会晚于清除
写回 appFontPath,导致清除失效并留下多份副本。现在选择与清除串行执行,
新任务先取消并等待上一个结束,复制完成后确认自身未被取代才写回路径。

副本改用内容摘要命名,同一字体无论导入多少次都指向同一路径,不再产生重复
文件,字体缓存也能按路径命中;清理时扫描私有目录中用不到的 app_font 副本,
不涉及主题包导入的 theme_ 资源。
5151561 před 3 měsíci
rodič
revize
d216067c8a

+ 66 - 23
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigViewModel.kt

@@ -21,7 +21,12 @@ import io.legado.app.utils.openInputStream
 import java.io.File
 import java.io.FileOutputStream
 import java.util.UUID
+import kotlin.coroutines.cancellation.CancellationException
+import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.ensureActive
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
@@ -48,6 +53,8 @@ class ThemeConfigViewModel(
     private val _effects = MutableSharedFlow<ThemeConfigEffect>(extraBufferCapacity = 16)
     val effects = _effects.asSharedFlow()
 
+    private var fontJob: Job? = null
+
     init {
         viewModelScope.launch {
             appShellSettingsGateway.settings.collect { settings ->
@@ -142,7 +149,7 @@ class ThemeConfigViewModel(
             is ThemeConfigIntent.SelectBackground -> setBackground(intent.uri, intent.dark)
             is ThemeConfigIntent.RemoveBackground -> removeBackground(intent.dark)
             is ThemeConfigIntent.SelectAppFont -> setAppFont(intent.file)
-            ThemeConfigIntent.ClearAppFont -> updateTheme { it.copy(appFontPath = null) }
+            ThemeConfigIntent.ClearAppFont -> clearAppFont()
             is ThemeConfigIntent.SetFontFolder -> viewModelScope.launch {
                 readSettingsGateway.update { it.copy(fontFolder = intent.path) }
             }
@@ -368,32 +375,68 @@ class ThemeConfigViewModel(
     }
 
     private fun setAppFont(fileDoc: FileDoc) {
-        viewModelScope.launch(Dispatchers.IO) {
-            runCatching {
-                val extension = fileDoc.name.substringAfterLast('.', "ttf")
-                    .lowercase()
-                    .takeIf { it.matches(Regex("[a-z0-9]{1,8}")) }
-                    ?: "ttf"
-                val fontDir = File(appCtx.filesDir, "fonts").apply { mkdirs() }
-                val target = File(fontDir, "app_font_${UUID.randomUUID()}.$extension")
-                val temp = File(fontDir, "${target.name}.tmp")
+        launchFontJob {
+            val extension = fileDoc.name.substringAfterLast('.', "ttf")
+                .lowercase()
+                .takeIf { it.matches(Regex("[a-z0-9]{1,8}")) }
+                ?: "ttf"
+            val fontDir = appFontDir()
+            val temp = File(fontDir, "app_font_${UUID.randomUUID()}.tmp")
+            val target = try {
                 fileDoc.openInputStream().getOrThrow().use { input ->
                     FileOutputStream(temp).use(input::copyTo)
                 }
-                if (!temp.renameTo(target)) {
-                    temp.copyTo(target, overwrite = true)
-                    temp.delete()
-                }
-                val oldPath = themeSettingsGateway.currentSettings.appFontPath
-                themeSettingsGateway.update { it.copy(appFontPath = target.absolutePath) }
-                oldPath?.let(::File)
-                    ?.takeIf {
-                        it != target &&
-                            it.parentFile == fontDir &&
-                            it.name.startsWith("app_font")
+                // 以内容摘要命名:同一字体无论导入多少次都指向同一路径,
+                // 既不会留下重复副本,字体缓存也能按路径命中。
+                val digest = temp.inputStream().use(MD5Utils::md5Encode)
+                File(fontDir, "app_font_$digest.$extension").also { target ->
+                    if (target.isFile) {
+                        temp.delete()
+                    } else if (!temp.renameTo(target)) {
+                        temp.copyTo(target, overwrite = true)
+                        temp.delete()
                     }
-                    ?.delete()
-            }.onFailure(Throwable::printStackTrace)
+                }
+            } catch (e: Throwable) {
+                temp.delete()
+                throw e
+            }
+            // 复制期间可能已被新的选择或清除取代,此时不能写回路径。
+            ensureActive()
+            themeSettingsGateway.update { it.copy(appFontPath = target.absolutePath) }
+            pruneCopiedFonts(keep = target)
+        }
+    }
+
+    private fun clearAppFont() {
+        launchFontJob {
+            themeSettingsGateway.update { it.copy(appFontPath = null) }
+            pruneCopiedFonts()
+        }
+    }
+
+    /**
+     * 字体的选择与清除串行执行:新任务先取消并等待上一个结束,
+     * 避免未完成的复制晚于清除写回 appFontPath。
+     */
+    private fun launchFontJob(block: suspend CoroutineScope.() -> Unit) {
+        val previous = fontJob
+        fontJob = viewModelScope.launch(Dispatchers.IO) {
+            previous?.cancelAndJoin()
+            runCatching { block() }
+                .onFailure { if (it !is CancellationException) it.printStackTrace() }
+        }
+    }
+
+    private fun appFontDir() = File(appCtx.filesDir, "fonts").apply { mkdirs() }
+
+    /**
+     * 清理私有目录里已经用不到的字体副本,只认本应用复制的 app_font 前缀,
+     * 不碰主题包导入的 theme_ 资源。
+     */
+    private fun pruneCopiedFonts(keep: File? = null) {
+        appFontDir().listFiles()?.forEach { file ->
+            if (file.name.startsWith("app_font") && file != keep) file.delete()
         }
     }
 }

+ 18 - 12
app/src/main/java/io/legado/app/ui/theme/ThemeComponents.kt

@@ -12,8 +12,11 @@ import androidx.compose.material3.Shapes
 import androidx.compose.material3.Typography
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.produceState
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.compositeOver
 import androidx.compose.ui.platform.LocalContext
@@ -29,20 +32,23 @@ import top.yukonga.miuix.kmp.theme.defaultTextStyles
 @Composable
 fun rememberCustomFont(fontPath: String?): FontFamily? {
     val context = LocalContext.current.applicationContext
-    val cachedFont = remember(fontPath) {
-        fontPath?.let { synchronized(customFontCache) { customFontCache.get(it) } }
+    val path = fontPath?.takeIf(String::isNotBlank)
+    val cachedFont = remember(path) {
+        path?.let { synchronized(customFontCache) { customFontCache.get(it) } }
     }
-    return produceState(cachedFont, fontPath, context) {
-        if (value == null) {
-            value = fontPath?.takeIf(String::isNotBlank)?.let { path ->
-                withContext(Dispatchers.IO) {
-                    loadCustomFont(context, path)?.also { fontFamily ->
-                        synchronized(customFontCache) { customFontCache.put(path, fontFamily) }
-                    }
-                }
+    // 加载结果跨 path 保留:切换字体时旧字体一直用到新字体就位,
+    // 中途不回落默认字体。
+    var loadedFont by remember(context) { mutableStateOf<FontFamily?>(null) }
+    LaunchedEffect(path, context) {
+        if (path == null || cachedFont != null) return@LaunchedEffect
+        loadedFont = withContext(Dispatchers.IO) {
+            loadCustomFont(context, path)?.also {
+                synchronized(customFontCache) { customFontCache.put(path, it) }
             }
         }
-    }.value
+    }
+    // path 为空即"清除",必须当帧生效,不能受 loadedFont 影响。
+    return if (path == null) null else cachedFont ?: loadedFont
 }
 
 private val customFontCache = LruCache<String, FontFamily>(4)