Flutter + OpenHarmony 实战:从零开发小游戏(一)——主菜单与最高分存储

个人主页:

文章目录

    • 引言
    • [一、项目初始化:强制横屏与 MyApp 入口](#一、项目初始化:强制横屏与 MyApp 入口)
      • [1. 为什么游戏要强制横屏?](#1. 为什么游戏要强制横屏?)
        • [实现方式:在 `main()` 中设置系统 UI](#实现方式:在 main() 中设置系统 UI)
      • [2. MyApp:统一主题与路由入口](#2. MyApp:统一主题与路由入口)
    • [二、主菜单页面:MainMenuScreen 的状态管理与 UI 构建](#二、主菜单页面:MainMenuScreen 的状态管理与 UI 构建)
      • [1. 核心需求分析](#1. 核心需求分析)
      • [2. StatefulWidget 管理异步状态](#2. StatefulWidget 管理异步状态)
        • [🔍 关键设计点:](#🔍 关键设计点:)
    • [三、数据持久化:使用 shared_preferences 存储最高分](#三、数据持久化:使用 shared_preferences 存储最高分)
      • [1. 添加依赖](#1. 添加依赖)
      • [2. 读写最佳实践](#2. 读写最佳实践)
      • [3. 在 OpenHarmony 上的兼容性](#3. 在 OpenHarmony 上的兼容性)
    • [四、页面导航:为何使用 pushReplacement?](#四、页面导航:为何使用 pushReplacement?)
    • [五、占位页面:SkinScreen 的扩展设计](#五、占位页面:SkinScreen 的扩展设计)
    • 六、代码示例:
    • 六、总结与下篇预告

引言

在移动应用生态日益多元的今天,OpenHarmony 作为开源的分布式操作系统,正迅速构建起属于自己的应用生态。而 Flutter 凭借其高性能渲染、跨平台一致性以及丰富的 UI 能力,成为许多开发者构建 OpenHarmony 应用的首选框架。尤其对于游戏类轻应用,Flutter 的自绘引擎(Skia)能轻松实现流畅动画与精美界面,同时通过 shared_preferences 等插件实现本地数据持久化。

本文是《Flutter + OpenHarmony 小游戏实战》系列的第一篇,我们将从 零开始搭建一个完整的小游戏项目骨架,重点实现:

  • 横屏强制锁定(适配游戏场景);
  • 主菜单页面(MainMenuScreen):含动态加载、最高分显示;
  • 皮肤设置页(SkinScreen):预留扩展入口;
  • 最高分持久化 :使用 shared_preferences 存储与读取;
  • 页面导航优化 :使用 Navigator.pushReplacement 避免返回栈堆积。

💡 目标设备:本文代码可直接运行于支持 Flutter 的 OpenHarmony 设备(如 HarmonyOS NEXT 测试机或社区移植版),亦兼容 Android/iOS。


一、项目初始化:强制横屏与 MyApp 入口

1. 为什么游戏要强制横屏?

绝大多数休闲/动作类小游戏采用 横屏布局,以提供更宽广的操作视野和沉浸式体验。在 OpenHarmony 设备上,我们需在应用启动时即锁定方向。

实现方式:在 main() 中设置系统 UI
dart 复制代码
import 'package:flutter/services.dart';

void main() {
  // 锁定为横屏(landscape)
  SystemChrome.setPreferredOrientations([
    DeviceOrientation.landscapeLeft,
    DeviceOrientation.landscapeRight,
  ]);
  
  // 禁用状态栏(可选,提升沉浸感)
  SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);

  runApp(const MyApp());
}

📌 说明

  • setPreferredOrientations 告诉系统仅允许横屏;
  • SystemUiMode.immersiveSticky 隐藏状态栏与导航栏,适用于全屏游戏;
  • 此设置对 OpenHarmony 和 Android 均有效(iOS 需额外配置 Info.plist)。

2. MyApp:统一主题与路由入口

dart 复制代码
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flappy Bird Clone',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        brightness: Brightness.dark, // 游戏常用深色背景
        scaffoldBackgroundColor: Colors.black,
        textTheme: const TextTheme(
          headlineMedium: TextStyle(color: Colors.white, fontSize: 48),
          bodyMedium: TextStyle(color: Colors.white70, fontSize: 24),
        ),
      ),
      home: MainMenuScreen(),
    );
  }
}
  • 深色主题:减少 OLED 屏幕功耗,提升视觉对比度;
  • 统一字体颜色 :避免各页面重复定义 TextStyle
  • 入口设为 MainMenuScreen:用户打开即见主菜单。

二、主菜单页面:MainMenuScreen 的状态管理与 UI 构建

1. 核心需求分析

主菜单需展示:

  • 游戏标题(大字居中);
  • "开始游戏"按钮;
  • "皮肤设置"按钮;
  • 历史最高分(从本地读取);
  • 加载态处理:首次进入时异步读取高分。

2. StatefulWidget 管理异步状态

dart 复制代码
class MainMenuScreen extends StatefulWidget {
  @override
  State<MainMenuScreen> createState() => _MainMenuScreenState();
}

class _MainMenuScreenState extends State<MainMenuScreen> {
  int? _highScore; // 可空,表示"正在加载"
  bool _isLoading = true;

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

  Future<void> _loadHighScore() async {
    final prefs = await SharedPreferences.getInstance();
    final score = prefs.getInt('high_score') ?? 0;
    if (mounted) {
      setState(() {
        _highScore = score;
        _isLoading = false;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        children: [
          // 背景(可替换为图片)
          Container(color: Colors.black),

          // 内容层
          Positioned.fill(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Text('FLAPPY BIRD', style: TextStyle(fontSize: 48, fontWeight: FontWeight.bold)),
                const SizedBox(height: 40),
                
                // 最高分显示
                if (_isLoading)
                  const CircularProgressIndicator(color: Colors.white)
                else
                  Text('最高分: $_highScore', style: const TextStyle(fontSize: 24)),

                const SizedBox(height: 60),
                
                // 按钮区域
                ElevatedButton(
                  onPressed: () => _startGame(),
                  child: const Text('开始游戏', style: TextStyle(fontSize: 20)),
                ),
                const SizedBox(height: 20),
                ElevatedButton(
                  onPressed: () => _goToSkin(),
                  child: const Text('皮肤设置', style: TextStyle(fontSize: 20)),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  void _startGame() {
    // TODO: 跳转到游戏页面
  }

  void _goToSkin() {
    Navigator.push(
      context,
      MaterialPageRoute(builder: (context) => const SkinScreen()),
    );
  }
}
🔍 关键设计点:
特性 实现方式 优势
加载态 _isLoading + CircularProgressIndicator 用户感知"正在准备",避免空白
安全更新 if (mounted) 检查 防止页面销毁后调用 setState
UI 布局 Stack + Positioned.fill + Column 背景与内容分离,弹性布局
深色适配 白色文字 + 黑色背景 符合游戏视觉规范

💡 为什么用 Stack

  • 背景可轻松替换为 ImageAnimatedBackground
  • 内容层居中不受背景影响;
  • 未来可叠加粒子特效(如飘落的羽毛)。

三、数据持久化:使用 shared_preferences 存储最高分

1. 添加依赖

pubspec.yaml 中添加:

yaml 复制代码
dependencies:
  flutter:
    sdk: flutter
  shared_preferences: ^2.2.0

2. 读写最佳实践

读取(已展示在 _loadHighScore):
dart 复制代码
final prefs = await SharedPreferences.getInstance();
final score = prefs.getInt('high_score') ?? 0;
写入(通常在游戏结束时调用):
dart 复制代码
Future<void> saveHighScore(int newScore) async {
  final prefs = await SharedPreferences.getInstance();
  final current = prefs.getInt('high_score') ?? 0;
  if (newScore > current) {
    await prefs.setInt('high_score', newScore);
  }
}

最佳实践总结

  • 键名规范 :使用 'high_score' 而非 'score',语义清晰;
  • 空值处理?? 0 避免首次启动崩溃;
  • 只存最高分:不存所有历史记录,节省空间;
  • 异步安全 :所有操作均为 Future,不阻塞 UI。

3. 在 OpenHarmony 上的兼容性

shared_preferences 在 OpenHarmony 上通过 Dart FFI 调用原生存储 API 实现。社区已有适配版本(如 flutter_ohos 插件包),确保数据持久化行为与 Android/iOS 一致。

⚠️ 注意:若使用纯 OpenHarmony SDK,需确认插件是否支持。建议优先选用官方或华为认证的 Flutter for OpenHarmony 运行时。


四、页面导航:为何使用 pushReplacement?

1. 场景分析

  • 用户从 主菜单 → 皮肤设置:应能返回主菜单;
  • 用户从 主菜单 → 游戏页面不应能返回主菜单(否则会重置游戏);
  • 游戏结束后,应 返回主菜单,而非回到"上一局"。

2. 导航策略

跳转路径 使用方法 原因
主菜单 → 皮肤页 Navigator.push() 允许返回
主菜单 → 游戏页 Navigator.pushReplacement() 清空返回栈,防止误返回中断游戏
游戏结束 → 主菜单 Navigator.pushReplacement() 直接回到起点,体验干净
示例:开始游戏
dart 复制代码
void _startGame() {
  Navigator.pushReplacement(
    context,
    MaterialPageRoute(builder: (context) => GameScreen()),
  );
}

🧠 用户体验思考

游戏是"一次性会话",用户结束即回到主菜单重新开始。若保留返回栈,用户可能误触返回导致成绩丢失,这是糟糕的设计。


五、占位页面:SkinScreen 的扩展设计

dart 复制代码
class SkinScreen extends StatelessWidget {
  const SkinScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('皮肤设置')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: const [
            Text('功能开发中...', style: TextStyle(fontSize: 24)),
            SizedBox(height: 20),
            Text('敬请期待!', style: TextStyle(color: Colors.grey)),
          ],
        ),
      ),
    );
  }
}
  • 独立页面:便于未来接入皮肤选择逻辑;
  • AppBar 返回:自动带有返回按钮,符合 Material 规范;
  • 占位文案:明确告知用户功能未上线,提升体验。

六、代码示例:

dart 复制代码
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';

void main() {
  // 锁定横屏 + 沉浸式模式
  SystemChrome.setPreferredOrientations([
    DeviceOrientation.landscapeLeft,
    DeviceOrientation.landscapeRight,
  ]);
  SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);

  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flappy Bird Clone',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        brightness: Brightness.dark,
        scaffoldBackgroundColor: Colors.black,
        textTheme: const TextTheme(
          headlineMedium: TextStyle(color: Colors.white, fontSize: 48, fontWeight: FontWeight.bold),
          bodyMedium: TextStyle(color: Colors.white70, fontSize: 24),
        ),
        elevatedButtonTheme: ElevatedButtonThemeData(
          style: ElevatedButton.styleFrom(
            backgroundColor: Colors.blue,
            foregroundColor: Colors.white,
            padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
            textStyle: const TextStyle(fontSize: 20),
          ),
        ),
      ),
      home: MainMenuScreen(),
    );
  }
}

class MainMenuScreen extends StatefulWidget {
  @override
  State<MainMenuScreen> createState() => _MainMenuScreenState();
}

class _MainMenuScreenState extends State<MainMenuScreen> {
  int? _highScore;
  bool _isLoading = true;

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

  Future<void> _loadHighScore() async {
    final prefs = await SharedPreferences.getInstance();
    final score = prefs.getInt('high_score') ?? 0;
    if (mounted) {
      setState(() {
        _highScore = score;
        _isLoading = false;
      });
    }
  }

  void _startGame() {
    // 模拟:游戏结束后会保存分数(这里先保存一个测试分数)
    // 实际项目中,此处应跳转到 GameScreen()
    _simulateGameEnd();

    // 跳转到"游戏页面"(用 MainMenuScreen 自身模拟,实际替换为 GameScreen)
    Navigator.pushReplacement(
      context,
      MaterialPageRoute(builder: (context) => const GameResultScreen()),
    );
  }

  void _simulateGameEnd() async {
    // 模拟一局游戏结束,随机生成分数并尝试更新最高分
    final randomScore = 50 + (DateTime.now().millisecondsSinceEpoch % 100);
    final prefs = await SharedPreferences.getInstance();
    final currentHigh = prefs.getInt('high_score') ?? 0;
    if (randomScore > currentHigh) {
      await prefs.setInt('high_score', randomScore);
    }
  }

  void _goToSkin() {
    Navigator.push(
      context,
      MaterialPageRoute(builder: (context) => const SkinScreen()),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        children: [
          // 背景
          Container(
            color: Colors.black,
          ),
          // 内容
          Positioned.fill(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              crossAxisAlignment: CrossAxisAlignment.center,
              children: [
                const Text('FLAPPY BIRD'),
                const SizedBox(height: 40),
                if (_isLoading)
                  const CircularProgressIndicator(color: Colors.white)
                else
                  Text('最高分: $_highScore', style: const TextStyle(fontSize: 24)),
                const SizedBox(height: 60),
                ElevatedButton(
                  onPressed: _startGame,
                  child: const Text('开始游戏'),
                ),
                const SizedBox(height: 20),
                ElevatedButton(
                  onPressed: _goToSkin,
                  child: const Text('皮肤设置'),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

// 模拟游戏结果页(实际项目中替换为真实游戏)
class GameResultScreen extends StatelessWidget {
  const GameResultScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('游戏结束!', style: TextStyle(fontSize: 36)),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: () {
                Navigator.pushReplacement(
                  context,
                  MaterialPageRoute(builder: (context) => MainMenuScreen()),
                );
              },
              child: const Text('返回主菜单'),
            ),
          ],
        ),
      ),
    );
  }
}

// 皮肤设置页(占位)
class SkinScreen extends StatelessWidget {
  const SkinScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('皮肤设置')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: const [
            Icon(Icons.palette, size: 64, color: Colors.blue),
            SizedBox(height: 20),
            Text('皮肤功能开发中...', style: TextStyle(fontSize: 24)),
            SizedBox(height: 10),
            Text('敬请期待!', style: TextStyle(color: Colors.grey)),
          ],
        ),
      ),
    );
  }
}

运行界面:


六、总结与下篇预告

本文完成了小游戏项目的 基础架构搭建,实现了:

  • ✅ 横屏锁定与沉浸式 UI;
  • ✅ 主菜单动态加载最高分;
  • ✅ 安全的数据持久化方案;
  • ✅ 合理的页面导航策略。

这些看似简单的功能,实则蕴含了 状态管理、异步处理、用户体验、跨平台兼容 等核心工程思想。尤其在 OpenHarmony 生态中,如何让 Flutter 应用既保持开发效率,又符合系统规范,是我们持续探索的方向。

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

相关推荐
2501_940007893 小时前
Flutter for OpenHarmony三国杀攻略App实战 - 性能优化与最佳实践
android·flutter·性能优化
2501_940007893 小时前
Flutter for OpenHarmony三国杀攻略App实战 - 战绩记录功能实现
开发语言·javascript·flutter
灰灰勇闯IT3 小时前
Flutter for OpenHarmony:TabBar 与 PageView 联动 —— 构建高效的内容导航系统
flutter
ujainu3 小时前
Flutter + OpenHarmony 实战:从零开发小游戏(三)——CustomPainter 实现拖尾与相机跟随
flutter·游戏·harmonyos
2601_949975084 小时前
flutter_for_openharmonyflutter小区门禁管理app实战+报修详情实现
flutter
程序员清洒4 小时前
Flutter for OpenHarmony:Scaffold 与 AppBar — 应用基础结构搭建
flutter·华为·鸿蒙
子春一4 小时前
Flutter for OpenHarmony:构建一个 Flutter 习惯打卡应用,深入解析周视图交互、连续打卡逻辑与状态驱动 UI
flutter·ui·交互
暮志未晚Webgl4 小时前
UE5使用CameraShake相机震动提升游戏体验
数码相机·游戏·ue5
菜鸟小芯5 小时前
【开源鸿蒙跨平台开发先锋训练营】DAY8~DAY13 底部选项卡&推荐功能实现
flutter·harmonyos