# 不專業教學系列 - Flutter To-Do List 第三集 連線到後端! >tag: `Flutter`、`Dart` 在經歷過前面幾篇文章的內容後,相信你一定對Flutter有更多的了解,對於如何從List中建構畫面,專案管理與方法調用等都有所進步了。今天的內容難度會有所提升,專案得架構也開始更複雜了,但我相信你沒問題的! ### 今天的重點: * 能夠發起Http請求 * 從後端取回所有待辦事項 * 將新增的事項送到後端寫入資料庫 > 後端部分請從我的[Github](https://github.com/allian000/todo_backend.git)下載,並切換分支到`section_4` 前幾篇的內容都是在App執行期間產生的資料,當App關閉後資料就會消失,再次打開App後所有的資料都沒了,那這App不是廢了嗎XD 為了使資料不會消失,那就需要把資料寫進資料庫了,你可能會好奇,要將資料寫進資料庫不就讓App直接連線操作資料庫就好了嗎? 為啥今天的重點是後端呢? ### 在真實情況下,App是不太可能直接操作資料庫的,需要透過後端才行,有以下幾個原因: * 安全隱患: 在操作資料庫時會需要用到帳號密碼做登入,如果直接從App連線至資料庫,很有可能會使帳號密碼外流。 * 權限驗證: 對於訪問後端的使用者,後端可以確保有授權的使用者操作資料庫,並且限制使用者的操作權限(你也不會希望甚麼人都能對你的資料庫嗯哼誒嘿吧XD)。 * 有了後端,可以對資料進行驗證,以免出現資料格式不對導致寫入失敗資料庫或App爆炸等等情況。 * 後端通常會處裡一些商業邏輯,讓你的資料可以進行你需要的操作。 以上就是為甚麼需要用到後端操作資料庫的幾項原因,但絕對不是全部,這邊只是簡單說明。 ## 嘗試發起第一個HTTP請求 在開始寫程式之前,要先簡單說一下HTTP請求,不然等等講不下去XD HTTP 請求是用於在客戶端(如你的 Flutter App)和伺服器之間進行通訊的基礎。這些請求可以用來獲取、創建、更新或刪除資料。常見的 HTTP 請求方法包括: * GET:用於從伺服器獲取資料。 * POST:用於向伺服器發送資料並創建新資源。 * PUT:用於更新伺服器上的現有資源。 * DELETE:用於刪除伺服器上的資源。 ### HTTP 請求的流程: ```sequence Client->Server: 客戶端發起請求 Note right of Server: 伺服器接收到請求後處理 Server->Client: 返回狀態碼和回應內容 ``` 1. 客戶端發起請求: 當用戶在應用中執行某個操作(例如新增待辦事項)時,客戶端會發起一個 HTTP 請求。 1. 請求頭: 請求頭包含了請求的基本資訊,例如請求方法(GET、POST 等)、目標 URL、標頭資訊(如內容類型和授權資訊)等。 1. 請求體: 對於需要發送資料的請求(如 POST 和 PUT),請求體會包含具體的資料內容,通常以 JSON 格式發送。 1. 伺服器處理請求: 伺服器接收到請求後,會根據請求的方法和 URL 來執行相應的操作,例如存取資料庫、執行業務邏輯等。 1. 伺服器回應: 處理完成後,伺服器會返回一個回應給客戶端。回應包含狀態碼(例如 200 表示成功、404 表示找不到資源、500 表示伺服器錯誤等)和可選的回應體(包含資料或錯誤訊息)。 1. 客戶端處理回應: 客戶端接收到回應後,會根據狀態碼和回應內容來執行後續的操作,例如顯示資料或提示錯誤。 ### 為甚麼要使用HTTP請求呢? * 安全性:透過 HTTP 請求,客戶端不需要直接連接資料庫,減少了暴露敏感資料的風險。 * 分離關注點:後端伺服器負責處理資料和業務邏輯,客戶端負責用戶介面和互動,這樣可以使程式碼更加模組化和易於維護。 * 資料驗證:後端可以對客戶端發送的資料進行驗證,確保資料的完整性和正確性。 * 一致性:後端可以統一處理所有的資料操作,確保資料的一致性和可靠性。 * 擴展性:後端伺服器可以根據需要進行擴展和優化,而不需要修改客戶端應用。 ___ ## 前置作業喔~~ 好了,我相信你是看完了而不是跳過,接下來可以開始寫程式了XD 首先讓我們建立幾個資料夾與檔案並先說明它們的功能: * 新增 `services/todo_service.dart`: 負責處裡連線API的責任,以及商業邏輯。 * 新增 `constants/urls.dart`: 先將連線字串在這裏面定義好,就不用在程式裡面重複寫IP,以免一個錯誤直接回到解放前。 這應該是你目前的專案結構: ``` . └── lib/ ├── constants/ │ └── urls.dart <--剛剛新增的 ├── providers/ │ └── todo_provider.dart ├── screens/ │ ├── display_todo_screen.dart │ └── display_completed_screen.dart ├── service/ │ └── todo_service.dart <--剛剛新增的 └── main.dart ``` 這邊分享我的`urls.dart` ```dart // urls.dart class Urls { static const String baseUrl = 'https://127.0.0.1:8000'; static const String getTodos = '$baseUrl/todo/'; static const String addTodo = '$baseUrl/todo/'; } ``` ## 更改資料模型 在開始寫Service之前,需要先更改一下`todo_model`的內容,之前在程式中,我們是直接操作物件,並且只有新增與移除,但現在要將資料寫入資料庫,從資料庫取得資料,以及我忘了加上的更新與刪除功能(~~對我真的忘了寫更新還有刪除......~~),所以要對`todo_model`做修改。 要修改的內容是新增`id`欄位,簡單來說,如果要更新或是刪除資料庫的資料就會需要一個主鍵,不然後端跟資料庫哪知道你要更新刪除哪一筆資料XD 那為甚麼之前刪除資料時不需要`id`呢? 因為我們是直接在畫面上操作物件,這物件本身就有在`todoList`中的索引值,所以不需要`id`。 ```dart // todo_model.dart class TodoData { int id; String title; String content; bool isChecked; TodoData({ required this.id, required this.title, required this.content, required this.isChecked, }); // Simple Factory factory TodoData.fromJson(Map<String, dynamic> json) { return TodoData( id: json['id'], title: json['title'], content: json['content'], isChecked: json['is_checked']); } } ``` 在上面的程式碼中有個重點,就是使用到了簡單工廠模式,簡單工廠模式(Simple Factory Pattern)是一種設計模式,用於創建物件的實例,而不需要公開具體實現邏輯給用戶。這種模式在 Flutter 開發中常用來創建不同的小部件(Widget)或服務(Service)。 為什麼這裡要使用它呢? 使用簡單工廠模式是因為不確定從後端拿回來的資料格式是否完全跟model定義的一樣,所以要從json中重新建構資料,以免發生資料格式不同的慘劇XD ## 建立To-Do Service 現在真的可以開始寫Service了...嗎? 等等,還需要先新增套件才行XD 在你的命令提示行輸入: ```shell flutter pub add http ``` 看到成功訊息就可以了。 現在真的可以開始寫Service了XD 記得import`http`還有`urls` ```dart // todo_service.dart class TodoService { Future<List<TodoData>> getTodos() async { final response = await http.get(Uri.parse(Urls.getTodos)).timeout(const Duration(seconds: 5)); if (response.statusCode == 200){ List<dynamic> todoList = json.decode(utf8.decode(response.bodyBytes)); return todoList.map((todo) => TodoData.fromJson(todo)).toList(); } else { throw Exception('資料讀取失敗!'); } } Future<TodoData> addTodo(TodoData todo) async { final response = await http.post( Uri.parse(Urls.addTodo), headers: <String, String>{ 'Content-Type': 'application/json; charset=UTF-8', }, body: jsonEncode({ "title": todo.title, "content": todo.content, "is_checked": todo.isChecked }), ).timeout(const Duration(seconds: 5)); if(response.statusCode == 200) { return TodoData.fromJson(json.decode(utf8.decode(response.bodyBytes))); } else { throw Exception('Failed to add todo'); } } } ``` 說明一下在Service中到底發生了甚麼事: `getTodos()`: * 目的: 從後端伺服器獲取待辦事項列表。 * 步驟: 1. 使用 http.get 發送 GET 請求到指定的 URL (Urls.getTodos)。 2. 設定超時時間為 5 秒,如果請求在這段時間內未完成,則會引發超時異常。 3. 如果伺服器返回的狀態碼為 200(請求成功),則將返回的 JSON 資料轉換為`List<TodoData>`。 4. 如果狀態碼不是 200,則拋出異常,提示資料讀取失敗。 `addTodo()`: * 目的: 向後端伺服器新增一個待辦事項。 * 步驟: 1. 使用 http.post 發送 POST 請求到指定的 URL (Urls.addTodo)。 2. 設定請求頭,指定內容類型為 application/json。 3. 將待辦事項的資料(標題、內容、是否完成)編碼為 JSON 格式,並作為請求的主體。 4. 設定超時時間為 5 秒,如果請求在這段時間內未完成,則會引發超時異常。 5. 如果伺服器返回的狀態碼為 200(請求成功),則將返回的 JSON 資料轉換為 TodoData 物件並返回。 6. 如果狀態碼不是 200,則拋出異常,提示新增待辦事項失敗。 這邊說明得比較細節,因為是重要內容XD ## Provider 進化啦! 還記得前面【[不專業教學系列 - Flutter To-Do List 第二集 讓專案更完整](/3JuIZFVISYO8R1kfduBbIQ)】的內容嗎,所有的資料都是在provider中處理,所以不論是要取得還是新增資料都要從provider更新狀態,不要直接調用service,保持縝密性:)),讓我們新增一點內容,導入service,加入讀取所有事項的功能並將原本的`addTodo`稍作修改: ```dart // todo_provider.dart class TodoProvider extends ChangeNotifier { ... final TodoService _todoService; TodoProvider(this._todoService); // 從API取得todo清單 Future<void> getTodos() async { _todoList = await _todoService.getTodos(); debugPrint(_todoList.toString()); notifyListeners(); } // 將資料交給API void addTodo(TodoData todo) async { TodoData todoData = await _todoService.addTodo(todo); _todoList.add(todoData); } // 其餘的不變 ... } ``` 可以發現在`addTodo`中,新增到`_todoList`的是從後端返回的`todoData`而不是傳進來的`todo`,原因與上述提到的內容有關係: > 要修改的內容是新增`id`欄位,簡單來說,如果要更新或是刪除資料庫的資料就會需要一個主鍵...(太長了跳過) #### 這邊提一些開發時的重點: 在Restful API設計中,新增物件後會回傳一個新增的設計資料,這筆回傳的資料就會有來自資料庫的主鍵,就可以避免以下經典情況: > 新增`todo`後,畫面上的`todo`是App內產生的,同時post到backend,直到畫面重新整理(App重開 or 切換畫面後)才會去後端抓資料回來繪製在畫面上,但這有個隱患,如果在資料同步之前我就要對資料庫做操作,此時畫面上的`todo`物件是沒有主鍵的,這樣就會出問題QQ。 > > 那為甚麼不要每次新增後就去backend抓資料回來呢? 是因為這要多做一次請求,想像一下,Post完資料後再去Get與將Post回傳的資料直接複寫直接少了一次操作,也不怕畫面沒有更新,只要將回傳的資料直接塞回`todoList`就萬事大吉了XD。 在開發App時常常會遇到資料傳輸的要求,可以好好思考在流程上能怎麼喔做! ___ 接下來稍微更新main.dart,將`TodoProvider`提供給程式,並確保在App啟動時調用`TodoService`: ```dart // main.dart class MainApp extends StatelessWidget { const MainApp({super.key}); @override Widget build(BuildContext context) { return MultiProvider( providers: [ ChangeNotifierProvider( create: (context) => TodoProvider(TodoService()), ), ], child: const MaterialApp( home: NavigationScreen(), ), ); } } ``` 為了要使程式能在建構畫面前去後端取得所有待辦事項,我們需要對`_DisplayTodoListState`做出修改,新增`_loadTodos`,並在`initState()`時調用: ```dart // display_todo_screen.dart class _DisplayTodoListState extends State<DisplayTodoScreen> { ... @override void initState() { super.initState(); _loadTodos(); } void _loadTodos() { final provider = Provider.of<TodoProvider>(context, listen: false); provider.getTodos(); } // 其餘不變 ... } ``` ## 結語 到了這邊你應該就已經完成了操作資料庫的部分了,由於UI部分沒有更動所以就不上圖片了。 回顧一下今天到底做了甚麼: * 發起HTTP請求 * 學習到簡易工廠模式 * Provider的新操作 * HTTP Method知識 今天這篇文章內容還是足夠的,應該吧? 本篇內容大約寫了四個小時,因為我是小菜雞XD 怎麼說呢,程式寫起來還是很快的,但是要將其中的關鍵用文字表達還是不太順利,所以我前幾篇文章就類似於流水帳哈哈哈。 這次本文中提到了很多Flutter之外的知識,但這些知識都是在開發時一定會碰到的內容,並且想要達成本文中的內容就必須要先了解概念,所以我有加強說明,希望對各位讀者老爺與小夥伴們有些幫助OUOb。 在測試App後你會發現已完成的事項在切換後跑回代辦事項,然後已完成事項的內容也還是在(~~真是太神奇了傑克~~),這是因為資料庫資料沒有更新,並且邏輯上還需要修正,~~以及最重要的更新代辦事項內容~~ 這些內容就留到下一篇再說吧XD。 謝謝觀看!