☰
Flutter×鸿蒙6.0预算管理实战:状态管理、自绘组件与适配踩坑记录
2026/10/6 9:11:32 网站建设 项目流程

「难忘账本」这个项目我断断续续做了快四个月,中间踩过不少坑,也推翻过几次方案。预算管理模块是最早动工、也是反复打磨最多的部分。一开始我只想在鸿蒙设备上跑通一个能记流水账的App,后来发现预算模块才是整个应用留存用户的关键——没有预算约束,记账坚持不了两周。这篇文章我把预算管理模块从数据模型、状态管理到HarmonyOS 6.0适配的完整思路和踩坑记录整理出来,给正在做Flutter跨端应用、或者准备把Flutter项目移植到鸿蒙的朋友一份可参考的实战笔记。

1. 「难忘账本」为什么押注 Flutter × HarmonyOS 6.0

先交代一下选型背景。我当时的目标用户主要在鸿蒙设备上,但团队又不想放弃安卓和iOS的存量市场,所以跨端框架是唯一理性的选择。在Flutter、React Native、Compose Multiplatform里过了一圈,最终还是定了Flutter,原因有几个:

  • 自绘渲染引擎保证了三端UI一致性。React Native和Compose Multiplatform在复杂动画、自定义绘制上要么依赖原生实现,要么各自为政,而Flutter从底层就是自己说了算。
  • 鸿蒙生态对Flutter的支持相对成熟。OpenHarmony社区维护着flutter_flutter仓库,官方Toolchain和SDK跟得也紧,到HarmonyOS 6.0这一代,Flutter应用已经能以HAP包的形式直接跑在HarmonyOS NEXT设备上。
  • 团队里没人写过Swift和Kotlin,Dart的上手曲线对我们更友好。

HarmonyOS 6.0这一代,终端设备的系统底座全面转向自研鸿蒙内核,不兼容安卓APK,所以必须用鸿蒙原生的HAP包格式。Flutter的跨端能力在这里反而成了一个关键优势:业务代码完全不用改,只要把构建链路切到鸿蒙SDK,就能产出HAP。

1.1 预算模块的需求边界

预算管理听起来简单,拆开来看就会遇到很多边界问题。我们的核心需求其实只有四件事:

  • 设置月度预算:按分类设置每月可用额度,比如餐饮3000、交通800。
  • 记录每一笔支出:必须关联到分类,金额和备注都不能缺。
  • 实时掌握剩余额度:每次记完账,要马上知道这个分类还剩多少、超了多少。
  • 超支预警:进度到50%、80%、100%时给出提醒。

这四个需求串起来就是完整的数据流:预算额度是静态配置,支出记录是持续追加的动态数据,进度比率是二者的派生结果。预算模块的所有代码都是围绕这个数据流展开的。

在动手写代码之前,我把分类和预算额度拆成了两个实体,而不是合并成一个。原因是用户可能先记几笔账,再决定要不要给某个分类设预算,分类表里的数据不应该因为预算字段为空就影响支出记录的使用。

1.2 Flutter与原生方案权衡中的现实考量

HarmonyOS原生开发用ArkTS + ArkUI,单看UI开发效率并不差,而且跟系统能力集成最顺。但问题是,我们不是只做鸿蒙一个平台。如果用ArkTS写一套,再用Kotlin写一套,再补iOS,三个人维护三套代码,预算模块这种强业务逻辑的模块会有一半时间花在同步三端的行为差异上,这账算不过来。

Flutter在HarmonyOS上的渲染走的是Impeller后端(6.0之后Impeller已经逐步成为默认的渲染引擎),而不是老的Skia。这一点对动画和滚动列表的流畅度影响很大,后面第六节我会专门讲Impeller在鸿蒙上的表现。

也有一个绕不开的现实问题:Flutter插件生态在鸿蒙上是缺口的。pub.dev上大量插件默认只实现了Android/iOS/macOS等平台,鸿蒙的plugin联邦还没有全面覆盖。我们的解法是,能绕开的插件绕开,绕不开的自已用MethodChannel封一层,或者直接用PlatformView嵌入原生组件。这个取舍贯穿了整个开发过程。

