☰
Flutter三端应用实战:专注计时器与OpenHarmony适配全记录
2026/10/15 4:27:09 网站建设 项目流程

不知道你有没有过这种时刻——本来只想回一条消息,结果顺着通知栏点进了一个短视频,再回过神时已经过去了四十分钟;手头一篇该写的方案文档,开了三个小时才写了两行。我做“专注时光盒”这个 Flutter 三端应用的初衷,就是这种被碎片洪流裹挟的失控感。它是一个横跨 Android、iOS、OpenHarmony 的专注计时器,核心思路不是简单倒计时,而是把“心流”当成一种需要被守护的状态,用数字容器的形式把一段时间完整地装起来,让外部消息进不来、内部精力出不去。这篇文章会把这套应用从立项到三端落地的完整过程拆开讲,包括为什么选 Flutter、专注状态机怎么设计、OpenHarmony 真机适配有哪些坑,以及数据统计层的落地细节。无论你是想用 Flutter 做多端产品,还是刚接触 OpenHarmony 开发,都能在里面找到一份可以直接参考的工程笔记。

1. 立项思路:数字容器与心流守护的产品定位

1.1 先解决“管不住手机”,而不是“不爱学习”

大多数人在做效率工具时,默认假设是“用户不够自律”,所以功能设计就往惩罚和监督的方向走:完不成任务就闹铃大作、记录失败次数、搞排行榜,甚至用绑定金额的方式倒逼坚持。我一开始也差点这么做,冷静想了一下,发现这个假设是错的。

你回想一下自己最投入工作的时候是什么状态:通常不是靠意志力“硬扛”出来的,而是进入了一个高度专注的心流区,时间感消失,外界干扰被自动过滤。真正打断心流的,往往也不是自己意志力薄弱,而是无处不在的小红点、锁屏消息、应用角标。你刚进入状态十分钟,一条外卖送达通知就让你拿起手机,之后你甚至不记得自己是怎么滑到视频流里的。

所以“专注时光盒”的产品定位从一开始就定成“容器”,而不是“监工”。它提供一个密封的时段:这段时间里手机变安静,界面只显示一个正在收窄的光圈,后台记录你按下开始键之后的分分秒秒。它不做惩罚,只做守护和复盘。这个定位决定了后续所有的界面和交互风格:不要红点、不要催促、不要复杂输入,甚至连“设置目标时长”都设计成一步滑动完成。

1.2 功能边界怎么画:最小可用版和后续迭代

第一版发布时,我给自己定了三条硬边界,超出的一律不做:

  • 专注会话是最基本单位:选择当前要专注的事情(可留空),设定一个时长,开始计时,结束时记录完成状态。
  • 打断必须被“看见”:当用户中途退出专注界面,会话不会被静默丢弃,而是被记录为一次中断,并弹出一个小弹窗问一句“刚才是什么吸引了你的注意”。
  • 所有历史数据沉淀为统计:按天、周、月维度展示有效专注时长、完成率和中断次数。

有人问社交功能呢?好友对战、专注排行榜、学习小组都没做。原因很简单:社交属性会引入通知、排名焦虑和攀比,这本身就是碎片化注意力的催化剂,和“守护心流”的产品目标直接冲突。我当时判断,一个工具型应用最该做的是安静,社交属性等用户量大起来再聊也不迟。

1.3 为什么一上来就做三端,而不是只做一个

我手头日常使用的设备比较杂:主力机是 Android,平时写稿和看资料用的平板是 iOS,后来又加了一台跑 OpenHarmony 的测试设备。如果只做 Android 版,iOS 上就少了一块使用场景;如果每个端从头开发,一个人根本维护不过来。

更重要的原因是,我当时想验证一个技术判断:Flutter 既然能一套代码跑 Android 和 iOS,那 OpenHarmony 这边到底能不能再吃一份红利?OpenHarmony 本身有一套自研 ArkUI 生态,但对 Flutter 的社区适配已经在推进中,只是细节远没有 Android/iOS 成熟。我想以一个真实产品、真实使用场景去测试这套路线的可行度,而不是写一个 demo 就跑。三端统一体验,核心代码逻辑一套复用,只在平台通道层做差异适配,这是项目当时最核心的技术愿景。

