用 Rust + Makepad 做一个 JSON 查看器:从零到能用的全过程

前言

上期结尾说做实战,评论区 JSON 查看器票数多。我先做了这个。

为什么是 JSON 查看器?两个原因。

第一,我自己真的会用。经常对着一坨 API 返回的 JSON 发呆,需要快速展开、折叠、搜索。切到浏览器再切回来很打断思路。

第二,复杂度刚好。不是 counter 那种三行交互写完的 demo,也不是编辑器那种两个月出不来的大项目。刚好把前六期全用上。

这篇不会贴完整源码。几百行你也不会看。我把真实的决策点、代码结构、踩坑经历写清楚。

1. 先定范围:别上来就写代码

看到"JSON 查看器"四个字,很多人(包括我)第一反应就是开编辑器。

我第一次就踩了这个坑。写了三个小时,发现自己在做 JSON Schema 校验------一个我可能永远用不上的功能。

停下来,在白板上列了三个问题:

  1. 我打开这个工具最常干什么?
  2. 什么功能我一年用不到一次?
  3. 最少做哪些东西,我才愿意用?

答案很快就出来了。

一定会用的: 打开文件、树形展开折叠、选中看详情、搜索跳转。

可能用到的: 格式化切换、复制节点路径、最近文件列表。

绝对不做的: Schema 校验、编辑 JSON、导出、主题切换、多 Tab。

这个清单拉出来,范围直接从"一个庞大的 JSON 编辑器"变成了"一个周末能搞完的查看器"。

我给自己定的目标:能打开文件,能展开折叠,能搜索,界面不丑。就四件事。

2. 页面结构:在写代码之前先画树

需求定了,第二步是页面结构。

以前我会直接对着 DSL 写。然后反复改布局,直到偶然撞到一个合理的结构。

这次学乖了。先在注释里把结构画出来:

text 复制代码
Window
  └── 根 View (flow: Down)
        ├── 工具栏 View (flow: Right)
        │     ├── Label "JSON 查看器"
        │     ├── View (弹簧)
        │     ├── Button "打开"
        │     ├── Button "展开全部"
        │     └── Button "折叠全部"
        ├── 搜索栏 View
        │     └── TextInput
        ├── 分割线
        ├── 内容区 View (flow: Right)
        │     ├── 左侧:树形列表 (width: 280)
        │     └── 右侧:节点详情 (width: Fill)
        └── 状态栏 View
              └── Label "共 N 个节点"

花了五分钟。但它值。因为后面写 DSL 的时候,每一层嵌套都对应这棵树里的一个节点。不用想"这个应该放哪",看树就行。

Makepad 这种嵌套 View 的布局方式,层级一多父子关系容易乱。画在纸上比画在脑子里靠谱十倍。

3. 状态设计:先把会变的东西全列出来

界面结构有了,下一个问题是:这个应用有哪些状态?

用第五期的思路:先列所有"会变的东西",再分组。

rust 复制代码
// ---- 文件 ----
current_file_path: Option<String>,
current_file_name: String,
file_size: String,

// ---- JSON 数据 ----
json_root: Option<JsonNode>,
all_nodes: Vec<JsonNode>,         // 扁平化,给搜索用
expanded_nodes: HashSet<String>,  // 展开的节点路径

// ---- 搜索 ----
search_keyword: String,
search_results: Vec<String>,      // 匹配的节点路径
search_index: usize,              // 当前跳到第几个

// ---- 选中 ----
selected_node_path: Option<String>,

// ---- UI 状态 ----
is_loading: bool,
status_message: String,

12 个字段,按功能分了 5 组。不算多,但已经超过了"散字段随便放"的阶段。

这里有一个设计选择值得单独说:expanded_nodes

我一开始想的是"每个节点加个 expanded: bool"。但展开/折叠时你得递归改所有子节点------深层折叠经常漏掉某个孙子,界面对不上。调了一个多小时。

后来换成 HashSet<String>,只存"哪些节点路径是展开的"。折叠时删自己就行,不碰子节点。瞬间干净了。

4. 逐个功能实现

4.1 打开文件

翻官方示例和 platform 模块,文件对话框大概长这样:

rust 复制代码
#[event]
fn handle_open(&mut self, cx: &mut Cx, actions: &Actions) {
    if self.ui.button(cx, ids!(open_btn)).clicked(actions) {
        self.ui.file_dialog(cx, ids!(file_dialog)).open(cx);
    }
}

#[event]
fn handle_file_selected(&mut self, cx: &mut Cx, actions: &Actions) {
    if self.ui.file_dialog(cx, ids!(file_dialog)).selected(actions) {
        if let Some(path) = self.ui.file_dialog(cx, ids!(file_dialog)).selected_path(cx) {
            self.load_json_file(cx, &path);
        }
    }
}

load_json_file 里用 std::fs::read_to_string 读文件,serde_json 解析。解析完把树转成扁平列表存到 all_nodes,树形列表只渲染顶层,子节点按 expanded_nodes 动态展开。

性能上有个保护:文件超过 1MB 就截断,弹提示"文件较大,仅展示前 1MB"。不优雅,但第一版够了。

4.2 树形列表的展开折叠

这是整个工具最核心的交互。

设计原则:树结构存在 json_root 里,列表只是一个视图。visible_nodes 是一个"当前该显示哪些行"的列表,每次展开折叠就重建它。