2. 预算模块的数据模型与派生状态:从分类表到剩余额度

预算模块的数据层设计决定了下半程开发是舒服还是难受。我第一版是把所有字段塞进一张大表,后来发现查询和更新都变得很别扭,重构了一次,最终采用三个实体加一个状态容器的结构。

2.1 核心实体定义

class BudgetCategory { final String id; final String name; final int iconCode; final int colorValue; final double monthlyLimit; const BudgetCategory({ required this.id, required this.name, required this.iconCode, required this.colorValue, required this.monthlyLimit, }); BudgetCategory copyWith({double? monthlyLimit}) { return BudgetCategory( id: id, name: name, iconCode: iconCode, colorValue: colorValue, monthlyLimit: monthlyLimit ?? this.monthlyLimit, ); } } class ExpenseRecord { final String id; final String categoryId; final double amount; final String note; final DateTime spentAt; const ExpenseRecord({ required this.id, required this.categoryId, required this.amount, required this.note, required this.spentAt, }); } class BudgetState { final List<BudgetCategory> categories; final List<ExpenseRecord> expenses; final DateTime currentMonth; const BudgetState({ required this.categories, required this.expenses, required this.currentMonth, }); }

分类和支出记录分开存,看起来多了一张表,实际上换来了两个好处:一是用户可以先把常用分类建好,预算额度后补;二是支出记录只依赖分类ID,后续分类改名、调整图标都不会影响历史账单。

currentMonth这个字段很容易被忽略,但它决定了所有进度计算的时间边界。预算一定按月算,如果本地存储里没有记录"当前计算周期",那每次打开App都要重新确认现在该算哪个月份的数据,极其容易出错。我直接在状态里固定住这个值,刷新和计算都基于它,逻辑就清晰多了。

2.2 派生状态:为什么不能用 setState 硬算

预算进度的核心矛盾是:已经花掉多少钱、剩余多少额度、进度百分比,这些都是"算出来的",不是"存下来的"。如果你每次改动数据后手动更新UI,一旦漏掉某个更新入口,界面就会显示过期数据。

我用Riverpod的StateNotifier来管理这层派生关系。以分类ID为维度,定义一个family Provider,专门算某个分类当月已支出金额:

final categorySpendingProvider = Provider.family<double, String>((ref, categoryId) { final state = ref.watch(budgetProvider); final month = state.currentMonth; return state.expenses .where((e) => e.categoryId == categoryId) .where((e) => e.spentAt.year == month.year && e.spentAt.month == month.month) .fold(0, (sum, e) => sum + e.amount); }); final categoryRemainingProvider = Provider.family<double, String>((ref, categoryId) { final category = ref.watch(categoryByIdProvider(categoryId)); final spent = ref.watch(categorySpendingProvider(categoryId)); return category.monthlyLimit - spent; });

这种写法的核心价值在于:当任何一条支出记录写进去,所有依赖它的派生数值会在组件树里自动更新,不需要手动通知列表页、进度环、汇总卡片去刷新。组件通信的问题在Riverpod里变成了"数据驱动"的问题——谁关心这个状态,谁就watch它,数据一变,消费方自动重建。

Flutter的组件通信是很多新手纠结的点:父子之间用什么回调、兄弟组件怎么同步、跨页面怎么共享,其实说到底就是"状态放哪里"的问题。预算模块的做法是把所有可变状态集中到Provider里,UI层只读不管写,页面之间不再直接传值,通信负担立刻降了一个量级。

2.3 本地存储选型与事务边界

支出记录的写入频率不高,但每次写入后都要重新计算月度汇总。我选择了sqflite,原因很简单:预算模块涉及分类、支出记录、月度统计三类数据,关系型查询(尤其是按月Group By汇总)写起来最直接。Hive虽然轻量,但Group By这类聚合逻辑要自己在内存里跑,数据量上来之后会拖慢冷启动。

存储层我单独封装了一个BudgetRepository,所有数据库操作都走这里。写入支出记录时,单条插入和下次启动时的汇总计算是两件事,不需要放在同一个事务里;但预算额度的更新和对应分类的缓存必须保持强一致,所以这两步用了一个事务,避免用户改完预算额度后看到一条旧的剩余金额。

