Flutter三方库适配鸿蒙【step_counter】模拟计步器应用项目完整实战

前言

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

step_counter 是一个健康运动类 Flutter 示例项目。它没有接入真实传感器,而是通过 Future.doWhileFuture.delayed 在前台模拟步数增长,并实时更新步数、目标完成度、卡路里估算、距离估算和小时活动柱状图。

计步器类应用通常涉及传感器、后台记录、权限和健康数据平台。这个项目选择从更轻量的前台模拟入手,适合用来练习 状态管理、异步循环、圆形进度条、统计卡片、柱状图比例计算、按钮状态切换和 OpenHarmony 基础 UI 适配

在这里插入图片描述

图示说明:本文围绕 Flutter 工程中的 step_counter 项目展开,重点分析模拟计步逻辑、目标进度、卡路里与距离估算、小时活动图,以及 OpenHarmony 适配中的真实边界。

计步器的核心体验是“运动状态 + 实时进度 + 数据反馈”。即使是模拟数据,也能完整练习健康类 Dashboard 的状态流转和可视化结构。

本文将基于项目真实源码展开,重点包括:

  • StepCounterApp 的应用入口与绿色 Material 3 主题
  • _steps_goal_isWalking 的状态关系
  • _simulateSteps() 如何每 2 秒模拟步数增加
  • _progress 如何驱动圆形目标进度
  • _caloriesBurned_distanceKm 的估算方式
  • _buildStatCard() 如何复用统计卡片
  • Hourly Activity 柱状图如何按比例计算高度
  • _hourlySteps 长度与真实小时取值不匹配的边界
  • OpenHarmony 适配时应该验证的图标、进度、异步刷新和布局

一、项目背景与目标

1.1 项目定位

step_counter 的定位是一个模拟计步器。用户点击 Start Counting 后,应用会每 2 秒模拟增加一批步数;点击 Stop Counting 后,模拟停止;点击 AppBar 右侧刷新按钮后,步数和小时活动数据归零。

从用户流程看,它包含:

  1. 打开应用,看到默认 0 步。
  2. 点击 Start Counting 开始模拟计步。
  3. 观察步数、百分比、卡路里和距离变化。
  4. 点击 Stop Counting 暂停模拟。
  5. 点击刷新按钮重置数据。
  6. 查看底部小时活动柱状图。

从工程流程看,它包含:

  1. 使用 _isWalking 表示是否正在计步。
  2. 使用 _steps 保存总步数。
  3. 使用 _goal 保存目标步数。
  4. 使用异步循环定期增加模拟步数。
  5. 使用 getter 派生进度、卡路里和距离。
  6. 使用 CircularProgressIndicator 展示目标完成率。
  7. 使用 RowContainer 绘制简易柱状图。

1.2 当前功能概览

功能 当前实现 技术点
应用入口 runApp(const StepCounterApp()) Flutter 启动流程
应用主题 绿色 Material 3 ColorScheme.fromSeed
总步数 默认 0 _steps
目标步数 10000 _goal
计步状态 Start / Stop _isWalking
模拟步数 每 2 秒增加 5 到 14 步 Future.doWhile
进度展示 圆形进度条 CircularProgressIndicator
卡路里估算 步数乘 0.04 _caloriesBurned
距离估算 步数除以 1300 _distanceKm
活动图 12 个柱状条 _hourlySteps
重置 清空步数和柱状图 _reset()

1.3 适合学习的能力

这个项目适合学习:

  • 模拟传感器数据流
  • 异步循环与 mounted 判断
  • 圆形进度可视化
  • 派生统计数据
  • 健康类 Dashboard 布局
  • 简易柱状图比例计算
  • 按钮状态和图标切换
  • OpenHarmony 前台 UI 适配验证

二、环境准备与工程结构

2.1 技术栈概览

项目只使用 Flutter SDK 自带能力,没有引入传感器插件。