2. 技术路线:为什么是 Flutter,OpenHarmony 这条线的选型考量

2.1 跨端框架对比:原生、RN、Flutter 在 OpenHarmony 上的可行性

我在动手之前做了一圈方案对比,核心关注点有三个:UI 一致性、跨端复用率、以及 OpenHarmony 的支持成熟度。这里直接放一张我当时用的对比表:

方案UI 一致性代码复用率OpenHarmony 支持度维护成本
两端原生 + OHOS 原生较差,各端有各端风格0%,业务逻辑要写三遍最好,但工作量最大极高
React Native 类方案中等,依赖原生组件桥接70% 左右,但桥接层复杂支持度一般,社区适配不统一偏高
Flutter 渲染层统一高,像素级一致90% 以上,纯 Dart 逻辑全复用社区有 OHOS 引擎适配分支较低

最终选择 Flutter,核心理由是它的自绘渲染架构。Flutter 不依赖系统原生组件,UI 是自己用 Skia(现在还有一部分沉浸式渲染走 Impeller 路线)画出来的,因此三端渲染出来的效果基本一致,不会出现 Android 上圆角正常、OpenHarmony 上控件变形的尴尬。业务逻辑更是几乎全用 Dart 编写,跨端复用在代码层面就已经实现了。

2.2 OpenHarmony 对 Flutter 适配现状怎么看

说实话,OpenHarmony 对 Flutter 的支持并不像 Android 和 iOS 那样是第一梯队。社区维护的 Flutter OHOS 引擎,在版本号上通常滞后 Flutter 官方版本一段时间,某些插件也会出现编译不过的问题。我当时判断的关键点是:这个项目真正的业务复杂度在 Dart 侧,平台通道(MethodChannel)只做非常薄的系统能力调用。哪怕底层引擎换一个分支,只要 Dart 侧接口稳定,迁移成本就可控。

所以我在工程里做了一个严格的隔离层:所有与系统交互的功能都定义成抽象接口,比如“设置屏幕常亮”“进入勿扰模式”“查询应用使用时长”,Dart 业务层只依赖抽象接口,不在业务代码里直接调用任何平台 API。这样哪怕是 OpenHarmony 适配分支暂时不支持某个插件,我也能自己用平台通道手写实现,而不会把整个业务拖下水。

2.3 项目分层与依赖选择

工程结构上我采用了标准的四层拆分:

  • presentation 层:负责 Widget、路由、动画,只处理 UI 展示和用户输入。
  • application 层:负责用例编排,比如“开始一次专注”“结束一次专注”“记录一次中断”。
  • domain 层:放纯粹的业务实体和规则,比如 Session、Task、专注状态机。
  • data 层:负责数据持久化和平台通道封装,向上层提供 Repository 接口。

状态管理选了 Riverpod,因为它对异步和依赖注入支持比较舒服,专注计时过程中要频繁读取当前剩余秒数,用 Provider 监听流式数据结构非常自然。本地持久化最终选了 drift 这个 SQLite 的 Dart 封装,后面数据层部分会细说。依赖注入没有额外引框架,Riverpod 本身就可以当依赖容器用,少一套依赖就少一份版本冲突风险。

这套分层带来的直接好处是:后来我在接入 OpenHarmony 端时,domain 层和 application 层一行代码都没改,只重写了 data 层里平台通道的具体实现。

3. 核心模块落地:专注状态机与心流提示的实现

3.1 专注状态机:为什么我不用一个 bool

刚写原型的时候,我也觉得专注计时很简单:一个布尔值标记“专注中”,一个 Timer 每秒减一秒。但真跑起来,很快遇到了状态混乱:用户从后台切回前台时到底算继续还是算中断?按 Home 键算退出吗?来电打断怎么处理?专注结束但用户没看手机,算完成还是算超时?

这些问题逼着我画了一张状态表,最后抽象出七个状态:

状态含义可迁移到
idle空闲,尚未开始ready
ready已选任务和时长,等待开始running, idle
running专注进行中paused, interrupted, finished, aborted
paused主动暂停,计时冻结running, aborted
interrupted被系统或外部事件打断running, finished, aborted
finished正常走完专注时长idle, break
aborted中途放弃,不计入完成idle

