From 58a97d1725041820a99419ac3542173596d5d1ee Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 15 Jun 2026 14:02:21 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20WebP=20=E6=96=87=E4=BB=B6=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=94=B9=E7=94=B1=E6=9E=84=E5=BB=BA=E6=9C=9F=20BuildC?= =?UTF-8?q?onfig=20=E6=B3=A8=E5=85=A5,=E6=B6=88=E9=99=A4=20(1..152)=20?= =?UTF-8?q?=E7=A1=AC=E7=BC=96=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题 - AnimatedWebp.kt 原写死 WEBP_FILES = (1..152).map{...}, 与磁盘文件耦合却无校验 - 加 153.webp → 永远不会被随机到; 删某个 → random() 偶发命中不存在的资源 - 这是静默失败, 运行期无报错, 难排查 改动 - core/build.gradle.kts defaultConfig: 构建期扫描 assets/animations/ 生成 buildConfigField("String[]", "WEBP_FILES", "new String[]{...}") 含 require(webpFiles.isNotEmpty()) 防空目录构建 - AnimatedWebp.kt: WEBP_FILES 从 private 硬编码改为 internal val = BuildConfig.WEBP_FILES.toList() (internal 让同模块测试可访问) - 新增 AnimatedWebpFilesTest: 2 个守卫测试 1. webpFilesMatchDirectoryContents: WEBP_FILES 必须与 assets/animations/ 一一对应 2. webpFilesUseZeroPaddedThreeDigitNames: 锁定 NNN.webp 命名约定 TDD 流程 - 先写测试 → 编译失败(WEBP_FILES 是 private) → 红灯成立 - 改实现 → 测试 PASS → 绿灯 - 突变验证: 临时改 WEBP_FILES = (1..150) 漏掉 151/152 → webpFilesMatchDirectoryContents 立即 FAIL, 守卫有效 - 恢复后全量 146 个测试 0 失败 设计说明 - 当前实现里 WEBP_FILES 与目录都来自同一次构建期扫描, 二者天然一致 - 测试的核心价值是锁定「两者一致」不变量, 防止有人回退成硬编码 (突变验证已证明: 回退后测试立即失败) - BuildConfig 生成结果验证: String[] 含 152 个元素 001~152.webp 验证 - ./gradlew :core:testDebugUnitTest → 146 tests, 0 failures, 0 errors - ./gradlew :app:assembleDebug → BUILD SUCCESSFUL - BuildConfig.java: public static final String[] WEBP_FILES = new String[]{"001.webp",...,"152.webp"} --- core/build.gradle.kts | 15 ++++++ .../plus/rua/project/ui/AnimatedWebp.kt | 8 ++- .../rua/project/ui/AnimatedWebpFilesTest.kt | 49 +++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 core/src/test/kotlin/plus/rua/project/ui/AnimatedWebpFilesTest.kt diff --git a/core/build.gradle.kts b/core/build.gradle.kts index aa3b9d9..fd3c00b 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -12,6 +12,21 @@ android { defaultConfig { minSdk = libs.versions.android.minSdk.get().toInt() consumerProguardFiles("proguard-rules.pro") + + // 构建期扫描 assets/animations/ 生成 WebP 文件列表,避免运行期硬编码 (1..152) + // 与 assets/ 目录耦合却不校验,导致增删文件后隐性 bug + val webpFiles = layout.projectDirectory + .dir("src/main/assets/animations") + .asFile + .listFiles { f -> f.extension.equals("webp", ignoreCase = true) } + ?.map { it.name } + ?.sorted() + ?: emptyList() + require(webpFiles.isNotEmpty()) { "assets/animations/ 不应为空,请检查目录" } + // 拼成 Java 数组字面量: new String[]{"001.webp","002.webp",...} + val quoted = webpFiles.joinToString(",") { name -> "\"$name\"" } + val arrayLiteral = "new String[]{$quoted}" + buildConfigField("String[]", "WEBP_FILES", arrayLiteral) } buildTypes { diff --git a/core/src/main/kotlin/plus/rua/project/ui/AnimatedWebp.kt b/core/src/main/kotlin/plus/rua/project/ui/AnimatedWebp.kt index efae534..b641955 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/AnimatedWebp.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/AnimatedWebp.kt @@ -15,11 +15,15 @@ import com.github.panpf.sketch.rememberAsyncImageState import com.github.panpf.sketch.request.ImageOptions import com.github.panpf.sketch.request.repeatCount import plus.rua.project.getWebpUri +import plus.rua.project.shared.BuildConfig /** - * WebP 动画文件名列表(001.webp ~ 152.webp)。 + * WebP 动画文件名列表,由构建期 `BuildConfig.WEBP_FILES` 注入 + * (见 `core/build.gradle.kts` 扫描 `assets/animations/` 的 `buildConfigField`)。 + * + * `internal` 让同模块测试可直接访问(见 `AnimatedWebpFilesTest`)。 */ -private val WEBP_FILES = (1..152).map { "${it.toString().padStart(3, '0')}.webp" } +internal val WEBP_FILES: List = BuildConfig.WEBP_FILES.toList() /** * 显示动画 WebP 图片,切换日期时随机选择一个。 diff --git a/core/src/test/kotlin/plus/rua/project/ui/AnimatedWebpFilesTest.kt b/core/src/test/kotlin/plus/rua/project/ui/AnimatedWebpFilesTest.kt new file mode 100644 index 0000000..2766739 --- /dev/null +++ b/core/src/test/kotlin/plus/rua/project/ui/AnimatedWebpFilesTest.kt @@ -0,0 +1,49 @@ +package plus.rua.project.ui + +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.junit.Test + +/** + * 守卫:运行期使用的 WebP 文件列表必须与 assets/animations/ 目录实际内容一致。 + * + * 防止两类回归: + * 1. 有人把 BuildConfig 方案回退成硬编码 (1..152),与目录脱钩后增删文件即隐性 bug + * 2. assets/animations/ 在 CI / 打包流程中意外丢失或被 Git LFS 过滤掉 + * + * 注:当前实现里 WEBP_FILES 与目录都来自同一次构建期扫描,二者天然一致; + * 本测试的核心价值是锁定「两者一致」这个不变量,使上述回归一旦发生即立即失败。 + */ +class AnimatedWebpFilesTest { + + private val animationsDir = File("src/main/assets/animations") + + @Test + fun webpFilesMatchDirectoryContents() { + assertTrue(animationsDir.exists(), "assets/animations 目录应存在: ${animationsDir.absolutePath}") + + val onDisk = animationsDir.listFiles { f -> f.extension.equals("webp", ignoreCase = true) } + ?.map { it.name } + ?.sorted() + ?: emptyList() + assertTrue(onDisk.isNotEmpty(), "assets/animations 不应为空(若失败请检查 Git LFS / checkout 完整性)") + + // WEBP_FILES 由构建期 BuildConfig 注入(见 core/build.gradle.kts 的 buildConfigField) + val inCode = WEBP_FILES.sorted() + + assertEquals(onDisk, inCode, "WEBP_FILES 必须与 assets/animations/ 实际 webp 文件一一对应") + } + + @Test + fun webpFilesUseZeroPaddedThreeDigitNames() { + // 锁定命名约定:NNN.webp(三位零填充),防止有人误改成 1.webp / 01.webp 等 + val expected = Regex("""^\d{3}\.webp$""") + WEBP_FILES.forEach { name -> + assertTrue( + expected.matches(name), + "文件名 $name 不符合 NNN.webp 约定,getWebpUri 与 AnimatedWebp 依赖此格式", + ) + } + } +}