【maaath】Flutter for OpenHarmony 实战:旅游攻略应用开发指南

Flutter for OpenHarmony 实战:旅游攻略应用开发指南

前言

欢迎加入开源鸿蒙跨平台社区: https://openharmonycrossplatform.csdn.net


在移动应用开发领域,Flutter 以其高效的跨平台能力和精美的 UI 表现赢得了广大开发者的青睐。而 Flutter for OpenHarmony 的出现,让 Flutter 应用能够运行在鸿蒙设备上,打破了平台壁垒。本文将以一个旅游攻略应用为例,详细介绍如何使用 Flutter 开发兼容 OpenHarmony 的应用,并提供完整的代码实现。

一、项目概述

本文要实现的旅游攻略应用具备以下核心功能:

  • 首页轮播图展示
  • 景点列表展示与详情页
  • 下拉刷新与上拉加载更多
  • 目的地城市分类浏览
  • 游记列表与详情

技术栈

  • 框架: Flutter 3.x
  • 语言: Dart
  • 状态管理: StatefulWidget
  • 网络图片: cached_network_image
  • 目标平台: OpenHarmony

二、项目结构设计

一个规范的项目结构对于代码维护至关重要。本文采用如下目录结构:

复制代码
lib/
├── main.dart                 # 应用入口
├── models/
│   └── travel_model.dart    # 数据模型
├── services/
│   └── travel_service.dart   # 数据服务层
└── pages/
    └── travel_main_page.dart # 主页面及子页面

三、数据模型定义

首先,我们定义应用所需的数据模型。Dart 的类设计清晰简洁,便于维护和扩展。

dart 复制代码
// lib/models/travel_model.dart

import 'package:flutter/material.dart';

/// 景点数据模型
class ScenicSpot {
  final String id;
  final String name;
  final String cityName;
  final String provinceName;
  final String coverUrl;
  final double rating;
  final int reviewCount;
  final double price;
  final List<String> tags;
  final String description;
  final String address;
  final String openTime;
  final String bestSeason;
  final String recommendedTime;
  bool isFavorite;
  bool isHot;
  final List<String> images;

  ScenicSpot({
    required this.id,
    required this.name,
    required this.cityName,
    required this.provinceName,
    required this.coverUrl,
    required this.rating,
    required this.reviewCount,
    required this.price,
    required this.tags,
    required this.description,
    required this.address,
    required this.openTime,
    required this.bestSeason,
    required this.recommendedTime,
    this.isFavorite = false,
    this.isHot = false,
    required this.images,
  });
}

/// 游记/攻略数据模型
class Guide {
  final String id;
  final String title;
  final String authorName;
  final String authorAvatar;
  final String coverUrl;
  final String summary;
  final int likeCount;
  final int collectCount;
  final int commentCount;
  final String publishTime;
  final String scenicSpotName;
  final String scenicSpotId;
  bool isFavorite;
  bool isLiked;
  final List<String> images;
  final int days;
  final String budget;

  Guide({
    required this.id,
    required this.title,
    required this.authorName,
    required this.authorAvatar,
    required this.coverUrl,
    required this.summary,
    required this.likeCount,
    required this.collectCount,
    required this.commentCount,
    required this.publishTime,
    required this.scenicSpotName,
    required this.scenicSpotId,
    this.isFavorite = false,
    this.isLiked = false,
    required this.images,
    required this.days,
    required this.budget,
  });
}

/// 目的地城市数据模型
class Destination {
  final String id;
  final String name;
  final String coverUrl;
  final String description;
  final int spotCount;
  final int guideCount;
  final double rating;
  final List<String> tags;
  final List<String> hotSpots;

  Destination({
    required this.id,
    required this.name,
    required this.coverUrl,
    required this.description,
    required this.spotCount,
    required this.guideCount,
    required this.rating,
    required this.tags,
    required this.hotSpots,
  });
}