状态迁移是硬编码的规则,不允许非法跳转。比如从 idle 直接到 running 是禁止的,必须先经过 ready。这套设计让业务逻辑好写很多:每次状态变化只处理“当前状态 + 触发事件”这一个组合,不会出现莫名其妙的 bug。

enum FocusEvent { selectTask, start, pause, resume, interrupt, finish, abort, reset } class FocusStateMachine { FocusStatus _status = FocusStatus.idle; FocusStatus transition(FocusEvent event) { final allowed = _allowedEvents[_status] ?? []; if (!allowed.contains(event)) { throw StateError('当前状态 $_status 不允许触发 $event'); } _status = _nextStatus[_status]![event]!; return _status; } }

3.2 后台计时的方案对比

这是整个项目里最需要认真对比的环节。做专注计时必然要回答一个问题:App 退到后台之后,计时怎么继续?

方案 A 是让 Dart 侧的 Timer 一直跑:App 在后台被系统挂起后,计时就停了,切回来发现走了三十分钟的钟只走了三分钟,直接不可用。

方案 B 是记录时间戳秒数,不依赖 Timer 持续触发:每次更新 UI 时读取当前时间,和会话的开始时间做差值,剩余秒数 = 目标秒数 - (当前时间戳 - 开始时间戳)。App 被挂起多久都不影响计算,因为时间戳算的是真实时间。

方案 C 是把任务交给系统调度,比如 Android 的 WorkManager、iOS 的 BGTask、OpenHarmony 的延迟任务能力。这套方案能保证后台精确执行,但配置复杂,而且三端调度 API 完全不同。

我最终选择的是方案 B 为主、方案 C 为辅。纯 Dart 层记录 startTimestamp 和 durationSeconds,UI 每秒刷新一次。需要系统级提醒时,比如专注结束时推送本地通知,才通过平台通道调用各端的本地通知能力。这样既保证了核心计时逻辑三端完全一致,又把系统差异隔离在了最边缘。

class FocusTimer { final int durationSeconds; final DateTime startedAt; final int pausedSeconds; int get remainingSeconds { final elapsed = DateTime.now().difference(startedAt).inSeconds - pausedSeconds; return (durationSeconds - elapsed).clamp(0, durationSeconds); } }

3.3 仪式感与“容器”感的交互设计

专注计时器这种工具,功能上各家都差不多,真正拉开体验差距的是仪式感,也就是用户愿不愿意每天打开它。

“专注时光盒”的启动流程被设计成三步:选择要专注的事(可跳过)、滑动设定时长、点击中间的光球开始。点击开始后,光球会扩展成一层半透明的磨砂玻璃罩,罩住整个屏幕的下半部分,暗示“现在开始,这段时间被封装起来了”。计时过程中,屏幕背景是一个缓慢呼吸的光晕,每完成一个完整的呼吸周期正好是一分钟,用户余光就能感知时间流逝,不需要一直盯着数字。

如果用户中途退出专注界面,系统不会立刻判定会话失败,而是弹出一屏纯黑背景加上一行小字:“你离开了这段时间。方便说说是什么吸引了你的注意吗?”下面三个可选标签:消息通知、刷视频、突然有事。用户点一下,这个标签就被记录到中断明细里。这个设计不是为了监控用户,而是让每次打断都在事后被“看见”——很多用户反馈,这个环节能明显降低无意识滑手机的次数,因为人一旦被迫承认“我是被一个无关通知拉走的”,下次就会更容易放下手机。

4. OpenHarmony 端真机适配:平台通道与系统能力接入

4.1 从创建工程到跑起 Flutter

OpenHarmony 端的 Flutter 适配分支,整体用法和标准 Flutter 很像,但有几个工程层面的差异得提前适应。

首先是环境准备。除了常规的 Flutter SDK,还要安装 OpenHarmony 的开发 IDE 和配套 SDK。Flutter 的 OHOS 版本通常可以通过命令行拉取特定分支,创建项目时选择支持 OpenHarmony 的模板。这个步骤坑比较多,我建议直接在一台干净的环境上操作,不要把现有的 Android Flutter 环境变量混在一起,否则容易出现 sdk 路径互相覆盖。

其次是产物区别:Android 出 APK,iOS 出 IPA,OpenHarmony 出 HAP(HarmonyOS Ability Package)。构建命令大概是:

