本文由玉刚说写作平台 提供写作赞助 赞助金额:200元 原作者:水晶虾饺
版权声明:本文版权归微信公众号玉刚说
所有,未经许可,不得以任何形式转载
本篇文章我们先学习 Flutter IO 相关的基础知识,然后在Flutter 开发(3)- 交互、动画、手势和事件处理 的基础上,继续开发一个 echo 客户端。由于日常开发中 HTTP 比 socket 更常见,我们的 echo 客户端将会使用 HTTP 协议跟服务端通信。Echo 服务器也会使用 Dart 来实现。
文件 为了执行文件操作,我们可以使用 Dart 的 io 包:
创建文件 在 Dart 里,我们通过类 File
来执行文件操作:
1 2 3 4 5 6 7 8 9 10 11 12 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:
1 2 3 4 5 6 7 8 9 10 11 12 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
方法:
1 2 3 4 5 const filepath = "path to your file" ;var file = File(filepath);await file.writeAsString('Hello, Dart IO' );List <int > toBeWritten = [1 , 2 , 3 ];await file.writeAsBytes(toBeWritten);
如果只是为了写文件,还可以使用 openWrite
打开一个 IOSink
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 void foo() async { const filepath = "path to your file" ; var file = File(filepath); IOSink sink; try { sink = file.openWrite(); sink.write('Hello, Dart' ); await sink.flush(); } catch (e) { print (e); } finally { sink?.close(); } }
读文件 读写原始的 bytes 也是相当简单的:
1 2 var msg = await file.readAsString();List <int > content = await file.readAsBytes();
和写文件类似,它还有一个 openRead
方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 import 'dart:async' ;import 'dart:convert' ;import 'dart:io' ;void foo() async { const filepath = "path to your file" ; var file = File(filepath); try { Stream<List <int >> stream = file.openRead(); var lines = stream .transform(utf8.decoder) .transform(LineSplitter()); await for (var line in lines) { print (line); } } catch (e) { print (e); } }
最后需要注意的是,我们读写 bytes 的时候,使用的对象是 List<int>
,而一个 int
在 Dart 里面有 64 位。Dart 一开始设计就是用于 Web,这部分的效率也就不那么高了。
JSON JSON 相关的 API 放在了 convert 包里面:
把对象转换为 JSON 假设我们有这样一个对象:
1 2 3 4 5 6 7 class Point { int x; int y; String description; Point(this .x, this .y, this .description); }
为了把他转换为 JSON,我们给他定义一个 toJson
方法(注意,不能改变他的方法签名):
1 2 3 4 5 6 7 8 9 10 11 class Point { Map <String , dynamic > toJson() => { 'x' : x, 'y' : y, 'desc' : description }; }
接下来我们调用 json.encode
方法把对象转换为 JSON:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 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' ); }
把 JSON 转换为对象 首先,我们给 Point
类再加多一个构造函数:
1 2 3 4 5 6 7 8 9 10 11 12 class Point { Point.fromJson(Map <String , dynamic > map) : x = map['x' ], y = map['y' ], description = map['desc' ]; @override String toString() { return "Point{x=$x, y=$y, desc=$description}" ; } }
为了解析 JSON 字符串,我们可以用 json.decode
方法:
1 dynamic obj = json.decode(jsonString);
返回一个 dynamic
的原因在于,Dart 不知道传进去的 JSON 是什么。如果是一个 JSON 对象,返回值将是一个 Map<String, dynamic>
;如果是 JSON 数组,则会返回 List<dynamic>
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 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 = <Point>[]; for (var map in decoded) { points2.add(Point.fromJson(map)); } print ('points2 = $points2' ); }
运行结果如下:
1 2 3 4 5 6 7 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<String, dynamic> point2 = Point{x=2, y=12, desc=Some point} decoded.runtimeType = List<dynamic> points2 = [Point{x=2, y=12, desc=Some point}, Point{x=2, y=12, desc=Some point}]
需要说明的是,我们把 Map<String, dynamic>
转化为对象时使用时定义了一个构造函数,但这个是任意的,使用静态方法、Dart 工厂方法等都是可行的。之所以限定 toJson
方法的原型,是因为 json.encode
只支持 Map、List、String、int 等内置类型。当它遇到不认识的类型时,如果没有给它设置参数 toEncodable,就会调用对象的 toJson
方法(所以方法的原型不能改变)。
HTTP 为了向服务器发送 HTTP 请求,我们可以使用 io 包里面的 HttpClient
。但它实在不是那么好用,于是就有人弄出了一个 http 包。为了使用 http 包,需要修改 pubspec.yaml:
1 2 3 dependencies: http: ^0.11.3+17
http 包的使用非常直接,为了发出一个 GET,可以使用 http.get
方法;对应的,还有 post
、put
等。
1 2 3 4 5 6 7 8 9 10 11 12 13 import 'package:http/http.dart' as http;Future<String > 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:
1 2 dependencies: sqflite: any
sqflite 的 API 跟 Android 的那些非常像,下面我们直接用一个例子来演示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 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 <String , dynamic > map) : id = map[columnId], title = map[columnTitle], content = map[columnContent]; Map <String , dynamic > toMap() => { columnTitle: title, columnContent: content, }; @override String toString() { return 'Todo{id=$id, title=$title, content=$content}' ; } } void foo() async { const table = 'Todo' ; var path = await getDatabasesPath() + '/demo.db' ; 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' ) ''' ; 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 <Map > list = await database.query(table); 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 { await txn.update(table, todo1.toMap(), 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(); }
运行结果如下:
1 2 3 4 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 上找到上篇文章的代码,我们将在它的基础上进行开发。
1 2 3 git clone https://github.com/Jekton/flutter_demo.git cd flutter_demo git checkout ux-basic
服务端架构 首先我们来看看服务端的架构(说是架构,但其实非常的简单,或者说很简陋):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 import 'dart:async' ;import 'dart:io' ;class HttpEchoServer { final int port; HttpServer httpServer; Map <String , void Function (HttpRequest)> routes; HttpEchoServer(this .port) { _initRoutes(); } void _initRoutes() { routes = { '/history' : _history, '/echo' : _echo, }; } Future start() async { httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, port); return httpServer.listen((request) { final path = request.uri.path; final handler = routes[path]; if (handler != null ) { handler(request); } else { 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 做一些小修改:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class Message { final String msg; final int timestamp; Message(this .msg, this .timestamp); Message.create(String msg) : msg = msg, timestamp = DateTime .now().millisecondsSinceEpoch; Map <String , dynamic > toJson() => { "msg" : "$msg" , "timestamp" : timestamp }; @override String toString() { return 'Message{msg: $msg, timestamp: $timestamp}' ; } }
这里我们加入一个 toJson 方法。下面是服务端的 _echo 方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 class HttpEchoServer { static const GET = 'GET' ; static const POST = 'POST' ; const List <Message> messages = []; _unsupportedMethod(HttpRequest request) { request.response.statusCode = HttpStatus.methodNotAllowed; request.response.close(); } void _echo(HttpRequest request) async { if (request.method != POST) { _unsupportedMethod(request); return ; } String body = await request.transform(utf8.decoder).join(); if (body != null ) { var message = Message.create(body); messages.add(message); request.response.statusCode = HttpStatus.ok; 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 里:
1 2 3 4 5 dependencies: http: ^0.11.3+17
客户端实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 import 'package:http/http.dart' as http;class HttpEchoClient { final int port; final String host; HttpEchoClient(this .port): host = 'http://localhost:$port' ; Future<Message> send(String msg) async { final response = await http.post(host + '/echo' , body: msg); if (response.statusCode == 200 ) { Map <String , dynamic > msgJson = json.decode(response.body); var message = Message.fromJson(msgJson); return message; } else { return null ; } } } class Message { final String msg; final int timestamp; Message.fromJson(Map <String , dynamic > json) : msg = json['msg' ], timestamp = json['timestamp' ]; }
现在,让我们把他们和上一节的 UI 结合到一起。首先启动服务器,然后创建客户端:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 HttpEchoServer _server; HttpEchoClient _client; class _MessageListState extends State <MessageList > { final List <Message> messages = []; @override void initState() { super .initState(); const port = 6060 ; _server = HttpEchoServer(port); _server.start().then((_) { _client = HttpEchoClient(port); }); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 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 ; var msg = await _client.send(result); if (msg != null ) { messageListKey.currentState.addMessage(msg); } else { debugPrint('fail to send $result' ); } }, ) ); } }
大功告成,在做了这么多工作以后,我们的应用现在是真正的 echo 客户端了,虽然看起来跟之前没什么两样。接下来,我们就做一些跟之前不一样的——把历史记录保存下来。
历史记录存储、恢复 获取应用的存储路径 为了获得应用的文件存储路径,我们引入多一个库:
1 2 3 4 5 dependencies: path_provider: ^0.4.1
通过它我们可以拿到应用的 file、cache 和 external storage 的路径:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import 'package:path_provider/path_provider.dart' as path_provider;class HttpEchoServer { String historyFilepath; Future start() async { historyFilepath = await _historyPath(); } Future<String > _historyPath() async { final directory = await path_provider.getApplicationDocumentsDirectory(); return directory.path + '/messages.json' ; } }
保存历史记录 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 class HttpEchoServer { void _echo(HttpRequest request) async { _storeMessages(); } Future<bool > _storeMessages() async { try { final data = json.encode(messages); final file = File(historyFilepath); final exists = await file.exists(); if (!exists) { await file.create(); } file.writeAsString(data); return true ; } catch (e) { print ('_storeMessages: $e' ); return false ; } } }
加载历史记录 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 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 函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 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 全都返回给客户端。
接下来是客户端部分:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 class HttpEchoClient { Future<List <Message>> getHistory() async { try { final response = await http.get (host + '/history' ); if (response.statusCode == 200 ) { return _decodeHistory(response.body); } } catch (e) { print ('getHistory: $e' ); } return null ; } List <Message> _decodeHistory(String response) { var messages = json.decode(response); var list = <Message>[]; for (var msgJson in messages) { list.add(Message.fromJson(msgJson)); } return list; } } class _MessageListState extends State <MessageList > { final List <Message> 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 那么好用就是啦)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 class _MessageListState extends State <MessageList > 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(); } } }
现在,我们的应用是这个样子的:
所有的代码可以在 GitHub 上找到:
1 2 3 git clone https://github.com/Jekton/flutter_demo.git cd flutter_demo git checkout io-basic
使用 SQLite 数据库 前面的实现中我们把 echo 服务器的数据存放在了文件里。这一节我们改一改,把数据存到 SQLite 中。
别忘了添加依赖:
1 2 dependencies: sqflite: any
初始化数据库 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 import 'package:sqflite/sqflite.dart' ;class HttpEchoServer { static const tableName = 'History' ; 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
方法中,这里我们修改原有的实现,让它从数据库加载数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class HttpEchoServer { Future _loadMessages() async { var list = await database.query( tableName, columns: [columnMsg, columnTimestamp], orderBy: columnId, ); for (var item in list) { var message = Message.fromJson(item); messages.add(message); } } }
实际上改为使用数据库来存储后,我们并不需要把所有的消息都存放在内存中(也就是这里的 _loadMessage
是不必要的)。客户请求历史记录时,我们再按需从数据库读取数据即可。为了避免修改到程序的逻辑,这里还是继续保持一份数据在内存中。有兴趣的读者可以对程序作出相应的修改。
保存记录 记录的保存很简单,一行代码就可以搞定了:
1 2 3 4 5 6 7 8 9 void _echo(HttpRequest request) async { _storeMessage(message); } void _storeMessage(Message msg) { database.insert(tableName, msg.toJson()); }
使用 JSON 的版本,我们每次都需要把所有的数据都保存一遍。对数据库来说,只要把收到的这一条信息存进去即可。读者也应该能够感受到,就我们的需求来说,使用 SQLite 的版本实现起来更简单,也更高效。
关闭数据库 close
方法也要做相应的修改:
1 2 3 4 5 6 7 void close() async { var db = database; database = null ; db?.close(); }
这部分代码可以查看 tag echo-db:
1 2 3 git clone https://github.com/Jekton/flutter_demo.git cd flutter_demo git checkout echo-db