这里有一个实际教训:不要为了省事把所有读写都放在UI线程。sqflite的异步API本身不会block UI,但如果你的查询在页面build里同步执行,遇到大月份账单数据还是会掉帧。我们的做法是所有数据库操作都走async方法,页面通过FutureBuilder或者Provider的异步加载来接数据。

3. 预算录入与支出记录:异步边界上的经验与教训

数据模型定完之后,最花时间的是预算录入和支出记录这两个交互流程。流程本身不复杂,但中间夹着异步写入、状态刷新、动画联动,稍不留神就会出现"账记了但进度没动"这种诡异问题。

3.1 预算录入的表单校验与默认分类策略

预算录入页面是一个典型的表单页:分类选择器、金额输入框、保存按钮。分类选择器一开始用的是Flutter自带的BottomSheet加GridView,后来改成在页面内嵌一个横向滚动的分类卡片列表,用户一眼能看到全部分类和对应进度,交互更直观。

金额输入这里有个细节:不直接放TextField,而是用一个大号金额展示组件配合数字键盘。原因是预算金额和支出金额都有精度要求,原生TextField在输入小数点和删除操作时很容易让用户感到别扭。我们用的是TextInputFormatter配合正则限制,只允许数字和最多两位小数。

校验逻辑分三层:

  • 金额必须大于0且不大于999999。
  • 分类必须已存在,新建分类时名称不能和已有分类重复。
  • 月度预算变更时,要提示用户当前该分类已经超支。

第三层校验是最容易漏的。试想一下,用户在月底发现餐饮超支了500块,想把预算从3000调到4000,如果你在表单校验阶段没有提示"当前已超支",用户保存后会看到进度环直接变成绿色,仿佛超支不存在了。我们加了一个提示条:当前分类本月已支出3500,若预算调整为4000,剩余额度为500。

3.2 支出记录的异步写入:Future.then回调与微任务队列

记账流程是预算模块里对异步时序最敏感的地方。用户在记账页面填完金额,点击保存,接下来要做三件事:写入数据库、刷新内存状态、触发进度环动画。

第一版我写的是:

Future<void> _saveExpense(ExpenseRecord record) async { await repository.insertExpense(record); ref.read(budgetProvider.notifier).addExpense(record); ringController.forward(from: 0); }

看着没什么问题,直到我在日志里发现一个偶现的现象:进度环动画偶尔跑在状态刷新之前,导致动画起始值和最终值不一致。这就要说到Dart的事件循环机制。

Dart是单线程模型,事件循环里有两个队列:事件队列(Event Queue)和微任务队列(Microtask Queue)。当一个Future完成时,它的then回调并不会立刻执行,而是被注册进微任务队列。在当前同步代码块执行完之后,微任务队列的优先级高于事件队列,所以then回调会在下一个事件任务之前运行。

也就是说,await repository.insertExpense(record)之后的代码,本质上相当于挂了一个then,它会在当前同步逻辑结束后的微任务阶段执行。如果你在await之前就已经调用了ringController.forward,那么动画启动和状态更新就不在同一个同步块里,UI可能先看到了旧状态的起始帧。

修正后的写法是把状态更新和动画启动放进同一个事务逻辑里,或者说确保它们被提交到同一个微任务批次之后:

