1. 项目背景与整体设计思路
1.1 为什么在OpenHarmony上选Flutter
先说结论:如果你要在OpenHarmony设备上做一款跨端的生活助手App,Flutter目前是性价比最高的选择,没有之一。
我最初拿到这个项目需求的时候,第一反应是用ArkUI(方舟UI)原生开发。毕竟OpenHarmony是华为系生态的主力,ArkUI是官方一等的开发方式,文档齐全、组件丰富。但紧接着就被现实打脸了——项目要求的不只是跑在OpenHarmony上,后续还要覆盖Android和iOS,团队里熟悉Flutter的人比熟悉ArkUI的人多出一大截,再加上生活助手这类工具型App对UI动态性和图表交互要求并不低,ArkUI虽然能写,但很多生态组件需要自己造轮子。
这时候Flutter for OpenHarmony的优势就体现出来了:得益于OpenHarmony对Flutter引擎的原生适配,Flutter代码可以无损跑在OH设备上,Dart层的UI逻辑、状态管理、动画系统完全复用,底层通过OpenHarmony的Flutter SDK桥接到系统能力。这对一个既要快速交付、又要保持多端一致体验的团队来说,是很务实的路线。
1.2 支出分析模块的架构拆解
生活助手App的功能范围通常包括:账单记录、预算提醒、支出统计、月度报表等。而我这次重点要讲的,是其中最核心也最容易被做砸的模块——支出分析图表。
为什么说容易做砸?因为图表这个功能,表面上只是把数据画出来,但实际上牵扯到三块硬骨头:数据模型的合理性、图表库的选型适配、以及在不同分辨率/不同系统版本上的渲染兼容性。尤其是在OpenHarmony这种“新兴平台”上,很多图表库没有专门适配过,跑起来可能会出现字体渲染异常、Canvas尺寸不刷新、动画掉帧等让人头疼的问题。
我最终的架构分层是这样的:
- 数据层:本地SQLite存储账单记录,通过Repository模式向外提供数据;
- 业务层:统计聚合逻辑独立成纯Dart类,不依赖任何UI,方便测试;
- UI层:Flutter页面负责展示,图表组件独立封装,统一处理加载态、空态和异常态;
- 状态管理:轻量级的Provider,够用且不容易引入心智负担。
这套分层的好处在于:即使后续要换掉图表库,或者把统计逻辑迁移到服务端,改动点都是局部的,不会牵一发动全身。
2. 环境搭建与工程配置
2.1 Flutter for OpenHarmony环境准备
很多人问我说,Flutter for OpenHarmony是不是要用华为自己的IDE?其实不是。开发依然用你熟悉的Flutter工具链,只是需要额外安装OpenHarmony SDK和对应的Flutter SDK分支。
我在实践中的标准环境如下:
- Flutter SDK:使用OpenHarmony适配版(当前稳定版是3.7.x系列,支持ArkTS插件互通);
- OpenHarmony SDK:API 9以上,建议用API 10/11,因为图表渲染涉及Canvas和字体,新版本对Skia引擎的适配更稳;
- IDE组合:VS Code写Dart代码 + DevEco Studio用来构建和运行OH应用包;
- 构建命令:在项目根目录执行
flutter build hap(HAP就是OpenHarmony的应用包格式)。
注意:直接用官方原版Flutter SDK去构建OpenHarmony目标是不行的。必须使用flutter_flutter仓库里带
ohos支持的分支,否则执行flutter devices根本识别不到OpenHarmony设备。
安装完之后,跑一下flutter doctor验证环境,如果显示OpenHarmony toolchain相关的检查项通过,说明环境基本就绪。我第一次配置的时候卡在OH SDK路径没写对,环境变量OHOS_SDK_HOME指到了一个空目录,结果flutter doctor报错报了半天,后来老老实实把SDK路径填到local.properties里才解决。
2.2 工程目录组织与依赖引入
工程结构我习惯按feature来划分,而不是按技术类型划分。这次的核心目录如下:
lib/ ├── main.dart # 入口与全局配置 ├── models/ # 数据模型 │ └── expense_record.dart ├── data/ │ ├── database_helper.dart # SQLite封装 │ └── expense_repository.dart ├── logic/ │ └── expense_statistics.dart # 统计聚合纯逻辑 ├── pages/ │ ├── home_page.dart # 首页(账单列表) │ ├── stats_page.dart # 支出分析页 │ └── widgets/ │ ├── expense_bar_chart.dart │ ├── expense_pie_chart.dart │ └── expense_trend_chart.dart └── state/ └── app_state.dart # Provider状态依赖方面,我实际引入了以下几个关键包:
fl_chart:图表主库,柱状图/折线图/饼图都有;sqflite:SQLite数据库,但注意在OpenHarmony上要用sqflite_ohos这个适配版本;provider:状态管理;intl:日期和货币格式化。
这里特别提醒:sqflite在OH上不能直接用,官方原版依赖了Android的SQLite插件实现,在OpenHarmony上会直接报MissingPluginException。用社区适配的sqflite_ohos即可,API基本一致,只是包名不同。
3. 支出数据的本地存储与模型设计
3.1 数据模型定义
支出分析的基础是数据,数据模型设计不好,后面所有统计逻辑都得返工。我踩过一次坑,最初表结构只存了amount、category、created_at三个字段,结果到了要按月统计的时候发现,没有冗余一个month字段,每次都要用strftime去解析日期,查询效率不高,代码也啰嗦。
最终我稳定下来的表结构是这样的:
CREATE TABLE expense_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, amount REAL NOT NULL, category TEXT NOT NULL, note TEXT, created_at INTEGER NOT NULL, year INTEGER NOT NULL, month INTEGER NOT NULL, day INTEGER NOT NULL );冗余year/month/day三个字段的核心原因:统计SQL可以走索引,不需要对created_at做函数运算。对于账单量级在几万条以内的个人App来说,这种设计足够高效,而且代码可读性更好。
Dart侧的模型类也很简单,重点是要把toMap和fromMap两个转换方法写对:
class ExpenseRecord { final int? id; final double amount; final String category; final String note; final DateTime createdAt; final int year; final int month; final int day; Map<String, dynamic> toMap() { return { 'id': id, 'amount': amount, 'category': category, 'note': note, 'created_at': createdAt.millisecondsSinceEpoch, 'year': year, 'month': month, 'day': day, }; } }3.2 数据库选型与操作封装
在OpenHarmony上做本地存储,选项就三个:Preferences(轻量键值对)、SQLite、分布式数据服务。对于账单这种结构化数据,SQLite是唯一合理的选择。
我封装数据库操作的时候遵循了一个原则:Repository层只暴露业务方法,不暴露SQL。比如:
class ExpenseRepository { Future<List<ExpenseRecord>> getByMonth(int year, int month) async { final db = await DatabaseHelper.instance.database; final result = await db.query( 'expense_records', where: 'year = ? AND month = ?', whereArgs: [year, month], orderBy: 'created_at DESC', ); return result.map((e) => ExpenseRecord.fromMap(e)).toList(); } }这样做的好处是UI层和数据层彻底解耦,后面不管是换数据库还是加缓存层,都不需要改动页面代码。
还有一点经验:在OH设备上,数据库文件路径和Android略有差异。sqflite_ohos会自动处理路径映射,但你如果手动指定getDatabasesPath(),建议打日志确认一下实际路径,避免开发阶段数据库文件找不到,导致每次启动都是空数据。
4. 图表组件的选型与对比
4.1 常见Flutter图表库横向对比
图表库的选择是最耗费我时间的一步。我先后试过fl_chart、charts_flutter、syncfusion_flutter_charts,以及自己用CustomPainter硬画,简单说一下对比结果:
| 图表库 | 维护状态 | OH兼容性 | 包体积影响 | 学习成本 | 定制灵活度 |
|---|---|---|---|---|---|
| fl_chart | 活跃 | 良好(纯Dart绘制) | 较小 | 中等 | 高 |
| charts_flutter | 基本停更 | 一般 | 中等 | 较高 | 中 |
| syncfusion_flutter_charts | 活跃 | 未知(依赖商业SDK) | 较大 | 中等 | 低 |
| CustomPainter自绘 | 自己维护 | 完全可控 | 零依赖 | 高 | 极高 |
charts_flutter最大的问题是项目处于半停滞状态,而且它对异步加载数据时的动画支持并不好,在OH设备上偶发Canvas状态丢失。
syncfusion_flutter_charts虽然功能强大,但它的包体积比较大,而且部分基础功能需要商业授权,对开源项目不友好。
fl_chart的优势在于纯Dart实现、不依赖平台特定代码,因此天然适配OpenHarmony,而且它提供的BarChart、PieChart、LineChart三个组件覆盖了我这次统计页面的所有需求。
4.2 最终选型及原因
最终我选择了fl_chart,并辅以一个自研的轻量工具类来处理图表tooltip和loading态。
选它并不仅仅是因为OH兼容性好,更关键的是它的API设计思路贴近数据展示的本质:你只需要提供数据列表和样式配置,它会自动处理坐标轴、网格线、动画过渡。它的BarChartData和PieChartData都是不可变配置对象,数据更新时直接替换整个对象即可,配合Flutter的widget重建机制,不需要手动刷新Canvas。
经验之谈:如果你只想显示静态报表、不需要复杂交互,fl_chart是很好的选择。但如果你需要类似K线图、金融级的高频数据联动,fl_chart的性能和定制深度会有些吃力,那时候就得考虑用CustomPainter自己来了。
5. 支出分析图表核心实现
5.1 月度支出统计与柱状图绘制
月度支出统计的目标是让用户“一眼看出每个月花了多少钱、哪些月份花得特别多”。我采用柱状图展示最近6个月的支出总额。
数据侧的逻辑很简单,在统计类里加一个聚合方法:
class ExpenseStatistics { static List<MonthlyExpense> aggregateByMonth( List<ExpenseRecord> records, {int monthCount = 6} ) { final result = <MonthlyExpense>[]; final now = DateTime.now(); for (int i = monthCount - 1; i >= 0; i--) { final month = DateTime(now.year, now.month - i); final total = records .where((r) => r.year == month.year && r.month == month.month) .fold(0.0, (sum, r) => sum + r.amount); result.add(MonthlyExpense(year: month.year, month: month.month, total: total)); } return result; } }UI侧使用fl_chart的BarChart:
BarChart( BarChartData( alignment: BarChartAlignment.spaceAround, maxY: _getMaxY(totals), barGroups: List.generate(totals.length, (index) { return BarChartGroupData( x: index, barRods: [ BarChartRodData( toY: totals[index].total, color: _getBarColor(index), width: 18, borderRadius: BorderRadius.circular(4), ), ], ); }), titlesData: FlTitlesData( bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, getTitlesWidget: (value, meta) { final index = value.toInt(); if (index < 0 || index >= totals.length) return const SizedBox.shrink(); return Text('${totals[index].month}月'); }, ), ), leftTitles: const AxisTitles( sideTitles: SideTitles(showTitles: false), ), ), ), )这段代码有几个细节值得说:
第一,maxY不能直接取数据最大值,否则柱子顶到顶格很难看。我一般会把最大值向上取整到最近的整百,再多留20%的空间:
double _getMaxY(List<double> values) { final max = values.reduce((a, b) => a > b ? a : b); return ((max / 100).ceil() * 100) * 1.2; }第二,borderRadius给柱子加圆角是必要的。直角柱状图在视觉上显得生硬,圆角看着舒服很多,而且fl_chart的实现效率不低。
5.2 分类占比与饼图实现
分类占比用饼图(环形图)展示,让用户知道“钱都花去哪了”。
分类体系我建议内置几大类:餐饮、交通、购物、居住、娱乐、医疗、其他。注意这里不要搞太细,太多分类的饼图切得密密麻麻,阅读体验很差。
聚合逻辑:
Map<String, double> aggregateByCategory(List<ExpenseRecord> records) { final result = <String, double>{}; for (final record in records) { result[record.category] = (result[record.category] ?? 0) + record.amount; } return result; }饼图绘制的核心是处理PieChartSectionData列表。fl_chart会按照你传入的数值自动计算占比,但你需要注意两点:
颜色分配:分类颜色要固定,不能每次随数据变化。否则用户几次打开看到的颜色不同,视觉记忆会混乱。我把分类和颜色做成映射表:
const Map<String, Color> categoryColors = { '餐饮': Color(0xFFFF7043), '交通': Color(0xFF42A5F5), '购物': Color(0xFF66BB6A), '居住': Color(0xFFFFA726), '娱乐': Color(0xFFAB47BC), '医疗': Color(0xFFEC407A), '其他': Color(0xFF8D6E63), };中心文字:环形图中间显示“本月支出XXX元”是个很好的做法,但fl_chart本身不直接在中心渲染文字。我的解决方法是使用Stack叠加:底层放PieChart,上层放一个忽略点击的Center文本组件。
Stack( alignment: Alignment.center, children: [ PieChart( PieChartData( sectionsSpace: 2, centerSpaceRadius: 60, sections: sections, ), ), Column( mainAxisSize: MainAxisSize.min, children: [ const Text('本月支出', style: TextStyle(fontSize: 13, color: Colors.grey)), Text('¥${total.toStringAsFixed(0)}', style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold)), ], ), ], )centerSpaceRadius设置得越大,环形越细,中心可放置的文字空间越大。实测值为60到80之间视觉最协调,低于40文字会显得拥挤。
5.3 趋势曲线与滑动交互
除了柱状图和饼图,我还加了一张近30天支出趋势折线图。折线图对“观察消费行为周期性变化”非常有价值,比如周末聚会多、月末购物少之类的规律,一图就能看出来。
fl_chart的折线图API和柱状图略有不同,需要构造FlSpot列表:
LineChart( LineChartData( lineBarsData: [ LineChartBarData( spots: spots, // List<FlSpot> isCurved: true, color: const Color(0xFF42A5F5), barWidth: 2.5, dotData: const FlDotData(show: false), belowBarData: BarAreaData( show: true, color: const Color(0xFF42A5F5).withOpacity(0.15), ), ), ], titlesData: FlTitlesData( leftTitles: const AxisTitles( sideTitles: SideTitles(showTitles: true, reservedSize: 40), ), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, interval: 7, getTitlesWidget: (value, meta) { final day = value.toInt(); if (day % 7 != 0) return const SizedBox.shrink(); return Text('${day}日'); }, ), ), ), lineTouchData: LineTouchData( touchTooltipData: LineTouchTooltipData( getTooltipItems: (touchedSpots) { return touchedSpots.map((spot) { return LineTooltipItem( '${spot.x.toInt()}日 ¥${spot.y.toStringAsFixed(0)}', const TextStyle(color: Colors.white, fontWeight: FontWeight.bold), ); }).toList(); }, ), ), ), )interval: 7的作用是让X轴标签每隔7天显示一次,避免30个标签挤在一起看不清。fl_chart支持自定义getTitlesWidget,这里我做了个简单判断来隐藏非整周的天数标签。
折线图的交互默认自带触摸跟随,用户手指滑动时能看到tooltip显示具体那天的支出金额。这个体验在手机端是很加分的。
6. 常见问题与排查实录
6.1 图表在OpenHarmony设备上的渲染兼容问题
这是我整个项目里最折腾的部分。一起整理一下我遇到过的三类问题。
第一类是文字模糊。同一套图表代码,在Android模拟器上一切正常,跑到OpenHarmony真机上,图表坐标轴文字发虚。排查后发现是OH设备上Flutter引擎默认关闭了文本抗锯齿的部分场景优化。解决方案是在main.dart里手动设置一下ui.ParagraphStyle相关的字体渲染参数,或者在图表外层包一个RepaintBoundary强制独立图层。实测后者立竿见影。
第二类是Canvas尺寸不刷新。页面从竖屏旋转到横屏时,图表区域经常出现右边大片空白或者被裁切。原因是fl_chart内部对尺寸做了缓存,没有响应布局变化。解决办法是给图表组件的父级设置一个LayoutBuilder,在宽度变化时刷新图表数据对象。我这边的代码如下:
LayoutBuilder( builder: (context, constraints) { return BarChart( _buildBarChartData(constraints.maxWidth), ); }, )核心思路是把constraints.maxWidth传入图表数据的构造方法,强制数据对象随尺寸更新。
第三类是动画卡顿。PieChart在首次加载时的入场动画在低端OH设备上偶尔掉帧。我不太确定是不是引擎优化的问题,但定位到了设置项PieChartData.duration和curve之后,把动画时长从默认的150毫秒调到了300毫秒,同时改用Curves.easeOutCubic,体感流畅度有明显提升。
6.2 数据刷新与页面生命周期
另一个高频问题是:用户从记账页新增一笔支出,回到统计页发现图表没有更新。
原因在于我的统计页在initState里只加载了一次数据,后续页面切换时没有重新读取数据库。解决方式有两个,我最后采用的是结合Provider和页面生命周期回调:
在Provider状态类里暴露一个refreshStats()方法,内部重新查询数据库并更新statsData对象。然后在统计页的build阶段监听一个pageVisible状态,每次页面从二级页面返回时触发refreshStats()。
还有一个小坑:首次打开图表页时,数据显示有延迟。这个问题是数据库查询是异步的,图表组件在数据未返回时拿到的空列表,然后因为fl_chart的数据对象没有变化,即使Provider通知了重建,图表也不会刷新。
解决方式是给图表组件设置一个isLoading标志位,数据加载完之前显示加载中占位,把图表组件从widget树中移除,数据到位后再挂载:
if (_isLoading) { return const Center(child: CircularProgressIndicator()); } return BarChart(_buildBarChartData(constraints.maxWidth));这种“先卸载再挂载”的方式虽然简单粗暴,但确实是最稳妥的。比起试图让fl_chart去响应空列表变化,直接重建组件反而省心。
7. 性能优化与细节打磨
7.1 图表渲染性能优化
图表页如果有4到6个图表同时展示,首帧渲染时间会明显变长。我做过的最大优化是懒加载:页面初始只渲染顶部汇总卡片和第一个图表,其他图表区域用一个Visibility组件包住,进入视口后才真正加载。
另外还有两个体积优化的点:
第一,避免不必要的setState。fl_chart的图表数据对象是immutable的,如果你在动画过程中频繁setState,它会对所有配置项做diff,性能消耗较大。我的经验是把多个数据更新合并成一次setState调用,减少重建次数。
第二,用const减少widget重建。图表容器内很多静态组件(如文字标签、图标)都可以用const声明,这样Flutter在重建时会跳过它们的diff计算。别小看这个优化,在低端设备上,const可以省下不少帧时间。
7.2 空状态与加载体验
图表页不能做成“没数据就空白一片”。用户第一次打开App时肯定是没有账单记录的,你给他一个空坐标轴毫无意义。
我做了三个层级的空状态:
- 无任何记录:显示插画和引导文案“点击下方按钮记下第一笔支出吧”,图表区不渲染;
- 当月无记录,但历史有数据:显示提示“本月还没有支出记录,查看历史统计”,并正常展示历史图表;
- 分类数据全为0:饼图中央显示“本月无支出”,避免出现零占比时的奇怪渲染。
后来还加了一个小细节:在柱状图顶部显示当月最高支出月份,并标注“较上月增长x%”。这种对比信息虽然不复杂,但用户感知很强烈,算是用数据讲了一个小故事。
至于加载体验,我在数据库查询耗时超过100ms时显示骨架屏,低于100ms则直接闪现内容,避免闪烁。
7.3 多端适配经验
前面说过我的目标平台不止OpenHarmony,还要兼顾Android和iOS。开发过程中我总结了一些适配经验:
- 字体:数字和货币符号建议用等宽字体,避免金额跳动时宽度不一造成布局抖动;
- 深色模式:图表颜色要专门适配深色背景,否则深色模式下默认颜色的对比度不足。我给颜色定义做了一层theme桥接;
- 屏幕尺寸:柱状图柱宽不要写死,根据屏宽动态计算。我的方案是用
constraints.maxWidth / 柱数 * 0.45作为柱宽,保证平板和手机上都是合理密度; - 手势冲突:如果页面有竖向滚动,和折线图的横向触摸滑动会冲突。我是通过设置
LineTouchData.enabled为 true 但不拦截滚动事件来处理的。
这里还要特意说一下:OpenHarmony设备的默认返回手势和Android不太一样,部分版本是底部上滑式手势导航。如果你的统计页有一个横向滚动的图表,需要注意底部手势区域和图表滑动区域的冲突。这个问题在真机测试前很难发现,建议尽早让目标设备上的用户参与体验测试。
8. 我对这个项目的复盘体会
做Flutter for OpenHarmony的支出分析图表,回头看最值得沉淀的,不是哪个API怎么用,而是在跨端碎片化环境下如何控制认知复杂度。
第一次适配OpenHarmony的时候,心理预期是要处理很多平台差异,但实际上OpenHarmony走的是兼容Flutter和Android API的路子,大部分问题都能在前人踩过的坑里找到答案。真正花费时间的,反而是那些看似琐碎却直接决定体验的细节——图表刷新时机、空状态文案、加载与交互的过渡动画。
另外推荐一个调试小技巧:用debugPaintSizeEnabled打开Flutter的布局调试模式,在OH真机上查看图表组件的边界。像素级对不齐的问题,在这个模式下几乎是一目了然。
最后留个扩展思路:如果后续想增加预算提醒功能,可以在现有的支出模型上加一个budget_plans表,用同一套统计逻辑去计算“预算-实际支出”的差额,图表上直接叠加一条预算线。这样既扩了功能,又没有破坏原有的架构。项目的路就是这样一步步走宽的,关键是底层设计不要给自己挖坑。