如何在Dart/Flutter中解析JSON的基本指南

4,377 阅读13分钟

对于需要从互联网获取数据的应用程序来说,解析JSON是一项非常常见的任务。

而根据你需要处理的JSON数据的多少,你有两个选择。

  1. 手动编写所有的JSON解析代码
  2. 用代码生成来自动处理

本指南将重点介绍如何手动解析JSON到Dart代码,包括:

  • 编码和解码JSON
  • 定义类型安全的模型类
  • 使用工厂构造器将JSON解析为Dart代码
  • 处理空值/可选值
  • 数据验证
  • 序列化回JSON
  • 解析复杂/嵌套的JSON数据
  • 使用deep_pick包挑选深度值

在本文结束时,你将学会如何用强大的JSON解析和验证代码编写模型类。

我们有很多内容要讲,所以让我们从基础开始。

剖析JSON文档

如果你以前使用过任何REST API,这个JSON响应样本应该看起来很熟悉。

{
  "name": "Pizza da Mario",
  "cuisine": "Italian",
  "reviews": [
    {
      "score": 4.5,
      "review": "The pizza was amazing!"
    },
    {
      "score": 5.0,
      "review": "Very friendly staff, excellent service!"
    }
  ]
}

这个简单的文档表示一个键值对的映射,其中:

  • 键是字符串
  • 值可以是任何原始类型(如布尔值、数字或字符串),或一个集合(如列表或地图)

JSON数据可以包含键值对的映射(使用{} )和列表(使用[] )。这些都可以被组合起来,创建代表复杂数据结构的嵌套集合

事实上,我们的例子包括一个特定餐厅的评论列表。

"reviews": [
  {
    "score": 4.5,
    "review": "The pizza was amazing!"
  },
  {
    "score": 5.0,
    "review": "Very friendly staff, excellent service!"
  }
]

这些被存储为reviews 关键的映射列表,每个评论本身就是一个有效的JSON片段

编码和解码JSON

当JSON响应通过网络发送时,整个有效载荷被编码为一个字符串。

但在我们的Flutter应用程序中,我们不想手动提取字符串中的数据。

// json payload as a string
final json = '{ "name": "Pizza da Mario", "cuisine": "Italian", "reviews": [{"score": 4.5,"review": "The pizza was amazing!"},{"score": 5.0,"review": "Very friendly staff, excellent service!"}]}';

相反,我们可以通过对JSON进行解码来读取其内容。

要在网络上发送JSON数据,首先需要对它进行编码序列化编码是将数据结构变成字符串的过程。相反的过程被称为解码反序列化。当你收到作为字符串的JSON有效载荷时,你需要在使用它之前对其进行解码反序列化

用dart:convert对JSON进行解码

为了简单起见,让我们考虑这个小的JSON有效载荷。

// this represents some response data we get from the network, for example:
// ```
// final response = await http.get(uri);
// final jsonData = response.body
// ```
final jsonData = '{ "name": "Pizza da Mario", "cuisine": "Italian" }';

为了读取其中的键和值,我们首先需要使用dart:convert 包对其进行解码

// 1. import dart:convert
import 'dart:convert';

// this represents some response data we get from the network
final jsonData = '{ "name": "Pizza da Mario", "cuisine": "Italian" }';
// 2. decode the json
final parsedJson = jsonDecode(jsonData);
// 3. print the type and value
print('${parsedJson.runtimeType} : $parsedJson');

如果我们运行这段代码,我们会得到这样的输出。

_InternalLinkedHashMap<String, dynamic> : {name: Pizza da Mario, cuisine: Italian}

实际上,结果类型与Map<String, dynamic> 相同。

_InternalLinkedHashMap是**LinkedHashMap的一个私有实现,它又实现了Map**。

所以键的类型是String ,值的类型是dynamic 。这是有道理的,因为每个JSON值可能是一个原始类型(布尔/数字/字符串),或者是一个集合(列表或地图)。

事实上,jsonDecode() 是一个通用的方法,对任何有效的JSON有效载荷都有效,不管它里面是什么。它所做的就是进行解码并返回一个dynamic

但是如果我们在Dart中使用dynamic 值,我们就会失去强类型安全的所有好处。一个更好的方法是定义一些自定义的模型类在个案的基础上表示我们的响应数据。

由于Dart是一种静态类型的语言,重要的是将JSON数据转换为代表现实世界对象(如食谱、雇员等)的模型类,并充分利用类型系统

因此,让我们看看如何做到这一点。

将JSON解析为Dart模型类