类别 当前使用 说明
开发语言 Dart Flutter 应用主语言
UI 框架 Flutter Material 页面、按钮、卡片、图标
状态管理 StatefulWidget + setState 页面内状态
模拟计时 Future.doWhile + Future.delayed 每 2 秒更新一次
进度组件 CircularProgressIndicator 目标进度展示
柱状图 Row + Container 小型活动图
目标适配 Flutter / OpenHarmony 基础 UI 和异步刷新验证

2.2 pubspec 关键配置

工程配置如下:

environment:
  sdk: ^3.9.2

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.8

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true

这里需要注意:

  1. 当前项目没有 pedometer、传感器或健康数据插件。
  2. Material Icons 依赖 uses-material-design: true
  3. 主要验证 Flutter Framework 层能力,不验证真实计步硬件能力。

2.3 主源码结构

核心代码集中在 lib/main.dart

import 'package:flutter/material.dart';

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

主要结构如下:

结构 类型 作用
StepCounterApp StatelessWidget 应用根组件
StepCounterHomePage StatefulWidget 计步器首页
_StepCounterHomePageState State 管理步数、状态和统计

2.4 常用运行命令

完成 Flutter 环境准备后,可以执行:

flutter pub get
flutter analyze
flutter test
flutter run

OpenHarmony 环境运行时,还需要结合本地 Flutter OpenHarmony 发行版、DevEco Studio、设备连接和签名配置。

三、应用入口与主题配置

3.1 main 函数

应用入口如下:

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

runApp 会把根组件挂载到 Flutter 渲染树。

3.2 StepCounterApp 根组件

根组件代码如下:

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

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Step Counter',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
        useMaterial3: true,
      ),
      home: const StepCounterHomePage(title: 'Step Counter'),
    );
  }
}

它完成了:

  1. 设置应用标题为 Step Counter
  2. 使用绿色作为 Material 3 种子色。
  3. 设置首页为 StepCounterHomePage

3.3 绿色主题的意义

绿色通常用于健康、运动和目标达成场景。项目中绿色被用于:

  • 运行状态图标。
  • 目标进度条。
  • Start Counting 按钮。
  • Hourly Activity 柱状图。
  • 统计卡片图标。

这让页面视觉语义保持一致。

四、页面状态设计

4.1 状态字段

页面状态如下:

class _StepCounterHomePageState extends State<StepCounterHomePage> {
  int _steps = 0;
  int _goal = 10000;
  bool _isWalking = false;
  final List<int> _hourlySteps = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
  int _currentHour = DateTime.now().hour;
}

字段含义如下:

字段 类型 作用
_steps int 当前总步数
_goal int 目标步数,默认 10000
_isWalking bool 是否正在模拟计步
_hourlySteps List<int> 小时活动步数桶
_currentHour int 当前小时

4.2 状态关系

_isWalking 控制是否增加步数;_steps 控制主进度;_hourlySteps 控制底部柱状图;_goal 控制目标完成比例。

这些状态共同驱动页面:

double get _progress => _steps / _goal;
int get _caloriesBurned => (_steps * 0.04).round();
double get _distanceKm => _steps / 1300;

4.3 真实传感器边界

当前项目没有读取系统传感器,也没有申请运动权限。步数来源是模拟逻辑:

final randomSteps = 5 + (DateTime.now().second % 10);

因此它不是一个真实计步器,而是一个用于学习 UI 状态和数据可视化的模拟计步器。

五、模拟计步逻辑

5.1 initState 启动模拟循环

页面初始化时调用 _simulateSteps()


void initState() {
  super.initState();
  _simulateSteps();
}

这意味着异步循环在页面创建后就开始运行,但只有 _isWalkingtrue 时才会增加步数。

5.2 _simulateSteps 方法

模拟计步代码如下:

void _simulateSteps() {
  Future.doWhile(() async {
    await Future.delayed(const Duration(seconds: 2));
    if (mounted) {
      setState(() {
        if (_isWalking) {
          final randomSteps = 5 + (DateTime.now().second % 10);
          _steps += randomSteps;
          _hourlySteps[_currentHour] += randomSteps;
        }
      });
      return true;
    }
    return false;
  });
}