/// 轮播图数据模型
class Banner {
  final String id;
  final String title;
  final String imageUrl;
  final String actionType;
  final String actionValue;

  Banner({
    required this.id,
    required this.title,
    required this.imageUrl,
    required this.actionType,
    required this.actionValue,
  });
}

/// 用户数据模型
class User {
  final String id;
  final String nickname;
  final String avatar;
  final String signature;
  final int favoriteCount;
  final int guideCount;
  final int followCount;
  final int fansCount;
  final int level;

  User({
    required this.id,
    required this.nickname,
    required this.avatar,
    required this.signature,
    required this.favoriteCount,
    required this.guideCount,
    required this.followCount,
    required this.fansCount,
    required this.level,
  });
}

四、服务层实现

服务层负责数据获取和处理,本文采用模拟数据的方式,便于读者快速运行和调试。在实际项目中,可以替换为真实的 API 调用。

dart 复制代码
// lib/services/travel_service.dart

import '../models/travel_model.dart';

/// 旅游攻略服务 - 模拟网络请求
class TravelService {
  /// 获取首页轮播图
  Future<List<Banner>> getBanners() async {
    await Future.delayed(const Duration(milliseconds: 500));
    return _mockBanners;
  }

  /// 获取推荐景点列表
  Future<List<ScenicSpot>> getRecommendSpots(int page) async {
    await Future.delayed(const Duration(milliseconds: 500));
    int pageSize = 10;
    int start = (page - 1) * pageSize;
    int end = start + pageSize;
    if (start >= _mockSpots.length) return [];
    return _mockSpots.sublist(
      start,
      end > _mockSpots.length ? _mockSpots.length : end,
    );
  }

  /// 获取目的地城市列表
  Future<List<Destination>> getDestinations() async {
    await Future.delayed(const Duration(milliseconds: 500));
    return _mockDestinations;
  }

  /// 获取游记列表
  Future<List<Guide>> getGuides(int page) async {
    await Future.delayed(const Duration(milliseconds: 500));
    int pageSize = 10;
    int start = (page - 1) * pageSize;
    int end = start + pageSize;
    if (start >= _mockGuides.length) return [];
    return _mockGuides.sublist(
      start,
      end > _mockGuides.length ? _mockGuides.length : end,
    );
  }

  /// 获取当前用户信息
  Future<User> getCurrentUser() async {
    await Future.delayed(const Duration(milliseconds: 300));
    return _mockUser;
  }

  /// 格式化评论数量
  String formatCount(int count) {
    if (count >= 10000) {
      return '${(count / 10000).toStringAsFixed(1)}w';
    } else if (count >= 1000) {
      return '${(count / 1000).toStringAsFixed(1)}k';
    }
    return count.toString();
  }

  // 模拟轮播图数据
  static final List<Banner> _mockBanners = [
    Banner(
      id: 'banner_1',
      title: '云南丽江古城',
      imageUrl: 'https://picsum.photos/seed/lijiang/750/400',
      actionType: 'spot',
      actionValue: 'spot_1',
    ),
    Banner(
      id: 'banner_2',
      title: '张家界天门山',
      imageUrl: 'https://picsum.photos/seed/zhangjiajie/750/400',
      actionType: 'spot',
      actionValue: 'spot_2',
    ),
    // ... 更多数据
  ];

  // 模拟景点数据
  static final List<ScenicSpot> _mockSpots = [
    ScenicSpot(
      id: 'spot_1',
      name: '丽江古城',
      cityName: '丽江',
      provinceName: '云南',
      coverUrl: 'https://picsum.photos/seed/lijiang古城/600/400',
      rating: 4.8,
      reviewCount: 256000,
      price: 0,
      tags: ['5A景区', '世界文化遗产', '古镇'],
      description: '丽江古城位于云南省丽江市古城区...',
      address: '云南省丽江市古城区',
      openTime: '全天开放',
      bestSeason: '春季秋季',
      recommendedTime: '2-3天',
      isHot: true,
      images: [
        'https://picsum.photos/seed/lijiang1/800/600',
        'https://picsum.photos/seed/lijiang2/800/600',
      ],
    ),
    // ... 更多景点数据
  ];