鉴于这个简单的JSON。

{
  "name": "Pizza da Mario",
  "cuisine": "Italian"
}

我们可以写一个Restaurant 类来表示它。

class Restaurant {
  Restaurant({required this.name, required this.cuisine});
  final String name;
  final String cuisine;
}

因此,与其像这样读数据,不如像这样读。

parsedJson['name']; // dynamic
parsedJson['cuisine']; // dynamic

我们可以像这样读取它。

restaurant.name; // guaranteed to be a non-nullable, immutable String 
restaurant.cuisine; // guaranteed to be a non-nullable, immutable String

这样就干净多了,我们可以利用类型系统来获得编译时的安全性,避免错别字和其他错误。

然而,我们还没有指定如何将我们的parsedJson 转化为Restaurant 对象!

JSON到Dart。添加一个工厂构造函数

让我们定义一个工厂构造函数来处理这个问题。

factory Restaurant.fromJson(Map<String, dynamic> data) {
  // note the explicit cast to String
  // this is required if robust lint rules are enabled
  final name = data['name'] as String;
  final cuisine = data['cuisine'] as String;
  return Restaurant(name: name, cuisine: cuisine);
}

对于JSON解析来说,工厂构造函数是一个不错的选择,因为它可以让我们在返回结果之前做一些工作(创建变量,执行一些验证)。这在普通的(生成式)构造函数中是不可能的。

由于我们的map的值是dynamic ,所以我们明确地将它们转换为我们想要的类型(本例中是String )。这是一个很好的做法,可以通过使用推荐的lint规则来强制执行。

这就是我们如何使用我们的构造函数。

// type: String
final jsonData = '{ "name": "Pizza da Mario", "cuisine": "Italian" }';
// type: dynamic (runtime type: _InternalLinkedHashMap<String, dynamic>)
final parsedJson = jsonDecode(jsonData);
// type: Restaurant
final restaurant = Restaurant.fromJson(parsedJson);

好多了。现在我们其余的代码可以使用Restaurant ,并获得Dart中强类型安全的所有优势。

JSON到Dart的空值安全

有时我们需要解析一些JSON,它可能有也可能没有某个键值对。

例如,假设我们有一个可选字段,告诉我们一家餐馆是什么时候开业的。

{
  "name": "Ezo Sushi",
  "cuisine": "Japanese",
  "year_opened": 1990
}

如果year_opened 字段是可选的,我们可以在我们的模型类中用一个可空变量来表示它。

下面是Restaurant 类的一个更新的实现。

class Restaurant {
  Restaurant({required this.name, required this.cuisine, this.yearOpened});
  final String name; // non-nullable
  final String cuisine; // non-nullable
  final int? yearOpened; // nullable

  factory Restaurant.fromJson(Map<String, dynamic> data) {
    final name = data['name'] as String; // cast as non-nullable String
    final cuisine = data['cuisine'] as String; // cast as non-nullable String
    final yearOpened = data['year_opened'] as int?; // cast as nullable int
    return Restaurant(name: name, cuisine: cuisine, yearOpened: yearOpened);
  }
}

一般来说,我们应该将可选的JSON值映射为可忽略的Dart属性。另外,我们也可以使用非空值的Dart属性,并有一个合理的默认值,就像本例中一样。

// note: all the previous properties have been omitted for simplicity
class Restaurant {
  Restaurant({
    // 1. required
    required this.hasIndoorSeating,
  });
  // 2. *non-nullable*
  final bool hasIndoorSeating;

  factory Restaurant.fromJson(Map<String, dynamic> data) {
    // 3. cast as *nullable* bool
    final hasIndoorSeating = data['has_indoor_seating'] as bool?;
    return Restaurant(
      // 4. use ?? operator to provide a default value
      hasIndoorSeating: hasIndoorSeating ?? true,
    );
  }
}

请注意,在这种情况下,我们使用空值凝聚运算符(??)来提供一个默认值。

数据验证

使用工厂构造函数的一个好处是,如果需要,我们可以做一些额外的验证。

例如,我们可以写一些防御性的代码,如果缺少一个必需的值,就抛出一个UnsupportedError

factory Restaurant.fromJson(Map<String, dynamic> data) {
  // casting as a nullable String so we can do an explicit null check
  final name = data['name'] as String?;
  if (name == null) {
    throw UnsupportedError('Invalid data: $data -> "name" is missing');
  }
  // casting as a nullable String so we can do an explicit null check
  final cuisine = data['cuisine'] as String?;
  if (cuisine == null) {
    throw UnsupportedError('Invalid data: $data -> "cuisine" is missing');
  }
  final yearOpened = data['year_opened'] as int?;
  // thanks to the if statements above, name and cuisine are guaranteed to be non-null here
  return Restaurant(name: name, cuisine: cuisine, yearOpened: yearOpened);
}

