#83 Flutter 05-Riverpod 狀態管理,Provider 與 FutureProvider 實作-台灣熱門小吃食譜 App
在 Riverpod 中,除了 StateProvider
,還有許多其他常用的提供者(Provider)類型,每個都有不同的用途和特性。前一篇我們使用了StateProvider
實作,這次我們要使用 Provider
和 FutureProvider
來抓取之前在 GitHub 上自定義的 JSON API,以建立台灣熱門小吃食譜 App。
前一篇及之前建立 GitHub JSON API 的文章:
APP操作GIF:
APP螢幕截圖:
Provider 簡介
Provider
Provider
用於提供不會改變的數據,例如配置信息、服務等。它在構建時提供一次值,並在後續重建中重用。示例:
final configProvider = Provider<Map<String, dynamic>>((ref) {
return {'apiBaseUrl': 'https://api.example.com'};
});
FutureProvider
FutureProvider
用於提供異步加載的數據,它會在異步操作完成後更新狀態。示例:
final snacksProvider = FutureProvider<List<Snack>>((ref) async {
final config = ref.watch(configProvider);
final response = await http.get(Uri.parse(config['apiBaseUrl']));
if (response.statusCode == 200) {
List<dynamic> data = json.decode(response.body);
return data.map((item) => Snack.fromJson(item)).toList();
} else {
throw Exception('Failed to load snacks');
}
});
之後在需要處理回傳內容的地方去監聽 FutureProvider
。
使用 when
處理不同狀態,when
是 AsyncValue
提供的一個方法,用於處理 FutureProvider
返回的數據。它能夠根據數據的不同狀態(加載中、成功或失敗)來顯示對應的 UI。
class SnackList extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncSnacks = ref.watch(snacksProvider);
return asyncSnacks.when(
data: (snacks) => ...
loading: () => ...,
error: (err, stack) => ...,
);
}
}
步驟 1:建立專案
建立一個新的 Flutter 專案並命名為 github_json_api
。
步驟 2:安裝 Riverpod
在 pubspec.yaml
文件中添加 flutter_riverpod
和 http
依賴:
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.5.1
http: ^1.2.1
然後在終端(terminal)中執行以下指令來安裝依賴:
//flutter pub cache clean //如果需要清除之前的安裝的
flutter pub get
步驟 3:定義 Providers
定義 Provider
我們使用 Provider
來定義 API 的 URL。這個 URL 指向一個包含小吃食譜的 JSON 文件。
final configProvider = Provider<Map<String, dynamic>>((ref) {
return {'apiBaseUrl': 'https://raw.githubusercontent.com/JasonHungApp/JSON_API/main/TaiwanSnackPreparation.json'};
});
定義 FutureProvider
使用 FutureProvider
來抓取 API 的內容,可以看到有 async / await 的搭配。
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
// 定義 configProvider,提供靜態的 API 配置
final configProvider = Provider<Map<String, dynamic>>((ref) {
return {'apiBaseUrl': 'https://raw.githubusercontent.com/JasonHungApp/JSON_API/main/TaiwanSnackPreparation.json'};
});
// 定義 snacksProvider,使用 configProvider 來獲取 API URL
final snacksProvider = FutureProvider<List<Snack>>((ref) async {
final config = ref.watch(configProvider);
final response = await http.get(Uri.parse(config['apiBaseUrl']));
if (response.statusCode == 200) {
List<dynamic> data = json.decode(response.body);
return data.map((item) => Snack.fromJson(item)).toList();
} else {
throw Exception('Failed to load snacks');
}
});
步驟 4:定義 JSON 的格式
先透過 API 網址,查看 API 回傳的JSON格式
[
{
"name": "小籠包",
"type": "點心",
"ingredients": [
{"name": "豬肉", "quantity": "200克"},
{"name": "湯汁", "quantity": "100毫升"},
{"name": "餃子皮", "quantity": "適量"}
],
"instructions": [
"將豬肉剁成細末。",
"將湯汁和剁碎的豬肉混合均勻。",
"將適量的餃子皮包入混合物,形成小籠包的形狀。",
"蒸煮約15分鐘,直至小籠包熟透即可。"
],
"details": {
"description": "以豬肉餡料和特製湯汁為特色的小吃,包裹在薄薄的餃子皮中。",
"origin": "上海",
"popular_places": ["臺北市", "新北市"]
}
},
]
依回傳的 JSON格式,設定對應的 Class。
// 定義 Snack 模型類
class Snack {
final String name;
final String type;
final List<Ingredient> ingredients;
final List<String> instructions;
final SnackDetails details;
Snack({
required this.name,
required this.type,
required this.ingredients,
required this.instructions,
required this.details,
});
factory Snack.fromJson(Map<String, dynamic> json) {
return Snack(
name: json['name'],
type: json['type'],
ingredients: (json['ingredients'] as List)
.map((item) => Ingredient.fromJson(item))
.toList(),
instructions: List<String>.from(json['instructions']),
details: SnackDetails.fromJson(json['details']),
);
}
}
// 定義 Ingredient 模型類
class Ingredient {
final String name;
final String quantity;
Ingredient({required this.name, required this.quantity});
factory Ingredient.fromJson(Map<String, dynamic> json) {
return Ingredient(
name: json['name'],
quantity: json['quantity'],
);
}
}
// 定義 SnackDetails 模型類
class SnackDetails {
final String description;
final String origin;
final List<String> popularPlaces;
SnackDetails({
required this.description,
required this.origin,
required this.popularPlaces,
});
factory SnackDetails.fromJson(Map<String, dynamic> json) {
return SnackDetails(
description: json['description'],
origin: json['origin'],
popularPlaces: List<String>.from(json['popular_places']),
);
}
}
創建新的 Dart 文件:
在 lib
目錄下創建一個新的檔案來放這些 class,比如 models/snack.dart
。
在需要引用的檔案加上 import
import 'package:github_json_api/models/snack.dart';
在主應用中使用 Providers
在主應用 MyApp 中,使用 ProviderScope
包裹根部件:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'package:github_json_api/models/snack.dart';
void main() {
runApp(ProviderScope(child: MyApp()));
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('台灣熱門小吃食譜', style: TextStyle(fontSize: 26))),
body: SnackList(),
),
);
}
}
使用 ConsumerWidget 來讀取 Providers
使用 ConsumerWidget
來讀取 snacksProvider
的資料並顯示:
class SnackList extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncSnacks = ref.watch(snacksProvider);
return asyncSnacks.when(
data: (snacks) => ListView.builder(
itemCount: snacks.length,
itemBuilder: (context, index) {
final snack = snacks[index];
return ListTile(
title: Text(snack.name),
subtitle: Text(snack.details.description),
onTap: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(snack.name),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Type: ${snack.type}'),
Text('Ingredients:'),
...snack.ingredients
.map((ing) => Text('${ing.name}: ${ing.quantity}'))
.toList(),
Text('Instructions:'),
...snack.instructions.map((instr) => Text(instr)).toList(),
Text('Origin: ${snack.details.origin}'),
Text('Popular Places:'),
...snack.details.popularPlaces.map((place) => Text(place)).toList(),
],
),
),
);
},
);
},
),
loading: () => Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
);
}
}
備註
ProviderScope
:提供所有 Providers 的範圍。snacksProvider
:使用FutureProvider
獲取 JSON 資料。Snack
、Ingredient
、SnackDetails
:定義 JSON 結構的模型類。ConsumerWidget
:用來讀取和顯示 Providers 的資料。asyncSnacks.when
:處理FutureProvider
返回的異步資料,提供data
、loading
和error
三種狀態。
切分 showDialog
部分
onTap: () 裡,是顯示點擊列表後要出現的畫面,直接寫在裡面的話,看起來階層有點多,一堆括號,我們把它放到外面去。
定義一個函數來顯示 Snack 詳情的對話框,順便改一下排版,原本只是簡單的置中排列,改為靠左,子標題跟內容左側有4個空白的距離,改完後程式碼看起來又更複雜了。
void showSnackDetailsDialog(BuildContext context, Snack snack) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(snack.name),
content: SingleChildScrollView( // 使用 SingleChildScrollView 以防止內容過長溢出
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('類型:', style: TextStyle(fontWeight: FontWeight.bold)),
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Text(snack.type),
),
const SizedBox(height: 10),
const Text('食材:', style: TextStyle(fontWeight: FontWeight.bold)),
...snack.ingredients.map((ing) => Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Text('${ing.name}: ${ing.quantity}'),
)),
const SizedBox(height: 10),
const Text('作法:', style: TextStyle(fontWeight: FontWeight.bold)),
...snack.instructions.map((instr) => Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Text(instr),
)).toList(),
const SizedBox(height: 10),
const Text('發源地:', style: TextStyle(fontWeight: FontWeight.bold)),
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Text(snack.details.origin),
),
const SizedBox(height: 10),
const Text('熱門地點:', style: TextStyle(fontWeight: FontWeight.bold)),
...snack.details.popularPlaces.map((place) => Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Text(place),
)).toList(),
],
),
),
),
);
}
預期看到的樣子
主頁面中的修改
在 SnackList
中修改 onTap
,以調用新的函數,把一堆括號放到外面去,看起來有沒有精簡很多。
class SnackList extends ConsumerWidget {
const SnackList({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncSnacks = ref.watch(snacksProvider);
return asyncSnacks.when(
data: (snacks) => ListView.builder(
itemCount: snacks.length,
itemBuilder: (context, index) {
final snack = snacks[index];
return ListTile(
title: Text(snack.name),
subtitle: Text(snack.details.description),
onTap: () => showSnackDetailsDialog(context, snack), // 調用新的函數
);
},
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
);
}
}
這樣就完成了一個簡單的台灣熱門小吃食譜 App,通過使用 Riverpod
管理狀態,並從 GitHub 上抓取 JSON 資料來展示小吃的詳細信息。
參考:
補充:
ListView
使用 ListView 來排版,
主要參數
- * itemCount:
itemCount
指定了列表項的數量,這裡我們設置為snacks.length
,即小吃列表的長度。 - itemBuilder:
itemBuilder
是一個回調函數,用於創建每個列表項。它有兩個參數:
context
:構建上下文。index
:當前項目的索引。
在 itemBuilder
中,我們獲取當前索引的 snack
,並返回一個 ListTile
小部件,ListTile
是一個方便的預構建小部件,用於創建標準列表項。
ListTile
- title:顯示小吃的名稱,並設置字體大小和顏色。
- subtitle:顯示小吃的描述,並設置顏色。
- onTap:當用戶點擊列表項時,調用
showSnackDetailsDialog
函數來顯示小吃的詳細信息。
查看一下畫面
背景圖
好像素了點,再練習加個背景圖
在 Flutter 中,你可以使用 BoxDecoration
和 DecorationImage
來為 SnackList
添加背景圖。
創建 assets
文件夾:
- 在 Android Studio 中,右鍵單擊你的專案根目錄,選擇
New
>Directory
。命名這個新文件夾為assets
。
添加圖片到 assets
文件夾:
- 將你想要作為背景的圖片文件(例如
background.png
)拷貝到assets
文件夾中。
在pubspec.yaml
配置背景圖資源
首先,確保在 pubspec.yaml
中添加背景圖片資源:
flutter:
assets:
- assets/background.png
確保你的圖片 background.png
位於 assets
文件夾中。
修改 SnackList
來設置背景圖
接下來,修改 SnackList
小部件,將 Scaffold
的 body
包裝在 Container
中,並設置背景圖片。
原本
class SnackList extends ConsumerWidget {
const SnackList({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncSnacks = ref.watch(snacksProvider);
return asyncSnacks.when(
...
加上 Container
class SnackList extends ConsumerWidget {
const SnackList({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncSnacks = ref.watch(snacksProvider);
return Container(
decoration: const BoxDecoration(
image: DecorationImage(
image: AssetImage('assets/background.png'),
fit: BoxFit.cover,
),
),
child: asyncSnacks.when(
...
備忘
Android Studio 程式碼自動排版的功能