拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

OpenHarmony上Flutter图表开发实战:支出分析模块架构与性能优化

OpenHarmony上Flutter图表开发实战:支出分析模块架构与性能优化

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表,用同一套统计逻辑去计算“预算-实际支出”的差额,图表上直接叠加一条预算线。这样既扩了功能,又没有破坏原有的架构。项目的路就是这样一步步走宽的,关键是底层设计不要给自己挖坑。

返回列表