1. 项目背景与整体设计思路
跑过Flutter在多个平台之间来回横跳的人,大概率都体会过一种矛盾心态:跨端框架最值钱的能力其实不是“写一次跑到处”这句口号,而是让你能把有限的精力从底层适配里抽出来,专心去磨业务细节和交互手感。可一旦目标平台变成OpenHarmony这种相对较新的系统生态,很多以前默认成立的经验都得重新拿回来验一遍。这篇文章就聊一个非常具体的场景——个人理财管理App里的交易详情页面,基于Flutter for OpenHarmony从环境准备、数据建模、页面实现到图表绘制、主题适配和问题排查的完整过程。适合正在评估Flutter在OpenHarmony上的开发效率、或者准备把现有Flutter工程迁到OpenHarmony设备上的朋友参考。
我之所以选这个页面作为切入点,是因为交易详情页是理财类App里信息密度最高、也最能检验UI功底的一块硬骨头。金额、交易状态、分类图标、时间、渠道、订单号、备注、分类占比图全都要塞进一屏,还要保证深浅色模式下都看得清、列表返回时数据不出错。把这个页面啃下来,整个App其他页面的开发节奏基本就顺了。
1.1 为什么选择Flutter + OpenHarmony组合
先说为什么Flutter能在OpenHarmony上跑。Flutter不是依赖系统原生控件做拼装的UI框架,它的引擎自己完成布局、绘制、事件处理,只通过一层适配层对接操作系统的图形栈、窗口管理和系统能力。OpenHarmony提供了完整的图形与事件子系统,Flutter在这套系统上做适配,从原理上是走得通的。实际开发中,UI代码和业务逻辑确实可以跨平台复用,这一点对个人开发者或小团队尤其友好。
再从理财产品本身的特点看。理财管理App高度依赖自定义图表、账单卡片、状态标签和流畅的列表滚动,这些场景恰好是Flutter自绘引擎擅长的部分。如果使用系统原生方案开发,很多视觉元素要从零封装,而Flutter内置的布局系统、动画机制和Material组件能省掉大量重复工作。所以对我来说,这套组合不是“炫技”,是实打实的投入产出比选择。
1.2 个人理财App的功能地图与详情页定位
把整个App的功能摊开看,大致可以分成四块:账户总览、交易流水、分类统计和预算管理。账户总览页展示所有账户的资产总额和本月收支概览;交易流水页是按时间倒序排列的收支列表,每一条记录都能点击进入详情;分类统计页用图表展示各分类的支出占比;预算管理页则给每个分类设置月度限额。这四块功能都有一个共同的底层依赖:一笔结构清晰的交易数据。
交易详情页在中间扮演的角色类似“承重墙”。它上接交易流水列表,下接编辑、删除、导出等操作链路,同时还要完整呈现一笔交易的全部上下文。用户点进来的心理预期是搞清楚三个问题:这笔钱花在哪、什么时候花的、花得是否合理。页面设计必须围绕这三个问题来组织信息层级,而不是简单地把字段罗列出来。
1.3 技术选型上的取舍思路
我在选型时给自己定了一条硬规则:能减少不可控依赖的方案优先。在OpenHarmony生态里,许多pub.dev上的第三方插件还没有完成适配验证,运行时可能因为平台通道差异直接闪退。所以状态管理我选择了对运行时依赖极小的方案,直接用Flutter自带的StatefulWidget管理页面状态,只在需要跨页面共享账户信息时使用轻量的Provide模式。本地存储先用JSON文件模拟,将来需要再无缝切换到数据库方案。图表部分不引入第三方图表库,用CustomPainter自绘饼图,这部分后面会详细展开。这种偏保守的选型思路,让我在排查问题时能把变量控制在最小范围。
2. 环境准备与工程初始化
2.1 打地基:开发环境清单
在写任何页面前,得先把OpenHarmony侧的构建链路跑通。我当时的开发环境由四部分组成:一台支持OpenHarmony的真机、对应版本的OpenHarmony SDK、适配OpenHarmony的Flutter SDK,以及构建HAP包所需的工具链。最关键的坑是Flutter SDK不能直接用官网release版本,必须换成适配OpenHarmony的社区构建版,安装后通过flutter doctor确认设备能被识别。
我强烈建议先跑通官方自带的example工程,再开始写自己的业务代码。这一步能快速暴露三类问题:SDK版本不匹配、设备连接异常、构建配置缺失。很多人一上来就直接创建新工程,结果第一遍构建失败后分不清是环境问题还是代码问题,非常浪费时间。确认example能在真机上跑起来,相当于给你后面的排查省掉一整类可能性。
2.2 创建工程与目录规划
工程创建本身还是用Flutter的标准命令,但构建命令和产物会不一样。大致流程是:先按常规方式创建工程,然后切换到OpenHarmony适配SDK的构建脚本,生成HAP格式的安装包。目录结构我做了精细划分,让页面、模型、组件和工具类各归其位:
lib/ ├── app.dart ├── models/ │ └── transaction_model.dart ├── pages/ │ ├── home_page.dart │ ├── transaction_list_page.dart │ └── transaction_detail_page.dart ├── widgets/ │ ├── amount_card.dart │ ├── status_tag.dart │ ├── info_item.dart │ └── category_pie_chart.dart └── utils/ └── formatter_util.dart这样的划分有两个好处:一个是页面和组件解耦,交易详情页里的金额卡片、状态标签、分类饼图都能被其他页面复用;另一个是模型独立成文件后,后续接入数据库或API时只需要改模型内部实现,不用动UI层。我在开发中还养成了一个习惯:每个目录都塞一个README文件,记录这个模块里踩过的坑和选型理由,时间长了回头看特别有价值。
2.3 与标准Flutter工程的关键差异
用惯了标准Flutter工程的人,在切换OpenHarmony目标平台时会遇到几个明显差异点,我用一张表归纳如下:
| 对比项 | 标准Flutter工程 | Flutter for OpenHarmony |
|---|---|---|
| 构建产物 | APK / AAB | HAP |
| 原生能力访问 | 通过Android/iOS平台通道 | 需要适配层的平台通道或系统扩展 |
| 第三方插件 | pub.dev可直接集成 | 部分插件需要寻找适配版本或自行实现 |
| 调试工具 | 浏览器DevTools + 设备日志 | 兼容DevTools,但设备日志查看方式不同 |
| 系统API调用 | Android/iOS SDK | OpenHarmony SDK |
这个差异表直接影响交易详情页的代码写法。最典型的例子是剪贴板复制功能:在标准工程里调用Clipboard.setData就完事了,但在OpenHarmony适配版上要确认剪贴板通道是否可用,不可用就得改用系统能力扩展来实现。我在开发中吃了好几次这种“同名方法不同表现”的亏,所以后来养成一个习惯:凡是涉及平台能力的调用,先写一个工具类封装,后续适配时只需要改一处。
3. 数据模型与路由设计
3.1 交易数据模型:字段怎么定才不返工
交易详情页是所有页面里字段需求最完整的,所以数据模型必须一次设计到位,否则后面列表页、统计页都要跟着改。我的TransactionModel包含这些字段:交易ID、交易类型(收入/支出)、金额、分类名称、分类图标、交易时间、交易状态、商户名称、交易账户、交易单号、备注。核心代码长这样:
enum TransactionType { income, expense } enum TransactionStatus { success, pending, failed } class TransactionModel { final String id; final TransactionType type; final int amount; // 单位为分,避免浮点误差 final String category; final String categoryIcon; final DateTime time; final TransactionStatus status; final String merchant; final String account; final String tradeNo; final String remark; const TransactionModel({ required this.id, required this.type, required this.amount, required this.category, required this.categoryIcon, required this.time, required this.status, required this.merchant, required this.account, required this.tradeNo, required this.remark, }); factory TransactionModel.fromJson(Map<String, dynamic> json) { return TransactionModel( id: json['id'] as String, type: json['type'] == 'income' ? TransactionType.income : TransactionType.expense, amount: json['amount'] as int, category: json['category'] as String, categoryIcon: json['categoryIcon'] as String, time: DateTime.fromMillisecondsSinceEpoch(json['time'] as int), status: TransactionStatus.values[json['status'] as int], merchant: json['merchant'] as String, account: json['account'] as String, tradeNo: json['tradeNo'] as String, remark: json['remark'] as String, ); } }这里有个非常关键的细节:金额字段我用的int类型,单位是分。直接使用double存储金额,在多次加减和比较时会出现精度漂移,对理财应用来说这是不可接受的。把金额转成分存整数,展示时才做格式化,是财务类应用的标准做法。我见过不少新手在这个字段上栽跟头,所以多说一句:记账类数据,永远不要用浮点数做主存储。
3.2 从列表跳到详情:路由传参的取舍
交易详情页通常是从流水列表点击进入的,这里有两种传参方式:直接把TransactionModel对象整个传过去,或者只传交易ID让详情页自己查询。我选择了后者。为什么?因为列表页数据可能来自缓存、可能经过筛选排序,直接传引用会让详情页和列表页的状态耦合;而传ID的方式让详情页每次加载都走统一的数据获取逻辑,后续接入数据库或API时改动面更小。
路由跳转代码非常简单:
Navigator.push( context, MaterialPageRoute( builder: (context) => TransactionDetailPage(transactionId: model.id), ), );在详情页的initState里,通过TransactionRepository根据ID获取完整数据。开发阶段Repository内部返回的是Mock数据,将来只需要替换Repository实现,页面代码完全不用动。这样做还有一层额外好处:如果App将来要做深链接、消息推送跳转,只需要传交易ID就能打开对应详情页,不需要构造完整对象。
3.3 金额与时间的格式化处理
交易详情页的金额展示不只是简单加个小数点。我的FormatterUtil里实现了三个方法:千分位格式化成标准金额字符串、根据时间距离今天的天数显示“今天 14:32”或“昨天 09:15”或完整日期、把状态枚举转成对应的中文文案。这些逻辑看似不起眼,却是用户判断信息效率的关键。
有一个值得分享的小细节:日期显示不是越全越好。交易时间是当天时,显示“今天 14:32”比“2025年1月15日 14:32”更直观;时间超过一周才显示完整年月日。这套规则在交易列表和详情页保持一致,用户就不会产生认知割裂。格式化的逻辑要集中放在工具类里,不要在Widget里到处copy,否则忘记统一规则的时候非常难看。
4. 交易详情页面的UI实现
4.1 页面骨架:一屏怎么装下这么多信息
交易详情页的信息量很大,如果全部堆在屏幕上一屏显示不下,就必须分清主次和滚动关系。我的布局方案是:固定的顶部栏 + 可滚动内容区 + 固定的底部操作栏。内容区从上到下分别是金额卡片、状态与分类行、分类占比图表、信息明细区、备注区。
用Column套Expanded再加ListView.builder的组合,让页面在内容不足时也能正常显示,内容超长时可以滚动且底部操作按钮始终可见。这种做法比整页套SingleChildScrollView更规范,因为它不会在键盘弹出时产生奇怪的布局行为,也不会让底部按钮跟着内容一起滚出屏幕。页面骨架的核心代码:
Scaffold( appBar: AppBar(title: const Text('交易详情')), body: Column( children: [ Expanded( child: ListView( padding: const EdgeInsets.all(16), children: [ AmountCard(model: _model), const SizedBox(height: 16), Row( children: [ StatusTag(status: _model.status), const Spacer(), CategoryChip(icon: _model.categoryIcon, name: _model.category), ], ), const SizedBox(height: 16), CategoryPieChart(expenseByCategory: _expenseByCategory), const SizedBox(height: 16), _buildInfoSection(), if (_model.remark.isNotEmpty) _buildRemarkSection(), ], ), ), _buildBottomBar(), ], ), )4.2 金额卡片:全页视觉锚点
金额是交易详情页最核心的信息,视觉上必须第一时间抓住用户注意力。我的做法是把金额放进一张圆角卡片里,使用超大字号显示,同时用颜色区分收支:收入用绿色系,支出用主题色系里的警示色。卡片背景采用极浅的色调渐变,让金额区域在页面中自然突出。
为了让页面不显得死板,我给金额文字包了一层AnimatedSwitcher,加载数据时数字带有轻微的上移动画。这个动画的实现很轻量,但用户能明显感受到页面有“活”的反馈。卡片内部还加了一行小字展示实际到账账户,比如“余额宝”或“招商银行(尾号1234)”,让用户第一眼就确认金额和账户对应得上,减少信息混淆风险。
4.3 状态标签与分类标识
交易状态用独立标签组件展示,三种状态三种视觉:成功用绿色背景加对勾图标,处理中用橙色背景加时钟图标,失败用红色背景加感叹号图标。这里我踩了一个坑:在OpenHarmony适配版上部分Material图标在低渲染模式下显示为方块。解决方法是把状态图标优先使用基础图形,比如圆点、对勾这类通用性强的图形,避免使用过于复杂的矢量图标。
分类标识我本来想用emoji表示,但实际测试发现OpenHarmony设备上的emoji字体不全,很多图标直接渲染成方框,非常影响页面质感。后来改成用一个小的圆形容器加上分类名称首字,比如“餐”配浅橙色背景,“行”配浅蓝色背景。这个方案兼容性好、辨识度高,还不用额外引入图片资源。这个改动给我提了个醒:在系统生态尚未完全成熟时,优先使用最基础可靠的渲染方式,视觉享受让位于稳定呈现。
4.4 信息明细区:细节决定体验
信息明细区采用分组卡片形式,每组用标题分隔,里面是标签和值的组合。交易单号这类长文本,我增加了点击复制功能,复制成功后通过SnackBar提示用户。时间字段显示完整的“2025-01-15 14:32:08”,并同步展示距离现在的相对时间。商户字段和备注字段分开展示,备注为空时整段隐藏,避免页面出现大量空白区。
每一个明细项都封装成独立的InfoItem组件,左侧固定宽度的灰色标签,右侧右对齐的黑色或主题色值。这样做的好处是字段顺序调整成本极低,加字段只需要在数据源里加一个配置条目。支付方式和交易账户之间还加入了一条交互:点击后跳转到该账户的收支列表。这个功能是我后来补的,但使用频率很高,因为用户的习惯就是看到一笔交易后想看看这个账户最近都发生了哪些收支。
4.5 底部操作链:编辑、删除、复制
底部操作栏放三个按钮:复制详情、编辑交易、删除交易。复制详情和编辑是主要操作,用主题色填充按钮样式;删除是次要操作,用文字按钮样式并居右显示。
删除操作必须加二次确认Dialog,这是记账类应用的底线要求。确认删除后,我通过调用Repository的delete方法删除数据,然后Navigator.pop返回列表页,并携带一个“需要刷新”的结果标记。这个标记在列表页接收后触发重新加载逻辑,保证用户回到列表时数据已经同步。编辑操作则跳转到编辑页面,保存成功后同样带着刷新标记返回。这里要特别注意:返回后刷新不能写在initState里,因为页面实例在栈里可能已经被回收,正确的做法是在await push返回后判断结果再刷新。
5. 分类图表实现:用CustomPainter画饼图
5.1 图表方案:自绘还是引库
交易详情页里我加了一个当月该分类下支出占比的饼图,用来回答“这笔钱占了这个分类的多大比例”。做图表时有两条路:一条是引入第三方图表库,另一条是用CustomPainter自己画。我在选型时考虑到OpenHarmony生态的第三方库兼容性还没有被广泛验证,引入一个重型的图表库可能会在运行期踩到渲染层的兼容性坑。饼图虽然看起来唬人,本质不过是一堆扇形,自己画完全没有难度,还能完全控制配色和交互。
但如果是复杂的折线图或K线图,我不建议自己造轮子,因为涉及坐标轴计算、缩放平移、手势冲突等大量边界问题。我的原则是简单图表自绘,复杂图表引库并提前在目标设备上验证。这个原则在后面的开发中帮我节省了大量排查时间。
5.2 饼图的绘制原理与实现
CustomPainter画饼图的核心逻辑是遍历数据列表,按占比计算每块扇形的起始角度和结束角度,再用drawArc绘制。起点从-90度开始,按照数学坐标系顺时针扫过对应的角度。绘制时我给每块扇形加了2度的间隙,用背景色填充间隙,形成类似环形进度条的分隔效果。
class CategoryPiePainter extends CustomPainter { final List<PieData> data; final Color centerColor; CategoryPiePainter({required this.data, required this.centerColor}); @override void paint(Canvas canvas, Size size) { final rect = Rect.fromCircle( center: Offset(size.width / 2, size.height / 2), radius: size.width / 2, ); double startAngle = -90.0; for (final item in data) { final sweepAngle = item.percent * 360.0 - 2.0; final paint = Paint() ..color = item.color ..style = PaintingStyle.fill ..isAntiAlias = true; canvas.drawArc(rect, startAngle * pi / 180, sweepAngle * pi / 180, true, paint); startAngle += item.percent * 360.0; } } @override bool shouldRepaint(covariant CategoryPiePainter oldDelegate) { return oldDelegate.data != data || oldDelegate.centerColor != centerColor; } }饼图中心预留的空白区用来放大分类总金额,这样图表不仅有占比信息,还承载了“本分类这月一共花多少”的附加信息,信息密度更高。绘制时记得开启抗锯齿,否则扇形边缘在深色模式下会显得毛糙。
5.3 图例与空数据状态
饼图旁边需要配图例才能让用户看懂每个扇区代表什么。我用的图例是竖排样式:左侧圆形色块,中间分类名称,右侧百分比。分类名称过长时用省略号截断,保证右侧百分比永远完整可读。
空数据状态在理财App里很容易被忽略,但用户第一次安装App或当月没有支出时,图表区域直接空着会显得很突兀。我专门做了一个空状态组件,显示一行“本月暂无支出数据”的文字加一个灰色圆环,而不是让Canvas什么都不画。这个细节没什么技术含量,但对用户感知影响很明显。
6. 深色模式与主题适配
6.1 深浅色切换的正确姿势
理财App使用场景包括睡前看账单,深色模式是刚需。Flutter适配深浅色最常规的做法是通过ThemeData的brightness属性响应系统主题,但OpenHarmony上有一个细节要注意:系统切换深浅色不一定像Android那样立刻触发Flutter的rebuild。实测中我发现,App处于前台时系统切换主题,页面有时不会自动刷新,需要重进页面才能看到效果。
为了解决这个问题,我在App生命周期监听里加入了手动判断:监听到系统主题亮度变化时,主动调用主题刷新回调更新UI。同时页面自身不要写死颜色,所有会随主题变化的颜色都从Theme.of(context)里取。这个做法保证用户从系统设置切换深浅色后,回到App立刻生效,不会出现页面一半深色一半浅色的尴尬状态。
6.2 用ThemeExtension管理语义色
交易详情页涉及的颜色不少:金额收入色、支出色、状态标签的三种状态色、卡片背景色、分割线色。如果这些颜色散落在各个Widget里,换主题时就得肉眼扫描全文。我用ThemeExtension定义了一套语义色,从深层颜色配置里取值,Widget侧只声明“我取的是语义色”,不关心具体色值。
| 语义色 | 浅色模式 | 深色模式 |
|---|---|---|
| 页面背景 | #F5F6FA | #121212 |
| 卡片背景 | #FFFFFF | #1E1E1E |
| 金额收入 | #1B873F | #34C759 |
| 金额支出 | #E53935 | #FF6B6B |
| 标签成功 | #E8F5E9 | #1B3A2A |
| 标签失败 | #FDECEA | #3A1F1D |
这样做的收益在后期调主题时特别明显:想给深色模式换一套配色,只需要扩展ThemeExtension的实现数据,页面代码一行都不用改。这种模式尤其适合交易详情页这种颜色语义复杂的页面,值得在项目初期就建立起来。
6.3 在OpenHarmony上适配主题的几个注意点
我在OpenHarmony上适配主题时遇到三个比较典型的问题。第一个是状态栏和刘海屏区域的适配,页面顶部内容会被系统状态栏遮挡,需要获取安全区域padding并手动设置AppBar的高度。第二个是系统字体的渲染差异,部分中文粗体在真机上显示偏细,需要为标题文字显式指定FontWeight。第三个是从后台切回前台时主题色可能闪烁,解决方法是把主题数据缓存到本地,启动时先加载缓存再响应系统变化。
这三个问题都不是Flutter框架本身造成的,而是跨系统适配必有的细节。我的经验是:主题适配不要最后做,应该在页面开发的同时就写好基础框架,否则到后期页面多了,改起来容易顾此失彼。
7. 常见问题排查与性能优化
7.1 问题速查表
交易详情页开发过程中我遇到的问题不少,整理成一张速查表,方便遇到同类问题时快速定位:
| 问题现象 | 可能原因 | 处理建议 |
|---|---|---|
| 中文文字显示为方框 | 系统字体缺失 | 显式配置中文字体资源或使用系统默认字体 |
| 图标渲染成方块 | 图标库不兼容 | 改用基础图形或内置图片资源 |
| 页面底部按钮被遮挡 | 安全区域适配不当 | 使用MediaQuery的padding避开系统导航区 |
| 返回列表后数据未刷新 | 刷新逻辑写在initState里 | 改为await路由push返回后处理 |
| 深色模式下卡片看不清 | 颜色写死 | 统一使用ThemeExtension语义色 |
| 图表边缘毛糙 | 抗锯齿未开启 | 绘制时设置isAntiAlias true |
| 长交易单号显示不全 | 单行文本溢出 | 设置Flexible+ellipsis或允许换行 |
这张表不仅适用于交易详情页,大部分Flutter跨平台开发中遇到的问题都是相通的。我建议每位开发者都把自己团队的踩坑记录维护成类似速查表,发布到团队Wiki里,避免同一类问题反复排查。
7.2 详情页性能优化的几个有效手段
交易详情页虽然只有一个页面,但性能优化依然有讲究。首先是重活隔离:图表区域包上RepaintBoundary,让饼图的Canvas重绘不会触发整个页面重绘。其次是列表区域使用ListView.builder懒加载,虽然详情页数据量不大,但明细项如果有大量富文本组件,继续沿用ListView.builder能保证未来扩展时的基础性能。第三是对图片资源做裁剪,分类图标和账户Logo控制在64像素内,避免大图降采样。第四是数据层做不可变对象,交易模型的字段全是final,页面内setState时不会意外修改已有数据,减少了不必要的状态噪音。
7.3 回顾:这次实操里我最想提醒的几件事
最后说几件实际操作里的真实体会,希望能帮你少走弯路。第一,在OpenHarmony上开发Flutter应用,一定先用真机测试,模拟器的行为和真机有挺多差异,尤其是字体渲染和图形性能,这两块恰恰是交易详情页最依赖的能力。第二,第三方依赖能少则少,哪怕会牺牲一点开发效率,也要选择可控的实现方式,我在自绘饼图上的投入,换来的是后续删包提速和零运行时闪退。第三,格式化工具类务必集中管理,金额单位和日期规则这类全局规范一旦散落,后面要统一改时就会头痛。
我自己的习惯是每做完一个页面,回头把当时“怀疑过但没验证”的假设全部补测试一遍。这次交易详情页里就有一个意外的验证结果:原本担心OpenHarmony上动画性能不行,实际跑下来AnimatedSwitcher和局部重绘都相当流畅。所以说到底,跨平台开发的很多焦虑来自未知,而消除未知最好的办法就是把手弄脏,尽早跑起来,尽早填坑。