flutter build hap --release

生成物在 build/hap/outputs/ 目录下。HAP 打包的本质是把 Flutter 引擎、Dart 业务代码和原生壳层打成一个安装包,所以体积一般比同等 Android 包稍大一点,我第一次打出来的时候吓一跳,后来做了一轮资源裁剪才压下来。

4.2 平台通道调用系统能力

这个项目真正需要调用的系统能力其实不多,但每一条都关键:设置屏幕常亮、进入勿扰模式、查询本周屏幕时间(用于统计对比)、发本地通知。我把这些统一定义成 Dart 侧抽象接口:

abstract class SystemCapability { Future<void> enableScreenBrightness({required bool enabled}); Future<void> enterDisturbMode(); Future<void> exitDisturbMode(); Future<int> getScreenMinutesToday(); Future<void> scheduleLocalNotification({ required String title, required Duration after, }); }

Android 端实现走标准的 MethodChannel,调用 PowerManager、NotificationManager、AccessibilityService 等系统服务。OpenHarmony 端的实现大同小异,只是系统 API 名称和权限声明位置不同:Android 的权限写在 AndroidManifest.xml,iOS 写在 Info.plist,OpenHarmony 写在 module.json5 里,并且用户授权弹窗的触发时机要严格放在调用之前,否则静默失败。这里有个细节:OpenHarmony 的勿扰能力模块在部分设备上需要额外申请系统级权限,而普通应用不一定能拿到。我的兜底方案是调用通用媒体音量静音,虽然不如系统勿扰彻底,但至少能保证应用内无声音干扰。

4.3 三端差异点对比与兼容处理

真机跑起来后,我列了一张三端差异对照表,贴在项目文档首页,随时查:

能力AndroidiOSOpenHarmony
后台计时可存活,但受省电策略影响会被挂起,时间戳方案才可靠省电策略更激进,依赖时间戳
本地通知需要渠道配置需要申请权限权限模型和 Android 类似
屏幕常亮WakeLockUIApplication.idleTimerDisabled系统能力 + 权限声明
返回键行为默认退出 Activity无实体返回键,手势退出返回键可拦截,需谨慎处理
应用角标可设置需系统授权部分设备不支持

发现没有,真正决定架构的永远是后台行为和权限模型,UI 反而是最省心的部分。时间戳方案救了我好几次,尤其是 OpenHarmony 真机的省电策略,会默认把不活跃应用冻结,如果计时依赖 Timer,基本就是废的。这点在后面踩坑实录里还会展开。

5. 数据层与统计:把碎片时间变成可视化回报

5.1 本地存储选型

第一版我用的是 shared_preferences,数据量小、上手快,但很快发现两个问题:一是无法做跨表关联,比如查询“某个任务累计专注时长”需要全量遍历;二是没有查询语言,统计报表的活全得手写循环。于是第二版换成了 drift。

drift 是 SQLite 上的类型安全封装,支持原生 SQL、流式查询、迁移脚本。建表 SQL 大概长这样:

CREATE TABLE focus_sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER, started_at INTEGER NOT NULL, duration_seconds INTEGER NOT NULL, completed INTEGER NOT NULL DEFAULT 0, interrupted_reason TEXT, created_at INTEGER NOT NULL );

专注会话、任务、中断记录三张表,足够支撑所有统计。drift 的流式查询让 UI 联动变得很舒服:比如首页的“今日已守护时长”卡片,只需要订阅一条监听 SQL,数据表变化时 UI 自动刷新,不用手动 setState。

选 drift 还有一个考虑:它支持事务。专注会话结束时会同时更新任务表和总时长统计,如果用多个非事务写入,中途崩溃会造成数据不一致。而事务能保证“要么全部成功,要么全部回滚”,这在记录用户真实专注历史时是底线要求。

5.2 统计口径设计

统计报表能不能让人信任,关键在于口径一致。我踩过一个小坑:第一版统计完成率时,把“用户打开了专注但中途退出”的会话也算成了完成里,结果周报上“完成率 95%”,但用户实际有效专注时间少得可怜,体验很割裂。

