文件存储和网络

Flutter学习指南
5. 交互、手势和动画
4. UI布局和控件
3. 熟悉Dart语言
2. 编写第一个应用
1. 开发环境搭建

本文是学习指南系列的第6篇文章,建议大家收藏起来,欢迎分享给他人。本篇文章我们先学习 Flutter IO 相关的基础知识,然后在上次文章的基础上,继续开发一个 echo 客户端。由于日常开发中 HTTP 比 socket 更常见,我们的 echo 客户端将会使用 HTTP 协议跟服务端通信。Echo 服务器也会使用 Dart 来实现。

文件

为了执行文件操作,我们可以使用 Dart 的 io 包:

import 'dart:io';

创建文件

在 Dart 里,我们通过类 File 来执行文件操作:

void foo() async {
  const filepath = "path to your file";
  var file = File(filepath);
  try {
    bool exists = await file.exists();
    if (!exists) {
      await file.create();
    }
  } catch (e) {
    print(e);
  }
}

相对于 CPU,IO 总是很慢的,所以大部分文件操作都返回一个 Future,并在出错的时候抛出一个异常。如果你需要,也可以使用同步版本,这些方法都带一个后缀 Sync:

void foo() {
  const filepath = "path to your file";
  var file = File(filepath);
  try {
    bool exists = file.existsSync();
    if (!exists) {
      file.createSync();
    }
  } catch (e) {
    print(e);
  }
}

async 方法使得我们可以像写同步方法一样写异步代码,同步版本的 io 方法已经没有太多使用的必要了(Dart 1 不支持 async 函数,所以同步版本的方法的存在是有必要的)。

写文件

写 String 时我们可以使用 writeAsString 和 writeAsBytes 方法:

const filepath = "path to your file";
var file = File(filepath);
await file.writeAsString('Hello, Dart IO');
List toBeWritten = [1, 2, 3];
await file.writeAsBytes(toBeWritten);

如果只是为了写文件,还可以使用 openWrite 打开一个 IOSink:

void foo() async {
  const filepath = "path to your file";
  var file = File(filepath);
  IOSink sink;
  try {
    sink = file.openWrite();
    // 默认的写文件操作会覆盖原有内容;如果要追究内容,用 append 模式
    // sink = file.openWrite(mode: FileMode.append);

    // write() 的参数是一个 Object,他会执行 obj.toString() 把转换后
    // 的 String 写入文件
    sink.write('Hello, Dart');
    //调用 flush 后才会真的把数据写出去
    await sink.flush();
  } catch (e) {
    print(e);
  } finally {
    sink?.close();
  }
}

读文件

读写原始的 bytes 也是相当简单的:

var msg = await file.readAsString();
List content = await file.readAsBytes();

和写文件类似,它还有一个 openRead 方法:

// Stream 是 async 包里的类
import 'dart:async';
// utf8、LineSplitter 属于 convert 包
import 'dart:convert';
import 'dart:io';

void foo() async {
  const filepath = "path to your file";
  var file = File(filepath);
  try {
    Stream> stream = file.openRead();
    var lines = stream
        // 把内容用 utf-8 解码
        .transform(utf8.decoder)
        // 每次返回一行
        .transform(LineSplitter());
    await for (var line in lines) {
      print(line);
    }
  } catch (e) {
    print(e);
  }
}

最后需要注意的是,我们读写 bytes 的时候,使用的对象是 List,而一个 int 在 Dart 里面有 64 位。Dart 一开始设计就是用于 Web,这部分的效率也就不那么高了。

JSON

JSON 相关的 API 放在了 convert 包里面:

import 'dart:convert';

把对象转换为 JSON

假设我们有这样一个对象:

class Point {
  int x;
  int y;
  String description;

  Point(this.x, this.y, this.description);
}

为了把他转换为 JSON,我们给他定义一个 toJson 方法(注意,不能改变他的方法签名):

class Point {
  // ...

  // 注意,我们的方法只有一个语句,这个语句定义了一个 map。
  // 使用这种语法的时候,Dart 会自动把这个 map 当做方法的返回值
  Map toJson() => {
    'x': x,
    'y': y,
    'desc': description
  };
}

接下来我们调用 json.encode 方法把对象转换为 JSON:

