观影卡片格数变成奇数(21 格):配图按海报去重、列表按行过滤,两个单位不一致 #3

Open
opened 2026-10-28 02:07:11 +00:00 by laoyang · 2 comments
Owner

现象(用户反馈)

「怎么是单数的?我这里看,显示的是21张图片。观影卡片。」

上一版(f0d0ab9)修掉了"只有 6 张图",但格数变成了 21 这种单数,
两列布局最后一行只剩一个格子,右侧空出一块。

根因(已定位,是上一版修法留下的结构问题)

上一版的设计意图是「网格子数由图数量决定」,做法是:

// 配图:按 poster **去重**,最多 20 张
for (const r of rows) {
  if (r.poster !== '' && keys.indexOf(r.poster) < 0 && keys.length < CARD_IMG_MAX) {
    keys.push(r.poster);
  }
}
// ...
// 列表:保留**所有** img !== '' 的行
const list: FbMovieCardRow[] = [];
for (const r of rows) {
  if (r.img !== '') { list.push(r); }
}

问题在于两条路径的计数单位不同:

单位 上限
keys 海报(去重后的电影) 20
list 行(观影记录) 无(只要 img 非空就收)

⇒ 一部电影看了两场(两天各一场、或重映),两行 poster 相同,
配图时只下一次、但两行都会拿到同一个 img,于是 list 里有图的
行数 > 图片张数。既可能超过 20,也可能出现奇数。

实测佐证(本轮真机扫描里「镖人:风起大漠」在一屏内出现了两次)。

为什么是"单数"特别刺眼

两列栅格,奇数格必然导致最后一行右侧留空。用户看到的就是"21 张图"。

修复方向

不论走哪条,必须保证 list.length 是偶数,且与图片张数自洽。候选:

A. 按海报去重后再建行(推荐)
list 的语义本来就是"海报墙",一格一部电影而不是一场观影。
按 poster 去重、取前 N 部,天然不会撞图,且 N 可直接取偶数。
副作用:同一部片看两次只显示一格(符合"海报墙"语义,但会少显示记录)。

B. 保留按行,但把总数裁成偶数
list 截断到 len - (len % 2)。改动最小,但"同一部片出现两格相同海报"
在视觉上仍是重复。

C. 按行且不去重,但放开图数上限
keys 也改按行收集(同片重复下载)。受 20 张 / 10MB 上限约束,
且同一个 URL 下两次是浪费。

验收标准

  1. 观影 4×4 / 6×4 两张卡:格数为偶数,最后一行两格都齐全。
  2. 每一格都有海报(保持上一版的成果,不得回退)。
  3. 同一部电影不出现两块一模一样的海报(若采纳 A)。
  4. 图片张数 ≤ 20、解码后总内存 < 10MB(官方 API 20+ 规格)。
  5. 2×2/2×4 卡不受影响(它们不画图,走 recentJson)。
