Flutter for OpenHarmony 跨平台开发:日历打卡功能实战指南

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


一、引言

随着移动互联网的快速发展,跨平台开发技术日益受到开发者的关注。Flutter作为Google推出的开源UI框架,凭借其"一次编写,多处运行"的特性,已成为跨平台开发的主流选择之一。Flutter for OpenHarmony的出现,为开发者提供了将Flutter应用部署到鸿蒙设备的能力,进一步拓展了跨平台开发的应用场景。

本文将以日历打卡功能为例,详细介绍如何使用Flutter for OpenHarmony进行跨平台开发。日历打卡功能是习惯养成类应用的核心功能之一,涉及日期处理、状态管理、UI布局等多个技术要点,具有较强的实践参考价值。


二、技术背景

2.1 Flutter for OpenHarmony概述

Flutter是Google于2017年发布的开源UI框架,采用Dart语言进行开发。Flutter通过自绘引擎实现跨平台渲染,不依赖平台原生组件,从而保证了不同平台上UI的一致性。

OpenHarmony是由开放原子开源基金会孵化的开源操作系统项目,旨在构建万物智联的操作系统生态。Flutter for OpenHarmony是Flutter在OpenHarmony平台上的适配实现,使Flutter开发者能够将应用无缝部署到鸿蒙设备。

2.2 跨平台开发的技术优势

相较于传统的原生开发模式,Flutter跨平台开发具有以下优势:

开发效率:开发者只需维护一套代码库,即可同时支持Android、iOS、Web以及OpenHarmony等多个平台,显著降低了开发和维护成本。

渲染性能:Flutter采用Skia渲染引擎,直接绘制像素到屏幕,避免了JavaScript桥接带来的性能损耗,实现了接近原生的渲染性能。

开发体验:Flutter提供热重载功能,开发者可以在不重启应用的情况下查看代码修改效果,大幅提升了开发效率。

UI一致性:Flutter不依赖平台原生组件,通过自绘方式实现UI渲染,确保了不同平台上界面表现的一致性。

2.3 Flutter与原生鸿蒙开发的对比

对比维度 Flutter for OpenHarmony 原生鸿蒙开发(ArkTS)
编程语言 Dart ArkTS
学习曲线 相对平缓 需要学习新语言和框架
跨平台能力 支持多平台 仅限鸿蒙平台
性能表现 接近原生 原生性能
生态系统 Flutter生态成熟 鸿蒙生态发展中
UI组件 Material/Cupertino组件 ArkUI组件

三、功能设计

3.1 需求分析

日历打卡功能的核心需求包括:

  1. 多习惯追踪:支持用户同时追踪多个习惯的打卡记录
  2. 日历视图:以月历形式展示打卡状态
  3. 统计功能:提供打卡次数、连续天数等统计数据
  4. 交互操作:支持打卡、取消打卡等操作

3.2 架构设计

本功能采用Flutter的状态管理模式进行架构设计:

  • 数据层:使用Map结构存储打卡记录
  • 逻辑层:实现打卡状态判断、统计计算等业务逻辑
  • 视图层:构建日历网格、统计面板等UI组件

3.3 界面设计

界面采用垂直布局,从上到下依次为:

  1. 习惯选择器:使用FilterChip组件实现习惯切换
  2. 统计面板:展示本月打卡、连续天数、总天数
  3. 月份导航:支持切换上/下月
  4. 日历网格:7列网格展示日期

四、核心实现

4.1 数据模型设计

打卡记录采用Map结构存储,以习惯名称为键,打卡日期集合为值:

// 打卡记录存储结构
final Map<String, Set<String>> _habitRecords = {};

// 日期格式:年-月-日,如 "2026-04-29"

这种设计的优点在于:

  • 查询效率高,时间复杂度为O(1)
  • 支持多个习惯的独立记录
  • 便于扩展其他习惯类型

4.2 日历网格计算

日历网格的构建需要计算两个关键参数:

// 获取月份第一天
final firstDay = DateTime(_currentMonth.year, _currentMonth.month, 1);

// 获取月份最后一天
final lastDay = DateTime(_currentMonth.year, _currentMonth.month + 1, 0);

// 计算起始位置(将周一为1转换为周日为0)
final startWeekday = firstDay.weekday % 7;

// 计算总格子数
final totalDays = lastDay.day + startWeekday;

4.3 打卡状态判断

bool _isChecked(DateTime date) {
  final key = '${date.year}-${date.month}-${date.day}';
  return (_habitRecords[_selectedHabit] ?? {}).contains(key);
}