void main() {
  var point = Point(2, 12, 'Some point');
  var pointJson = json.encode(point);
  print('pointJson = $pointJson');

  // List, Map 都是支持的
  var points = [point, point];
  var pointsJson = json.encode(points);
  print('pointsJson = $pointsJson');
}

// 执行后打印出:
// pointJson = {"x":2,"y":12,"desc":"Some point"}
// pointsJson = [{"x":2,"y":12,"desc":"Some point"},{"x":2,"y":12,"desc":"Some point"}]

把 JSON 转换为对象

首先,我们给 Point 类再加多一个构造函数:

class Point {
  // ...

  Point.fromJson(Map map)
      : x = map['x'], y = map['y'], description = map['desc'];

  // 为了方便后面演示,也加入一个 toString
  @override
  String toString() {
    return "Point{x=$x, y=$y, desc=$description}";
  }
}

为了解析 JSON 字符串,我们可以用 json.decode 方法:

dynamic obj = json.decode(jsonString);

返回一个 dynamic 的原因在于,Dart 不知道传进去的 JSON 是什么。如果是一个 JSON 对象,返回值将是一个 Map;如果是 JSON 数组,则会返回 List

void main() {
  var point = Point(2, 12, 'Some point');
  var pointJson = json.encode(point);
  print('pointJson = $pointJson');
  var points = [point, point];
  var pointsJson = json.encode(points);
  print('pointsJson = $pointsJson');
  print('');

  var decoded = json.decode(pointJson);
  print('decoded.runtimeType = ${decoded.runtimeType}');
  var point2 = Point.fromJson(decoded);
  print('point2 = $point2');

  decoded = json.decode(pointsJson);
  print('decoded.runtimeType = ${decoded.runtimeType}');
  var points2 = [];
  for (var map in decoded) {
    points2.add(Point.fromJson(map));
  }
  print('points2 = $points2');
}

运行结果如下:

pointJson = {"x":2,"y":12,"desc":"Some point"}
pointsJson = [{"x":2,"y":12,"desc":"Some point"},{"x":2,"y":12,"desc":"Some point"}]

decoded.runtimeType = _InternalLinkedHashMap
point2 = Point{x=2, y=12, desc=Some point}
decoded.runtimeType = List
points2 = [Point{x=2, y=12, desc=Some point}, Point{x=2, y=12, desc=Some point}]

需要说明的是,我们把 Map转化为对象时使用时定义了一个构造函数,但这个是任意的,使用静态方法、Dart 工厂方法等都是可行的。之所以限定 toJson 方法的原型,是因为 json.encode 只支持 Map、List、String、int 等内置类型。当它遇到不认识的类型时,如果没有给它设置参数 toEncodable,就会调用对象的 toJson 方法(所以方法的原型不能改变)。

HTTP

为了向服务器发送 HTTP 请求,我们可以使用 io 包里面的 HttpClient。但它实在不是那么好用,于是就有人弄出了一个 http 包。为了使用 http 包,需要修改 pubspec.yaml:

# pubspec.yaml
dependencies:
  http: ^0.11.3+17

http 包的使用非常直接,为了发出一个 GET,可以使用 http.get 方法;对应的,还有 post、put 等。

import 'package:http/http.dart' as http;

Future getMessage() async {
  try {
    final response = await http.get('http://www.xxx.com/yyy/zzz');
    if (response.statusCode == 200) {
      return response.body;
    }
  } catch (e) {
    print('getMessage: $e');
  }
  return null;
}

HTTP POST 的例子我们在下面实现 echo 客户端的时候再看。

使用 SQLite 数据库

包 sqflite 可以让我们使用 SQLite:

dependencies:
  sqflite: any

sqflite 的 API 跟 Android 的那些非常像,下面我们直接用一个例子来演示:

import 'package:sqflite/sqflite.dart';

class Todo {
  static const columnId = 'id';
  static const columnTitle = 'title';
  static const columnContent = 'content';

  int id;
  String title;
  String content;

  Todo(this.title, this.content, [this.id]);

  Todo.fromMap(Map map)
      : id = map[columnId], title = map[columnTitle], content = map[columnContent];

  Map toMap() => {
    columnTitle: title,
    columnContent: content,
  };

  @override
  String toString() {
    return 'Todo{id=$id, title=$title, content=$content}';
  }
}