  // 模拟用户数据
  static final User _mockUser = User(
    id: 'user_1',
    nickname: '旅行爱好者',
    avatar: 'https://picsum.photos/seed/myavatar/200/200',
    signature: '走遍中国,发现美好',
    favoriteCount: 56,
    guideCount: 12,
    followCount: 128,
    fansCount: 568,
    level: 5,
  );
}

五、主页面实现

主页面采用底部导航栏设计,包含四个主要选项卡:推荐、目的地、游记和我的。下面是核心代码实现。

dart 复制代码
// lib/pages/travel_main_page.dart

import 'package:flutter/material.dart';
import '../models/travel_model.dart';
import '../services/travel_service.dart';

/// 主页面 - 底部选项卡
class TravelMainPage extends StatefulWidget {
  const TravelMainPage({super.key});

  @override
  State<TravelMainPage> createState() => _TravelMainPageState();
}

class _TravelMainPageState extends State<TravelMainPage> {
  int _currentIndex = 0;

  final List<Widget> _pages = const [
    RecommendPage(),
    DestinationPage(),
    GuidePage(),
    MyPage(),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: IndexedStack(
        index: _currentIndex,
        children: _pages,
      ),
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _currentIndex,
        onTap: (index) {
          setState(() {
            _currentIndex = index;
          });
        },
        type: BottomNavigationBarType.fixed,
        selectedItemColor: const Color(0xFFFF6B4A),
        unselectedItemColor: Colors.grey,
        items: const [
          BottomNavigationBarItem(
            icon: Icon(Icons.home_outlined),
            activeIcon: Icon(Icons.home),
            label: '推荐',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.location_on_outlined),
            activeIcon: Icon(Icons.location_on),
            label: '目的地',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.book_outlined),
            activeIcon: Icon(Icons.book),
            label: '游记',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.person_outline),
            activeIcon: Icon(Icons.person),
            label: '我的',
          ),
        ],
      ),
    );
  }
}

六、推荐页面实现

推荐页面是应用的核心页面,包含轮播图和景点列表。支持下拉刷新和上拉加载更多功能。

dart 复制代码
/// 推荐页面
class RecommendPage extends StatefulWidget {
  const RecommendPage({super.key});

  @override
  State<RecommendPage> createState() => _RecommendPageState();
}

class _RecommendPageState extends State<RecommendPage> {
  final TravelService _service = TravelService();
  List<Banner> _banners = [];
  List<ScenicSpot> _spots = [];
  bool _isLoading = true;
  bool _isLoadingMore = false;
  bool _hasMore = true;
  int _currentPage = 1;
  final ScrollController _scrollController = ScrollController();