一般来说,作为API的消费者,我们的工作是为每个值计算出来。

  • 它的类型 (String,int, 等等。)
  • 它是否是可选的****(nullablevsnon-nullable)。
  • 允许的数值范围是多少

这将使我们的JSON解析代码更加强大。而且我们将不必在我们的部件类中处理无效的数据,因为所有的验证都是预先完成的

为了使你的代码可以投入生产,考虑编写单元测试来测试你所有模型类的所有可能的边缘情况。

用toJson()进行JSON序列化

解析JSON是很有用的,但有时我们想把一个模型对象转换回JSON并通过网络发送。

要做到这一点,我们可以为我们的Restaurant 类定义一个toJson() 方法。

// note the return type
Map<String, dynamic> toJson() {
  // return a map literal with all the non-null key-value pairs
  return {
    'name': name,
    'cuisine': cuisine,
    // here we use collection-if to account for null values
    if (yearOpened != null) 'year_opened': yearOpened,
  };
}

而我们可以像这样使用。

// given a Restaurant object
final restaurant = Restaurant(name: "Patatas Bravas", cuisine: "Spanish");
// convert it to map
final jsonMap = restaurant.toJson();
// encode it to a JSON string
final encodedJson = jsonEncode(jsonMap);
// then send it as a request body with any networking package

解析嵌套的JSON:地图的列表

现在我们了解了JSON解析和验证的基本知识,让我们回到最初的例子,看看如何解析它。

{
  "name": "Pizza da Mario",
  "cuisine": "Italian",
  "reviews": [
    {
      "score": 4.5,
      "review": "The pizza was amazing!"
    },
    {
      "score": 5.0,
      "review": "Very friendly staff, excellent service!"
    }
  ]
}

我们想一直使用模型类和类型安全,所以让我们定义一个Review 类。

class Review {
  Review({required this.score, this.review});
  // non-nullable - assuming the score field is always present
  final double score;
  // nullable - assuming the review field is optional
  final String? review;

  factory Review.fromJson(Map<String, dynamic> data) {
    final score = data['score'] as double;
    final review = data['review'] as String?;
    return Review(score: score, review: review);
  }

  Map<String, dynamic> toJson() {
    return {
      'score': score,
      // here we use collection-if to account for null values
      if (review != null) 'review': review,
    };
  }
}

然后我们可以更新Restaurant 类,以包括一个评论的列表。

class Restaurant {
  Restaurant({
    required this.name,
    required this.cuisine,
    this.yearOpened,
    required this.reviews,
  });
  final String name;
  final String cuisine;
  final int? yearOpened;
  final List<Review> reviews;
}

我们还可以更新工厂构造函数。

factory Restaurant.fromJson(Map<String, dynamic> data) {
  final name = data['name'] as String;
  final cuisine = data['cuisine'] as String;
  final yearOpened = data['year_opened'] as int?;
  // cast to a nullable list as the reviews may be missing
  final reviewsData = data['reviews'] as List<dynamic>?;
  // if the reviews are not missing
  final reviews = reviewsData != null
      // map each review to a Review object
      ? reviewsData.map((reviewData) => Review.fromJson(reviewData))
        // map() returns an Iterable so we convert it to a List
        .toList()
      // use an empty list as fallback value
      : <Review>[];
  // return result passing all the arguments
  return Restaurant(
    name: name,
    cuisine: cuisine,
    yearOpened: yearOpened,
    reviews: reviews,
  );
}

下面是所有新代码的分类。

  • 评论可能会丢失,因此我们将其转换为nullableList
  • 列表中的值可以有任何类型,所以我们使用List<dynamic>
  • 我们使用.map() 操作符将每个dynamic 值转换为Review 对象。Review.fromJson()
  • 如果评论缺失,我们使用一个空列表(<Review>[])作为备用。

这个具体的实现对什么可能是或不可能是null ,使用什么后备值等做了一些假设。你需要编写最适合使用情况的解析代码。

嵌套模型的序列化

作为最后一步,这里是toJson() 方法,将一个Restaurant (和它的所有评论)转换回一个Map

Map<String, dynamic> toJson() {
  return {
    'name': name,
    'cuisine': cuisine,
    if (yearOpened != null) 'year_opened': yearOpened,
    'reviews': reviews.map((review) => review.toJson()).toList(),
  };
}