流程如下:

  1. 每次等待 2 秒。
  2. 判断页面是否仍然 mounted。
  3. 如果 _isWalking 为 true,就生成模拟步数。
  4. 增加总步数。
  5. 增加当前小时桶内步数。
  6. 返回 true 继续循环。

5.3 模拟步数范围

模拟步数计算如下:

final randomSteps = 5 + (DateTime.now().second % 10);

DateTime.now().second % 10 的范围是 0 到 9,因此每次增加:

最小值 最大值
5 14

这不是随机数,而是基于当前秒数的确定性变化。

5.4 mounted 的作用

异步循环里使用:

if (mounted) {
  setState(() {
    // update state
  });
}

这样可以避免页面销毁后继续调用 setState

六、开始、停止与重置

6.1 Start / Stop 切换

按钮回调为 _toggleWalking()

void _toggleWalking() {
  setState(() {
    _isWalking = !_isWalking;
    _currentHour = DateTime.now().hour;
  });
}

它会切换 _isWalking,并更新当前小时。

6.2 按钮 UI

按钮根据 _isWalking 改变图标、文案和颜色:

ElevatedButton.icon(
  onPressed: _toggleWalking,
  icon: Icon(_isWalking ? Icons.pause : Icons.play_arrow),
  label: Text(_isWalking ? 'Stop Counting' : 'Start Counting'),
  style: ElevatedButton.styleFrom(
    padding: const EdgeInsets.all(16),
    backgroundColor: _isWalking ? Colors.red : Colors.green,
  ),
)

状态对应关系:

状态 图标 文案 颜色
未计步 Icons.play_arrow Start Counting 绿色
正在计步 Icons.pause Stop Counting 红色

6.3 重置逻辑

重置方法如下:

void _reset() {
  setState(() {
    _steps = 0;
    _hourlySteps.fillRange(0, _hourlySteps.length, 0);
  });
}

它会清空总步数和小时活动图。

6.4 重置的真实边界

当前 _reset() 不会把 _isWalking 改为 false。如果用户正在计步时点击重置,下一轮模拟循环仍会继续增加步数。

这不一定是错误,但需要明确:当前重置是清空数据,不是停止计步。如果希望 Reset 同时停止,可以写成:

void _reset() {
  setState(() {
    _isWalking = false;
    _steps = 0;
    _hourlySteps.fillRange(0, _hourlySteps.length, 0);
  });
}

七、目标进度与圆形 UI

7.1 进度计算

目标进度通过 getter 计算:

double get _progress => _steps / _goal;

默认目标是 10000 步,因此:

步数 进度
1000 10%
5000 50%
10000 100%

7.2 圆形进度条

圆形进度条代码如下:

CircularProgressIndicator(
  value: _progress.clamp(0, 1),
  strokeWidth: 12,
  backgroundColor: Colors.grey.shade200,
  valueColor: AlwaysStoppedAnimation(
    _progress >= 1 ? Colors.green : Colors.green.shade300,
  ),
)

_progress.clamp(0, 1) 可以保证传给进度条的值始终在 0 到 1 之间。

7.3 中心内容

进度条中心显示图标、步数和百分比:

Column(
  children: [
    Icon(
      _isWalking ? Icons.directions_walk : Icons.accessibility_new,
      size: 48,
      color: _isWalking ? Colors.green : Colors.grey,
    ),
    Text('$_steps'),
    Text('${(_progress * 100).toInt()}%'),
  ],
)

图标会跟随计步状态变化:

  • 计步中:行走图标,绿色。
  • 未计步:静态人体图标,灰色。

7.4 超过目标后的表现

当步数超过目标时,圆形进度条保持满格,因为 value 被限制到 1。但百分比文本会继续增长,例如 12000 步时显示 120%。

这是当前源码的真实表现。

八、卡路里与距离估算

8.1 卡路里估算

卡路里通过 getter 计算:

int get _caloriesBurned => (_steps * 0.04).round();

这表示每步约 0.04 卡路里,是一个非常粗略的估算。

示例:

步数 卡路里估算
1000 40
5000 200
10000 400

8.2 距离估算

距离通过 getter 计算:

double get _distanceKm => _steps / 1300;

