上一节 6.1 主界面框架与信息层级 解决了「找得到吗」,用的是跳数。但跳数只说了一半:同样 4 跳,如果其中两跳需要翻页、拖拽、二次确认,玩家的体感会完全不同。这一节把「点击成本」做成一个可以加权的量,并回答一个产品经理每天都要回答的问题:这个功能,值不值它现在的点击次数?
判断的锚点是操作频率。读邮件一天 8 次,路径必须短到极致;买一次商城商品一天 1 次,多一跳无所谓。把频率当权重,点击成本才有意义。
点击成本:最短路径的边权和
在界面图上,一次「操作路径」就是主界面到目标界面的一条路径,点击成本是这条路径上所有边的 cost 之和。玩家不会走远路,所以我们要的是最短路径:
- 如果每条边的
cost都是 1,最短路就是 BFS 跳数(上一节算过的深度)。 - 一旦允许某些跳转成本更高(比如需要长按、翻页、拖动地图),就得用 Dijkstra:边权非负,求最小权和。
本节的 config 里所有边 cost 都是 1,但模型按任意非负整数权设计,这样以后想给「翻页」记 2 分、「拖动地图」记 3 分,不用改代码。整数权还有一个好处:Python 与 TypeScript 算出来的最短路径逐位一致,不会因为浮点误差分叉。
任务的配置
点击成本要对着一组高频操作任务来算。任务是配置,写在 config/ch06_click_cost.json 里,每个任务有目标界面、日均频率和设计预算:
{
"version": 1,
"note": "高频操作任务:target 为完成该操作要到达的界面,freqPerDay 为日均触发次数(权重),budgetClicks 为设计预算(超过即软告警)。start 固定为 main_city。",
"start": "main_city",
"tasks": [
{ "id": "upgrade_building", "name": "升级建筑", "target": "build_upgrade", "freqPerDay": 6, "budgetClicks": 3 },
{ "id": "send_troops", "name": "派兵出征", "target": "march_confirm", "freqPerDay": 5, "budgetClicks": 3 },
{ "id": "train_troops", "name": "训练部队", "target": "train_confirm", "freqPerDay": 4, "budgetClicks": 3 },
{ "id": "levelup_hero", "name": "英雄升级", "target": "hero_levelup", "freqPerDay": 3, "budgetClicks": 2 },
{ "id": "claim_quest", "name": "领取任务", "target": "quest_detail", "freqPerDay": 3, "budgetClicks": 2 },
{ "id": "tech_contribute", "name": "联盟科技捐献", "target": "tech_contribute", "freqPerDay": 2, "budgetClicks": 3 },
{ "id": "read_mail", "name": "阅读邮件", "target": "mail", "freqPerDay": 8, "budgetClicks": 1 },
{ "id": "buy_shop", "name": "商城购买", "target": "purchase_confirm", "freqPerDay": 1, "budgetClicks": 3 },
{ "id": "enter_arena", "name": "进入竞技场", "target": "arena", "freqPerDay": 1, "budgetClicks": 3 }
]
}
budgetClicks 是设计预算,也就是「这个操作最多允许几次点击」。它来自两处:同类竞品的公开可观察机制(不是实测,见附录 A 的口径),以及团队对体感的判断。预算不是硬约束,而是后面基准对照的参照线。
求最短点击路径
下面这段是 S 层仿真脚本,读界面图与任务配置,对每个任务跑一次 Dijkstra,得到最短点击成本与路径,按频率算加权平均,并把明细写进 CSV。
# file: sim/ch06_click_cost.py
# tier: S
# verify: python3 sim/ch06_click_cost.py --config config/ --out out/
"""第 6 章点击成本:对每个高频操作,用 Dijkstra 求主界面到目标界面的最短点击路径。
边权 = 该次跳转的点击成本(整数),全部为非负整数,故 Dijkstra 与「逐层展开」等价,
可与 proto/src/ch06_path.ts 逐位对齐。产出:
out/ch06_click_cost.json 每个任务的最短成本、路径、是否超预算、加权平均
out/ch06_click_cost.csv 任务级明细(供表格/回归比对)
"""
from __future__ import annotations
import argparse
import csv
import heapq
import json
import os
def load(config_dir: str, name: str) -> dict:
with open(os.path.join(config_dir, f"{name}.json"), encoding="utf-8") as f:
return json.load(f)
def build_adj(edges: list[dict]) -> dict[str, list[tuple[str, int]]]:
adj: dict[str, list[tuple[str, int]]] = {}
for e in edges:
adj.setdefault(e["from"], []).append((e["to"], e["cost"]))
# 保证邻接表有序,跨语言结果稳定
for k in adj:
adj[k].sort()
return adj
def dijkstra(adj: dict, start: str) -> tuple[dict[str, int], dict[str, str | None]]:
dist: dict[str, int] = {start: 0}
prev: dict[str, str | None] = {start: None}
pq = [(0, start)]
while pq:
d, cur = heapq.heappop(pq)
if d > dist.get(cur, 1 << 60):
continue
for nxt, w in adj.get(cur, []):
nd = d + w
if nd < dist.get(nxt, 1 << 60):
dist[nxt] = nd
prev[nxt] = cur
heapq.heappush(pq, (nd, nxt))
return dist, prev
def path_of(prev: dict, target: str) -> list[str]:
if target not in prev:
return []
out = []
cur: str | None = target
while cur is not None:
out.append(cur)
cur = prev[cur]
return out[::-1]
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config")
ap.add_argument("--out", default="out")
args = ap.parse_args()
graph = load(args.config, "ch06_ui_graph")
task_cfg = load(args.config, "ch06_click_cost")
adj = build_adj(graph["edges"])
start = task_cfg["start"]
dist, prev = dijkstra(adj, start)
rows = []
for t in task_cfg["tasks"]:
tgt = t["target"]
cost = dist.get(tgt) # None 表示不可达
path = path_of(prev, tgt) if cost is not None else []
over = cost is not None and cost > t["budgetClicks"]
rows.append({
"id": t["id"], "name": t["name"], "target": tgt,
"freqPerDay": t["freqPerDay"], "budgetClicks": t["budgetClicks"],
"clicks": cost, "overBudget": over,
"path": "->".join(path) if path else "(不可达)",
})
reachable = [r for r in rows if r["clicks"] is not None]
total_freq = sum(r["freqPerDay"] for r in reachable)
weighted = sum(r["clicks"] * r["freqPerDay"] for r in reachable)
avg_clicks = round(weighted / total_freq, 4) if total_freq else 0.0
report = {
"version": 1,
"start": start,
"tasks": rows,
"reachableTasks": len(reachable),
"unreachableTasks": [r["id"] for r in rows if r["clicks"] is None],
"weightedAvgClicks": avg_clicks,
"overBudget": [r["id"] for r in rows if r["overBudget"]],
"maxClicks": max((r["clicks"] for r in reachable), default=0),
}
os.makedirs(args.out, exist_ok=True)
with open(os.path.join(args.out, "ch06_click_cost.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "ch06_click_cost.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "name", "target", "freqPerDay", "budgetClicks", "clicks", "overBudget", "path"])
for r in rows:
w.writerow([r["id"], r["name"], r["target"], r["freqPerDay"], r["budgetClicks"],
r["clicks"] if r["clicks"] is not None else "", r["overBudget"], r["path"]])
print(json.dumps({"weightedAvgClicks": avg_clicks, "reachableTasks": len(reachable),
"unreachableTasks": report["unreachableTasks"], "overBudget": report["overBudget"],
"maxClicks": report["maxClicks"]}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑一遍:
# snippet: 运行命令(在工作区 bastion/ 下执行)
python3 sim/ch06_click_cost.py --config config/ --out out/
输出(同机相对量级,绝对数值不可移植):
# snippet: 实跑输出
{"weightedAvgClicks": 2.5625, "reachableTasks": 8, "unreachableTasks": ["enter_arena"], "overBudget": ["send_troops", "levelup_hero"], "maxClicks": 4}
结果解读:哪些操作该优化
CSV 明细把每个任务摊开:
# snippet: out/ch06_click_cost.csv
id,name,target,freqPerDay,budgetClicks,clicks,overBudget,path
upgrade_building,升级建筑,build_upgrade,6,3,3,False,main_city->city_build->build_detail->build_upgrade
send_troops,派兵出征,march_confirm,5,3,4,True,main_city->world_map->tile_detail->march_panel->march_confirm
train_troops,训练部队,train_confirm,4,3,3,False,main_city->army->train_panel->train_confirm
levelup_hero,英雄升级,hero_levelup,3,2,3,True,main_city->hero->hero_detail->hero_levelup
claim_quest,领取任务,quest_detail,3,2,2,False,main_city->quest->quest_detail
tech_contribute,联盟科技捐献,tech_contribute,2,3,3,False,main_city->alliance->alliance_tech->tech_contribute
read_mail,阅读邮件,mail,8,1,1,False,main_city->mail
buy_shop,商城购买,purchase_confirm,1,3,3,False,main_city->shop->shop_item->purchase_confirm
enter_arena,进入竞技场,arena,1,3,,False,(不可达)
三个信号:
- 超标:
send_troops(4 次 vs 预算 3)和levelup_hero(3 次 vs 预算 2)。这两个都是高频操作——派兵一天 5 次、英雄升级一天 3 次——路径偏长会直接磨损体感。优化方向是深链接:从主界面直达出征确认,或把英雄升级的确认合并进详情页。 - 不可达:
enter_arena的clicks是空的,路径写着「不可达」。这正是上一节arena孤儿问题的下游后果——功能没入口,任何点击成本都无从谈起。一个不可达的高频任务比超标更严重,因为它等于功能不存在。 - 加权平均:
weightedAvgClicks = 2.5625(82 次点击 / 32 次触发)。这个数是整条操作链的「平均体感长度」,可以作为版本迭代的回归基线——优化后它应该下降,不能上升。
TypeScript 侧:一份配置两处消费
同一份 config 也被原型层消费。proto/src/ch06_path.ts 用 Dijkstra 实现同一套最短路径,与 Python 侧逐位一致。下面是它的核心片段(P 层原型,完整文件用 node --test 断言):
// snippet: proto/src/ch06_path.ts(节选,Dijkstra 核心;完整文件见工作区)
// verify: node --test 'proto/**/*.test.ts'
export function dijkstra(adj: Map<string, Array<[string, number]>>, start: string) {
const dist = new Map<string, number>([[start, 0]]);
const prev = new Map<string, string | null>([[start, null]]);
const frontier = new Set<string>([start]);
while (frontier.size > 0) {
let cur: string | null = null, best = Infinity;
for (const n of frontier) { const d = dist.get(n)!; if (d < best) { best = d; cur = n; } }
frontier.delete(cur!);
for (const [nxt, w] of adj.get(cur!) ?? []) {
const nd = dist.get(cur!)! + w;
if (nd < (dist.has(nxt) ? dist.get(nxt)! : Infinity)) { dist.set(nxt, nd); prev.set(nxt, cur); frontier.add(nxt); }
}
}
return { dist, prev };
}
断言里直接写死了 Python 侧算出的期望值——weightedAvgClicks 是 2.5625,overBudget 是 ["send_troops", "levelup_hero"],enter_arena 的 clicks 是 null。两边只要有一个数字对不上,node --test 就红。这就是「唯一真源」的验收方式。
基准对照:实测 vs 预算(B 层)
把「实测加权平均」和「预算加权平均」放一起对照,就是一条 B 层基准。下面这段读同一份配置,算出预算加权平均,与实测偏差超过阈值时软告警——注意它只告警、不失败,返回码始终是 0。
# file: sim/ch06_click_baseline.py
# tier: B
# verify: python3 sim/ch06_click_baseline.py --config config/ --out out/
"""第 6 章 B 基准:把「实测加权平均点击成本」与「设计预算加权平均」对照,软告警不硬失败。
B 层只产出 JSON 对照,不做断言。偏差超过阈值只打印告警,进程仍返回 0。
"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ch06_click_cost import build_adj, dijkstra, load # noqa: E402
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config")
ap.add_argument("--out", default="out")
ap.add_argument("--warn-pct", type=int, default=10)
args = ap.parse_args()
graph = load(args.config, "ch06_ui_graph")
task_cfg = load(args.config, "ch06_click_cost")
adj = build_adj(graph["edges"])
dist, _ = dijkstra(adj, task_cfg["start"])
freq = budget_freq = actual_freq = 0
per_task = []
for t in task_cfg["tasks"]:
cost = dist.get(t["target"])
if cost is None:
continue
f = t["freqPerDay"]
freq += f
budget_freq += t["budgetClicks"] * f
actual_freq += cost * f
per_task.append({"id": t["id"], "clicks": cost, "budget": t["budgetClicks"],
"delta": cost - t["budgetClicks"]})
budget_avg = round(budget_freq / freq, 4) if freq else 0.0
actual_avg = round(actual_freq / freq, 4) if freq else 0.0
# 整数百分比偏差(避免浮点:deviation = (actual - budget) / budget * 100)
dev_pct = round((actual_freq - budget_freq) * 100 / budget_freq, 2) if budget_freq else 0.0
warn = abs(dev_pct) > args.warn_pct
report = {
"version": 1,
"budgetAvgClicks": budget_avg,
"actualAvgClicks": actual_avg,
"deviationPct": dev_pct,
"warnThresholdPct": args.warn_pct,
"softWarning": warn,
"perTask": per_task,
}
os.makedirs(args.out, exist_ok=True)
with open(os.path.join(args.out, "ch06_click_baseline.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
tag = "WARN" if warn else "OK"
print(f"{tag} baseline budgetAvg={budget_avg} actualAvg={actual_avg} dev={dev_pct}% (threshold {args.warn_pct}%)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
跑出来:
# snippet: 运行命令
python3 sim/ch06_click_baseline.py --config config/ --out out/
# snippet: 实跑输出
WARN baseline budgetAvg=2.3125 actualAvg=2.5625 dev=10.81% (threshold 10%)
实测加权平均 2.5625,预算加权平均 2.3125,偏差 +10.81%,超过 10% 的阈值,于是软告警。这个告警不是失败——它只是告诉产品:整体点击成本比设计预期高了约一成,该回头看那两个超标任务了。B 层的价值就在这里:它把「感觉有点繁琐」变成一个会随配置变化的数字,且不阻塞流水线。
小结
这一节把「操作顺不顺手」拆成了三个可计算的东西:最短点击路径(Dijkstra)、加权平均点击成本(按频率加权)、实测与预算的偏差(B 基准)。三个数字都来自同一份配置,Python 与 TypeScript 两侧逐位一致。
需要记住的一条纪律:点击成本要和操作频率一起看。同样是 4 次点击,一天触发 5 次的出征和一天触发 1 次的购买,优先级完全不同。下一节 6.3 新手引导的信息架构 会把同样的图论思路用到新玩家身上——只是那里的「路径」变成了引导步骤,「流失」变成了要优化的成本。
阅读导航:上一节:主界面框架与信息层级 · 下一节:新手引导的信息架构 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。