## 现象(用户反馈) > 「怎么是单数的?我这里看,显示的是21张图片。观影卡片。」 上一版(`f0d0ab9`)修掉了"只有 6 张图",但**格数变成了 21 这种单数**, 两列布局最后一行只剩一个格子,右侧空出一块。 ## 根因(已定位,是上一版修法留下的结构问题) 上一版的设计意图是「**网格子数由图数量决定**」,做法是: ```typescript // 配图:按 poster **去重**,最多 20 张 for (const r of rows) { if (r.poster !== '' && keys.indexOf(r.poster) < 0 && keys.length < CARD_IMG_MAX) { keys.push(r.poster); } } // ... // 列表:保留**所有** img !== '' 的行 const list: FbMovieCardRow[] = []; for (const r of rows) { if (r.img !== '') { list.push(r); } } ``` 问题在于**两条路径的计数单位不同**: | | 单位 | 上限 | |---|---|---| | `keys` | **海报(去重后的电影)** | 20 | | `list` | **行(观影记录)** | 无(只要 `img` 非空就收) | ⇒ 一部电影看了两场(两天各一场、或重映),两行 `poster` 相同, 配图时只下一次、但**两行都会拿到同一个 `img`**,于是 `list` 里有图的 **行数 > 图片张数**。既可能超过 20,也可能出现奇数。 实测佐证(本轮真机扫描里「镖人:风起大漠」在一屏内出现了两次)。 ### 为什么是"单数"特别刺眼 两列栅格,奇数格必然导致最后一行右侧留空。用户看到的就是"21 张图"。 ## 修复方向 不论走哪条,**必须保证 `list.length` 是偶数**,且与图片张数自洽。候选: **A. 按海报去重后再建行**(推荐) `list` 的语义本来就是"海报墙",一格一部**电影**而不是一场观影。 按 `poster` 去重、取前 N 部,天然不会撞图,且 N 可直接取偶数。 副作用:同一部片看两次只显示一格(符合"海报墙"语义,但会少显示记录)。 **B. 保留按行,但把总数裁成偶数** `list` 截断到 `len - (len % 2)`。改动最小,但"同一部片出现两格相同海报" 在视觉上仍是重复。 **C. 按行且不去重,但放开图数上限** `keys` 也改按行收集(同片重复下载)。受 20 张 / 10MB 上限约束, 且同一个 URL 下两次是浪费。 ## 验收标准 1. 观影 4×4 / 6×4 两张卡:**格数为偶数**,最后一行两格都齐全。 2. 每一格都有海报(保持上一版的成果,不得回退)。 3. 同一部电影不出现两块一模一样的海报(若采纳 A)。 4. 图片张数 ≤ 20、解码后总内存 < 10MB(官方 API 20+ 规格)。 5. 2×2/2×4 卡不受影响(它们不画图,走 `recentJson`)。
Author
Owner

已修复并编译通过,真机待验(设备掉线)