这表示约 1300 步为 1 公里。

示例:

步数 距离估算
1300 1.00 km
6500 5.00 km
10000 7.69 km

8.3 估算边界

卡路里和距离都不是精准健康数据,真实数值会受身高、体重、步幅、速度、地形等因素影响。当前实现更适合演示派生数据,而不是医学或训练级统计。

8.4 统计卡片复用

三个统计项使用 _buildStatCard 构建:

_buildStatCard(Icons.local_fire_department, _caloriesBurned.toString(), 'Calories')
_buildStatCard(Icons.straighten, _distanceKm.toStringAsFixed(2), 'km')
_buildStatCard(Icons.flag, _goal.toString(), 'Goal')

这让页面结构更简洁,避免重复写三段相似 UI。

九、统计卡片组件

9.1 _buildStatCard 方法

组件方法如下:

Widget _buildStatCard(IconData icon, String value, String label) {
  return Card(
    child: Container(
      padding: const EdgeInsets.all(12),
      child: Column(
        children: [
          Icon(icon, color: Colors.green),
          const SizedBox(height: 4),
          Text(
            value,
            style: const TextStyle(
              fontSize: 20,
              fontWeight: FontWeight.bold,
            ),
          ),
          Text(
            label,
            style: TextStyle(
              fontSize: 12,
              color: Colors.grey.shade600,
            ),
          ),
        ],
      ),
    ),
  );
}

9.2 组件参数

参数 类型 作用
icon IconData 卡片图标
value String 主要数值
label String 指标名称

9.3 组件复用价值

这个方法让统计卡片保持统一样式:

  • 图标统一绿色。
  • 数值统一 20 号粗体。
  • 标签统一 12 号灰色。
  • padding 和布局一致。

如果后续增加更多指标,只需要继续调用 _buildStatCard()

十、Hourly Activity 柱状图

10.1 数据来源

小时活动数据保存在:

final List<int> _hourlySteps = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];

当前长度是 12。

10.2 柱状图生成

柱状图使用 List.generate(12, ...) 生成:

children: List.generate(12, (index) {
  final maxSteps = _hourlySteps.reduce((a, b) => a > b ? a : b);
  final height = maxSteps > 0
      ? (_hourlySteps[index] / maxSteps) * 80
      : 0.0;
  return Column(
    mainAxisAlignment: MainAxisAlignment.end,
    children: [
      Container(
        width: 20,
        height: height,
        decoration: BoxDecoration(
          color: index == _currentHour
              ? Colors.green
              : Colors.green.shade200,
          borderRadius: BorderRadius.circular(4),
        ),
      ),
      const SizedBox(height: 4),
      Text('$index'),
    ],
  );
})

10.3 高度计算逻辑

先找出最大步数:

final maxSteps = _hourlySteps.reduce((a, b) => a > b ? a : b);

再按最大值计算当前柱高:

final height = maxSteps > 0
    ? (_hourlySteps[index] / maxSteps) * 80
    : 0.0;

最高柱为 80 像素,其余柱按比例缩放。

10.4 当前小时索引的真实问题

_currentHour 来自:

int _currentHour = DateTime.now().hour;

DateTime.now().hour 的范围是 0 到 23。但 _hourlySteps 只有 12 个元素。如果当前时间是 12 点到 23 点,执行下面这行时就可能索引越界:

_hourlySteps[_currentHour] += randomSteps;

这是当前源码中最重要的真实边界。更稳妥的方案是让 _hourlySteps 长度为 24,或者把小时映射到 12 小时桶。

十一、小时图修正方案

11.1 使用 24 小时桶

最直接的修正是把列表改成 24 个元素:

final List<int> _hourlySteps = List.filled(24, 0);

柱状图也生成 24 个:

children: List.generate(24, (index) {
  // build bar
})

这样 _currentHour 的 0 到 23 范围就能安全对应列表索引。

11.2 使用 12 小时映射

如果希望保留 12 个柱子,可以映射:

final bucket = DateTime.now().hour % 12;
_hourlySteps[bucket] += randomSteps;

