Flutter for OpenHarmony Web开发助手App实战:JSON查看器
JSON是Web开发中最常用的数据格式。今天我们来实现一个JSON查看器,让复杂的JSON数据变得清晰易读。

JSON是Web开发中最常用的数据格式。今天我们来实现一个JSON查看器,让复杂的JSON数据变得清晰易读。
功能规划
一个好用的JSON查看器应该具备:
格式化显示:把压缩的JSON展开成易读的格式。
语法高亮:不同类型的数据用不同颜色显示。
折叠展开:对于嵌套的对象和数组,支持折叠和展开。
路径显示:显示当前节点在JSON中的路径。
搜索功能:快速查找特定的键或值。
完整代码实现
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'dart:convert';
class JsonViewerPage extends StatefulWidget {
const JsonViewerPage({Key? key}) : super(key: key);
State<JsonViewerPage> createState() => _JsonViewerPageState();
}
class _JsonViewerPageState extends State<JsonViewerPage> {
final TextEditingController _inputController = TextEditingController();
final TextEditingController _searchController = TextEditingController();
dynamic _jsonData;
String _errorMessage = '';
String _searchQuery = '';
void initState() {
super.initState();
_inputController.text = '''{
"name": "张三",
"age": 28,
"email": "zhangsan@example.com",
"isActive": true,
"balance": 1234.56,
"address": {
"city": "北京",
"district": "朝阳区",
"street": "建国路88号"
},
"hobbies": ["阅读", "旅游", "摄影"],
"projects": [
{
"name": "项目A",
"status": "进行中",
"progress": 75
},
{
"name": "项目B",
"status": "已完成",
"progress": 100
}
]
}''';
_parseJson();
}
void dispose() {
_inputController.dispose();
_searchController.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('JSON查看器'),
actions: [
IconButton(
icon: const Icon(Icons.content_paste),
onPressed: _pasteFromClipboard,
tooltip: '从剪贴板粘贴',
),
IconButton(
icon: const Icon(Icons.copy),
onPressed: _copyToClipboard,
tooltip: '复制JSON',
),
],
),
body: Row(
children: [
// 左侧输入区
Expanded(
flex: 1,
child: Container(
color: Colors.grey[50],
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: EdgeInsets.all(12.w),
color: Colors.blue,
child: Row(
children: [
Icon(Icons.edit, color: Colors.white, size: 20.sp),
SizedBox(width: 8.w),
Text(
'JSON输入',
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.clear, color: Colors.white),
onPressed: () {
_inputController.clear();
setState(() {
_jsonData = null;
_errorMessage = '';
});
},
tooltip: '清空',
),
],
),
),
Expanded(
child: TextField(
controller: _inputController,
maxLines: null,
expands: true,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 13.sp,
),
decoration: InputDecoration(
hintText: '粘贴或输入JSON数据...',
border: InputBorder.none,
contentPadding: EdgeInsets.all(16.w),
),
),
),
Container(
padding: EdgeInsets.all(12.w),
child: Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _parseJson,
icon: const Icon(Icons.play_arrow),
label: const Text('解析JSON'),
),
),
SizedBox(width: 8.w),
Expanded(
child: OutlinedButton.icon(
onPressed: _formatJson,
icon: const Icon(Icons.auto_fix_high),
label: const Text('格式化'),
),
),
],
),
),
],
),
),
),
// 分隔线
Container(width: 2.w, color: Colors.grey[300]),
// 右侧查看区
Expanded(
flex: 1,
child: Container(
color: Colors.white,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: EdgeInsets.all(12.w),
color: Colors.green,
child: Row(
children: [
Icon(Icons.visibility, color: Colors.white, size: 20.sp),
SizedBox(width: 8.w),
Text(
'JSON树形视图',
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.bold,
),
),
],
),
),
// 搜索栏
Container(
padding: EdgeInsets.all(12.w),
color: Colors.grey[100],
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: '搜索键或值...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.r),
),
contentPadding: EdgeInsets.symmetric(
horizontal: 12.w,
vertical: 8.h,
),
),
onChanged: (value) {
setState(() => _searchQuery = value.toLowerCase());
},
),
),
// JSON树形显示
Expanded(
child: _buildJsonView(),
),
],
),
),
),
],
),
);
}
Widget _buildJsonView() {
if (_errorMessage.isNotEmpty) {
return Center(
child: Padding(
padding: EdgeInsets.all(24.w),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 48.sp, color: Colors.red),
SizedBox(height: 16.h),
Text(
'JSON解析错误',
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.bold,
color: Colors.red,
),
),
SizedBox(height: 8.h),
Text(
_errorMessage,
style: TextStyle(fontSize: 14.sp, color: Colors.grey[600]),
textAlign: TextAlign.center,
),
],
),
),
);
}
if (_jsonData == null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.data_object, size: 48.sp, color: Colors.grey[400]),
SizedBox(height: 16.h),
Text(
'输入JSON数据并点击"解析JSON"',
style: TextStyle(fontSize: 14.sp, color: Colors.grey[600]),
),
],
),
);
}
return SingleChildScrollView(
padding: EdgeInsets.all(16.w),
child: JsonNodeWidget(
data: _jsonData,
path: 'root',
searchQuery: _searchQuery,
),
);
}
void _parseJson() {
setState(() {
_errorMessage = '';
try {
_jsonData = jsonDecode(_inputController.text);
} catch (e) {
_errorMessage = e.toString();
_jsonData = null;
}
});
}
void _formatJson() {
try {
final json = jsonDecode(_inputController.text);
final formatted = const JsonEncoder.withIndent(' ').convert(json);
_inputController.text = formatted;
Get.snackbar('成功', 'JSON已格式化',
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 2));
} catch (e) {
Get.snackbar('错误', 'JSON格式不正确',
snackPosition: SnackPosition.BOTTOM);
}
}
Future<void> _pasteFromClipboard() async {
final data = await Clipboard.getData('text/plain');
if (data != null && data.text != null) {
_inputController.text = data.text!;
_parseJson();
}
}
void _copyToClipboard() {
if (_jsonData != null) {
final formatted = const JsonEncoder.withIndent(' ').convert(_jsonData);
Clipboard.setData(ClipboardData(text: formatted));
Get.snackbar('成功', 'JSON已复制到剪贴板',
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 2));
}
}
}
// JSON节点Widget
class JsonNodeWidget extends StatefulWidget {
final dynamic data;
final String path;
final String searchQuery;
const JsonNodeWidget({
Key? key,
required this.data,
required this.path,
this.searchQuery = '',
}) : super(key: key);
State<JsonNodeWidget> createState() => _JsonNodeWidgetState();
}
class _JsonNodeWidgetState extends State<JsonNodeWidget> {
bool _isExpanded = true;
Widget build(BuildContext context) {
if (widget.data is Map) {
return _buildMapNode();
} else if (widget.data is List) {
return _buildListNode();
} else {
return _buildValueNode();
}
}
Widget _buildMapNode() {
final map = widget.data as Map;
final entries = map.entries.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () => setState(() => _isExpanded = !_isExpanded),
child: Row(
children: [
Icon(
_isExpanded ? Icons.arrow_drop_down : Icons.arrow_right,
size: 20.sp,
),
Text(
'{} Object (${entries.length} ${entries.length == 1 ? 'key' : 'keys'})',
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.bold,
color: Colors.purple,
),
),
],
),
),
if (_isExpanded)
Padding(
padding: EdgeInsets.only(left: 20.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: entries.map((entry) {
final key = entry.key.toString();
final isHighlighted = widget.searchQuery.isNotEmpty &&
key.toLowerCase().contains(widget.searchQuery);
return Padding(
padding: EdgeInsets.symmetric(vertical: 4.h),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 2.h),
decoration: BoxDecoration(
color: isHighlighted ? Colors.yellow[200] : Colors.blue[50],
borderRadius: BorderRadius.circular(4.r),
),
child: Text(
'"$key"',
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.bold,
color: Colors.blue[900],
),
),
),
Text(': ', style: TextStyle(fontSize: 13.sp)),
Expanded(
child: JsonNodeWidget(
data: entry.value,
path: '${widget.path}.$key',
searchQuery: widget.searchQuery,
),
),
],
),
);
}).toList(),
),
),
],
);
}
Widget _buildListNode() {
final list = widget.data as List;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () => setState(() => _isExpanded = !_isExpanded),
child: Row(
children: [
Icon(
_isExpanded ? Icons.arrow_drop_down : Icons.arrow_right,
size: 20.sp,
),
Text(
'[] Array (${list.length} ${list.length == 1 ? 'item' : 'items'})',
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.bold,
color: Colors.orange,
),
),
],
),
),
if (_isExpanded)
Padding(
padding: EdgeInsets.only(left: 20.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(list.length, (index) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 4.h),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 2.h),
decoration: BoxDecoration(
color: Colors.orange[50],
borderRadius: BorderRadius.circular(4.r),
),
child: Text(
'[$index]',
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.bold,
color: Colors.orange[900],
),
),
),
Text(': ', style: TextStyle(fontSize: 13.sp)),
Expanded(
child: JsonNodeWidget(
data: list[index],
path: '${widget.path}[$index]',
searchQuery: widget.searchQuery,
),
),
],
),
);
}),
),
),
],
);
}
Widget _buildValueNode() {
final value = widget.data;
Color valueColor;
String displayValue;
if (value == null) {
valueColor = Colors.grey;
displayValue = 'null';
} else if (value is bool) {
valueColor = Colors.purple;
displayValue = value.toString();
} else if (value is num) {
valueColor = Colors.green;
displayValue = value.toString();
} else {
valueColor = Colors.red[700]!;
displayValue = '"$value"';
}
final isHighlighted = widget.searchQuery.isNotEmpty &&
displayValue.toLowerCase().contains(widget.searchQuery);
return Container(
padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 2.h),
decoration: BoxDecoration(
color: isHighlighted ? Colors.yellow[200] : Colors.transparent,
borderRadius: BorderRadius.circular(4.r),
),
child: Text(
displayValue,
style: TextStyle(
fontSize: 13.sp,
color: valueColor,
fontFamily: 'monospace',
),
),
);
}
}
左右分栏布局
整个页面分为左右两部分:
左侧:JSON输入和编辑区域。
右侧:解析后的树形视图。
这种布局让用户可以边编辑边查看效果,非常直观。
JSON解析
使用Dart内置的jsonDecode函数:
void _parseJson() {
try {
_jsonData = jsonDecode(_inputController.text);
} catch (e) {
_errorMessage = e.toString();
_jsonData = null;
}
}
解析失败时显示错误信息,而不是让应用崩溃。
树形视图实现
JSON数据有三种类型:对象、数组、基本值。我们为每种类型创建不同的显示方式:
对象(Map):显示为{} Object (n keys),可以展开查看所有键值对。
数组(List):显示为[] Array (n items),可以展开查看所有元素。
基本值:直接显示值,不同类型用不同颜色。
折叠展开功能
使用一个布尔值控制节点的展开状态:
bool _isExpanded = true;
GestureDetector(
onTap: () => setState(() => _isExpanded = !_isExpanded),
child: Row(
children: [
Icon(_isExpanded ? Icons.arrow_drop_down : Icons.arrow_right),
Text('{} Object'),
],
),
)
点击节点头部可以切换展开/折叠状态。箭头图标也会相应改变。
语法高亮
不同类型的数据用不同颜色显示:
Color valueColor;
if (value == null) {
valueColor = Colors.grey;
} else if (value is bool) {
valueColor = Colors.purple;
} else if (value is num) {
valueColor = Colors.green;
} else {
valueColor = Colors.red[700]!;
}
null:灰色
布尔值:紫色
数字:绿色
字符串:红色
对象键:蓝色
数组索引:橙色
这种颜色方案参考了主流的JSON编辑器,用户会觉得很熟悉。
搜索功能
输入搜索关键词,匹配的键和值会高亮显示:
final isHighlighted = widget.searchQuery.isNotEmpty &&
key.toLowerCase().contains(widget.searchQuery);
Container(
decoration: BoxDecoration(
color: isHighlighted ? Colors.yellow[200] : Colors.blue[50],
),
child: Text(key),
)
使用黄色背景标记匹配的内容,非常醒目。
搜索是实时的,输入时立即更新高亮。
格式化功能
一键美化JSON代码:
void _formatJson() {
try {
final json = jsonDecode(_inputController.text);
final formatted = const JsonEncoder.withIndent(' ').convert(json);
_inputController.text = formatted;
} catch (e) {
Get.snackbar('错误', 'JSON格式不正确');
}
}
使用2个空格作为缩进,这是最常见的格式。
剪贴板操作
支持从剪贴板粘贴和复制到剪贴板:
Future<void> _pasteFromClipboard() async {
final data = await Clipboard.getData('text/plain');
if (data != null && data.text != null) {
_inputController.text = data.text!;
_parseJson();
}
}
void _copyToClipboard() {
final formatted = const JsonEncoder.withIndent(' ').convert(_jsonData);
Clipboard.setData(ClipboardData(text: formatted));
}
粘贴后自动解析,复制时自动格式化,提升用户体验。
错误处理
JSON解析失败时显示友好的错误提示:
if (_errorMessage.isNotEmpty) {
return Center(
child: Column(
children: [
Icon(Icons.error_outline, color: Colors.red),
Text('JSON解析错误'),
Text(_errorMessage),
],
),
);
}
显示具体的错误信息,帮助用户定位问题。
性能优化
对于大型JSON,全部展开会很卡。我们的实现默认展开所有节点,但用户可以手动折叠不需要查看的部分。
更好的方案是:
懒加载:只渲染可见的节点。
虚拟滚动:对于超长列表使用虚拟滚动。
默认折叠:超过一定深度的节点默认折叠。
功能扩展建议
编辑功能:直接在树形视图中编辑值。
路径复制:复制节点的JSON路径,如root.address.city。
类型统计:统计JSON中各种类型的数量。
验证功能:根据JSON Schema验证数据。
比较功能:对比两个JSON的差异。
导出功能:导出为CSV、XML等格式。
实战经验
做JSON查看器时,最大的挑战是如何优雅地显示嵌套结构。
一开始我用缩进来表示层级,但嵌套深了就很难看。后来改成树形结构,配合折叠展开功能,效果好多了。
还有一个细节:颜色的选择。不同类型用不同颜色,但颜色不能太多太杂,否则会眼花。最后选了6种颜色,既能区分类型,又不会太乱。
小结
JSON查看器通过树形结构和语法高亮,让复杂的JSON数据变得清晰易读。折叠展开功能让用户可以专注于感兴趣的部分,搜索功能帮助快速定位信息。
这个工具在调试API、查看配置文件时特别有用。虽然功能不如专业的JSON编辑器完整,但对于日常使用已经足够了。
记住:好的工具要让复杂的事情变简单,让用户专注于内容而不是格式。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐


所有评论(0)