# 〖共创稿事节〗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 设备上能不能起来",两件都写在同系列的另一篇里。

九、收个尾

被驳回之后最没用的动作是解释,最有用的动作是让每个主张都有复算路径。搜索靠字段清单和断言,计数靠只算不写的账本,丰富度靠与被驳回包的增量对照。三件事都做完,"内容丰富度欠佳"就不再是一句无法回应的话,而是一张可以被逐行核对的表。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