4.4 连续打卡天数计算

连续打卡天数的计算采用从当前日期向前遍历的方法:

int _getStreak() {
  int streak = 0;
  DateTime checkDate = DateTime.now();
  while (true) {
    final key = '${checkDate.year}-${checkDate.month}-${checkDate.day}';
    if ((_habitRecords[_selectedHabit] ?? {}).contains(key)) {
      streak++;
      checkDate = checkDate.subtract(const Duration(days: 1));
    } else {
      break;
    }
  }
  return streak;
}

五、完整代码实现

import 'package:flutter/material.dart';

class CalendarFeature extends StatefulWidget {
  const CalendarFeature({super.key});

  
  State<CalendarFeature> createState() => _CalendarFeatureState();
}

class _CalendarFeatureState extends State<CalendarFeature> {
  DateTime _currentMonth = DateTime.now();
  String _selectedHabit = '早起';
  final List<String> _habits = ['早起', '运动', '阅读', '学习', '冥想', '喝水'];
  final Map<String, Set<String>> _habitRecords = {};

  bool _isChecked(DateTime date) {
    final key = '${date.year}-${date.month}-${date.day}';
    return (_habitRecords[_selectedHabit] ?? {}).contains(key);
  }

  void _toggleCheck(DateTime date) {
    final key = '${date.year}-${date.month}-${date.day}';
    setState(() {
      _habitRecords[_selectedHabit] ??= {};
      if (_habitRecords[_selectedHabit]!.contains(key)) {
        _habitRecords[_selectedHabit]!.remove(key);
      } else {
        _habitRecords[_selectedHabit]!.add(key);
      }
    });
  }

  int _getMonthCheckCount() {
    return (_habitRecords[_selectedHabit] ?? {})
        .where((d) => d.startsWith('${_currentMonth.year}-${_currentMonth.month}'))
        .length;
  }

  int _getStreak() {
    int streak = 0;
    DateTime checkDate = DateTime.now();
    while (true) {
      final key = '${checkDate.year}-${checkDate.month}-${checkDate.day}';
      if ((_habitRecords[_selectedHabit] ?? {}).contains(key)) {
        streak++;
        checkDate = checkDate.subtract(const Duration(days: 1));
      } else {
        break;
      }
    }
    return streak;
  }

  
  Widget build(BuildContext context) {
    return Column(
      children: [
        _buildHabitSelector(),
        _buildStats(),
        _buildMonthHeader(),
        _buildWeekDays(),
        Expanded(child: _buildCalendarGrid()),
      ],
    );
  }

  Widget _buildHabitSelector() {
    return Container(
      padding: const EdgeInsets.all(12),
      child: SingleChildScrollView(
        scrollDirection: Axis.horizontal,
        child: Row(
          children: _habits.map((habit) => Padding(
            padding: const EdgeInsets.only(right: 8),
            child: FilterChip(
              label: Text(habit),
              selected: _selectedHabit == habit,
              onSelected: (selected) {
                setState(() => _selectedHabit = habit);
              },
              selectedColor: Colors.green.shade200,
            ),
          )).toList(),
        ),
      ),
    );
  }

