Flutter學習指南:檔案、儲存和網路
本篇文章我們先學習 Flutter IO 相關的基礎知識,然後在 ofollow,noindex">Flutter學習指南:互動、手勢和動畫 的基礎上,繼續開發一個 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<int> 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<int> 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<List<int>> 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>,而一個 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<String, dynamic> 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<String, dynamic> 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<dynamic>:
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'); } 複製程式碼
執行結果如下:
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 轉化為物件時使用時定義了一個建構函式,但這個是任意的,使用靜態方法、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<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:
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<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'; // 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<Map> 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<String, void Function(HttpRequest)> 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<String, dynamic> 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<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; } // 獲取從客戶端 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<Message> 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<String, dynamic> msgJson = json.decode(response.body); // Dart 並不知道我們的 Message 長什麼樣,我們需要自己通過 // Map<String, dynamic> 來構造物件 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 結合到一起。首先啟動伺服器,然後建立客戶端:
HttpEchoServer _server; HttpEchoClient _client; class _MessageListState extends State<MessageList> { final List<Message> 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<String> _historyPath() async { // 獲取應用私有的檔案目錄 final directory = await path_provider.getApplicationDocumentsDirectory(); return directory.path + '/messages.json'; } } 複製程式碼
儲存歷史記錄
class HttpEchoServer { void _echo(HttpRequest request) async { // ... // 原諒我,為了簡單,我們就多存幾次吧 _storeMessages(); } Future<bool> _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<List<Message>> 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<Message> _decodeHistory(String response) { // JSON 陣列 decode 出來是一個 <Map<String, dynamic>>[] 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 那麼好用就是啦)。
// 為了使用 WidgetsBinding,我們繼承 WidgetsBindingObserver 然後覆蓋相應的方法 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 上找到:
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 複製程式碼
程式設計·思維·職場