void foo() async {
  const table = 'Todo';
  // getDatabasesPath() 的 sqflite 提供的函数
  var path = await getDatabasesPath() + '/demo.db';
  // 使用 openDatabase 打开数据库
  var database = await openDatabase(
      path,
      version: 1,
      onCreate: (db, version) async {
        var sql ='''
            CREATE TABLE $table ('
            ${Todo.columnId} INTEGER PRIMARY KEY,'
            ${Todo.columnTitle} TEXT,'
            ${Todo.columnContent} TEXT'
            )
            ''';
        // execute 方法可以执行任意的 SQL
        await db.execute(sql);
      }
  );
  // 为了让每次运行的结果都一样,先把数据清掉
  await database.delete(table);

  var todo1 = Todo('Flutter', 'Learn Flutter widgets.');
  var todo2 = Todo('Flutter', 'Learn how to to IO in Flutter.');

  // 插入数据
  await database.insert(table, todo1.toMap());
  await database.insert(table, todo2.toMap());

  List list = await database.query(table);
  // 重新赋值,这样 todo.id 才不会为 0
  todo1 = Todo.fromMap(list[0]);
  todo2 = Todo.fromMap(list[1]);
  print('query: todo1 = $todo1');
  print('query: todo2 = $todo2');

  todo1.content += ' Come on!';
  todo2.content += ' I\'m tired';
  // 使用事务
  await database.transaction((txn) async {
    // 注意,这里面只能用 txn。直接使用 database 将导致死锁
    await txn.update(table, todo1.toMap(),
        // where 的参数里,我们可以使用 ? 作为占位符,对应的值按顺序放在 whereArgs

        // 注意,whereArgs 的参数类型是 List,这里不能写成 todo1.id.toString()。
        // 不然就变成了用 String 和 int 比较,这样一来就匹配不到待更新的那一行了
        where: '${Todo.columnId} = ?', whereArgs: [todo1.id]);
    await txn.update(table, todo2.toMap(),
        where: '${Todo.columnId} = ?', whereArgs: [todo2.id]);
  });

  list = await database.query(table);
  for (var map in list) {
    var todo = Todo.fromMap(map);
    print('updated: todo = $todo');
  }

  // 最后,别忘了关闭数据库
  await database.close();
}

运行结果如下:

query: todo1 = Todo{id=1, title=Flutter, content=Learn Flutter widgets}
query: todo2 = Todo{id=2, title=Flutter, content=Learn how to to IO in Flutter}
updated: todo = Todo{id=1, title=Flutter, content=Learn Flutter widgets. Come on!}
updated: todo = Todo{id=2, title=Flutter, content=Learn how to to IO in Flutter. I'm tired}

有 Android 经验的读者会发现,使用 Dart 编写数据库相关代码的时候舒服很多。如果读者对数据库不太熟悉,可以参考《SQL必知必会》。本篇的主要知识点到这里的就讲完了,作为练习,下面我们就一起来实现 echo 客户端的后端。

echo 客户端

HTTP 服务端

在开始之前,你可以在 GitHub 上找到上篇文章的代码,我们将在它的基础上进行开发。

git clone https://github.com/Jekton/flutter_demo.git
cd flutter_demo
git checkout ux-basic

服务端架构

首先我们来看看服务端的架构(说是架构,但其实非常的简单,或者说很简陋):

import 'dart:async';
import 'dart:io';

class HttpEchoServer {

  final int port;
  HttpServer httpServer;
  // 在 Dart 里面,函数也是 first class object,所以我们可以直接把
  // 函数放到 Map 里面
  Map routes;

  HttpEchoServer(this.port) {
    _initRoutes();
  }

  void _initRoutes() {
    routes = {
      // 我们只支持 path 为 '/history' 和 '/echo' 的请求。
      // history 用于获取历史记录;
      // echo 则提供 echo 服务。
      '/history': _history,
      '/echo': _echo,
    };
  }

  // 返回一个 Future,这样客户端就能够在 start 完成后做一些事
  Future start() async {
    // 1. 创建一个 HttpServer
    httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, port);
    // 2. 开始监听客户请求
    return httpServer.listen((request) {
      final path = request.uri.path;
      final handler = routes[path];
      if (handler != null) {
        handler(request);
      } else {
        // 给客户返回一个 404
        request.response.statusCode = HttpStatus.notFound;
        request.response.close();
      }
    });
  }

  void _history(HttpRequest request) {
    // ...
  }

  void _echo(HttpRequest request) async {
    // ...
  }

  void close() async {
    var server = httpServer;
    httpServer = null;
    await server?.close();
  }
}

