在鸿蒙文搜图应用里,3×3画布每次点格都会触发一次检索。原文没有把它只当成交互,而是按一门微型DSL来拆:九个格子是语法位置,十个词是词表,`0:蓝|6:明亮`是源程序,Native逐格打分是求值器,三套预设是宏。对开发者真正有价值的地方,是ArkTS侧如何emit、parse、缓存,NAPI边界如何只传string+int,Native侧如何解析并合成trace,以及错误、版本和分享协议怎么设计。
一、先把画布状态收敛成一行源程序
原文的语法很小:用`|`分隔项,用`:`连接位置与词,位置只能是0到8,词来自固定词表。`avg`与`min`不放进源程序,而是执行参数:它们改变约束如何合成为总分,不改变约束本身。这样同一份谱子可以换两种解释执行,程序文本不用重写。
ArkTS侧维护board[9],emit负责丢掉空格并拼成字符串:- export function emit(board: string[]): string {
- const parts: string[] = [];
- for (let i = 0; i < 9; i++) if (board[i].length > 0) parts.push(`${i}:${board[i]}`);
- return parts.join('|');
- }
复制代码
解析器生成最小AST,并显式返回错误码。原文用`EMPTY_PROGRAM`、`BAD_CELL`、`UNKNOWN_WORD`、`DUPLICATE_CELL`区分失败原因。其中重复格是一个语义空白:画布上不可能出现同格两个词,但文本协议允许手抄出现。原文倾向报错而不是后者覆盖前者,因为静默覆盖会让分享出去的程序在不同版本、不同解析顺序下得到不同结果。
- export type Cell = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
- export type Mode = 'avg' | 'min';
- export interface Placement { cell: Cell; word: string; }
- export interface Program { placements: Placement[]; mode: Mode; }
- export type ParseError = 'EMPTY_PROGRAM' | 'BAD_CELL' | 'UNKNOWN_WORD' | 'DUPLICATE_CELL';
- export function parse(source: string, mode: Mode = 'avg') {
- const errors: ParseError[] = [];
- const placements: Placement[] = [];
- const seen = new Set<number>();
- if (source.trim().length === 0) {
- return { program: { placements: [], mode }, errors: ['EMPTY_PROGRAM'] };
- }
- for (const raw of source.split('|')) {
- const seg = raw.trim();
- if (seg.length === 0) continue;
- const colon = seg.indexOf(':');
- const cell = Number(colon >= 0 ? seg.slice(0, colon) : '');
- const word = colon >= 0 ? seg.slice(colon + 1) : '';
- if (!Number.isInteger(cell) || cell < 0 || cell > 8) { errors.push('BAD_CELL'); continue; }
- if (!VOCAB.includes(word)) { errors.push('UNKNOWN_WORD'); continue; }
- if (seen.has(cell)) { errors.push('DUPLICATE_CELL'); continue; }
- seen.add(cell);
- placements.push({ cell: cell as Cell, word });
- }
- return { program: { placements, mode }, errors };
- }
复制代码
二、求值结果不能只有总分,还要有逐格trace
原文把一张图切成九格,每格有一组可观测量,一个词对应一支探针,返回0到1的满足度。程序求值时,未出现在程序里的格子不参与计算,返回-1作为哨兵,UI映射成灰格。这里的关键是区分“未约束”和“约束得0分”:前者是没测,后者是测了且完全不符。混在一起会让热力图整片飘红。
- export interface Trace { id: number; total: number; cells: number[]; }
- export function evaluate(program: Program, probes: (cell: number, word: string) => number, id: number): Trace {
- const cells: number[] = new Array(9).fill(-1);
- const scores: number[] = [];
- for (const p of program.placements) {
- const s = probes(p.cell, p.word);
- cells[p.cell] = s;
- scores.push(s);
- }
- if (scores.length === 0) return { id, total: 0, cells };
- const total = program.mode === 'min' ? Math.min(...scores) : scores.reduce((a, b) => a + b, 0) / scores.length;
- return { id, total, cells };
- }
复制代码
原文第六张截图那组数字能说明两种模式的差异:九格里七格接近1.00,两格是0.12与0.17。`avg`把短板平均掉,得到约0.72;`min`直接取0.12。同一份程序、同一份数据,解释策略决定它像不像一张“好图”,所以mode必须显式写进程序,不能藏在实现里。
三、编译链分四层,跨语言只传一行字符串
原文的编译链是:画布board[9]在UI层可变,emit丢掉空格拼成源程序,parse校验位置和词表生成AST,normalize生成缓存键,再通过NAPI把string+int传到Native;Native侧ParseBoard拆词、查UTF-8字节词表,Evaluate每格取特征、算探针、合成总分,最后返回JSON给UI画热力九格。
Native侧手写`find(':')`而不是引JSON库,原因是这门语言没有嵌套结构、没有可选字段、没有转义需求:词表是固定UTF-8字节串,位置是一位数字。为此引入JSON解析器反而增加跨语言不一致风险。原文还强调词表用UTF-8字节转义是词法层编码约定,保证ArkTS传来的“绿”与Native里的字节表示无论源码文件是什么编码都能相等。
- struct Placement { int cell = 0; Kind kind = K_UNKNOWN; };
- static std::vector<Placement> ParseBoard(const std::string& src) {
- std::vector<Placement> out;
- size_t start = 0;
- while (start <= src.size()) {
- size_t end = src.find('|', start);
- if (end == std::string::npos) end = src.size();
- if (end > start) {
- std::string seg = src.substr(start, end - start);
- size_t colon = seg.find(':');
- if (colon != std::string::npos && colon == 1 && seg[0] >= '0' && seg[0] <= '8') {
- Kind k = Lookup(seg.substr(colon + 1));
- if (k != K_UNKNOWN) out.push_back(Placement{seg[0] - '0', k});
- }
- }
- if (end == src.size()) break;
- start = end + 1;
- }
- return out;
- }
复制代码
四、规范化与缓存:交换律的直接工程收益
原文指出交换律有一个直接收益:编译产物比较可以与书写顺序无关。`0:复杂|4:蓝`和`4:蓝|0:复杂`规范化后得到同一个缓存键。ArkTS侧用normalize排序并拼上mode,QueryCache最多保留64项,超出后删除最早一项。
- export function normalize(program: Program): string {
- const items = program.placements.map(p => `${p.cell}:${p.word}`).sort();
- return `${program.mode}#${items.join('|')}`;
- }
- export class QueryCache {
- private store = new Map<string, Trace[]>();
- get(program: Program): Trace[] | null { return this.store.get(normalize(program)) ?? null; }
- put(program: Program, traces: Trace[]): void {
- if (this.store.size > 64) {
- const first = this.store.keys().next().value;
- if (first !== undefined) this.store.delete(first);
- }
- this.store.set(normalize(program), traces);
- }
- }
复制代码
五、预设是宏,不是模板
三套预设看起来只是帮用户填好九格,但原文按宏实现,得到三个好处:可展开、可参数化、可版本化。宏展开后就是普通源程序,可继续逐格编辑;withSubstitution能把“雪山天光”里的“蓝”整体换成“绿”,生成新谱子。version字段不能省:词表新增词或探针阈值调整后,同名旧宏可能产生不同结果。序列化时始终保留展开后的src,宏名只作可读附注。宏是缩写,程序才是事实。
- export interface Macro { name: string; version: number; cells: string[]; }
- export interface Serialized { v: number; macro?: string; macroVersion?: number; mode: Mode; src: string; }
- export function serialize(p: Program, macro?: Macro): string {
- const doc: Serialized = { v: 1, macro: macro?.name, macroVersion: macro?.version, mode: p.mode, src: emitFromProgram(p) };
- return JSON.stringify(doc);
- }
复制代码
六、错误必须可见,覆盖率要和分数一起读
原文强调文本协议才是分享与存档的载体,它必须自己站得住。多词查询里若未识别实词被静默忽略,用户以为写了三个条件,引擎只执行一个,还会给出看起来合理的分数。未知词必须是一次可见失败,宁可不出结果。诊断层把BAD_CELL、UNKNOWN_WORD、DUPLICATE_CELL作为error,把高度相关词同时出现作为REDUNDANT_TERM警告:不违法,但会稀释程序信息量,提示比替用户做决定更合适。
另一个容易误导的是覆盖率。只摆一格时覆盖率1/9,任何图只要那一格碰巧符合就能拿满分。原文展示的“碧海晴空100分”正是短程序语义弱的表现。一个低成本修法是给总分乘先验因子,alpha取0.5时单格程序满分被压到约0.67,九格铺满不受影响。它不是严格统计校正,但足以让“摆一格得100分”不再误导。
- export interface Diagnostic { level: 'error' | 'warning'; code: ParseError | 'REDUNDANT_TERM'; at: string; }
- export function coverage(p: Program): number { return p.placements.length / 9; }
- export function optimize(p: Program): Program {
- const seen = new Set<number>();
- const placements = p.placements.slice().sort((a, b) => a.cell - b.cell).filter(x => {
- if (seen.has(x.cell)) return false;
- seen.add(x.cell);
- return true;
- });
- return { placements, mode: p.mode };
- }
复制代码
七、分享短码要带版本前缀
界面是临时的,程序不是。原文用`MOSAIC1.`作为协议标识,短码由mode标记、版本和源程序base64url组成。以后语法扩展就升到MOSAIC2,接收方看到不认识的前缀直接拒绝,而不是按旧规则误解析出一堆未识别词。短码里始终保留展开后的src,即使接收方没有同名预设,程序依然完整;即使将来探针阈值调整,至少能明确知道这份结果属于v1语义。
- export function shareCode(doc: Serialized): string {
- const head = `${doc.mode === 'min' ? 'M' : 'A'}${doc.v}`;
- const body = Buffer.from(doc.src, 'utf8').toString('base64url');
- return `MOSAIC1.${head}.${body}`;
- }
复制代码
八、几个实现取舍
mode为什么不放进语法?因为它改变的是约束如何合成为总分,不是约束是什么。放在参数里,同一份谱子可以换两种解释执行,缓存键只需在规范化时带上一位标记。重复格为什么报错?静默覆盖会让分享出去的程序在不同版本、不同解析顺序下得到不同结果,语言层面拒绝歧义比在实现里挑约定更安全。avg和min怎么选?想找“大致这个感觉”用avg;九个位置都有明确要求、一个都不能错,用min。
这套九格DSL的代码量不大,但它把鸿蒙文搜图里的UI交互、ArkTS状态、NAPI跨语言边界、Native求值和分享协议串成了一条可验证链路。原文最值得复用的不是“九格”本身,而是几个工程习惯:程序文本始终可序列化,跨语言只传最小字符串,错误码和trace一起返回,缓存键先规范化,分享格式先带版本。 |