后来定下了严格口径:

  • 有效专注:状态为 finished,并且实际专注时间大于等于目标时长的 80%,才计入有效专注分钟数。
  • 中断次数:所有 interrupted 和 aborted 的会话都算一次中断。
  • 连续心流天数:连续三天以上每天有效专注超过 60 分钟,才累积 1 天“连续心流”记录。

这三个口径让报表变得诚实。用户看到“今天中断 7 次”,比看到“完成率 100%”更有提醒意义。最初有几个用户反馈说这个数字看着扎眼,但坚持用下来的人都认可它的价值——因为它反映的是真实状态,而不是粉饰过的自我感动。

5.3 趋势视图的 UI

统计页我做了三个层次:今日卡片、本周趋势、月度热力。今日卡片显示“已守护 xx 分钟”,数字很大,底下配一条完成进度条。本周趋势用横向柱状图展示七天有效专注时长,点击某一天可以下钻到当天的会话列表。月度热力是最受欢迎的功能,仿照代码提交热力格子的样式,但颜色统一用柔和的蓝色渐变,专注时长越长颜色越深。

数据可视化有一个容易被忽略的原则:不要让用户为了看到趋势而去理解图例。所有颜色、数字、进度条都有直接的文字标签,比如“周三 45 分钟 / 目标 60 分钟”,一眼就能看懂。这比炫酷的雷达图有用得多。

6. 三端发布与工程化差异:签名、权限、包体积

6.1 Android 与 iOS 侧的注意点

Android 发布相对简单,主要关注三件事:权限声明最小化、ABI 拆分、以及多尺寸图标。权限最小化尤其重要,我见过不少应用被应用市场警告“权限与功能不符”,就是因为明明只用定位,却申请了通讯录读取权限。专注时光盒只申请了三项:通知、电池优化白名单、应用使用情况访问(用于统计屏幕时间),每一句权限用途说明都写清楚了。

iOS 侧要提前留意的是本地通知权限申请时机。如果一启动就弹授权框,很多用户会直接点拒绝。我的做法是只在用户第一次真正开启专注会话时才申请,这时候用户已经对产品价值有感知,允许率明显更高。另外 iOS 后台保活的限制很严,时间戳方案在这里是必须而不是备选。

6.2 OpenHarmony 的打包与签名流程

OpenHarmony 的发布流程和 Android 类似但有自己的特点。HAP 打包后需要签名才能安装到真机,签名流程包括生成密钥、配置签名证书、将证书关联到应用。每一步都有对应工具,如果中间对不上,安装时会提示证书校验失败。

我遇到的第一个坑是 debug 包和 release 包签名不一致。因为调试时用的签名和最终发布签名不同,导致某些设备上卸载后无法通过增量包升级。后来我在 CI 流程里固定用同一套签名配置文件,这个问题才彻底消失。建议所有做 OpenHarmony 发布的人从第一天就建两套签名配置:一套 debug 专用、一套发布专用,并保证发布专用的签名配置永远不用于日常调试。

6.3 版本管理与自动构建

三端版本号我统一用从 Dart 侧读取,也就是把 pubspec.yaml 的 version 字段作为唯一版本源。Android 的 versionName 和 iOS 的 CFBundleShortVersionString 都在构建脚本里从这个字段读取并写入各平台文件,这样三条产品线不会出现版本号对不上的情况。

自动构建我一开始跑了一个简单的本地脚本:三个平台分别执行构建命令,把产物输出到指定目录。后来加了持续集成,在每次打 tag 时自动触发三端构建,并附上各平台的构建日志。工程化带来的最大收益不是省事,而是让每次发版都可以复现。以前手动构建的时候,偶尔会出现“我这台机器能编过,换台机器就报错”的诡异问题,CI 跑起来之后,环境差异导致的构建问题基本绝迹了。

7. 踩坑实录:一次跨端时间漂移的完整排查链路

7.1 现象:Android 正常但 OpenHarmony 上 Timer 不准

项目进行到真机测试阶段时,我收到一个非常典型的反馈:同样一个 25 分钟专注会话,在 Android 上完美走完,在 OpenHarmony 设备上,用户盯着屏幕看是正常的,但只要锁屏或者切到后台,再回来发现剩余时间不对——有次整整多走了 7 分钟,有次又少走了 4 分钟。