在服务端框架里,我们把支持的所有路径都加到 routes 里面,当收到客户请求的时候,只需要直接从 routes 里取出对应的处理函数,把请求分发给他就可以了。如果读者对服务端编程没有太大兴趣或不太了解,这部分可以不用太关注。

将对象序列化为 JSON

为了把 Message 对象序列化为 JSON,这里我们对 Message 做一些小修改:

class Message {
  final String msg;
  final int timestamp;

  Message(this.msg, this.timestamp);
  Message.create(String msg)
      : msg = msg, timestamp = DateTime.now().millisecondsSinceEpoch;

  Map toJson() => {
    "msg": "$msg",
    "timestamp": timestamp
  };

  @override
  String toString() {
    return 'Message{msg: $msg, timestamp: $timestamp}';
  }
}

这里我们加入一个 toJson 方法。下面是服务端的 _echo 方法:

class HttpEchoServer {
  static const GET = 'GET';
  static const POST = 'POST';

  const List messages = [];

  // ...

  _unsupportedMethod(HttpRequest request) {
    request.response.statusCode = HttpStatus.methodNotAllowed;
    request.response.close();
  }

  void _echo(HttpRequest request) async {
    if (request.method != POST) {
      _unsupportedMethod(request);
      return;
    }

    // 获取从客户端 POST 请求的 body,更多的知识,参考
    // https://www.dartlang.org/tutorials/dart-vm/httpserver
    String body = await request.transform(utf8.decoder).join();
    if (body != null) {
      var message = Message.create(body);
      messages.add(message);
      request.response.statusCode = HttpStatus.ok;
      // json 是 convert 包里的对象,encode 方法还有第二个参数 toEncodable。当遇到对象不是
      // Dart 的内置对象时,如果提供这个参数,就会调用它对对象进行序列化;这里我们没有提供,
      // 所以 encode 方法会调用对象的 toJson 方法,这个方法在前面我们已经定义了
      var data = json.encode(message);
      // 把响应写回给客户端
      request.response.write(data);
    } else {
      request.response.statusCode = HttpStatus.badRequest;
    }
    request.response.close();
  }
}

HTTP 客户端

我们的 echo 服务器使用了 dart:io 包里面 HttpServer 来开发。对应的,我们也可以使用这个包里的 HttpRequest 来执行 HTTP 请求,但这里我们并不打算这么做。第三方库 http 提供了更简单易用的接口。

首先把依赖添加到 pubspec 里:

# pubspec.yaml
dependencies:
  # ...

  http: ^0.11.3+17

客户端实现如下:

import 'package:http/http.dart' as http;

class HttpEchoClient {
  final int port;
  final String host;

  HttpEchoClient(this.port): host = 'http://localhost:$port';

  Future send(String msg) async {
    // http.post 用来执行一个 HTTP POST 请求。
    // 它的 body 参数是一个 dynamic,可以支持不同类型的 body,这里我们
    // 只是直接把客户输入的消息发给服务端就可以了。由于 msg 是一个 String,
    // post 方法会自动设置 HTTP 的 Content-Type 为 text/plain
    final response = await http.post(host + '/echo', body: msg);
    if (response.statusCode == 200) {
      Map msgJson = json.decode(response.body);
      // Dart 并不知道我们的 Message 长什么样,我们需要自己通过
      // Map 来构造对象
      var message = Message.fromJson(msgJson);
      return message;
    } else {
      return null;
    }
  }
}

class Message {
  final String msg;
  final int timestamp;

  Message.fromJson(Map json)
    : msg = json['msg'], timestamp = json['timestamp'];

  // ...
}

现在,让我们把他们和上一节的 UI 结合到一起。首先启动服务器,然后创建客户端:

HttpEchoServer _server;
HttpEchoClient _client;

class _MessageListState extends State {
  final List messages = [];