注意我们是如何将List<Review> 转换回List<Map<String, dynamic>> ,因为我们需要将所有的嵌套值也序列化(而不仅仅是Restaurant 类本身)。

通过上面的代码,我们可以创建一个Restaurant 对象,并将其转换回一个可以被编码和打印或通过网络发送的地图。

final restaurant = Restaurant(
  name: 'Pizza da Mario',
  cuisine: 'Italian',
  reviews: [
    Review(score: 4.5, review: 'The pizza was amazing!'),
    Review(score: 5.0, review: 'Very friendly staff, excellent service!'),
  ],
);
final encoded = jsonEncode(restaurant.toJson());
print(encoded);
// output: {"name":"Pizza da Mario","cuisine":"Italian","reviews":[{"score":4.5,"review":"The pizza was amazing!"},{"score":5.0,"review":"Very friendly staff, excellent service!"}]}

挑选深度值

将整个JSON文档解析为类型安全的模型类是一个非常常见的用例。

但有时我们只想读取一些可能是深度嵌套特定值

让我们再一次考虑我们的JSON样本。

{
  "name": "Pizza da Mario",
  "cuisine": "Italian",
  "reviews": [
    {
      "score": 4.5,
      "review": "The pizza was amazing!" 
    },
    {
      "score": 5.0,
      "review": "Very friendly staff, excellent service!"
    }
  ]
}

如果我们想得到第一次审查的分数,我们可以这样做。

final decodedJson = jsonDecode(jsonData); // dynamic
final score = decodedJson['reviews'][0]['score'] as double;

这是有效的Dart代码,因为decodedJson 变量是dynamic ,我们可以用下标运算符([])。

但是上面的代码既不是null安全的,也不是类型安全的,我们必须明确地将解析后的值转换为我们想要的类型 (double)。

我们怎样才能改进这一点呢?

deep_pick包

deep_pick包通过一个类型安全的API简化了JSON解析。

一旦安装,我们可以使用它来获得我们想要的值,而不需要手动转换。

import 'dart:convert';
import 'package:deep_pick/deep_pick.dart';

final decodedJson = jsonDecode(jsonData); // dynamic
final score = pick(decodedJson, 'reviews', 0, 'score').asDoubleOrThrow();

deep_pick提供了各种灵活的API,我们可以用它来解析原始类型、列表、地图、DateTime对象,以及更多。阅读文档获取更多信息。

奖励:添加一个toString()方法

当使用模型类时,提供一个toString() 方法是非常有用的,这样它们就可以很容易地被打印到控制台。

由于我们已经有了一个toJson() 方法,我们可以像这样利用它。

@override
String toString() => toJson().toString();

结果是,我们可以像这样直接打印我们的餐厅。

print(restaurant);
// output: {name: Pizza da Mario, cuisine: Italian, reviews: [{score: 4.5, review: The pizza was amazing!}, {score: 5.0, review: Very friendly staff, excellent service!}]}

如果我们能够使用== 操作符来比较我们的模型类,那就更好了,这在编写单元测试时经常需要。要了解如何做到这一点,请查看Equatable包。

关于性能的注意事项

当你解析小的JSON文档时,你的应用程序可能会保持响应,不会遇到性能问题。

但是解析非常大的JSON文档可能会导致昂贵的计算,最好在后台单独的Dart隔离区中进行。官方文档对此有一个很好的指导。

总结

JSON序列化是一项非常平凡的工作。但如果我们想让我们的应用程序正常工作,那么正确地完成它并注意细节是非常重要的。

  • 使用jsonEncode()jsonDecode()'dart:convert' 来序列化JSON数据
  • fromJson()toJson() 为你应用程序中所有特定领域的JSON对象创建模型类
  • fromJson() 内添加明确的转换验证空值检查,以使解析代码更加健壮
  • 对于嵌套的JSON数据(地图的列表),应用fromJson()toJson() 方法
  • 考虑使用deep_pick包,以类型安全的方式解析JSON。

虽然我们用作参考的JSON例子并不太复杂,但我们最终还是得到了相当多的代码。

如果你有很多不同的模型类,或者每个类都有很多属性,那么用手写所有的解析代码就会变得很费时而且容易出错。

在这种情况下,代码生成是一个更好的选择,这篇文章解释了如何使用它。

而如果你需要解析大的JSON数据,你应该在一个单独的隔离区中进行,以获得最佳性能。这篇文章涵盖了所有的细节。