第一反应是代码 bug。因为当时时间戳方案还没有完全替换掉 Timer,部分 UI 的刷新逻辑还依赖 Dart 侧 Timer.periodic。我立刻检查了 Timer 的取消和重建逻辑,没发现问题。这就有点奇怪了——如果 Timer 代码有误,Android 端应该也会异常,可 Android 端一直是好的,说明只有 OpenHarmony 环境触发了这个路径。

7.2 排查过程:一层层剥开省电策略

我先做了个对照实验:同样的真机,关闭系统省电模式、关闭所有电池优化白名单限制,Timer 完全正常。打开省电模式后,复现率几乎百分之百。这个对照基本锁定了方向:OpenHarmony 的低功耗管理机制比 Android 激进得多。

具体原理是:HarmonyOS 的功耗组件会识别应用在后台或锁屏状态下的行为,如果发现某个应用持续高频唤醒或保持 CPU 活动,会在一段时间后强制冻结该应用的调度资源。Dart 的 Timer 虽然还在逻辑上“活着”,但底层触发被系统拉长或延迟,导致实际回调时间和预期秒数严重偏离。这解释了为什么“多走了 7 分钟”和“少走了 4 分钟”同时存在——不是纯粹的快慢问题,而是 Timer 回调时间戳不准确。

我还怀疑过另一个方向:锁屏之后屏幕常亮能力导致电量状态变化,进而触发系统的激进省电策略。但这个假设在关掉屏幕常亮后并未完全消失,反而凸显了核心原因还是冻结调度。

7.3 根因到位后,修复方案其实很简单

问题定位之后,修复反而非常简单:把所有和“时间计算”相关的逻辑彻底改成时间戳差值方案,Dart 侧的 Timer 只负责 UI 层每秒刷新一次,不再承担任何时间依据。同时,在平台通道里加了一个“专注期间请求系统勿打扰”的逻辑,在开始专注时通过 OpenHarmony 能力申请电池优化白名单,这会显著降低被冻结的概率。

另外还在 UI 层加了一个保护:每次从后台切回前台时,重新计算剩余时间并校准显示。这样即使系统真的冻结了 UI 刷新,用户回来的那一刻也看到的是正确剩余时长,而不是一个停在原地的假数字。

@override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { _timerController.refreshFromTimestamp(); } }

这个修复上线后再跑了十几个 25 分钟会话,OpenHarmony 端的剩余时间和真实时间完全对上,问题闭环。

7.4 这次排查给我留下的工程原则

首先,跨端项目永远不要假设某一端的行为和另一端等同。Android 上跑得好,不代表 OpenHarmony 上也会跑得好。每个平台的资源调度策略都不同,越是涉及计时、定位、后台任务这类敏感能力,越要提前做平台差异分析。

其次,平台通道的实现一定要先写协议文档再动手写代码。我后来把三端系统能力的所有调用路径、权限要求、预期行为全部整理了文档,接新设备时照着文档检查,比自己临时猜省太多时间。哪怕是一个小能力,只要涉及系统边界,文档都应该先于代码存在。

最后,像 Timer 这种看似简单的 API,背后暴露的是整个系统的调度假设。做跨端应用时,时间必须以墙上时钟(wall clock)为唯一基准,任何本地倒计时变量都只是 UI 表现层,不能成为业务依据。

说实话,做完“专注时光盒”再去回看这半年的开发过程,最有价值的反而不是技术选型或代码结构,而是对一个问题的持续回答:你用什么方式,让用户主动选择放下手机?技术方案再花哨,如果用户只在安装后打开一次然后卸载,那也是零。这个项目让我体会到,数字容器要做的不是“关住用户”,而是给用户提供一个短暂离开信息洪流的借口。一个会呼吸的光球、一句不指责的中断提醒、一份诚实的时间账簿,这些细节的综合作用,才让它从一个效率工具变成了一个能真正陪人进入心流的容器。最后分享一个小技巧:我把启动动画做成了一盏缓缓点亮的呼吸灯,每次点击开始之前都会跟着它做一次深呼吸。这个小改动让很多用户反馈说“光球亮起来的时候已经在进入状态了”。如果你也想做一个类似的项目,不妨从这个细节开始,技术从来不是门槛,让用户愿意为它停留,才是真正的课题。

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

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

立即咨询