  @override
  void initState() {
    super.initState();
    _loadData();
    _scrollController.addListener(_onScroll);
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  Future<void> _loadData() async {
    setState(() => _isLoading = true);
    final banners = await _service.getBanners();
    final spots = await _service.getRecommendSpots(1);
    setState(() {
      _banners = banners;
      _spots = spots;
      _currentPage = 1;
      _hasMore = spots.length >= 10;
      _isLoading = false;
    });
  }

  Future<void> _loadMore() async {
    if (_isLoadingMore || !_hasMore) return;
    setState(() => _isLoadingMore = true);
    _currentPage++;
    final moreSpots = await _service.getRecommendSpots(_currentPage);
    if (moreSpots.isNotEmpty) {
      setState(() {
        _spots.addAll(moreSpots);
        _hasMore = moreSpots.length >= 10;
        _isLoadingMore = false;
      });
    } else {
      setState(() {
        _hasMore = false;
        _isLoadingMore = false;
      });
    }
  }

  void _onScroll() {
    if (_scrollController.position.pixels >=
        _scrollController.position.maxScrollExtent - 200) {
      _loadMore();
    }
  }

  @override
  Widget build(BuildContext context) {
    if (_isLoading) {
      return const Center(
        child: CircularProgressIndicator(color: Color(0xFFFF6B4A)),
      );
    }

    return RefreshIndicator(
      onRefresh: _loadData,
      color: const Color(0xFFFF6B4A),
      child: CustomScrollView(
        controller: _scrollController,
        slivers: [
          // 轮播图
          SliverToBoxAdapter(
            child: _buildBannerSwiper(),
          ),
          // 热门景点标题
          SliverToBoxAdapter(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Row(
                children: [
                  const Text(
                    '热门景点',
                    style: TextStyle(
                      fontSize: 18,
                      fontWeight: FontWeight.bold,
                      color: Color(0xFF333333),
                    ),
                  ),
                  const SizedBox(width: 8),
                  Text(
                    '热门推荐,精彩不停',
                    style: TextStyle(
                      fontSize: 12,
                      color: Colors.grey[600],
                    ),
                  ),
                ],
              ),
            ),
          ),
          // 景点列表
          SliverList(
            delegate: SliverChildBuilderDelegate(
              (context, index) {
                if (index < _spots.length) {
                  return _buildSpotItem(_spots[index]);
                } else if (_isLoadingMore) {
                  return const Padding(
                    padding: EdgeInsets.all(16),
                    child: Center(
                      child: CircularProgressIndicator(color: Color(0xFFFF6B4A)),
                    ),
                  );
                } else if (!_hasMore) {
                  return const Padding(
                    padding: EdgeInsets.all(16),
                    child: Center(
                      child: Text(
                        '- 已加载全部 -',
                        style: TextStyle(color: Colors.grey),
                      ),
                    ),
                  );
                }
                return null;
              },
              childCount: _spots.length + 1,
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildBannerSwiper() {
    return Container(
      height: 200,
      padding: const EdgeInsets.all(16),
      child: PageView.builder(
        itemCount: _banners.length,
        itemBuilder: (context, index) {
          final banner = _banners[index];
          return Container(
            decoration: BoxDecoration(
              borderRadius: BorderRadius.circular(12),
              image: DecorationImage(
                image: NetworkImage(banner.imageUrl),
                fit: BoxFit.cover,
              ),
            ),
            child: Container(
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(12),
                gradient: LinearGradient(
                  begin: Alignment.topCenter,
                  end: Alignment.bottomCenter,
                  colors: [
                    Colors.transparent,
                    Colors.black.withOpacity(0.5),
                  ],
                ),
              ),
              alignment: Alignment.bottomLeft,
              padding: const EdgeInsets.all(12),
              child: Text(
                banner.title,
                style: const TextStyle(
                  color: Colors.white,
                  fontSize: 16,
                  fontWeight: FontWeight.bold,
                ),
              ),
            ),
          );
        },
      ),
    );
  }

  Widget _buildSpotItem(ScenicSpot spot) {
    return Container(
      margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
      child: Row(
        children: [
          ClipRRect(
            borderRadius: BorderRadius.circular(8),
            child: Image.network(
              spot.coverUrl,
              width: 120,
              height: 100,
              fit: BoxFit.cover,
            ),
          ),
          const SizedBox(width: 12),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Row(
                  children: [
                    Expanded(
                      child: Text(
                        spot.name,
                        style: const TextStyle(
                          fontSize: 16,
                          fontWeight: FontWeight.bold,
                          color: Color(0xFF333333),
                        ),
                        maxLines: 1,
                        overflow: TextOverflow.ellipsis,
                      ),
                    ),
                    Row(
                      children: [
                        const Text(
                          '★',
                          style: TextStyle(color: Color(0xFFFFB800), fontSize: 12),
                        ),
                        Text(
                          spot.rating.toString(),
                          style: const TextStyle(
                            color: Color(0xFFFFB800),
                            fontSize: 12,
                          ),
                        ),
                      ],
                    ),
                  ],
                ),
                const SizedBox(height: 6),
                Row(
                  children: [
                    const Icon(Icons.location_on, size: 12, color: Colors.grey),
                    const SizedBox(width: 4),
                    Text(
                      spot.cityName,
                      style: TextStyle(fontSize: 12, color: Colors.grey[600]),
                    ),
                  ],
                ),
                const SizedBox(height: 8),
                Wrap(
                  spacing: 6,
                  children: spot.tags.take(2).map((tag) {
                    return Container(
                      padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
                      decoration: BoxDecoration(
                        color: const Color(0xFFFF6B4A).withOpacity(0.1),
                        borderRadius: BorderRadius.circular(4),
                      ),
                      child: Text(
                        tag,
                        style: const TextStyle(
                          fontSize: 10,
                          color: Color(0xFFFF6B4A),
                        ),
                      ),
                    );
                  }).toList(),
                ),
                const SizedBox(height: 6),
                Row(
                  children: [
                    if (spot.price > 0) ...[
                      const Text('¥', style: TextStyle(color: Color(0xFFFF6B4A), fontSize: 12)),
                      Text(
                        spot.price.toString(),
                        style: const TextStyle(
                          color: Color(0xFFFF6B4A),
                          fontSize: 16,
                          fontWeight: FontWeight.bold,
                        ),
                      ),
                      const Text('起', style: TextStyle(color: Colors.grey, fontSize: 10)),
                    ] else
                      const Text(
                        '免费',
                        style: TextStyle(
                          color: Color(0xFF52C41A),
                          fontSize: 14,
                          fontWeight: FontWeight.bold,
                        ),
                      ),
                  ],
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

七、目的地上下文页面

目的地上下文页面展示了热门目的地城市列表,采用网格和列表两种布局方式,便于用户快速找到目标景点。

dart 复制代码
/// 目的地页面
class DestinationPage extends StatefulWidget {
  const DestinationPage({super.key});

  @override
  State<DestinationPage> createState() => _DestinationPageState();
}

class _DestinationPageState extends State<DestinationPage> {
  final TravelService _service = TravelService();
  List<Destination> _destinations = [];
  bool _isLoading = true;

  @override
  void initState() {
    super.initState();
    _loadData();
  }

  Future<void> _loadData() async {
    final destinations = await _service.getDestinations();
    setState(() {
      _destinations = destinations;
      _isLoading = false;
    });
  }

  @override
  Widget build(BuildContext context) {
    if (_isLoading) {
      return const Center(
        child: CircularProgressIndicator(color: Color(0xFFFF6B4A)),
      );
    }

    return CustomScrollView(
      slivers: [
        const SliverToBoxAdapter(
          child: Padding(
            padding: EdgeInsets.all(16),
            child: Text(
              '热门目的地',
              style: TextStyle(
                fontSize: 18,
                fontWeight: FontWeight.bold,
                color: Color(0xFF333333),
              ),
            ),
          ),
        ),
        // 网格视图
        SliverPadding(
          padding: const EdgeInsets.symmetric(horizontal: 16),
          sliver: SliverGrid(
            gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
              crossAxisCount: 3,
              crossAxisSpacing: 12,
              mainAxisSpacing: 12,
              childAspectRatio: 0.8,
            ),
            delegate: SliverChildBuilderDelegate(
              (context, index) {
                final dest = _destinations[index];
                return _buildDestinationGridItem(dest);
              },
              childCount: _destinations.length,
            ),
          ),
        ),
        // ... 更多内容
      ],
    );
  }

  Widget _buildDestinationGridItem(Destination dest) {
    return Column(
      children: [
        Expanded(
          child: Stack(
            alignment: Alignment.bottomCenter,
            children: [
              ClipRRect(
                borderRadius: BorderRadius.circular(8),
                child: Image.network(
                  dest.coverUrl,
                  width: double.infinity,
                  height: double.infinity,
                  fit: BoxFit.cover,
                ),
              ),
              Container(
                padding: const EdgeInsets.only(bottom: 8),
                child: Text(
                  dest.name,
                  style: const TextStyle(
                    color: Colors.white,
                    fontSize: 14,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ),
            ],
          ),
        ),
        const SizedBox(height: 4),
        Text(
          '${dest.spotCount}个景点',
          style: TextStyle(fontSize: 11, color: Colors.grey[600]),
        ),
      ],
    );
  }
}

八、应用入口

最后,配置应用入口文件,设置主题色和默认页面。

dart 复制代码
// lib/main.dart

import 'package:flutter/material.dart';
import 'pages/travel_main_page.dart';

void main() {
  runApp(const TravelGuideApp());
}

class TravelGuideApp extends StatelessWidget {
  const TravelGuideApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '旅游攻略',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        primaryColor: const Color(0xFFFF6B4A),
        scaffoldBackgroundColor: const Color(0xFFF5F5F5),
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFFFF6B4A),
        ),
        useMaterial3: true,
      ),
      home: const TravelMainPage(),
    );
  }
}

九、截图运行板块

以下是旅游攻略应用在 OpenHarmony 设备上的运行截图:

1. 启动页与推荐页

图1:推荐页面截图 - 展示轮播图和热门景点列表

在推荐页面中,我们可以看到:

  • 顶部轮播图自动播放,点击可跳转至对应景点详情
  • 热门景点列表展示景区封面、名称、评分、标签和价格

2. 景点页面

图2:详情景点页面截图 - 展示热门景点介绍与图集

  • 我们可以看到景点标签,价格,介绍,与精彩图集

十、总结

本文详细介绍了如何使用 Flutter 开发旅游攻略应用,并成功运行在 OpenHarmony 设备上。通过本文,读者可以掌握:

  1. Flutter 项目结构设计规范
  2. 数据模型的设计与定义
  3. 服务层的架构实现
  4. 底部导航栏的实现
  5. 下拉刷新与上拉加载的实现
  6. 轮播图组件的使用
  7. 列表视图的优化

Flutter for OpenHarmony 为开发者提供了强大的跨平台能力,一套代码即可运行在多个平台上,大大提高了开发效率。希望本文能帮助读者快速入门 Flutter 鸿蒙开发。

附录

完整代码仓库

本文涉及的完整代码已托管至 AtomGit:

依赖配置

yaml 复制代码
# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  cached_network_image: ^3.3.1

相关推荐
jiejiejiejie_2 小时前
Flutter for OpenHarmony 跨平台开发:计算器功能实战指南
flutter
jiejiejiejie_3 小时前
Flutter for OpenHarmony 交互体验实战合集:底部导航优化 + 萌系用户反馈全攻略
flutter
liulian09163 小时前
Flutter for OpenHarmony 跨平台开发:番茄钟功能实战指南
flutter
三声三视3 小时前
ArkTS 性能优化实战:从卡顿分析到高帧率应用全攻略
华为·性能优化·harmonyos·鸿蒙
liulian09164 小时前
Flutter for OpenHarmony 效率工具开发实战:我实现的番茄钟与倒计时功能总结
flutter
小雨青年5 小时前
鸿蒙 HarmonyOS 6 | PDFKit预览能力升级实战
华为·harmonyos
jiejiejiejie_5 小时前
Flutter for OpenHarmony 跨平台开发:待办事项功能实战指南
flutter
花先锋队长6 小时前
鸿蒙6.1加持菜鸟App:地理围栏+实况窗,靠近驿站自动提醒,取件不再遗漏
华为·智能手机·harmonyos
nashane6 小时前
HarmonyOS 6学习:页面跳转弹窗状态保持全解析
学习·华为·harmonyos·harmonyos 5