Flutter for OpenHarmony 实战:体重变化图表 - 记录体重变化
在移动开发领域,我们总是面临着选择与适配。今天,你的Flutter应用在Android和iOS上跑得正欢,明天可能就需要考虑一个新的平台:HarmonyOS(鸿蒙)。这不是一道选答题,而是很多团队正在面对的现实。Flutter的优势很明确——写一套代码,就能在两个主要平台上运行,开发体验流畅。而鸿蒙代表的是下一个时代的互联生态,它不仅仅是手机系统,更着眼于未来全场景的体验。
欢迎加入开源鸿蒙跨平台社区: https://openharmonycrossplatform.csdn.net
前言:跨生态开发的新机遇
在移动开发领域,我们总是面临着选择与适配。今天,你的Flutter应用在Android和iOS上跑得正欢,明天可能就需要考虑一个新的平台:HarmonyOS(鸿蒙)。这不是一道选答题,而是很多团队正在面对的现实。
Flutter的优势很明确——写一套代码,就能在两个主要平台上运行,开发体验流畅。而鸿蒙代表的是下一个时代的互联生态,它不仅仅是手机系统,更着眼于未来全场景的体验。将现有的Flutter应用适配到鸿蒙,听起来像是一个“跨界”任务,但它本质上是一次有价值的技术拓展:让产品触达更多用户,也让技术栈覆盖更广。
不过,这条路走起来并不像听起来那么简单。Flutter和鸿蒙,从底层的架构到上层的工具链,都有着各自的设计逻辑。会遇到一些具体的问题:代码如何组织?原有的功能在鸿蒙上如何实现?那些平台特有的能力该怎么调用?更实际的是,从编译打包到上架部署,整个流程都需要重新摸索。
这篇文章想做的,就是把这些我们趟过的路、踩过的坑,清晰地摊开给你看。我们不会只停留在“怎么做”,还会聊到“为什么得这么做”,以及“如果出了问题该往哪想”。这更像是一份实战笔记,源自真实的项目经验,聚焦于那些真正卡住过我们的环节。
无论你是在为一个成熟产品寻找新的落地平台,还是从一开始就希望构建能面向多端的应用,这里的思路和解决方案都能提供直接的参考。理解了两套体系之间的异同,掌握了关键的衔接技术,不仅能完成这次迁移,更能积累起应对未来技术变化的能力。
混合工程结构深度解析
项目目录架构
当Flutter项目集成鸿蒙支持后,典型的项目结构会发生显著变化。以下是经过ohos_flutter插件初始化后的项目结构:
my_flutter_harmony_app/
├── lib/ # Flutter业务代码(基本不变)
│ ├── main.dart # 应用入口
│ ├── home_page.dart # 首页
│ └── utils/
│ └── platform_utils.dart # 平台工具类
├── pubspec.yaml # Flutter依赖配置
├── ohos/ # 鸿蒙原生层(核心适配区)
│ ├── entry/ # 主模块
│ │ └── src/main/
│ │ ├── ets/ # ArkTS代码
│ │ │ ├── MainAbility/
│ │ │ │ ├── MainAbility.ts # 主Ability
│ │ │ │ └── MainAbilityContext.ts
│ │ │ └── pages/
│ │ │ ├── Index.ets # 主页面
│ │ │ └── Splash.ets # 启动页
│ │ ├── resources/ # 鸿蒙资源文件
│ │ │ ├── base/
│ │ │ │ ├── element/ # 字符串等
│ │ │ │ ├── media/ # 图片资源
│ │ │ │ └── profile/ # 配置文件
│ │ │ └── en_US/ # 英文资源
│ │ └── config.json # 应用核心配置
│ ├── ohos_test/ # 测试模块
│ ├── build-profile.json5 # 构建配置
│ └── oh-package.json5 # 鸿蒙依赖管理
└── README.md
展示效果图片
flutter 实时预览 效果展示
运行到鸿蒙虚拟设备中效果展示
目录
功能代码实现
WeightChart 主组件
WeightChart 是一个 StatefulWidget,负责管理体重记录数据、计算统计信息、处理用户交互以及布局展示。
核心功能实现
1. 数据管理
// 体重记录数据
List<Map<String, dynamic>> weightRecords = [
{'date': '1月1日', 'weight': 65.5},
{'date': '1月2日', 'weight': 65.2},
{'date': '1月3日', 'weight': 64.8},
{'date': '1月4日', 'weight': 64.5},
{'date': '1月5日', 'weight': 64.2},
{'date': '1月6日', 'weight': 63.8},
{'date': '1月7日', 'weight': 63.5},
];
double newWeight = 63.0;
bool showAddForm = false;
bool showDetails = false;
int selectedIndex = -1;
2. 数据计算方法
// 添加新体重记录
void addWeightRecord() {
setState(() {
String today = '1月${weightRecords.length + 1}日';
weightRecords.add({
'date': today,
'weight': newWeight,
});
showAddForm = false;
newWeight = 63.0;
});
}
// 计算体重变化趋势
double calculateTrend() {
if (weightRecords.length < 2) return 0;
double firstWeight = weightRecords[0]['weight'];
double lastWeight = weightRecords[weightRecords.length - 1]['weight'];
return lastWeight - firstWeight;
}
// 计算平均体重
double calculateAverageWeight() {
if (weightRecords.isEmpty) return 0;
double sum = 0;
for (var record in weightRecords) {
sum += record['weight'];
}
return sum / weightRecords.length;
}
// 计算最大和最小体重
Map<String, double> calculateMinMaxWeight() {
if (weightRecords.isEmpty) return {'min': 0, 'max': 0};
double min = weightRecords[0]['weight'];
double max = weightRecords[0]['weight'];
for (var record in weightRecords) {
if (record['weight'] < min) min = record['weight'];
if (record['weight'] > max) max = record['weight'];
}
return {'min': min, 'max': max};
}
// 选择图表上的点
void selectPoint(int index) {
setState(() {
selectedIndex = index;
showDetails = true;
});
}
3. 布局结构
Widget build(BuildContext context) {
double trend = calculateTrend();
double averageWeight = calculateAverageWeight();
Map<String, double> minMax = calculateMinMaxWeight();
return Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// 标题
Text(
'体重变化图表',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
// 体重统计信息
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey[50],
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.grey[200]!,
width: 2,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// 当前体重
Column(
children: [
Text(
'当前体重',
style: Theme.of(context).textTheme.bodySmall,
),
Text(
'${weightRecords.isNotEmpty ? weightRecords.last['weight'] : 0} kg',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
// 变化趋势
Column(
children: [
Text(
'变化趋势',
style: Theme.of(context).textTheme.bodySmall,
),
Text(
'${trend > 0 ? '+' : ''}$trend kg',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: trend < 0 ? Colors.green : Colors.red,
fontWeight: FontWeight.bold,
),
),
],
),
// 平均体重
Column(
children: [
Text(
'平均体重',
style: Theme.of(context).textTheme.bodySmall,
),
Text(
'${averageWeight.toStringAsFixed(1)} kg',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
],
),
),
const SizedBox(height: 32),
// 体重图表
Container(
height: 300,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.1),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Stack(
children: [
// 网格背景
Container(
child: Column(
children: List.generate(6, (index) {
return Expanded(
child: Row(
children: List.generate(8, (colIndex) {
return Expanded(
child: Container(
decoration: BoxDecoration(
border: Border(
right: colIndex < 7
? BorderSide(color: Colors.grey[100]!)
: BorderSide.none,
bottom: index < 5
? BorderSide(color: Colors.grey[100]!)
: BorderSide.none,
),
),
),
);
}),
),
);
}),
),
),
// 体重曲线
if (weightRecords.isNotEmpty)
CustomPaint(
painter: WeightChartPainter(
weightRecords: weightRecords,
minMax: minMax,
selectedIndex: selectedIndex,
),
child: Container(),
),
// 图表标签
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: weightRecords.asMap().entries.map((entry) {
int index = entry.key;
var record = entry.value;
return GestureDetector(
onTap: () => selectPoint(index),
child: Container(
padding: const EdgeInsets.all(4),
child: Text(
record['date'],
style: Theme.of(context).textTheme.bodySmall,
),
),
);
}).toList(),
),
),
],
),
),
// 详细信息
if (showDetails && selectedIndex >= 0 && selectedIndex < weightRecords.length)
Container(
margin: const EdgeInsets.only(top: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
),
),
child: Column(
children: [
Text(
'详细信息',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'日期: ${weightRecords[selectedIndex]['date']}',
style: Theme.of(context).textTheme.bodyMedium,
),
Text(
'体重: ${weightRecords[selectedIndex]['weight']} kg',
style: Theme.of(context).textTheme.bodyMedium,
),
if (selectedIndex > 0)
Text(
'变化: ${(weightRecords[selectedIndex]['weight'] - weightRecords[selectedIndex - 1]['weight'] > 0 ? '+' : '')}${(weightRecords[selectedIndex]['weight'] - weightRecords[selectedIndex - 1]['weight']).toStringAsFixed(1)} kg',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: weightRecords[selectedIndex]['weight'] < weightRecords[selectedIndex - 1]['weight']
? Colors.green
: Colors.red,
),
),
],
),
),
const SizedBox(height: 32),
// 添加记录按钮
ElevatedButton(
onPressed: () {
setState(() {
showAddForm = !showAddForm;
});
},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
backgroundColor: Theme.of(context).colorScheme.primary,
),
child: Text(
showAddForm ? '取消' : '添加记录',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
// 添加记录表单
if (showAddForm)
Container(
margin: const EdgeInsets.only(top: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.1),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [
Text(
'添加新体重记录',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
TextField(
keyboardType: TextInputType.number,
onChanged: (value) {
setState(() {
newWeight = double.tryParse(value) ?? 63.0;
});
},
decoration: InputDecoration(
labelText: '体重 (kg)',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: Colors.grey[50],
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: addWeightRecord,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
backgroundColor: Theme.of(context).colorScheme.secondary,
),
child: Text(
'保存记录',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
],
),
);
}
开发注意事项
-
状态管理:使用
setState()管理组件状态,确保数据变化时 UI 能够及时更新。 -
数据验证:在添加新体重记录时,使用
double.tryParse()进行数据验证,避免无效输入导致应用崩溃。 -
响应式布局:使用
Theme.of(context)获取主题样式,确保组件在不同主题下都能正常显示。 -
用户交互:通过
GestureDetector实现图表点的点击交互,提升用户体验。
WeightChartPainter 自定义绘制器
WeightChartPainter 是一个 CustomPainter,负责绘制体重变化曲线和数据点。
核心实现
class WeightChartPainter extends CustomPainter {
final List<Map<String, dynamic>> weightRecords;
final Map<String, double> minMax;
final int selectedIndex;
WeightChartPainter({
required this.weightRecords,
required this.minMax,
required this.selectedIndex,
});
void paint(Canvas canvas, Size size) {
if (weightRecords.isEmpty) return;
double minWeight = minMax['min'] ?? 0;
double maxWeight = minMax['max'] ?? 0;
double weightRange = maxWeight - minWeight;
if (weightRange == 0) weightRange = 1;
// 绘制曲线
Paint linePaint = Paint()
..color = Colors.blue
..strokeWidth = 3
..style = PaintingStyle.stroke;
// 计算点的位置
List<Offset> points = [];
for (int i = 0; i < weightRecords.length; i++) {
double x = (i / (weightRecords.length - 1)) * (size.width - 40) + 20;
double y = size.height - 40 - ((weightRecords[i]['weight'] - minWeight) / weightRange) * (size.height - 80);
points.add(Offset(x, y));
}
// 绘制曲线
Path path = Path();
path.moveTo(points[0].dx, points[0].dy);
for (int i = 1; i < points.length; i++) {
path.lineTo(points[i].dx, points[i].dy);
}
canvas.drawPath(path, linePaint);
// 绘制点
for (int i = 0; i < points.length; i++) {
Paint pointPaint = Paint()
..color = i == selectedIndex ? Colors.red : Colors.blue
..style = PaintingStyle.fill;
canvas.drawCircle(points[i], i == selectedIndex ? 6 : 4, pointPaint);
// 绘制选中点的连接线
if (i == selectedIndex) {
Paint linePaint = Paint()
..color = Colors.red
..strokeWidth = 1
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..blendMode = BlendMode.srcOver
..maskFilter = MaskFilter.blur(BlurStyle.normal, 1.0);
canvas.drawLine(points[i], Offset(points[i].dx, size.height - 40), linePaint);
}
}
}
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
}
开发注意事项
-
坐标计算:确保图表点的坐标计算准确,特别是在不同屏幕尺寸下的适配。
-
性能优化:在
shouldRepaint()方法中返回true,确保数据变化时图表能够重新绘制。 -
视觉效果:通过不同的颜色和大小区分选中点和普通点,提升用户体验。
-
边界处理:当
weightRange为 0 时,设置默认值 1,避免除以零的错误。
组件使用方法
在首页直接使用
import 'package:flutter/material.dart';
import 'components/weight_chart.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: '体重变化图表',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(title: '体重变化图表'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: const SingleChildScrollView(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const WeightChart(),
],
),
),
),
);
}
}
使用步骤
-
导入组件:在需要使用的文件中导入
weight_chart.dart。 -
添加到布局:将
WeightChart组件添加到页面布局中。 -
数据管理:组件内部已经实现了数据管理逻辑,无需外部传入数据。
-
交互操作:
- 点击图表下方的日期标签查看详细信息
- 点击 “添加记录” 按钮添加新的体重记录
- 在表单中输入体重值,点击 “保存记录” 完成添加
本次开发中容易遇到的问题
1. 图表绘制问题
问题描述:在绘制体重变化曲线时,可能会出现曲线不连续或点位置计算错误的情况。
解决方案:
- 确保
weightRecords不为空再进行绘制 - 正确计算每个点的坐标,特别是 x 轴和 y 轴的比例
- 当体重范围为 0 时,设置默认值 1,避免除以零的错误
2. 状态管理问题
问题描述:添加新体重记录后,UI 可能不会及时更新,或者状态管理混乱导致应用崩溃。
解决方案:
- 所有状态变化都通过
setState()方法进行管理 - 确保状态变量的初始化和更新逻辑正确
- 在添加记录后,重置表单状态和输入值
3. 用户交互问题
问题描述:点击图表点时,可能不会显示详细信息,或者点击区域不准确。
解决方案:
- 使用
GestureDetector包裹每个日期标签,确保点击事件能够正确触发 - 确保
selectedIndex的值在有效范围内 - 点击事件处理函数中,正确更新
selectedIndex和showDetails状态
4. 数据验证问题
问题描述:用户输入无效的体重值时,可能会导致应用崩溃或数据错误。
解决方案:
- 使用
double.tryParse()进行数据验证,避免无效输入 - 设置默认体重值,确保即使输入无效也能正常添加记录
- 在 UI 中添加适当的提示,引导用户输入有效的体重值
5. 布局适配问题
问题描述:在不同屏幕尺寸下,图表可能会显示不全或布局混乱。
解决方案:
- 使用
Container的padding和margin控制组件间距 - 使用
Expanded和Flexible组件实现灵活布局 - 确保图表容器有固定的高度,避免内容溢出
总结本次开发中用到的技术点
1. Flutter 基础组件
- StatefulWidget:用于管理有状态的组件,如体重记录数据和表单状态。
- StatelessWidget:用于构建无状态的 UI 组件,如标题和统计信息。
- Container:用于布局和样式控制,提供 padding、margin、decoration 等属性。
- Column 和 Row:用于垂直和水平布局,实现灵活的 UI 结构。
- Text:用于显示文本信息,支持不同的样式和主题。
- ElevatedButton:用于添加交互按钮,支持自定义样式和点击事件。
- TextField:用于用户输入,支持键盘类型设置和输入验证。
- GestureDetector:用于实现点击、滑动等手势交互。
- Stack:用于层叠布局,实现图表和网格背景的叠加显示。
- Positioned:用于在 Stack 中精确定位子组件。
- SingleChildScrollView:用于处理内容溢出,确保在小屏幕上也能正常显示。
2. 自定义绘制
- CustomPaint:用于绘制自定义图形,如体重变化曲线。
- CustomPainter:用于实现具体的绘制逻辑,控制画笔和路径。
- Canvas:提供绘制操作的画布,支持绘制线条、圆形、路径等。
- Paint:用于设置画笔的颜色、宽度、样式等属性。
- Path:用于定义绘制路径,实现曲线和形状的绘制。
3. 状态管理
- setState():用于更新组件状态,触发 UI 重新构建。
- 状态变量:用于存储组件的状态信息,如体重记录、表单状态、选中索引等。
4. 数据处理
- List 操作:用于管理体重记录数据,如添加新记录和遍历计算。
- Map 操作:用于存储和访问体重记录的日期和体重值。
- 数学计算:用于计算体重变化趋势、平均体重、最大最小体重等统计信息。
- 数据验证:用于验证用户输入的体重值,确保数据的有效性。
5. 主题和样式
- Theme.of(context):用于获取应用的主题样式,确保组件在不同主题下的一致性。
- ColorScheme:用于获取主题的颜色方案,实现与系统主题的适配。
- BoxDecoration:用于设置容器的背景、边框、阴影等样式。
- BorderRadius:用于设置圆角,提升 UI 的视觉效果。
- BoxShadow:用于添加阴影效果,增强 UI 的层次感。
6. 响应式设计
- 自适应布局:使用相对单位和弹性布局,确保组件在不同屏幕尺寸下都能正常显示。
- 主题适配:通过
Theme.of(context)获取主题样式,确保组件在不同主题下的一致性。 - 内容溢出处理:使用
SingleChildScrollView处理内容溢出,确保在小屏幕上也能正常显示。
7. 用户体验
- 交互反馈:通过颜色变化、动画效果等提供即时的交互反馈。
- 错误处理:通过数据验证和默认值设置,避免用户操作导致的错误。
- 视觉层次:通过布局、颜色、阴影等元素,创建清晰的视觉层次,提升用户体验。
- 信息展示:通过图表、统计信息、详细信息等多种形式,直观展示体重变化数据。
欢迎加入开源鸿蒙跨平台社区: https://openharmonycrossplatform.csdn.net
更多推荐
所有评论(0)