  @override
  void initState() {
    super.initState();

    const port = 6060;
    _server = HttpEchoServer(port);
    // initState 不是一个 async 函数,这里我们不能直接 await _server.start(),
    // future.then(...) 跟 await 是等价的
    _server.start().then((_) {
      // 等服务器启动后才创建客户端
      _client = HttpEchoClient(port);
    });
  }

  // ...
}
class MessageListScreen extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // ...

      floatingActionButton: FloatingActionButton(
        onPressed: () async {
          final result = await Navigator.push(
              context,
              MaterialPageRoute(builder: (_) => AddMessageScreen())
          );
          // 以下是修改了的地方
          if (_client == null) return;
          // 现在,我们不是直接构造一个 Message,而是通过 _client 把消息
          // 发送给服务器
          var msg = await _client.send(result);
          if (msg != null) {
            messageListKey.currentState.addMessage(msg);
          } else {
            debugPrint('fail to send $result');
          }
        },
        // ...
      )
    );
  }
}

大功告成,在做了这么多工作以后,我们的应用现在是真正的 echo 客户端了,虽然看起来跟之前没什么两样。接下来,我们就做一些跟之前不一样的——把历史记录保存下来。

历史记录存储、恢复

获取应用的存储路径

为了获得应用的文件存储路径,我们引入多一个库:

# pubspec.yaml
dependencies:
  # ...

  path_provider: ^0.4.1

通过它我们可以拿到应用的 file、cache 和 external storage 的路径:

import 'package:path_provider/path_provider.dart' as path_provider;

class HttpEchoServer {
  String historyFilepath;

  Future start() async {
    historyFilepath = await _historyPath();

    // ...
  }

  Future _historyPath() async {
    // 获取应用私有的文件目录
    final directory = await path_provider.getApplicationDocumentsDirectory();
    return directory.path + '/messages.json';
  }
}

保存历史记录

class HttpEchoServer {

  void _echo(HttpRequest request) async {
    // ...

    // 原谅我,为了简单,我们就多存几次吧
    _storeMessages();
  }

  Future _storeMessages() async {
    try {
      // json.encode 支持 List、Map
      final data = json.encode(messages);
      // File 是 dart:io 里的类
      final file = File(historyFilepath);
      final exists = await file.exists();
      if (!exists) {
        await file.create();
      }
      file.writeAsString(data);
      return true;
    // 虽然文件操作方法都是异步的,我们仍然可以通过这种方式 catch 到
    // 他们抛出的异常
    } catch (e) {
      print('_storeMessages: $e');
      return false;
    }
  }
}

加载历史记录

class HttpEchoServer {

  // ...

  Future start() async {
    historyFilepath = await _historyPath();
    // 在启动服务器前先加载历史记录
    await _loadMessages();
    httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, port);
    // ...
  }

  Future _loadMessages() async {
    try {
      var file = File(historyFilepath);
      var exists = await file.exists();
      if (!exists) return;

      var content = await file.readAsString();
      var list = json.decode(content);
      for (var msg in list) {
        var message = Message.fromJson(msg);
        messages.add(message);
      }
    } catch (e) {
      print('_loadMessages: $e');
    }
  }
}

现在,我们来实现 _history 函数:

class HttpEchoServer {
  // ...

  void _history(HttpRequest request) {
    if (request.method != GET) {
      _unsupportedMethod(request);
      return;
    }

    String historyData = json.encode(messages);
    request.response.write(historyData);
    request.response.close();
  }
}

_history 的实现很直接,我们只是把 messages 全都返回给客户端。

接下来是客户端部分:

class HttpEchoClient {

  // ...

  Future> getHistory() async {
    try {
      // http 包的 get 方法用来执行 HTTP GET 请求
      final response = await http.get(host + '/history');
      if (response.statusCode == 200) {
        return _decodeHistory(response.body);
      }
    } catch (e) {
      print('getHistory: $e');
    }
    return null;
  }

  List _decodeHistory(String response) {
    // JSON 数组 decode 出来是一个 >[]
    var messages = json.decode(response);
    var list = [];
    for (var msgJson in messages) {
      list.add(Message.fromJson(msgJson));
    }
    return list;
  }
}


class _MessageListState extends State {
  final List messages = [];

  @override
  void initState() {
    super.initState();

    const port = 6060;
    _server = HttpEchoServer(port);
    _server.start().then((_) {
      // 我们等服务器启动后才创建客户端
      _client = HttpEchoClient(port);
      // 创建客户端后马上拉取历史记录
      _client.getHistory().then((list) {
        setState(() {
          messages.addAll(list);
        });
      });
    });
  }