这样不会越界,但上午 9 点和晚上 21 点都会落到同一个桶。

11.3 两种方案对比

方案 优点 边界
24 小时桶 数据语义最清晰 柱子更多,小屏幕更挤
12 小时映射 UI 更紧凑 上午和下午会合并

11.4 推荐方向

如果目标是健康数据统计,建议使用 24 小时桶,因为它更贴近“今天每小时活动量”的真实含义。小屏幕上可以通过横向滚动或更细柱子解决布局问题。

十二、OpenHarmony 适配要点

12.1 适配关注范围

当前项目没有真实传感器插件,因此适配重点集中在 Flutter 前台 UI 和异步刷新。

适配项 涉及源码 验证重点
MaterialApp 根组件 应用启动和主题
AppBar 刷新按钮 图标和点击
CircularProgressIndicator 圆形进度 进度和颜色
Stack 中心叠加 图标、数字、百分比
ElevatedButton Start / Stop 状态切换
Card 统计卡片和活动图 间距、阴影
Future.doWhile 模拟步数 定时刷新
Row 柱状图 Hourly Activity 小屏布局
Material Icons 多个图标 字体资源

12.2 图标资源验证

项目使用了多个 Material Icons:

Icons.refresh
Icons.directions_walk
Icons.accessibility_new
Icons.local_fire_department
Icons.straighten
Icons.flag
Icons.pause
Icons.play_arrow

OpenHarmony 设备上需要确认:

  1. 图标是否可见。
  2. 图标颜色是否正常。
  3. 图标和文本是否对齐。
  4. Material Icons 字体是否随应用打包。

12.3 异步刷新验证

模拟步数依赖:

await Future.delayed(const Duration(seconds: 2));

适配时需要观察:

  • 点击 Start 后步数是否每 2 秒变化。
  • 点击 Stop 后步数是否停止增长。
  • 点击 Reset 后步数是否清零。
  • 页面退出后是否停止 setState。
  • 长时间运行是否稳定。

12.4 布局验证

页面包含三个统计卡片和 12 个柱状条。小屏幕需要重点看:

  • 三个统计卡片是否挤压。
  • Hourly Activity 柱子是否溢出。
  • Start Counting 文案是否超出按钮。
  • 进度圆环和统计卡片之间间距是否合理。

十三、测试与验证

13.1 静态分析

建议先执行:

flutter analyze

重点关注:

  • _hourlySteps[_currentHour] 是否可能越界。
  • 异步循环和 mounted 判断。
  • reset 是否符合预期。
  • 统计数据是否只是估算。

13.2 组件测试方向

可以执行:

flutter test

适合覆盖的行为包括:

  1. 初始显示 0 步。
  2. 初始按钮为 Start Counting
  3. 点击后按钮变为 Stop Counting
  4. 点击刷新后步数归零。
  5. 目标显示为 10000。
  6. 统计卡片显示 Calories、km、Goal。

13.3 示例测试代码

下面是一段测试按钮切换的思路:

testWidgets('toggles counting state', (tester) async {
  await tester.pumpWidget(const StepCounterApp());

  expect(find.text('Start Counting'), findsOneWidget);

  await tester.tap(find.text('Start Counting'));
  await tester.pumpAndSettle();

  expect(find.text('Stop Counting'), findsOneWidget);
});

这段测试验证 _isWalking 与按钮 UI 的绑定关系。

13.4 模拟步数测试思路

可以通过等待时间验证模拟步数增长:

testWidgets('simulated steps increase while counting', (tester) async {
  await tester.pumpWidget(const StepCounterApp());

  await tester.tap(find.text('Start Counting'));
  await tester.pump(const Duration(seconds: 3));

  expect(find.text('0'), findsNothing);
});

如果当前小时大于 11,这个测试会暴露 _hourlySteps 索引问题,因此修正小时桶后再做稳定测试更合适。

13.5 手动验证流程