提交:acb2ead(fix(card): 观影海报墙格数改偶数并按海报去重 #3)

改了什么

entry/src/main/ets/data/FbCardMediaData.ets 里 list 的构造:

const list: FbMovieCardRow[] = [];
const seenPoster: string[] = [];
for (const r of rows) {
  if (r.img === '' || r.poster === '') {
    continue;                       // 没图 / 没海报键 → 不进墙
  }
  if (seenPoster.indexOf(r.poster) >= 0) {
    continue;                       // 同一部电影只留一格
  }
  seenPoster.push(r.poster);
  list.push(r);
}
// 两列栅格:奇数格会让最后一行右侧空半格 ⇒ 裁掉末尾那个
if (list.length % 2 === 1) {
  list.pop();
}

三条要点:

点 为什么
去重键用 poster 而不是 title 同名重映 / 译名相同的不同片不算同一部;只有 poster 相同才是真的同一部
poster === '' 显式跳过 没有海报键的行即使 img 侥幸非空也不该进墙。写成显式判断,免得以后逻辑变动时漏掉
裁剪写 len - (len % 2) 而非写死 20/18 图少时(比如只有 7 张)写死减法会变负索引

验证

① 静态守卫:1324 通过 / 0 失败(新增 groupV97() 六条)

② 反向验证:13 通过 / 0 失败,6 条注入各自命中目标断言

注入 内容 结果
A 去掉 poster 去重(回到按行) ✅ 红 1 条
B 去掉裁偶数(奇数格回来) ✅ 红 1 条
C 去重键改成 title ✅ 红 1 条
D 裁剪写死 20 ✅ 红 1 条
E CARD_IMG_MAX 改 19(奇数) ✅ 红 1 条
F 只判奇数、不 pop ✅ 红 1 条

③ 编译:BUILD SUCCESSFUL,产物 6.2M

🔴 反向验证又抓出一条假绿断言

v97 的 C 项(去重键不得用 title)第一版这么写:

ok(!/seenTitle\.indexOf\(/.test(codeFm) && !/seen\.indexOf\(r\.title\)/.test(codeFm), ...)

两个变量名都是我猜的。可实际注入是把
seenPoster.indexOf(r.poster) 改成 seenPoster.indexOf(r.title) ——
变量名一个都没变,只换了入参 ⇒ 注入后该项仍然 ✅,又一条恒真断言。

改成「任何 indexOf( 的入参都不能是 r.title」:

ok(!/indexOf\(\s*r\.title\s*\)/.test(codeFm), ...)

教训(与 #462 同族):判据不要枚举「我猜的变量名」,
猜错了就恒真。要对「可能的写法空间」做闭合否定。

顺带修掉两个挡住验证的环境问题

与本次 bug 无关,但会让「编译/装机」这一步直接跑不起来:

  1. PackageHap 缺 java:只报 Error Code: 00308018 Unknown Error,
    真因 spawn java ENOENT 藏在 .hvigor/outputs/build-logs/build.log 深处。
    DevEco 自带 jbr 但不会自动进 PATH。
  2. PATH 不能用 D:/... 写法:MSYS2/Git Bash 的 PATH 是冒号分隔的
    POSIX 路径列表,塞进 D:/Program Files/... 时那个冒号会被当分隔符,
    路径被劈成 D 和 /Program Files/... 两段 ⇒ java: command not found,
    症状和「根本没设」一模一样。必须写 /d/Program Files/...。

两条都已写进 familybook_harmonyos_run.sh 并加注释。

待办

⏸ 真机未验:hdc list targets 又变空了(只剩 COM3/COM4 两个 UART 口),
重启 hdc 服务也没恢复。等设备重新连上后,重点验:

  • 观影 4×4 / 6×4 的格数是否为偶数(不应再出现 21)
  • 最后一行两格是否都齐全、右侧不再空半格
  • 每一格仍有海报(不得回退 #2 的成果)
  • 同一部电影不再出现两块一样的海报
## 已修复并编译通过,真机待验(设备掉线) 提交:`acb2ead`(`fix(card): 观影海报墙格数改偶数并按海报去重 #3`) ### 改了什么 `entry/src/main/ets/data/FbCardMediaData.ets` 里 `list` 的构造: ```typescript const list: FbMovieCardRow[] = []; const seenPoster: string[] = []; for (const r of rows) { if (r.img === '' || r.poster === '') { continue; // 没图 / 没海报键 → 不进墙 } if (seenPoster.indexOf(r.poster) >= 0) { continue; // 同一部电影只留一格 } seenPoster.push(r.poster); list.push(r); } // 两列栅格:奇数格会让最后一行右侧空半格 ⇒ 裁掉末尾那个 if (list.length % 2 === 1) { list.pop(); } ``` 三条要点: | 点 | 为什么 | |---|---| | 去重键用 `poster` 而不是 `title` | 同名重映 / 译名相同的**不同片**不算同一部;只有 poster 相同才是真的同一部 | | `poster === ''` 显式跳过 | 没有海报键的行即使 `img` 侥幸非空也不该进墙。写成显式判断,免得以后逻辑变动时漏掉 | | 裁剪写 `len - (len % 2)` 而非写死 20/18 | 图少时(比如只有 7 张)写死减法会变负索引 | ### 验证 **① 静态守卫**:`1324 通过 / 0 失败`(新增 `groupV97()` 六条) **② 反向验证**:`13 通过 / 0 失败`,6 条注入各自命中目标断言 | 注入 | 内容 | 结果 | |---|---|---| | A | 去掉 poster 去重(回到按行) | ✅ 红 1 条 | | B | 去掉裁偶数(奇数格回来) | ✅ 红 1 条 | | C | 去重键改成 `title` | ✅ 红 1 条 | | D | 裁剪写死 20 | ✅ 红 1 条 | | E | `CARD_IMG_MAX` 改 19(奇数) | ✅ 红 1 条 | | F | 只判奇数、不 pop | ✅ 红 1 条 | **③ 编译**:BUILD SUCCESSFUL,产物 6.2M ### 🔴 反向验证又抓出一条假绿断言 `v97` 的 C 项(去重键不得用 title)第一版这么写: ```javascript ok(!/seenTitle\.indexOf\(/.test(codeFm) && !/seen\.indexOf\(r\.title\)/.test(codeFm), ...) ``` 两个变量名都是**我猜的**。可实际注入是把 `seenPoster.indexOf(r.poster)` 改成 `seenPoster.indexOf(r.title)` —— 变量名一个都没变,只换了入参 ⇒ 注入后该项仍然 ✅,又一条恒真断言。 改成「**任何** `indexOf(` 的入参都不能是 `r.title`」: ```javascript ok(!/indexOf\(\s*r\.title\s*\)/.test(codeFm), ...) ``` > 教训(与 #462 同族):**判据不要枚举「我猜的变量名」**, > 猜错了就恒真。要对「可能的写法空间」做闭合否定。 ### 顺带修掉两个挡住验证的环境问题 与本次 bug 无关,但会让「编译/装机」这一步直接跑不起来: 1. **`PackageHap` 缺 java**:只报 `Error Code: 00308018 Unknown Error`, 真因 `spawn java ENOENT` 藏在 `.hvigor/outputs/build-logs/build.log` 深处。 DevEco 自带 jbr 但不会自动进 PATH。 2. **PATH 不能用 `D:/...` 写法**:MSYS2/Git Bash 的 PATH 是冒号分隔的 POSIX 路径列表,塞进 `D:/Program Files/...` 时那个冒号会被当分隔符, 路径被劈成 `D` 和 `/Program Files/...` 两段 ⇒ `java: command not found`, 症状和「根本没设」一模一样。必须写 `/d/Program Files/...`。 两条都已写进 `familybook_harmonyos_run.sh` 并加注释。 ### 待办 ⏸ **真机未验**:`hdc list targets` 又变空了(只剩 COM3/COM4 两个 UART 口), 重启 hdc 服务也没恢复。等设备重新连上后,重点验: - 观影 4×4 / 6×4 的格数是否为**偶数**(不应再出现 21) - 最后一行两格是否都齐全、右侧不再空半格 - 每一格仍有海报(不得回退 #2 的成果) - 同一部电影不再出现两块一样的海报
Author
Owner

代码已落地并推送上库,真机验收因设备掉线暂缓

一、修法(acb2ead)

FbCardMediaData.ets 的 list 构造改成按 poster 去重 + 裁偶数:

const list: FbMovieCardRow[] = [];
const seenPoster: string[] = [];
for (const r of rows) {
  if (r.img === '' || r.poster === '') { continue; }      // ① 没海报键的行不进墙
  if (seenPoster.indexOf(r.poster) >= 0) { continue; }    // ② 按 poster 去重
  seenPoster.push(r.poster);
  list.push(r);
}
if (list.length % 2 === 1) { list.pop(); }                // ③ 裁成偶数

三条都不能省:

  1. 去重键用 poster,不是 title —— 同名重映 / 译名相同的不同片
    不该被合并,只有 poster 相同才是真的同一部。
  2. poster === '' 显式跳过 —— 没海报键的行即使 img 侥幸非空也不该进墙。
  3. 裁剪用 len - (len % 2),不写死 20 / 18 —— 图少时(比如只有 7 张)
    写死减法会变负索引。

二、补一个语义说明(3e910f1,只改注释)

上一版在同一位置写着「这样"格子数"和"图数量"在结构上永远相等」。
这句话是错的,也正是本次 21 格的思想源头:

单位 上限
keys 海报(去重) 20
list 行 无 → 再裁偶数

两个集合基数不可能恒等,能说的只是「≤,不是 ==」。
把错误断言留在注释里,下一个人会照着它推理、再踩一次同样的坑,所以一并清掉。

三、本地验证(已完成)

项目 结果
ArkTS 静态守卫 1324 / 0(含新增 groupV97 六条断言)
v97 反向验证 13 / 0(6 条注入各自命中目标断言)
编译 ✅ BUILD SUCCESSFUL,产物 entry-default-signed.hap 6.2M

反向验证的 6 条注入(证明判据不是恒绿):

  • A 去掉 poster 去重 → 命中「list 按 poster 去重」
  • B 去掉裁偶数 → 命中「裁偶数」
  • C 去重键改成 title → 命中「去重键不得用 title」
  • D 裁剪写死 20 → 命中「裁剪不写死数字」
  • E CARD_IMG_MAX 改成 19 → 命中「图数上限是偶数」
  • F 只判奇数不 pop(等于没修)→ 命中「确实执行了 list.pop()」

四、⏳ 尚未完成:真机验收

设备当前掉线(hdc list targets 只剩 COM3/COM4 两个 UART 口,
重启 hdc 服务后仍为 [Empty]),无法装机。

等设备恢复后要确认的四点:

  1. 4×4 / 6×4 海报墙格数为偶数(不再是 21);
  2. 最后一行两格齐全,右侧无空半格;
  3. 每格仍有海报(工单 #2 的成果不能回退);
  4. 同一部电影不再出现两块相同海报(去重生效)。

验收没过之前不关单。

## 代码已落地并推送上库,真机验收因设备掉线暂缓 ### 一、修法(`acb2ead`) `FbCardMediaData.ets` 的 `list` 构造改成**按 poster 去重 + 裁偶数**: ```typescript const list: FbMovieCardRow[] = []; const seenPoster: string[] = []; for (const r of rows) { if (r.img === '' || r.poster === '') { continue; } // ① 没海报键的行不进墙 if (seenPoster.indexOf(r.poster) >= 0) { continue; } // ② 按 poster 去重 seenPoster.push(r.poster); list.push(r); } if (list.length % 2 === 1) { list.pop(); } // ③ 裁成偶数 ``` 三条都不能省: 1. **去重键用 `poster`,不是 `title`** —— 同名重映 / 译名相同的**不同片** 不该被合并,只有 poster 相同才是真的同一部。 2. `poster === ''` 显式跳过 —— 没海报键的行即使 `img` 侥幸非空也不该进墙。 3. 裁剪用 `len - (len % 2)`,**不写死 20 / 18** —— 图少时(比如只有 7 张) 写死减法会变负索引。 ### 二、补一个语义说明(`3e910f1`,只改注释) 上一版在同一位置写着「这样"格子数"和"图数量"在结构上永远相等」。 **这句话是错的,也正是本次 21 格的思想源头**: | | 单位 | 上限 | |---|---|---| | `keys` | 海报(去重) | 20 | | `list` | 行 | 无 → 再裁偶数 | 两个集合基数不可能恒等,能说的只是「≤,不是 ==」。 把错误断言留在注释里,下一个人会照着它推理、再踩一次同样的坑,所以一并清掉。 ### 三、本地验证(已完成) | 项目 | 结果 | |---|---| | ArkTS 静态守卫 | **1324 / 0**(含新增 groupV97 六条断言) | | v97 反向验证 | **13 / 0**(6 条注入各自命中目标断言) | | 编译 | ✅ BUILD SUCCESSFUL,产物 `entry-default-signed.hap` 6.2M | 反向验证的 6 条注入(证明判据不是恒绿): - A 去掉 poster 去重 → 命中「`list` 按 poster 去重」 - B 去掉裁偶数 → 命中「裁偶数」 - C 去重键改成 `title` → 命中「去重键不得用 title」 - D 裁剪写死 20 → 命中「裁剪不写死数字」 - E `CARD_IMG_MAX` 改成 19 → 命中「图数上限是偶数」 - F 只判奇数不 pop(等于没修)→ 命中「确实执行了 `list.pop()`」 ### 四、⏳ 尚未完成:真机验收 设备当前**掉线**(`hdc list targets` 只剩 `COM3`/`COM4` 两个 UART 口, 重启 hdc 服务后仍为 `[Empty]`),无法装机。 等设备恢复后要确认的四点: 1. 4×4 / 6×4 海报墙格数为**偶数**(不再是 21); 2. 最后一行**两格齐全**,右侧无空半格; 3. 每格**仍有海报**(工单 #2 的成果不能回退); 4. 同一部电影**不再出现两块相同海报**(去重生效)。 **验收没过之前不关单。**
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: laoyang/FamilyBook_harmonyos#3