  // ...
}

生命周期

最后需要做的是,在 APP 退出后关闭服务器。这就要求我们能够收到应用生命周期变化的通知。为了达到这个目的,Flutter 为我们提供了 WidgetsBinding 类(虽然没有 Android 的 Lifecycle 那么好用就是啦)。

// 为了使用 WidgetsBinding,我们继承 WidgetsBindingObserver 然后覆盖相应的方法
class _MessageListState extends State with WidgetsBindingObserver {

  // ...

  @override
  void initState() {
    // ...
    _server.start().then((_) {
      // ...

      // 注册生命周期回调
      WidgetsBinding.instance.addObserver(this);
    });
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.paused) {
      var server = _server;
      _server = null;
      server?.close();
    }
  }
}

现在,我们的应用是这个样子的:

flutter-echo-demo

所有的代码可以在 GitHub 上找到:

git clone https://github.com/Jekton/flutter_demo.git
cd flutter_demo
git checkout io-basic

使用 SQLite 数据库

前面的实现中我们把 echo 服务器的数据存放在了文件里。这一节我们改一改,把数据存到 SQLite 中。

别忘了添加依赖:

dependencies:
  sqflite: any

初始化数据库

import 'package:sqflite/sqflite.dart';

class HttpEchoServer {
  // ...

  static const tableName = 'History';
  // 这部分常量最好是放到 Message 的定义里。为了方便阅读,就暂且放这里吧
  static const columnId = 'id';
  static const columnMsg = 'msg';
  static const columnTimestamp = 'timestamp';

  Database database;

  Future start() async {
    await _initDatabase();

    // ...
  }

  Future _initDatabase() async {
    var path = await getDatabasesPath() + '/history.db';
    database = await openDatabase(
      path,
      version: 1,
      onCreate: (db, version) async {
        var sql = '''
            CREATE TABLE $tableName (
            $columnId INTEGER PRIMARY KEY,
            $columnMsg TEXT,
            $columnTimestamp INTEGER
            )
            ''';
        await db.execute(sql);
      }
    );
  }
}

加载历史记录

加载历史记录的相关代码在 _loadMessages 方法中,这里我们修改原有的实现,让它从数据库加载数据:

class HttpEchoServer {
  // ...

  Future _loadMessages() async {
    var list = await database.query(
      tableName,
      columns: [columnMsg, columnTimestamp],
      orderBy: columnId,
    );
    for (var item in list) {
      // fromJson 也适用于使用数据库的场景
      var message = Message.fromJson(item);
      messages.add(message);
    }
  }
}

实际上改为使用数据库来存储后,我们并不需要把所有的消息都存放在内存中(也就是这里的 _loadMessage 是不必要的)。客户请求历史记录时,我们再按需从数据库读取数据即可。为了避免修改到程序的逻辑,这里还是继续保持一份数据在内存中。有兴趣的读者可以对程序作出相应的修改。

保存记录

记录的保存很简单,一行代码就可以搞定了:

void _echo(HttpRequest request) async {
  // ...

  _storeMessage(message);
}

void _storeMessage(Message msg) {
  database.insert(tableName, msg.toJson());
}

使用 JSON 的版本,我们每次都需要把所有的数据都保存一遍。对数据库来说,只要把收到的这一条信息存进去即可。读者也应该能够感受到,就我们的需求来说,使用 SQLite 的版本实现起来更简单,也更高效。

关闭数据库

close 方法也要做相应的修改:

void close() async {
  // ...

  var db = database;
  database = null;
  db?.close();
}

这部分代码可以查看 tag echo-db:

git clone https://github.com/Jekton/flutter_demo.git
cd flutter_demo
git checkout echo-db

 

原文https://mp.weixin.qq.com/s?__biz=MzIwMTAzMTMxMg==&mid=2649493395&idx=1&sn=01a51c9b5ddb3b36807f5dab6c0cbf19&chksm=8eec846cb99b0d7aee799f1d5015359583942ea3ac854875d8dc5ff22b622b495f0df6fb42a3&scene=21#wechat_redirect

你可能感兴趣的:(【Flutter点滴知识,】)