Future<void> _saveExpense(ExpenseRecord record) async { await repository.insertExpense(record); // 先更新状态,再启动动画,放在同一个同步块内 ref.read(budgetProvider.notifier).addExpense(record); WidgetsBinding.instance.addPostFrameCallback((_) { ringController.forward(from: 0); }); }

这里绕开了"then回调进微任务队列"带来的时序陷阱。实际上如果你完整理解了Dart的事件循环,会发现任何await之后紧跟state update和animation start的写法都成立,关键是不能让动画依赖的输入值在动画启动后才被计算。

3.3 组件通信在记账流程里的具体落地

记账页面触发保存后,预算首页的进度环、分类列表的剩余额度、本月支出Top3这三个组件都需要刷新。这三个组件分布在不同的页面层级,有些还在底部Tab切换后才会重建。

我在项目里给这个场景定义了一个简单的规则:跨页面状态一律走Riverpod,不写EventBus,不用全局单例的ValueNotifier手动通知。原因很实在:EventBus的通信是点对点的,发出事件的人不知道谁在听,调试的时候很难追踪;而Riverpod的Provider体系里,状态变更链路是显式的,任何依赖它的组件都会在同一个重建周期内收到通知。

等后面组件一多,你会发现组件通信的复杂度不是取决于组件数量,而是取决于状态分散的程度。把所有状态收拢到Provider层之后,组件通信问题简化成了"我应该watch哪个Provider"的问题。

4. 预算进度可视化与超支预警:自绘组件带来的自由度

预算模块的视觉核心是进度环和预警卡片。这两个组件我没有直接用第三方图表库,而是用CustomPaint自绘。第三方图表库虽然省事,但在预算这种高度定制化的场景里,往往要为了一个圆环引入几十KB的代码,还要处理主题适配,性价比不高。

4.1 环形进度组件的绘制逻辑

环形进度的实现思路:最外层是一个背景圆环,内层是进度弧线。进度弧线的角度范围从-90度开始,顺时针扫过 progress * 360 度。代码不复杂,但有几个绘制细节会影响最终效果:

class BudgetRingPainter extends CustomPainter { final double progress; final Color trackColor; final Color progressColor; final double strokeWidth; BudgetRingPainter({ required this.progress, required this.trackColor, required this.progressColor, required this.strokeWidth, }); @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); final radius = (size.shortestSide - strokeWidth) / 2; final rect = Rect.fromCircle(center: center, radius: radius); final trackPaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth ..color = trackColor; canvas.drawCircle(center, radius, trackPaint); final progressPaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth ..strokeCap = StrokeCap.round ..color = progressColor; final sweepAngle = (progress.clamp(0.0, 1.0)) * 2 * pi; canvas.drawArc(rect, -pi / 2, sweepAngle, false, progressPaint); } @override bool shouldRepaint(covariant BudgetRingPainter oldDelegate) { return oldDelegate.progress != progress || oldDelegate.trackColor != trackColor || oldDelegate.progressColor != progressColor || oldDelegate.strokeWidth != strokeWidth; } }

进度超过100%时,进度环会画出一整圈,但颜色从正常色切到超支色。这个逻辑我放在了组件外面:计算进度值时直接对1.0做clamp,但颜色由超支状态决定。这样进度环就不会出现"画了一圈半"的错乱效果,超支后显示的是转红的满环,视觉语义更明确。

还有一个小细节:弧线误差。CustomPaint的drawArc在stroke模式下,如果strokeWidth较大且圆弧接近闭合,首尾会有一小段重叠。用StrokeCap.round可以减少这个视觉瑕疵,但完全消除需要手动把sweepAngle乘以0.9995,这个微调看个人偏好,不做也不会被用户察觉。

4.2 阈值预警的状态机设计

超支预警我用了一个简单的三级状态机:

  • 进度 0%-50%:正常,进度环绿色,不提醒。
  • 进度 50%-80%:注意,进度环黄色,列表页显示一个小提示条。
  • 进度 80%-100%:警戒,进度环橙色,提示条加粗。
  • 进度 >=100%:超支,进度环红色,弹出通知。

这个状态机不复杂,但要注意的是状态切换的判定条件。我们不能在每次build的时候都去跳状态机——用户滑动列表、切换Tab都可能触发重建,如果重建时都判断一次弹不弹通知,就等着看通知轰炸吧。

我的做法是只在两处触发状态迁移:一处是保存支出的成功回调,另一处是手动刷新数据的入口。状态迁移逻辑封装在BudgetNotifier里,当计算出的支出金额跨越阈值时,返回一个事件对象给UI层,由UI层决定弹通知还是显示提示条。

通知的渠道在鸿蒙上我用的是本地通知能力,需要在module.json5里声明通知权限。这里提一下:不同版本鸿蒙的通知权限声明有差异,6.0上如果发现通知发不出去,优先检查权限声明和通知渠道ID是否匹配,而不是先怀疑Flutter代码。