手动验证可以按如下顺序进行:

  1. 启动应用,确认步数为 0。
  2. 点击 Start Counting,确认按钮变为 Stop Counting
  3. 等待 2 到 4 秒,确认步数增加。
  4. 观察卡路里、距离、百分比是否同步更新。
  5. 点击 Stop Counting,确认步数停止增长。
  6. 点击刷新按钮,确认步数和活动图清零。
  7. 观察 Hourly Activity 柱状图是否按比例变化。
  8. 在 12 点之后运行,重点验证小时索引修正情况。

十四、常见问题与优化建议

14.1 为什么它不是实时传感器计步器

因为当前源码没有接入传感器插件,步数来自模拟逻辑:

final randomSteps = 5 + (DateTime.now().second % 10);

所以它更像一个计步器 UI 和状态流演示项目。

14.2 为什么下午运行可能出错

因为 _currentHour 可能是 12 到 23,而 _hourlySteps 只有 12 个元素:

final List<int> _hourlySteps = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];

访问 _hourlySteps[_currentHour] 时可能越界。推荐改为 24 小时桶。

14.3 为什么 Reset 后还会继续增加步数

因为 _reset() 只清空了数据,没有停止 _isWalking

void _reset() {
  setState(() {
    _steps = 0;
    _hourlySteps.fillRange(0, _hourlySteps.length, 0);
  });
}

如果重置时仍处于计步状态,下一次模拟循环会继续加步数。

14.4 卡路里和距离是否准确

当前卡路里和距离是固定公式估算:

int get _caloriesBurned => (_steps * 0.04).round();
double get _distanceKm => _steps / 1300;

它们适合演示,不适合作为精准健康数据。

14.5 是否需要持久化

当前所有数据都在内存中,应用重启后清零。真实运动类应用通常需要保存:

  • 每日步数。
  • 每小时步数。
  • 历史目标完成情况。
  • 运动记录时间。
  • 用户个性化目标。

十五、工程扩展方向

15.1 接入真实计步传感器

真实计步需要接入平台传感器或健康数据能力。大致流程包括:

  1. 申请运动传感器权限。
  2. 监听步数变化。
  3. 处理应用前后台状态。
  4. 按天保存基准步数。
  5. 计算当天真实步数。

OpenHarmony 上还需要结合平台能力确认传感器 API 和权限模型。

15.2 24 小时活动图

推荐把小时桶改成:

final List<int> _hourlySteps = List.filled(24, 0);

生成柱状图时:

children: List.generate(24, (index) {
  // build bar
})

这样数据语义更准确。

15.3 增加目标设置

当前目标固定为 10000。可以加入目标设置:

void _updateGoal(int goal) {
  setState(() {
    _goal = goal;
  });
}

再通过步进器或滑块调整目标。

15.4 增加历史记录

可以设计每日记录模型:

class DailyStepRecord {
  const DailyStepRecord({
    required this.date,
    required this.steps,
  });

  final DateTime date;
  final int steps;
}

有了模型后,就可以做日、周、月统计。

15.5 增加真实进度图

当前柱状图是手写 Container。如果后续数据变复杂,可以接入图表库,或者自定义 CustomPainter 绘制更精细的图表。

总结

step_counter 用前台模拟的方式实现了一个计步器界面:它通过 _isWalking 控制是否模拟计步,通过 _steps 保存总步数,通过 _goal 计算目标进度,通过 _caloriesBurned_distanceKm 派生统计数据,通过 CircularProgressIndicator 展示目标进度,并使用 RowCardContainer 绘制统计卡片与小时活动柱状图。

从 OpenHarmony 适配角度看,这个项目覆盖了 Material 主题、AppBar、图标字体、圆形进度条、按钮状态切换、异步前台刷新、统计卡片和简易柱状图等基础能力,适合用来验证 Flutter 健康运动类 Dashboard 在 OpenHarmony 上的表现。

当前源码也有几个真实边界:步数来自模拟逻辑,不是传感器;_hourlySteps 只有 12 个元素,但 _currentHour 可能是 0 到 23,存在索引越界风险;Reset 不会停止计步状态;卡路里和距离只是固定公式估算;数据没有持久化。这些边界不影响它作为入门实战项目使用,但在工程化扩展时需要优先处理。

如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!


相关资源:

Logo

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

更多推荐