前言
上期结尾说做实战,评论区 JSON 查看器票数多。我先做了这个。
为什么是 JSON 查看器?两个原因。
第一,我自己真的会用。经常对着一坨 API 返回的 JSON 发呆,需要快速展开、折叠、搜索。切到浏览器再切回来很打断思路。
第二,复杂度刚好。不是 counter 那种三行交互写完的 demo,也不是编辑器那种两个月出不来的大项目。刚好把前六期全用上。
这篇不会贴完整源码。几百行你也不会看。我把真实的决策点、代码结构、踩坑经历写清楚。
1. 先定范围:别上来就写代码
看到"JSON 查看器"四个字,很多人(包括我)第一反应就是开编辑器。
我第一次就踩了这个坑。写了三个小时,发现自己在做 JSON Schema 校验------一个我可能永远用不上的功能。
停下来,在白板上列了三个问题:
- 我打开这个工具最常干什么?
- 什么功能我一年用不到一次?
- 最少做哪些东西,我才愿意用?
答案很快就出来了。
一定会用的: 打开文件、树形展开折叠、选中看详情、搜索跳转。
可能用到的: 格式化切换、复制节点路径、最近文件列表。
绝对不做的: 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。把桌面应用接上地气。