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 环境配置

  1. 安装 Flutter SDK(建议版本 3.7+)
  2. 安装 DevEco Studio 并配置鸿蒙开发环境
  3. 添加 Flutter OHOS 支持:flutter config --enable-ohos
  4. 使用鸿蒙化版本的依赖包

8.2 运行步骤

# 1. 获取依赖
flutter pub get

# 2. 编译项目
flutter build ohos --debug

# 3. 运行到设备
flutter run -d <设备ID>

九、运行截图

以下是应用在鸿蒙设备上的运行效果截图:

图1:应用首页

> [请在此处插入应用首页截图]

图2:食谱详情页

在这里插入图片描述

图3:底部功能切换

在这里插入图片描述

十、总结

本文通过一个完整的食谱美食应用实例,详细讲解了 Flutter for OpenHarmony 开发的核心技术要点:

  1. 数据模型设计:使用 Dart 类定义数据结构,支持 JSON 序列化
  2. 网络请求:使用 dio 库实现 API 调用,保持与原生 Flutter 一致的 API
  3. 本地存储:通过 shared_preferences 实现数据的持久化
  4. 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

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