〖共创稿事节〗HarmonyOS 7 新特性实战(52):审核说「内容太少」,我把每个数字都换成了算出来的
# 〖共创稿事节〗HarmonyOS 7 新特性实战(52):审核说「内容太少」,我把每个数字都换成了算出来的
2026-09-11 的审核报告回来了。第一组是影响上架的功能异常,原文只有三句话:
> 1.图鉴-搜索:部分输入和搜索结果不一致>> 2.应用内显示知识节点(642)与实际内容不一致,应用内仅少量山海经知识,内容丰富度欠佳
第一句是缺陷,给个输入就能复现。第二句一半是缺陷、一半是判断:642 这个数字确实和实际内容对不上,但"仅少量知识"是审核员读完应用之后的整体感受。第三句最麻烦——"内容丰富度欠佳"没有给出任何可执行的标准,既不告诉你要多少条,也不告诉你要多长。
一个应用被这样驳回之后,最容易走的两条路都不可取:一条是往包里硬塞内容,把数字做上去;另一条是写一篇长文解释"我们的内容虽然少但很精"。前者会在下一轮被问"这些内容是真的可读吗",后者不解决任何问题。
真正能推进的只有一件事:把三句话各自拆成一种可以验证的形态,然后让每个数字都能被重新算一遍。
一、三句话不是同一类问题
把它们放进同一张表就能看出来,它们需要三种完全不同的证据。
| 审核意见 | 它其实是什么 | 可验证的形态 | 当时的处置 |
|---|---|---|---|
| 搜索:部分输入和结果不一致 | 确定性缺陷 | 「输入 → 结果」的命中矩阵 | 字段覆盖 + 结果组织重构,1116 条断言回归 |
| 知识节点 642 与实际内容不一致 | 展示值与数据源脱节 | 常量与目录数据的逐项复算 | 移除手写常量,改成从目录实时统计 |
| 内容丰富度欠佳 | 相对判断 | 与上一个被驳回包的增量清单 | 用可读内容的字数、条数、入口逐项对账 |
第一类可以靠断言守住,第二类可以靠"只算不写"从结构上消灭,第三类只能靠和谁比、比什么来说清楚。三种证据备齐,才叫可复审。
二、搜索:用户的输入到底该命中哪些字段
被驳回时,图鉴搜索只覆盖了名称和简介几个字段。用户输入一个在原文里出现过的词,比如某只异兽出没的地域,或者知识卡正文里的一个词,结果为空——从用户角度看就是"输入和结果不一致"。
修法不是给搜索加几个 includes,而是先把「可被搜索的内容」定义成一份字段清单:
private searchFields(beast: BeastItem, regions: RegionItem[]): BeastSearchField[] {
const rich: ShanhaiRichBeastContent | undefined =
SHANHAI_RICH_BEAST_CONTENT.find((item) => item.beastId === beast.id)
?? SHANHAI_BATCH2_PILOT_RICH_CONTENT.find((item) => item.beastId === beast.id);
const fields: BeastSearchField[] = [
{ label: '名称', text: beast.name }, { label: '别名', text: beast.alias },
{ label: '简介', text: beast.summary }, { label: '详情', text: beast.detail },
{ label: '文献', text: beast.source.name }, { label: '原文位置', text: beast.source.section ?? '' },
{ label: '分类', text: this.categoryOf(beast) },
{ label: '出没地', text: this.regionName(beast.regionId, regions) }
];
if (rich !== undefined) {
fields.push({ label: '原文', text: rich.originalText }, { label: '白话', text: rich.readingNote },
{ label: '知识标题', text: rich.knowledge.title },
{ label: '知识摘要', text: rich.knowledge.summary },
{ label: '知识正文', text: rich.knowledge.content },
{ label: '故事正文', text: rich.story.content });
rich.glossary.forEach((item) => fields.push({ label: '字词', text: item.term },
{ label: '字词注', text: item.note }));
}
return fields;
}
这份清单是搜索的契约:每加一类内容(知识卡、故事卡、字词注、馆长主题),就同时决定它是否进入搜索。字段名不是为了好看,后面对账要用。搜索框本身的输入事件与属性,可以对照 Search 组件参考。
只改命中还不够。用户搜"东海",命中的可能是简介里的"东海",也可能是异兽的出没地。两种命中在界面上必须能区分,否则用户依旧会觉得"我搜的东西和结果对不上":
searchExcerpt(beast: BeastItem, regions: RegionItem[], searchText: string): string {
const keyword: string = searchText.trim();
if (keyword.length === 0) {
return '';
}
const field: BeastSearchField | undefined = this.searchFields(beast, regions)
.find((item: BeastSearchField) => item.text.indexOf(keyword) >= 0);
if (field === undefined) {
return '';
}
const offset: number = field.text.indexOf(keyword);
const start: number = Math.max(0, offset - 8);
const end: number = Math.min(field.text.length, Math.max(start + 64, offset + keyword.length));
const reason: string = field.label === '出没地' ? '地区匹配' : '内容匹配·' + field.label;
return reason + ':' + (start > 0 ? '…' : '') + field.text.substring(start, end) +
(end < field.text.length ? '…' : '');
}
结果卡的摘要带上"命中的是哪一类字段",用户看到「地区匹配:…东海…」就明白为什么这条会出现。搜索从"猜"变成了"说得出理由"。