4.3 日历选择与PlatformView的取舍

预算模块里有个月度视图,用户点开可以看到这个月每一天的支出柱状图。第一版我直接用Flutter的showDatePicker,交互没问题,但视觉上跟应用的卡片风格不搭,而且鸿蒙上showDatePicker弹出的对话框控件在某些机型上会偶发被输入法顶起的问题。

后来我决定嵌入系统日历控件,这就用到了PlatformView。Flutter的PlatformView机制允许在Flutter的widget树里嵌入原生视图,在鸿蒙上对应的实现是PlatformViewLink加UiKitView(鸿蒙侧对应的是PlatformView的适配层)。代码大概长这样:

PlatformViewLink( viewType: 'harmony_calendar', onCreatePlatformView: (params) { return PlatformViewsService.initSurfaceAndroidView( id: params.id, viewType: params.viewType, layoutDirection: TextDirection.ltr, creationParams: {'month': currentMonth}, creationParamsCodec: const StandardMessageCodec(), onFocus: () => params.onFocusChanged(true), ); }, onPlatformViewCreated: (id) { // 日历点击事件的回传 }, child: const SizedBox(), )

PlatformView不是万能的。嵌入原生视图意味着两套渲染引擎要在同一个页面上共存,滚动性能、点击穿透、键盘避让都容易出问题。在预算模块里,我只有日历这一个场景用了PlatformView,其他能不用尽量不用。如果你也是做跨端应用,记住一个原则:能用Flutter自绘解决的交互,优先自绘,PlatformView是兜底方案,不是首选方案。

5. 账单列表与交互体验:在长列表里做减法

预算模块的账单列表是用户每天都会看的页面。这个页面的优化思路比较朴素:让列表滚动流畅、筛选即时生效、空状态不让人焦虑。

5.1 下拉刷新与分类筛选的联动设计

列表页的结构是顶部一个横向分类筛选器,下面跟按月分组的账单列表。筛选器选中某个分类时,列表只显示该分类的账单;选"全部"时,显示所有账单并按月份分组。

下拉刷新用的是RefreshIndicator。这里有一个在鸿蒙上容易踩的坑:RefreshIndicator的child如果是一个CustomScrollView,并且CustomScrollView的第一个sliver是SliverAppBar(比如吸顶的分类筛选栏),在鸿蒙上会出现下拉手势被外层HorizontalScrollView吞掉的问题。解决方法是把RefreshIndicator放在CustomScrollView外面,并给RefreshIndicator加上notificationPredicate:

RefreshIndicator( notificationPredicate: (notification) { return notification.metrics.axis == Axis.vertical; }, onRefresh: () => ref.read(budgetProvider.notifier).reload(), child: CustomScrollView( slivers: [...], ), )

筛选状态的变更走的是一个Provider,列表组件watch它,数据自动重组。筛选本身不触发数据库查询,只是对内存中的账单列表做filter,这样做的好处是切换分类时列表是即时响应的,不需要加载动画。

5.2 ListView.builder与分组索引的性能细节

账单列表的数据量理论上很小(一个月几百条顶天了),但架不住用户翻历史账单。我一开始用ListView.builder直接渲染所有月份的账单,后来数据超过一年后,每次滚动到很深的月份还是会掉帧。

优化方案是双轨制:当前月份用分组列表展示,历史月份用懒加载的分页。实际写的时候发现,Flutter的ListView.builder本身足够快,瓶颈在于每条item的构建逻辑。我的item里同时展示了分类图标、类别名称、备注、金额、时间,还要按月度做分隔头,这些布局嵌套太深,导致每个item的build成本偏高。

我把item拆成三个独立的widget,用const构造函数配合相同参数复用,让Flutter对未变化的部分跳过重建。同时给ListView设置了itemExtent为固定高度(列表卡片高度固定),减少布局计算。这一版优化后,即使在老款鸿蒙设备上,滚动到12个月前的账单也能保持60fps。

5.3 空状态与首次使用引导

预算模块最难设计的其实是空状态。新用户进来,没有分类、没有账单、没有预算,进度环画什么?画一个0%的灰环,配一句"点击右上角设置预算"。这句话看起来很平淡,但比什么都强。

后来我加了一个小引导流程:首次进入预算模块时,弹出一个三步引导页,告诉用户"1. 选分类 2. 设额度 3. 开心记账"。引导页不阻塞,用户可以跳过,但被跳过的用户会在预算首页看到持续的小气泡提示。实际数据下来,走了引导的用户一周后留存率明显高于直接跳过引导的用户。

6. HarmonyOS 6.0 适配实录:从新项目跑不起来到稳定上线

这部分是最想让准备做鸿蒙适配的Flutter开发者看到的内容。HarmonyOS 6.0 + Flutter这件事,网上资料少,官方文档更新快,很多坑只能自己踩。我把适配过程中最关键的几个问题按时间顺序列出来。

6.1 创建鸿蒙工程:flutter create 的隐藏参数

Flutter默认创建的工程只有android、ios、web等目录,不会自动生成ohos目录。第一次跑的时候,我在鸿蒙设备上根本找不到安装入口,后来才发现需要用这个命令手工补上鸿蒙平台:

flutter create . --platforms=ohos

这个命令会在工程根目录生成ohos目录,里面是完整的鸿蒙工程结构,包括entry模块、module.json5、build-profile.json5这些鸿蒙特有的配置。生成完成后,还要在DevEco Studio里打开ohos目录单独配置SDK路径,因为Flutter的命令行工具不会自动帮你去配置DevEco的SDK。

6.2 Gradle插件配置:imperatively applying的报错修复

新项目生成后,我遇到了一个非常典型的Gradle报错:

You are applying Flutter's main Gradle plugin imperatively using the apply script method, which is not supported. Please migrate to the plugin DSL.

这个报错我在安卓工程里也见过,根因是Flutter Gradle插件的加载方式从apply脚本迁移到了plugin DSL,而你本地的Gradle版本还在用老旧的apply from: flutter_tools/gradle写法。鸿蒙工程的ohos目录里同样有一份Gradle配置,适配的时候必须把它升级到插件DSL方式。

修复动作是在settings.gradle里加上pluginManagement,并把flutter plugin的resolutionStrategy指到本地Flutter SDK的gradle插件路径:

pluginManagement { def flutterSdkPath = { def properties = new Properties() file("local.properties").withInputStream { properties.load(it) } def flutterSdkPath = properties.getProperty("flutter.sdk") assert flutterSdkPath != null, "flutter.sdk not set in local.properties" return flutterSdkPath }() includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") repositories { google() mavenCentral() gradlePluginPortal() } }

然后在ohos目录的build.gradle里改用plugins块声明:

plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "com.huawei.ohos" version "6.0.0" }

这个问题不解决,鸿蒙包根本构建不出来。而且它不会在Flutter侧报错,因为Flutter命令走的是自己的构建管线,要等到你在DevEco Studio里跑鸿蒙构建,或者用flutter build hap的时候才暴露。

6.3 HAP构建产物与AAR的打包关系

Flutter在鸿蒙上的产物是HAP包,但它内部的Flutter引擎和Dart代码是以AAR的方式被鸿蒙工程依赖的。换句话说,flutter build hap执行时会先把Flutter引擎相关代码打成AAR(内部还分为ohos-arm64-v8a和ohos-x86_64两个架构),然后再嵌入到HAP的libs目录里。

这个机制带来一个典型坑:如果你在主工程里同时引用了多个Flutter模块(比如一个独立的share_engine),多个AAR之间的引擎库版本必须完全一致,否则运行时会出现链接错误。我在项目里遇到了两个模块的Flutter SDK版本不一致导致的崩溃,现象是Dart VM初始化直接失败,日志长这样:

E/flutter (31173): [ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled exception: E/flutter (31173): [ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Failed to setup Dart VM

排查思路:先确认所有依赖的Flutter模块是不是同一个SDK版本,再看ohos目录下的libs里是不是混入了多份flutter.jar。这种问题优先怀疑依赖冲突,不要先去翻Dart代码。

6.4 Impeller渲染引擎在鸿蒙上的表现

HarmonyOS 6.0上跑Flutter,默认渲染引擎已经是Impeller。Impeller的优势是把Skia的运行时编译着色器改成了预编译,减少了首帧和动画启动时的卡顿。在鸿蒙设备上,实测结论是:列表滚动和路由转场的流畅度比Skia版本好,但有几个小兼容问题需要处理。

  • 第一个是截图模糊。页面做模糊特效时,BackdropFilter在Impeller下偶尔会渲染成整块黑色,这个问题在老的Skia引擎下不存在。解决方案是尽量避免大面积BackdropFilter,改为使用带透明度的半透明色块。
  • 第二个是文字渲染。Impeller的字体渲染在部分鸿蒙机型上偏细,如果你的UI对文字字重有严格要求,要在TextTheme里手动调fontWeight。
  • 第三个是动画曲线。Impeller对某些自定义Painter的绘制指令做了优化,但如果你在绘制中使用了非常规的BlendMode,可能出现左右不对称的渲染结果。这个在我们自绘环形进度环时遇到过,最终用shader替代BlendMode解决。

6.5 鸿蒙权限模型与通知适配

顶部提到超支预警的本地通知,在鸿蒙上有一套独立的权限模型。manifest里要向用户申请通知权限,且用户拒绝后需要引导去设置页开启。这块与Android的notification权限逻辑类似,但API命名不同,千万别把Android的写法抄进来。

实际踩坑是:在部分HarmonyOS 6.0设备上,应用安装后默认不开通通知权限,即使你在module.json5里声明了也不行。需要先通过Notification.requestEnableNotification接口去请求,然后再用NotificationManager发布通知。我封装了一个NotificationBridge,用MethodChannel对原生侧发起请求:

class NotificationBridge { static const platform = MethodChannel('com.ledger.app/notification'); static Future<bool> requestPermission() async { final result = await platform.invokeMethod('requestNotificationPermission'); return result == true; } static Future<void> showBudgetWarning(String message) async { await platform.invokeMethod('showNotification', {'message': message}); } }

这里的方法通道调用要格外小心:通道调用返回的Future如果失败,会在Dart侧抛PlatformException,而Flutter的默认行为是把这个异常传到一个未处理的Future上,容易触发dart_vm_initializer.cc的崩溃日志。建议所有invokeMethod都包一层try/catch,即使你确定原生侧已经实现了对应方法。

6.6 实测数据与最终稳定性

修复完这些适配问题后,我在三台鸿蒙设备上跑了一个月的稳定性测试:一台HarmonyOS 6.0的手机,一台平板,一台老的openharmony设备。统计结果如下:

  • 冷启动时间:手机900ms左右,平板1.2s,老设备1.8s。
  • 预算模块页面停留时的平均帧率:接近满帧,滚动列表时最低帧率58fps。
  • 崩溃率:适配初期5%左右,修复Impeller模糊问题和通道异常后降到0.3%以下。
  • 内存占用:预算模块常驻约为120MB,包含了Flutter引擎和页面资源,可接受。

这些数据能说明HarmonyOS 6.0跑Flutter应用已经具备实用条件,不再是个"能跑但跑不爽"的状态。当然,这个结论基于预算模块这种UI复杂度中等的页面,如果是重度依赖原生能力的应用,适配工作量会是另一个量级。

写在最后的几点体会

回过头看,"难忘账本"的预算模块从零到上线,让我最深刻的一点是:跨端框架的适配问题,大多数时候不是框架本身的性能瓶颈,而是平台差异带来的隐性成本。HarmonyOS 6.0 + Flutter的组合,给了我们一套代码覆盖多端的机会,但也要求开发者对鸿蒙工程结构、Gradle构建链路、权限模型和通知API有足够的耐心去逐个打通。如果你正在做类似的移植,我建议先把构建链路跑通(HAP能装上、能跑起来),再考虑功能开发,否则越到后面越会被环境问题拖后腿。

如果你也在做预算类或者财务类的跨端应用,希望这篇笔记能帮你少走一些弯路。后续我会继续把账单导入导出、多账本切换、图表分析这几个模块的实战经验整理出来,到时候再和大家细聊。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询