【maaath】Flutter for OpenHarmony食谱美食应用实战
Flutter for OpenHarmony 食谱美食应用实战
作者:maaath
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
一、前言
随着 Flutter for OpenHarmony(简称 Flutter OHOS)技术的日益成熟,开发者终于可以使用 Dart 语言开发原生运行在鸿蒙设备上的跨平台应用。本文将基于一个完整的食谱美食应用实例,深入讲解如何利用 Flutter for OpenHarmony 实现网络请求、本地存储、UI 组件开发等核心功能,帮助读者快速掌握这一新兴的跨平台开发技术。
二、项目概述
本项目是一款食谱美食应用,主要功能包括:
- 食谱浏览与搜索
- 分类查看
- 收藏管理
- 本地数据持久化
项目采用 Flutter OHOS 框架开发,使用 flutter_ohos 插件实现与鸿蒙原生能力的交互,核心依赖包括 dio 网络库和 shared_preferences 本地存储库(已适配鸿蒙版本)。
三、数据模型设计
良好的数据模型是应用开发的基础。在 Flutter OHOS 中,我们使用 Dart 类来定义数据结构:
/// 食谱模型
class Recipe {
final String id;
final String name;
final String image;
final String description;
final String author;
final double rating;
final String cookTime;
final String difficulty;
final int servings;
final int viewCount;
final int collectCount;
final List<Ingredient> ingredients;
final List<CookStep> steps;
bool isFavorite;
String? category;
Recipe({
required this.id,
required this.name,
required this.image,
required this.description,
required this.author,
required this.rating,
required this.cookTime,
required this.difficulty,
required this.servings,
required this.viewCount,
required this.collectCount,
required this.ingredients,
required this.steps,
this.isFavorite = false,
this.category,
});
factory Recipe.fromJson(Map<String, dynamic> json) {
return Recipe(
id: json['idMeal'] ?? '',
name: json['strMeal'] ?? '',
image: json['strMealThumb'] ?? '',
description: json['strCategory'] ?? '',
author: json['strArea'] ?? '未知',
rating: 4.0 + (DateTime.now().millisecond % 10) * 0.1,
cookTime: '30-60分钟',
difficulty: '中等',
servings: 2,
viewCount: Random().nextInt(2000),
collectCount: Random().nextInt(200),
ingredients: _parseIngredients(json),
steps: _parseSteps(json['strInstructions'] ?? ''),
category: json['strCategory'],
);
}
static List<Ingredient> _parseIngredients(Map<String, dynamic> json) {
final List<Ingredient> ingredients = [];
for (int i = 1; i <= 20; i++) {
final ingredient = json['strIngredient$i'];
final measure = json['strMeasure$i'];
if (ingredient != null && ingredient.toString().trim().isNotEmpty) {
ingredients.add(Ingredient(
name: ingredient.toString().trim(),
amount: (measure ?? '').toString().trim(),
));
}
}
return ingredients;
}
static List<CookStep> _parseSteps(String instructions) {
final steps = instructions
.split(RegExp(r'[\r\n]+'))
.where((s) => s.trim().isNotEmpty)
.toList();
return steps.asMap().entries.map((entry) {
return CookStep(
stepNumber: entry.key + 1,
description: entry.value.trim(),
);
}).toList();
}
}
/// 食材模型
class Ingredient {
final String name;
final String amount;
Ingredient({required this.name, required this.amount});
}
/// 烹饪步骤模型
class CookStep {
final int stepNumber;
final String description;
CookStep({required this.stepNumber, required this.description});
}
四、网络请求服务
网络请求是应用中不可或缺的部分。在 Flutter OHOS 中,推荐使用 dio 库进行 HTTP 请求,其鸿蒙化版本保持了与原生 Flutter 完全一致的 API 设计:
import 'package:dio/dio.dart';
import '../models/recipe_model.dart';
/// 网络请求服务
class HttpService {
static const String _baseUrl = 'https://www.themealdb.com/api/json/v1/1';
static final Dio _dio = Dio(BaseOptions(
baseUrl: _baseUrl,
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
));
/// 获取食谱列表
static Future<List<Recipe>> fetchRecipeList({
int page = 1,
int pageSize = 10,
}) async {
try {
final response = await _dio.get('/search.php', queryParameters: {'s': ''});
if (response.statusCode == 200) {
final data = response.data;
final meals = data['meals'] as List? ?? [];
final start = (page - 1) * pageSize;
final end = start + pageSize;
return meals
.skip(start)
.take(end)
.map((meal) => Recipe.fromJson(meal))
.toList();
}
return _getMockRecipes();
} catch (e) {
debugPrint('请求食谱列表失败: $e');
return _getMockRecipes();
}
}
/// 获取分类食谱
static Future<List<Recipe>> fetchRecipesByCategory(String category) async {
try {
final response = await _dio.get(
'/filter.php',
queryParameters: {'c': category},
);
if (response.statusCode == 200) {
final data = response.data;
final meals = data['meals'] as List? ?? [];
return meals.take(10).map((meal) {
return Recipe(
id: meal['idMeal'] ?? '',
name: meal['strMeal'] ?? '',
image: meal['strMealThumb'] ?? '',
description: '$category 美食',
author: '官方',
rating: 4.5,
cookTime: '30分钟',
difficulty: '简单',
servings: 2,
viewCount: Random().nextInt(1000),
collectCount: Random().nextInt(100),
ingredients: [],
steps: [],
category: category,
);
}).toList();
}
return [];
} catch (e) {
debugPrint('请求分类食谱失败: $e');
return [];
}
}
/// Mock 数据
static List<Recipe> _getMockRecipes() {
final names = ['红烧肉', '宫保鸡丁', '麻婆豆腐', '鱼香肉丝', '糖醋排骨'];
final images = [
'https://cdn.pixabay.com/photo/2016/06/08/00/03/ramen-1442855_640.jpg',
'https://cdn.pixabay.com/photo/2016/10/25/13/29/egg-rolls-1768892_640.jpg',
];
return List.generate(names.length, (index) {
return Recipe(
id: 'mock_$index',
name: names[index],
image: images[index % images.length],
description: '美味可口的家常菜',
author: '大厨',
rating: 4.0 + Random().nextDouble(),
cookTime: '30分钟',
difficulty: '简单',
servings: 2,
viewCount: Random().nextInt(2000),
collectCount: Random().nextInt(200),
ingredients: [Ingredient(name: '主料', amount: '适量')],
steps: [CookStep(stepNumber: 1, description: '将食材准备好')],
);
});
}
}
五、本地存储服务
本地存储功能使用 shared_preferences 库实现数据的持久化,包括收藏管理和浏览历史记录:
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/recipe_model.dart';
/// 本地存储服务
class StorageService {
static const String _favoritesKey = 'favorites';
static const String _browseHistoryKey = 'browse_history';
static SharedPreferences? _prefs;
/// 初始化
static Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
}
/// 保存收藏
static Future<bool> saveFavorite(Recipe recipe) async {
try {
final favorites = await getFavorites();
final exists = favorites.any((r) => r.id == recipe.id);
if (!exists) {
favorites.add(recipe);
await _prefs?.setString(_favoritesKey, jsonEncode(
favorites.map((r) => _recipeToJson(r)).toList(),
));
}
return true;
} catch (e) {
debugPrint('保存收藏失败: $e');
return false;
}
}
/// 移除收藏
static Future<bool> removeFavorite(String recipeId) async {
try {
final favorites = await getFavorites();
favorites.removeWhere((r) => r.id == recipeId);
await _prefs?.setString(_favoritesKey, jsonEncode(
favorites.map((r) => _recipeToJson(r)).toList(),
));
return true;
} catch (e) {
debugPrint('移除收藏失败: $e');
return false;
}
}
/// 获取收藏列表
static Future<List<Recipe>> getFavorites() async {
try {
final data = _prefs?.getString(_favoritesKey) ?? '[]';
final List<dynamic> jsonList = jsonDecode(data);
return jsonList.map((json) => _recipeFromJson(json)).toList();
} catch (e) {
debugPrint('获取收藏失败: $e');
return [];
}
}
/// 检查是否已收藏
static Future<bool> isFavorite(String recipeId) async {
final favorites = await getFavorites();
return favorites.any((r) => r.id == recipeId);
}
/// 保存浏览历史
static Future<bool> saveBrowseHistory(Recipe recipe) async {
try {
final history = await getBrowseHistory();
history.removeWhere((r) => r.id == recipe.id);
history.insert(0, recipe);
if (history.length > 20) {
history.removeRange(20, history.length);
}
await _prefs?.setString(_browseHistoryKey, jsonEncode(
history.map((r) => _recipeToJson(r)).toList(),
));
return true;
} catch (e) {
debugPrint('保存浏览历史失败: $e');
return false;
}
}
/// 获取浏览历史
static Future<List<Recipe>> getBrowseHistory() async {
try {
final data = _prefs?.getString(_browseHistoryKey) ?? '[]';
final List<dynamic> jsonList = jsonDecode(data);
return jsonList.map((json) => _recipeFromJson(json)).toList();
} catch (e) {
debugPrint('获取浏览历史失败: $e');
return [];
}
}
/// 清空浏览历史
static Future<bool> clearBrowseHistory() async {
try {
await _prefs?.setString(_browseHistoryKey, '[]');
return true;
} catch (e) {
debugPrint('清空浏览历史失败: $e');
return false;
}
}
/// Recipe 转 JSON
static Map<String, dynamic> _recipeToJson(Recipe recipe) {
return {
'id': recipe.id,
'name': recipe.name,
'image': recipe.image,
'description': recipe.description,
'author': recipe.author,
'rating': recipe.rating,
'cookTime': recipe.cookTime,
'difficulty': recipe.difficulty,
'servings': recipe.servings,
'viewCount': recipe.viewCount,
'collectCount': recipe.collectCount,
'category': recipe.category,
};
}
/// JSON 转 Recipe
static Recipe _recipeFromJson(Map<String, dynamic> json) {
return Recipe(
id: json['id'] ?? '',
name: json['name'] ?? '',
image: json['image'] ?? '',
description: json['description'] ?? '',
author: json['author'] ?? '',
rating: (json['rating'] ?? 0).toDouble(),
cookTime: json['cookTime'] ?? '',
difficulty: json['difficulty'] ?? '',
servings: json['servings'] ?? 0,
viewCount: json['viewCount'] ?? 0,
collectCount: json['collectCount'] ?? 0,
ingredients: [],
steps: [],
category: json['category'],
);
}
}
六、UI 组件开发
Flutter OHOS 提供了丰富的 UI 组件支持,我们可以使用与原生 Flutter 完全一致的语法编写界面:
import 'package:flutter/material.dart';
import '../models/recipe_model.dart';
/// 食谱卡片组件
class RecipeCard extends StatelessWidget {
final Recipe recipe;
final VoidCallback onTap;
const RecipeCard({
super.key,
required this.recipe,
required this.onTap,
});
Widget build(BuildContext context) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 图片区域
Stack(
children: [
AspectRatio(
aspectRatio: 16 / 10,
child: Image.network(
recipe.image,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
color: Colors.grey[200],
child: const Icon(Icons.restaurant, size: 48),
);
},
),
),
if (recipe.isFavorite)
Positioned(
top: 8,
right: 8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0xFFFF6B35).withOpacity(0.9),
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'已收藏',
style: TextStyle(
color: Colors.white,
fontSize: 10,
),
),
),
),
],
),
// 信息区域
Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
recipe.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF333333),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
recipe.description,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Row(
children: [
const Icon(Icons.star, color: Color(0xFFFFB800), size: 16),
const SizedBox(width: 4),
Text(
recipe.rating.toStringAsFixed(1),
style: const TextStyle(
fontSize: 12,
color: Color(0xFF666666),
),
),
const Spacer(),
const Icon(Icons.favorite, color: Color(0xFFFF6B35), size: 16),
const SizedBox(width: 4),
Text(
'${recipe.collectCount}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
),
),
],
),
],
),
),
],
),
),
);
}
}
七、主页面实现
主页采用底部导航栏设计,实现推荐、分类、收藏、个人中心四个主要功能模块:
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/recipe_model.dart';
import '../services/http_service.dart';
import '../services/storage_service.dart';
import 'recipe_card.dart';
/// 主页面
class HomePage extends StatefulWidget {
const HomePage({super.key});
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
int _currentIndex = 0;
List<Recipe> _recipes = [];
bool _isLoading = true;
final List<String> _tabTitles = ['推荐', '分类', '收藏', '我的'];
void initState() {
super.initState();
_loadRecipes();
}
Future<void> _loadRecipes() async {
setState(() => _isLoading = true);
try {
await StorageService.init();
final recipes = await HttpService.fetchRecipeList();
setState(() {
_recipes = recipes;
_isLoading = false;
});
} catch (e) {
debugPrint('加载食谱失败: $e');
setState(() => _isLoading = false);
}
}
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: AppBar(
title: Text(
_tabTitles[_currentIndex],
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFF333333),
),
),
backgroundColor: Colors.white,
elevation: 0,
centerTitle: true,
),
body: _buildBody(),
bottomNavigationBar: _buildBottomNav(),
);
}
Widget _buildBody() {
switch (_currentIndex) {
case 0:
return _buildRecommendContent();
case 1:
return _buildCategoryContent();
case 2:
return _buildFavoriteContent();
case 3:
return _buildProfileContent();
default:
return const SizedBox.shrink();
}
}
Widget _buildRecommendContent() {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_recipes.isEmpty) {
return const Center(
child: Text('暂无食谱数据'),
);
}
return RefreshIndicator(
onRefresh: _loadRecipes,
child: GridView.builder(
padding: const EdgeInsets.all(12),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.75,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: _recipes.length,
itemBuilder: (context, index) {
final recipe = _recipes[index];
return RecipeCard(
recipe: recipe,
onTap: () => _navigateToDetail(recipe),
);
},
),
);
}
Widget _buildCategoryContent() {
final categories = ['家常菜', '川菜', '粤菜', '湘菜', '鲁菜', '浙菜', '苏菜', '闽菜', '徽菜'];
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
childAspectRatio: 1,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: categories.length,
itemBuilder: (context, index) {
return _buildCategoryItem(categories[index]);
},
);
}
Widget _buildCategoryItem(String category) {
return InkWell(
onTap: () => debugPrint('点击分类: $category'),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.restaurant_menu,
size: 36,
color: Color(0xFFFF6B35),
),
const SizedBox(height: 8),
Text(
category,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF333333),
),
),
],
),
),
);
}
Widget _buildFavoriteContent() {
return FutureBuilder<List<Recipe>>(
future: StorageService.getFavorites(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final favorites = snapshot.data ?? [];
if (favorites.isEmpty) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.favorite_border, size: 64, color: Color(0xFFCCCCCC)),
SizedBox(height: 16),
Text(
'暂无收藏',
style: TextStyle(fontSize: 16, color: Color(0xFF999999)),
),
],
),
);
}
return GridView.builder(
padding: const EdgeInsets.all(12),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.75,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: favorites.length,
itemBuilder: (context, index) {
return RecipeCard(
recipe: favorites[index],
onTap: () => _navigateToDetail(favorites[index]),
);
},
);
},
);
}
Widget _buildProfileContent() {
return ListView(
padding: const EdgeInsets.all(16),
children: [
_buildProfileItem(Icons.history, '浏览历史'),
_buildProfileItem(Icons.settings, '设置'),
_buildProfileItem(Icons.info_outline, '关于我们'),
],
);
}
Widget _buildProfileItem(IconData icon, String title) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
leading: Icon(icon, color: const Color(0xFFFF6B35)),
title: Text(title),
trailing: const Icon(Icons.chevron_right),
onTap: () {},
),
);
}
Widget _buildBottomNav() {
return Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
child: Row(
children: List.generate(_tabTitles.length, (index) {
return Expanded(
child: InkWell(
onTap: () => setState(() => _currentIndex = index),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getIcon(index),
size: 24,
color: _currentIndex == index
? const Color(0xFFFF6B35)
: const Color(0xFF999999),
),
const SizedBox(height: 4),
Text(
_tabTitles[index],
style: TextStyle(
fontSize: 12,
color: _currentIndex == index
? const Color(0xFFFF6B35)
: const Color(0xFF999999),
),
),
],
),
),
),
);
}),
),
),
);
}
IconData _getIcon(int index) {
switch (index) {
case 0:
return Icons.home;
case 1:
return Icons.category;
case 2:
return Icons.favorite;
case 3:
return Icons.person;
default:
return Icons.home;
}
}
void _navigateToDetail(Recipe recipe) {
debugPrint('跳转到食谱详情: ${recipe.name}');
}
}
八、在鸿蒙设备上运行
8.1 环境配置
- 安装 Flutter SDK(建议版本 3.7+)
- 安装 DevEco Studio 并配置鸿蒙开发环境
- 添加 Flutter OHOS 支持:
flutter config --enable-ohos - 使用鸿蒙化版本的依赖包
8.2 运行步骤
# 1. 获取依赖
flutter pub get
# 2. 编译项目
flutter build ohos --debug
# 3. 运行到设备
flutter run -d <设备ID>
九、运行截图
以下是应用在鸿蒙设备上的运行效果截图:
图1:应用首页
图2:食谱详情页
图3:底部功能切换
十、总结
本文通过一个完整的食谱美食应用实例,详细讲解了 Flutter for OpenHarmony 开发的核心技术要点:
- 数据模型设计:使用 Dart 类定义数据结构,支持 JSON 序列化
- 网络请求:使用
dio库实现 API 调用,保持与原生 Flutter 一致的 API - 本地存储:通过
shared_preferences实现数据的持久化 - UI 开发:利用 Flutter 丰富的组件库构建美观的界面
Flutter for OpenHarmony 为开发者提供了一个强大的跨平台开发方案,能够实现"一次开发,多端运行"的愿景。随着技术的不断成熟,相信会有越来越多的应用迁移到这一平台上。
十一、参考资料
- 代码仓库:https://atomgit.com/maaath/recipe_app_flutter_ohos
- Flutter OHOS 官方文档:https://gitee.com/openharmony-sig/flutter
- TheMealDB API:https://www.themealdb.com/api.php
更多推荐
![> [请在此处插入应用首页截图]](https://i-blog.csdnimg.cn/direct/9193465d908e4d188f0ae9df8b3c04e3.png)



所有评论(0)