  Widget _buildStats() {
    return Container(
      margin: const EdgeInsets.symmetric(horizontal: 12),
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        gradient: LinearGradient(
          colors: [Colors.green.shade400, Colors.green.shade600],
          begin: Alignment.centerLeft,
          end: Alignment.centerRight,
        ),
        borderRadius: BorderRadius.circular(12),
      ),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceAround,
        children: [
          _buildStatItem('本月打卡', _getMonthCheckCount(), Icons.calendar_today),
          _buildStatItem('连续天数', _getStreak(), Icons.local_fire_department),
          _buildStatItem('总天数', (_habitRecords[_selectedHabit] ?? {}).length, Icons.star),
        ],
      ),
    );
  }

  Widget _buildStatItem(String label, int value, IconData icon) {
    return Column(
      children: [
        Icon(icon, color: Colors.white, size: 24),
        const SizedBox(height: 4),
        Text('$value', style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.white)),
        Text(label, style: const TextStyle(fontSize: 12, color: Colors.white70)),
      ],
    );
  }

  Widget _buildMonthHeader() {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          IconButton(
            icon: const Icon(Icons.chevron_left, size: 28),
            onPressed: () => setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1)),
          ),
          Text(
            '${_currentMonth.year}${_currentMonth.month}月',
            style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
          ),
          IconButton(
            icon: const Icon(Icons.chevron_right, size: 28),
            onPressed: () => setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month + 1)),
          ),
        ],
      ),
    );
  }

  Widget _buildWeekDays() {
    const weekDays = ['日', '一', '二', '三', '四', '五', '六'];
    return Container(
      padding: const EdgeInsets.symmetric(vertical: 8),
      child: Row(
        children: weekDays.map((d) => Expanded(
          child: Center(
            child: Text(d, style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.grey)),
          ),
        )).toList(),
      ),
    );
  }

  Widget _buildCalendarGrid() {
    final firstDay = DateTime(_currentMonth.year, _currentMonth.month, 1);
    final lastDay = DateTime(_currentMonth.year, _currentMonth.month + 1, 0);
    final startWeekday = firstDay.weekday % 7;
    final totalDays = lastDay.day + startWeekday;

    return GridView.builder(
      padding: const EdgeInsets.symmetric(horizontal: 8),
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 7,
        childAspectRatio: 1,
      ),
      itemCount: totalDays,
      itemBuilder: (context, index) {
        if (index < startWeekday) return const SizedBox();

        final day = index - startWeekday + 1;
        final date = DateTime(_currentMonth.year, _currentMonth.month, day);
        final checked = _isChecked(date);
        final isToday = _isToday(date);
        final isFuture = date.isAfter(DateTime.now());

        return GestureDetector(
          onTap: isFuture ? null : () => _toggleCheck(date),
          child: Container(
            margin: const EdgeInsets.all(4),
            decoration: BoxDecoration(
              color: checked ? Colors.green : (isToday ? Colors.blue.shade50 : null),
              shape: BoxShape.circle,
              border: isToday && !checked ? Border.all(color: Colors.blue, width: 2) : null,
            ),
            child: Stack(
              alignment: Alignment.center,
              children: [
                Text(
                  '$day',
                  style: TextStyle(
                    color: checked ? Colors.white : (isFuture ? Colors.grey.shade300 : null),
                    fontWeight: isToday ? FontWeight.bold : null,
                  ),
                ),
                if (checked)
                  const Positioned(
                    bottom: 2,
                    child: Icon(Icons.check, size: 12, color: Colors.white),
                  ),
              ],
            ),
          ),
        );
      },
    );
  }

  bool _isToday(DateTime date) {
    final now = DateTime.now();
    return date.year == now.year && date.month == now.month && date.day == now.day;
  }
}

六、运行效果

在这里插入图片描述


七、关键技术点解析

7.1 GridView与日历布局

Flutter的GridView组件非常适合构建日历网格。本实现使用SliverGridDelegateWithFixedCrossAxisCount设置7列布局,对应一周7天。关键点在于正确计算月份第一天的起始位置:

final startWeekday = firstDay.weekday % 7;

Dart的weekday属性返回1-7(周一到周日),通过% 7转换为0-6(周日到周六),适配日历布局。

7.2 FilterChip组件应用

FilterChip是Material Design 3的选择芯片组件,适用于单选或多选场景。本实现利用其selected属性控制选中状态,selectedColor设置选中颜色,实现了简洁的习惯选择功能。

7.3 OpenHarmony平台适配

在OpenHarmony设备上运行Flutter应用,需要注意以下几点:

  1. 签名配置:需在DevEco Studio中配置应用签名
  2. 权限声明:如涉及网络请求等敏感操作,需在module.json5中声明相应权限
  3. 触摸交互:使用GestureDetector或InkWell处理触摸事件,确保交互响应正常

八、总结与展望

本文详细介绍了使用Flutter for OpenHarmony开发日历打卡功能的完整过程。通过合理的数据结构设计、清晰的业务逻辑实现、规范的UI组件构建,完成了一个功能完善、交互友好的打卡功能模块。

技术要点回顾

  • 使用Map<String, Set>存储打卡记录,查询效率高
  • GridView构建日历网格,布局灵活可控
  • FilterChip实现习惯选择,交互简洁直观
  • 连续打卡算法采用向前遍历策略,逻辑清晰

扩展方向

  • 数据持久化:集成shared_preferences或hive实现数据本地存储
  • 提醒功能:添加本地通知,定时提醒用户打卡
  • 数据可视化:引入图表库展示打卡趋势
  • 云同步:接入后端服务实现多设备数据同步

Flutter for OpenHarmony为开发者提供了便捷的跨平台开发能力,随着鸿蒙生态的不断发展,将会有更多应用场景等待探索。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