门禁把这份契约写成了断言。36 只异兽的每个搜索字段、每个分类、每种筛选模式、空查询、空白查询、跨地域命中、超出范围的输入,逐条跑一遍,合计 1116 条:
| 断言组 | 覆盖内容 | 结果 |
|---|---|---|
| 单字段命中 | 36 只 × 全部可搜字段,输入该字段的一段原文 | 全过 |
| 分类矩阵 | 6 个分类 × 空查询,返回集合与分类定义一致 | 全过 |
| 筛选与排序 | 已发现 / 未发现 / 水生 / 陆生 × 排序模式 | 全过 |
| 空与异常输入 | 空串、纯空格、不存在的词、超长词 | 全过 |
| 摘要理由 | 地区匹配 与 内容匹配·字段 的文本形态 | 全过 |
三、642 这个数字是怎么来的
642 是开发早期写在页面上的占位数字,用来表示「知识节点总量」。后来内容按篇章重排,真实目录变成 36 只异兽加若干篇章,这个常量没人回头改,就留在了界面上。
它的问题不在于数字错了,而在于它和内容没有任何绑定关系:内容删一半,它还是 642。审核员打开应用看到它,再去数实际能读的条目,自然会认定"显示与实际不一致"。
处置方式只有一条——把这类数字从"写"改成"算"。第一步是给"算"定义口径,四个词必须先分清:
| 口径 | 定义 | 当时的真实值 |
|---|---|---|
| 篇 | 一篇典籍原文(《南山经》算一篇) | 20 |
| 节 | 篇内的目录节点 | 58 |
| 段 | 节内的自然段 | 912 |
| 条注 | 段内标记为注文的文字块(行内小字夹注) | 1,762 |
早期文档里出现过"2487 段",那是正文文字块的数量,不是段数。口径不先钉死,后面的数字一定会打起来。
四、只算不写的账本
口径定好之后,账本做成一个纯函数:输入是已经录入的篇章、异兽、地域、展厅数据,输出是一组计数。它不持有任何常量:
function countChapter(chapter: ShanhaiSourceChapter, tally: number[]): void {
const sections: ShanhaiChapterSection[] = chapter.sections;
tally[1] += sections.length;
for (let i = 0; i < sections.length; i++) {
const segments: ShanhaiChapterRun[][] = sections[i].segments;
tally[2] += segments.length;
for (let j = 0; j < segments.length; j++) {
const runs: ShanhaiChapterRun[] = segments[j];
for (let k = 0; k < runs.length; k++) {
if (runs[k].note) {
tally[3] += 1;
tally[5] += runs[k].text.length;
} else {
tally[4] += runs[k].text.length;
}
}
}
}
}
tally 的六个槽位分别是篇、节、段、条注数、正文字数、注文字数。遍历只做一件事:数出来。内容改了,账本跟着变;内容没改,账本不可能自己跳。
有一个细节值得单独说。读解卡的数量没有去 import 卡片数据,而是用「每只异兽一张知识卡 + 一张故事卡」推导:
// 不 import 卡片数据:那两个模块 400KB 级,
// 首页为了一个数字把整包富文本拉进首屏不值得。
const learningCardCount: number = beasts.length * 2;
const summary: string = chapterCount.toString() + ' 篇 · ' +
shanhaiGroupDigits(segmentCount) + ' 段 · ' + shanhaiGroupDigits(noteCount) + ' 条旧注';
const subSummary: string = shanhaiTenThousand(totalChars) + ' 万字 · ' +
beasts.length.toString() + ' 只异兽 · ' + relationCount.toString() + ' 条关联';
推导有推导的风险——万一某只异兽缺一张卡,数字就虚高。所以门禁里加了一条断言:实际卡片数必须等于异兽数乘二。只要这条断言在,推导就不会悄悄失准,而首屏也不用为一个数字拖进 400KB 的富文本。
折叠态的文案还有一个尺寸约束。390vp 宽的屏幕去掉左右留白大约剩 300vp,「20 篇 · 912 段 · 1,762 条旧注」已经接近 250vp,再加「58 节」就会贴边截断。所以收起时只放篇、段、注三层,节数在展开后的典籍行里照样看得到:
| 展示位 | 放什么 | 不放什么 | 原因 |
|---|---|---|---|
| 收起第一行 | 篇 · 段 · 条注 | 节数 | 一行放不下,节可在展开层看到 |
| 收起第二行 | 万字 · 异兽 · 关联 | 展厅数 | 与第一行的体量层级错开 |
| 展开典籍行 | 篇 / 节 · 段 · 注 | 正文字数 | 字数放第二行总述 |
| 形态分布 | 真实存在的分类与数量 | 分组里列了但没数据的 id | 避免再次出现「显示多于实际」 |