rust 复制代码
fn rebuild_visible_nodes(&mut self) {
    self.visible_nodes.clear();
    self.build_recursive(&self.json_root, 0);
}

fn build_recursive(&mut self, node: &JsonNode, depth: usize) {
    self.visible_nodes.push(VisibleNode {
        path: node.path.clone(),
        depth,
        display: self.format_node(node),
        has_children: !node.children.is_empty(),
        expanded: self.expanded_nodes.contains(&node.path),
    });
    if self.expanded_nodes.contains(&node.path) {
        for child in &node.children {
            self.build_recursive(child, depth + 1);
        }
    }
}

代价:每次展开折叠都重建列表,大 JSON 有开销。好处:逻辑简单,不会出现 UI 展开状态和数据不一致的 bug。

4.3 搜索和跳转

搜索本身不复杂------遍历 all_nodes,匹配 key 或 value 摘要,收集匹配的路径。

跳转是体验的关键。

rust 复制代码
fn jump_to_next(&mut self, cx: &mut Cx) {
    let target = &self.search_results[self.search_index];
    self.search_index = (self.search_index + 1) % self.search_results.len();
    self.expand_ancestors(target);    // 展开目标的所有祖先
    self.rebuild_visible_nodes();
    // 在列表中选中目标
    if let Some(idx) = self.find_visible_index(target) {
        self.ui.list(cx, ids!(tree_list)).select_item(cx, idx);
    }
}

expand_ancestors 这步我一开始没做。搜完看到"找到 3 个结果",列表一片空白。愣了几秒------祖先节点全折叠着,目标根本不可见。

加上之后体验立刻对了。

5. 界面调优

功能跑通了,界面还是第六期里那个 demo 样。上期的六个步骤直接搬过来用。

加 padding、分区域设 flow/align、建间距层级、定颜色、定字号------这些第六期写过了,不重复。

只说花了最多时间的一件事:调间距。标题和内容之间 12 还是 16?行高 28 还是 32?你写 CSS 怎么调像素,在 Makepad 里就是一样的体验。一个下午,大部分时间就耗在这几个数字上。

6. 踩过的坑

6.1 大文件卡界面

第一次用 2MB 的 JSON 测试,点"打开"后窗口卡了三秒。read_to_string + serde_json::from_str 全在主线程。

Makepad 的异步支持还在完善。我的临时方案:超过 100KB 的文件先显示"加载中...",在下一帧再解析。短暂的等待但不至于卡死。不是完美的解,但对 100KB 以内的 JSON------这是我日常碰到的大多数------根本没这个问题。

6.2 列表不虚拟化

树形列表超过 1000 个节点,滚动开始不流畅。10000 个节点时明显掉帧。Makepad 的 List 控件目前不虚拟化,所有行都会创建实例。

我的折中:默认只展开前两层。深层节点用户手动点才展开。可见行数通常不超过 200,滚动流畅。

6.3 搜索高亮受限

搜到节点后我想在列表里高亮。但 Makepad 的 List 对行样式的定制能力有限。目前的方案:匹配行文字颜色改成主色(蓝色),没匹配的保持默认。行内关键词高亮做不到,但至少能区分"搜到的"和"没搜到的"。

7. 项目结构

text 复制代码
json-viewer/
├── Cargo.toml
└── src/
    ├── main.rs          # UI 描述 + 事件处理,~200 行
    ├── json_model.rs    # 数据模型:解析、树结构、扁平化,~150 行
    ├── search.rs        # 搜索逻辑,~60 行
    └── theme.rs         # 设计变量,~40 行

总共不到 500 行。不大。但结构清晰:数据、搜索、主题、UI 各管各的。比一个 500 行的单文件好维护得多。

总结

前六期是学一个点练一个点。这一期是把它们全串起来。

做这个小工具最大的感受:每个功能单拎出来都不难。拼在一起才难。展开折叠要对、搜索要能跳、界面不能丑、大文件不能卡。花时间的不是单个功能,是它们之间的协调。

如果你也在用 Makepad 做小工具,我的建议:先定范围(第一版别贪多),先画结构(纸上比脑子里清楚),写完功能再调界面(别边写功能边纠结颜色)。

下一期写数据、文件和本地能力:读写文件、保存配置、接 API。把桌面应用接上地气。

相关推荐
yijianace1 小时前
Python爬虫实战:分页爬取 + 详情页采集 + CSV存储
前端·爬虫·python
想吃火锅10051 小时前
【前端手撕】防抖节流
前端
MemoriKu1 小时前
Flutter 相册 APP 视频模态稳定化实战:从视频抽帧、Embedding 元数据到 Android 真机启动修复
android·开发语言·前端·flutter·架构·音视频·embedding
lichenyang4531 小时前
ArkUI 票根卡片:PathShape 真挖洞,shadow 沿凹陷外发光
前端
Cache技术分享1 小时前
432. Java 日期时间 API - 时间工具 TemporalQuery 详解
前端·后端
假如让我当三天老蒯2 小时前
暂时性死区是否和闭包是相背的呢(自学用)
前端·javascript
渣波2 小时前
前端开发主页面小技巧
前端·javascript
柯克七七2 小时前
我用3个周末重构了公司的前端项目,老板没发现,但同事都来找我要代码了
前端
bonechips2 小时前
JS:同步与异步,从单线程到 Promise 的编程之路
前端·javascript