6.2 操作路径与点击成本

把高频操作的最短点击路径建模成图上最短路问题:边权是一次跳转的点击成本,用 Dijkstra 求主界面到目标功能的最少点击次数,再按操作频率算加权平均点击成本,最后与设计预算做基准对照。含可跑的 Python 仿真、TypeScript 原型、CSV 明细与软告警基准。

上一节 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 新手引导的信息架构 会把同样的图论思路用到新玩家身上——只是那里的「路径」变成了引导步骤,「流失」变成了要优化的成本。

阅读导航:上一节:主界面框架与信息层级 · 下一节:新手引导的信息架构 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「slg」更多文章

  1. 12.3 留存与付费的联合分析
  2. 12.2 流失预警与召回
  3. 附录 C:全书公式、参数与数据集索引