Flutter for OpenHarmony 视力保护提醒App实战 - 健康报告生成
做移动开发这些年,我发现自己身边几乎所有人都在抱怨同一件事:每天盯着屏幕的时间越来越长,眼睛干涩、疲劳成了家常便饭。市场上提醒休息的App其实不少,但绝大多数只能做到“到点弹个通知”,用户关掉提醒继续刷,久而久之连App一起卸载。我这次在OpenHarmony上用Flutter做一个视力保护提醒App,核心目标就两个:一是把“提醒”这件事做得不烦人,二是用一份可读性很强的健康报告,让用户真正看到自己用眼习惯的变化。这篇实战记录重点讲健康报告生成这条链路,从数据采集、评分模型到PDF导出和分享,完整跑通一套可以在真机上用的方案。
如果你正在纠结“Flutter到底能不能在OpenHarmony上开发正经业务”,或者已经上手了Flutter但被EventChannel、PlatformChannel这些原生交互搞得头疼,又或者你只是想给自己的App加一个“报告生成”模块但不想踩中文字体、PDF导出这些坑,这篇文章应该都能帮上忙。
1. 先说清楚需求:一个护眼App为什么非要搞健康报告
护眼类App的第一直觉是做一个“计时器+提醒闹钟”。但把这类App放到用户手里跑一个月,会发现留存率惨不忍睹。原因很简单:提醒类功能本质上是“对抗用户当前行为”的,用户被反复打断之后,第一反应是关掉通知,而不是改变习惯。
我在设计这个项目的时候,把“健康报告生成”放在和提醒同等重要的位置,甚至可以说报告才是这个App真正的留存抓手。报告能回答用户三个问题:我今天到底用眼多久了?疲劳程度处于什么水平?和上周比有没有进步?没有报告,用户只觉得你在烦他;有了报告,用户会主动打开App看数据,这就把“被动提醒”变成了“主动关注”。
1.1 报告功能的需求拆解
从产品层面拆解,健康报告至少要有三块内容:
- 每日概览:当天用眼总时长、连续用眼最长时长、休息次数、得分和风险等级。
- 趋势曲线:最近7天或30天的用眼时长变化、得分变化,让用户看到习惯走势。
- 行动建议:根据数据自动生成几条简单可执行的建议,比如“今天连续用眼超过50分钟的次数偏多,建议每45分钟休息5分钟”。
这三个模块里,每日概览和行动建议是纯逻辑计算,趋势曲线需要图表组件,导出分享则需要PDF生成能力。整个链路由数据采集、数据建模、评分计算、可视化渲染、文件生成五段组成,每一段在Flutter + OpenHarmony的组合下都有一些值得记录的点。
1.2 技术选型复盘:Flutter在这个项目里值不值
先说结论:值,但前提是你要接受“OpenHarmony上的Flutter不是官方主线支持的”这个现实。
目前OpenHarmony生态里有专门的社区/组织在维护适配Flutter的分支(比如OpenHarmony-SIG相关仓库),使用方式大体上是:用适配后的Flutter SDK创建工程,Dart业务代码照常写,最终在DevEco Studio里通过一个OpenHarmony壳工程来承载。换句话说,Dart侧几乎不用关心底层是Android、iOS还是OpenHarmony,你能拿到跨端的UI和业务逻辑复用,代价是部分系统能力(传感器、电量、通知等)需要自己写平台通道去桥接。
从我实际体验看,这个成本是可控的。项目里真正需要走原生通道的只有传感器数据、屏幕使用统计、系统通知和文件分享这几件事,而Flutter的MethodChannel和EventChannel机制在OpenHarmony分支上基本可用。如果这个项目只针对单一平台做,那我当然建议直接用ArkTS原生写;但如果你有后续多端发布的需求,Flutter这套方案能帮你省掉大量重复UI代码,这笔账算得过来。
2. 工程搭建与目录结构:Flutter跑上OpenHarmony的准备工作
搭建环境这一步卡住的概率最高,我建议你先下载OpenHarmony社区维护的Flutter SDK分支(Gitee上搜OpenHarmony-SIG),然后配合DevEco Studio使用。不要直接用flutter官网的稳定版SDK去create,那会默认生成android/ios目录,没有OpenHarmony侧的能力输出。
创建项目时,用这个思路操作:
# 使用适配分支的Flutter SDK flutter version # 确认flutter命令来自于适配后的SDK路径 flutter create --platforms=android,ios,ohos eye_care_app如果你拿到的分支不支持“ohos”这个平台标识,也可以用另一种方案:先flutter create生成常规工程,再用DevEco Studio新建一个空OpenHarmony工程,把Dart工程作为依赖集成进去。这个方式的本质是“ArkTS壳工程 + Flutter业务包”的混合体,壳工程负责系统权限、生命周期、传感器原生逻辑,Flutter负责UI和业务计算。
我项目里最终采用的目录结构长这样:
eye_care_app/ ├── lib/ │ ├── main.dart # Flutter入口 │ ├── models/ # 数据模型 │ ├── services/ # 采集、评分、报告服务 │ ├── pages/ # 页面 │ └── platform/ # 平台通道封装 ├── ohos/ # OpenHarmony壳工程 │ ├── entry/src/main/ │ │ ├── ets/ # ArkTS代码 │ │ └── module.json5 # 权限声明 ├── android/ └── ios/公章上已经听过一个很隐蔽的坑。Flutter在OpenHarmony上跑,入口不一定是main.dart直接创建的那个Activity,而是壳工程里的一个Ability。如果你发现Flutter页面能起来,但某个生命周期回调始终不触发,优先去检查壳工程的Ability配置,而不是在Dart代码里反复排查。
2.1 在壳工程里需要完成的三个基础配置
第一,申请传感器和后台运行相关权限。OpenHarmony的权限模型和Android不一样,传感器权限在module.json5里声明,但不同传感器类型对应的权限名也不同,我建议直接把需要的权限全部列出来,后面调试会省很多事。
第二,配置FlutterAbility的启动参数。你需要告诉壳工程Flutter入口是哪个Dart文件、路由是哪个页面。没有正确配置的话,一启动就是白屏。
第三,把Flutter引擎产物和原生工程的构建链打通。开发和发布模式要分别测试,Debug模式下DevEco会直接连接Dart热重载端口,Release模式则需要提前把flutter build产物打包进壳工程。发布前务必用“clean build”验证一遍完整流程,只跑调试模式是看不出打包问题的。
这部分虽然繁琐,但属于一次性投入。配置好了以后,后面所有Dart侧改动都可以走热重载,开发效率并不低。
3. 数据采集链路:EventChannel负责“持续流”,MethodChannel负责“点查询”
健康报告要成立,数据采集必须可靠。我当时把采集源分成三路:
- 屏幕使用时长:基于应用自身的前台计时,加上系统层可获取的亮屏时长数据。
- 距离与环境光:通过OpenHarmony的系统传感器能力读取距离传感器和环境光传感器的读数,判断用户是否把手机拿得太近、环境光是否过暗。
- 休息行为记录:用户每次执行“开始休息”“休息结束”操作,写一条行为日志。
这三路数据里,屏幕计时可以完全在Dart侧做,距离传感器和环境光传感器必须走原生,因为Flutter引擎本身不直接暴露传感器API。这里就用到了EventChannel和MethodChannel的分工。
3.1 为什么选EventChannel而不是周期轮询
传感器是典型的“持续产生数据的源”,用轮询方案在Dart侧每隔几百毫秒发起一次MethodChannel调用去拿一次读数,在工程上不是不行,但有两个明显的坏处:一是频繁跨通道调用会增加系统负担,二是会出现数据和真实触发时机不同步的问题,尤其是用户快速切换App场景。
EventChannel的设计正好解决这个问题:原生侧作为事件源主动向Dart侧推送数据流,Dart侧用Stream订阅,数据来了就处理,没来就等着,完全是事件驱动的模型,不占用额外轮询资源。
我用一个最简单的比喻,MethodChannel像是“你打电话问银行账户余额,问一次答一次”,EventChannel像是“银行办了个短信通知,账户一变就短信推给你”。做传感器这种高频数据,自然要选后者。
Dart侧订阅传感器事件的代码大致是这个套路:
import 'package:flutter/services.dart'; class SensorService { static const EventChannel _lightChannel = EventChannel('com.eyeapp/sensor/light'); static const EventChannel _proximityChannel = EventChannel('com.eyeapp/sensor/proximity'); Stream<SensorData> lightStream() { return _lightChannel .receiveBroadcastStream() .map((event) => SensorData.fromJson(event as String)); } Stream<SensorData> proximityStream() { return _proximityChannel .receiveBroadcastStream() .map((event) => SensorData.fromJson(event as String)); } }原生侧的OpenHarmony代码负责把传感器采样值格式化后主动写入通道。这部分的具体API要看传感器服务的接口版本,但整体骨架是创建一个事件流对象,在传感器数据回调里把数据转成JSON字符串,通过通道发送到Dart侧。
有一个容易被忽略的点:传感器采样频率不要直接拉到最高。我一开始为了“保证数据精细度”,把光传感器频率设成了高频模式,结果发现真机上CPU占用明显上升、手机发热、电量掉得快。后来改成“有变化才推送(阈值触发模式)”,只推送光照强度突变或距离数值变化的帧,电量问题立刻缓解。这个经验也适用于所有高频传感器采集场景,数据不是越密越好,够用就行。
3.2 本地落库与聚合逻辑
连续流数据不能直接拿来生成报告,必须按时间窗口做聚合。我的做法是:
- 每秒累计前台使用时长,每5分钟写一条明细记录。
- 光传感器和距离传感器不落明细,只在检测到“暗光持续超过5分钟”或“距离过近持续超过3分钟”时写一条事件。
- 休息行为独立成表,记录开始时间、结束时间、是否按计划完成。
聚合逻辑放在Dart侧的一个Service里,每天0点生成前一天的日汇总,每周一生成周汇总。汇总结果存JSON格式,这份JSON是后面评分和生成PDF的公共中间层。
class DailySummary { final DateTime date; final int totalScreenMinutes; final int longestContinuousMinutes; final int restCount; final int badLightCount; final int badDistanceCount; }有了DailySummary这条统一的数据结构,无论后面是画图表、算得分还是输出PDF,都不用再去原始明细表里反复查数据,性能干净利落,逻辑也清晰很多。
4. 健康评分模型:从“一堆数据”到“一个结论”的翻译器
用户在报告页里看到满屏的“总时长352分钟、最长连续用眼68分钟、休息4次”,第一反应往往是“然后呢?”。评分模型存在的意义,就是把这些原始指标翻译成一个带有价值判断的结果:你今天的用眼健康是A级、B级还是C级,主要扣分项是什么,明天应该重点改哪里。
4.1 评分维度设计:权重凭什么这么定
我参考了比较通行的眼保健建议(比如20-20-20法则:每用眼20分钟,看20英尺外20秒),结合这个项目的实际场景,把评分拆成三个维度:
| 维度 | 满分 | 核心逻辑 |
|---|---|---|
| 用眼时长 | 40分 | 单日累计时长和连续用眼时长越合理,分越高 |
| 休息质量 | 30分 | 休息次数、是否遵守提醒、单次休息时长综合打分 |
| 环境健康 | 30分 | 暗光用眼、近距离用眼事件越少,分越高 |
用眼时长维度满分40分,是因为它是护眼报告里最核心的基础指标。具体打分逻辑我用一段示例代码来说明:
double calcDurationScore(int totalMinutes, int longestContinuous) { double score = 0; // 单日累计时长:3小时以内给满分,超过6小时逐步清零 if (totalMinutes <= 180) { score += 25; } else if (totalMinutes <= 360) { score += 25 - (totalMinutes - 180) * 25 / 180; } else { score += 10; } // 连续用眼最长时长:60分钟以内给满分,超过90分钟减半 if (longestContinuous <= 60) { score += 15; } else if (longestContinuous <= 90) { score += 15 - (longestContinuous - 60) * 15 / 30; } else { score += 5; } return score; }休息质量和环境健康的逻辑类似,也是做一个分档加权。这个模型不需要做到医学级严谨,它的核心目的是让用户有一个直观的、连续可比较的分数。分数每周末能比周初高一点,用户就会觉得App有用。
4.2 从评分到等级和建议
算完总分之后,我把结果映射成风险等级:
- 总分 >= 85:状态良好,保持现有节奏。
- 总分 70-84:轻度疲劳,建议增加休息频次。
- 总分 55-69:中度疲劳,需要主动调整用眼习惯。
- 总分 < 55:高度疲劳,建议减少非必要屏幕时长。
“行动建议”这一块,则根据三个维度扣分的具体位置,从预设的规则池里取对应文案。比如休息质量维度扣分多,返回“你今天连续用眼偏长,明天试着在每45分钟后强制休息5分钟”;环境健康维度扣分多,返回“今天有多次暗光环境用眼记录,建议调整桌面灯光亮度”。
这套规则引擎写起来很简单,就是一个维度到文案的映射表,但用户体验差别很大:一份只说“你得分78”的报告,和一份说“昨天你在暗光下刷了2小时手机,今晚记得开灯”的报告,说服力完全不同。
4.3 报告中间件JSON的设计
为了让UI渲染和PDF生成共用一份数据,我先用一段JSON承载报告的全部内容:
{ "period": "week", "startDate": "2026-03-02", "endDate": "2026-03-08", "totalScore": 78, "level": "B", "scoreBreakdown": { "duration": 30, "rest": 24, "environment": 24 }, "dailyPoints": [ { "date": "2026-03-02", "score": 72, "minutes": 305 }, { "date": "2026-03-03", "score": 80, "minutes": 270 } ], "suggestions": [ "本周连续用眼超过60分钟的次数减少了一次", "暗光条件下使用手机次数偏多,建议开启自动亮度" ] }这份JSON既是页面渲染的数据源,也是PDF导出的数据源,彻底避免了“页面显示一套、PDF导出另一套”的问题。所有上游同学只要维护这个协议,下游怎么消费都可以。
5. 报告生成实战:图表可视化、PDF导出与分享落地
前面的评分模型产出了结构化数据,接下来就是把这份数据变成用户能看、能存、能分享的正式报告。这个环节有两个最容易翻车的点,一个是图表中文字体,一个是PDF文件中文字体。两个坑我都踩过,下面详细说。
5.1 用fl_chart画用眼趋势图
趋势图我选用的是fl_chart这个库,它支持折线图、柱状图、饼图等常见类型,文档比较全,社区活跃度也高。在OpenHarmony的Flutter分支上,fl_chart没有暴露出兼容性问题,因为它本质上就是Dart层绘制,不依赖原生组件。
画“近7天用眼时长柱状图”的核心代码大概是这个样子:
BarChart( BarChartData( barGroups: dailyList.map((d) { return BarChartGroupData( x: d.date.day, barRods: [ BarChartRodData( toY: d.totalScreenMinutes.toDouble(), color: d.totalScreenMinutes > 240 ? Color(0xFFE53935) : Color(0xFF43A047), ) ], ); }).toList(), titlesData: FlTitlesData( bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, getTitlesWidget: (value, meta) { return Text('${value}日', style: TextStyle(fontSize: 10)); }, ), ), ), ), )这里有一个体验上的细节:超过健康阈值的柱子用红色显示,正常范围用绿色显示。用户扫一眼图片就知道哪几天“破戒”了,比看纯数字直观得多。我建议所有做数据可视化的项目都把“异常高亮”作为一个标配,调色尽量克制,红绿两色足够。
5.2 生成PDF时,中文字体是绕不过去的坑
PDF生成我用的dart生态里的pdf库(pub.dev上的package:pdf)。这个库本身支持自定义字体,但默认字体是Helvetica,对中文支持为零。第一次导出时我没有做任何字体处理,导出来的PDF里所有中文全部是豆腐块。排查了很久才发现是要手动加载一个支持中文的TTF字体文件。
正确做法是把一个中文字体文件放到assets目录,比如思源黑体或Noto Sans SC,然后在生成PDF时绑定到TextStyle:
final fontData = await rootBundle.load('assets/fonts/NotoSansSC.ttf'); final ttf = pw.Font.ttf(fontData.buffer.asByteData()); final textStyle = pw.TextStyle(font: ttf, fontSize: 14); pdf.addPage( pw.Page( build: (pw.Context context) { return pw.Padding( padding: pw.EdgeInsets.all(32), child: pw.Text('视力保护健康报告', style: pw.TextStyle(font: ttf, fontSize: 24)), ); }, ), );我踩坑的地方在于:一是字体文件体积大,直接打包进assets会让安装包体积明显增加,需要做裁剪或者用子集化字体;二是在真机上加载字体耗时,如果用户点“导出PDF”后界面无响应,要考虑先展示loading状态,再延迟渲染PDF。
5.3 报告生成和分享的完整链路
整个报告生成的流程,我把它串成下面这个管线:
- 用户点击“生成周报”按钮,进入loading页。
- 从本地数据库读取最近7天的DailySummary。
- 调用评分模型,生成报告JSON。
- 用JSON渲染预览页(包含图表和文字结论)。
- 用户点击“导出PDF”,再次加载同一份JSON,用pdf库生成文件。
- 将生成的PDF写到临时目录,调用系统分享能力发送出去。
这套双消费模式(页面消费+文件消费)的好处我在前面说过,这里再强调一句:不要为PDF单独写一套数据逻辑,90%的坑都来自于两份数据不一致。
分享这一步,在OpenHarmony上也是走原生通道。可以用MethodChannel调用原生分享接口,把PDF文件路径传给系统分享面板。实测下来这个流程是通顺的,但原生侧需要处理好临时文件的读写权限。
6. 我在OpenHarmony适配中踩过的三个坑
这一段是整个项目里最让我头皮发麻的部分。Dart侧代码在模拟器上跑得顺顺当当,一到OpenHarmony真机就开始出幺蛾子。我抽三个最典型的坑详细讲讲排查过程,希望能帮你省几天的调试时间。
6.1 切到后台再回来,EventChannel静默丢事件
症状:App从后台切回前台,传感器数据流彻底不更新,但也没有报错。
排查链路:我先在Dart侧打日志,发现Stream的listen回调确实收不到数据;然后怀疑是生命周期问题,检查了AppLifecycleState的resume事件,发现Flutter侧生命周期恢复是正常的;继续深入,把排查重点转向原生侧,发现OpenHarmony的传感器服务在应用退到后台后,默认会暂停向应用投递事件,切回前台时,上层业务没有重新触发订阅,事件流就断了。
解决:在原生侧监听Ability的onForeground回调,恢复前台时调用传感器订阅的resume接口;同时在Dart侧用EventChannel重新接收一次初始化指令,确保链路恢复。
这个坑值得所有做传感器类Flutter开发的人警惕:EventChannel的连接并不是永久有效的,宿主系统随时可能因为生命周期变化把它掐断。最保险的做法是在App切换前后台时显式做一次“重建通道”,不要相信通道会自动恢复。
6.2 PlatformView显示区域黑屏或错位
因为视力报告需要内置一个系统能力相关的原生控件,我尝试过用PlatformView嵌入原生组件。在Android上这套路径很成熟,但在OpenHarmony的Flutter分支上,PlatformView的加载时序和Flutter引擎的合成器偶尔会冲突,表现是显示区域黑屏或者组件位置偏移。
排查到最后,发现是壳工程里初始化Flutter引擎和注册PlatformView的先后顺序问题。解决办法是把PlatformView的注册时机尽量提前,并且在页面第一次push前预创建好实例,避免在页面转场过程中动态创建。这属于比较“糙”的规避方式,但实测有效。如果你也遇到PlatformView相关的问题,可以先尝试避开动态创建,用预创建+复用实例的方案。
6.3 光传感器采样导致CPU跑高
这个在前面已经提到了,我再补充一点排查细节。当时CPU占用异常,我先以为是渲染性能问题,用性能分析工具看了一遍,发现渲染线程占用并不高,反而是平台通道相关线程在忙。进一步定位发现是原生侧高频推送光传感器数据,每一条都触发一次Dart侧JSON解析和逻辑处理。
改动方案是原生侧做节流,数据变化幅度小于设定阈值就不推送;同时Dart侧接收端用缓冲+合并的方式处理,多帧数据合并成一次统计。改完之后CPU占用率大幅下降,而且报告数据完全没有失真,因为这个场景本来就不需要这么高的时间分辨率。
下面把这三个问题整理成一个对照表,方便你直接检索:
| 症状 | 根因方向 | 解决策略 |
|---|---|---|
| 切后台后数据流中断 | 原生传感器服务在后台暂停投递 | onForeground时重建EventChannel |
| PlatformView黑屏错位 | 引擎与原生组件加载时序冲突 | 预创建组件实例,避免动态创建 |
| CPU占用过高 | 传感器采样频率过高,无差别推送 | 原生侧阈值节流+Dart侧合并处理 |
7. 健康报告还能往哪个方向深化
当前这版报告已经能跑通“采集-评分-展示-导出”的完整闭环,但从产品视角看,还有很多可以深挖的空间。我自己在项目迭代中主要考虑这几个方向,写出来供你参考。
7.1 从“周报”走向“趋势洞察”
目前的报告是基于固定时间窗口的静态快照,更深一层的做法是引入趋势对比:本周得分相比上周是涨是跌?连续用眼最长时长是变好还是变差?更近一步,如果数据积累够了,可以做预测——按当前趋势,下周哪个维度最可能继续扣分。这种“数据预测”不需要多复杂的模型,简单的移动平均就能给用户带来很强的“懂我”感觉。
7.2 本地计算与隐私保护
我坚持把健康数据全部存放在本地,不做云端上报,一是为了隐私安全,二是OpenHarmony设备端计算能力完全够用,没必要把数据送出去增加合规风险。如果你要做多端同步,也建议优先考虑端侧加密同步链路,而不是把明文健康数据直接上传。
7.3 与系统健康能力的联动
OpenHarmony本身也在强化系统级健康能力,未来护眼提醒可以被整合进系统级的“健康卡片”或者负一屏,App生成的报告卡片直接推送到系统桌面。这在技术上是可行的,核心思路是把报告JSON和关键结论做成标准格式,由系统侧统一渲染。作为开发者,现在就可以把报告数据协议规范化,为后续联动铺路。
8. 写在最后:这套方案值不值得正式落地
如果只是做demo,Flutter + OpenHarmony的组合足够惊艳;如果要做正式产品,我的建议是“混合架构”:系统级能力(后台服务、传感器、通知、分享)用ArkTS原生实现,业务界面和报告生成用Flutter实现。不要把所有的宝都押在Flutter平台通道上,那会让你的排错成本翻倍。
从我个人的实测感受看,整个项目里最稳定的反而是报告生成和PDF导出这一段,因为它在Dart侧闭门造车,不和系统能力纠缠。最费精力的永远是原生和Flutter的桥接层。如果你打算在OpenHarmony上复刻类似的项目,我的建议是先把数据采集链路彻底跑稳,再去做评分和PDF,否则你会分不清到底是数据错了还是页面错了。
视力保护这个赛道,拼的从来不是提醒打扰的次数,而是用户对自己健康变化的理解程度。一份设计良好的健康报告,就是这个理解的载体。希望这篇实战记录能帮你少走几步弯路,把更多精力花在真正有意义的功能上。