五、内容增量要跟谁比
前两句都能靠代码修,第三句只能靠事实。事实的基准不是"我觉得内容不少",而是上一个被驳回的包。2026-09-08 那个 release 包里能读的内容是:36 条神兽、72 张学习卡、144 篇馆长主题、9 个地域、8 个展厅。
相对这个包,当前版本能读到的增量是:
| 维度 | 被驳回包 | 当前版本 | 可复算的口径 |
|---|---|---|---|
| 典籍原文 | 无 | 20 篇 / 58 节 / 912 段 | 账本逐篇遍历 |
| 旧注 | 无 | 1,762 条 | 段内 note === true 的文字块 |
| 正文字数 | 无 | 76,859 字(正文 + 注文) | 按 run.text.length 累加 |
| 关联阅读 | 无 | 90 条 | 异兽 relations 数组求和 |
| 跨条目长文 | 无 | 3 篇(1,566 / 1,097 / 2,006 字) | 挂在地域页、展厅页的入口 |
| 探索方式 | 信息页 | 信息 / 地图双模式 + 本地 3D 地图(九区 27 地标) | 页面入口与 3D 场景 |
| 阅读方式 | 无语音 | 馆长讲解(空间音频)+ 系统媒体播控卡 | 讲解服务与会话 |
这张表的每一行都能被重新数一遍,没有"约"和"左右"。审核员不需要相信作者,只需要相信口径。

六、复审前要先证明"没有新的谎"
修完旧问题,还得证明整改没有引入新问题。复审前跑的是四类检查,任何一类不过都不提交:
| 检查 | 方法 | 结果 |
|---|---|---|
| 全量门禁 | 52 个脚本逐个执行 | 51 通过 / 1 失败 |
| 客户可见文案 | 按「测试 / 调试 / 示例 / 占位 / 占位标记 / debug」扫描资源与代码字符串 | 资源零命中,代码 2 处均为不可达的开发 mock 与注释 |
| 搜索与账本 | 1116 条字段断言 + 账本逐项复算 | 全过 |
| 上架自检 | AGC 云测,5 台设备 117 个用例 | 兼容性 100%、稳定性无崩溃 |
唯一失败的那个脚本属于另一条在改的工具链,改动范围在超分查看器相关的文件里,和本次内容整改无关。这条红没有掩盖,也不需要为了让报告好看而临时关掉。
七、把说明写成一封能读下去的信
技术证据齐了,还要让审核员在两分钟里看懂。审核备注按 152 字写完,结构是固定的五段:驳回项逐条回应、计数改成动态、内容丰富度的口径、测试路径、本版改动。它不是公关稿,每一句都对应前面的一张表或一次门禁。
隐私声明也在这一轮里重写。原声明有三类问题:权限章节整节空白、个人信息管理章节渲染出 5 处 undefined、写了"注销账户"而应用根本没有账号体系。逐项改完之后重新生成,托管页回读确认补充章节、日期与权限说明都在,生效日期更新到 2026-11-03。
八、提交之后
提交状态是预审中,之后还有人工审核。能不能通过由平台判断,这里记录的是提交前已经能复算的东西:三句驳回意见各自对应哪类证据、每个数字从哪来、复审前跑过哪些检查。
同一个版本里还有两件事不在这篇的范围内:上架包从调试签名换成发布签名、双层验签与哈希台账,以及低版本设备冷启动崩溃的定位与修复。前者决定了"审核员拿到的包是谁签的",后者决定了"这个包在 API 20 设备上能不能起来",两件都写在同系列的另一篇里。
九、收个尾
被驳回之后最没用的动作是解释,最有用的动作是让每个主张都有复算路径。搜索靠字段清单和断言,计数靠只算不写的账本,丰富度靠与被驳回包的增量对照。三件事都做完,"内容丰富度欠佳"就不再是一句无法回应的话,而是一张可以被逐行核对的表。
更多推荐
所有评论(0)